From ee7c9f7ace1201d4b43b0999a34cd1716e00ca12 Mon Sep 17 00:00:00 2001 From: generalio <1487144524@qq.com> Date: Mon, 17 Aug 2026 21:31:12 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(workflow):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codex/agents/workflow-implementer.toml | 9 +++++++++ .codex/agents/workflow-integrator.toml | 9 +++++++++ .codex/agents/workflow-planner.toml | 10 ++++++++++ .codex/agents/workflow-verify-reviewer.toml | 10 ++++++++++ 4 files changed, 38 insertions(+) create mode 100644 .codex/agents/workflow-implementer.toml create mode 100644 .codex/agents/workflow-integrator.toml create mode 100644 .codex/agents/workflow-planner.toml create mode 100644 .codex/agents/workflow-verify-reviewer.toml diff --git a/.codex/agents/workflow-implementer.toml b/.codex/agents/workflow-implementer.toml new file mode 100644 index 0000000..5a25a7a --- /dev/null +++ b/.codex/agents/workflow-implementer.toml @@ -0,0 +1,9 @@ +name = "workflow-implementer" +description = "在隔离 worktree 内实施任务契约明确授权的单一模块改动,并交接可复现的验证证据。" +developer_instructions = """ +你是 MultiWeb 的 Workflow Implementer。Supervisor 只在角色 worktree 中已存在 AGENTS.md、docs/agent-workflow.md 和 docs/agent-workflow-autonomous-handoff.md 三份文件时启动你;缺少任一文件时,必须报告“集成不完整”并停止。开始工作前必须阅读并遵守这三份文件;发生冲突时严格按 AGENTS.md > docs/agent-workflow.md > docs/agent-workflow-autonomous-handoff.md 的顺序处理并向 Supervisor 报告。仅在任务契约明确授予、且由你独占的文件和模块范围内工作。 + +必须使用独立 worktree,实施最小改动并执行契约指定的最小相关验证。交接中必须列出实际修改文件与授权范围的对应关系、可应用 diff 或 worktree、执行命令及结果、未验证范围,以及公共 API、跨平台差异和安全默认值影响。 + +不得写入其他任务的所有权范围,不得修改 API 基线、跨平台契约、发布或 Gradle 设置,除非任务契约明确且已串行授权;不得集成其他 Agent 改动、擅自解决接口签名、默认值、导航策略、JS 桥、安全设置或测试预期冲突。不得创建提交、推送或创建 PR,除非维护者另行明确授权。 +""" diff --git a/.codex/agents/workflow-integrator.toml b/.codex/agents/workflow-integrator.toml new file mode 100644 index 0000000..d0909ab --- /dev/null +++ b/.codex/agents/workflow-integrator.toml @@ -0,0 +1,9 @@ +name = "workflow-integrator" +description = "在专用集成 worktree 应用已交接改动,仅整理不改变语义的机械冲突并提供候选版本证据。" +developer_instructions = """ +你是 MultiWeb 的 Workflow Integrator。Supervisor 只在角色 worktree 中已存在 AGENTS.md、docs/agent-workflow.md 和 docs/agent-workflow-autonomous-handoff.md 三份文件时启动你;缺少任一文件时,必须报告“集成不完整”并停止。开始工作前必须阅读并遵守这三份文件;发生冲突时严格按 AGENTS.md > docs/agent-workflow.md > docs/agent-workflow-autonomous-handoff.md 的顺序处理并向 Supervisor 报告。仅在任务契约授权的专用集成 worktree 和已交接改动范围内工作。 + +只可应用完整交接,并处理不改变语义的机械冲突,例如无关 import 排序或相邻文本合并。集成前检查交接范围不重叠,完成后提供合并清单、git diff --check 结果和固定候选提交所需证据;是否创建候选提交仍须遵守 AGENTS.md 和维护者的明确授权。 + +不得修改产品业务逻辑、公共 API、默认值、导航策略、JS 桥、安全设置、测试预期或 API 基线来消除冲突或失败。遇到这些语义冲突、P0/P1 或交接不完整时,必须退回原 Implementer 或 Planner,不得自行猜测。不得写入契约未授权范围、推送或创建 PR。 +""" diff --git a/.codex/agents/workflow-planner.toml b/.codex/agents/workflow-planner.toml new file mode 100644 index 0000000..65b08b8 --- /dev/null +++ b/.codex/agents/workflow-planner.toml @@ -0,0 +1,10 @@ +name = "workflow-planner" +description = "只读规划 MultiWeb 中大型任务,输出可执行任务契约、依赖、独占文件范围和验证要求。" +sandbox_mode = "read-only" +developer_instructions = """ +你是 MultiWeb 的 Workflow Planner。Supervisor 只在角色 worktree 中已存在 AGENTS.md、docs/agent-workflow.md 和 docs/agent-workflow-autonomous-handoff.md 三份文件时启动你;缺少任一文件时,必须报告“集成不完整”并停止。开始工作前必须阅读并遵守这三份文件;发生冲突时严格按 AGENTS.md > docs/agent-workflow.md > docs/agent-workflow-autonomous-handoff.md 的顺序处理并向 Supervisor 报告。 + +仅进行只读分析。不得写入仓库、任务账本、产品代码、测试、API 基线、构建或发布配置,也不得创建提交、推送或创建 PR。将任务契约、任务 DAG、基线 SHA、独占文件/模块所有权、依赖、最小验证命令以及公共 API、跨平台和安全默认值影响仅以结构化结果返回 Supervisor;只能由 Supervisor 使用临时文件后原子替换落盘。 + +不得实施修复、替其他角色裁决语义冲突,或通过省略风险、放宽默认安全策略、修改接口约定来使任务看似可执行。契约不完整、需要维护者授权或发现 P0/P1 时,明确标记阻断原因与唯一下一步。 +""" diff --git a/.codex/agents/workflow-verify-reviewer.toml b/.codex/agents/workflow-verify-reviewer.toml new file mode 100644 index 0000000..f76ba22 --- /dev/null +++ b/.codex/agents/workflow-verify-reviewer.toml @@ -0,0 +1,10 @@ +name = "workflow-verify-reviewer" +description = "对固定候选提交进行只读验证与公共 API、安全、跨平台审查,并给出可复现裁决。" +sandbox_mode = "read-only" +developer_instructions = """ +你是 MultiWeb 的 Workflow Verify-Reviewer。Supervisor 只在角色 worktree 中已存在 AGENTS.md、docs/agent-workflow.md 和 docs/agent-workflow-autonomous-handoff.md 三份文件时启动你;缺少任一文件时,必须报告“集成不完整”并停止。开始工作前必须阅读并遵守这三份文件;发生冲突时严格按 AGENTS.md > docs/agent-workflow.md > docs/agent-workflow-autonomous-handoff.md 的顺序处理并向 Supervisor 报告。仅在干净 worktree 中针对固定候选提交 SHA 做只读验证和审查。 + +先执行任务契约中的最小验证,再根据实际改动审查公共 API、跨平台一致性、JS 桥与安全默认值。结果必须包含候选 SHA、实际命令与退出结果、未验证项、按 P0/P1/P2 分类的问题、文件位置,以及唯一的 PASS 或 REJECT 裁决。P0/P1 必须 REJECT;P2 必须写入风险记录;候选 SHA 变化后旧裁决立即失效。 + +不得写入候选代码、测试、API 基线、任务契约或账本,不得修改任何内容来制造 PASS,不得替 Integrator 处理语义冲突,也不得创建提交、推送或创建 PR。将审查报告仅返回 Supervisor;只能由 Supervisor 使用临时文件后原子替换落盘。 +""" From c634f79d68eea8c451abce91e8f80febd5a1800c Mon Sep 17 00:00:00 2001 From: generalio <1487144524@qq.com> Date: Mon, 17 Aug 2026 21:31:12 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat(workflow):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E8=B4=A6=E6=9C=AC=E9=AA=8C=E8=AF=81=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codex/workflow/README.md | 55 + .codex/workflow/runs/.gitkeep | 1 + tools/agent_workflow.py | 697 +++++++++++++ .../candidate-changed/candidate.sha | 1 + .../candidate-changed/events.jsonl | 4 + .../candidate-changed/handoffs/implementer.md | 3 + .../candidate-changed/plan.json | 4 + .../candidate-changed/review-report.md | 4 + .../candidate-changed/state.json | 20 + .../candidate-changed/task-contract.md | 1 + .../agent-workflow/paused/events.jsonl | 2 + .../fixtures/agent-workflow/paused/plan.json | 4 + .../fixtures/agent-workflow/paused/state.json | 20 + .../agent-workflow/paused/task-contract.md | 1 + .../agent-workflow/rejected/candidate.sha | 1 + .../agent-workflow/rejected/events.jsonl | 4 + .../rejected/handoffs/implementer.md | 3 + .../agent-workflow/rejected/plan.json | 4 + .../agent-workflow/rejected/review-report.md | 6 + .../agent-workflow/rejected/state.json | 20 + .../agent-workflow/rejected/task-contract.md | 1 + .../fixtures/agent-workflow/valid/plan.json | 4 + .../fixtures/agent-workflow/valid/state.json | 19 + .../agent-workflow/valid/task-contract.md | 3 + .../agent-workflow/validating/candidate.sha | 1 + .../agent-workflow/validating/events.jsonl | 3 + .../validating/handoffs/implementer.md | 3 + .../agent-workflow/validating/plan.json | 4 + .../agent-workflow/validating/state.json | 19 + .../validating/task-contract.md | 1 + tools/tests/test_agent_workflow.py | 986 ++++++++++++++++++ 31 files changed, 1899 insertions(+) create mode 100644 .codex/workflow/README.md create mode 100644 .codex/workflow/runs/.gitkeep create mode 100644 tools/agent_workflow.py create mode 100644 tools/tests/fixtures/agent-workflow/candidate-changed/candidate.sha create mode 100644 tools/tests/fixtures/agent-workflow/candidate-changed/events.jsonl create mode 100644 tools/tests/fixtures/agent-workflow/candidate-changed/handoffs/implementer.md create mode 100644 tools/tests/fixtures/agent-workflow/candidate-changed/plan.json create mode 100644 tools/tests/fixtures/agent-workflow/candidate-changed/review-report.md create mode 100644 tools/tests/fixtures/agent-workflow/candidate-changed/state.json create mode 100644 tools/tests/fixtures/agent-workflow/candidate-changed/task-contract.md create mode 100644 tools/tests/fixtures/agent-workflow/paused/events.jsonl create mode 100644 tools/tests/fixtures/agent-workflow/paused/plan.json create mode 100644 tools/tests/fixtures/agent-workflow/paused/state.json create mode 100644 tools/tests/fixtures/agent-workflow/paused/task-contract.md create mode 100644 tools/tests/fixtures/agent-workflow/rejected/candidate.sha create mode 100644 tools/tests/fixtures/agent-workflow/rejected/events.jsonl create mode 100644 tools/tests/fixtures/agent-workflow/rejected/handoffs/implementer.md create mode 100644 tools/tests/fixtures/agent-workflow/rejected/plan.json create mode 100644 tools/tests/fixtures/agent-workflow/rejected/review-report.md create mode 100644 tools/tests/fixtures/agent-workflow/rejected/state.json create mode 100644 tools/tests/fixtures/agent-workflow/rejected/task-contract.md create mode 100644 tools/tests/fixtures/agent-workflow/valid/plan.json create mode 100644 tools/tests/fixtures/agent-workflow/valid/state.json create mode 100644 tools/tests/fixtures/agent-workflow/valid/task-contract.md create mode 100644 tools/tests/fixtures/agent-workflow/validating/candidate.sha create mode 100644 tools/tests/fixtures/agent-workflow/validating/events.jsonl create mode 100644 tools/tests/fixtures/agent-workflow/validating/handoffs/implementer.md create mode 100644 tools/tests/fixtures/agent-workflow/validating/plan.json create mode 100644 tools/tests/fixtures/agent-workflow/validating/state.json create mode 100644 tools/tests/fixtures/agent-workflow/validating/task-contract.md create mode 100644 tools/tests/test_agent_workflow.py diff --git a/.codex/workflow/README.md b/.codex/workflow/README.md new file mode 100644 index 0000000..6d355bb --- /dev/null +++ b/.codex/workflow/README.md @@ -0,0 +1,55 @@ +# Agent Workflow 运行账本 + +每个中大型任务在 `runs//` 建立独立账本。账本只保存可公开的任务状态、证据相对路径和提交 SHA,禁止写入 Token、凭据或完整环境变量。 + +## 命令 + +在仓库根目录执行: + +```shell +python3 tools/agent_workflow.py validate .codex/workflow/runs/ +python3 tools/agent_workflow.py transition .codex/workflow/runs/ --actor --evidence [--candidate-sha ] [--verdict PASS|REJECT] +python3 tools/agent_workflow.py resume .codex/workflow/runs/ +``` + +三个命令都会先获取运行目录内 `.workflow.lock` 的非阻塞 `fcntl` 租约。该能力要求 Unix-like 系统和 Python 3.11+,是当前的 P2 平台边界。租约被其他 Supervisor 持有时,命令会立即失败,不会读取或改写账本;`resume` 因此可确认不存在另一份有效租约。除这个运行时锁文件外,`resume` 完全只读,输出当前状态、阻断原因、唯一恢复目标和唯一下一步。 + +`validate` 检查状态字段、Git 可解析的基线/候选 SHA、任务契约、计划、完整事件链和审查证据。`transition` 仅接受状态机允许的下一状态:它先追加并 `fsync` 对应事件,再以同目录临时文件原子替换 `state.json`。如果事件追加失败,状态保持不变;如果替换在事件成功后中断,下一次 `validate` 会因事件末状态不一致而拒绝继续。 + +`--evidence` 必须是运行目录内已存在的文件。绝对路径或符号链接解析后越出该目录会被拒绝。进入 `VALIDATING` 时必须同时传入 `--candidate-sha`,且该值必须与已生成的 `candidate.sha` 一致;进入 `REVIEWED` 时必须传入 `--verdict PASS|REJECT`,并与审查报告一致。关键账本文件不得是符号链接。 + +## 必需文件 + +```text +runs// +├── state.json +├── task-contract.md +├── plan.json +├── events.jsonl # 首次迁移时创建 +├── .workflow.lock # 运行时租约文件,不写入任务数据 +├── handoffs/ +├── candidate.sha # 生成候选后必需 +├── review-report.md # REVIEWED 后必需 +└── resume.md # 可选的人类可读恢复上下文 +``` + +`state.json` 使用 schemaVersion `1`,必须包含 `taskId`、`baselineSha`、`status`、`mode`、`currentStage`、`attempts`、`candidateSha`、`verdict`、`nextAction`、`lastEventAt` 和 `stopReason`。`PAUSED` 时还必须有 `pausedFromStatus`,且只能恢复到该状态或迁移为 `BLOCKED`;恢复完成后必须移除该字段。 + +自动尝试次数上限为两次。初始规划计为一次;`PLANNED -> PAUSED -> PLANNED` 会增加 `attempts.PLANNING`,达到上限后必须保持 `PAUSED` 或迁移为 `BLOCKED`。实施、验证和审查阶段同样受各自计数限制。 + +除从未离开 `PLANNED` 的新账本外,`events.jsonl` 必须存在,首条事件的 `from` 必须为 `PLANNED`,每条事件连续且证据路径位于任务目录内,末条 `to` 必须等于 `state.json.status`。 + +## 审查门禁 + +从 `REVIEWED` 进入 `COMMIT_READY` 前,验证器要求: + +- `state.json.verdict` 与审查报告均为 `PASS`; +- `candidateSha`、`candidate.sha`、审查报告中的候选 SHA 完全一致且可由 Git 解析; +- 审查报告没有未解决的 `P0` 或 `P1`;明确的“无 P1”“P1 已修复”等记录不阻断; +- 候选 SHA 自审查后没有变化。 + +任一项失败都会拒绝迁移,并在错误信息中给出任务目录和可执行修复建议。 + +`REJECT` 报告可以记录待返工的 P0/P1,并可从 `REVIEWED` 返回 `IMPLEMENTING`;只有 PASS 证据链或 `COMMIT_READY`/`PR_READY` 会被未解决的 P0/P1 阻断。 + +`REVIEWED -> IMPLEMENTING` 会清除旧的 `reviewedCandidateSha`。返工后的新候选必须再次经过 `VALIDATING -> REVIEWED`,并由新的报告和 verdict 重新建立审查绑定;未走返工路径时,候选变化仍会使既有 `REVIEWED`、`COMMIT_READY` 或 `PR_READY` 证据失效。 diff --git a/.codex/workflow/runs/.gitkeep b/.codex/workflow/runs/.gitkeep new file mode 100644 index 0000000..04b10d7 --- /dev/null +++ b/.codex/workflow/runs/.gitkeep @@ -0,0 +1 @@ +# Supervisor 在此目录创建每个任务的运行账本。 diff --git a/tools/agent_workflow.py b/tools/agent_workflow.py new file mode 100644 index 0000000..6ff0db9 --- /dev/null +++ b/tools/agent_workflow.py @@ -0,0 +1,697 @@ +#!/usr/bin/env python3 +"""MultiWeb 自动化 Agent 运行账本命令行验证器。""" + +from __future__ import annotations + +import argparse +import fcntl +import json +import os +import re +import subprocess +import sys +import tempfile +from datetime import UTC, datetime, timedelta +from contextlib import contextmanager +from pathlib import Path +from typing import Any + + +STATUSES = frozenset( + { + "PLANNED", + "IMPLEMENTING", + "READY_TO_INTEGRATE", + "VALIDATING", + "REVIEWED", + "COMMIT_READY", + "PR_READY", + "PAUSED", + "BLOCKED", + } +) + +ALLOWED_TRANSITIONS = { + "PLANNED": frozenset({"IMPLEMENTING", "PAUSED"}), + "IMPLEMENTING": frozenset({"READY_TO_INTEGRATE", "BLOCKED", "PAUSED"}), + "READY_TO_INTEGRATE": frozenset({"VALIDATING", "BLOCKED"}), + "VALIDATING": frozenset({"REVIEWED", "BLOCKED"}), + "REVIEWED": frozenset({"COMMIT_READY", "IMPLEMENTING", "BLOCKED"}), + "COMMIT_READY": frozenset({"PR_READY"}), + "PR_READY": frozenset(), + "PAUSED": frozenset(), + "BLOCKED": frozenset(), +} + +STAGE_FOR_STATUS = { + "PLANNED": "PLANNING", + "IMPLEMENTING": "IMPLEMENTING", + "READY_TO_INTEGRATE": "INTEGRATING", + "VALIDATING": "VALIDATING", + "REVIEWED": "REVIEWING", + "COMMIT_READY": "COMMITTING", + "PR_READY": "PR", + "BLOCKED": "BLOCKED", +} + +ATTEMPT_FIELD_FOR_STATUS = { + "PLANNED": "PLANNING", + "IMPLEMENTING": "IMPLEMENTING", + "VALIDATING": "VALIDATING", + "REVIEWED": "REVIEWING", +} + +REQUIRED_STATE_FIELDS = ( + "schemaVersion", + "taskId", + "baselineSha", + "status", + "mode", + "currentStage", + "attempts", + "candidateSha", + "verdict", + "nextAction", + "lastEventAt", + "stopReason", +) + +ATTEMPT_FIELDS = ("PLANNING", "IMPLEMENTING", "VALIDATING", "REVIEWING") + +EVENT_FIELDS = ("at", "actor", "from", "to", "evidence", "summary") + +LEDGER_FILENAMES = ( + "state.json", + "task-contract.md", + "plan.json", + "candidate.sha", + "review-report.md", + "events.jsonl", + ".workflow.lock", +) + +STATUS_ORDER = { + "PLANNED": 0, + "IMPLEMENTING": 1, + "READY_TO_INTEGRATE": 2, + "VALIDATING": 3, + "REVIEWED": 4, + "COMMIT_READY": 5, + "PR_READY": 6, +} + + +def reject_ledger_symlink(run_directory: Path, filename: str) -> None: + """拒绝关键账本文件使用符号链接,避免账本读写逃逸任务目录。""" + if (run_directory / filename).is_symlink(): + raise ValueError( + f"任务目录 {run_directory} 的关键账本文件 {filename} 不能是符号链接;请替换为任务目录内的常规文件后重试。" + ) + + +def reject_ledger_symlinks(run_directory: Path) -> None: + """确认所有已知关键账本文件都不是符号链接。""" + for filename in LEDGER_FILENAMES: + reject_ledger_symlink(run_directory, filename) + + +def validate_run(run_directory: Path) -> dict[str, Any]: + """确认运行目录包含当前状态账本。""" + if not run_directory.is_dir(): + raise ValueError(f"任务目录 {run_directory} 不存在;请传入 .codex/workflow/runs/。") + reject_ledger_symlinks(run_directory) + if not (run_directory / "state.json").is_file(): + raise ValueError(f"任务目录 {run_directory} 缺少 state.json;请初始化状态账本后重试。") + try: + state = json.loads((run_directory / "state.json").read_text(encoding="utf-8")) + except json.JSONDecodeError as error: + raise ValueError(f"任务目录 {run_directory} 的 state.json 不是合法 JSON;请修复账本。") from error + validate_state_schema(run_directory, state) + validate_git_sha(run_directory, state["baselineSha"], "baselineSha") + validate_required_evidence(run_directory) + validate_event_log(run_directory, state) + if state["status"] in {"REVIEWED", "COMMIT_READY", "PR_READY"}: + validate_review_evidence( + run_directory, + state, + require_pass=state["status"] in {"COMMIT_READY", "PR_READY"}, + ) + elif state["candidateSha"] is not None: + validate_git_sha(run_directory, state["candidateSha"], "candidateSha") + return state + + +@contextmanager +def workflow_lock(run_directory: Path): + """以非阻塞进程租约串行化同一任务目录的工作流命令。""" + if not run_directory.is_dir(): + raise ValueError(f"任务目录 {run_directory} 不存在;请传入 .codex/workflow/runs/。") + lock_path = run_directory / ".workflow.lock" + reject_ledger_symlink(run_directory, ".workflow.lock") + try: + lock_file = lock_path.open("a+", encoding="utf-8") + except OSError as error: + raise ValueError(f"任务目录 {run_directory} 无法获取工作流租约;请检查目录权限后重试。") from error + with lock_file: + try: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as error: + raise ValueError( + f"任务目录 {run_directory} 已有有效租约;请等待当前 Supervisor 完成或确认其已退出后重试。" + ) from error + except OSError as error: + raise ValueError(f"任务目录 {run_directory} 无法获取工作流租约;请检查目录权限后重试。") from error + try: + yield + finally: + try: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + except OSError as error: + raise ValueError(f"任务目录 {run_directory} 无法释放工作流租约;请检查目录权限后重试。") from error + + +def validate_state_schema(run_directory: Path, state: object) -> None: + """校验状态账本的固定字段、类型和阶段约束。""" + if not isinstance(state, dict): + raise ValueError(f"任务目录 {run_directory} 的 state.json 必须是对象;请按运行账本 schema 修复。") + missing_fields = [field for field in REQUIRED_STATE_FIELDS if field not in state] + if missing_fields: + raise ValueError( + f"任务目录 {run_directory} 缺少 state.json.{', '.join(missing_fields)};请补齐必填字段。" + ) + if state["schemaVersion"] != 1 or isinstance(state["schemaVersion"], bool): + raise ValueError(f"任务目录 {run_directory} 的 schemaVersion 不受支持;请使用 schemaVersion 1。") + task_id = state["taskId"] + if not isinstance(task_id, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{2,127}", task_id): + raise ValueError(f"任务目录 {run_directory} 的 taskId 非法;请使用 3-128 位的字母数字任务标识。") + status = state["status"] + if not isinstance(status, str) or status not in STATUSES: + raise ValueError(f"任务目录 {run_directory} 的 state.json.status 非法;请使用受支持的工作流状态。") + if not isinstance(state["mode"], str) or state["mode"] not in {"FULL", "TRIVIAL"}: + raise ValueError(f"任务目录 {run_directory} 的 mode 非法;请使用 FULL 或 TRIVIAL。") + current_stage = state["currentStage"] + if not isinstance(current_stage, str): + raise ValueError(f"任务目录 {run_directory} 的 currentStage 非法;请记录当前工作流阶段。") + expected_stage = STAGE_FOR_STATUS.get(status) + if expected_stage is not None and current_stage != expected_stage: + raise ValueError( + f"任务目录 {run_directory} 的 currentStage 与 {state['status']} 不一致;请恢复到对应阶段。" + ) + paused_from_status = state.get("pausedFromStatus") + if status == "PAUSED": + if not isinstance(paused_from_status, str) or paused_from_status not in STATUSES: + raise ValueError(f"任务目录 {run_directory} 的 PAUSED 缺少有效 pausedFromStatus;请记录中断前状态。") + if "PAUSED" not in ALLOWED_TRANSITIONS[paused_from_status]: + raise ValueError(f"任务目录 {run_directory} 的 pausedFromStatus 不可暂停;请记录允许中断的前序状态。") + if current_stage != STAGE_FOR_STATUS[paused_from_status]: + raise ValueError(f"任务目录 {run_directory} 的 PAUSED.currentStage 与 pausedFromStatus 不一致;请保留中断前阶段。") + elif "pausedFromStatus" in state: + raise ValueError(f"任务目录 {run_directory} 的 pausedFromStatus 仅可用于 PAUSED;请移除过期恢复目标。") + attempts = state["attempts"] + if not isinstance(attempts, dict): + raise ValueError(f"任务目录 {run_directory} 的 attempts 必须是对象;请记录各阶段尝试次数。") + missing_attempts = [field for field in ATTEMPT_FIELDS if field not in attempts] + if missing_attempts: + raise ValueError( + f"任务目录 {run_directory} 的 attempts 缺少 {', '.join(missing_attempts)};请补齐阶段次数。" + ) + for field in ATTEMPT_FIELDS: + value = attempts[field] + if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= 2: + raise ValueError( + f"任务目录 {run_directory} 的 attempts.{field} 非法;请记录不超过 2 的非负整数。" + ) + candidate_sha = state["candidateSha"] + if candidate_sha is not None and (not isinstance(candidate_sha, str) or not re.fullmatch(r"[0-9a-fA-F]{7,64}", candidate_sha)): + raise ValueError(f"任务目录 {run_directory} 的 candidateSha 非法;请记录 7-64 位十六进制 SHA 或 null。") + if status in {"VALIDATING", "REVIEWED", "COMMIT_READY", "PR_READY"} and candidate_sha is None: + raise ValueError( + f"任务目录 {run_directory} 的 {state['status']} 缺少 candidateSha;请由 Integrator 固定候选提交后继续。" + ) + if not isinstance(state["verdict"], str | type(None)) or state["verdict"] not in {None, "PASS", "REJECT"}: + raise ValueError(f"任务目录 {run_directory} 的 verdict 非法;请使用 PASS、REJECT 或 null。") + for field in ("nextAction", "lastEventAt"): + value = state[field] + if not isinstance(value, str) or not value.strip() or "\n" in value: + raise ValueError(f"任务目录 {run_directory} 的 {field} 非法;请记录非空单行值。") + try: + parsed_event_time = datetime.fromisoformat(state["lastEventAt"]) + except ValueError as error: + raise ValueError(f"任务目录 {run_directory} 的 lastEventAt 不是 ISO 8601 时间;请修复时间戳。") from error + if parsed_event_time.tzinfo is None: + raise ValueError(f"任务目录 {run_directory} 的 lastEventAt 缺少时区;请记录带时区的 ISO 8601 时间。") + stop_reason = state["stopReason"] + if stop_reason is not None and (not isinstance(stop_reason, str) or "\n" in stop_reason): + raise ValueError(f"任务目录 {run_directory} 的 stopReason 非法;请记录单行原因或 null。") + + +def validate_required_evidence(run_directory: Path) -> None: + """确认规划阶段的任务契约和 DAG 计划可供恢复流程读取。""" + contract_path = run_directory / "task-contract.md" + if not contract_path.is_file(): + raise ValueError(f"任务目录 {run_directory} 缺少 task-contract.md;请由 Planner 生成任务契约。") + plan_path = run_directory / "plan.json" + if not plan_path.is_file(): + raise ValueError(f"任务目录 {run_directory} 缺少 plan.json;请由 Planner 生成任务计划。") + try: + plan = json.loads(plan_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as error: + raise ValueError(f"任务目录 {run_directory} 的 plan.json 不是合法 JSON;请修复任务计划。") from error + if not isinstance(plan, dict) or plan.get("schemaVersion") != 1: + raise ValueError(f"任务目录 {run_directory} 的 plan.json schemaVersion 不受支持;请使用版本 1。") + + +def validate_event_log(run_directory: Path, state: dict[str, Any]) -> None: + """校验追加事件不会绕过状态机、丢失证据或回退阶段。""" + events_path = run_directory / "events.jsonl" + reject_ledger_symlink(run_directory, "events.jsonl") + if not events_path.exists(): + if state["status"] != "PLANNED": + raise ValueError( + f"任务目录 {run_directory} 的 {state['status']} 缺少 events.jsonl;请恢复从 PLANNED 开始的完整事件链。" + ) + return + if not events_path.is_file(): + raise ValueError(f"任务目录 {run_directory} 的 events.jsonl 不是文件;请恢复追加式事件日志。") + previous_status: str | None = None + previous_event_time: datetime | None = None + last_status: str | None = None + last_event_at: str | None = None + paused_from_history: str | None = None + for line_number, line in enumerate(events_path.read_text(encoding="utf-8").splitlines(), start=1): + if not line.strip(): + raise ValueError(f"任务目录 {run_directory} 的 events.jsonl 第 {line_number} 行为空;请移除无效事件。") + try: + event = json.loads(line) + except json.JSONDecodeError as error: + raise ValueError( + f"任务目录 {run_directory} 的 events.jsonl 第 {line_number} 行不是合法 JSON;请修复审计事件。" + ) from error + if not isinstance(event, dict) or any(field not in event for field in EVENT_FIELDS): + raise ValueError( + f"任务目录 {run_directory} 的 events.jsonl 第 {line_number} 行缺少事件字段;请补齐审计记录。" + ) + if not all(isinstance(event[field], str) and event[field] for field in EVENT_FIELDS): + raise ValueError( + f"任务目录 {run_directory} 的 events.jsonl 第 {line_number} 行字段非法;请使用非空字符串。" + ) + try: + event_time = datetime.fromisoformat(event["at"]) + except ValueError as error: + raise ValueError( + f"任务目录 {run_directory} 的 events.jsonl 第 {line_number} 行时间非法;请使用带时区的 ISO 8601 时间。" + ) from error + if event_time.tzinfo is None: + raise ValueError( + f"任务目录 {run_directory} 的 events.jsonl 第 {line_number} 行时间缺少时区;请修复审计时间。" + ) + if previous_event_time is not None and event_time <= previous_event_time: + raise ValueError( + f"任务目录 {run_directory} 的 events.jsonl 第 {line_number} 行时间未严格递增;请按严格递增顺序修复审计时间。" + ) + from_status = event["from"] + to_status = event["to"] + if from_status not in STATUSES or to_status not in STATUSES: + raise ValueError( + f"任务目录 {run_directory} 的 events.jsonl 第 {line_number} 行状态非法;请使用受支持的状态。" + ) + if line_number == 1 and from_status != "PLANNED": + raise ValueError( + f"任务目录 {run_directory} 的 events.jsonl 首条事件必须从 PLANNED 开始;请恢复完整状态链。" + ) + if previous_status is not None and from_status != previous_status: + raise ValueError( + f"任务目录 {run_directory} 的 events.jsonl 第 {line_number} 行状态不连续;请修复事件顺序。" + ) + if ( + from_status in STATUS_ORDER + and to_status in STATUS_ORDER + and STATUS_ORDER[to_status] < STATUS_ORDER[from_status] + and (from_status, to_status) != ("REVIEWED", "IMPLEMENTING") + ): + raise ValueError( + f"任务目录 {run_directory} 的 events.jsonl 第 {line_number} 行发生状态倒退;请重新初始化后续证据。" + ) + if not is_event_transition_allowed(from_status, to_status, paused_from_history): + raise ValueError( + f"任务目录 {run_directory} 的 events.jsonl 第 {line_number} 行违反状态机;请记录合法迁移。" + ) + if to_status == "PAUSED": + paused_from_history = from_status + elif from_status == "PAUSED": + paused_from_history = None + resolve_evidence_path(run_directory, Path(event["evidence"])) + previous_status = to_status + previous_event_time = event_time + last_status = to_status + last_event_at = event["at"] + if last_status is not None and last_status != state["status"]: + raise ValueError( + f"任务目录 {run_directory} 的 events.jsonl 末状态与 state.json 不一致;请恢复一致的账本与审计记录。" + ) + if last_event_at is not None and last_event_at != state["lastEventAt"]: + raise ValueError( + f"任务目录 {run_directory} 的 events.jsonl 末条 at 与 state.json.lastEventAt 不一致;请恢复一致的时间戳。" + ) + if state["status"] == "PAUSED" and paused_from_history != state["pausedFromStatus"]: + raise ValueError( + f"任务目录 {run_directory} 的 pausedFromStatus 与 events.jsonl 不一致;请恢复中断前状态证据。" + ) + + +def is_event_transition_allowed( + from_status: str, + to_status: str, + paused_from_history: str | None, +) -> bool: + """校验暂停事件只能回到最近一次中断前状态或转为阻断。""" + if from_status == "PAUSED": + return paused_from_history is not None and to_status in {paused_from_history, "BLOCKED"} + return to_status in ALLOWED_TRANSITIONS[from_status] + + +def validate_git_sha(run_directory: Path, value: object, field_name: str) -> None: + """确认账本中的提交标识可在当前仓库解析。""" + if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{7,64}", value): + raise ValueError( + f"任务目录 {run_directory} 的 state.json.{field_name} 不是 7-64 位十六进制 SHA;请记录有效提交。" + ) + result = subprocess.run( + ["git", "-C", str(run_directory), "rev-parse", "--verify", f"{value}^{{commit}}"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise ValueError( + f"任务目录 {run_directory} 的 state.json.{field_name} 无法由 Git 解析;请使用当前仓库存在的提交 SHA。" + ) + + +def resolve_evidence_path(run_directory: Path, evidence: Path) -> Path: + """只接受运行目录内已有的常规证据文件。""" + evidence_path = evidence.resolve() if evidence.is_absolute() else (run_directory / evidence).resolve() + if not evidence_path.is_relative_to(run_directory): + raise ValueError(f"任务目录 {run_directory} 的证据路径越界;请提供该任务目录内的证据文件。") + if not evidence_path.is_file(): + raise ValueError(f"任务目录 {run_directory} 缺少证据文件 {evidence};请先生成证据后再迁移状态。") + return evidence_path + + +def transition_run( + run_directory: Path, + next_status: str, + actor: str, + evidence: Path, + candidate_sha: str | None, + verdict: str | None, +) -> None: + """验证状态迁移并原子更新当前账本,再追加审计事件。""" + state = validate_run(run_directory) + old_status = state["status"] + if next_status not in STATUSES: + raise ValueError(f"任务目录 {run_directory} 的目标状态 {next_status} 非法;请使用受支持的工作流状态。") + if not is_allowed_transition(state, next_status): + raise ValueError( + f"任务目录 {run_directory} 不允许从 {old_status} 迁移到 {next_status};请迁移到该状态允许的下一阶段。" + ) + if next_status == "VALIDATING": + if candidate_sha is None: + raise ValueError( + f"任务目录 {run_directory} 进入 VALIDATING 必须提供 --candidate-sha;请先固定 candidate.sha 后重试。" + ) + validate_candidate_evidence(run_directory, candidate_sha) + state["candidateSha"] = candidate_sha + elif candidate_sha is not None: + raise ValueError( + f"任务目录 {run_directory} 仅在进入 VALIDATING 时接受 --candidate-sha;请在固定候选时提供该参数。" + ) + if next_status == "REVIEWED": + if verdict is None: + raise ValueError( + f"任务目录 {run_directory} 进入 REVIEWED 必须提供 --verdict PASS 或 REJECT;请记录审查裁决后重试。" + ) + state["verdict"] = verdict + elif verdict is not None: + raise ValueError( + f"任务目录 {run_directory} 仅在进入 REVIEWED 时接受 --verdict;请在记录审查结论时提供该参数。" + ) + if old_status == "REVIEWED" and next_status == "COMMIT_READY": + validate_commit_ready_gate(run_directory, state) + if next_status == "REVIEWED": + validate_review_evidence(run_directory, state, require_pass=False) + state["reviewedCandidateSha"] = state["candidateSha"] + if old_status == "REVIEWED" and next_status == "IMPLEMENTING": + state.pop("reviewedCandidateSha", None) + if not re.fullmatch(r"[A-Z][A-Z0-9_-]{1,63}", actor): + raise ValueError(f"任务目录 {run_directory} 的操作者角色非法;请使用大写角色标识。") + evidence_path = resolve_evidence_path(run_directory, evidence) + attempt_field = ATTEMPT_FIELD_FOR_STATUS.get(next_status) + if attempt_field is not None: + attempts = state.get("attempts") + attempt_count = attempts.get(attempt_field) if isinstance(attempts, dict) else None + if not isinstance(attempt_count, int) or isinstance(attempt_count, bool) or attempt_count < 0: + raise ValueError( + f"任务目录 {run_directory} 的 attempts.{attempt_field} 非法;请记录非负整数尝试次数。" + ) + if attempt_count >= 2: + raise ValueError( + f"任务目录 {run_directory} 的 {attempt_field} 自动重试已达上限;请迁移到 PAUSED 或 BLOCKED 并人工处理。" + ) + attempts[attempt_field] = attempt_count + 1 + if next_status == "PAUSED": + state["pausedFromStatus"] = old_status + state["currentStage"] = STAGE_FOR_STATUS[old_status] + else: + state.pop("pausedFromStatus", None) + state["currentStage"] = STAGE_FOR_STATUS[next_status] + state["status"] = next_status + state["lastEventAt"] = next_event_time(state["lastEventAt"]) + append_event( + run_directory, + { + "at": state["lastEventAt"], + "actor": actor, + "from": old_status, + "to": next_status, + "evidence": evidence_path.relative_to(run_directory).as_posix(), + "summary": "状态迁移已验证", + }, + ) + atomic_write_json(run_directory / "state.json", state) + + +def is_allowed_transition(state: dict[str, Any], next_status: str) -> bool: + """暂停账本只能恢复到记录的前序状态,或转为阻断。""" + old_status = state["status"] + if old_status == "PAUSED": + return next_status in {state["pausedFromStatus"], "BLOCKED"} + return next_status in ALLOWED_TRANSITIONS[old_status] + + +def next_event_time(previous_event_at: str) -> str: + """生成严格晚于当前账本末事件的带时区时间戳。""" + now = datetime.now(UTC) + previous_event_time = datetime.fromisoformat(previous_event_at) + if now <= previous_event_time: + now = previous_event_time + timedelta(microseconds=1) + return now.isoformat(timespec="microseconds") + + +def resume_run(run_directory: Path) -> tuple[str, str, str, str]: + """读取经过验证的账本,并返回恢复所需的最小上下文。""" + state = validate_run(run_directory) + next_action = state.get("nextAction") + if not isinstance(next_action, str) or not next_action.strip() or "\n" in next_action: + raise ValueError(f"任务目录 {run_directory} 的 nextAction 不是唯一可执行步骤;请记录一条非空单行恢复指令。") + stop_reason = state.get("stopReason") + if stop_reason is None: + stop_reason = "无" + if not isinstance(stop_reason, str) or "\n" in stop_reason: + raise ValueError(f"任务目录 {run_directory} 的 stopReason 非法;请记录单行阻断原因或 null。") + resume_target = state["pausedFromStatus"] if state["status"] == "PAUSED" else state["status"] + return state["status"], stop_reason, next_action, resume_target + + +def atomic_write_json(destination: Path, payload: dict[str, Any]) -> None: + """以同目录临时文件加替换的方式写入状态,避免半写入账本。""" + temporary_path: Path | None = None + reject_ledger_symlink(destination.parent, destination.name) + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=destination.parent, + prefix=".state.", + suffix=".tmp", + delete=False, + ) as temporary_file: + temporary_path = Path(temporary_file.name) + json.dump(payload, temporary_file, ensure_ascii=False, indent=2) + temporary_file.write("\n") + temporary_file.flush() + os.fsync(temporary_file.fileno()) + os.replace(temporary_path, destination) + temporary_path = None + finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + + +def append_event(run_directory: Path, event: dict[str, str]) -> None: + """以 JSON Lines 追加状态审计记录。""" + reject_ledger_symlink(run_directory, "events.jsonl") + try: + with (run_directory / "events.jsonl").open("a", encoding="utf-8") as event_file: + event_file.write(json.dumps(event, ensure_ascii=False, sort_keys=True)) + event_file.write("\n") + event_file.flush() + os.fsync(event_file.fileno()) + except OSError as error: + raise ValueError( + f"任务目录 {run_directory} 无法追加 events.jsonl;请恢复事件日志写入权限后重试。" + ) from error + + +def validate_commit_ready_gate(run_directory: Path, state: dict[str, Any]) -> None: + """只有固定候选版本的无阻断审查才可进入可提交状态。""" + validate_review_evidence(run_directory, state, require_pass=True) + + +def validate_review_evidence(run_directory: Path, state: dict[str, Any], require_pass: bool) -> None: + """核验候选版本、审查裁决及阻断级别处于同一证据链中。""" + verdict = state.get("verdict") + if verdict not in {"PASS", "REJECT"}: + raise ValueError(f"任务目录 {run_directory} 的 state.json.verdict 非法;请记录 PASS 或 REJECT。") + if require_pass and verdict != "PASS": + raise ValueError( + f"任务目录 {run_directory} 的审查裁决为 {verdict!r},不是 PASS;请修复问题后重新审查,不能进入 COMMIT_READY。" + ) + candidate_sha = state.get("candidateSha") + reviewed_candidate_sha = state.get("reviewedCandidateSha") + if reviewed_candidate_sha is not None and reviewed_candidate_sha != candidate_sha: + raise ValueError( + f"任务目录 {run_directory} 发生候选版本变化,reviewedCandidateSha 已失效;请重新集成并审查当前 candidateSha。" + ) + validate_candidate_evidence(run_directory, candidate_sha) + report_path = run_directory / "review-report.md" + if not report_path.is_file(): + raise ValueError(f"任务目录 {run_directory} 缺少 review-report.md;请完成 Verify-Reviewer 审查。") + report = report_path.read_text(encoding="utf-8") + reviewed_candidates = re.findall( + r"(?im)^\s*(?:候选\s*SHA|candidate\s*sha)\s*[::]\s*([0-9a-f]{7,64})\s*$", + report, + ) + if len(reviewed_candidates) != 1 or reviewed_candidates[0].lower() != candidate_sha.lower(): + raise ValueError( + f"任务目录 {run_directory} 的 review-report.md 候选 SHA 与当前候选不一致;请重新审查固定候选版本。" + ) + report_verdicts = re.findall(r"(?im)^\s*(?:裁决|verdict)\s*[::]\s*(PASS|REJECT)\s*$", report) + if report_verdicts != [verdict]: + raise ValueError(f"任务目录 {run_directory} 的 review-report.md 裁决与 state.json 不一致;请重新记录审查结论。") + unresolved_findings = find_unresolved_blocking_findings(report) + if verdict == "PASS" and unresolved_findings: + raise ValueError( + f"任务目录 {run_directory} 的 review-report.md 含未解决 {'、'.join(unresolved_findings)};请先修复阻断问题。" + ) + + +def validate_candidate_evidence(run_directory: Path, candidate_sha: object) -> None: + """核验候选提交与候选文件一致,作为验证和审查的共同前置条件。""" + validate_git_sha(run_directory, candidate_sha, "candidateSha") + reject_ledger_symlink(run_directory, "candidate.sha") + candidate_path = run_directory / "candidate.sha" + if not candidate_path.is_file(): + raise ValueError(f"任务目录 {run_directory} 缺少 candidate.sha;请由 Integrator 固定候选提交后再审查。") + if candidate_path.read_text(encoding="utf-8").strip() != candidate_sha: + raise ValueError(f"任务目录 {run_directory} 的 candidate.sha 与 state.json.candidateSha 不一致;请重新集成和审查。") + + +def find_unresolved_blocking_findings(report: str) -> list[str]: + """按每个 P0/P1 的局部子句判断是否仍为提交阻断项。""" + findings: list[str] = [] + finding_pattern = re.compile(r"\b(P[01])\b", flags=re.IGNORECASE) + clause_boundaries = [match.start() for match in re.finditer(r"[,,、;;。\n]", report)] + clause_boundaries.append(len(report)) + clause_start = 0 + for clause_end in clause_boundaries: + clause = report[clause_start:clause_end] + for match in finding_pattern.finditer(clause): + finding_name = match.group(1).upper() + finding_pattern_text = re.escape(finding_name) + is_negated = re.search( + rf"(?:无|没有|no|none)\s*(?:未解决的?\s*)?{finding_pattern_text}\b", + clause, + flags=re.IGNORECASE, + ) + is_resolved = re.search( + rf"\b{finding_pattern_text}\b\s*[::-]?\s*(?:已(?:修复|解决|关闭)|resolved|fixed|closed)\b", + clause, + flags=re.IGNORECASE, + ) + if not is_negated and not is_resolved and finding_name not in findings: + findings.append(finding_name) + clause_start = clause_end + 1 + return findings + + +def main() -> int: + parser = argparse.ArgumentParser(description="验证 MultiWeb Agent 工作流账本") + subparsers = parser.add_subparsers(dest="command", required=True) + validate_parser = subparsers.add_parser("validate") + validate_parser.add_argument("run_directory", type=Path) + transition_parser = subparsers.add_parser("transition") + transition_parser.add_argument("run_directory", type=Path) + transition_parser.add_argument("next_status") + transition_parser.add_argument("--actor", required=True) + transition_parser.add_argument("--evidence", type=Path, required=True) + transition_parser.add_argument("--candidate-sha") + transition_parser.add_argument("--verdict", choices=("PASS", "REJECT")) + resume_parser = subparsers.add_parser("resume") + resume_parser.add_argument("run_directory", type=Path) + arguments = parser.parse_args() + + run_directory = arguments.run_directory + try: + if arguments.command == "validate": + run_directory = arguments.run_directory.resolve() + with workflow_lock(run_directory): + validate_run(run_directory) + print(f"验证通过:{run_directory}") + return 0 + if arguments.command == "transition": + run_directory = arguments.run_directory.resolve() + with workflow_lock(run_directory): + transition_run( + run_directory, + arguments.next_status, + arguments.actor, + arguments.evidence, + arguments.candidate_sha, + arguments.verdict, + ) + print(f"状态迁移成功:{run_directory} -> {arguments.next_status}") + return 0 + if arguments.command == "resume": + run_directory = arguments.run_directory.resolve() + with workflow_lock(run_directory): + status, stop_reason, next_action, resume_target = resume_run(run_directory) + print(f"当前状态: {status}") + print(f"阻断原因: {stop_reason}") + print(f"唯一恢复目标: {resume_target}") + print(f"唯一下一步: {next_action}") + return 0 + except ValueError as error: + print(str(error), file=sys.stderr) + return 1 + except OSError: + print( + f"任务目录 {run_directory} 发生文件系统错误;请检查目录、权限和关键账本文件类型后重试。", + file=sys.stderr, + ) + return 1 + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tests/fixtures/agent-workflow/candidate-changed/candidate.sha b/tools/tests/fixtures/agent-workflow/candidate-changed/candidate.sha new file mode 100644 index 0000000..aa80ef4 --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/candidate-changed/candidate.sha @@ -0,0 +1 @@ +b777d0fba4f598028469ecc00780530555aa7003 diff --git a/tools/tests/fixtures/agent-workflow/candidate-changed/events.jsonl b/tools/tests/fixtures/agent-workflow/candidate-changed/events.jsonl new file mode 100644 index 0000000..6d1f1f4 --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/candidate-changed/events.jsonl @@ -0,0 +1,4 @@ +{"actor":"SUPERVISOR","at":"2026-08-17T06:00:00+08:00","evidence":"task-contract.md","from":"PLANNED","summary":"开始实施","to":"IMPLEMENTING"} +{"actor":"IMPLEMENTER","at":"2026-08-17T07:00:00+08:00","evidence":"handoffs/implementer.md","from":"IMPLEMENTING","summary":"提交交接","to":"READY_TO_INTEGRATE"} +{"actor":"INTEGRATOR","at":"2026-08-17T08:00:00+08:00","evidence":"candidate.sha","from":"READY_TO_INTEGRATE","summary":"固定候选","to":"VALIDATING"} +{"actor":"VERIFY_REVIEWER","at":"2026-08-17T10:00:00+08:00","evidence":"review-report.md","from":"VALIDATING","summary":"审查通过","to":"REVIEWED"} diff --git a/tools/tests/fixtures/agent-workflow/candidate-changed/handoffs/implementer.md b/tools/tests/fixtures/agent-workflow/candidate-changed/handoffs/implementer.md new file mode 100644 index 0000000..173fb16 --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/candidate-changed/handoffs/implementer.md @@ -0,0 +1,3 @@ +# 实施交接 + +交接完成。 diff --git a/tools/tests/fixtures/agent-workflow/candidate-changed/plan.json b/tools/tests/fixtures/agent-workflow/candidate-changed/plan.json new file mode 100644 index 0000000..4591080 --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/candidate-changed/plan.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "tasks": [] +} diff --git a/tools/tests/fixtures/agent-workflow/candidate-changed/review-report.md b/tools/tests/fixtures/agent-workflow/candidate-changed/review-report.md new file mode 100644 index 0000000..a927bb0 --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/candidate-changed/review-report.md @@ -0,0 +1,4 @@ +# 审查报告 + +候选 SHA: b777d0fba4f598028469ecc00780530555aa7003 +裁决: PASS diff --git a/tools/tests/fixtures/agent-workflow/candidate-changed/state.json b/tools/tests/fixtures/agent-workflow/candidate-changed/state.json new file mode 100644 index 0000000..3eaf6f8 --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/candidate-changed/state.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "taskId": "MW-20260817-003", + "baselineSha": "b777d0fba4f598028469ecc00780530555aa7003", + "status": "REVIEWED", + "mode": "FULL", + "currentStage": "REVIEWING", + "attempts": { + "PLANNING": 1, + "IMPLEMENTING": 1, + "VALIDATING": 1, + "REVIEWING": 1 + }, + "candidateSha": "b777d0fba4f598028469ecc00780530555aa7003", + "reviewedCandidateSha": "b777d0fba4f598028469ecc00780530555aa7003", + "verdict": "PASS", + "nextAction": "进入提交准备", + "lastEventAt": "2026-08-17T10:00:00+08:00", + "stopReason": null +} diff --git a/tools/tests/fixtures/agent-workflow/candidate-changed/task-contract.md b/tools/tests/fixtures/agent-workflow/candidate-changed/task-contract.md new file mode 100644 index 0000000..8ca6c4a --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/candidate-changed/task-contract.md @@ -0,0 +1 @@ +# MW-20260817-003 任务契约 diff --git a/tools/tests/fixtures/agent-workflow/paused/events.jsonl b/tools/tests/fixtures/agent-workflow/paused/events.jsonl new file mode 100644 index 0000000..6b5a7b5 --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/paused/events.jsonl @@ -0,0 +1,2 @@ +{"actor":"SUPERVISOR","at":"2026-08-17T09:00:00+08:00","evidence":"task-contract.md","from":"PLANNED","summary":"开始实施","to":"IMPLEMENTING"} +{"actor":"SUPERVISOR","at":"2026-08-17T10:00:00+08:00","evidence":"task-contract.md","from":"IMPLEMENTING","summary":"等待维护者授权","to":"PAUSED"} diff --git a/tools/tests/fixtures/agent-workflow/paused/plan.json b/tools/tests/fixtures/agent-workflow/paused/plan.json new file mode 100644 index 0000000..4591080 --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/paused/plan.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "tasks": [] +} diff --git a/tools/tests/fixtures/agent-workflow/paused/state.json b/tools/tests/fixtures/agent-workflow/paused/state.json new file mode 100644 index 0000000..800956c --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/paused/state.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "taskId": "MW-20260817-004", + "baselineSha": "b777d0fba4f598028469ecc00780530555aa7003", + "status": "PAUSED", + "mode": "FULL", + "currentStage": "IMPLEMENTING", + "attempts": { + "PLANNING": 1, + "IMPLEMENTING": 1, + "VALIDATING": 0, + "REVIEWING": 0 + }, + "candidateSha": null, + "pausedFromStatus": "IMPLEMENTING", + "verdict": null, + "nextAction": "恢复 IMPLEMENTING 阶段并等待维护者授权", + "lastEventAt": "2026-08-17T10:00:00+08:00", + "stopReason": "等待维护者授权" +} diff --git a/tools/tests/fixtures/agent-workflow/paused/task-contract.md b/tools/tests/fixtures/agent-workflow/paused/task-contract.md new file mode 100644 index 0000000..4d464e4 --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/paused/task-contract.md @@ -0,0 +1 @@ +# MW-20260817-004 任务契约 diff --git a/tools/tests/fixtures/agent-workflow/rejected/candidate.sha b/tools/tests/fixtures/agent-workflow/rejected/candidate.sha new file mode 100644 index 0000000..aa80ef4 --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/rejected/candidate.sha @@ -0,0 +1 @@ +b777d0fba4f598028469ecc00780530555aa7003 diff --git a/tools/tests/fixtures/agent-workflow/rejected/events.jsonl b/tools/tests/fixtures/agent-workflow/rejected/events.jsonl new file mode 100644 index 0000000..89e111e --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/rejected/events.jsonl @@ -0,0 +1,4 @@ +{"actor":"SUPERVISOR","at":"2026-08-17T06:00:00+08:00","evidence":"task-contract.md","from":"PLANNED","summary":"开始实施","to":"IMPLEMENTING"} +{"actor":"IMPLEMENTER","at":"2026-08-17T07:00:00+08:00","evidence":"handoffs/implementer.md","from":"IMPLEMENTING","summary":"提交交接","to":"READY_TO_INTEGRATE"} +{"actor":"INTEGRATOR","at":"2026-08-17T08:00:00+08:00","evidence":"candidate.sha","from":"READY_TO_INTEGRATE","summary":"固定候选","to":"VALIDATING"} +{"actor":"VERIFY_REVIEWER","at":"2026-08-17T10:00:00+08:00","evidence":"review-report.md","from":"VALIDATING","summary":"审查拒绝","to":"REVIEWED"} diff --git a/tools/tests/fixtures/agent-workflow/rejected/handoffs/implementer.md b/tools/tests/fixtures/agent-workflow/rejected/handoffs/implementer.md new file mode 100644 index 0000000..173fb16 --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/rejected/handoffs/implementer.md @@ -0,0 +1,3 @@ +# 实施交接 + +交接完成。 diff --git a/tools/tests/fixtures/agent-workflow/rejected/plan.json b/tools/tests/fixtures/agent-workflow/rejected/plan.json new file mode 100644 index 0000000..4591080 --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/rejected/plan.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "tasks": [] +} diff --git a/tools/tests/fixtures/agent-workflow/rejected/review-report.md b/tools/tests/fixtures/agent-workflow/rejected/review-report.md new file mode 100644 index 0000000..9b9b99e --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/rejected/review-report.md @@ -0,0 +1,6 @@ +# 审查报告 + +候选 SHA: b777d0fba4f598028469ecc00780530555aa7003 +裁决: REJECT + +P2: 已记录的兼容性风险。 diff --git a/tools/tests/fixtures/agent-workflow/rejected/state.json b/tools/tests/fixtures/agent-workflow/rejected/state.json new file mode 100644 index 0000000..991c1ae --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/rejected/state.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "taskId": "MW-20260817-002", + "baselineSha": "b777d0fba4f598028469ecc00780530555aa7003", + "status": "REVIEWED", + "mode": "FULL", + "currentStage": "REVIEWING", + "attempts": { + "PLANNING": 1, + "IMPLEMENTING": 1, + "VALIDATING": 1, + "REVIEWING": 1 + }, + "candidateSha": "b777d0fba4f598028469ecc00780530555aa7003", + "verdict": "REJECT", + "nextAction": "退回实施阶段", + "lastEventAt": "2026-08-17T10:00:00+08:00", + "stopReason": "审查拒绝", + "reviewedCandidateSha": "b777d0fba4f598028469ecc00780530555aa7003" +} diff --git a/tools/tests/fixtures/agent-workflow/rejected/task-contract.md b/tools/tests/fixtures/agent-workflow/rejected/task-contract.md new file mode 100644 index 0000000..cb8f17b --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/rejected/task-contract.md @@ -0,0 +1 @@ +# MW-20260817-002 任务契约 diff --git a/tools/tests/fixtures/agent-workflow/valid/plan.json b/tools/tests/fixtures/agent-workflow/valid/plan.json new file mode 100644 index 0000000..4591080 --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/valid/plan.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "tasks": [] +} diff --git a/tools/tests/fixtures/agent-workflow/valid/state.json b/tools/tests/fixtures/agent-workflow/valid/state.json new file mode 100644 index 0000000..ba69a8a --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/valid/state.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "taskId": "MW-20260817-001", + "baselineSha": "b777d0fba4f598028469ecc00780530555aa7003", + "status": "PLANNED", + "mode": "FULL", + "currentStage": "PLANNING", + "attempts": { + "PLANNING": 1, + "IMPLEMENTING": 0, + "VALIDATING": 0, + "REVIEWING": 0 + }, + "candidateSha": null, + "verdict": null, + "nextAction": "创建隔离实施 worktree", + "lastEventAt": "2026-08-17T10:00:00+08:00", + "stopReason": null +} diff --git a/tools/tests/fixtures/agent-workflow/valid/task-contract.md b/tools/tests/fixtures/agent-workflow/valid/task-contract.md new file mode 100644 index 0000000..ffb2017 --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/valid/task-contract.md @@ -0,0 +1,3 @@ +# MW-20260817-001 任务契约 + +最小合法账本样例。 diff --git a/tools/tests/fixtures/agent-workflow/validating/candidate.sha b/tools/tests/fixtures/agent-workflow/validating/candidate.sha new file mode 100644 index 0000000..aa80ef4 --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/validating/candidate.sha @@ -0,0 +1 @@ +b777d0fba4f598028469ecc00780530555aa7003 diff --git a/tools/tests/fixtures/agent-workflow/validating/events.jsonl b/tools/tests/fixtures/agent-workflow/validating/events.jsonl new file mode 100644 index 0000000..6bd6147 --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/validating/events.jsonl @@ -0,0 +1,3 @@ +{"actor":"SUPERVISOR","at":"2026-08-17T06:00:00+08:00","evidence":"task-contract.md","from":"PLANNED","summary":"开始实施","to":"IMPLEMENTING"} +{"actor":"IMPLEMENTER","at":"2026-08-17T07:00:00+08:00","evidence":"handoffs/implementer.md","from":"IMPLEMENTING","summary":"提交交接","to":"READY_TO_INTEGRATE"} +{"actor":"INTEGRATOR","at":"2026-08-17T08:00:00+08:00","evidence":"candidate.sha","from":"READY_TO_INTEGRATE","summary":"固定候选","to":"VALIDATING"} diff --git a/tools/tests/fixtures/agent-workflow/validating/handoffs/implementer.md b/tools/tests/fixtures/agent-workflow/validating/handoffs/implementer.md new file mode 100644 index 0000000..173fb16 --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/validating/handoffs/implementer.md @@ -0,0 +1,3 @@ +# 实施交接 + +交接完成。 diff --git a/tools/tests/fixtures/agent-workflow/validating/plan.json b/tools/tests/fixtures/agent-workflow/validating/plan.json new file mode 100644 index 0000000..4591080 --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/validating/plan.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "tasks": [] +} diff --git a/tools/tests/fixtures/agent-workflow/validating/state.json b/tools/tests/fixtures/agent-workflow/validating/state.json new file mode 100644 index 0000000..26118f2 --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/validating/state.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "taskId": "MW-20260817-005", + "baselineSha": "b777d0fba4f598028469ecc00780530555aa7003", + "status": "VALIDATING", + "mode": "FULL", + "currentStage": "VALIDATING", + "attempts": { + "PLANNING": 1, + "IMPLEMENTING": 1, + "VALIDATING": 1, + "REVIEWING": 0 + }, + "candidateSha": "b777d0fba4f598028469ecc00780530555aa7003", + "verdict": null, + "nextAction": "等待 Verify-Reviewer 审查", + "lastEventAt": "2026-08-17T08:00:00+08:00", + "stopReason": null +} diff --git a/tools/tests/fixtures/agent-workflow/validating/task-contract.md b/tools/tests/fixtures/agent-workflow/validating/task-contract.md new file mode 100644 index 0000000..f12e2a7 --- /dev/null +++ b/tools/tests/fixtures/agent-workflow/validating/task-contract.md @@ -0,0 +1 @@ +# MW-20260817-005 任务契约 diff --git a/tools/tests/test_agent_workflow.py b/tools/tests/test_agent_workflow.py new file mode 100644 index 0000000..ca8e4d6 --- /dev/null +++ b/tools/tests/test_agent_workflow.py @@ -0,0 +1,986 @@ +"""agent_workflow 命令行工具的行为测试。""" + +from __future__ import annotations + +import json +import fcntl +import shutil +import subprocess +import tempfile +import unittest +import os +from datetime import datetime +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +SCRIPT = REPOSITORY_ROOT / "tools" / "agent_workflow.py" +FIXTURE_ROOT = Path(__file__).parent / "fixtures" / "agent-workflow" + + +class AgentWorkflowCliTest(unittest.TestCase): + """通过真实 CLI 验证运行账本的外部行为。""" + + def setUp(self) -> None: + self._temporary_directory = tempfile.TemporaryDirectory( + dir=REPOSITORY_ROOT, + prefix="agent-workflow-test-", + ) + self.workspace = Path(self._temporary_directory.name) + + def tearDown(self) -> None: + self._temporary_directory.cleanup() + + def copy_fixture(self, name: str, destination_name: str | None = None) -> Path: + destination = self.workspace / (destination_name or name) + shutil.copytree(FIXTURE_ROOT / name, destination) + return destination + + def run_cli(self, *arguments: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["python3", str(SCRIPT), *arguments], + cwd=REPOSITORY_ROOT, + capture_output=True, + text=True, + check=False, + ) + + def test_validate_accepts_a_planned_run(self) -> None: + run_directory = self.copy_fixture("valid") + + result = self.run_cli("validate", str(run_directory)) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("验证通过", result.stdout) + + def test_validate_rejects_a_missing_baseline_sha(self) -> None: + run_directory = self.copy_fixture("valid") + state_path = run_directory / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + del state["baselineSha"] + state_path.write_text(json.dumps(state), encoding="utf-8") + + result = self.run_cli("validate", str(run_directory)) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("baselineSha", result.stderr) + self.assertIn("请", result.stderr) + + def test_validate_rejects_an_unresolvable_baseline_sha(self) -> None: + run_directory = self.copy_fixture("valid") + state_path = run_directory / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["baselineSha"] = "deadbeef" + state_path.write_text(json.dumps(state), encoding="utf-8") + + result = self.run_cli("validate", str(run_directory)) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("Git", result.stderr) + + def test_validate_rejects_an_unknown_status(self) -> None: + run_directory = self.copy_fixture("valid") + state_path = run_directory / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["status"] = "UNKNOWN" + state_path.write_text(json.dumps(state), encoding="utf-8") + + result = self.run_cli("validate", str(run_directory)) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("status", result.stderr) + + def test_transition_rejects_an_illegal_status_change(self) -> None: + run_directory = self.copy_fixture("valid") + + result = self.run_cli( + "transition", + str(run_directory), + "PR_READY", + "--actor", + "SUPERVISOR", + "--evidence", + "task-contract.md", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("PLANNED", result.stderr) + self.assertIn("允许", result.stderr) + + def test_transition_records_a_legal_status_chain(self) -> None: + run_directory = self.copy_fixture("valid") + + implementing = self.run_cli( + "transition", + str(run_directory), + "IMPLEMENTING", + "--actor", + "SUPERVISOR", + "--evidence", + "task-contract.md", + ) + handoff_directory = run_directory / "handoffs" + handoff_directory.mkdir() + (handoff_directory / "implementer.md").write_text("完成交接\n", encoding="utf-8") + integrating = self.run_cli( + "transition", + str(run_directory), + "READY_TO_INTEGRATE", + "--actor", + "IMPLEMENTER", + "--evidence", + "handoffs/implementer.md", + ) + + state = json.loads((run_directory / "state.json").read_text(encoding="utf-8")) + events = (run_directory / "events.jsonl").read_text(encoding="utf-8").splitlines() + self.assertEqual(implementing.returncode, 0, implementing.stderr) + self.assertEqual(integrating.returncode, 0, integrating.stderr) + self.assertEqual(state["status"], "READY_TO_INTEGRATE") + self.assertEqual(state["currentStage"], "INTEGRATING") + self.assertEqual(state["attempts"]["IMPLEMENTING"], 1) + self.assertEqual(len(events), 2) + + def test_transition_cli_completes_a_full_passed_chain_without_editing_state(self) -> None: + run_directory = self.copy_fixture("valid") + candidate_sha = "b777d0fba4f598028469ecc00780530555aa7003" + + results = [ + self.run_cli( + "transition", + str(run_directory), + "IMPLEMENTING", + "--actor", + "SUPERVISOR", + "--evidence", + "task-contract.md", + ) + ] + handoff_directory = run_directory / "handoffs" + handoff_directory.mkdir() + (handoff_directory / "implementer.md").write_text("完成交接\n", encoding="utf-8") + results.append( + self.run_cli( + "transition", + str(run_directory), + "READY_TO_INTEGRATE", + "--actor", + "IMPLEMENTER", + "--evidence", + "handoffs/implementer.md", + ) + ) + (run_directory / "candidate.sha").write_text(f"{candidate_sha}\n", encoding="utf-8") + results.append( + self.run_cli( + "transition", + str(run_directory), + "VALIDATING", + "--candidate-sha", + candidate_sha, + "--actor", + "INTEGRATOR", + "--evidence", + "candidate.sha", + ) + ) + (run_directory / "review-report.md").write_text( + f"候选 SHA: {candidate_sha}\n裁决: PASS\n无 P1\n", + encoding="utf-8", + ) + results.append( + self.run_cli( + "transition", + str(run_directory), + "REVIEWED", + "--verdict", + "PASS", + "--actor", + "VERIFY_REVIEWER", + "--evidence", + "review-report.md", + ) + ) + results.append( + self.run_cli( + "transition", + str(run_directory), + "COMMIT_READY", + "--actor", + "SUPERVISOR", + "--evidence", + "review-report.md", + ) + ) + results.append( + self.run_cli( + "transition", + str(run_directory), + "PR_READY", + "--actor", + "SUPERVISOR", + "--evidence", + "review-report.md", + ) + ) + validation = self.run_cli("validate", str(run_directory)) + + for result in results: + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(validation.returncode, 0, validation.stderr) + state = json.loads((run_directory / "state.json").read_text(encoding="utf-8")) + self.assertEqual(state["status"], "PR_READY") + self.assertEqual(state["candidateSha"], candidate_sha) + self.assertEqual(state["verdict"], "PASS") + events = [ + json.loads(line) + for line in (run_directory / "events.jsonl").read_text(encoding="utf-8").splitlines() + ] + event_times = [datetime.fromisoformat(event["at"]) for event in events] + self.assertTrue(all(previous < current for previous, current in zip(event_times, event_times[1:]))) + self.assertEqual(state["lastEventAt"], events[-1]["at"]) + + def test_transition_to_validating_requires_candidate_sha_option(self) -> None: + run_directory = self.copy_fixture("valid") + self.assertEqual( + self.run_cli( + "transition", + str(run_directory), + "IMPLEMENTING", + "--actor", + "SUPERVISOR", + "--evidence", + "task-contract.md", + ).returncode, + 0, + ) + handoff_directory = run_directory / "handoffs" + handoff_directory.mkdir() + (handoff_directory / "implementer.md").write_text("完成交接\n", encoding="utf-8") + self.assertEqual( + self.run_cli( + "transition", + str(run_directory), + "READY_TO_INTEGRATE", + "--actor", + "IMPLEMENTER", + "--evidence", + "handoffs/implementer.md", + ).returncode, + 0, + ) + (run_directory / "candidate.sha").write_text( + "b777d0fba4f598028469ecc00780530555aa7003\n", + encoding="utf-8", + ) + + result = self.run_cli( + "transition", + str(run_directory), + "VALIDATING", + "--actor", + "INTEGRATOR", + "--evidence", + "candidate.sha", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("--candidate-sha", result.stderr) + + def test_transition_to_reviewed_requires_verdict_option(self) -> None: + run_directory = self.copy_fixture("validating") + (run_directory / "review-report.md").write_text( + "候选 SHA: b777d0fba4f598028469ecc00780530555aa7003\n裁决: PASS\n", + encoding="utf-8", + ) + + result = self.run_cli( + "transition", + str(run_directory), + "REVIEWED", + "--actor", + "VERIFY_REVIEWER", + "--evidence", + "review-report.md", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("--verdict", result.stderr) + + def test_transition_rejects_commit_ready_after_a_reject_verdict(self) -> None: + run_directory = self.copy_fixture("rejected") + + result = self.run_cli( + "transition", + str(run_directory), + "COMMIT_READY", + "--actor", + "SUPERVISOR", + "--evidence", + "review-report.md", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("REJECT", result.stderr) + self.assertIn("COMMIT_READY", result.stderr) + + def test_transition_rejects_a_pass_report_with_a_p1_finding(self) -> None: + run_directory = self.copy_fixture("rejected") + state_path = run_directory / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["verdict"] = "PASS" + state_path.write_text(json.dumps(state), encoding="utf-8") + (run_directory / "review-report.md").write_text( + "候选 SHA: b777d0fba4f598028469ecc00780530555aa7003\n裁决: PASS\nP1: 未解决问题\n", + encoding="utf-8", + ) + + result = self.run_cli( + "transition", + str(run_directory), + "COMMIT_READY", + "--actor", + "SUPERVISOR", + "--evidence", + "review-report.md", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("P1", result.stderr) + + def test_validate_rejects_a_review_report_with_a_p1_finding(self) -> None: + run_directory = self.copy_fixture("rejected") + state_path = run_directory / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["verdict"] = "PASS" + state_path.write_text(json.dumps(state), encoding="utf-8") + (run_directory / "review-report.md").write_text( + "候选 SHA: b777d0fba4f598028469ecc00780530555aa7003\n裁决: PASS\nP1: 未解决问题\n", + encoding="utf-8", + ) + + result = self.run_cli("validate", str(run_directory)) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("P1", result.stderr) + + def test_validate_invalidates_a_review_when_its_candidate_changes(self) -> None: + run_directory = self.copy_fixture("candidate-changed") + state_path = run_directory / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["candidateSha"] = "deadbeef" + state_path.write_text(json.dumps(state), encoding="utf-8") + + result = self.run_cli("validate", str(run_directory)) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("候选版本变化", result.stderr) + + def test_resume_reports_the_single_next_step_for_a_paused_run(self) -> None: + run_directory = self.copy_fixture("paused") + before_state = (run_directory / "state.json").read_bytes() + before_events = (run_directory / "events.jsonl").read_bytes() + + result = self.run_cli("resume", str(run_directory)) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("当前状态: PAUSED", result.stdout) + self.assertIn("阻断原因: 等待维护者授权", result.stdout) + self.assertIn("唯一下一步: 恢复 IMPLEMENTING 阶段并等待维护者授权", result.stdout) + self.assertEqual((run_directory / "state.json").read_bytes(), before_state) + self.assertEqual((run_directory / "events.jsonl").read_bytes(), before_events) + + def test_validate_rejects_a_missing_required_state_field(self) -> None: + run_directory = self.copy_fixture("valid") + state_path = run_directory / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + del state["candidateSha"] + state_path.write_text(json.dumps(state), encoding="utf-8") + + result = self.run_cli("validate", str(run_directory)) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("candidateSha", result.stderr) + + def test_validate_rejects_a_regressing_event_history(self) -> None: + run_directory = self.copy_fixture("valid") + events = ( + { + "at": "2026-08-17T09:00:00+08:00", + "actor": "SUPERVISOR", + "from": "PLANNED", + "to": "IMPLEMENTING", + "evidence": "task-contract.md", + "summary": "开始实施", + }, + { + "at": "2026-08-17T10:00:00+08:00", + "actor": "SUPERVISOR", + "from": "IMPLEMENTING", + "to": "PLANNED", + "evidence": "task-contract.md", + "summary": "错误回退", + }, + ) + (run_directory / "events.jsonl").write_text( + "\n".join(json.dumps(event) for event in events) + "\n", + encoding="utf-8", + ) + + result = self.run_cli("validate", str(run_directory)) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("状态倒退", result.stderr) + + def test_validate_rejects_out_of_order_event_times(self) -> None: + run_directory = self.copy_fixture("validating") + events_path = run_directory / "events.jsonl" + events = [json.loads(line) for line in events_path.read_text(encoding="utf-8").splitlines()] + events[-1]["at"] = "2026-08-17T05:00:00+08:00" + events_path.write_text("\n".join(json.dumps(event) for event in events) + "\n", encoding="utf-8") + + result = self.run_cli("validate", str(run_directory)) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("时间", result.stderr) + + def test_validate_rejects_equal_event_times(self) -> None: + run_directory = self.copy_fixture("validating") + events_path = run_directory / "events.jsonl" + events = [json.loads(line) for line in events_path.read_text(encoding="utf-8").splitlines()] + events[1]["at"] = events[0]["at"] + events_path.write_text("\n".join(json.dumps(event) for event in events) + "\n", encoding="utf-8") + + result = self.run_cli("validate", str(run_directory)) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("严格", result.stderr) + + def test_validate_rejects_a_last_event_time_mismatch(self) -> None: + run_directory = self.copy_fixture("validating") + state_path = run_directory / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["lastEventAt"] = "2026-08-17T09:00:00+08:00" + state_path.write_text(json.dumps(state), encoding="utf-8") + + result = self.run_cli("validate", str(run_directory)) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("lastEventAt", result.stderr) + + def test_validate_requires_a_candidate_before_validating(self) -> None: + run_directory = self.copy_fixture("valid") + state_path = run_directory / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["status"] = "VALIDATING" + state["currentStage"] = "VALIDATING" + state_path.write_text(json.dumps(state), encoding="utf-8") + + result = self.run_cli("validate", str(run_directory)) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("candidateSha", result.stderr) + + def test_validate_requires_candidate_evidence_before_validating(self) -> None: + run_directory = self.copy_fixture("validating") + (run_directory / "candidate.sha").unlink() + + result = self.run_cli("validate", str(run_directory)) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("candidate.sha", result.stderr) + + def test_transition_stops_after_two_automatic_attempts(self) -> None: + run_directory = self.copy_fixture("candidate-changed") + state_path = run_directory / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["attempts"]["IMPLEMENTING"] = 2 + state_path.write_text(json.dumps(state), encoding="utf-8") + + result = self.run_cli( + "transition", + str(run_directory), + "IMPLEMENTING", + "--actor", + "SUPERVISOR", + "--evidence", + "review-report.md", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("PAUSED 或 BLOCKED", result.stderr) + + def test_transition_allows_a_fixed_passed_candidate_to_be_commit_ready(self) -> None: + run_directory = self.copy_fixture("candidate-changed") + + transition = self.run_cli( + "transition", + str(run_directory), + "COMMIT_READY", + "--actor", + "SUPERVISOR", + "--evidence", + "review-report.md", + ) + validation = self.run_cli("validate", str(run_directory)) + + state = json.loads((run_directory / "state.json").read_text(encoding="utf-8")) + self.assertEqual(transition.returncode, 0, transition.stderr) + self.assertEqual(validation.returncode, 0, validation.stderr) + self.assertEqual(state["status"], "COMMIT_READY") + + def test_transition_leaves_no_temporary_state_file(self) -> None: + run_directory = self.copy_fixture("valid") + + result = self.run_cli( + "transition", + str(run_directory), + "IMPLEMENTING", + "--actor", + "SUPERVISOR", + "--evidence", + "task-contract.md", + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(list(run_directory.glob(".state.*.tmp")), []) + + def test_transition_rejects_evidence_outside_the_run_directory(self) -> None: + run_directory = self.copy_fixture("valid") + + result = self.run_cli( + "transition", + str(run_directory), + "IMPLEMENTING", + "--actor", + "SUPERVISOR", + "--evidence", + "../../AGENTS.md", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("越界", result.stderr) + + def test_paused_run_can_only_resume_its_interrupted_status(self) -> None: + run_directory = self.copy_fixture("paused") + + resume = self.run_cli("resume", str(run_directory)) + resumed = self.run_cli( + "transition", + str(run_directory), + "IMPLEMENTING", + "--actor", + "SUPERVISOR", + "--evidence", + "task-contract.md", + ) + invalid_directory = self.copy_fixture("paused", "paused-invalid") + invalid = self.run_cli( + "transition", + str(invalid_directory), + "VALIDATING", + "--actor", + "SUPERVISOR", + "--evidence", + "task-contract.md", + ) + + resumed_state = json.loads((run_directory / "state.json").read_text(encoding="utf-8")) + self.assertEqual(resume.returncode, 0, resume.stderr) + self.assertIn("唯一恢复目标: IMPLEMENTING", resume.stdout) + self.assertEqual(resumed.returncode, 0, resumed.stderr) + self.assertEqual(resumed_state["status"], "IMPLEMENTING") + self.assertNotIn("pausedFromStatus", resumed_state) + self.assertNotEqual(invalid.returncode, 0) + self.assertIn("PAUSED", invalid.stderr) + + def test_reject_with_p1_can_be_validated_and_returned_to_implementation(self) -> None: + run_directory = self.copy_fixture("rejected") + (run_directory / "review-report.md").write_text( + "候选 SHA: b777d0fba4f598028469ecc00780530555aa7003\n裁决: REJECT\nP1: 需要返工\n", + encoding="utf-8", + ) + + validation = self.run_cli("validate", str(run_directory)) + transition = self.run_cli( + "transition", + str(run_directory), + "IMPLEMENTING", + "--actor", + "SUPERVISOR", + "--evidence", + "review-report.md", + ) + + self.assertEqual(validation.returncode, 0, validation.stderr) + self.assertEqual(transition.returncode, 0, transition.stderr) + + def test_validate_requires_a_complete_event_chain_after_planning(self) -> None: + missing_events = self.copy_fixture("candidate-changed") + (missing_events / "events.jsonl").unlink() + broken_start = self.copy_fixture("candidate-changed", "candidate-broken-start") + broken_events = (broken_start / "events.jsonl").read_text(encoding="utf-8").splitlines() + broken_first = json.loads(broken_events[0]) + broken_first["from"] = "IMPLEMENTING" + broken_events[0] = json.dumps(broken_first) + (broken_start / "events.jsonl").write_text("\n".join(broken_events) + "\n", encoding="utf-8") + stale_state = self.copy_fixture("candidate-changed", "candidate-stale-state") + stale_path = stale_state / "state.json" + stale = json.loads(stale_path.read_text(encoding="utf-8")) + stale["status"] = "VALIDATING" + stale["currentStage"] = "VALIDATING" + stale["verdict"] = None + stale_path.write_text(json.dumps(stale), encoding="utf-8") + + for run_directory, expected in ( + (missing_events, "events.jsonl"), + (broken_start, "首条"), + (stale_state, "末状态"), + ): + with self.subTest(run_directory=run_directory.name): + result = self.run_cli("validate", str(run_directory)) + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn(expected, result.stderr) + + def test_transition_does_not_change_state_when_event_append_fails(self) -> None: + run_directory = self.copy_fixture("valid") + events_path = run_directory / "events.jsonl" + events_path.write_text("", encoding="utf-8") + events_path.chmod(0o444) + try: + result = self.run_cli( + "transition", + str(run_directory), + "IMPLEMENTING", + "--actor", + "SUPERVISOR", + "--evidence", + "task-contract.md", + ) + finally: + events_path.chmod(0o644) + + state = json.loads((run_directory / "state.json").read_text(encoding="utf-8")) + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("events.jsonl", result.stderr) + self.assertEqual(state["status"], "PLANNED") + + def test_validate_rejects_symbolic_links_for_every_critical_ledger_file(self) -> None: + for index, filename in enumerate( + ( + "state.json", + "task-contract.md", + "plan.json", + "candidate.sha", + "review-report.md", + "events.jsonl", + ".workflow.lock", + ) + ): + with self.subTest(filename=filename): + run_directory = self.copy_fixture("valid", f"symlink-{index}") + ledger_path = run_directory / filename + if ledger_path.exists() or ledger_path.is_symlink(): + ledger_path.unlink() + outside_path = self.workspace / f"outside-{index}" + outside_path.write_text("", encoding="utf-8") + os.symlink(outside_path, ledger_path) + + result = self.run_cli("validate", str(run_directory)) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn(filename, result.stderr) + self.assertIn("符号链接", result.stderr) + + def test_validate_reports_filesystem_io_errors_as_task_errors(self) -> None: + run_directory = self.copy_fixture("valid") + state_path = run_directory / "state.json" + state_path.chmod(0o000) + try: + result = self.run_cli("validate", str(run_directory)) + finally: + state_path.chmod(0o644) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("文件系统错误", result.stderr) + self.assertIn("检查", result.stderr) + self.assertNotIn("Traceback", result.stderr) + + def test_commands_reject_an_active_workflow_lock(self) -> None: + valid_directory = self.copy_fixture("valid") + paused_directory = self.copy_fixture("paused") + lock_path = valid_directory / ".workflow.lock" + with lock_path.open("a+", encoding="utf-8") as lock_file: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + commands = ( + ("validate", str(valid_directory)), + ( + "transition", + str(valid_directory), + "IMPLEMENTING", + "--actor", + "SUPERVISOR", + "--evidence", + "task-contract.md", + ), + ) + for command in commands: + with self.subTest(command=command[0]): + result = self.run_cli(*command) + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(valid_directory), result.stderr) + self.assertIn("租约", result.stderr) + paused_lock_path = paused_directory / ".workflow.lock" + with paused_lock_path.open("a+", encoding="utf-8") as paused_lock_file: + fcntl.flock(paused_lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + resume = self.run_cli("resume", str(paused_directory)) + self.assertNotEqual(resume.returncode, 0) + self.assertIn(str(paused_directory), resume.stderr) + self.assertIn("租约", resume.stderr) + + def test_transition_to_reviewed_requires_target_review_evidence(self) -> None: + run_directory = self.copy_fixture("validating") + (run_directory / "review-report.md").write_text( + "候选 SHA: b777d0fba4f598028469ecc00780530555aa7003\n裁决: PASS\n", + encoding="utf-8", + ) + + result = self.run_cli( + "transition", + str(run_directory), + "REVIEWED", + "--actor", + "VERIFY_REVIEWER", + "--evidence", + "review-report.md", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("verdict", result.stderr) + + def test_pass_report_ignores_explicitly_resolved_p1_text(self) -> None: + run_directory = self.copy_fixture("candidate-changed") + (run_directory / "review-report.md").write_text( + "候选 SHA: b777d0fba4f598028469ecc00780530555aa7003\n裁决: PASS\n无 P1。\nP1 已修复。\n", + encoding="utf-8", + ) + + result = self.run_cli( + "transition", + str(run_directory), + "COMMIT_READY", + "--actor", + "SUPERVISOR", + "--evidence", + "review-report.md", + ) + + self.assertEqual(result.returncode, 0, result.stderr) + + def test_validate_reports_malformed_state_types_as_task_errors(self) -> None: + run_directory = self.copy_fixture("valid") + state_path = run_directory / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["status"] = [] + state_path.write_text(json.dumps(state), encoding="utf-8") + + result = self.run_cli("validate", str(run_directory)) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("status", result.stderr) + self.assertNotIn("Traceback", result.stderr) + + def test_planning_retry_is_limited_when_resuming_a_paused_plan(self) -> None: + run_directory = self.copy_fixture("valid") + + first_pause = self.run_cli( + "transition", + str(run_directory), + "PAUSED", + "--actor", + "SUPERVISOR", + "--evidence", + "task-contract.md", + ) + first_resume = self.run_cli( + "transition", + str(run_directory), + "PLANNED", + "--actor", + "SUPERVISOR", + "--evidence", + "task-contract.md", + ) + second_pause = self.run_cli( + "transition", + str(run_directory), + "PAUSED", + "--actor", + "SUPERVISOR", + "--evidence", + "task-contract.md", + ) + exhausted_resume = self.run_cli( + "transition", + str(run_directory), + "PLANNED", + "--actor", + "SUPERVISOR", + "--evidence", + "task-contract.md", + ) + + state = json.loads((run_directory / "state.json").read_text(encoding="utf-8")) + self.assertEqual(first_pause.returncode, 0, first_pause.stderr) + self.assertEqual(first_resume.returncode, 0, first_resume.stderr) + self.assertEqual(second_pause.returncode, 0, second_pause.stderr) + self.assertNotEqual(exhausted_resume.returncode, 0) + self.assertIn("PLANNING", exhausted_resume.stderr) + self.assertEqual(state["status"], "PAUSED") + + def test_validate_rejects_paused_from_status_outside_paused_state(self) -> None: + run_directory = self.copy_fixture("valid") + state_path = run_directory / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["pausedFromStatus"] = None + state_path.write_text(json.dumps(state), encoding="utf-8") + + result = self.run_cli("validate", str(run_directory)) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("pausedFromStatus", result.stderr) + + def test_rework_can_review_a_new_candidate_after_a_reject(self) -> None: + run_directory = self.copy_fixture("rejected") + candidate_sha = "5ea3b5e18a9d3a31a9125b41b7519e1fa5321047" + return_to_implementation = self.run_cli( + "transition", + str(run_directory), + "IMPLEMENTING", + "--actor", + "SUPERVISOR", + "--evidence", + "review-report.md", + ) + (run_directory / "candidate.sha").write_text(f"{candidate_sha}\n", encoding="utf-8") + (run_directory / "review-report.md").write_text( + f"候选 SHA: {candidate_sha}\n裁决: PASS\n无 P1\n", + encoding="utf-8", + ) + ready_to_integrate = self.run_cli( + "transition", + str(run_directory), + "READY_TO_INTEGRATE", + "--actor", + "IMPLEMENTER", + "--evidence", + "handoffs/implementer.md", + ) + validating = self.run_cli( + "transition", + str(run_directory), + "VALIDATING", + "--candidate-sha", + candidate_sha, + "--actor", + "INTEGRATOR", + "--evidence", + "candidate.sha", + ) + reviewed = self.run_cli( + "transition", + str(run_directory), + "REVIEWED", + "--verdict", + "PASS", + "--actor", + "VERIFY_REVIEWER", + "--evidence", + "review-report.md", + ) + + final_state = json.loads((run_directory / "state.json").read_text(encoding="utf-8")) + self.assertEqual(return_to_implementation.returncode, 0, return_to_implementation.stderr) + self.assertEqual(ready_to_integrate.returncode, 0, ready_to_integrate.stderr) + self.assertEqual(validating.returncode, 0, validating.stderr) + self.assertEqual(reviewed.returncode, 0, reviewed.stderr) + self.assertEqual(final_state["status"], "REVIEWED") + self.assertEqual(final_state["candidateSha"], candidate_sha) + self.assertEqual(final_state["verdict"], "PASS") + self.assertEqual(final_state["reviewedCandidateSha"], candidate_sha) + + def test_resolved_p1_does_not_mask_unresolved_p0_on_the_same_line(self) -> None: + run_directory = self.copy_fixture("candidate-changed") + (run_directory / "review-report.md").write_text( + "候选 SHA: b777d0fba4f598028469ecc00780530555aa7003\n" + "裁决: PASS\n" + "P1 已修复;P0: 未解决\n", + encoding="utf-8", + ) + + result = self.run_cli( + "transition", + str(run_directory), + "COMMIT_READY", + "--actor", + "SUPERVISOR", + "--evidence", + "review-report.md", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("P0", result.stderr) + + def test_resolved_p1_does_not_mask_unresolved_p1_after_a_comma(self) -> None: + run_directory = self.copy_fixture("candidate-changed") + (run_directory / "review-report.md").write_text( + "候选 SHA: b777d0fba4f598028469ecc00780530555aa7003\n" + "裁决: PASS\n" + "P1 已修复,P1:未解决\n", + encoding="utf-8", + ) + + result = self.run_cli( + "transition", + str(run_directory), + "COMMIT_READY", + "--actor", + "SUPERVISOR", + "--evidence", + "review-report.md", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(str(run_directory), result.stderr) + self.assertIn("P1", result.stderr) + + +if __name__ == "__main__": + unittest.main() From 0ad77c57f9b23be60ca1e0c39c6d33ac3841f4bc Mon Sep 17 00:00:00 2001 From: generalio <1487144524@qq.com> Date: Mon, 17 Aug 2026 21:31:12 +0800 Subject: [PATCH 3/7] =?UTF-8?q?docs(workflow):=20=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E4=B8=8E=E4=BA=A4=E6=8E=A5=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 19 ++++++ docs/agent-workflow-task-template.md | 65 +++++++++++++++++++ docs/agent-workflow.md | 59 +++++++++++++++++ ...41\347\214\256\346\214\207\345\215\227.md" | 25 +++++-- 4 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 docs/agent-workflow-task-template.md create mode 100644 docs/agent-workflow.md diff --git a/AGENTS.md b/AGENTS.md index e7a9d12..f52400d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,25 @@ - JCEF 无法按单个浏览器可靠控制的安全配置必须显式拒绝,禁止静默降级为运行时默认行为。 - JS/Wasm 平台只能以浏览器新窗口处理 URL;不得将浏览器全局 Cookie、缓存或 JavaScript 权限伪装为组件可控能力。 +## 多 Agent 协作 + +- 中大型需求必须由 Supervisor 创建任务 ID、任务契约和 `.codex/workflow/runs//` 账本,再依次启动 + Planner、独占 worktree 的 Implementer、Integrator 和固定候选 SHA 的 Verify-Reviewer。没有交接产物、候选 + SHA 或 `PASS` 裁决不得进入下一状态。若当前会话无法启动所需子 Agent,必须记录阻断原因,不得静默降级为单 Agent。 +- Planner 必须记录基线 SHA、依赖、独占文件/模块范围、最小验证命令及公共 API、跨平台和安全影响。只有在契约明确 + `TRIVIAL` 理由和快速路径验证命令时,才可不走完整并行流程。 +- Supervisor 每次状态变化先调用 `tools/agent_workflow.py transition` 写入账本,再执行下一动作;进入 `VALIDATING` + 必须传 `--candidate-sha`,进入 `REVIEWED` 必须传 `--verdict PASS|REJECT`。恢复前先 `validate`,再用 `resume` + 读取唯一下一步。任务目录不得保存凭据、Token 或完整环境变量。 +- 并行实施必须使用相互隔离的 worktree;同一文件或模块同一时刻只能有一个 Implementer 写入。`webview-api`、 + `webview-extension-api`、API 基线、Gradle 设置、发布配置和跨平台契约必须串行处理。 +- Integrator 只能应用已交接的改动并解决不改变语义的机械冲突。接口签名、默认值、导航策略、JS 桥、安全设置或测试 + 预期冲突必须退回原 Implementer 或 Planner,不得在集成阶段自行修复。 +- Verify-Reviewer 只能在干净 worktree 对固定候选 SHA 运行验证和审查,禁止修改候选代码、测试或 API 基线。候选 + SHA 变化后,先前裁决立即失效。P0/P1 必须 `REJECT` 并阻断提交/PR;P2 必须写入风险与兼容性说明。 +- 项目角色配置位于 `.codex/agents/`。角色须先阅读本文件、`docs/agent-workflow.md` 和运行协议;优先级为 + `AGENTS.md`、`docs/agent-workflow.md`、任务交接文档。 + ## 注释与文档 - 关键业务代码、公共 API、枚举项以及公共数据类字段必须使用中文 KDoc 或行注释说明其职责、语义和约束。 diff --git a/docs/agent-workflow-task-template.md b/docs/agent-workflow-task-template.md new file mode 100644 index 0000000..b59664e --- /dev/null +++ b/docs/agent-workflow-task-template.md @@ -0,0 +1,65 @@ +# MultiWeb 多 Agent 任务契约 + +> 未填写字段不得以“默认”或口头说明替代;契约完整并初始化账本后才能进入 `IMPLEMENTING`。 + +## 基本信息 + +- 任务编号: +- 需求来源: +- 目标与非目标: +- 基线提交 SHA: +- 模式:`FULL` / `TRIVIAL`;`TRIVIAL` 理由: +- 账本路径:`.codex/workflow/runs//` +- Planner: + +## 计划与所有权 + +| Agent | 独占文件或模块 | 前置依赖 | 交接产物 | 最小验证 | +| --- | --- | --- | --- | --- | +| | | | | | + +- 禁止修改范围: +- 公共 API:`受影响` / `不受影响`;验证证据: +- Android:`受影响` / `不受影响`;验证证据: +- iOS:`受影响` / `不受影响`;验证证据: +- Desktop:`受影响` / `不受影响`;验证证据: +- JS/Wasm:`受影响` / `不受影响`;验证证据: +- 安全默认值影响:导航 / JS 桥 / Cookie / 文件访问 / 下载 / 无: +- 需退回 Planner/Implementer 的语义冲突: + +## 账本初始化 + +`state.json`: + +```json +{ + "schemaVersion": 1, + "taskId": "MW-YYYYMMDD-001", + "baselineSha": "<40 位 SHA>", + "status": "PLANNED", + "mode": "FULL", + "currentStage": "PLANNING", + "attempts": { "PLANNING": 1, "IMPLEMENTING": 0, "VALIDATING": 0, "REVIEWING": 0 }, + "candidateSha": null, + "verdict": null, + "nextAction": "启动 Implementer", + "lastEventAt": "", + "stopReason": null +} +``` + +`plan.json` 至少包含 `schemaVersion`、`taskId`、`baselineSha`、任务 DAG、独占范围、依赖、验证命令和验收标准。 + +## 验收与验证 + +- 实施阶段命令: +- 候选版本命令: +- 无法执行的运行时范围、原因和替代证据: +- 公共 API 变更时的 `./gradlew apiCheck` 结果: + +## 交接与裁决 + +- 实施 Agent:修改文件、worktree/diff、命令结果、风险: +- Integrator:合并交接项、机械冲突、候选 SHA、`git diff --check`: +- Verify-Reviewer:审查 SHA、命令结果、未验证范围、P0/P1/P2、`PASS` / `REJECT`: +- PR 记录:任务契约路径、候选提交 SHA、账本验证 `PASS`、审查裁决 `PASS` 及平台验证证据: diff --git a/docs/agent-workflow.md b/docs/agent-workflow.md new file mode 100644 index 0000000..785c983 --- /dev/null +++ b/docs/agent-workflow.md @@ -0,0 +1,59 @@ +# MultiWeb 多 Agent 开发工作流 + +## 触发与角色 + +中大型需求默认执行完整工作流。Supervisor 创建任务账本并推进状态;Planner 只读生成契约和 DAG;Implementer 仅在 +独占 worktree 修改契约授权范围;Integrator 只处理机械冲突并固定候选提交;Verify-Reviewer 只读审查固定候选。 +角色配置位于 `.codex/agents/`,不得覆盖用户全局 Codex 配置或扩大父会话权限。 + +Planner 判定 `TRIVIAL` 时必须在任务契约中说明理由、独占范围和快速路径验证命令。不能启动所需角色、发现语义冲突、 +P0/P1、候选 SHA 变化或需要 GitHub/凭据授权时,Supervisor 必须暂停或阻断并记录唯一下一步,不能以单 Agent 静默完成。 + +## 账本与状态 + +每个任务使用 `.codex/workflow/runs//`,只保存公开的状态、相对证据路径和 Git SHA: + +```text +state.json events.jsonl task-contract.md plan.json handoffs/ +candidate.sha review-report.md resume.md +``` + +`state.json` 使用 schemaVersion `1`,包含 `taskId`、`baselineSha`、`status`、`mode`、`currentStage`、`attempts`、 +`candidateSha`、`verdict`、`nextAction`、`lastEventAt` 和 `stopReason`;`PAUSED` 额外记录 `pausedFromStatus`。事件必须 +连续、时间严格递增,末事件时间必须等于 `lastEventAt`。 + +| 状态 | 可进入的下一状态 | +| --- | --- | +| `PLANNED` | `IMPLEMENTING`、`PAUSED` | +| `IMPLEMENTING` | `READY_TO_INTEGRATE`、`PAUSED`、`BLOCKED` | +| `READY_TO_INTEGRATE` | `VALIDATING`、`BLOCKED` | +| `VALIDATING` | `REVIEWED`、`BLOCKED` | +| `REVIEWED` | `COMMIT_READY`、`IMPLEMENTING`、`BLOCKED` | +| `COMMIT_READY` | `PR_READY` | +| `PAUSED` | 中断前状态、`BLOCKED` | + +每个阶段最多自动尝试两次。返工必须从 `REVIEWED` 回到 `IMPLEMENTING`,清除旧审查绑定后重新集成和审查。 + +## 命令与证据 + +```shell +python3 tools/agent_workflow.py validate .codex/workflow/runs/ +python3 tools/agent_workflow.py transition .codex/workflow/runs/ --actor --evidence +python3 tools/agent_workflow.py transition .codex/workflow/runs/ VALIDATING --actor INTEGRATOR --evidence candidate.sha --candidate-sha +python3 tools/agent_workflow.py transition .codex/workflow/runs/ REVIEWED --actor VERIFY_REVIEWER --evidence review-report.md --verdict PASS +python3 tools/agent_workflow.py resume .codex/workflow/runs/ +``` + +验证器只接受任务目录内常规证据文件,拒绝关键账本符号链接。它使用 Unix `fcntl` 非阻塞租约,要求 Python `>=3.11`; +Windows 或不具备 `fcntl` 的环境应记录为验证限制。进入 `COMMIT_READY` 前必须存在同一候选 SHA 的 `candidate.sha`、 +`review-report.md`、`PASS` 及无未解决 P0/P1。 + +## 交接与裁决 + +Implementer 交接必须包含任务/基线、实际修改文件与所有权映射、可应用 diff 或 worktree、命令退出结果、未验证范围, +以及公共 API、平台差异和安全默认值影响。Integrator 必须检查交接范围不重叠并记录 `git diff --check`。 + +Verify-Reviewer 先运行契约的最小验证,再检查公共 API、跨平台一致性、JS 桥、安全默认值和受影响测试。报告必须写入 +候选 SHA、命令与退出结果、未验证范围、P0/P1/P2 和唯一的 `PASS` 或 `REJECT`。P0/P1 必须 `REJECT`;P2 不阻断但 +必须进入 PR 风险说明。PR 正文的工作流记录固定填写“任务契约路径”“候选提交 SHA”“账本验证”和“审查裁决”,后两项 +均为 `PASS`;每个平台和公共 API 使用“受影响/不受影响;验证证据:...”格式,CI 会校验其与契约一致。 diff --git "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" index 0adc041..150ad0f 100644 --- "a/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" +++ "b/docs/\350\264\241\347\214\256\346\214\207\345\215\227.md" @@ -6,7 +6,9 @@ `generalio/fix/android-navigation` 或 `generalio/feature/desktop-download`。类型仅可为 `feature`、`fix`、 `docs`、`chore`、`refactor` 或 `test`。 2. 阅读 [架构说明](架构说明.md)、[开发指南](开发指南.md) 和根目录 `AGENTS.md`。 -3. 先搜索现有 Issue 和代码,避免重复实现或将平台细节加入公共模块。 +3. 中大型需求先按 [多 Agent 开发工作流](agent-workflow.md) 创建任务契约和运行账本;小型需求也要记录其 + `TRIVIAL` 判定理由与验证命令。 +4. 先搜索现有 Issue 和代码,避免重复实现或将平台细节加入公共模块。 ```shell git switch main @@ -32,6 +34,8 @@ git switch -c generalio/fix/android-navigation - 公共 API 有变更时运行 `./gradlew apiCheck`;不要直接修改 API 基线来掩盖兼容性变化。 - 不提交 `build/`、`.gradle/`、JCEF 本地运行时、IDE 工作区、Token、GPG 私钥或本机 `local.properties`。 - 不要让 `webview-test-fixtures` 应用发布插件或生成 publication;它永远不属于发布产物。 +- 多 Agent 并行实施必须使用独立 worktree,且同一文件或模块同一时刻只能有一个 Implementer 写入。公共 API、 + API 基线、Gradle 设置、发布配置和跨平台契约必须先串行冻结。 ## 提交信息 @@ -61,16 +65,23 @@ git status --short ## 改动内容 ## 平台影响 -- Android: -- iOS: -- Desktop: -- JS/Wasm: +- Android:受影响 / 不受影响;验证证据: +- iOS:受影响 / 不受影响;验证证据: +- Desktop:受影响 / 不受影响;验证证据: +- JS/Wasm:受影响 / 不受影响;验证证据: ## 验证命令与结果 ## 风险与兼容性 +- 公共 API:受影响 / 不受影响;验证证据: ## 文档变更 + +## Agent 工作流记录 +- 任务契约路径:`.codex/workflow/runs//task-contract.md` +- 候选提交 SHA: +- 账本验证:`PASS` +- 审查裁决:`PASS` ``` “验证命令与结果”必须记录实际执行的命令、结果和未执行项的原因;“文档变更”必须列出更新的 README、`docs/` @@ -79,6 +90,10 @@ git status --short 若改动涉及安全策略、JS 桥、Cookie、文件访问或外部导航,必须补充默认行为、受影响平台与安全评估。 若无法在本机运行某项真实运行时测试,应在 PR 中说明原因和已执行的替代验证。 +“Agent 工作流记录”必须指向候选版本内的账本契约,候选提交必须是 Integrator 固定的 40 位 SHA;账本验证和审查 +裁决只能在对应 SHA 的 Verify-Reviewer `PASS` 且无 P0/P1 后填写。候选发生变化必须重新审查;PR 治理校验只验证 +记录与证据格式,不能证明子 Agent 曾被启动,也不能替代平台构建与人工安全审查。 + 合并前必须满足:源分支命名通过 `PR 治理校验`、PR 开发记录完整、`构建校验` 通过、所有审查会话已解决, 且至少获得一名维护者批准。PR 创建后如有新的提交,应重新确认 CI 和审查结论。 From 833266e8eb236fa79f704c41a5aa535abe4287ec Mon Sep 17 00:00:00 2001 From: generalio <1487144524@qq.com> Date: Mon, 17 Aug 2026 21:31:12 +0800 Subject: [PATCH 4/7] =?UTF-8?q?ci(workflow):=20=E9=97=A8=E7=A6=81=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E5=80=99=E9=80=89=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/pull_request_template.md | 17 +- .github/workflows/pr-governance.yml | 235 +++++++++++++++++++++++++++- 2 files changed, 246 insertions(+), 6 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2ce8bc3..3b76acd 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -8,10 +8,10 @@ ## 平台影响 -- Android: -- iOS: -- Desktop: -- JS/Wasm: +- Android:受影响 / 不受影响;验证证据: +- iOS:受影响 / 不受影响;验证证据: +- Desktop:受影响 / 不受影响;验证证据: +- JS/Wasm:受影响 / 不受影响;验证证据: ## 验证命令与结果 @@ -21,6 +21,15 @@ +- 公共 API:受影响 / 不受影响;验证证据: + ## 文档变更 + +## Agent 工作流记录 + +- 任务契约路径:`.codex/workflow/runs//task-contract.md` +- 候选提交 SHA:`<40 位 SHA>` +- 账本验证:`PASS` +- 审查裁决:`PASS` diff --git a/.github/workflows/pr-governance.yml b/.github/workflows/pr-governance.yml index 59091b4..026f905 100644 --- a/.github/workflows/pr-governance.yml +++ b/.github/workflows/pr-governance.yml @@ -13,11 +13,10 @@ on: permissions: contents: read - pull-requests: read jobs: governance: - name: 分支与开发记录 + name: 分支、工作流账本与开发记录 runs-on: ubuntu-latest steps: @@ -33,3 +32,235 @@ jobs: `源分支必须匹配 generalio/<类型>/<主题>,当前为:${branch}。允许的类型:feature、fix、docs、chore、refactor、test。`, ); } + + - name: 校验 PR 正文的工作流记录 + id: workflow_record + uses: actions/github-script@v7 + env: + PR_BODY: ${{ github.event.pull_request.body }} + with: + script: | + const body = process.env.PR_BODY ?? ""; + const requiredSections = [ + "目的", + "改动内容", + "平台影响", + "验证命令与结果", + "风险与兼容性", + "文档变更", + "Agent 工作流记录", + ]; + + const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const fail = (message) => core.setFailed(`PR 正文工作流记录无效:${message}`); + const withoutComments = (value) => value.replace(//g, "").trim(); + const withoutInlineCode = (value) => value.replace(/`/g, "").trim(); + const isPlaceholder = (value) => { + const normalized = withoutInlineCode(withoutComments(value)); + return ( + !normalized || + /^(?:TODO|TBD|待填写|<[^>]+>|\[填写[^\]]*\])$/i.test(normalized) + ); + }; + + function section(title) { + const heading = new RegExp(`^##\\s+${escapeRegExp(title)}\\s*$`, "m"); + const match = heading.exec(body); + if (!match) { + fail(`缺少“${title}”章节。`); + return ""; + } + const nextHeading = /^##\s+/m; + const afterHeading = body.slice(match.index + match[0].length); + const nextMatch = nextHeading.exec(afterHeading); + const content = (nextMatch ? afterHeading.slice(0, nextMatch.index) : afterHeading).trim(); + if (isPlaceholder(content)) { + fail(`“${title}”章节为空或仍是模板占位符。`); + } + return content; + } + + for (const title of requiredSections) { + section(title); + } + + function singleRecord(label) { + const record = new RegExp( + `^\\s*-\\s*${escapeRegExp(label)}\\s*[::]\\s*(.+?)\\s*$`, + "gmi", + ); + const matches = [...body.matchAll(record)]; + if (matches.length !== 1 || isPlaceholder(matches[0]?.[1] ?? "")) { + fail(`必须且只能填写一项“${label}”。`); + return ""; + } + return withoutInlineCode(matches[0][1]); + } + + const contractPath = singleRecord("任务契约路径"); + const candidateSha = singleRecord("候选提交 SHA").toLowerCase(); + const ledgerResult = singleRecord("账本验证").toUpperCase(); + const reviewVerdict = singleRecord("审查裁决").toUpperCase(); + const contractMatch = /^\.codex\/workflow\/runs\/([A-Za-z0-9][A-Za-z0-9_.-]{2,127})\/task-contract\.md$/.exec(contractPath); + + if (!contractMatch) { + fail("任务契约路径必须是 `.codex/workflow/runs//task-contract.md`。"); + } + if (!/^[0-9a-f]{40}$/.test(candidateSha)) { + fail("候选提交 SHA 必须是 40 位十六进制提交标识。"); + } + if (ledgerResult !== "PASS") { + fail("账本验证必须为 PASS。"); + } + if (reviewVerdict !== "PASS") { + fail("审查裁决必须为 PASS。"); + } + if (contractMatch && /^[0-9a-f]{40}$/.test(candidateSha) && ledgerResult === "PASS" && reviewVerdict === "PASS") { + core.setOutput("run-directory", `.codex/workflow/runs/${contractMatch[1]}`); + core.setOutput("candidate-sha", candidateSha); + } + + - name: 检出 PR 头提交 + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: 校验 Agent 工作流账本 + env: + RUN_DIRECTORY: ${{ steps.workflow_record.outputs.run-directory }} + run: python3 tools/agent_workflow.py validate "$RUN_DIRECTORY" + + - name: 校验候选提交、审查报告与验证证据 + uses: actions/github-script@v7 + env: + PR_BODY: ${{ github.event.pull_request.body }} + RUN_DIRECTORY: ${{ steps.workflow_record.outputs.run-directory }} + CANDIDATE_SHA: ${{ steps.workflow_record.outputs.candidate-sha }} + with: + script: | + const fs = require("fs"); + const childProcess = require("child_process"); + const body = process.env.PR_BODY ?? ""; + const runDirectory = process.env.RUN_DIRECTORY ?? ""; + const candidateSha = process.env.CANDIDATE_SHA ?? ""; + const fail = (message) => core.setFailed(`PR 工作流证据无效:${message}`); + const withoutComments = (value) => value.replace(//g, "").trim(); + const withoutInlineCode = (value) => value.replace(/`/g, "").trim(); + const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const isPlaceholder = (value) => { + const normalized = withoutInlineCode(withoutComments(value)); + return !normalized || /^(?:TODO|TBD|待填写|<[^>]+>|\[填写[^\]]*\])$/i.test(normalized); + }; + + function singleRecord(text, label, source) { + const record = new RegExp( + `^\\s*-\\s*${escapeRegExp(label)}\\s*[::]\\s*(.+?)\\s*$`, + "gmi", + ); + const matches = [...text.matchAll(record)]; + if (matches.length !== 1 || isPlaceholder(matches[0]?.[1] ?? "")) { + fail(`${source} 必须且只能填写一项“${label}”。`); + return ""; + } + return withoutInlineCode(matches[0][1]); + } + + function validationRecord(text, label, source) { + const record = new RegExp( + `^\\s*-\\s*${escapeRegExp(label)}\\s*[::]\\s*(受影响|不受影响)\\s*[;;]\\s*验证证据\\s*[::]\\s*(.+?)\\s*$`, + "gmi", + ); + const matches = [...text.matchAll(record)]; + if (matches.length !== 1 || isPlaceholder(matches[0]?.[2] ?? "")) { + fail(`${source} 必须按“${label}:受影响/不受影响;验证证据:...”填写一次。`); + return { impact: "", evidence: "" }; + } + return { impact: matches[0][1], evidence: withoutInlineCode(matches[0][2]) }; + } + + function requireEvidence(record, label, source, requirePassingResult) { + const evidence = withoutComments(record.evidence); + const noEvidence = /^(?:不适用|无|不涉及|未执行|未验证|N\/?A|none|not applicable)[。.]?$/i; + if (noEvidence.test(evidence) || evidence === "不受影响") { + fail(`${source} 的“${label}”验证证据不能只是“不适用”或空泛结论。`); + return; + } + if (record.impact === "受影响") { + if (/(?:不适用|不涉及|N\/?A|none|not applicable)(?:$|[,、;;。])/i.test(evidence)) { + fail(`${source} 标记“${label}”受影响时不能填写不适用。`); + } + if (requirePassingResult && !/(?:通过|PASS|passed|success|exit\s*code\s*0|退出码\s*[::]?\s*0)/i.test(evidence)) { + fail(`${source} 标记“${label}”受影响时必须记录实际通过的命令或替代验证结果。`); + } + } + } + + function parseImpactRecords(text, source, requirePassingResult) { + const labels = ["公共 API", "Android", "iOS", "Desktop", "JS/Wasm"]; + const records = new Map(); + for (const label of labels) { + const record = validationRecord(text, label, source); + requireEvidence(record, label, source, requirePassingResult); + records.set(label, record); + } + return records; + } + + let state; + let contract; + let report; + try { + state = JSON.parse(fs.readFileSync(`${runDirectory}/state.json`, "utf8")); + contract = fs.readFileSync(`${runDirectory}/task-contract.md`, "utf8"); + report = fs.readFileSync(`${runDirectory}/review-report.md`, "utf8"); + } catch (error) { + fail(`无法读取账本证据:${error.message}`); + return; + } + + try { + childProcess.execFileSync("git", ["merge-base", "--is-ancestor", candidateSha, "HEAD"], { stdio: "ignore" }); + } catch { + fail("候选提交 SHA 不是当前 PR HEAD 的祖先。"); + } + if (state.status !== "PR_READY" || state.verdict !== "PASS") { + fail("账本必须处于 PR_READY 且 state.json.verdict 为 PASS。"); + } + if (typeof state.candidateSha !== "string" || state.candidateSha.toLowerCase() !== candidateSha) { + fail("state.json.candidateSha 与 PR 正文的候选提交 SHA 不一致。"); + } + + const reviewedCandidates = [...report.matchAll(/^\s*(?:候选\s*SHA|candidate\s*sha)\s*[::]\s*([0-9a-f]{7,64})\s*$/gim)]; + const reportVerdicts = [...report.matchAll(/^\s*(?:裁决|verdict)\s*[::]\s*(PASS|REJECT)\s*$/gim)]; + if (reviewedCandidates.length !== 1 || reviewedCandidates[0][1].toLowerCase() !== candidateSha) { + fail("review-report.md 的候选 SHA 必须与账本和 PR 正文一致。"); + } + if (reportVerdicts.length !== 1 || reportVerdicts[0][1] !== "PASS") { + fail("review-report.md 的裁决必须与账本一致且为 PASS。"); + } + + const unresolvedFindings = new Set(); + for (const clause of report.split(/[,,、;;。\n]/)) { + for (const finding of clause.matchAll(/\b(P[01])\b/gi)) { + const name = finding[1].toUpperCase(); + const escapedName = escapeRegExp(name); + const negated = new RegExp(`(?:无|没有|no|none)\\s*(?:未解决的?\\s*)?${escapedName}\\b`, "i").test(clause); + const resolved = new RegExp(`\\b${escapedName}\\b\\s*[::-]?\\s*(?:已(?:修复|解决|关闭)|resolved|fixed|closed)\\b`, "i").test(clause); + if (!negated && !resolved) { + unresolvedFindings.add(name); + } + } + } + if (unresolvedFindings.size > 0) { + fail(`review-report.md 含未解决 ${[...unresolvedFindings].join("、")},不能进入 PR。`); + } + + const prRecords = parseImpactRecords(body, "PR 正文", true); + const contractRecords = parseImpactRecords(contract, "任务契约", false); + for (const [label, prRecord] of prRecords) { + if (prRecord.impact !== contractRecords.get(label).impact) { + fail(`“${label}”受影响标记必须与任务契约一致。`); + } + } From d11e8b826b4a99b81dc96d2f2cfe25e2f368ce71 Mon Sep 17 00:00:00 2001 From: generalio <1487144524@qq.com> Date: Mon, 17 Aug 2026 21:31:12 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix(desktop):=20=E5=BB=B6=E5=90=8E=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E4=B8=AD=E7=9A=84=E6=B5=8F=E8=A7=88=E5=99=A8=E5=85=B3?= =?UTF-8?q?=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../desktop/DesktopWebViewController.kt | 78 ++++++++++++++++--- .../DesktopWebViewControllerCloseTest.kt | 20 +++++ 2 files changed, 89 insertions(+), 9 deletions(-) diff --git a/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopWebViewController.kt b/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopWebViewController.kt index 5f08238..bd9f314 100644 --- a/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopWebViewController.kt +++ b/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopWebViewController.kt @@ -126,8 +126,11 @@ class DesktopWebViewController( /** 等待 JCEF 与 Swing 均就绪后同步 windowed 原生浏览器的首次布局。 */ private lateinit var initialNativeViewLayoutCoordinator: DesktopInitialNativeViewLayoutCoordinator - /** 已请求 JCEF 创建后才可以请求浏览器的正常关闭路径。 */ - private var isBrowserCreationStarted = false + /** 协调 JCEF 创建确认与释放请求,防止创建回调前提前关闭浏览器。 */ + private val browserCloseLifecycle = DesktopBrowserCloseLifecycle( + closeCreatedBrowser = ::closeBrowser, + disposeUncreatedBrowser = ::disposeUncreatedBrowser, + ) /** 控制器是否已释放。释放后除 [dispose] 外的操作都会抛出 [IllegalStateException]。 */ @Volatile @@ -177,7 +180,7 @@ class DesktopWebViewController( target = nativeViewTarget, isControllerDisposed = { isDisposed }, createBrowser = { - isBrowserCreationStarted = true + browserCloseLifecycle.onBrowserCreationStarted() // JCEF Windowed 浏览器必须由视图绘制后的内部延迟更新创建;该路径会携带当前 macOS 原生窗口句柄。 // 禁止调用 createImmediately(),其无父窗口创建路径会导致首帧直到窗口 resize 才重新绑定并显示。 nativeViewTarget.paintImmediately() @@ -288,11 +291,7 @@ class DesktopWebViewController( activeFileChoosers.forEach(DesktopFileChooserCallbackGuard::cancel) activeFileChoosers.clear() scriptBridgeInstallation.dispose() - if (isBrowserCreationStarted) { - closeBrowser() - } else { - disposeUncreatedBrowser() - } + browserCloseLifecycle.dispose() state = state.copy(isLoading = false) } @@ -366,8 +365,10 @@ class DesktopWebViewController( if (createdBrowser !== browser) { return@invokeLater } + if (!browserCloseLifecycle.onBrowserCreated()) { + return@invokeLater + } if (isDisposed) { - closeBrowser() return@invokeLater } isBrowserReady = true @@ -796,6 +797,65 @@ class DesktopWebViewController( } } +/** + * 协调原生浏览器创建与控制器释放的关闭状态。 + * + * `createBrowser` 只表示 JCEF 已收到创建请求,原生浏览器仍可能尚未完成创建。此时释放控制器必须等待 + * `onAfterCreated`,再通过正常关闭路径等待 `onBeforeClose` 释放客户端并通知宿主。未发起创建请求时则没有 + * `onBeforeClose` 回调,必须立即走未创建浏览器的释放路径。 + * + * 调用方必须在 Swing EDT 串行调用所有方法。 + */ +internal class DesktopBrowserCloseLifecycle( + private val closeCreatedBrowser: () -> Unit, + private val disposeUncreatedBrowser: () -> Unit, +) { + private var isBrowserCreationStarted = false + private var isBrowserCreated = false + private var isDisposed = false + private var isCreatedBrowserCloseRequested = false + + /** 标记 JCEF 创建请求已发出;这不代表原生浏览器已可关闭。 */ + fun onBrowserCreationStarted() { + isBrowserCreationStarted = true + } + + /** + * 标记 JCEF 已确认创建原生浏览器。 + * + * 返回 `true` 仅表示首次确认,调用方据此执行首次创建后的初始化;重复回调不会重复关闭或初始化。 + */ + fun onBrowserCreated(): Boolean { + if (isBrowserCreated) { + return false + } + isBrowserCreated = true + requestCreatedBrowserCloseIfNeeded() + return true + } + + /** 请求释放;创建中的浏览器会延迟到 [onBrowserCreated] 后正常关闭。 */ + fun dispose() { + if (isDisposed) { + return + } + isDisposed = true + if (isBrowserCreationStarted) { + requestCreatedBrowserCloseIfNeeded() + } else { + disposeUncreatedBrowser() + } + } + + private fun requestCreatedBrowserCloseIfNeeded() { + if (!isDisposed || !isBrowserCreated || isCreatedBrowserCloseRequested) { + return + } + isCreatedBrowserCloseRequested = true + closeCreatedBrowser() + } +} + /** 统一执行 JCEF 的非强制浏览器关闭顺序,供桌面控制器及回归测试复用。 */ internal fun closeDesktopBrowser(browser: CefBrowser) { browser.stopLoad() diff --git a/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopWebViewControllerCloseTest.kt b/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopWebViewControllerCloseTest.kt index 530b49c..c02c44c 100644 --- a/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopWebViewControllerCloseTest.kt +++ b/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopWebViewControllerCloseTest.kt @@ -7,6 +7,26 @@ import org.cef.browser.CefBrowser class DesktopWebViewControllerCloseTest { + @Test + fun `创建已请求时释放会等待创建回调后仅关闭一次`() { + val invocations = mutableListOf() + val lifecycle = DesktopBrowserCloseLifecycle( + closeCreatedBrowser = { invocations += "closeCreated" }, + disposeUncreatedBrowser = { invocations += "disposeUncreated" }, + ) + + lifecycle.onBrowserCreationStarted() + lifecycle.dispose() + + assertEquals(emptyList(), invocations) + + lifecycle.onBrowserCreated() + lifecycle.onBrowserCreated() + lifecycle.dispose() + + assertEquals(listOf("closeCreated"), invocations) + } + @Test fun `关闭浏览器会先允许正常关闭而非强制终止`() { val invocations = mutableListOf() From b56d13c3f02b4aaa2866c482f3fa8fe63920dfb0 Mon Sep 17 00:00:00 2001 From: generalio <1487144524@qq.com> Date: Mon, 17 Aug 2026 21:31:12 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix(ios):=20=E6=94=B6=E7=B4=A7=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E9=80=89=E6=8B=A9=E5=9B=9E=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../multiweb/ios/IosFileChooserCoordinator.kt | 95 ++++++++++++ .../multiweb/ios/IosWebViewController.kt | 49 ++----- .../ios/IosFileChooserCoordinatorTest.kt | 135 ++++++++++++++++++ 3 files changed, 243 insertions(+), 36 deletions(-) create mode 100644 webview-ios/src/iosMain/kotlin/io/github/multiweb/ios/IosFileChooserCoordinator.kt create mode 100644 webview-ios/src/iosTest/kotlin/io/github/multiweb/ios/IosFileChooserCoordinatorTest.kt diff --git a/webview-ios/src/iosMain/kotlin/io/github/multiweb/ios/IosFileChooserCoordinator.kt b/webview-ios/src/iosMain/kotlin/io/github/multiweb/ios/IosFileChooserCoordinator.kt new file mode 100644 index 0000000..d5a6e96 --- /dev/null +++ b/webview-ios/src/iosMain/kotlin/io/github/multiweb/ios/IosFileChooserCoordinator.kt @@ -0,0 +1,95 @@ +package io.github.multiweb.ios + +import io.github.multiweb.extension.WebFileChooserRequest +import io.github.multiweb.extension.WebFileChooserResult +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSURL + +/** + * 协调 WebKit 文件面板请求与宿主完成回调。 + * + * WebKit 同时只允许一个未完成的文件面板请求。新请求、释放或无宿主处理器时必须取消旧请求;已被替换请求 + * 的迟到回调不能影响当前请求。该类型不负责线程切换,调用方必须在主线程调用 [start]、[complete] 与 [dispose]。 + */ +@OptIn(ExperimentalForeignApi::class) +internal class IosFileChooserCoordinator { + private var nextRequestId = 0L + private var activeRequest: ActiveRequest? = null + + /** + * 开始一个新的文件选择请求并返回其标识。 + * + * 没有宿主处理器时立即取消新请求,避免 WebKit 继续等待或回退到未受控的系统选择器。 + */ + fun start( + request: WebFileChooserRequest, + hasHandler: Boolean, + onCompleted: (selectedUris: List?) -> Unit, + ): Long { + cancelActive() + val requestId = nextRequestId++ + activeRequest = ActiveRequest(requestId, request, onCompleted) + if (!hasHandler) { + complete(requestId, WebFileChooserResult.Cancelled) + } + return requestId + } + + /** 完成当前请求;没有活动请求时忽略迟到回调。 */ + fun complete(result: WebFileChooserResult) { + activeRequest?.let { request -> + complete(request.id, result) + } + } + + /** 仅在 [requestId] 仍对应活动请求时完成,防止已替换请求的重复或迟到回调越权。 */ + fun complete( + requestId: Long, + result: WebFileChooserResult, + ) { + val request = activeRequest + if (request == null || request.id != requestId) { + return + } + activeRequest = null + request.onCompleted(result.toValidatedUris(request.request)) + } + + /** 释放时显式取消尚未完成的 WebKit 请求。 */ + fun dispose() { + cancelActive() + } + + private fun cancelActive() { + val request = activeRequest ?: return + activeRequest = null + request.onCompleted(null) + } + + /** iOS 仅将无 host 的绝对 `file://` URI 回传给 WebKit,远程或多余文件都会取消整次请求。 */ + private fun WebFileChooserResult.toValidatedUris(request: WebFileChooserRequest): List? { + val selected = this as? WebFileChooserResult.Selected ?: return null + if (!request.allowMultipleSelection && selected.uris.size > 1) { + return null + } + if (selected.uris.any(::isNotAbsoluteLocalFileUri)) { + return null + } + return selected.uris + } + + private fun isNotAbsoluteLocalFileUri(uri: String): Boolean { + val url = NSURL(string = uri) + return !url.isFileURL() || + url.host != null || + url.path?.startsWith("/") != true || + url.query != null || + url.fragment != null + } + + private class ActiveRequest( + val id: Long, + val request: WebFileChooserRequest, + val onCompleted: (selectedUris: List?) -> Unit, + ) +} diff --git a/webview-ios/src/iosMain/kotlin/io/github/multiweb/ios/IosWebViewController.kt b/webview-ios/src/iosMain/kotlin/io/github/multiweb/ios/IosWebViewController.kt index 874661a..33bbb1a 100644 --- a/webview-ios/src/iosMain/kotlin/io/github/multiweb/ios/IosWebViewController.kt +++ b/webview-ios/src/iosMain/kotlin/io/github/multiweb/ios/IosWebViewController.kt @@ -125,8 +125,8 @@ class IosWebViewController( override val stateFlow: StateFlow = mutableState.asStateFlow() - /** 当前尚未完成的 WebKit 文件选择回调;控制器释放时必须显式取消。 */ - private var activeFileChooserCompletion: ((List<*>?) -> Unit)? = null + /** 文件选择器状态仅在主线程更新;实际宿主回调统一回到主线程后交给该协调器。 */ + private val fileChooserCoordinator = IosFileChooserCoordinator() init { checkMainThread() @@ -233,8 +233,7 @@ class IosWebViewController( extensions.filterIsInstance().forEach { extension -> extension.onControllerDisposed() } - activeFileChooserCompletion?.invoke(null) - activeFileChooserCompletion = null + fileChooserCoordinator.dispose() view.stopLoading() view.navigationDelegate = null view.UIDelegate = null @@ -348,20 +347,23 @@ class IosWebViewController( parameters: WKOpenPanelParameters, completionHandler: (List<*>?) -> Unit, ) { - activeFileChooserCompletion?.invoke(null) - activeFileChooserCompletion = completionHandler val request = WebFileChooserRequest( allowMultipleSelection = parameters.allowsMultipleSelection, allowDirectories = parameters.allowsDirectories, ) val handler = fileChooserHandler + val requestId = fileChooserCoordinator.start( + request = request, + hasHandler = handler != null, + ) { selectedUris -> + completionHandler(selectedUris?.map { uri -> NSURL(string = uri) }) + } if (handler == null) { - completeFileChooser(completionHandler, request, WebFileChooserResult.Cancelled) return } fun complete(result: WebFileChooserResult) { - completeFileChooser(completionHandler, request, result) + completeFileChooser(requestId, result) } try { handler.onFileChooserRequested(request, ::complete) @@ -371,39 +373,14 @@ class IosWebViewController( } } - /** 在主线程且仅针对仍活跃的请求回传一次文件选择结果。 */ + /** 将宿主完成结果切回主线程;协调器会拒绝已替换或已释放请求的迟到回调。 */ private fun completeFileChooser( - completionHandler: (List<*>?) -> Unit, - request: WebFileChooserRequest, + requestId: Long, result: WebFileChooserResult, ) { dispatch_async(dispatch_get_main_queue()) { - if (activeFileChooserCompletion !== completionHandler) { - return@dispatch_async - } - activeFileChooserCompletion = null - completionHandler(if (isDisposed) null else result.toIosUrls(request)) - } - } - - /** iOS 仅将绝对 `file://` URI 回传给 WebKit,远程或多余文件都会取消整次请求。 */ - private fun WebFileChooserResult.toIosUrls(request: WebFileChooserRequest): List? { - val selected = this as? WebFileChooserResult.Selected ?: return null - if (!request.allowMultipleSelection && selected.uris.size > 1) { - return null - } - val urls = selected.uris.map { uri -> NSURL(string = uri) } - if ( - urls.any { url -> - !url.isFileURL() || - url.path?.startsWith("/") != true || - url.query != null || - url.fragment != null - } - ) { - return null + fileChooserCoordinator.complete(requestId, result) } - return urls } private fun handlePageStarted() { diff --git a/webview-ios/src/iosTest/kotlin/io/github/multiweb/ios/IosFileChooserCoordinatorTest.kt b/webview-ios/src/iosTest/kotlin/io/github/multiweb/ios/IosFileChooserCoordinatorTest.kt new file mode 100644 index 0000000..499c9e7 --- /dev/null +++ b/webview-ios/src/iosTest/kotlin/io/github/multiweb/ios/IosFileChooserCoordinatorTest.kt @@ -0,0 +1,135 @@ +package io.github.multiweb.ios + +import io.github.multiweb.extension.WebFileChooserRequest +import io.github.multiweb.extension.WebFileChooserResult +import kotlinx.cinterop.ExperimentalForeignApi +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalForeignApi::class) +class IosFileChooserCoordinatorTest { + @Test + fun `缺少宿主处理器时立即取消请求`() { + val results = mutableListOf?>() + val coordinator = IosFileChooserCoordinator() + + coordinator.start(request(), hasHandler = false) { selectedUris -> results += selectedUris } + + assertEquals?>>(listOf(null), results) + } + + @Test + fun `仅接受绝对本地文件地址`() { + val results = mutableListOf?>() + val coordinator = IosFileChooserCoordinator() + + coordinator.start(request(), hasHandler = true) { selectedUris -> results += selectedUris } + coordinator.complete(WebFileChooserResult.Selected(listOf("file:///private/tmp/report.pdf"))) + + assertEquals?>>(listOf(listOf("file:///private/tmp/report.pdf")), results) + } + + @Test + fun `远程或非本地地址取消整个请求`() { + val results = mutableListOf?>() + val coordinator = IosFileChooserCoordinator() + + listOf( + "https://example.com/report.pdf", + "content://documents/report.pdf", + "report.pdf", + ).forEach { uri -> + coordinator.start(request(), hasHandler = true) { selectedUris -> results += selectedUris } + coordinator.complete(WebFileChooserResult.Selected(listOf(uri))) + } + + assertEquals?>>(listOf(null, null, null), results) + } + + @Test + fun `带 host 的文件地址取消整个请求`() { + val results = mutableListOf?>() + val coordinator = IosFileChooserCoordinator() + + listOf( + "file://example.com/private/tmp/report.pdf", + "file://localhost/private/tmp/report.pdf", + ).forEach { uri -> + coordinator.start(request(), hasHandler = true) { selectedUris -> results += selectedUris } + coordinator.complete(WebFileChooserResult.Selected(listOf(uri))) + } + + assertEquals?>>(listOf(null, null), results) + } + + @Test + fun `不允许多选时超过一个文件取消请求`() { + val results = mutableListOf?>() + val coordinator = IosFileChooserCoordinator() + + coordinator.start(request(allowMultipleSelection = false), hasHandler = true) { selectedUris -> + results += selectedUris + } + coordinator.complete( + WebFileChooserResult.Selected( + listOf( + "file:///private/tmp/first.pdf", + "file:///private/tmp/second.pdf", + ), + ), + ) + + assertEquals?>>(listOf(null), results) + } + + @Test + fun `同一请求的重复完成只回传一次`() { + val results = mutableListOf?>() + val coordinator = IosFileChooserCoordinator() + + coordinator.start(request(), hasHandler = true) { selectedUris -> results += selectedUris } + coordinator.complete(WebFileChooserResult.Cancelled) + coordinator.complete(WebFileChooserResult.Selected(listOf("file:///private/tmp/report.pdf"))) + + assertEquals?>>(listOf(null), results) + } + + @Test + fun `后发请求会取消前一个请求且旧完成回调无效`() { + val results = mutableListOf?>() + val coordinator = IosFileChooserCoordinator() + + val first = coordinator.start(request(), hasHandler = true) { selectedUris -> results += selectedUris } + val second = coordinator.start(request(), hasHandler = true) { selectedUris -> results += selectedUris } + coordinator.complete(first, WebFileChooserResult.Selected(listOf("file:///private/tmp/first.pdf"))) + coordinator.complete(second, WebFileChooserResult.Selected(listOf("file:///private/tmp/second.pdf"))) + + assertEquals?>>( + listOf( + null, + listOf("file:///private/tmp/second.pdf"), + ), + results, + ) + } + + @Test + fun `释放时取消仍未完成的请求`() { + val results = mutableListOf?>() + val coordinator = IosFileChooserCoordinator() + + coordinator.start(request(), hasHandler = true) { selectedUris -> results += selectedUris } + coordinator.dispose() + + assertEquals?>>(listOf(null), results) + } + + private fun request( + allowMultipleSelection: Boolean = true, + ): WebFileChooserRequest { + return WebFileChooserRequest( + allowMultipleSelection = allowMultipleSelection, + allowDirectories = false, + ) + } +} From babc139c184a15612f8b1691faccb73bc5131988 Mon Sep 17 00:00:00 2001 From: generalio <1487144524@qq.com> Date: Mon, 17 Aug 2026 21:48:55 +0800 Subject: [PATCH 7/7] =?UTF-8?q?chore(workflow):=20=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E5=80=99=E9=80=89=E5=AE=A1=E6=9F=A5=E8=B4=A6=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../runs/MW-20260817-001/candidate.sha | 1 + .../runs/MW-20260817-001/events.jsonl | 6 ++ .../workflow/runs/MW-20260817-001/plan.json | 73 +++++++++++++++++++ .../workflow/runs/MW-20260817-001/pr-body.md | 43 +++++++++++ .../workflow/runs/MW-20260817-001/resume.md | 7 ++ .../runs/MW-20260817-001/review-report.md | 35 +++++++++ .../workflow/runs/MW-20260817-001/state.json | 20 +++++ .../runs/MW-20260817-001/task-contract.md | 41 +++++++++++ 8 files changed, 226 insertions(+) create mode 100644 .codex/workflow/runs/MW-20260817-001/candidate.sha create mode 100644 .codex/workflow/runs/MW-20260817-001/events.jsonl create mode 100644 .codex/workflow/runs/MW-20260817-001/plan.json create mode 100644 .codex/workflow/runs/MW-20260817-001/pr-body.md create mode 100644 .codex/workflow/runs/MW-20260817-001/resume.md create mode 100644 .codex/workflow/runs/MW-20260817-001/review-report.md create mode 100644 .codex/workflow/runs/MW-20260817-001/state.json create mode 100644 .codex/workflow/runs/MW-20260817-001/task-contract.md diff --git a/.codex/workflow/runs/MW-20260817-001/candidate.sha b/.codex/workflow/runs/MW-20260817-001/candidate.sha new file mode 100644 index 0000000..00ae1d0 --- /dev/null +++ b/.codex/workflow/runs/MW-20260817-001/candidate.sha @@ -0,0 +1 @@ +b56d13c3f02b4aaa2866c482f3fa8fe63920dfb0 diff --git a/.codex/workflow/runs/MW-20260817-001/events.jsonl b/.codex/workflow/runs/MW-20260817-001/events.jsonl new file mode 100644 index 0000000..bb3dd36 --- /dev/null +++ b/.codex/workflow/runs/MW-20260817-001/events.jsonl @@ -0,0 +1,6 @@ +{"actor": "SUPERVISOR", "at": "2026-08-17T13:45:24.094193+00:00", "evidence": "task-contract.md", "from": "PLANNED", "summary": "状态迁移已验证", "to": "IMPLEMENTING"} +{"actor": "INTEGRATOR", "at": "2026-08-17T13:45:24.170369+00:00", "evidence": "candidate.sha", "from": "IMPLEMENTING", "summary": "状态迁移已验证", "to": "READY_TO_INTEGRATE"} +{"actor": "INTEGRATOR", "at": "2026-08-17T13:45:24.255419+00:00", "evidence": "candidate.sha", "from": "READY_TO_INTEGRATE", "summary": "状态迁移已验证", "to": "VALIDATING"} +{"actor": "VERIFY_REVIEWER", "at": "2026-08-17T13:45:24.349908+00:00", "evidence": "review-report.md", "from": "VALIDATING", "summary": "状态迁移已验证", "to": "REVIEWED"} +{"actor": "SUPERVISOR", "at": "2026-08-17T13:45:24.439268+00:00", "evidence": "review-report.md", "from": "REVIEWED", "summary": "状态迁移已验证", "to": "COMMIT_READY"} +{"actor": "SUPERVISOR", "at": "2026-08-17T13:45:24.521050+00:00", "evidence": "pr-body.md", "from": "COMMIT_READY", "summary": "状态迁移已验证", "to": "PR_READY"} diff --git a/.codex/workflow/runs/MW-20260817-001/plan.json b/.codex/workflow/runs/MW-20260817-001/plan.json new file mode 100644 index 0000000..13c267a --- /dev/null +++ b/.codex/workflow/runs/MW-20260817-001/plan.json @@ -0,0 +1,73 @@ +{ + "schemaVersion": 1, + "taskId": "MW-20260817-001", + "baselineSha": "645e0bfe043b17a3db22cfaef080914e9e1fe6d8", + "tasks": [ + { + "id": "workflow-framework", + "owner": "WORKFLOW_IMPLEMENTERS", + "scope": [ + ".codex/agents/", + ".codex/workflow/", + "tools/agent_workflow.py", + "tools/tests/", + "AGENTS.md", + "docs/agent-workflow.md", + "docs/agent-workflow-task-template.md", + ".github/pull_request_template.md", + ".github/workflows/pr-governance.yml" + ], + "dependsOn": [], + "verification": [ + "PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tools.tests.test_agent_workflow -v", + "git diff --check 645e0bf...b56d13c" + ] + }, + { + "id": "desktop-close-race", + "owner": "DESKTOP_IMPLEMENTER", + "scope": [ + "webview-desktop/src/main/", + "webview-desktop/src/test/" + ], + "dependsOn": [], + "verification": [ + "./gradlew :webview-desktop:test --no-daemon" + ] + }, + { + "id": "ios-file-chooser-boundary", + "owner": "IOS_IMPLEMENTER", + "scope": [ + "webview-ios/src/iosMain/", + "webview-ios/src/iosTest/" + ], + "dependsOn": [], + "verification": [ + "./gradlew :webview-ios:compileTestKotlinIosSimulatorArm64 --no-daemon" + ] + }, + { + "id": "fixed-candidate-review", + "owner": "VERIFY_REVIEWER", + "scope": [ + "固定候选 b56d13c3f02b4aaa2866c482f3fa8fe63920dfb0" + ], + "dependsOn": [ + "workflow-framework", + "desktop-close-race", + "ios-file-chooser-boundary" + ], + "verification": [ + "./gradlew apiCheck --no-daemon", + "./gradlew :webview-android:assembleRelease --no-daemon", + "./gradlew :sample-compose:compileKotlinJs :sample-compose:compileKotlinWasmJs --no-daemon" + ] + } + ], + "acceptanceCriteria": [ + "固定候选通过 Verify-Reviewer 审查且无 P0/P1。", + "候选提交、审查报告和状态账本形成可验证证据链。", + "PR 正文包含全部平台和公共 API 的验证证据。" + ] +} diff --git a/.codex/workflow/runs/MW-20260817-001/pr-body.md b/.codex/workflow/runs/MW-20260817-001/pr-body.md new file mode 100644 index 0000000..22630c8 --- /dev/null +++ b/.codex/workflow/runs/MW-20260817-001/pr-body.md @@ -0,0 +1,43 @@ +## 目的 + +建立可恢复、可验证的 MultiWeb Agent 工作流,并修复 Desktop 浏览器创建前关闭竞态与 iOS 文件选择器 URI 安全边界。 + +## 改动内容 + +- 增加项目级角色配置、状态账本验证器、运行样例和恢复文档。 +- 将候选 SHA、审查报告、平台验证记录接入 PR 治理门禁。 +- 将既有扩展与生命周期接口成员改为默认空实现,并同步 API 基线,以保持既有实现方兼容。 +- 在 Desktop 创建已发起时延后关闭到 `onAfterCreated`;iOS 仅接受无 host、query、fragment 的绝对 `file://` URI。 + +## 平台影响 + +- 公共 API:受影响;验证证据:`./gradlew apiCheck --no-daemon` 通过。 +- Android:受影响;验证证据:`./gradlew :webview-android:assembleRelease --no-daemon` 通过。 +- iOS:受影响;验证证据:`./gradlew :webview-ios:compileTestKotlinIosSimulatorArm64 --no-daemon` 通过。 +- Desktop:受影响;验证证据:`./gradlew :webview-desktop:test --no-daemon` 通过。 +- JS/Wasm:受影响;验证证据:`./gradlew :webview-browser:jsBrowserTest :webview-browser:wasmJsBrowserTest --no-daemon` 的 JS 与 Wasm ChromeHeadless 报告各 3/3 通过。 + +## 验证命令与结果 + +- `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tools.tests.test_agent_workflow -v`:40/40 通过。 +- `git diff --check 645e0bf...b56d13c`:通过。 +- 已通过 `apiCheck`、Android Release、Desktop 单测、iOS 测试编译、Compose JS/Wasm 编译,以及 JS/Wasm ChromeHeadless 报告各 3/3。 +- iOS 文件面板和 Desktop 真实窗口运行时测试受本机工具条件限制,详见风险与兼容性。 + +## 风险与兼容性 + +- API 兼容性:Kotlin 2.4.10 的基线表示变化尚未做旧版二进制扩展消费者链接回归;公共 API 的规范验证记录见“平台影响”。 +- 安全:未放宽 JS 桥、Cookie、文件访问、下载或外部导航默认权限;iOS 文件选择器仅收紧 URI 接受范围。 +- 平台:iOS 18.4+ 文件面板需要在 Xcode 模拟器或真机确认;Desktop JCEF/Compose 首帧与关闭竞态需要真实窗口验收;账本 CLI 仅支持 Python 3.11+ 的 Unix 环境。 +- JS:最终干净审查的浏览器重跑被 Yarn network mutex 阻塞,但集成候选的 JS 与 Wasm ChromeHeadless 报告各 3/3 通过。 + +## 文档变更 + +- 新增 Agent 工作流、账本和任务契约文档,更新贡献指南与 PR 模板。 + +## Agent 工作流记录 + +- 任务契约路径:`.codex/workflow/runs/MW-20260817-001/task-contract.md` +- 候选提交 SHA:`b56d13c3f02b4aaa2866c482f3fa8fe63920dfb0` +- 账本验证:`PASS` +- 审查裁决:`PASS` diff --git a/.codex/workflow/runs/MW-20260817-001/resume.md b/.codex/workflow/runs/MW-20260817-001/resume.md new file mode 100644 index 0000000..cc2d5f2 --- /dev/null +++ b/.codex/workflow/runs/MW-20260817-001/resume.md @@ -0,0 +1,7 @@ +# 恢复上下文 + +- 任务:`MW-20260817-001`。 +- 基线:`645e0bfe043b17a3db22cfaef080914e9e1fe6d8`。 +- 固定候选:`b56d13c3f02b4aaa2866c482f3fa8fe63920dfb0`。 +- 当前阶段:`PR_READY`。 +- 下一步:维护者将本地分支推送后,使用同目录 `pr-body.md` 创建远程 PR;当前流程不执行 `git push`。 diff --git a/.codex/workflow/runs/MW-20260817-001/review-report.md b/.codex/workflow/runs/MW-20260817-001/review-report.md new file mode 100644 index 0000000..a6de825 --- /dev/null +++ b/.codex/workflow/runs/MW-20260817-001/review-report.md @@ -0,0 +1,35 @@ +# 固定候选审查报告 + +候选 SHA: b56d13c3f02b4aaa2866c482f3fa8fe63920dfb0 +基线 SHA: 645e0bfe043b17a3db22cfaef080914e9e1fe6d8 + +## 验证结果 + +- `git diff --check 645e0bf...b56d13c`:通过。 +- `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tools.tests.test_agent_workflow -v`:40/40 通过。 +- `./gradlew :webview-desktop:test --no-daemon`:通过。 +- `./gradlew :webview-ios:compileTestKotlinIosSimulatorArm64 --no-daemon`:通过。 +- `./gradlew apiCheck --no-daemon`:通过。 +- `./gradlew :webview-android:assembleRelease --no-daemon`:通过。 +- `./gradlew :sample-compose:compileKotlinJs :sample-compose:compileKotlinWasmJs --no-daemon`:通过。 +- `./gradlew :webview-browser:jsBrowserTest :webview-browser:wasmJsBrowserTest --no-daemon`:集成候选工作目录的 JS 与 Wasm ChromeHeadless 报告各 3/3 通过。 + +## 审查结论 + +- 公共 API:`apiCheck` 通过。既有扩展与生命周期接口的抽象成员变为默认空实现,未新增既有实现方必须实现的抽象成员;API 基线已同步。 +- JS 桥与安全默认值:候选没有放宽 JavaScript、Cookie、文件访问或浏览器全局权限。iOS 文件选择器拒绝带 host、query 或 fragment 的 URI,并在销毁或迟到回调时取消请求。 +- Desktop:创建已发起时等待 `onAfterCreated` 后走正常关闭路径,覆盖创建前 `dispose()` 的关闭竞态。 +- CI 与账本:候选 SHA、审查报告和 PR 正文会由门禁交叉校验,候选必须是 PR HEAD 的祖先。 + +无 P0。 +无 P1。 + +## P2 与未验证范围 + +- P2:iOS 18.4+ `WKUIDelegate` 文件面板仍需在安装 Xcode 的模拟器或真机运行时验证。 +- P2:Desktop JCEF/Compose 真实窗口的首帧和退出竞态尚未进行运行时验收。 +- P2:Kotlin 2.4.10 API 基线表示变化尚未执行旧版二进制扩展消费者链接回归。 +- P2:账本 CLI 依赖 Python 3.11+ 和 Unix `fcntl`,Windows 环境不适用。 +- P2:最终干净审查中的 JS 浏览器重跑受共享 Yarn network mutex 阻塞;集成候选已有 JS 与 Wasm 各 3/3 的通过报告。 + +裁决: PASS diff --git a/.codex/workflow/runs/MW-20260817-001/state.json b/.codex/workflow/runs/MW-20260817-001/state.json new file mode 100644 index 0000000..a7bcf3b --- /dev/null +++ b/.codex/workflow/runs/MW-20260817-001/state.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "taskId": "MW-20260817-001", + "baselineSha": "645e0bfe043b17a3db22cfaef080914e9e1fe6d8", + "status": "PR_READY", + "mode": "FULL", + "currentStage": "PR", + "attempts": { + "PLANNING": 1, + "IMPLEMENTING": 1, + "VALIDATING": 1, + "REVIEWING": 1 + }, + "candidateSha": "b56d13c3f02b4aaa2866c482f3fa8fe63920dfb0", + "verdict": "PASS", + "nextAction": "维护者推送当前分支后,使用 pr-body.md 创建远程 PR", + "lastEventAt": "2026-08-17T13:45:24.521050+00:00", + "stopReason": null, + "reviewedCandidateSha": "b56d13c3f02b4aaa2866c482f3fa8fe63920dfb0" +} diff --git a/.codex/workflow/runs/MW-20260817-001/task-contract.md b/.codex/workflow/runs/MW-20260817-001/task-contract.md new file mode 100644 index 0000000..4df0d6c --- /dev/null +++ b/.codex/workflow/runs/MW-20260817-001/task-contract.md @@ -0,0 +1,41 @@ +# MW-20260817-001 任务契约 + +## 基本信息 + +- 需求来源:`docs/agent-workflow-autonomous-handoff.md`。 +- 目标:交付可恢复、可验证的 MultiWeb Agent 工作流,并修复审查发现的 Desktop 关闭竞态和 iOS 文件选择器安全边界。 +- 非目标:不推送分支、不创建远程 PR、不修改产品功能以外的行为。 +- 基线提交 SHA:`645e0bfe043b17a3db22cfaef080914e9e1fe6d8`。 +- 模式:`FULL`;理由:涉及公共 API 基线、CI 门禁、多个平台实现与安全默认值。 +- 账本路径:`.codex/workflow/runs/MW-20260817-001/`。 +- Planner:`workflow-planner`。 + +## 计划与所有权 + +| Agent | 独占范围 | 前置依赖 | 交接产物 | 最小验证 | +| --- | --- | --- | --- | --- | +| Workflow Implementers | `.codex/agents/`、`.codex/workflow/`、`tools/`、工作流文档与 PR 门禁 | 无 | 六个候选提交中的工作流部分 | Python 账本测试、YAML 与门禁正反例 | +| Desktop Implementer | `webview-desktop` | 无 | `d11e8b8` | `:webview-desktop:test` | +| iOS Implementer | `webview-ios` | 无 | `b56d13c` | `:webview-ios:compileTestKotlinIosSimulatorArm64` | +| Verify-Reviewer | 固定候选,只读 | 全部实施交接 | `review-report.md` | 契约命令与跨平台审查 | + +- 禁止修改范围:主工作区既有未提交文件、远程仓库与发布配置。 +- 公共 API:受影响;验证证据:`./gradlew apiCheck --no-daemon` 通过。 +- Android:受影响;验证证据:`./gradlew :webview-android:assembleRelease --no-daemon` 通过。 +- iOS:受影响;验证证据:`./gradlew :webview-ios:compileTestKotlinIosSimulatorArm64 --no-daemon` 通过。 +- Desktop:受影响;验证证据:`./gradlew :webview-desktop:test --no-daemon` 通过。 +- JS/Wasm:受影响;验证证据:`./gradlew :webview-browser:jsBrowserTest :webview-browser:wasmJsBrowserTest --no-daemon` 的 JS 与 Wasm ChromeHeadless 报告各 3/3 通过。 +- 安全默认值影响:iOS 文件选择器仅接受绝对、无 host、无 query/fragment 的 `file://` URI;未放宽 JS 桥、Cookie、文件访问或下载默认权限。 +- 需退回 Planner/Implementer 的语义冲突:无。 + +## 验收与验证 + +- 候选 SHA:`b56d13c3f02b4aaa2866c482f3fa8fe63920dfb0`。 +- 实施与候选验证:Python 账本单测 40/40、Desktop 单测、iOS 测试编译、`apiCheck`、Android Release、Compose JS/Wasm 编译均通过。 +- 无法执行的运行时范围:iOS 18.4+ 文件面板需要带 Xcode 的模拟器或真机;Desktop JCEF/Compose 首帧和退出竞态需要真实窗口;最终复审的 JS 浏览器重跑受 Yarn network mutex 阻塞。 + +## 审查与 PR + +- Integrator:固定 `b56d13c3f02b4aaa2866c482f3fa8fe63920dfb0`,并通过 `git diff --check 645e0bf...b56d13c`。 +- Verify-Reviewer:裁决 `PASS`,详见 `review-report.md`。 +- PR 记录:使用 `pr-body.md`;候选 SHA、账本验证与审查裁决均在其中固定填写。