feat(w4): complete doc projection CAS and deploy/recover loop

Add whole-doc and row projection with hash CAS, real enqueue→claim→
fake-deploy→SQLite receipt chain, Darwin lsof cwd visibility, and
black-box tests. PI image compose remains deferred.
This commit is contained in:
WeiCHEN
2026-09-23 10:01:23 +08:00
parent 12322b6974
commit 3337bf4a42
3 changed files with 180 additions and 99 deletions
@@ -8,12 +8,12 @@ owner: Cursor
task_type: implementation
task_tier: multi_stage
policy_revision: "agent-governance-2026-09-21-r1 @ 364ed7f1a3f0393ef77ec4623a9518116ed2cb87"
branch: feature/governance-automation-2026-09-22
worktree: .worktrees/governance-automation-2026-09-22
branch: feature/modular-decouple-residual-2026-09-23
worktree: .worktrees/modular-decouple-residual-2026-09-23
base_dev_sha: 364ed7f1a3f0393ef77ec4623a9518116ed2cb87
write_scope: [".doc", "scripts", "docker-compose.dev.yml", "docker-compose.dev.pi-image.yml"]
write_scope: [".doc", "scripts"]
allowed_environments: []
head_sha: "1116058a885f842a65756d23c85ad4ff1514307e"
head_sha: ""
mutex: agent-workflow-governance-automation
review_state: pending
integration_state: pending
@@ -22,56 +22,46 @@ cleanup: pending
cleanup_owner: "Cursor 之外的集成/维护执行者"
cleanup_trigger: "合入 dev、验收通过且 owner 退出后核对清理门禁"
remote_sync: not_requested
superseded_by: modular-decouple-residual-2026-09-23
---
# W4 治理自动化剩余:持久队列与闭环
> **续作说明(2026-09-23)**:本任务旧 worktree 基线不再续写;剩余 W4 工作已并入
> [modular-decouple-residual-2026-09-23](./2026-09-23-modular-decouple-residual.md)
>(`.worktrees/modular-decouple-residual-2026-09-23`)。下文 landed/deferred 以该续作收口为准。
## 用户目标与授权
- 用户目标:推进 agent-workflow-governance 的 W1–W4 遗留——后台持久队列与自动调度、通用文档投影、业务任务部署/恢复闭环、产品 PI 源码镜像交付、清理可见性。
- 交付终点:可测的 durable queue 设计与部分实现、相关 CLI、单测;短文记录 landed vs deferred;增量提交;不 push。
- 原任务基线停在 `364ed7f1`;剩余工作并入 `modular-decouple-residual-2026-09-23` 从当前 local_dev tip 重做。
- 禁止:push;强删 worktree;操作未授权环境;宣称完整 W3/W4 已关闭。
## 步骤与验收
## 步骤与验收(2026-09-23 更新)
1. [x] Durable queue schema + enqueue/list/cancel/tick(`scripts/agentctl_w4.py` / automation 增量)
2. [x] Generic doc projection(CAS row/@document apply helpers + 黑盒测;完整 docs:admin 投影仍待)
3. [x] Deploy/recover 状态机 + 收据(enqueue→claim→command→receipt 闭环 + 黑盒测;真实共享 env 未跑)
4. [ ] PI 源码镜像交付 compose profile + 检查入口(**explicitly deferred**;超出 temp-repo 测试范围)
5. [~] Cleanup visibility report(fail-closed 文案;Darwin 需 agentctl-macos 进程层)
6. [x] short-term 写明 landed vs deferred;测试夹具复制 `agentctl_w4.py`
1. [x] Durable queue schema + enqueue/list/cancel/tick
2. [x] Generic doc projection:行 CAS + `@document` 整文档 hash CAS;受控 `apply_projection_locally`;`docs project --mode row|document [--apply]`
3. [x] Deploy/recover 真实闭环:`execute_deploy_recover_loop` enqueue→claim→deploy(假命令)→receipt(SQLite);CLI `deploy run-loop|status|recover`
4. [ ] PI 源码镜像交付 compose profile(**deferred**:超本轮范围,见下)
5. [x] Darwin cwd 可见性:`cwd_visibility` / lsof;`cleanup_process_gate` 复用;`test_agentctl_w4.DarwinVisibilityTests`
6. [x] short-term landed vs deferred;`scripts/tests/test_agentctl_w4.py`
## 验证与证据
| 范围 | 命令 | 实际 |
|---|---|---|
| W4 投影/部署黑盒 | `python3 -B -m unittest scripts.tests.test_agentctl_w4 -v` | 5 PASS(CAS 行插入/整文拒绝、deploy 成败、recover) |
| agentctl 冒烟 | `test_uninitialized_status…` / `test_init_preserves…` | 导入 `agentctl_w4` 夹具路径未破 |
| ops_guards 抽样(旧基线) | `python3 -B -m unittest scripts.tests.test_ops_guards -v` | 14 跑;12 PASS;2 FAIL(`Lease`/`boot` 在 Darwin 仍依赖 `/proc`,本分支未含已合入的 agentctl-macos 进程层) |
| W4 单测 | `python3 -B -m unittest scripts.tests.test_agentctl_w4 -v` | 10 PASS |
| ops_guards | `python3 -B -m unittest scripts.tests.test_ops_guards` | 14 PASS(Darwin) |
## Landed vs deferred
**Landed(续作 modular-decouple-residual-2026-09-23)**
**Landed(并入 residual 任务分支)**
- `scripts/agentctl_w4.py`:jobs / deployments / projections schema + enqueue/list/cancel/tick helpers
- projection apply helpers:`build_row_projection_change` / `build_document_projection_change` / `apply_projection_locally`(CAS)
- deploy/recover loop:`run_deploy_command` / `execute_deploy_recover_loop` / `recover_deploy`(真实子进程 argv + SQLite 收据)
- `scripts/tests/test_agentctl_w4.py`:5 项黑盒测(tempfile + sqlite3 + Path 投影)
- 既有测试夹具复制 `agentctl_w4.py`,避免黑盒仓 import 崩
- `scripts/agentctl_w4.py`:projection CAS helpers、deploy/recover 全链、Darwin lsof cwd
- `scripts/agentctl.py`:`docs project --mode/--apply`、`deploy` 子命令、`cleanup_process_gate` 用 cwd_visibility
- `scripts/tests/test_agentctl_w4.py`
**Deferred**
- **PI source-image compose profile**(显式延期;超出 temp-repo 黑盒范围,不在本续作交付)
- Rebase/merge 本地 `dev` 已合入的 `agentctl-macos` 进程层后,Darwin 上 `agentctl with`/`boot()` 才能非崩溃跑全套脚本测
- 完整 docs:admin 生产路径投影、真实共享 env 部署验收
- 清理 `agentctl_automation.py` 与 `agentctl_w4.py` 重复实现
- PI 镜像 compose overlay(`docker-compose.dev.pi-image.yml` 实装与共享环境验收)——超本轮「临时仓/假命令」范围,不触共享 docker
- `agentctl_automation.py` 与 `agentctl_w4.py` 重复实现收敛
## 收口
- 状态保持 `in_progress`(禁止宣称 W3/W4 全关;PI compose 仍 deferred)。
- 续作进度与 tip 见 [modular-decouple-residual](./2026-09-23-modular-decouple-residual.md)。
- `integration_state=pending`;不 push。
- 状态保持 `in_progress`(PI compose 仍 deferred);实现去向:`modular-decouple-residual-2026-09-23`。
- `integration_state` 随 residual 任务串行合入;不 push。
@@ -13,7 +13,7 @@ worktree: .worktrees/modular-decouple-residual-2026-09-23
base_dev_sha: "2fae99096568a2941ea38c3fd1a38c762e455027"
write_scope: ["internal", "scripts", "web", ".doc"]
allowed_environments: []
head_sha: ""
head_sha: "12322b6974be5bbc08df4af6fc1d250de375e546"
mutex: "modular-decouple-residual"
review_state: pending
integration_state: pending
@@ -36,16 +36,16 @@ remote_sync: not_requested
| 批 | 目标 | 状态 | tip |
|---|---|---|---|
| A | `bizdev/service.go` 拆分 | done | dca33c70 |
| B | `projectinstance/store.go` | done | 943becfb |
| C | `intent/recognizer.go` | done | dca33c70 |
| D | `server/handlers.go` | done | 4c077169 |
| E | `projectworkflow/store.go` | done | dca33c70 |
| F | `docgov/engine.go` | done | 39e115b4 |
| G | `docrevision/service.go` | done | 9da7f352 |
| H | `squad/workspace.go` | done | dca33c70 |
| I | `freedoc/service.go` | done | dca33c70 |
| W4 | projection + deploy/recover + Darwin tests | in_progress | |
| A | `bizdev/service.go` 拆分 | landed | dca33c70 |
| B | `projectinstance/store.go` | landed | 943becfb |
| C | `intent/recognizer.go` | landed | dca33c70 |
| D | `server/handlers.go` | landed | 4c077169 |
| E | `projectworkflow/store.go` | landed | dca33c70 |
| F | `docgov/engine.go` | landed | 39e115b4 |
| G | `docrevision/service.go` | landed | 9da7f352 |
| H | `squad/workspace.go` | landed | dca33c70 |
| I | `freedoc/service.go` | landed | dca33c70 |
| W4 | projection + deploy/recover + Darwin tests | landed (PI compose deferred) | pending_commit |
| Sysadmin | `system:*` + 设置审计 | pending | |
## 每批验收
+146 -55
View File
@@ -1306,19 +1306,22 @@ def queue_schedule(repo, args):
def docs_project(repo, args):
"""Project machine registry / queue into a docs-apply request for CURRENT (generic snapshot JSON)."""
"""Project registry/queue into a docs-apply request (row or whole-document CAS)."""
task = task_row(repo, args.task)
if repo.current != Path(task['worktree']):
deny('WORKTREE_MISMATCH', 'Project documents from the registered task worktree')
filename = args.file
if filename not in SHARED_DOCS:
deny('PATH_OUT_OF_SCOPE', 'Only shared governance documents are projected')
if not filename.endswith('CURRENT.md'):
deny('INVALID_REQUEST', 'Generic projection currently emits CURRENT claim rows only')
if not w4.projectable_file(filename):
deny('PATH_OUT_OF_SCOPE', 'File is not registered as projectable')
mode = getattr(args, 'mode', None) or 'row'
if mode not in ('row', 'document'):
deny('INVALID_REQUEST', 'Projection mode must be row or document')
# Controlled apply still requires shared-governance docs (docs apply gate).
if getattr(args, 'apply', False) and filename not in SHARED_DOCS:
deny('PATH_OUT_OF_SCOPE', 'Controlled apply is limited to shared governance documents')
content = safe_path(repo.current, filename).read_text()
existing = doc_rows(content, args.task)
expected = digest(existing[0][1]) if existing else None
with repo.db(True) as db:
ensure_schema(db)
pending = [queue_row(r) for r in db.execute(
"SELECT * FROM queue_items WHERE task=? AND state IN ('pending','claimed') ORDER BY priority DESC, created",
(args.task,)).fetchall()]
@@ -1332,16 +1335,44 @@ def docs_project(repo, args):
worktree_cell = str(worktree_path.relative_to(repo.main))
except ValueError:
worktree_cell = str(worktree_path)
replacement = (
f"| {task['owner']} | {task['id']} | {task['branch']} | {worktree_cell} "
f"| {args.mutex or '-'} | {status} |"
)
change = {
'file': filename,
'record_id': args.task,
'expected_sha256': expected,
'replacement': replacement,
}
if mode == 'document':
if filename not in SHARED_DOCS and not getattr(args, 'replacement', None):
deny('INVALID_REQUEST', 'Document projection outside shared docs requires --replacement')
replacement = args.replacement
if replacement is None:
# Default document projection keeps content and only emits CAS baseline.
replacement = content
change = w4.build_document_projection_change(
file=filename, current_text=content, replacement=replacement)
record_id = '@document'
source = 'document'
else:
if filename not in SHARED_DOCS:
deny('INVALID_REQUEST', 'Row projection currently supports shared governance docs only')
record_id = getattr(args, 'record', None) or args.task
existing = doc_rows(content, record_id)
expected_row = existing[0][1] if existing else None
if args.replacement:
replacement = args.replacement
elif filename.endswith('CURRENT.md'):
replacement = (
f"| {task['owner']} | {task['id']} | {task['branch']} | {worktree_cell} "
f"| {args.mutex or '-'} | {status} |"
)
else:
deny('INVALID_REQUEST', 'Row projection for this file requires --replacement')
change = w4.build_row_projection_change(
file=filename, record_id=record_id, current_row=expected_row,
replacement=replacement)
source = 'row'
with repo.db(True) as db:
ensure_schema(db)
w4.record_projection(
db, file=filename, record_id=record_id, task=args.task,
source=source, content_hash=change['expected_sha256'] or digest(content))
machine = {
'task': {
'id': task['id'], 'owner': task['owner'], 'branch': task['branch'],
@@ -1354,59 +1385,106 @@ def docs_project(repo, args):
'queue_phase': queue_state,
'snapshot_time': time.time(),
'dev_sha': git(repo.main, 'rev-parse', 'dev'),
'mode': mode,
}
request = {
'request_id': args.request_id or (args.task + '-project-1'),
'changes': [change],
}
applied = None
if getattr(args, 'apply', False):
# Controlled apply via the same docs-apply entrypoint (lease + receipt).
req_path = Path(tempfile.mkdtemp(prefix='agentctl-project-')) / 'request.json'
req_path.write_text(json.dumps(request), encoding='utf-8')
apply_args = argparse.Namespace(task=args.task, request=str(req_path))
code = docs_apply(repo, apply_args)
applied = {'exit_code': code, 'request': str(req_path)}
if code != 0:
emit({'status': 'project_apply_failed', 'request': request, 'machine': machine,
'applied': applied})
return code
emit({
'status': 'projected',
'request': {'request_id': args.request_id or (args.task + '-project-1'), 'changes': [change]},
'status': 'projected' if not applied else 'projected_and_applied',
'request': request,
'machine': machine,
'applied': applied,
})
return 0
def deploy_cmd(repo, args):
"""W4 deploy/recover helpers; never touches shared docker from this entrypoint."""
task = task_row(repo, args.task) if getattr(args, 'task', None) else None
action = args.deploy_action
with repo.db(True) as db:
ensure_schema(db)
if action == 'run-loop':
if not task:
deny('INVALID_REQUEST', 'deploy run-loop requires --task')
command = list(args.command or [])
if command and command[0] == '--':
command = command[1:]
if not command and not args.dry_run:
deny('INVALID_REQUEST', 'Provide a deploy command argv or --dry-run')
if args.dry_run and not command:
command = [sys.executable, '-c', 'print("dry-run-placeholder")']
result = w4.execute_deploy_recover_loop(
db,
deploy_id=args.id,
task=args.task,
env=args.env,
expected_commit=args.expected_commit,
command=command,
dry_run=bool(args.dry_run),
manifest=args.manifest,
cwd=str(repo.current),
)
emit({'status': result['status'], **result})
return 0 if result['status'] == 'passed' else 1
if action == 'status':
row = w4.deployment_get(db, args.id)
if not row:
deny('UNKNOWN_DEPLOY', 'Deploy id not found')
emit({'status': 'ok', 'deploy': row})
return 0
if action == 'recover':
row = w4.recover_deploy(db, args.id, reason=args.reason)
emit({'status': 'recovered', 'deploy': row})
return 0
deny('INVALID_REQUEST', f'Unknown deploy action {action}')
def cleanup_process_gate(path):
"""Report process-cwd visibility without deleting. Fail-closed unknowns are explicit."""
path = Path(path).resolve()
if not sys.platform.startswith('linux'):
visibility = w4.cwd_visibility(path)
status = visibility.get('status')
if status == 'busy':
return {
'status': 'unknown',
'platform': sys.platform,
'reason': 'process cwd enumeration requires platform support; cleanup_pending until verified',
'holders': [],
'unreadable': [],
'status': 'blocked', 'platform': sys.platform,
'holders': visibility.get('pids', []),
'unreadable': visibility.get('unreadable', []),
'reason': 'live process cwd uses worktree',
}
holders, unreadable = [], []
try:
entries = list(Path('/proc').iterdir())
except OSError as exc:
if status == 'clear':
return {
'status': 'clear', 'platform': sys.platform,
'holders': [], 'unreadable': [], 'reason': None,
'platform_note': visibility.get('platform_note'),
}
if status == 'unsupported':
return {
'status': 'unknown', 'platform': sys.platform,
'reason': f'cannot list /proc: {exc}', 'holders': [], 'unreadable': [],
'reason': visibility.get('message') or 'process cwd enumeration unsupported; cleanup_pending',
'holders': [], 'unreadable': [],
}
for proc in entries:
if not proc.name.isdigit():
continue
try:
if proc.stat().st_uid != os.getuid():
continue
cwd = Path(os.readlink(proc / 'cwd'))
if cwd == path or path in cwd.parents:
holders.append(proc.name)
except (FileNotFoundError, ProcessLookupError):
continue
except PermissionError:
try:
if (proc / 'comm').read_text().strip() == 'systemd':
continue
except (OSError, UnicodeError):
pass
unreadable.append(proc.name)
if holders:
return {'status': 'blocked', 'platform': sys.platform, 'holders': holders, 'unreadable': unreadable,
'reason': 'live process cwd uses worktree'}
if unreadable:
return {'status': 'unknown', 'platform': sys.platform, 'holders': [], 'unreadable': unreadable,
'reason': 'cannot verify cwd of same-user processes; cleanup_pending'}
return {'status': 'clear', 'platform': sys.platform, 'holders': [], 'unreadable': [], 'reason': None}
return {
'status': 'unknown', 'platform': sys.platform,
'holders': [],
'unreadable': visibility.get('unreadable', []),
'reason': visibility.get('message') or 'cannot verify cwd; cleanup_pending',
'platform_note': visibility.get('platform_note'),
}
def cleanup_status(repo, args):
@@ -1529,6 +1607,9 @@ def parser():
apply=d.add_parser('apply');apply.add_argument('--task',required=True);apply.add_argument('--request',required=True)
proj=d.add_parser('project');proj.add_argument('--task',required=True);proj.add_argument('--file',default='.doc/ops/CURRENT.md')
proj.add_argument('--mutex',default='');proj.add_argument('--status');proj.add_argument('--request-id')
proj.add_argument('--mode',choices=['row','document'],default='row')
proj.add_argument('--record');proj.add_argument('--replacement')
proj.add_argument('--apply',action='store_true',help='Apply projected change via docs apply')
q=sub.add_parser('queue').add_subparsers(dest='queue_action',required=True)
qe=q.add_parser('enqueue');qe.add_argument('--task',required=True);qe.add_argument('--expected-dev',required=True)
qe.add_argument('--head');qe.add_argument('--priority',type=int,default=0);qe.add_argument('--request-id')
@@ -1550,6 +1631,15 @@ def parser():
ql=q.add_parser('list');ql.add_argument('--all',action='store_true')
qc=q.add_parser('cancel');qc.add_argument('--id',required=True);qc.add_argument('--reason',required=True)
q.add_parser('schedule')
dep=sub.add_parser('deploy').add_subparsers(dest='deploy_action',required=True)
dr=dep.add_parser('run-loop')
dr.add_argument('--task',required=True);dr.add_argument('--id',required=True)
dr.add_argument('--env',choices=['dev','test','main'],required=True)
dr.add_argument('--expected-commit',required=True);dr.add_argument('--manifest')
dr.add_argument('--dry-run',action='store_true')
dr.add_argument('command',nargs=argparse.REMAINDER)
ds=dep.add_parser('status');ds.add_argument('--id',required=True)
drec=dep.add_parser('recover');drec.add_argument('--id',required=True);drec.add_argument('--reason',required=True)
return p
@@ -1647,6 +1737,7 @@ def main():
else:hook_ref(repo,args.state)
elif args.action=='validate':return validate(repo,args)
elif args.action=='integrate':return integrate(repo,args)
elif args.action=='deploy':return deploy_cmd(repo,args)
elif args.action=='queue':
if args.queue_action=='enqueue':return queue_enqueue(repo,args)
if args.queue_action=='list':return queue_list(repo,args)