diff --git a/docs/implementation-gap-analysis.md b/docs/implementation-gap-analysis.md new file mode 100644 index 0000000..5469672 --- /dev/null +++ b/docs/implementation-gap-analysis.md @@ -0,0 +1,135 @@ +# 实现现状 vs 目标/BRD/PRD/SDD 差距分析 + +**日期**: 2026-08-03 +**性质**: 现状快照,回应用户 "现在整个系统的实现和我们的文档的 goal 和 brd prd sdd 还有哪些差距"。基于对 `git log`、 +`docs/development-plan.md`(含各 Unit 事后补记)、`docs/build-report.md`、`docs/sdd/06-验收与测试锚点.md` 和实际 +代码(`src/`、`supervisor/`)的交叉核对得出,不是重新跑一遍验收。 +**结论先说**: 45 个 Unit(U-001~U-043 + U-004.5/U-024.5)全部有对应实现和提交,`docs/build-report.md` 的 +"全部完成"判断在"每个 Unit 都有代码落地"这个意义上是对的。但"全部完成"不等于"和文档设定的目标完全对齐"—— +差距集中在 PH5 的"自动"两个字还没完全兑现、系统还不是真正自驱动的、以及若干 SDD 契约的已知简化。逐条见下。 +**如何维护本文档**: 这是一次性快照,会随实现推进而过时。发现某条已经补上或又出现新缺口时,直接编辑对应条目 +(改状态、补日期),不要另开一份新文件——避免重蹈 `development-plan.md`/`unit_matrix.json`/`build-report.md` +互相之间已经出现的"哪份是最新的"混乱(见第 4 节)。 + +--- + +## 1. 目标层面:PH5 的"自动"闭环从未真正走完 + +- REQ-15(Deploy Supervisor 蓝绿部署 + 自动回滚)的 happy path 只验证到**真实 cutover**,从没有一次交互式压测 + 真正等到 `finalized`——生产参数是 24 小时或 10 个真实任务的监控窗口,本来就不该在一次交互会话里等到。 +- 更关键:**Deploy Supervisor 真正"自动"的那部分——持续轮询"有没有新的 self-target 合并等待部署"——从未 + 实现**。`supervisor/deploy_supervisor.py`(U-036)只管进程生命周期(start/stop/status/PID),`poll_fn` 是留 + 给未来的钩子;`supervisor/self_hosting_e2e.py`(U-042)只是验收脚本的一次性编排(人工触发一条 Goal→...→ + cutover 的直线调用),不是常驻发现循环。PH5 的目标"自主修改自己代码后自动安全部署",目前是"能做到"而不是 + "会自己做"。 +- 回滚路径 B(监控期异常检测触发)只在 mock 测试里验证过分支逻辑,从未在真实生产数据下真正触发过。 +- **(2026-08-03 自举可行性分析中新发现)部署缺互斥锁**:`supervisor/deploy_stage.stage_deployment()` 只检查 + `_last_finalized_deployment()`(上一次成功落地的部署是什么),**不检查当前是否已有一个进行中 + (`staged`/`candidate_starting`/…未到终态)的部署**。如果用调度器并发派发多个 self-target 任务、且多个都 + 想 cutover,会互相踩——这不是原来清单里的条目,是分析"能否用调度器批量跑 gap 修复"时才暴露出来的新缺口, + 建议在真正启用并发自举之前先补上。 + +## 2. "熄灯"叙事本身:没有常驻调度入口 + +`src/scheduler.py`(U-032)从未被 `backend_server.py` 或任何生产入口调用——`backend_server.py` 里只有一句注 +释提到"调度器以后会长在这里"(grep 确认,无实际调用)。系统目前不存在一个真正常驻、自动扫描 `queued` 任务 +并派发的进程;每一次"跑起来"都是脚本/测试手动调用 `run_scheduler_once`/`run_goal_to_completion`。这和"Level +5 自主研发系统"的叙事有实质差距——机制都齐了,但缺"自己跑起来"这一层。 + +## 3. SDD 契约与实现之间已知的简化/偏差 + +- **Skill 内容从未真正迁移**:`.claude/agents/*.md` 的 `skills:` frontmatter 只是引用名字(如 + `code-task-development`),仓库里没有 `skills//` 真实文件,测试靠 seed 占位内容绕过—— + `src/skill_update.py` 自己的注释也承认这点。 +- **Coder 阶段没有真正的多模块 Work Graph 规划**:`c_work_graph_plan` 目前只服务 Planner 的固定四节点线性图 + (brd→prd→sdd→benchmark);Coder 永远走一个合成的单节点 manifest,"规划一个真正的多文件代码变更图"这个能 + 力从未被要求也没实现。 +- **✅ 已修复(2026-08-03)普通 target project 从未部署过 Agent 定义**: + `agent_profile.deploy_agent_definitions()` 存在,但没有任何生产代码路径调用它——`project_scaffold.create_project()` + 建 project 时不会调,`org_node_trigger.py` 自己的注释也如实写着"this function does not deploy it"。self-target + 场景"恰好"没事,是因为 `.claude/agents/*.md` 本来就是 graphlemon 自己仓库里已提交的文件;但任何**新建的普通 + target project**,第一次触发 Planner 时 `claude --agent planner` 会因为 cwd 下找不到 agent 定义而非零退出, + 抛出 `OrgNodeTriggerError`——这个异常不在 `scheduler._ROUTABLE_EXCEPTIONS` 里,被兜底成笼统的 + `blocked_reason='execution_error'`,真实报错内容不会被持久化,控制台上完全看不出原因。真实复现:项目 + `LemonGraphFront`(`target_folder_path=.../graphlemon/frontend/`)的任务从 `queued` 直接跳到 + `blocked_pending_human`,零 artifact 产出。 + **修复**:`project_scaffold.create_project()` 现在会在 `.graphlemon/` 初始化提交时一并调用 + `agent_profile.deploy_agent_definitions()` 并 commit(同一个 commit,没有多引入一次提交,也顺带解决了下面 + worktree 隔离那条 gap);`scheduler._block_task()`/`task_execution._mark_blocked()` 新增 `detail` 参数,把真实 + 异常信息写进 `execution_event.payload`,不再是不可诊断的裸字符串(`blocked_reason` 本身值不变, + `budget_guardrail` 等地方靠精确匹配它的值,没有改)。新增/扩展了 3 处回归测试 + (`test_project_scaffold.py`/`test_scheduler.py`/`test_task_execution.py`),`src/` 全量 498 个测试 + `supervisor/` + 123 个测试全绿。**真实验证**:给 `LemonGraphFront` 项目手动补部署+提交 agent 定义后,`task_id=7400d876-…` 走完 + 了完整闭环——BRD/PRD/SDD/Benchmark/code_diff/test_report 六个 artifact 全部 `validation_status=passed`,中途 + Validator 第一轮 FAIL 还真实触发了一次 Org-level Loop 的 `full_replan` 路由、重新规划后第二轮通过,最终 + `task.status=done`。是通过这次交互式会话直接改代码修的,**不是**走 graphlemon 自己的 Planner→Coder→Validator + 自举出来的——对应第 6 节任务清单顺序 1(原描述是"补 commit",实际根因和修复范围比原描述更大,一并记在这里)。 +- **普通 target project 的并行 worktree 隔离没被真正踩过**:`needs_worktree` 目前只有 self-target 场景被 + `work_graph_orchestrate.py` 强制打开;给普通项目手动开启时,原先 `agent_profile.deploy_agent_definitions()` 只 + 写 `.claude/agents/*.md` 文件、不 commit,而 `git worktree add` 只认已提交内容——这条路径理论上会失败。 + **随上面那条一起修复**:`create_project()` 现在会把部署的 agent 定义提交,新建项目从一开始就有已提交的 + `.claude/agents/*.md`,这个阻塞点已解除;但"手动对一个已存在、之前没有 agent 定义的项目开启 worktree"这种 + 场景仍未专门测试过,不算完全清零。 +- `v_artifact_browser`(U-028)有意不做 Markdown 渲染/代码高亮(02-V 原型期望有),因为要避免引入第一个前端 + 第三方库,判断为有意简化而非漏做。 + +## 4. 文档本身滞后于实现(这本身就是一种"差距") + +- `development-plan.md`/`unit_matrix.json` 从写就没有随实现更新"done"状态,只能靠 `git log` 和散落在各 Unit + 条目下的事后补记("实现补充"/"真实 bug")判断真实进度。 +- `docs/build-report.md` 里"⚠️ 尚未真实执行的一项"那一段(称 `RUN_REAL_SELF_HOSTING_E2E` 从未跑过)本身也已 + 经过时——`development-plan.md` U-042 条目下 2026-08-03 的记录显示之后确实做了 13 次真实自举压测(`git log` + 里那一长串 `graphlemon: auto-commit`/`merge`/artifact 提交就是证据),只是仍未到达 `finalized`。连"总结性 + 收口文档"都会在自举持续运行的过程中被甩在后面——这是个会持续放大的风险,不是一次性的疏漏。 + +## 5. 验证方法上的缺口 + +- 几乎所有 PH3 前端 Unit(U-022~U-030)的验证方式都写着"浏览器扩展本次会话不可用",靠 `curl`/Node 原生 + WebSocket/`node --check` 语法检查代替,**没有一次是真人在浏览器里点击验证过的**——功能逻辑上大概率没问 + 题,但这是个反复出现、被如实记录但从未补上的验证缺口。 +- `supervisor-auto-rollback-design.md` 里健康检查超时、监控窗口时长等参数明确标注"工程经验值,需要实测校 + 准",还没有真实生产数据校准过(PH5 已知交叉缺口第 4 条,`development-plan.md` 第 422-427 行)。 + +--- + +## 6. 用 graphlemon 自举修复以上 gap 的可行性,及任务拆解清单 + +**结论**:机制上可行且已被验证过(13 次真实自举压测已经走通 Planner→Coder→Validator→Org-level Loop→Deploy +Supervisor 全链路),但不能整体当成"黑灯工厂"一次性丢给它——原因就是第 2 节本身:没有常驻调度器,每一次 +执行仍需人工手动触发(`run_goal_to_completion()` 或门控测试),这是当前系统的真实边界,不是这批任务的缺陷。 + +下表把上述 gap 拆成可以逐个手动触发的 self-target task,标注风险等级和建议顺序。"手动触发"指人工调用一次 +`scheduler.run_goal_to_completion(...)`(或未来对应的 API),不是让它们排队后无人值守跑完;每个任务完成、 +Deploy Supervisor 走到 cutover 前,仍按现有习惯人工确认一次。 + +| 顺序 | Gap(对应章节) | 建议 goal_text 方向 | 风险等级 | 备注 | +|---|---|---|---|---| +| 1 | ✅ 已修复(2026-08-03) 普通 target project 从未部署 Agent 定义 + worktree 隔离未提交(§3) | ~~让 `agent_profile.deploy_agent_definitions()` 在写完 `.claude/agents/*.md` 后自动 `git add`+commit~~ | 低 | 实际根因比预想的更基础(根本没调用点,不只是没 commit);**这次是人工直接改代码修的,不是走自举流程验证的**——留作顺序 2 之后再真正试一次"用 graphlemon 自己修 graphlemon"的对照 | +| 2 | 文档滞后(§4) | 让 Planner/Coder 核对 `development-plan.md`/`unit_matrix.json` 与 `git log` 的实际进度,补齐/订正"done"状态字段 | 低 | 纯文档任务,不改行为代码,出错代价最小;但要求 Coder 能准确核对而不是编造状态,建议 Validator 阶段重点复核 | +| 3 | Skill 内容未真正迁移(§3) | 把 `~/.hermes/profiles/{code,planning,qa}/skills/` 下 frontmatter 引用到的 Skill 真实内容迁移进仓库 `skills//` | 中 | 已确认 `~/.hermes/skills` 在本机真实存在;风险点在于 Coder 会话对仓库外路径的读取权限没有专门验证过,需要先确认 `auto` 权限模式下这条路径可达 | +| 4 | 部署缺互斥锁(本节新发现,§1) | 给 `deploy_stage.stage_deployment()` 加"当前是否已有进行中部署"的检查,拒绝/排队重复 staging | 中 | 应该在启用并发自举(顺序 6 之后)之前完成,否则后续任务并发跑时有真实冲突风险 | +| 5 | PH3 前端从未被真人验证(§5) | 不适合作为自动化 Coder 任务——这是"人用浏览器点一遍"的验证缺口,不是代码缺陷;列在这里是为了不让它被误当成可自举任务 | 不适用 | 需要人(或 claude-in-chrome 之类的浏览器自动化)介入,不建议塞进 Planner→Coder 循环 | +| 6 | 调度器接入生产入口(§2) | 给某个常驻进程(`backend_server.py` 或独立进程)接入 `scheduler.run_scheduler_once()` 的轮询循环 | 高 | **改变系统自己的自主权边界**,建议单独一批、额外人工审查 diff,不要和其他 gap 混跑;完成并 cutover 后,理论上后续 gap 才谈得上真正黑灯 | +| 7 | Deploy Supervisor 缺 `poll_fn`(§1) | 实现"发现待部署的 self-target 合并"逻辑,接入 `supervisor/deploy_supervisor.py` 的 `poll_fn` 钩子 | 高 | 依赖顺序 6 先完成(两者共同构成"真正自驱动");且 SDD 里"什么条件触发自部署"本身有文档内部冲突(设计文档 vs 规范正文),需要人工先拍板这个设计决策,不能让 Planner 自己悄悄选一种理解 | +| — | Coder 阶段的多模块 Work Graph 规划(§3) | 不建议列入这批"修 gap"任务 | 不适用 | 这是被明确判断为"超出范围的新能力",不是缺陷;如果要做,应该走正常的新功能立项,不该包装成"修复"塞进自动循环 | + +**执行建议**:先跑 1→2(验证流程本身),再跑 3→4(补齐已知风险点),5 留给人工/浏览器自动化处理,6→7 作为 +独立的、审查最严格的一批,完成后再评估是否真的要把后续维护交给自动调度。整个过程每一步仍需人工确认后触发, +不建议现在就配置成无人值守连续跑——待 6/7 完成并观察一段时间稳定后再考虑。 + +**顺序 1 验证过程中额外发现的一个非代码问题,记一笔**:`org_node_lifecycle._check_concurrency()` 统计"活跃任务 +数"是**跨全部 project 全局统计**的,不分项目;`/tmp/graphlemon-visual-check/graphlemon.db` 这个测试库里另一个 +project 下有条历史演示任务一直停在 `coding`(非终态但没人真的在跑),加上 `org_concurrency_config` 表当时是空 +的(兜底 `max_concurrent_goals=1`),导致新任务永远被并发闸门挡住,和 agent 定义那个 gap 完全无关,纯粹是这个 +临时测试库里的历史数据问题(已经通过 `PUT /api/settings/concurrency` 调高上限绕过,没有改代码)。不算一个新 +gap,但**全局而非按项目的并发统计**是不是有意为之,值得找个时间单独确认一下——如果以后真的多项目并行用,这 +个设计会让不相关项目的任务互相卡对方的并发额度。 + +--- + +## 最值得关注的三条 + +1. 调度器没接入生产入口,系统还不是真正自驱动的(第 2 节)。 +2. Deploy Supervisor 缺"自动发现待部署改动"的 `poll_fn`,PH5 的"自动"两个字还没完全兑现(第 1 节)。 +3. 文档(development-plan/unit_matrix/build-report)本身持续滞后于自举产生的新提交,这个滞后会越滚越大, + 建议找个时间点做一次文档收口,而不是继续靠事后补记维持可读性(第 4 节)。 diff --git a/src/project_scaffold.py b/src/project_scaffold.py index 2cbb8be..bec96ce 100644 --- a/src/project_scaffold.py +++ b/src/project_scaffold.py @@ -14,6 +14,8 @@ import os import subprocess import uuid +from src import agent_profile + _GIT_IDENTITY = [ "-c", "user.name=graphlemon", "-c", "user.email=graphlemon@localhost", @@ -79,9 +81,17 @@ def create_project(conn, target_folder_path, name=None, init_git_if_missing=Fals # arrives once tasks actually execute (01-M 1.5 artifact.file_path). open(os.path.join(graphlemon_subdir, ".gitkeep"), "w").close() - _run_git(["add", ".graphlemon"], cwd=target_folder_path) + # Without this, the first task ever triggered on this project fails + # immediately: `claude --agent planner` has nothing to find in this cwd + # (org_node_trigger.py's own docstring already says the caller is + # responsible for deploying this first, but until now nothing did -- + # self-target usage never noticed because graphlemon's own .claude/agents/ + # is already committed as part of its own repo). + agent_profile.deploy_agent_definitions(target_folder_path) + + _run_git(["add", ".graphlemon", ".claude"], cwd=target_folder_path) _run_git( - _GIT_IDENTITY + ["commit", "-m", "graphlemon: initialize .graphlemon/ workspace"], + _GIT_IDENTITY + ["commit", "-m", "graphlemon: initialize .graphlemon/ workspace + deploy agent definitions"], cwd=target_folder_path, ) diff --git a/src/scheduler.py b/src/scheduler.py index 8d93985..4538bc8 100644 --- a/src/scheduler.py +++ b/src/scheduler.py @@ -95,8 +95,19 @@ def _blocked_reason_for(exc): raise AssertionError("no blocked_reason registered for " + type(exc).__name__) -def _block_task(conn, task_id, blocked_reason): +def _block_task(conn, task_id, blocked_reason, detail=None): + """`detail` (2026-08-03): the real exception message, when there is one -- + `blocked_reason` itself stays a short routing code (budget_guardrail's + auto-resume query matches on its exact value, e.g. 'budget_exceeded'), so + this doesn't touch it. Without `detail` an unroutable failure (anything + not in `_ROUTABLE_EXCEPTIONS`) landed as a bare 'execution_error' with + zero diagnostic content anywhere -- found for real via the console's + manual Start button on a freshly created project (see + project_scaffold.py's agent-definitions fix, same incident).""" task_row = conn.execute("SELECT project_id, status FROM task WHERE task_id = ?", (task_id,)).fetchone() + payload = {"from_status": task_row[1], "to_status": "blocked_pending_human"} + if detail: + payload["detail"] = detail conn.execute( "UPDATE task SET status = 'blocked_pending_human', blocked_reason = ?, " "updated_at = CURRENT_TIMESTAMP WHERE task_id = ?", @@ -105,7 +116,7 @@ def _block_task(conn, task_id, blocked_reason): conn.execute( "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " "VALUES (?, ?, ?, 'task_status_changed', ?)", - (str(uuid.uuid4()), task_row[0], task_id, json.dumps({"from_status": task_row[1], "to_status": "blocked_pending_human"})), + (str(uuid.uuid4()), task_row[0], task_id, json.dumps(payload)), ) conn.commit() @@ -181,7 +192,7 @@ def run_goal_to_completion(db_path, target_folder_path, goal_text): result = e2e_pipeline.run_task_lifecycle(conn, task_id, target_folder_path, goal_text) except _ROUTABLE_EXCEPTIONS as e: blocked_reason = _blocked_reason_for(e) - _block_task(conn, task_id, blocked_reason) + _block_task(conn, task_id, blocked_reason, detail=str(e)) return {"task_id": task_id, "final_status": "blocked_pending_human", "blocked_reason": blocked_reason} return finish_task_lifecycle(conn, result, target_folder_path, goal_text) finally: diff --git a/src/task_execution.py b/src/task_execution.py index 25ead76..d796ef3 100644 --- a/src/task_execution.py +++ b/src/task_execution.py @@ -114,7 +114,7 @@ def run_or_resume_task_lifecycle(conn, task_id, target_folder_path, goal_text): def _mark_blocked(conn, task_id, exc): reason = scheduler._blocked_reason_for(exc) if isinstance(exc, scheduler._ROUTABLE_EXCEPTIONS) else "execution_error" - scheduler._block_task(conn, task_id, reason) + scheduler._block_task(conn, task_id, reason, detail=str(exc)) def start_task_execution_in_background(db_path, task_id, target_folder_path, goal_text): diff --git a/src/test_project_scaffold.py b/src/test_project_scaffold.py index 81490b6..195c925 100644 --- a/src/test_project_scaffold.py +++ b/src/test_project_scaffold.py @@ -10,7 +10,7 @@ import subprocess import tempfile import unittest -from src import project_scaffold, schema +from src import agent_profile, project_scaffold, schema def _init_git_repo(path): @@ -70,6 +70,25 @@ class HappyPathTest(ProjectScaffoldTestBase): ).fetchone() self.assertEqual(row[0], "Snake Game") + def test_agent_definitions_deployed_and_committed(self): + # Real incident (2026-08-03): without this, the first task ever + # triggered on a freshly created project fails immediately -- + # `claude --agent planner` has nothing to find in this cwd. Only + # self-target usage was ever exercised, where .claude/agents/ is + # already committed as part of graphlemon's own repo, so this gap + # went unnoticed until a real non-self-target project hit it. + _init_git_repo(self.tmpdir) + project_scaffold.create_project(self.conn, self.tmpdir) + + for org_node in agent_profile.ORG_NODES: + agent_path = os.path.join(self.tmpdir, ".claude", "agents", org_node + ".md") + self.assertTrue(os.path.isfile(agent_path), agent_path) + + status = subprocess.run( + ["git", "status", "--porcelain"], cwd=self.tmpdir, check=True, capture_output=True, text=True + ) + self.assertEqual(status.stdout, "", "agent definitions must be committed, not left untracked") + class NonGitRepoConfirmationFlowTest(ProjectScaffoldTestBase): """02-V §2.1 / 03-C §3.0: never silently `git init`.""" diff --git a/src/test_scheduler.py b/src/test_scheduler.py index 381d40e..bb5d8b3 100644 --- a/src/test_scheduler.py +++ b/src/test_scheduler.py @@ -10,6 +10,7 @@ multi-Goal coverage (RUN_REAL_AGENT_E2E=1) is in src/test_scheduler_e2e.py. Run: python3 -m unittest src.test_scheduler -v """ +import json import os import tempfile import threading @@ -307,6 +308,21 @@ class CorruptPersistedStateRoutingTest(RoutableExceptionTestBase): self.assertEqual(result["blocked_reason"], "corrupt_persisted_state") self._assert_task_blocked(result["task_id"], "corrupt_persisted_state") + def test_real_exception_message_is_persisted_for_diagnosis(self): + # 2026-08-03: a blocked task used to carry only a short routing code + # (e.g. 'execution_error') with the real failure reason nowhere to + # be found -- found via a real console Start-button failure that + # left no trace of *why*. _block_task now also stores the real + # exception message in the execution_event payload. + result = self._run_with_raising_lifecycle(work_node_run.CorruptPersistedStateError("disk full, boom")) + conn = schema.connect(self.db_path) + payload = conn.execute( + "SELECT payload FROM execution_event WHERE task_id = ? ORDER BY rowid DESC LIMIT 1", + (result["task_id"],), + ).fetchone()[0] + conn.close() + self.assertEqual(json.loads(payload)["detail"], "disk full, boom") + class ClassifierBlockedRoutingTest(RoutableExceptionTestBase): def test_routes_to_blocked_pending_human(self): diff --git a/src/test_task_execution.py b/src/test_task_execution.py index c7d377c..ad4610f 100644 --- a/src/test_task_execution.py +++ b/src/test_task_execution.py @@ -6,6 +6,7 @@ pause wiring, not e2e_pipeline's phase mechanics again. Run: python3 -m unittest src.test_task_execution -v """ +import json import os import subprocess import tempfile @@ -290,8 +291,14 @@ class StartTaskExecutionInBackgroundTest(unittest.TestCase): conn = schema.connect(self.db_path) row = conn.execute("SELECT status, blocked_reason FROM task WHERE task_id = 'task-1'").fetchone() + payload = conn.execute( + "SELECT payload FROM execution_event WHERE task_id = 'task-1' ORDER BY rowid DESC LIMIT 1" + ).fetchone()[0] conn.close() self.assertEqual(row, ("blocked_pending_human", "execution_error")) + # 2026-08-03: 'execution_error' alone gives zero diagnostic signal -- + # the real exception message must still be recoverable somewhere. + self.assertEqual(json.loads(payload)["detail"], "boom") if __name__ == "__main__":