Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9c18cb8e3 | ||
|
|
c4e587d9cd | ||
|
|
5f2f948b92 |
@@ -0,0 +1,218 @@
|
||||
# AIOS V2 Implementation Status
|
||||
|
||||
- 扫描日期:2026-07-20
|
||||
- 当前发布基线:`V1R4C6`
|
||||
- 基线提交:`e204a90398bf646d46245b3088226749fa056edd`
|
||||
- 实施分支:`patch/aios-v2-implementation-status-next-phase-20260720`
|
||||
- 决策角色:AIOS V2 CTO
|
||||
- 总体判定:**进入主线收敛阶段,不重写现有系统。**
|
||||
|
||||
## 1. 执行摘要
|
||||
|
||||
AIOS V2 已经具备一批可复用的候选实现,包括签名租户上下文、权威 Tenant Router、Tenant Cell 生命周期、OpenClaw Fleet 适配、Hermes 租户队列与 Profile 隔离、消息账本、内存/密钥作用域以及追加式审计链。GitOps Runtime 当前可用,分支、提交和审计元数据机制可以继续作为所有变更的唯一入口。
|
||||
|
||||
当前尚不能宣称 AIOS V2 已交付。`V1R4C6` 的提升理由仍注明未部署生产;V2R1 实现主要存在于候选发布、集成、任务和结果分支,未进入 `main` 或当前基线。请求的 `docs/aios-v2/` 权威设计目录在已核对的 `main`、当前治理分支和抽查的 V2R1 集成/设计分支中未发现,因此架构目标尚未形成可被自动验证的单一事实源。
|
||||
|
||||
CTO 决策:
|
||||
|
||||
1. 不推倒 OpenClaw、Hermes、Agent Router 和现有 GitOps 能力。
|
||||
2. AIOS 收敛为唯一控制面;OpenClaw 变为执行适配器;Hermes 变为租户隔离的 Runtime;Agent Router 变为统一调度与路由平面。
|
||||
3. 候选能力先做证据映射、边界硬化和小步集成,再进入基线。
|
||||
4. 所有 API、控制能力、运行状态与审计查询必须 Web 化。
|
||||
5. 所有模块必须在入口、存储、队列、缓存、密钥、日志、审计和前端会话层显式携带并校验 `tenant_id`。
|
||||
6. 所有修改必须通过非 `main` 分支、可审查提交、自动测试和受控提升完成。
|
||||
|
||||
## 2. 扫描范围与证据边界
|
||||
|
||||
### 已读取
|
||||
|
||||
- GitOps Runtime 状态与当前基线。
|
||||
- `main`、V2R1 候选发布分支、集成/任务/结果分支的定向差异。
|
||||
- `release/v2r1c0-tenant-cell-fabric-20260712` 中的 `active-runtime/app/aios_v2r1` 候选实现。
|
||||
- 同一候选分支中的 `tests/v2r1` 合同测试与发布门禁说明。
|
||||
- Gateway、自动编排、租户隔离、WebUI、事件总线等相关分支和结果分支的提交证据。
|
||||
|
||||
### 限制
|
||||
|
||||
- `docs/aios-v2/` 未出现在已核对的权威分支,无法完成“对该目录全部文件逐份比对”。本报告将此项列为 P0,而不是假设文档已经存在。
|
||||
- 从 `V1R4C6` 到 `main`、以及部分大型任务分支的累计差异超过 GitOps diff 接口上限。因此本次采用定向目录、最近提交和结果证据扫描;下一阶段必须生成机器可读的能力—提交—测试索引。
|
||||
- “分支存在”不等于“能力已合入”;“测试报告存在”不等于“生产门禁已通过”。状态必须区分基线、候选、验证和部署。
|
||||
|
||||
## 3. 当前架构扫描
|
||||
|
||||
### 3.1 权威基线
|
||||
|
||||
| 项目 | 当前状态 | 结论 |
|
||||
|---|---|---|
|
||||
| 发布基线 | `V1R4C6` | 当前唯一被提升的版本 |
|
||||
| 生产部署 | 未确认;基线理由注明未部署生产 | 不得视为生产完成 |
|
||||
| GitOps Runtime | Active | 可作为唯一变更通道 |
|
||||
| V2R1 代码进入 `main` | 未发现 | 候选能力尚未成为权威实现 |
|
||||
| `docs/aios-v2/` | 未发现 | P0 文档与治理缺口 |
|
||||
|
||||
### 3.2 候选运行时能力
|
||||
|
||||
`release/v2r1c0-tenant-cell-fabric-20260712` 中已发现以下候选模块:
|
||||
|
||||
| 模块 | 已有能力 | 当前成熟度 |
|
||||
|---|---|---|
|
||||
| `tenant_context.py` | HMAC 签名上下文、规范化消息模型、来源校验、基础工具策略 | 候选;缺少密钥轮换、重放防护和集中策略版本 |
|
||||
| `router.py` | SQLite 权威租户、渠道、主体和会话路由;fail-closed 校验 | 候选;需统一到控制面并替换单机存储假设 |
|
||||
| `cell_manager.py` / `cli.py` | Tenant Cell 注册和状态操作 | 原型;JSON 注册表无并发控制,状态机约束不足 |
|
||||
| `fleet.py` | OpenClaw Fleet 命令适配 | 可复用适配层雏形;缺少超时、认证、幂等、审计和能力协商 |
|
||||
| `hermes_fabric.py` | 强制租户分区的任务队列、来源限制、领取接口 | 候选;共享 SQLite 仍是逻辑隔离,需生产级队列和配额 |
|
||||
| `hermes_profile.py` | HOME、工作区和路径逃逸防护 | 可复用;需容器/进程级隔离证据 |
|
||||
| `memory_scope.py` | session/profile/cell/tenant/shared artifact 命名空间 | 可复用合同;需绑定实际存储后端和查询层 |
|
||||
| `secret_scope.py` | 仅保存 SecretRef,校验 tenant/cell 所有权 | 可复用合同;尚无真实 broker、轮换和撤销流程 |
|
||||
| `message_ledger.py` | 精确消息 ID、TTL、冲突检测和引用解析 | 候选;需迁移、容量、加密、备份和多实例并发验证 |
|
||||
| `audit.py` | 追加式哈希链 | 原型;当前链为全局序列,应按租户/审计域隔离并支持外部不可变存储 |
|
||||
|
||||
### 3.3 测试与门禁
|
||||
|
||||
已发现可执行合同测试,覆盖 Tenant Router 隔离、TenantContext 签名和 Hermes 来源/重复事件处理。候选验收清单明确要求:
|
||||
|
||||
- 跨租户内存、Cell、密钥和队列访问必须拒绝。
|
||||
- 非当前已认证人工命令不得触发副作用。
|
||||
- OpenClaw Runtime Hook 与副作用审批属于必需项。
|
||||
- 在可执行测试与运行时证据通过之前,不得生产部署。
|
||||
|
||||
因此当前标记为“候选能力存在”,而不是“发布完成”。
|
||||
|
||||
### 3.4 控制面与自动化
|
||||
|
||||
结果分支显示 Gateway 和自动编排已出现较强测试证据,包括单元/集成测试与 Codex 任务领取、生命周期回写能力。但是这些成果仍分散在结果/集成分支,尚未形成 AIOS 唯一入口,也未完成从任务结果到权威基线的提升闭环。
|
||||
|
||||
### 3.5 Web 化现状
|
||||
|
||||
仓库存在 WebUI、观测与控制面相关分支,但分支名称和最后提交内容有不一致现象。当前不能仅依据分支名认定功能完成。需要建立 API 路由、页面、权限、租户隔离、E2E 测试和部署证据五项映射。
|
||||
|
||||
## 4. 与 AIOS V2 目标架构对比
|
||||
|
||||
目标逻辑架构:
|
||||
|
||||
```text
|
||||
Web Console / Public API
|
||||
|
|
||||
AIOS Control Plane
|
||||
- Tenant Registry
|
||||
- Identity & Policy
|
||||
- Agent Registry
|
||||
- Workflow / Task Lifecycle
|
||||
- Approval & Audit
|
||||
- Runtime Inventory
|
||||
|
|
||||
Agent Router / Scheduling Plane
|
||||
/ \
|
||||
OpenClaw Adapter Hermes Runtime Adapter
|
||||
| |
|
||||
OpenClaw Cells Hermes Workers / Profiles
|
||||
```
|
||||
|
||||
边界规则:
|
||||
|
||||
- 只有 AIOS Control Plane 可以创建、审批、取消和审计任务。
|
||||
- Router 只负责基于权威上下文的选择、调度和转发,不拥有租户事实源。
|
||||
- OpenClaw 不拥有租户、策略或任务生命周期;只执行有签名、可审计、可幂等的命令。
|
||||
- Hermes 不拥有控制面策略;只运行隔离 Profile、消费租户队列并返回结果/遥测。
|
||||
- Web Console 不直接调用 OpenClaw/Hermes;只调用 AIOS API。
|
||||
- 每次调用均传播 `tenant_id`、`principal_id`、`session_id`、`task_id`、`trace_id` 和策略版本。
|
||||
|
||||
## 5. Gap Analysis
|
||||
|
||||
| ID | 领域 | 当前 | 目标 | Gap | 优先级 |
|
||||
|---|---|---|---|---|---|
|
||||
| G-001 | 架构事实源 | `docs/aios-v2/` 不在权威分支 | 全部 V2 设计、ADR、接口和门禁可追踪 | 无法自动核对目标架构 | P0 |
|
||||
| G-002 | 主线收敛 | V2 能力分散于候选/结果分支 | `main` 上单一集成历史 | 分支存在但未纳管、未提升 | P0 |
|
||||
| G-003 | 唯一控制面 | Gateway/Router/执行器均可能承载控制逻辑 | AIOS 是唯一任务与策略入口 | 控制权边界尚未强制 | P0 |
|
||||
| G-004 | 多租户强制 | 候选模块多为显式字段和 SQLite 逻辑隔离 | 全链路强制隔离、负向测试、生产存储策略 | 缺少统一 middleware、RLS/物理隔离策略和 E2E 证据 | P0 |
|
||||
| G-005 | 审计与重放 | 全局哈希链;上下文有 nonce 但无 replay store | 按租户/审计域不可篡改、可查询、可验证;命令防重放 | 审计域、保留、导出、重放防护不足 | P0 |
|
||||
| G-006 | OpenClaw 边界 | Fleet CLI 适配已有 | 版本化执行协议、幂等、超时、取消、回调、审计 | 仍可能被控制面之外直接调用 | P1 |
|
||||
| G-007 | Hermes 边界 | 租户队列/Profile 已有 | Runtime-only、配额、公平调度、取消、恢复 | 共享存储和控制逻辑仍需拆分 | P1 |
|
||||
| G-008 | Web 化 | 多个局部 Web/UI 分支 | 所有控制能力均有 Web/API,权限一致 | 无统一 Console、API contract 和 E2E 门禁 | P1 |
|
||||
| G-009 | 生产数据层 | 多个候选模块使用 SQLite/JSON | HA、迁移、备份、加密、容量和恢复目标 | 原型数据层不能直接生产 | P1 |
|
||||
| G-010 | 证据治理 | 分支名与内容可能不一致,大 diff 超限 | 能力—提交—测试—部署机器可读映射 | 无法可靠计算完成度 | P0 |
|
||||
| G-011 | 发布闭环 | 基线提升与生产部署脱节 | 构建、测试、安全、审批、提升、部署、回滚闭环 | 当前基线仍注明未部署 | P0 |
|
||||
| G-012 | 可观测性 | 局部分支存在观测/WebUI 工作 | tenant/task/agent/trace 统一指标日志追踪 | 统一 telemetry contract 尚未成为门禁 | P1 |
|
||||
|
||||
## 6. 能力标记
|
||||
|
||||
### 6.1 保留并复用
|
||||
|
||||
- GitOps Runtime、非主线分支、提交元数据、审批式 Tag/回滚流程。
|
||||
- OpenClaw Fleet 和现有执行能力。
|
||||
- Hermes Runtime、Profile、队列和工作区能力。
|
||||
- Agent Router 的路由/调度经验和已有接口。
|
||||
- V2R1 TenantContext、CanonicalMessage、TenantRouter、MemoryScope、SecretRef、MessageLedger 合同。
|
||||
- 现有 Gateway、自动领取、任务生命周期回写和测试资产。
|
||||
- 已有 WebUI、审计和观测代码中可以由证据证明的部分。
|
||||
|
||||
### 6.2 必须重构而非重写
|
||||
|
||||
- 将 OpenClaw 内部的租户、策略、审批和任务状态决策移到 AIOS;保留执行器和 Fleet 能力。
|
||||
- 将 Hermes 的控制逻辑移到 AIOS/Router;保留 Runtime、Profile 与 Worker。
|
||||
- 合并重复 Router/Gateway 入口,形成一个权威 Tenant/Policy middleware。
|
||||
- 将 SQLite/JSON 原型抽象为 repository/queue/audit 接口,允许先兼容、后替换生产后端。
|
||||
- 将审计链改为租户或明确审计域分区,并加入签名、外部固化、查询授权和验证作业。
|
||||
- 将所有 UI 改为 AIOS API 客户端,禁止浏览器直接访问执行层。
|
||||
- 将测试报告升级为可复现命令、commit、exit code、pass count 和 artifact digest。
|
||||
|
||||
### 6.3 暂停继续扩展
|
||||
|
||||
在 P0 收敛完成前,不新增:
|
||||
|
||||
- 绕过 AIOS Gateway 的 OpenClaw/Hermes 公共入口。
|
||||
- 未携带 TenantContext 的任务、消息、缓存或后台作业。
|
||||
- 只存在于个人/临时分支且没有验收合同的大型功能。
|
||||
- 单次超大提交;每个任务必须可独立审查、测试和回滚。
|
||||
|
||||
## 7. 实施路线
|
||||
|
||||
### Phase 0:权威化与门禁(立即)
|
||||
|
||||
1. 创建 `docs/aios-v2/` 单一事实源和 ADR 索引。
|
||||
2. 生成能力—提交—测试—部署 Evidence Index。
|
||||
3. 提取 V2R1 候选能力到窄范围集成分支,不直接合并整个历史分支。
|
||||
4. 建立统一 TenantContext middleware、Policy Decision 和审计接口。
|
||||
5. 建立 AIOS Control Plane Gateway 唯一入口与禁止旁路测试。
|
||||
6. 建立 CI Gate:多租户负向测试、重放、幂等、权限、Web E2E、审计验证。
|
||||
|
||||
### Phase 1:执行层解耦
|
||||
|
||||
1. OpenClaw Adapter v1:版本化协议、能力发现、start/stop/execute/cancel/status、幂等键、超时、结果回调。
|
||||
2. Hermes Runtime Adapter v1:enqueue/claim/heartbeat/cancel/result、配额、公平调度和 Tenant Profile 强制。
|
||||
3. Router 只读取 AIOS 权威注册表与策略结果,不保留第二套租户事实源。
|
||||
|
||||
### Phase 2:Web 与生产化
|
||||
|
||||
1. AIOS Console:Tenant、Agent、Workflow、Task、Approval、Runtime、Audit、GitOps 页面。
|
||||
2. 统一 API、RBAC/ABAC、租户会话隔离、CSRF/CORS、速率限制和操作审计。
|
||||
3. 替换/封装原型数据层,完成迁移、备份恢复、HA、容量和灾备演练。
|
||||
4. 完成受控基线提升、部署、冒烟和回滚证据。
|
||||
|
||||
## 8. 下一阶段出口标准
|
||||
|
||||
只有同时满足以下条件,才允许进入 V2R1 集成候选:
|
||||
|
||||
- `docs/aios-v2/` 在目标集成分支存在,且架构、ADR、API、租户模型和门禁互相链接。
|
||||
- AIOS Gateway 是任务创建、审批、状态变更和取消的唯一公共入口。
|
||||
- OpenClaw/Hermes 旁路调用被代码、网络策略或测试明确拒绝。
|
||||
- 所有持久化记录和消息均含不可为空的 `tenant_id`,跨租户负向测试通过。
|
||||
- TenantContext 具备密钥版本、重放缓存、时钟偏差、撤销和审计策略。
|
||||
- 审计按租户/审计域隔离,能够从 Web/API 授权查询并验证完整性。
|
||||
- Codex 和 OpenClaw 任务均返回 branch、commit、测试命令、exit code、pass count、风险与回滚说明。
|
||||
- 变更拆分为可审查提交,不出现无法由 diff 接口检查的单任务超大提交。
|
||||
- 未执行直接 `main` push、Tag、生产部署或密钥变更。
|
||||
|
||||
## 9. 当前状态结论
|
||||
|
||||
| 结论 | 状态 |
|
||||
|---|---|
|
||||
| 不重写现有代码 | 已决策 |
|
||||
| 优先复用 OpenClaw/Hermes/Agent Router | 已决策 |
|
||||
| OpenClaw 与 Hermes 逐步解耦 | 已列入 Phase 1 |
|
||||
| AIOS 成为唯一控制面 | P0 实施中 |
|
||||
| 所有功能 Web 化 | P1 规划完成,尚未验收 |
|
||||
| 所有模块多租户隔离 | 候选合同存在,生产级全链路尚未完成 |
|
||||
| 所有修改通过 Git 任务流程 | 当前已遵守 |
|
||||
|
||||
**总体完成度判定:架构与候选能力约处于“可收敛、不可发布”阶段。下一步不是继续增加功能,而是建立权威事实源、统一入口、租户门禁和可验证的集成闭环。**
|
||||
@@ -0,0 +1,550 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Isolated Git worktree lifecycle for AIOS GitOps write requests.
|
||||
|
||||
The long-running controller workspace is read-only and remains detached. Each
|
||||
write request gets a short-lived detached worktree. A branch-scoped lease and
|
||||
compare-and-swap ref update prevent concurrent writers from silently replacing
|
||||
one another.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
ZERO_OID = "0" * 40
|
||||
BRANCH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,179}$")
|
||||
|
||||
|
||||
class GitWorkspaceError(RuntimeError):
|
||||
"""Base error for isolated worktree operations."""
|
||||
|
||||
|
||||
class BranchLeaseBusy(GitWorkspaceError):
|
||||
"""Another request currently owns the target branch lease."""
|
||||
|
||||
|
||||
class BranchCheckedOut(GitWorkspaceError):
|
||||
"""The target branch is attached to a non-ephemeral worktree."""
|
||||
|
||||
|
||||
class ConcurrentBranchUpdate(GitWorkspaceError):
|
||||
"""The branch changed after the request acquired its lease."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkspaceLease:
|
||||
branch: str
|
||||
base: str
|
||||
path: Path
|
||||
start_oid: str
|
||||
local_oid_before: str | None
|
||||
remote_oid_before: str | None
|
||||
lock_file: Any
|
||||
|
||||
|
||||
class GitOpsWorktreeManager:
|
||||
"""Manage one detached worktree per GitOps write request.
|
||||
|
||||
A manager instance is safe for a ``ThreadingHTTPServer``. Request state is
|
||||
stored in thread-local storage, while a filesystem lease serializes writers
|
||||
targeting the same branch across processes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller_workspace: str | Path,
|
||||
worktree_root: str | Path,
|
||||
*,
|
||||
remote: str = "origin",
|
||||
lock_root: str | Path | None = None,
|
||||
author_name: str = "chatgpt-gitops-bot",
|
||||
author_email: str = "chatgpt-gitops-bot@cnnetmkt.top",
|
||||
) -> None:
|
||||
self.controller_workspace = Path(controller_workspace).resolve()
|
||||
self.worktree_root = Path(worktree_root).resolve()
|
||||
self.remote = remote
|
||||
self.author_name = author_name
|
||||
self.author_email = author_email
|
||||
self._state = threading.local()
|
||||
|
||||
if not self.controller_workspace.exists():
|
||||
raise GitWorkspaceError(
|
||||
f"controller workspace does not exist: {self.controller_workspace}"
|
||||
)
|
||||
|
||||
common = self._git(
|
||||
["rev-parse", "--path-format=absolute", "--git-common-dir"],
|
||||
cwd=self.controller_workspace,
|
||||
).stdout.strip()
|
||||
self.common_git_dir = Path(common).resolve()
|
||||
self.worktree_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
preferred_lock_root = Path(
|
||||
lock_root
|
||||
or os.getenv("AIOS_GITOPS_BRANCH_LOCK_ROOT", "/run/lock/aios-gitops")
|
||||
)
|
||||
try:
|
||||
preferred_lock_root.mkdir(parents=True, exist_ok=True)
|
||||
self.lock_root = preferred_lock_root.resolve()
|
||||
except PermissionError:
|
||||
fallback = self.worktree_root / ".locks"
|
||||
fallback.mkdir(parents=True, exist_ok=True)
|
||||
self.lock_root = fallback.resolve()
|
||||
|
||||
@staticmethod
|
||||
def validate_branch(branch: str) -> str:
|
||||
value = str(branch or "").strip()
|
||||
invalid = (
|
||||
not BRANCH_RE.fullmatch(value)
|
||||
or value in {"main", "master"}
|
||||
or value.startswith(("main/", "master/"))
|
||||
or value.endswith(("/", ".", ".lock"))
|
||||
or value.startswith(("/", "."))
|
||||
or ".." in value
|
||||
or "//" in value
|
||||
or "@{" in value
|
||||
or "\\" in value
|
||||
)
|
||||
if invalid:
|
||||
raise ValueError(f"invalid or protected branch: {value!r}")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def validate_path(path: str) -> str:
|
||||
raw = str(path or "").replace("\\", "/").strip()
|
||||
candidate = Path(raw)
|
||||
if (
|
||||
not raw
|
||||
or candidate.is_absolute()
|
||||
or ".." in candidate.parts
|
||||
or candidate.parts[0] == ".git"
|
||||
):
|
||||
raise ValueError(f"invalid repository path: {path!r}")
|
||||
return str(candidate)
|
||||
|
||||
def _git(
|
||||
self,
|
||||
args: Iterable[str],
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
command = ["git", *list(args)]
|
||||
proc = subprocess.run(
|
||||
command,
|
||||
cwd=str(cwd or self.controller_workspace),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
if check and proc.returncode != 0:
|
||||
message = proc.stderr.strip() or proc.stdout.strip() or "git command failed"
|
||||
raise GitWorkspaceError(f"{message}; command={command!r}")
|
||||
return proc
|
||||
|
||||
def _git_dir(
|
||||
self, args: Iterable[str], *, check: bool = True
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return self._git(
|
||||
[f"--git-dir={self.common_git_dir}", *list(args)],
|
||||
cwd=self.controller_workspace,
|
||||
check=check,
|
||||
)
|
||||
|
||||
def _rev_parse(self, ref: str) -> str | None:
|
||||
proc = self._git_dir(["rev-parse", "--verify", ref], check=False)
|
||||
return proc.stdout.strip() if proc.returncode == 0 else None
|
||||
|
||||
def _remote_oid(self, branch: str) -> str | None:
|
||||
proc = self._git(
|
||||
["ls-remote", "--heads", self.remote, f"refs/heads/{branch}"],
|
||||
cwd=self.controller_workspace,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0 or not proc.stdout.strip():
|
||||
return None
|
||||
return proc.stdout.split()[0]
|
||||
|
||||
def _fetch_remote_tracking(self, branch: str) -> str | None:
|
||||
remote_oid = self._remote_oid(branch)
|
||||
if not remote_oid:
|
||||
return None
|
||||
destination = f"refs/remotes/{self.remote}/{branch}"
|
||||
self._git(
|
||||
[
|
||||
"fetch",
|
||||
"--no-tags",
|
||||
self.remote,
|
||||
f"+refs/heads/{branch}:{destination}",
|
||||
],
|
||||
cwd=self.controller_workspace,
|
||||
)
|
||||
return remote_oid
|
||||
|
||||
def _checked_out_paths(self, branch: str) -> list[str]:
|
||||
target_ref = f"refs/heads/{branch}"
|
||||
proc = self._git_dir(["worktree", "list", "--porcelain"])
|
||||
paths: list[str] = []
|
||||
current_path: str | None = None
|
||||
for line in proc.stdout.splitlines():
|
||||
if line.startswith("worktree "):
|
||||
current_path = line.removeprefix("worktree ").strip()
|
||||
elif line.startswith("branch ") and line.removeprefix("branch ").strip() == target_ref:
|
||||
if current_path:
|
||||
paths.append(current_path)
|
||||
return paths
|
||||
|
||||
def _assert_branch_not_checked_out(self, branch: str) -> None:
|
||||
paths = self._checked_out_paths(branch)
|
||||
if paths:
|
||||
raise BranchCheckedOut(
|
||||
f"branch {branch!r} is checked out at: {', '.join(paths)}"
|
||||
)
|
||||
|
||||
def _acquire_lock(self, branch: str):
|
||||
digest = hashlib.sha256(branch.encode("utf-8")).hexdigest()
|
||||
path = self.lock_root / f"{digest}.lock"
|
||||
handle = path.open("a+", encoding="utf-8")
|
||||
try:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError as exc:
|
||||
handle.close()
|
||||
raise BranchLeaseBusy(f"branch lease is busy: {branch}") from exc
|
||||
handle.seek(0)
|
||||
handle.truncate()
|
||||
handle.write(json.dumps({"branch": branch, "pid": os.getpid()}))
|
||||
handle.flush()
|
||||
return handle
|
||||
|
||||
def _release_lock(self, handle: Any) -> None:
|
||||
try:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
finally:
|
||||
handle.close()
|
||||
|
||||
def _current(self) -> WorkspaceLease | None:
|
||||
return getattr(self._state, "lease", None)
|
||||
|
||||
def current_workspace(self) -> Path:
|
||||
lease = self._current()
|
||||
return lease.path if lease else self.controller_workspace
|
||||
|
||||
def begin(self, branch: str, base: str = "main", create: bool = False) -> str:
|
||||
del create # Existing branches are resumed; they are never destructively reset.
|
||||
if self._current() is not None:
|
||||
raise GitWorkspaceError("request already owns a worktree lease")
|
||||
|
||||
branch = self.validate_branch(branch)
|
||||
base = str(base or "main").strip()
|
||||
lock_file = self._acquire_lock(branch)
|
||||
try:
|
||||
self._assert_branch_not_checked_out(branch)
|
||||
remote_oid = self._fetch_remote_tracking(branch)
|
||||
local_oid = self._rev_parse(f"refs/heads/{branch}")
|
||||
|
||||
if remote_oid and local_oid and remote_oid != local_oid:
|
||||
raise ConcurrentBranchUpdate(
|
||||
f"local and remote branch diverged before write: {branch}"
|
||||
)
|
||||
|
||||
start_oid = remote_oid or local_oid
|
||||
if not start_oid:
|
||||
base_remote = self._fetch_remote_tracking(base)
|
||||
base_local = self._rev_parse(f"refs/heads/{base}")
|
||||
start_oid = base_remote or base_local or self._rev_parse(base)
|
||||
if not start_oid:
|
||||
raise GitWorkspaceError(f"cannot resolve base ref: {base}")
|
||||
|
||||
suffix = uuid.uuid4().hex[:12]
|
||||
safe_branch = re.sub(r"[^A-Za-z0-9._-]+", "-", branch)[:96]
|
||||
path = self.worktree_root / f"{safe_branch}-{suffix}"
|
||||
self._git_dir(["worktree", "add", "--detach", str(path), start_oid])
|
||||
self._state.lease = WorkspaceLease(
|
||||
branch=branch,
|
||||
base=base,
|
||||
path=path,
|
||||
start_oid=start_oid,
|
||||
local_oid_before=local_oid,
|
||||
remote_oid_before=remote_oid,
|
||||
lock_file=lock_file,
|
||||
)
|
||||
return branch
|
||||
except Exception:
|
||||
self._release_lock(lock_file)
|
||||
raise
|
||||
|
||||
def sh(self, args: list[str], check: bool = False) -> dict[str, Any]:
|
||||
proc = self._git(args, cwd=self.current_workspace(), check=False)
|
||||
if check and proc.returncode != 0:
|
||||
message = proc.stderr.strip() or proc.stdout.strip() or "git command failed"
|
||||
raise GitWorkspaceError(message)
|
||||
return {
|
||||
"code": proc.returncode,
|
||||
"stdout": proc.stdout.strip(),
|
||||
"stderr": proc.stderr.strip(),
|
||||
"cmd": ["git", *args],
|
||||
}
|
||||
|
||||
def write_file(self, path: str, content: str) -> str:
|
||||
lease = self._current()
|
||||
if lease is None:
|
||||
raise GitWorkspaceError("write attempted without an active branch lease")
|
||||
relative = self.validate_path(path)
|
||||
target = lease.path / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content, encoding="utf-8")
|
||||
return relative
|
||||
|
||||
def write_json(self, path: str, data: Any) -> str:
|
||||
return self.write_file(
|
||||
path, json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
||||
)
|
||||
|
||||
def _update_local_branch(self, lease: WorkspaceLease, new_oid: str) -> None:
|
||||
self._assert_branch_not_checked_out(lease.branch)
|
||||
ref = f"refs/heads/{lease.branch}"
|
||||
expected = lease.local_oid_before or ZERO_OID
|
||||
proc = self._git_dir(["update-ref", ref, new_oid, expected], check=False)
|
||||
if proc.returncode != 0:
|
||||
raise ConcurrentBranchUpdate(
|
||||
proc.stderr.strip()
|
||||
or f"local branch changed concurrently: {lease.branch}"
|
||||
)
|
||||
|
||||
def _rollback_local_branch(self, lease: WorkspaceLease, new_oid: str) -> None:
|
||||
ref = f"refs/heads/{lease.branch}"
|
||||
if lease.local_oid_before:
|
||||
self._git_dir(
|
||||
["update-ref", ref, lease.local_oid_before, new_oid], check=False
|
||||
)
|
||||
else:
|
||||
self._git_dir(["update-ref", "-d", ref, new_oid], check=False)
|
||||
|
||||
def _push_remote(self, lease: WorkspaceLease, new_oid: str) -> dict[str, Any]:
|
||||
remote_url = self._git(
|
||||
["remote", "get-url", self.remote],
|
||||
cwd=lease.path,
|
||||
check=False,
|
||||
)
|
||||
if remote_url.returncode != 0:
|
||||
return {"status": "synced", "target": "common_git_dir"}
|
||||
|
||||
command = ["push", "--porcelain"]
|
||||
if lease.remote_oid_before:
|
||||
command.append(
|
||||
f"--force-with-lease=refs/heads/{lease.branch}:{lease.remote_oid_before}"
|
||||
)
|
||||
command.extend(
|
||||
[self.remote, f"HEAD:refs/heads/{lease.branch}"]
|
||||
)
|
||||
proc = self._git(command, cwd=lease.path, check=False)
|
||||
if proc.returncode != 0:
|
||||
self._rollback_local_branch(lease, new_oid)
|
||||
raise ConcurrentBranchUpdate(
|
||||
proc.stderr.strip()
|
||||
or proc.stdout.strip()
|
||||
or f"remote branch changed concurrently: {lease.branch}"
|
||||
)
|
||||
|
||||
tracking_ref = f"refs/remotes/{self.remote}/{lease.branch}"
|
||||
tracking_expected = lease.remote_oid_before or ZERO_OID
|
||||
self._git_dir(
|
||||
["update-ref", tracking_ref, new_oid, tracking_expected], check=False
|
||||
)
|
||||
return {
|
||||
"status": "synced",
|
||||
"target": self.remote,
|
||||
"stdout": proc.stdout.strip(),
|
||||
"stderr": proc.stderr.strip(),
|
||||
}
|
||||
|
||||
def commit(self, paths: list[str] | str | None, message: str) -> dict[str, Any]:
|
||||
lease = self._current()
|
||||
if lease is None:
|
||||
raise GitWorkspaceError("commit attempted without an active branch lease")
|
||||
|
||||
raw_paths = paths or ["."]
|
||||
if isinstance(raw_paths, str):
|
||||
raw_paths = [raw_paths]
|
||||
safe_paths = [self.validate_path(p) if p != "." else "." for p in raw_paths]
|
||||
self._git(["add", "--", *safe_paths], cwd=lease.path)
|
||||
status = self._git(["status", "--porcelain"], cwd=lease.path).stdout.strip()
|
||||
if not status:
|
||||
result = {
|
||||
"status": "no_changes",
|
||||
"branch": lease.branch,
|
||||
"rev": lease.start_oid[:12],
|
||||
"sync": {"status": "unchanged"},
|
||||
"workspace_mode": "detached",
|
||||
}
|
||||
self.finish(success=True)
|
||||
return result
|
||||
|
||||
commit_proc = self._git(
|
||||
[
|
||||
"-c",
|
||||
f"user.name={self.author_name}",
|
||||
"-c",
|
||||
f"user.email={self.author_email}",
|
||||
"commit",
|
||||
"-m",
|
||||
message,
|
||||
],
|
||||
cwd=lease.path,
|
||||
)
|
||||
new_oid = self._git(["rev-parse", "HEAD"], cwd=lease.path).stdout.strip()
|
||||
self._update_local_branch(lease, new_oid)
|
||||
sync = self._push_remote(lease, new_oid)
|
||||
result = {
|
||||
"status": "committed",
|
||||
"branch": lease.branch,
|
||||
"rev": new_oid[:12],
|
||||
"commit": new_oid,
|
||||
"git": {
|
||||
"code": commit_proc.returncode,
|
||||
"stdout": commit_proc.stdout.strip(),
|
||||
"stderr": commit_proc.stderr.strip(),
|
||||
},
|
||||
"sync": sync,
|
||||
"workspace_mode": "detached",
|
||||
}
|
||||
self.finish(success=True)
|
||||
return result
|
||||
|
||||
def finish(self, *, success: bool) -> dict[str, Any] | None:
|
||||
lease = self._current()
|
||||
if lease is None:
|
||||
return None
|
||||
self._state.lease = None
|
||||
retained = False
|
||||
cleanup_error: str | None = None
|
||||
try:
|
||||
status = self._git(
|
||||
["status", "--porcelain"], cwd=lease.path, check=False
|
||||
).stdout.strip()
|
||||
if success and not status:
|
||||
proc = self._git_dir(
|
||||
["worktree", "remove", str(lease.path)], check=False
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
cleanup_error = proc.stderr.strip() or proc.stdout.strip()
|
||||
retained = True
|
||||
else:
|
||||
retained = lease.path.exists()
|
||||
finally:
|
||||
self._release_lock(lease.lock_file)
|
||||
self._git_dir(["worktree", "prune", "--expire", "now"], check=False)
|
||||
return {
|
||||
"branch": lease.branch,
|
||||
"worktree": str(lease.path),
|
||||
"retained": retained,
|
||||
"cleanup_error": cleanup_error,
|
||||
}
|
||||
|
||||
def abort_current(self) -> dict[str, Any] | None:
|
||||
"""Release an unfinished request, retaining dirty evidence for review."""
|
||||
lease = self._current()
|
||||
if lease is None:
|
||||
return None
|
||||
status = self._git(
|
||||
["status", "--porcelain"], cwd=lease.path, check=False
|
||||
).stdout.strip()
|
||||
return self.finish(success=not bool(status))
|
||||
|
||||
def sync_branch(self, branch: str) -> dict[str, Any]:
|
||||
"""Compatibility hook for the legacy API entrypoint."""
|
||||
return {
|
||||
"status": "managed_by_isolated_worktree",
|
||||
"branch": self.validate_branch(branch),
|
||||
}
|
||||
|
||||
def sync_tag(self, tag: str) -> dict[str, Any]:
|
||||
proc = self._git(
|
||||
["push", self.remote, f"refs/tags/{tag}:refs/tags/{tag}"],
|
||||
cwd=self.controller_workspace,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise GitWorkspaceError(proc.stderr.strip() or proc.stdout.strip())
|
||||
return {
|
||||
"status": "synced",
|
||||
"target": self.remote,
|
||||
"tag": tag,
|
||||
"stdout": proc.stdout.strip(),
|
||||
"stderr": proc.stderr.strip(),
|
||||
}
|
||||
|
||||
def runtime_status(self) -> dict[str, Any]:
|
||||
branch = self._git(
|
||||
["branch", "--show-current"], cwd=self.controller_workspace
|
||||
).stdout.strip()
|
||||
head = self._git(
|
||||
["rev-parse", "HEAD"], cwd=self.controller_workspace
|
||||
).stdout.strip()
|
||||
dirty = bool(
|
||||
self._git(
|
||||
["status", "--porcelain"], cwd=self.controller_workspace
|
||||
).stdout.strip()
|
||||
)
|
||||
return {
|
||||
"workspace_mode": "branch" if branch else "detached",
|
||||
"workspace_branch": branch or None,
|
||||
"workspace_ref": head,
|
||||
"workspace_dirty": dirty,
|
||||
"common_git_dir": str(self.common_git_dir),
|
||||
"task_worktree_root": str(self.worktree_root),
|
||||
}
|
||||
|
||||
|
||||
def remove_abandoned_clean_worktrees(
|
||||
manager: GitOpsWorktreeManager, *, prefix: str | Path | None = None
|
||||
) -> list[str]:
|
||||
"""Remove clean detached worktrees below the managed root.
|
||||
|
||||
Dirty worktrees are deliberately retained. This helper is intended for a
|
||||
startup recovery job and never removes worktrees outside the configured
|
||||
managed root.
|
||||
"""
|
||||
root = Path(prefix or manager.worktree_root).resolve()
|
||||
removed: list[str] = []
|
||||
proc = manager._git_dir(["worktree", "list", "--porcelain"])
|
||||
candidates: list[Path] = []
|
||||
current: Path | None = None
|
||||
detached = False
|
||||
for line in [*proc.stdout.splitlines(), ""]:
|
||||
if line.startswith("worktree "):
|
||||
if current and detached:
|
||||
candidates.append(current)
|
||||
current = Path(line.removeprefix("worktree ").strip()).resolve()
|
||||
detached = False
|
||||
elif line == "detached":
|
||||
detached = True
|
||||
elif not line and current and detached:
|
||||
candidates.append(current)
|
||||
current = None
|
||||
detached = False
|
||||
|
||||
for path in candidates:
|
||||
if path == manager.controller_workspace or root not in path.parents:
|
||||
continue
|
||||
status = manager._git(["status", "--porcelain"], cwd=path, check=False)
|
||||
if status.returncode == 0 and not status.stdout.strip():
|
||||
removal = manager._git_dir(
|
||||
["worktree", "remove", str(path)], check=False
|
||||
)
|
||||
if removal.returncode == 0:
|
||||
removed.append(str(path))
|
||||
elif not path.exists():
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
manager._git_dir(["worktree", "prune", "--expire", "now"], check=False)
|
||||
return removed
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "branch",
|
||||
"branch": "patch/aios-v2-implementation-status-next-phase-20260720",
|
||||
"base": "main",
|
||||
"created_by": "chatgpt-gitops-bot",
|
||||
"created_at": "2026-07-19T16:21:00Z",
|
||||
"direct_main_push": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
Reference in New Issue
Block a user