Commit 92bebc5de27dde1b4a7bb77c329d3af167bf4523

Authored by 部署
2 parents 5313b4f2 a09b519b

Merge remote-tracking branch 'origin/main' into main

Showing 43 changed files with 2296 additions and 92 deletions
CLAUDE.md
@@ -110,7 +110,7 @@ npm run okr:migrate # goals → okr 存量迁移 @@ -110,7 +110,7 @@ npm run okr:migrate # goals → okr 存量迁移
110 | `/ws/claude` | Claude 实时对话(仅 `index.js`) | 110 | `/ws/claude` | Claude 实时对话(仅 `index.js`) |
111 | `/api/health` `/api/plugins` | 健康检查 / 插件状态 | 111 | `/api/health` `/api/plugins` | 健康检查 / 插件状态 |
112 | `/api/devloop/projects` | DevLoop:创建项目 + 跑 PM/Architect(POST)、列项目 / 查状态(GET,见下) | 112 | `/api/devloop/projects` | DevLoop:创建项目 + 跑 PM/Architect(POST)、列项目 / 查状态(GET,见下) |
113 -| `/api/dream/*` | 经验汇流(29-DREAM):`dream-service.js` 能力的 REST 封装,只读查询 + 触发 `dreamOnce`/`dreamSweep`,不新增业务逻辑,见 [`docs/todo/system-design/29-dream-design.md`](docs/todo/system-design/29-dream-design.md) | 113 +| `/api/dream/*` | 经验汇流(29-DREAM):`dream-service.js` 能力的 REST 封装,只读查询 + 触发 `dreamOnce`/`dreamSweep`,不新增业务逻辑,见 [`docs/done/system-design/29-dream-design.md`](docs/done/system-design/29-dream-design.md) |
114 114
115 ### DevLoop 子系统 115 ### DevLoop 子系统
116 116
bin/lemon.js
@@ -184,6 +184,16 @@ async function run() { @@ -184,6 +184,16 @@ async function run() {
184 phase: values.phase, artifact: values.artifact 184 phase: values.phase, artifact: values.artifact
185 }); 185 });
186 console.log(JSON.stringify(res.data, null, 2)); 186 console.log(JSON.stringify(res.data, null, 2));
  187 +
  188 + // 42 号文档 Phase 4:额外打一次 graph-runs 的 phase-note 端点,把这次阶段流转
  189 + // 落进 timeline.ndjson(大多数调用方——legacy 会话式 agent——这个端点会安静
  190 + // no-op,因为 --artifact 不是 work graph 节点产物目录的形状;不影响上面那次
  191 + // 调用的结果,出错也不影响本命令的退出码)。
  192 + try {
  193 + await request('POST', '/graph-runs/phase-note', {
  194 + artifact: values.artifact, phase: values.phase, agentId: values.agentId
  195 + });
  196 + } catch { /* best-effort,静默丢弃 */ }
187 } 197 }
188 else if (command === 'pipeline create') { 198 else if (command === 'pipeline create') {
189 const { values } = parseArgs({ args: args.slice(2), options: { 199 const { values } = parseArgs({ args: args.slice(2), options: {
docs/README.md
@@ -31,6 +31,12 @@ @@ -31,6 +31,12 @@
31 | [system-design/27-p3c-batch3-handover.md](./done/system-design/27-p3c-batch3-handover.md) | 批 3 交接 —— ✅ 已收口(2026-08-20)。**事故记录归档**:一份未经复核的代码被第三方会话提交进 main。留着是因为 §六 那几条多会话协作规则是真踩出来的 | 31 | [system-design/27-p3c-batch3-handover.md](./done/system-design/27-p3c-batch3-handover.md) | 批 3 交接 —— ✅ 已收口(2026-08-20)。**事故记录归档**:一份未经复核的代码被第三方会话提交进 main。留着是因为 §六 那几条多会话协作规则是真踩出来的 |
32 | [system-design/38-auth-multi-tenant-isolation.md](./done/system-design/38-auth-multi-tenant-isolation.md) | 登录鉴权 + 公司级多租户隔离 —— ✅ Phase 0/1/2/3 全部完成(2026-08-25)。种子账号 `admin`/`hex1234` + 登录页;全站挂 `requireAuth`(`agent-chat.js` 用 `requireAuthOrInternal` 放行三处服务端自环调用);`company.json` 加 `ownerId`,`canAccessCompany` 是全仓库唯一归属判据,`company.js`/`graph-run.js`/`devloop.js` 分别接入(`graph-run.js` 双挂载点参数形状不同,设计阶段描述不准确,实现时才发现);`okr.js` 因是零导航入口的孤儿页面改挂 `requireAdmin` 而非归属校验。**Phase 3 验证阶段真查出一个漏洞**:`agent-chat.js` 靠 `:agentId` 的 `"companyId:role"` 前缀区分会话归属却从没做过归属校验,任意登录用户可跨租户读别家公司会话,已修复。全量 `vitest` 1433 passed | 32 | [system-design/38-auth-multi-tenant-isolation.md](./done/system-design/38-auth-multi-tenant-isolation.md) | 登录鉴权 + 公司级多租户隔离 —— ✅ Phase 0/1/2/3 全部完成(2026-08-25)。种子账号 `admin`/`hex1234` + 登录页;全站挂 `requireAuth`(`agent-chat.js` 用 `requireAuthOrInternal` 放行三处服务端自环调用);`company.json` 加 `ownerId`,`canAccessCompany` 是全仓库唯一归属判据,`company.js`/`graph-run.js`/`devloop.js` 分别接入(`graph-run.js` 双挂载点参数形状不同,设计阶段描述不准确,实现时才发现);`okr.js` 因是零导航入口的孤儿页面改挂 `requireAdmin` 而非归属校验。**Phase 3 验证阶段真查出一个漏洞**:`agent-chat.js` 靠 `:agentId` 的 `"companyId:role"` 前缀区分会话归属却从没做过归属校验,任意登录用户可跨租户读别家公司会话,已修复。全量 `vitest` 1433 passed |
33 | [system-design/01-26](./done/system-design/) | 系统设计正文 01~26 章(14、25-harness-engineering-improvements 例外,见 todo/future/;27 例外,见 todo/),各章节状态见文内「状态」标记 | 33 | [system-design/01-26](./done/system-design/) | 系统设计正文 01~26 章(14、25-harness-engineering-improvements 例外,见 todo/future/;27 例外,见 todo/),各章节状态见文内「状态」标记 |
  34 +| [system-design/29-dream-design.md](./done/system-design/29-dream-design.md) | **dream(经验汇流)**— ✅ 已实现。定时整理所有 agent 在所有公司的记忆,提炼后跨公司沉淀(不泄漏公司信息)。四条取舍已拍板并实现:去敏独立判定、四落点分层写入(经验/`SKILL.md`/`soul.md`/`agent.md`,受管区块+三道闸门)、每日定时(串行+空转保护)、引用真伪自动查、REST+控制台入口(`/api/dream/*`、`/dream`)。`innovator-evolver` 真 CLI 跑通一次。**仍缺的**:「结论对不对」只能人抽查;「套进 org/work graph 来跑」评估后用户选了不做(作用域不匹配,见文内 §5.7) |
  35 +| [system-design/33-needs-gate-role-filter-bug.md](./done/system-design/33-needs-gate-role-filter-bug.md) | ✅ **已修复**。`executor.json`/`executor-fanout.json` 里 `produces:["log"]` 声明了一个 test/integration 类节点永远不会被索引到的 kind,导致 `org-coder-validator` 被 `missing_required_input` 反复误判 blocked;改成 `required:false`(对齐 `validator.json` 既有写法)。文档记录了一条**走过的弯路**(按 role 过滤 `inboundContracts` 会削弱 T-mt25ecbd 的产出闸门,已撤回),发现于 31 号文档的真实回归验证过程中,与兜底边问题无关 |
  36 +| [system-design/35-validation-rigor-and-body-readiness-proposal.md](./done/system-design/35-validation-rigor-and-body-readiness-proposal.md) | 📝 **复盘 + 改进方案,四条提案全部已实施**。承接 31/32/33/34 号的复盘结论:任务结果差主要是架构问题(完整性闸门 `missingBodyDefinition` 只在招聘时查、图执行侧完全没有第二道防线——新发现)+ 一个具体的招聘装配可靠性 bug,不是 agent 能力问题。提案 A:本体不完整时图执行前置判 `fail_reason=body_not_ready`,不扣预算。提案 D:`goal-contract.js` 的 `evidence_hint` 命中浏览器类关键词的维度必须有证据支撑,否则判不过。提案 C:新增 `scripts/visual-acceptance-check.mjs` 复用检查工具。提案 B:控制台「验收证据」小节展示每个维度有没有证据支撑 |
  37 +| [system-design/40-agent-chat-streaming-interrupt.md](./done/system-design/40-agent-chat-streaming-interrupt.md) | ✅ **已实现**(commit `4d23f50`,2026-08-30)。回应"前端刷新不及时/没有打字机效果,要不要把 SSE 换成 WebSocket"——排查后结论是传输层不是瓶颈(对照 hermes-webui/hermes-agent/deepseek-harness 三个参考实现,全部走 SSE,不用 WebSocket)。三个独立缺口全部补上:① 打字机不流畅→后端按字符数切片 + 前端 `createTypewriterQueue` 匀速吐出;② 执行中无法打断并接上新消息→`stop` 端点改轮询确认锁真正释放,前端据此直接发送/强制发送兜底,顺带修了服务端对同 convId 并发缺闸门这个真实 bug;③ 串台加固→`handleEvent` 抽成纯函数并补齐 `9b1c3f1` 那条不变式的回归测试。已用真实 Claude CLI 会话在浏览器验证 |
  38 +| [system-design/39-boss-agent-task-supervision.md](./done/system-design/39-boss-agent-task-supervision.md) | ✅ **P1-P5 全部已实现**(2026-08-30,`a00590a`/`5108d67`/`d967ac0`/`14cbaf5`)。boss agent(复用 `ceo`)监督 goal/task 看板闭环:draft task 回写、事件驱动+低频兜底唤醒、独立任务看板页、公司级 `target_dir`。**真实端到端测试**(建公司→下 goal→跑出一个 Playwright 5/5 验证通过的贪吃蛇游戏)撞出并记录了三个单测没覆盖的真问题,见文内「七、真跑复盘」;沉淀的待办已在 [42-org-work-graph-reliability-followups.md](./done/system-design/42-org-work-graph-reliability-followups.md) 全部收口 |
  39 +| [system-design/42-org-work-graph-reliability-followups.md](./done/system-design/42-org-work-graph-reliability-followups.md) | ✅ **Phase 1-4 全部已实现**(2026-08-30,`6ae1f76`/`2a60fe1`/`ae7650e`/`2a0ffb8`)。39 号文档真跑复盘沉淀的四 phase 可靠性收尾:① 卡住恢复闭环(服务器重启僵尸节点检测 + 强制判失败端点)② `ceo` 自建 task 的闸门穿透性回归测试 ③ `target_dir` 的 API/UI 配置入口(Phase 3 前端缺一次真实浏览器点击验证,见该节说明,是唯一已知缺口)④ `timeline.ndjson` 承接 planning/reflection 轨迹——`--artifact` 路径本身编码 `rootRunId`/`nodeRunId`,不用改任何 agent.md 或塞新环境变量 |
34 40
35 ## todo/(当前主线) 41 ## todo/(当前主线)
36 42
@@ -48,17 +54,13 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d @@ -48,17 +54,13 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d
48 | [27-goal-contract-format.md](./todo/system-design/27-goal-contract-format.md) | 验收契约格式(`goal-benchmark` / `goal-test-data` / `goal-test-script` 的产物形态与跨节点传递) | 54 | [27-goal-contract-format.md](./todo/system-design/27-goal-contract-format.md) | 验收契约格式(`goal-benchmark` / `goal-test-data` / `goal-test-script` 的产物形态与跨节点传递) |
49 | [27-p4-parallel-and-artifacts.md](./todo/system-design/27-p4-parallel-and-artifacts.md) | **P4(回收编号)并行执行与产物索引** — 📝 设计中,未实现。fan-out 真并发 + 同 agent 多实例隔离 + `files.json` 产物索引。三个 Phase,C 可插队 | 55 | [27-p4-parallel-and-artifacts.md](./todo/system-design/27-p4-parallel-and-artifacts.md) | **P4(回收编号)并行执行与产物索引** — 📝 设计中,未实现。fan-out 真并发 + 同 agent 多实例隔离 + `files.json` 产物索引。三个 Phase,C 可插队 |
50 | [28-followups.md](./todo/system-design/28-followups.md) | 28 号(Agent 注册表统一)的遗留清单 — 主文档已在 `done/`,这份装没做完的:dream 机制(设计见 29 号,已实现,见下)、`source_kind`(✅已删)/`legacy_path`(不能删)删列、三项「已实施未验证」。任务态清理(✅已实现,见下)。§五 是**已立起来的守卫清单**(别重复造,也别删) | 56 | [28-followups.md](./todo/system-design/28-followups.md) | 28 号(Agent 注册表统一)的遗留清单 — 主文档已在 `done/`,这份装没做完的:dream 机制(设计见 29 号,已实现,见下)、`source_kind`(✅已删)/`legacy_path`(不能删)删列、三项「已实施未验证」。任务态清理(✅已实现,见下)。§五 是**已立起来的守卫清单**(别重复造,也别删) |
51 -| [29-dream-design.md](./todo/system-design/29-dream-design.md) | **dream(经验汇流)**— ✅ 已实现。定时整理所有 agent 在所有公司的记忆,提炼后跨公司沉淀(不泄漏公司信息)。四条取舍已拍板并实现:去敏独立判定、四落点分层写入(经验/`SKILL.md`/`soul.md`/`agent.md`,受管区块+三道闸门)、每日定时(串行+空转保护)、引用真伪自动查、REST+控制台入口(`/api/dream/*`、`/dream`)。`innovator-evolver` 真 CLI 跑通一次。**仍缺的**:「结论对不对」只能人抽查;「套进 org/work graph 来跑」评估后用户选了不做(作用域不匹配,见文内 §5.7) |  
52 | [30-task-delegation-reliability-gaps.md](./todo/system-design/30-task-delegation-reliability-gaps.md) | **任务委派可靠性现状**— 📝 调研记录,非设计方案。DevLoop `runUnit`、Company 招聘装配、Graph 执行三条链路核对结论一致:都是「失败预算耗尽 → 停等人工」,没有「卡住了主动通知人」这一层;DevLoop 当前还默认走 mock,真实 CLI 成功率无实证数据。2026-08-23 追加真实实例并已修:`infra_flake` 原地重跑没有熔断线,真撞上过一次确定性死循环(节点把诊断结论误判成自己的执行结果,反复重跑却从不路由到诊断建议的目标节点);已加 `infra_flake_count` 列 + 硬顶 3 次熔断,连续判 infra_flake 超过硬顶就改判 blocked 交给人工 | 57 | [30-task-delegation-reliability-gaps.md](./todo/system-design/30-task-delegation-reliability-gaps.md) | **任务委派可靠性现状**— 📝 调研记录,非设计方案。DevLoop `runUnit`、Company 招聘装配、Graph 执行三条链路核对结论一致:都是「失败预算耗尽 → 停等人工」,没有「卡住了主动通知人」这一层;DevLoop 当前还默认走 mock,真实 CLI 成功率无实证数据。2026-08-23 追加真实实例并已修:`infra_flake` 原地重跑没有熔断线,真撞上过一次确定性死循环(节点把诊断结论误判成自己的执行结果,反复重跑却从不路由到诊断建议的目标节点);已加 `infra_flake_count` 列 + 硬顶 3 次熔断,连续判 infra_flake 超过硬顶就改判 blocked 交给人工 |
53 | [31-work-graph-fail-fallback-coverage.md](./todo/system-design/31-work-graph-fail-fallback-coverage.md) | work graph 兜底边覆盖不全 — ✅ 已完成(除入口节点外强制兜底边,`planner.json`/`executor-fanout.json` 补 6 条边),978/978 单测通过;2026-08-21 又用真实 LLM 起了一条新任务做端到端回归验证,`org-planner-sdd` 自判失败后自动打回 `org-planner-prd`(3 次,零人工介入),修复确认生效 | 58 | [31-work-graph-fail-fallback-coverage.md](./todo/system-design/31-work-graph-fail-fallback-coverage.md) | work graph 兜底边覆盖不全 — ✅ 已完成(除入口节点外强制兜底边,`planner.json`/`executor-fanout.json` 补 6 条边),978/978 单测通过;2026-08-21 又用真实 LLM 起了一条新任务做端到端回归验证,`org-planner-sdd` 自判失败后自动打回 `org-planner-prd`(3 次,零人工介入),修复确认生效 |
54 -| [39-boss-agent-task-supervision.md](./todo/system-design/39-boss-agent-task-supervision.md) | 📝 **设计评估中,未实现**。评估用户提出的五阶段方案(雇 boss agent 监督 goal/task 看板、独立任务看板页、心跳挨个调度、org/work graph 产出的 bug/新任务回写看板、公司级目标代码目录)。结论:P2 看板已实现、P1/P3 心跳与 `ceo` 本体基础设施已有大半、P5 的 `linked-existing` 模式已在 `path-resolver.js` 完整实现只差公司级默认值,**真正从零开始的是 P4(回写闭环)**——当前没有任何 agent 工具能读写 goal/task 板。建议顺序倒过来先建 P4 地基 |  
55 | [41-graph-vs-fde-reliability-discussion.md](./todo/system-design/41-graph-vs-fde-reliability-discussion.md) | 📝 **讨论记录 + 落地实测,非设计方案**。org/work graph 与 master-Innovator-FDE 两套工作流的稳定性对比:根因是"拓扑运行时生成 vs 写死"。问题 7 是真实执行记录——新建 `org-hunt/digest/evolve`(graph 原生协议,方法论抄 innovator-hunt/digest/evolver)跑 Hunt→Digest→Evolve,11 轮真跑中挖出并修复 5 个独立缺陷(建公司自动规划竞态、work-graph 模板库缺内容型交付物骨架、验收节点任务描述越权、两个规划 agent 的路径口径写错、R-19 产出索引 `indexNodeOutputs()` 不覆盖项目根——最后一条改了 `graph-engine.js` 核心逻辑,加 `contract.json` 的 `produces` 显式声明机制,全局生效,补了 2 条守卫测试)。最终产出与原生 FDE(`prd-methodology-trial`)对比:内容骨架可迁移、质量不打折,但过程严谨度不如原生 FDE,可靠性差距悬殊(原生一次跑通,graph 版 11 轮才稳定)。`routeFailure` 的 `infra_flake` 分支绕过跨层寻址(`target_node`)仍是唯一确认还没补的口子 | 59 | [41-graph-vs-fde-reliability-discussion.md](./todo/system-design/41-graph-vs-fde-reliability-discussion.md) | 📝 **讨论记录 + 落地实测,非设计方案**。org/work graph 与 master-Innovator-FDE 两套工作流的稳定性对比:根因是"拓扑运行时生成 vs 写死"。问题 7 是真实执行记录——新建 `org-hunt/digest/evolve`(graph 原生协议,方法论抄 innovator-hunt/digest/evolver)跑 Hunt→Digest→Evolve,11 轮真跑中挖出并修复 5 个独立缺陷(建公司自动规划竞态、work-graph 模板库缺内容型交付物骨架、验收节点任务描述越权、两个规划 agent 的路径口径写错、R-19 产出索引 `indexNodeOutputs()` 不覆盖项目根——最后一条改了 `graph-engine.js` 核心逻辑,加 `contract.json` 的 `produces` 显式声明机制,全局生效,补了 2 条守卫测试)。最终产出与原生 FDE(`prd-methodology-trial`)对比:内容骨架可迁移、质量不打折,但过程严谨度不如原生 FDE,可靠性差距悬殊(原生一次跑通,graph 版 11 轮才稳定)。`routeFailure` 的 `infra_flake` 分支绕过跨层寻址(`target_node`)仍是唯一确认还没补的口子 |
56 | [37-org-graph-department-scoping.md](./todo/system-design/37-org-graph-department-scoping.md) | 📝 **设计讨论,调用点扫描已完成,尚未立项实现**。现状 org graph 按 `company_id` 单例(`27-P2` 标题原文即"公司级"),塞不下 HR/财务/市场这类跟"交付流水线"无关的公司常设职能——`system/agents/hr/`("招·留·管·退")本体已存在却从没被装进任何图,全靠 fire-and-forget chat 招聘、无图执行审计轨迹。提议把 org graph 作用域从 `company_id` 升级为 `(company_id, department_id)`,一家公司可装配多张部门图,每张仍保持人工固定/慢变/可审计。全仓库调用点扫描结论:改造面是最初估算的 4 倍——真正的单点真相在 `work-graph-db.js:readDefGraph` 的裸 SQL 不变式(不在服务层),还牵涉两个易混淆服务文件间必须字节级一致的重复唯一索引、独立的 `graph-seed-service.js` 对账子系统、`company-hire-org-layer.test.js` 专属测试、`orggraph-planning`/`task-planning` 两份 agent 人设文档的自我认知改写 | 60 | [37-org-graph-department-scoping.md](./todo/system-design/37-org-graph-department-scoping.md) | 📝 **设计讨论,调用点扫描已完成,尚未立项实现**。现状 org graph 按 `company_id` 单例(`27-P2` 标题原文即"公司级"),塞不下 HR/财务/市场这类跟"交付流水线"无关的公司常设职能——`system/agents/hr/`("招·留·管·退")本体已存在却从没被装进任何图,全靠 fire-and-forget chat 招聘、无图执行审计轨迹。提议把 org graph 作用域从 `company_id` 升级为 `(company_id, department_id)`,一家公司可装配多张部门图,每张仍保持人工固定/慢变/可审计。全仓库调用点扫描结论:改造面是最初估算的 4 倍——真正的单点真相在 `work-graph-db.js:readDefGraph` 的裸 SQL 不变式(不在服务层),还牵涉两个易混淆服务文件间必须字节级一致的重复唯一索引、独立的 `graph-seed-service.js` 对账子系统、`company-hire-org-layer.test.js` 专属测试、`orggraph-planning`/`task-planning` 两份 agent 人设文档的自我认知改写 |
57 | [36-prd-agent-methodology-training-feasibility.md](./todo/system-design/36-prd-agent-methodology-training-feasibility.md) | 📝 **调研 + 阶段一/二均已完成**。回应"落地页太单薄是不是该用 Innovator 先训练专业 agent、再用 Dream 持续迭代"——认同方向。**阶段一**:`graph-engine.js` 结算节点后把 `success`/`fail:<reason>` 写进本体 `memory.md`(此前只有人类对话路径会写,Dream 天天空转)。**阶段二**:新建独立试验节点 `prd-methodology-trial` 走真实 Hunt→Digest→Evolve,2 轮收敛(96/97 分,真实基线 50 分),三个真实历史项目反推验证方法论有效;过程中连带修复同一个 2026-08-16 改名批次遗留的 5 处 `innovator`→`org-innovator` 死链(后端两处硬编码、前端"重新训练"按钮、`/api/agent/list` 展示 id 来源的四个 `config.json`),以及 `AgentScaffold` 默认模板一处裸相对路径缺陷。结论:FDE 对方法论类角色确实管用,但收敛速度/验收严谨度依赖任务书显式写明的自纠正要求,非免人工黑盒。环节 3(训练产物 → 雇佣本体转换)尚未开工 | 61 | [36-prd-agent-methodology-training-feasibility.md](./todo/system-design/36-prd-agent-methodology-training-feasibility.md) | 📝 **调研 + 阶段一/二均已完成**。回应"落地页太单薄是不是该用 Innovator 先训练专业 agent、再用 Dream 持续迭代"——认同方向。**阶段一**:`graph-engine.js` 结算节点后把 `success`/`fail:<reason>` 写进本体 `memory.md`(此前只有人类对话路径会写,Dream 天天空转)。**阶段二**:新建独立试验节点 `prd-methodology-trial` 走真实 Hunt→Digest→Evolve,2 轮收敛(96/97 分,真实基线 50 分),三个真实历史项目反推验证方法论有效;过程中连带修复同一个 2026-08-16 改名批次遗留的 5 处 `innovator`→`org-innovator` 死链(后端两处硬编码、前端"重新训练"按钮、`/api/agent/list` 展示 id 来源的四个 `config.json`),以及 `AgentScaffold` 默认模板一处裸相对路径缺陷。结论:FDE 对方法论类角色确实管用,但收敛速度/验收严谨度依赖任务书显式写明的自纠正要求,非免人工黑盒。环节 3(训练产物 → 雇佣本体转换)尚未开工 |
58 -| [35-validation-rigor-and-body-readiness-proposal.md](./todo/system-design/35-validation-rigor-and-body-readiness-proposal.md) | 📝 **复盘 + 改进方案,四条提案全部已实施**。承接 31/32/33/34 号的复盘结论:任务结果差主要是架构问题(完整性闸门 `missingBodyDefinition` 只在招聘时查、图执行侧完全没有第二道防线——新发现)+ 一个具体的招聘装配可靠性 bug,不是 agent 能力问题。提案 A:本体不完整时图执行前置判 `fail_reason=body_not_ready`,不扣预算。提案 D:`goal-contract.js` 的 `evidence_hint` 命中浏览器类关键词的维度必须有证据支撑,否则判不过。提案 C:新增 `scripts/visual-acceptance-check.mjs` 复用检查工具。提案 B:控制台「验收证据」小节展示每个维度有没有证据支撑 |  
59 | [34-validator-scored-without-real-deliverable.md](./todo/system-design/34-validator-scored-without-real-deliverable.md) | 📝 **调研记录,checklist 已查完**。最初以为 `org-coder-impl` 从未产出任何 HTML/CSS,后来发现真实网站其实存在于 `graph_files` 索引不覆盖的「项目根」目录(已顺手修:控制台「产物」面板现在会展示项目根)。追加查清 validator 有没有真的做浏览器级检查——中途自我订正一次:不是"没工具做不到"(`bash` 能走到仓库自带的 Playwright,另一条对照任务证明可行),是**没人教它这么做**;抽查另外两个项目根后发现**验证严谨度完全不稳定**(同类任务有的做了真实 Playwright 级验证、有的只做静态正则、有的没验证就被取消),根因很可能是新招聘的 `unit-tester` 本体撞上了 [32 号文档](./todo/system-design/32-hire-body-fill-reliability.md) 那类"装配没填充完"的真实样本,不是平台工具限制 | 62 | [34-validator-scored-without-real-deliverable.md](./todo/system-design/34-validator-scored-without-real-deliverable.md) | 📝 **调研记录,checklist 已查完**。最初以为 `org-coder-impl` 从未产出任何 HTML/CSS,后来发现真实网站其实存在于 `graph_files` 索引不覆盖的「项目根」目录(已顺手修:控制台「产物」面板现在会展示项目根)。追加查清 validator 有没有真的做浏览器级检查——中途自我订正一次:不是"没工具做不到"(`bash` 能走到仓库自带的 Playwright,另一条对照任务证明可行),是**没人教它这么做**;抽查另外两个项目根后发现**验证严谨度完全不稳定**(同类任务有的做了真实 Playwright 级验证、有的只做静态正则、有的没验证就被取消),根因很可能是新招聘的 `unit-tester` 本体撞上了 [32 号文档](./todo/system-design/32-hire-body-fill-reliability.md) 那类"装配没填充完"的真实样本,不是平台工具限制 |
60 -| [40-agent-chat-streaming-interrupt.md](./todo/system-design/40-agent-chat-streaming-interrupt.md) | 📝 **需求文档,讨论中,未实现**。回应"前端刷新不及时/没有打字机效果,要不要把 SSE 换成 WebSocket"——排查后结论是传输层不是瓶颈(对照 hermes-webui/hermes-agent/deepseek-harness 三个参考实现,全部走 SSE,不用 WebSocket)。真正的三个独立缺口:① 打字机不流畅(ACP chunk 粒度太粗,方案是前后端都做匀速切片/渲染缓冲);② 执行中无法打断并接上新消息(现状"先 Stop 再发"两步,方案是深度 1 的打断-排队,顺带发现服务端对同 convId 并发缺闸门这个现存 bug);③ 串台已在 `9b1c3f1` 修复但没有回归测试兜底 |  
61 -| [33-needs-gate-role-filter-bug.md](./todo/system-design/33-needs-gate-role-filter-bug.md) | ✅ **已修复**。`executor.json`/`executor-fanout.json` 里 `produces:["log"]` 声明了一个 test/integration 类节点永远不会被索引到的 kind,导致 `org-coder-validator` 被 `missing_required_input` 反复误判 blocked;改成 `required:false`(对齐 `validator.json` 既有写法)。文档记录了一条**走过的弯路**(按 role 过滤 `inboundContracts` 会削弱 T-mt25ecbd 的产出闸门,已撤回),发现于 31 号文档的真实回归验证过程中,与兜底边问题无关 | 63 +| [43-task-decomposition-granularity.md](./todo/system-design/43-task-decomposition-granularity.md) | 📝 **需求 + 方案定案 + 技术实现计划(5 个 Phase)已写完,尚未开工**。回应"org-planner 出 brd/prd/sdd 后,任务看板能不能展示成 task/sub-task 树,coder-validator 按清单逐条开发验收"。方案:goal→task 流水线自建父子机制、先只拆验收(开发仍整体完成)、`tasks.acceptance_mode` 由 task-planning 判断、子任务复用 `tasks.status` 既有状态语义、任一子任务 fail 父任务即 fail。**Phase 0-4** 逐条核实了代码形态:`createTask()`/`parent_task_id` 迁移模板、task-planning "硬规则 5"需要开例外让 LLM 多写一个字段、`org-planner` 产出的验收清单其实是 `acceptance.md` 不是 `acceptance.json`(且⚠️真实回归里从未被用上,落地前需先验证可靠性)、`coder-validator`(不是 org-validator,文档订正过一次)的 `checks[]` 目前没有任何服务端代码解析、看板树形渲染有 `FileNode.vue` 现成模板可抄。建议实施顺序 Phase 0→1→4→2→3 |
62 64
63 ## todo/future/(暂缓,非当前重心) 65 ## todo/future/(暂缓,非当前重心)
64 66
docs/todo/system-design/29-dream-design.md renamed to docs/done/system-design/29-dream-design.md
docs/todo/system-design/33-needs-gate-role-filter-bug.md renamed to docs/done/system-design/33-needs-gate-role-filter-bug.md
docs/todo/system-design/35-validation-rigor-and-body-readiness-proposal.md renamed to docs/done/system-design/35-validation-rigor-and-body-readiness-proposal.md
docs/todo/system-design/39-boss-agent-task-supervision.md renamed to docs/done/system-design/39-boss-agent-task-supervision.md
1 # 39. Boss Agent 任务看板监督 + 公司目标代码目录 — 需求评估与设计草案 1 # 39. Boss Agent 任务看板监督 + 公司目标代码目录 — 需求评估与设计草案
2 2
3 -> **状态**:✅ P1-P5 已实现(2026-08-30,commit `a00590a` P1/P3/P4 后端、`5108d67` P2 前端、`d967ac0` P5)。本文档是落地前的设计草案,实现细节以代码为准,本文档不再更新,仅存档决策过程。**已知未覆盖的后续工作见文末新增的「六、已知遗留」**。 3 +> **状态**:✅ P1-P5 已实现(2026-08-30,commit `a00590a` P1/P3/P4 后端、`5108d67` P2 前端、`d967ac0` P5、`14cbaf5` graph-run.js 鉴权补丁)。本文档是落地前的设计草案,实现细节以代码为准,仅存档决策过程。**已知未覆盖的后续工作见「六、已知遗留」,真实端到端跑测的复盘见「七、真跑复盘」,复盘沉淀的待办已拆成独立文档 [42-org-work-graph-reliability-followups.md](./42-org-work-graph-reliability-followups.md)(按 phase 排开工顺序)**。
4 > **日期**:2026-08-30 4 > **日期**:2026-08-30
5 > **背景**:承接对话讨论——goal/task 两级模型([27-P3C](../../done/system-design/27-p3c-goal-task-model.md))已落地,但推进它的是纯确定性代码(`task-scheduler.js` + `transitionRun` 钩子),没有任何 agent 在持续监督这块看板、处理异常、把执行中发现的新问题回写成新任务。用户提出五阶段方案补上这一层,并追加公司级目标代码目录需求。 5 > **背景**:承接对话讨论——goal/task 两级模型([27-P3C](../../done/system-design/27-p3c-goal-task-model.md))已落地,但推进它的是纯确定性代码(`task-scheduler.js` + `transitionRun` 钩子),没有任何 agent 在持续监督这块看板、处理异常、把执行中发现的新问题回写成新任务。用户提出五阶段方案补上这一层,并追加公司级目标代码目录需求。
6 > **读者须知**:本文档大量引用现有代码路径核实"已有 vs 缺失",结论以文内给出的具体函数/文件为准;这些引用会随代码演进过期,落地前请重新核实。 6 > **读者须知**:本文档大量引用现有代码路径核实"已有 vs 缺失",结论以文内给出的具体函数/文件为准;这些引用会随代码演进过期,落地前请重新核实。
@@ -190,3 +190,29 @@ @@ -190,3 +190,29 @@
190 1. **§四.4 定案的"回归测试钉死闸门"没有单独落地**。`ceo` 自主 `draft` + `enqueue` 走的是和人类调用完全相同的 `POST /:cid/tasks` → `POST /:cid/tasks/:taskId/enqueue` 两个端点(`max_parallel_tasks`/`DEFAULT_TASK_BUDGET`/goal 级 `org-validator` 这几道闸门都挂在数据层/调度器层,不认"谁创建的",理论上自动覆盖),但没有一条测试专门断言"ceo 创建的 task 一样会被这几道闸门拦下"——现有测试(`task-entry-draft-mode.test.js`、`task-blocked-ceo-wake.test.js`)验的是 draft 模式本身和唤醒机制,不是闸门穿透性。理论上没有绕过的口子,但没有测试钉死就不能说"已确认"。 190 1. **§四.4 定案的"回归测试钉死闸门"没有单独落地**。`ceo` 自主 `draft` + `enqueue` 走的是和人类调用完全相同的 `POST /:cid/tasks` → `POST /:cid/tasks/:taskId/enqueue` 两个端点(`max_parallel_tasks`/`DEFAULT_TASK_BUDGET`/goal 级 `org-validator` 这几道闸门都挂在数据层/调度器层,不认"谁创建的",理论上自动覆盖),但没有一条测试专门断言"ceo 创建的 task 一样会被这几道闸门拦下"——现有测试(`task-entry-draft-mode.test.js`、`task-blocked-ceo-wake.test.js`)验的是 draft 模式本身和唤醒机制,不是闸门穿透性。理论上没有绕过的口子,但没有测试钉死就不能说"已确认"。
191 2. **`company.json.target_dir`/`target_mode` 没有任何 API/UI 入口去设置**——现在只能手工编辑公司态目录下的 `company.json` 文件才能配置。P5 落地的是"读取并生效"这一半,"人怎么把这两个字段填进去"这一半还没做(哪怕只是一个 `PATCH /api/company/:cid` 端点)。 191 2. **`company.json.target_dir`/`target_mode` 没有任何 API/UI 入口去设置**——现在只能手工编辑公司态目录下的 `company.json` 文件才能配置。P5 落地的是"读取并生效"这一半,"人怎么把这两个字段填进去"这一半还没做(哪怕只是一个 `PATCH /api/company/:cid` 端点)。
192 3. **`server/routes/graph-run.js` 漏了同一处鉴权修复,已在真实跑动中撞出来并修复(2026-08-30)**。地基 0 当时只改了 `company.js`(goals/tasks 端点),`graph-run.js`(run 的 start/stop/retry/budget 等控制端点)还是纯 `requireAuth`。真实端到端测试里 `ceo` 被唤醒后能读写 goal/task 板,但调 `/api/graph-runs/*` 处理卡住的 run 时精确撞上 401,自己在对话记录里诊断出这一点并写出了人工需要跑的 curl 命令。已修:`router`/`companyRouter` 两个挂载点都改成 `requireAuthOrInternal`,两处 `:companyId`/`:id` 的 `param` 中间件加了与 `company.js` 一致的 `req.user?.internal` 旁路,`route-auth-gate.test.js` 同步更新断言,全量测试零回归(1469/1473,剩余 4 个是核实过的 pre-existing 失败)。**这次的教训**:地基 0 的"这一步做完了"判据不能只看 `company.js` 一个文件——`ceo` 需要触达的控制面横跨两个路由文件,遗漏了没有靠代码审查发现,是靠真实跑一遍才暴露的。 192 3. **`server/routes/graph-run.js` 漏了同一处鉴权修复,已在真实跑动中撞出来并修复(2026-08-30)**。地基 0 当时只改了 `company.js`(goals/tasks 端点),`graph-run.js`(run 的 start/stop/retry/budget 等控制端点)还是纯 `requireAuth`。真实端到端测试里 `ceo` 被唤醒后能读写 goal/task 板,但调 `/api/graph-runs/*` 处理卡住的 run 时精确撞上 401,自己在对话记录里诊断出这一点并写出了人工需要跑的 curl 命令。已修:`router`/`companyRouter` 两个挂载点都改成 `requireAuthOrInternal`,两处 `:companyId`/`:id` 的 `param` 中间件加了与 `company.js` 一致的 `req.user?.internal` 旁路,`route-auth-gate.test.js` 同步更新断言,全量测试零回归(1469/1473,剩余 4 个是核实过的 pre-existing 失败)。**这次的教训**:地基 0 的"这一步做完了"判据不能只看 `company.js` 一个文件——`ceo` 需要触达的控制面横跨两个路由文件,遗漏了没有靠代码审查发现,是靠真实跑一遍才暴露的。
  193 +
  194 +---
  195 +
  196 +## 七、真跑复盘(2026-08-30,`snakegame-co` 端到端测试)
  197 +
  198 +P1-P5 落地后,用它做了一次真实端到端测试:建公司 → 手工配 `target_dir` 指向一个真实的空目录 → 下一个"做 HTML 版贪吃蛇游戏"的 goal → `task-planning` 真实拆分 → 全程真实 Claude CLI 调用把 org-planner(PRD/SDD/验收标准)→ org-coder(RESEARCH/PLAN/EXECUTE/VERIFY,含真实的 test/validator/impl 修正迭代)跑完,最终 `goal status: done`、`task status: completed`,产出的 `index.html` 用独立跑的 Playwright 测试验证 5/5 通过(一个可以在浏览器里直接玩的贪吃蛇游戏)。
  199 +
  200 +**核心结论:1469 个单测全绿,这一次真实端到端跑动却测出了至少三个单测完全没覆盖到的真问题。** "全部测试通过"不能当"系统能用"的证据——这次最有价值的发现,没有一个是单测挑出来的,全是真实跑一遍撞出来的。
  201 +
  202 +### 7.1 真跑撞出的技术缺口
  203 +
  204 +按发现顺序:
  205 +
  206 +1. **服务器重启会让 in-flight 的 graph 节点变僵尸,没有任何机制能自动发现**。为了让 `graph-run.js` 鉴权修复生效重启了一次服务器,正在跑 `org-planner-sdd` 的真实 Claude CLI 子进程被连带杀掉,但 `graph_run_nodes.status` 永远停在 `running`——`ceo` 的两条唤醒路径(事件驱动 + 30 分钟兜底扫描)判据都只认 `status='blocked'`,对"运行中但背后进程已死"这个状态完全不可见。查过现成的"重启后清理残留"机制(`server/index.js` 的 `resumeIncompleteTasks()`),发现它只管 ArenaSK 的 Skill 训练任务,跟 org/work graph 毫无关系;`shutdown()` 也只关插件管理器,不碰 `graph_run_nodes`。这是这次代价最大的一个坑,恢复时手写了一段脚本直接调 `graph-run-sse.transitionNode` 强制把节点判成 `fail`(走的是引擎自己的转换机制,不是绕过系统直接改数据库),才能接上标准的 `retry` 端点继续推进——但这条恢复路径本身也暴露了:**"这个节点看着卡住了,强制判失败以便恢复"这个动作现在没有任何 REST 入口**,只能改代码。
  207 +2. **`graph-run.js` 的鉴权缺口**(见「六、已知遗留」第 3 条,已修复推送)。
  208 +3. **规划/行动/反思没有统一的可追溯位置**。`org-coder` 的四段式阶段流转(RESEARCH→PLAN→EXECUTE→VERIFY)靠调 `bin/lemon.js task transition`,打到 `agent-chat.js` 的 `/task/transition` 端点——这个端点靠自己的 `sessions` Map 找"当前活跃会话",但 `org-coder` 作为 work graph 节点跑的时候走的是 `graph-engine.js` → `AgentManager` → `session-pool.js`,是 CLAUDE.md 里早就记录过的"两个互不相干的会话池"那个坑的又一个受害者:阶段流转的业务逻辑(P0 审批拦截、OKR 子状态更新)在这条路径上**静默跳过**,`_plan.md` 这类中间产物既不进 `graph_files` 契约(这个是刻意的——validator 判定不该依赖"coder 计划写得像不像样"),也不进任何跨节点共享的轨迹,人类/`ceo` 事后想知道"这一步当时怎么想的",只能去读原始的 agent transcript。
  209 +
  210 +这三条已经拆成具体的开发计划:[42-org-work-graph-reliability-followups.md](./42-org-work-graph-reliability-followups.md)。
  211 +
  212 +### 7.2 过程方法上的教训
  213 +
  214 +1. **三个并行 fork 分工做 P1-P5 这个模式本身是对的**——文件边界分得干净,`git diff` 核实过没有意外重叠,最后能精确按 hunk(不是整文件)拆成三个独立 commit。值得作为以后类似规模改动的默认工作方式。
  215 +2. **"全部测试通过"不能替代真实跑一遍**——见上,这是本次复盘最核心的教训,直接决定了 41 号文档 Phase 1-2 优先做"让已经在跑的东西更可靠"而不是急着加新功能。
  216 +3. **"这一步做完了"的判据容易只验证了子集**——`graph-run.js` 的鉴权缺口就是典型:地基 0 当时的验证只覆盖了 `company.js`,`ceo` 实际需要触达的控制面横跨两个路由文件。以后给 agent 开权限类的工作,落地前要先列一份"这个角色实际会调哪些端点"的清单,逐条核对,不能默认"改了一个入口就等于都改了"。
  217 +4. **重启共享的开发服务器要谨慎**——为了让代码改动生效重启了一次服务器,没意识到这会杀掉正在跑的真实 Claude CLI 子进程,浪费了一整轮真实 LLM 调用(真金白银)。以后类似场景应该先确认有没有 in-flight 的真实执行,能等就等它到一个安全点再重启。
  218 +5. **"为什么不能自动恢复"这类追问是复盘里最值钱的部分**——第一反应往往是"手动修一下继续跑",是持续追问"这本该由谁负责、为什么没发生"才把表面的一次性意外,看清楚成系统性盲区(僵尸节点检测缺失)。这次几个最高价值的发现,都来自这种不满足于表面解释、继续往下问一层的模式,值得作为以后排查问题的默认姿势,不只是这一次的运气。
docs/todo/system-design/40-agent-chat-streaming-interrupt.md renamed to docs/done/system-design/40-agent-chat-streaming-interrupt.md
1 # 40-agent-chat-streaming-interrupt. Agent Chat 打字机平滑 + 打断-排队 + 串台加固 1 # 40-agent-chat-streaming-interrupt. Agent Chat 打字机平滑 + 打断-排队 + 串台加固
2 2
3 > **日期**: 2026-08-30 3 > **日期**: 2026-08-30
4 -> **状态**: 📝 需求文档,讨论中,尚未实现 4 +> **状态**: ✅ 已实现(commit `4d23f50`,2026-08-30)。三个方案全部落地:后端按字符数切片 +
  5 +> 前端 `createTypewriterQueue` 匀速吐出(打字机平滑);`stop` 端点改轮询确认
  6 +> `agentChatLocks` 真正释放后返回 `cleaned`,前端据此直接发送或走强制发送兜底,执行中解锁
  7 +> 输入框(打断-排队);`handleEvent` 分发逻辑抽成不依赖组件实例的纯函数
  8 +> (`agentChatEvents.js`),补上 `9b1c3f1` 那条不变式的回归测试(串台加固)。已用真实
  9 +> Claude CLI 会话(非 mock)在浏览器里验证打字机效果与打断并发送。实现过程中顺手修了一个
  10 +> 真实并发漏洞:同一 `agentId` + 同一 `convId` 的并发请求此前完全没有闸门。本文档不再更新,
  11 +> 仅存档设计过程,实现细节以代码为准。
5 > **触发**: 用户反馈"前端刷新不及时、没有打字机效果",怀疑要不要把传输层从 HTTP 换成 WebSocket 12 > **触发**: 用户反馈"前端刷新不及时、没有打字机效果",怀疑要不要把传输层从 HTTP 换成 WebSocket
6 > 来解决打字机效果、多 Agent/多聊天切换、执行中无法打断这几个问题。经排查现状代码 + 13 > 来解决打字机效果、多 Agent/多聊天切换、执行中无法打断这几个问题。经排查现状代码 +
7 > 对照 [hermes-webui](file:///Users/yibo/Documents/lemonAI/hermes-webui)、 14 > 对照 [hermes-webui](file:///Users/yibo/Documents/lemonAI/hermes-webui)、
docs/done/system-design/42-org-work-graph-reliability-followups.md 0 → 100644
  1 +# 42. Org/Work Graph 可靠性收尾 —— 39 号文档真跑复盘的待办
  2 +
  3 +> **状态**:Phase 1-4 全部 ✅ 已实现(2026-08-30)。Phase 3 前端缺一次真实浏览器验证(见该节说明),其余无已知缺口。
  4 +> **日期**:2026-08-30
  5 +> **背景**:[39-boss-agent-task-supervision.md](./39-boss-agent-task-supervision.md) 的 P1-P5 落地后,用它做了一次真实端到端测试(建公司 → 下 goal → `ceo` 监督 → 真实跑出一个可玩的贪吃蛇游戏,Playwright 5/5 验证通过)。**1469 个单测全绿,这次真跑却测出至少 3 个单测完全没覆盖到的真问题**——这份文档是那次复盘沉淀下来的待办,按开工顺序分四个 phase。
  6 +> **读者须知**:每个 phase 的现状描述都来自那次真实测试里实际撞到的场景,不是推测;具体文件/行号落地前按惯例重新核实。
  7 +
  8 +---
  9 +
  10 +## 一、为什么排这个顺序
  11 +
  12 +四个 phase 分两类:**Phase 1-2 是"让已经在跑的东西更可靠"**(不改变行为,只是补上恢复路径和验证),**Phase 3-4 是"补功能缺口"**(新增能力)。前者优先——真跑复盘里代价最大的坑(僵尸节点、鉴权漏挂)都是"东西本来能跑,只是卡住了没人能救"这一类,越早补上,后面几个 phase 开工时越不容易被同样的坑绊到。Phase 5(timeline 承接规划轨迹)单独放最后,因为它是结构性改动,需要先摸清 `org-coder` 之外还有哪些角色也在打那条断线的 `/task/transition`,工作量比其余四个 phase 加起来还大。
  13 +
  14 +```
  15 +Phase 1:卡住恢复闭环(僵尸节点检测 + 强制判失败端点)——两个互补的口子一起补
  16 +Phase 2:§四.4 闸门穿透性回归测试 —— 纯测试,零设计风险
  17 +Phase 3:target_dir 的 API/UI 入口 —— 纯加法
  18 +Phase 4:timeline.ndjson 承接 planning/reflection 轨迹 —— 结构性改动,排最后
  19 +```
  20 +
  21 +---
  22 +
  23 +## 二、Phase 1:卡住恢复闭环 —— ✅ 已实现(2026-08-30)
  24 +
  25 +**落地结果**(与下面的设计一一对应,实现时没有偏离):
  26 +- 1a:`graph-engine.js` 新增 `sweepOrphanedRunningNodes(cutoffIso)`,复用 `routeFailure` 的 `infra_flake` 分支;`server/index.js` 在 `PROCESS_STARTED_AT`(模块顶层捕获的本次进程启动时刻)挂上开机一次性调用,排在其余「开机补一次全量扫描」钩子之前。测试:`server/tests/unit/graph-engine-orphan-sweep.test.js`(4 例:孤儿转 infra_flake 原地重跑、晚于 cutoff 的不受影响、连续判孤儿撞硬顶转 blocked、无孤儿时安静 no-op)。
  27 +- 1b:`graph-engine.js` 新增 `forceFailNode(runId, nodeRunId, failReason='operator_intervention')`;`graph-run.js` 新增 `POST /:id/nodes/:nodeId/force-fail`,只接受 `status='running'`。测试:`server/tests/unit/graph-run-force-fail.test.js`(8 例:正常转 fail 并可被 retry 接上、自定义 failReason 透传、五种非 running 状态各返 400、节点不存在返 404)。
  28 +- 全量测试 1483/1487,零回归(剩余 4 个是核实过的 pre-existing 失败)。
  29 +
  30 +---
  31 +
  32 +### 1a. 服务器重启的僵尸节点检测
  33 +
  34 +**现状(真跑撞到的)**:`graph_run_nodes.status='running'` 这一行,没有任何机制确认"背后真的还有个活的进程"。真实测试里为了让 `graph-run.js` 鉴权修复生效重启了一次服务器,正在跑 `org-planner-sdd` 的 Claude CLI 子进程被连带杀掉,但那一行的状态永远停在 `running`——不会自己变成 `blocked`,`ceo` 的两条唤醒路径(事件驱动 + 30 分钟兜底扫描)判据都是 `status='blocked'`,对这个状态完全不可见。
  35 +
  36 +查过现成的"重启后清理残留"机制:`server/index.js` 的 `resumeIncompleteTasks()` 只处理 ArenaSK 的 Skill 训练任务(`skill-task-manager`),跟 org/work graph 毫无关系;`shutdown()`(`SIGTERM`/`SIGINT` 处理)只关插件管理器,不碰 `graph_run_nodes`。两处都不覆盖这个场景。
  37 +
  38 +**设计**:启动时扫一遍 `graph_run_nodes`,`status='running'` 且 `updated_at`(或 `startedAt`)早于**本次进程启动时间**的行,判定为孤儿(整个进程树都被换过一轮,不可能有真实进程还在跑它)。判定后按 **`infra_flake` 语义**处理,不发明新状态——原地重跑逻辑本来就是为"基础设施打断,跟设计/实现对不对无关"这类场景设计的,服务器重启只是这个桶里更严重的一个成员。复用 `graph-engine.js` 已有的 `routeFailure` 里 `INFRA_FLAKE_REASON` 那条分支,不用另起一套判定/熔断逻辑。
  39 +
  40 +**落点**:`server/index.js` 启动流程(挂在 `resumeIncompleteTasks()`/`sweepStuckActiveGoals()` 同一批"开机补一次全量扫描"的地方,跟这些既有钩子形状一致),加一个 `sweepOrphanedRunningNodes()`;判定逻辑放 `graph-engine.js` 或 `work-graph-db.js`(参照 `infra_flake` 现有实现挂在哪个文件)。
  41 +
  42 +**验收**:新增测试——起一个 `status='running'`、`startedAt` 早于"当前进程启动时间"的节点,跑一次 `sweepOrphanedRunningNodes()`,断言它被转成 `infra_flake` 路径能处理的状态,且不影响 `startedAt` 晚于启动时间的正常运行节点。
  43 +
  44 +### 1b. "强制判失败以便恢复"的 REST 端点
  45 +
  46 +**现状**:`POST /:id/nodes/:nodeId/retry` 只接受 `status ∈ {fail, blocked}` 的节点。真实测试里遇到一个卡住的 `running` 节点,唯一的恢复手段是**直接写一段脚本调用 `graph-run-sse.transitionNode` 内部函数**,绕过了任何 REST 接口——这对人类操作者或 `ceo` 都不现实,两者都只能走 HTTP。
  47 +
  48 +**设计**:新增 `POST /api/graph-runs/:id/nodes/:nodeId/force-fail`,语义是"我确认这个节点已经不可能再自己完成了,把它判成 `fail`(`failReason` 可选,默认某个通用值如 `operator_intervention`),交给既有的 fail→retry/backward-edge 路径处理"。**只接受 `status='running'` 的节点**(`fail`/`blocked` 已经有 `retry` 端点了,不重复造轮子);调用方是人类还是 `ceo` 不做区分,鉴权走已有的 `requireAuthOrInternal`(跟着 Phase 1a 一起补,`graph-run.js` 这次已经全部换过了)。1a 落地后,这个端点的日常触发频率应该会显著下降(大部分"卡住"场景 1a 已经自动兜底),但保留它作为人工/`ceo` 判断"这个不是重启造成的,就是卡了"时的手动出口。
  49 +
  50 +**验收**:新增测试——`running` 节点调 `force-fail` 转 `fail` 且可被 `retry` 端点接上;`fail`/`blocked`/`pending` 状态调用返回 400(沿用 `ensureStatus` 现成的错误形状)。
  51 +
  52 +---
  53 +
  54 +## 三、Phase 2:§四.4 闸门穿透性回归测试 —— ✅ 已实现(2026-08-30)
  55 +
  56 +**落地结果**:新建 `server/tests/unit/ceo-created-task-gates.test.js`,3 例全部走 `ceo` 实际会用的通路(`POST /tasks` 的 `draft:true` + `created_by:'ceo'` → `/enqueue`,不抄近道直接摆数据库行):
  57 +1. `max_parallel_tasks=1` 时坑已占满,`ceo` 建的第二条任务落 `queued`,不插队。
  58 +2. 预算给 `total_agent_calls:0`,`engine.checkBudget()` 照样判耗尽,`created_by='ceo'` 不改变结果。
  59 +3. `ceo` 建的两条 task 挂在同一个 goal 下,全部 `completed` 后 `goal-validation` 的 `validateGoal` 钩子只被调一次(用 `validation.setValidateGoal()` 注入假验收,照抄 `goal-task-acceptance.test.js` 的既有手法)。
  60 +
  61 +全量测试 1486/1490,零回归(剩余 4 个是核实过的 pre-existing 失败)。
  62 +
  63 +**过程小插曲**:驱动第二条 task 的 run 出队时,先试了直接调 `task-scheduler.tick()`——撞上一个跟这次改动无关的既有工具链毛刺(`require.cache` 手工失效对 vitest 的 CJS 模块图不完全生效,`task-scheduler.js` 惰性 `require` 的 `work-graph-db` 引用有时会绑定到清缓存之前的实例,触发 `graph-db.js` 的"测试进程别碰生产库"守卫)。绕开的办法是不依赖 `scheduleTick` 的 `setImmediate` 时序,坑空出来后直接调 `updateRunStatus(runB,'queued','ready')`——这条路径本来就是准入事务的真实实现,只是不经过调度器这层编排,测试意图("闸门拦不拦得住")不受影响。
  64 +
  65 +**现状**:39 号文档 §四.4 已拍板"`ceo` 全权自主 `enqueue`,但 `max_parallel_tasks`/`DEFAULT_TASK_BUDGET`/goal 级 `org-validator` 这几道闸门必须照样拦住它",落地时没有补这条测试(§六"已知遗留"记过一笔)。理论上这几道闸门挂在数据层/调度器层,不认"谁创建的",天然覆盖,但没有测试钉死就不能说"已确认"。
  66 +
  67 +**设计**:不涉及任何设计决策,纯粹是补测试。新增用例(大致形状,落地时按现有测试文件的辅助函数抄写法):
  68 +1. 把公司 `max_parallel_tasks` 设成 1,已有一条 task 占着坑,`ceo` 走 `draft`+`enqueue` 建第二条——断言它排队而不是插队执行。
  69 +2. 把公司 `DEFAULT_TASK_BUDGET` 设小,`ceo` 建的任务跑到预算耗尽——断言按现有 `budget-exhausted` 路径 `blocked`,不会因为"是 agent 建的"就获得额外预算。
  70 +3. `ceo` 建的多 task goal 全部 `completed` 后,断言 goal 级 `org-validator` 照样被调一次(不因创建者是 agent 而跳过收敛验收)。
  71 +
  72 +**落点**:新建 `server/tests/unit/ceo-created-task-gates.test.js`,或并入现有 `task-blocked-ceo-wake.test.js`(按当时哪个更顺手)。
  73 +
  74 +---
  75 +
  76 +## 四、Phase 3:`target_dir` 的 API/UI 入口 —— ✅ 已实现(2026-08-30)
  77 +
  78 +**落地结果**:
  79 +- 后端复用了已存在的 `PUT /api/company/:companyId`(`companyService.update()`),没有新建端点——设计里"如果已存在就扩展"这条命中了。`update()` 新增 `target_dir`/`target_mode` 分支,校验直接调 `path-resolver.validateLinkedTarget`(39 号文档 P5 抽出来的共享函数,和 `graph-engine.js` 运行时校验同一份判据),校验失败抛出,路由层 `try/catch` 转 400;`target_dir` 传空字符串清空配置;不传 `target_mode` 时默认 `linked-existing`,与 `graph-engine.js` 的 `readCompanyTargetConfig` 同一口径。测试:`server/tests/unit/company-target-dir-config.test.js`(8 例:`linked-existing`/`linked-new` 各自成功路径、默认 mode、敏感路径黑名单、`linked-new` 非空目录、`linked-existing` 不存在、清空配置、公司不存在)。
  80 +- 前端:`CompanyDashboard.vue` 的"概览"tab 加一张卡片(目标目录输入框 + 模式下拉 + 保存按钮),本地表单状态不直接双向绑定 `selectedCompany`(保存失败时不该让用户刚打的字被冲掉)。
  81 +
  82 +**验证方式的取舍**:`npm run web:build` 编译通过 + 后端测试直接打真实路由层(不 mock service)。**没有做真实浏览器点击验证**——当时服务器上有其它并行会话真实在跑的 agent 进程(`ps` 能看到 3 个 `claude` 子进程),而重启服务器才能让后端改动生效,42 号文档 §二 已经记过一次"重启服务器杀掉别人真实进程"的教训,这次选择不重复。**这是一个已知的验证缺口,不是"做完了"**,下次服务器有安全窗口(没有并行会话在跑真实进程)时应该补一次真实点击验证。
  83 +
  84 +---
  85 +
  86 +**现状**:P5 落地的是"配了 `company.json.target_dir` 就生效"这一半,"人怎么把这两个字段填进去"这一半没做——真实测试里是手工编辑公司态目录下的 JSON 文件配的。
  87 +
  88 +**设计**:
  89 +- **后端**:`PATCH /api/company/:companyId`(如果这个端点已存在就扩展,不存在就新建)接受 `target_dir`/`target_mode`,写回 `company.json`。校验复用 `path-resolver.js` 的 `validateLinkedTarget`(39 号文档 P5 已经抽出来的共享校验函数),配置阶段就能提前报错(不用等到第一次 `ensureProjectRoot` 才发现路径不对)。
  90 +- **前端**:公司设置页面(或 `TaskBoard.vue`/`GraphConsole.vue` 附近,具体挂哪个页面待定)加一个表单,填目录路径 + 选 `linked-new`/`linked-existing`。
  91 +
  92 +**验收**:新增测试覆盖 `PATCH` 端点的校验分支(合法路径写入成功、`isSensitivePath` 命中黑名单拒绝、`linked-new` 目录非空拒绝);前端至少过一次真实点击验证表单能提交成功。
  93 +
  94 +---
  95 +
  96 +## 五、Phase 4:`timeline.ndjson` 承接 planning/reflection 轨迹 —— ✅ 已实现(2026-08-30)
  97 +
  98 +**调研结论(落地前按设计要求先做的那一步)**:`grep` 全仓库 `system/agents/*/agent.md`,引用 `task transition` 的有 9 个真实角色(`_base`、`code-planner`、`coder`、`org-coder`、`org-planner`、`org-writer`、`pricing-api-integrator`、`unit-tester`、`workgraph-planning`,另有几个 `zz-*` 测试夹具不算)。**`_base` 在列**——这是所有新建 agent 复制的骨架模板,意味着这条能力管的不是几个角色的特例,是整个"多阶段流转"这一类角色的通用约定,往后任何继承 `_base` 的新角色自动适用。
  99 +
  100 +**关键发现,决定了整个实现方式**:`AgentManager.callAgent()` 的 `metadata`(含 `taskId`/`nodeRunId`/`rootRunId`)**只进服务端日志和 hook,agent 读不到**(代码原文注释)。但 agent 的节点私有产物目录本来就以 `data/companies/{cid}/runs/{rootRunId}/nodes/{nodeRunId}/` 这个形状写进它的提示词(`describeArtifactContract`),而 `--artifact` 参数本来就是这个目录下的文件路径——**两个 ID 已经现成地编码在调用方唯一知道、唯一会传的那个参数里**,不需要给 `AgentManager`/子进程塞任何新的环境变量,也不需要改一个字的 agent.md 提示词。
  101 +
  102 +**实现**(与设计里"两种做法二选一"对应:选的是**不改 `bin/lemon.js` 的 CLI 签名、不碰 `agent-chat.js`**这条更保守的路):
  103 +- `bin/lemon.js` 的 `task transition` 命令保持原有对 `/agent/:agentId/task/transition`(legacy)的调用完全不变,额外 best-effort 打一次新端点 `POST /api/graph-runs/phase-note`(body 只传 `artifact`/`phase`/`agentId`),失败静默吞掉,不影响命令本身的返回值/退出码。
  104 +- 新端点从 `artifact` 路径正则解出 `rootRunId`/`nodeRunId`,校验 `rootRunId` 对应真实存在的 run(不存在就是 legacy 会话式调用的路径形状,安静返回 `not-a-graph-artifact`,不是错误),校验通过后调 `graph-run-sse.emitEvent()` 追加一条 `kind:'agent.phase'` 的 timeline 记录,`ref` 是 `nodeRunId`。
  105 +- **`agent-chat.js` 完全没有改动**——这次特意避开了它:落地当天这个文件正被另一个并行会话实时编辑(`git status` 显示未提交修改),去碰一个正在被别人改的共享文件风险太高,改成把新能力放进这次 Phase 1-3 已经在用的 `graph-run.js`,物理上不产生交集。
  106 +- **"不改的部分"边界确认成立**:新端点只调 `emitEvent`(纯追加 timeline,`apply` 传 `null`),不碰 `graph_run_nodes`/`graph_runs` 任何一行——测试里断言了这一点(节点状态在调用前后不变)。
  107 +- **设计里 open 的第三个问题(P0 拦截/OKR 更新怎么处理)就此关闭**:答案是"完全不动"——这两段逻辑挂在 legacy `/task/transition` 里,图执行场景下(`sessions` Map 找不到会话)本来就从没触发过,这次也没打算让它们触发;新端点是完全独立的第二条路径,不是对旧端点的迁移或替换。
  108 +
  109 +**测试**:`server/tests/unit/graph-run-phase-note.test.js`(4 例:合法节点产物路径写进 timeline 且节点状态不受影响、路径形状不对时安静跳过、路径形状对但 run 不存在时安静跳过、没传 artifact 时报 `no-artifact`)。全量测试 1503/1506,零回归(剩余 3 个失败全部来自另一个并行会话正在进行中、还没提交的工作)。
  110 +
  111 +**现状(真跑撞到的,这是四个 phase 里最大的一个)**:`org-coder` 的四段式(RESEARCH→PLAN→EXECUTE→VERIFY)流转靠调 `bin/lemon.js task transition`,打到 `agent-chat.js` 的 `/task/transition` 端点——这个端点靠自己的 `sessions` Map 找"当前活跃会话",但 `org-coder` 作为 work graph 节点跑的时候走的是 `graph-engine.js` → `AgentManager` → `session-pool.js`,是 CLAUDE.md 里早就记录过的"两个互不相干的会话池"那个坑的又一个受害者。结果:`org-coder` 在 work graph 里调这条流转指令时,`sessions.entries()` 根本找不到它的会话,阶段流转的业务逻辑(P0 审批拦截、OKR 子状态更新)**静默跳过**;它的中间产物(`_research.md`/`_plan.md`/`_lessons.md`)不进 `graph_files` 契约(这个是刻意的,见下"不改的部分"),但也**不进任何跨节点/跨角色共享的轨迹**,人类或 `ceo` 事后想搞清楚"这一步当时是怎么想的",只能去读原始的 agent 会话 transcript(`GET /:id/nodes/:nodeRunId/transcript`),没有结构化的入口。
  112 +
  113 +**不改的部分(上一轮已经讨论定案,写在这里防止实现时跑偏)**:
  114 +- `_plan.md` 这类中间产物**不进 `needs`/`produces` 契约**,`org-validator` 判定依据仍然只认 `acceptance.md` + `artifact`/`log`。理由:validator 判定"代码对不对",不该依赖"coder 计划写得像不像样",这条边界一旦模糊,两个坏结果都会发生——coder 学会把计划写漂亮而不是把代码写对;`graph_files` 的契约面失控(这个仓库反复栽在"两套体系不统一"上)。
  115 +- 本 phase 只解决**可见性**(人类/`ceo` 事后能不能追溯),不解决**契约**(下游节点该不该依赖)——这是两件事,上一轮讨论已经拆开过。
  116 +
  117 +**设计**(需要先做的调研,落地前必做):
  118 +1. **调研范围**:`org-coder` 是这次撞到的一个,不代表只有它——扫一遍 `system/agents/*/agent.md` 里还有哪些角色的提示词也在调 `bin/lemon.js task transition`,把范围摸清楚再动手,不要只按 `org-coder` 这一个样本改。
  119 +2. **迁移方向**:把"阶段流转"这个动作,从打 `agent-chat.js` 的 `/task/transition`(legacy,不认 graph run),改成写一条 `timelineNote()`(`graph-engine.js` 已有机制,per-run、append-only、永不改已写行),带上 `phase`(research/plan/execute/verify 或角色自定义的阶段名)+ `artifact` 路径。**具体是改 `bin/lemon.js task transition` 这条命令本身去打新端点,还是给它接一条新命令**,两种做法都要过一遍现有 agent 提示词的改动成本再定——这些提示词文件散在几十个 `agent.md` 里,改一条命令的调用方式等于牵动所有引用它的本体。
  120 +3. **P0 审批拦截 / OKR 子状态更新这两块旧逻辑怎么处理**:`/task/transition` 里现在还挂着 P0 任务拦截、OKR 子状态更新这两段业务逻辑——这两者本身是不是还需要(尤其 OKR 那段,跟 P3C 的新 goal/task 模型是不是还有效对应),需要单独判断,不能假设"迁移流转机制"就等于"这两段逻辑原样照抄"。
  121 +
  122 +**验收**(待设计细化后补全,这里先占位):至少要有一条测试证明——`org-coder`(或调研出的任意一个角色)在 work graph 里完成一次阶段流转后,`timeline.ndjson` 里能查到对应的 `phase`+`artifact` 记录,且这条记录不影响 `graph_files`/`needs`/`produces` 的既有行为(回归测试守住"不改的部分"那条边界)。
  123 +
  124 +---
  125 +
  126 +## 六、开放问题
  127 +
  128 +1. Phase 1a 的孤儿判定条件(`startedAt` 早于进程启动时间)在多进程/多副本部署下是否还成立?现在是单进程部署,这条先按当前架构写,多副本场景不在这次范围内。
  129 +2. Phase 3 的 `PATCH /api/company/:companyId` 端点如果本来就不存在,是新建这一个端点,还是这次顺手看一下公司配置类的改动有没有更合适的既有入口——落地前查一遍 `company.js` 现状再定,不在这里预判。
  130 +3. Phase 4 的调研如果发现有 10+ 个角色都在用旧的 `/task/transition`,工作量会明显超出"一个 phase"的量级,届时可能需要再拆细——先按"调研完再评估"处理,不在这里预先估工作量。
docs/todo/system-design/28-followups.md
@@ -21,7 +21,7 @@ @@ -21,7 +21,7 @@
21 ### 1.1 dream 机制 21 ### 1.1 dream 机制
22 22
23 **状态(2026-08-20 更新)**:设计已成文并已实现,见 23 **状态(2026-08-20 更新)**:设计已成文并已实现,见
24 -[29-dream-design.md](./29-dream-design.md)。四条取舍(去敏判据 / 沉淀落点 / 触发时机 / 24 +[29-dream-design.md](../../done/system-design/29-dream-design.md)。四条取舍(去敏判据 / 沉淀落点 / 触发时机 /
25 质量评估)均已拍板,五步推进顺序 + 定时挂载 + 定义文件写入均已落地, 25 质量评估)均已拍板,五步推进顺序 + 定时挂载 + 定义文件写入均已落地,
26 `innovator-evolver` 上真 CLI 跑通一次(40 条原料 → 4 条抽象经验,8 条引用全部核对回原文)。 26 `innovator-evolver` 上真 CLI 跑通一次(40 条原料 → 4 条抽象经验,8 条引用全部核对回原文)。
27 27
docs/todo/system-design/31-work-graph-fail-fallback-coverage.md
@@ -15,7 +15,7 @@ @@ -15,7 +15,7 @@
15 > (零 `retryNode` 调用),最终 planner 子图收敛到 `completed`。**修复确认生效,有真实证据**。 15 > (零 `retryNode` 调用),最终 planner 子图收敛到 `completed`。**修复确认生效,有真实证据**。
16 > 同一次验证顺带在 `org-coder` 子图撞见一个**独立的新 bug**(`inboundContracts` 未按 `role` 16 > 同一次验证顺带在 `org-coder` 子图撞见一个**独立的新 bug**(`inboundContracts` 未按 `role`
17 > 过滤,把 `produces` 声明误当 `needs`,导致 `missing_required_input` 假阳性),与本文档无关, 17 > 过滤,把 `produces` 声明误当 `needs`,导致 `missing_required_input` 假阳性),与本文档无关,
18 -> 已记录在 [33-needs-gate-role-filter-bug.md](./33-needs-gate-role-filter-bug.md)。 18 +> 已记录在 [33-needs-gate-role-filter-bug.md](../../done/system-design/33-needs-gate-role-filter-bug.md)。
19 19
20 --- 20 ---
21 21
docs/todo/system-design/34-validator-scored-without-real-deliverable.md
@@ -17,7 +17,7 @@ @@ -17,7 +17,7 @@
17 > 前置闸门统一验证严谨度,留给下次设计。 17 > 前置闸门统一验证严谨度,留给下次设计。
18 > **触发**: `e2e-demo` 公司真实回归任务 `T-mt2r6fz0-125b`(goal `G-mt2r62ia-73af`, 18 > **触发**: `e2e-demo` 公司真实回归任务 `T-mt2r6fz0-125b`(goal `G-mt2r62ia-73af`,
19 > root run `run_0e9074815130`)——[31 号文档](./31-work-graph-fail-fallback-coverage.md) 19 > root run `run_0e9074815130`)——[31 号文档](./31-work-graph-fail-fallback-coverage.md)
20 -> 兜底边修复 + [33 号文档](./33-needs-gate-role-filter-bug.md) 契约修复之后,把这条任务 20 +> 兜底边修复 + [33 号文档](../../done/system-design/33-needs-gate-role-filter-bug.md) 契约修复之后,把这条任务
21 > 真正推到 `completed`,回头查"最终产出在哪"时发现的 21 > 真正推到 `completed`,回头查"最终产出在哪"时发现的
22 22
23 --- 23 ---
docs/todo/system-design/43-task-decomposition-granularity.md 0 → 100644
  1 +# 43 · Goal 分解粒度:规划产出该长成文件,还是长成 task/subtask 树
  2 +
  3 +> **状态**: 📝 需求 + 现状调研,尚未设计、未实现
  4 +> **日期**: 2026-08-31
  5 +> **触发场景**: snakegame-co 公司,goal `G-mtfh23mu-bd63`("HTML 贪吃蛇游戏"),task `T-mtfh2kuf-7c31`。
  6 +> 该任务当天被 `missing_required_input` 死循环卡住(根因与修复见 [33 号文档](../../done/system-design/33-needs-gate-role-filter-bug.md) 的复发一例,
  7 +> `tools.md` 范例与 `work-graph-db.js` 引擎层已同日修复),排查过程中用户提出了本篇的需求。
  8 +> **格式对齐** `27-requirement.md`:原话照搬 + 结构化复述,现状调研另起一节。
  9 +
  10 +---
  11 +
  12 +## 原始需求(用户原话,一字不改)
  13 +
  14 +> 就这个任务,我讲讲我理想的状态,应该是 org planner 完成了brd prd sdd 后,在 task 任务看板应该能看到规划出来的 todo list 作为这个 html 贪吃蛇游戏 goal 的 task 和 sub task 展示出来,然后每一个 org coder 都是按照这个 task todo list 在逐个的开发完成,然后 org validator 应该是针对 task todo list 在逐个验收,这个现在是不是实现不了?
  15 +
  16 +---
  17 +
  18 +## 结构化复述
  19 +
  20 +用户设想的理想流程,拆成四步:
  21 +
  22 +1. **规划阶段**:org-planner 完成 brd/prd/sdd 之后,不只是产出文档,还应该把这份规划**分解成一份 todo 清单**。
  23 +2. **展示阶段**:这份 todo 清单应该以 **task 和 sub-task** 的形态出现在任务看板上,挂在"HTML 贪吃蛇游戏"这个 goal 下面(例子:方向键控制、吃食物加分、碰撞结束、重新开始——即当前 `acceptance.json` 里的 A.1-A.5)。
  24 +3. **开发阶段**:org-coder 不是一次性对着一大段任务描述写完整个交付物,而是**按 todo 清单逐条**开发。
  25 +4. **验收阶段**:org-validator 也不是一次性给整个交付物判总分,而是**按 todo 清单逐条**验收。
  26 +
  27 +用户明确问:**现状是不是做不到这个?**
  28 +
  29 +---
  30 +
  31 +## 现状调研结论(本次会话核实,非最终设计)
  32 +
  33 +结论:**部分可以,"做不到"的那部分是当前 agent 设计上的主动选择,不是漏做的功能**。四层现状分别是:
  34 +
  35 +### 1. `tasks.parent_task_id`——字段在,但服务的是另一条流水线
  36 +
  37 +`tasks` 表(`server/services/work-graph-db.js`)确实有 `parent_task_id` 列,也确实有代码在读写它——但那是 **LemonBoss Agent Chat 的人工派单流程**(`server/routes/agent-chat.js` + `task-service.js` 建任务树),跟本篇讨论的 goal → task → org-graph/work-graph 这条流水线是两套独立机制。
  38 +
  39 +本篇关心的这条流水线里,真正负责"从 goal 生成 task 行"的函数是 `work-graph-db.js` 的 `createTask({goalId, companyId, seq, title, description, executionMode, dependsOn, priority, budgetJson, reportTo, createdBy})`——**这个函数签名里根本没有 `parentTaskId` 参数**。也就是说,子任务对这条流水线而言是**结构性够不着**,不是"字段有、没人填"。
  40 +
  41 +### 2. task-planning 阶段本身有能力拆多任务,但拆分依据不是"检查项"
  42 +
  43 +真正把 goal 拆成 task 的阶段是 `task-planning`(`server/services/task-planning.js` + `system/agents/task-planning/`,落库调用点在 `server/routes/company.js:1577 insertTasksForGoal`)。这一步**确实能一次拆出多条 task**(已有测试验证过 2 条以上的场景)。
  44 +
  45 +snakegame-co 这次只拆出 1 条 task,不是能力限制,是 **`system/agents/task-planning/agent.md` 第二步的明确设计判断**:拆分依据是"交付物/验收边界",**不是"工作阶段"**。原文点名"①调研②设计③开发④测试⑤上线"这种按阶段拆是"最常见也最致命的错法"。按这份人设的判断,方向键/得分/碰撞/重开这四条,是**同一个交付物的验收清单**(正是最终 `acceptance.json` 里一起被同一份 Playwright 脚本验证的 A.1-A.5),不是四个可以独立排期验收的 task——agent 设计上就会拒绝把它们拆成四条 task。该 agent 人设还硬顶单 goal ≤ 8 条 task,整体偏"合并"不偏"细分"。
  46 +
  47 +### 3. org-planner 没有"建任务"这个工具
  48 +
  49 +org-planner 在跑的时候(`system/agents/org-planner/tools.md`)能力边界是:调研 → 阶段流转 → 写 brd/prd/sdd/acceptance.md 这几份**文件** → 向 CEO 汇报。**没有任何一个工具允许它创建新的 task 行**(org-coder / org-validator 同样没有)。规划阶段的产出,结构上只能落成文档,附着在它所属的那一条已经固定的 task 上,没法在跑的过程中反过来生出新的、可独立调度的子任务。
  50 +
  51 +### 4. 看板 UI 没有树形渲染
  52 +
  53 +`web/src/views/TaskBoard.vue`、`web/src/components/graph-console/GoalTaskPane.vue` 目前都没有父子任务的渲染逻辑——即便前三层都打通、数据库里真的出现了带 `parentTaskId` 的行,看板现在也画不出树形结构,只会当成平铺列表处理(或者干脆不显示子任务)。
  54 +
  55 +---
  56 +
  57 +## 设计决策(2026-08-31 定案,三选一各选完)
  58 +
  59 +| 问题 | 决定 | 理由 |
  60 +|------|------|------|
  61 +| 子任务挂在哪套机制上 | **goal → task 流水线里新建一套独立父子机制**(不复用 Agent Chat 的 `parent_task_id`) | 后者是为「人工派单、多轮对话」设计的语义,跟 org-graph 自动执行的生命周期(谁创建、何时终结)对不上,硬凑容易埋雷 |
  62 +| 子任务要不要独立开发+验收循环 | **先只拆验收,开发仍整体完成**——org-coder 一次性写完整个交付物,验收侧改成按子任务(验收条目)逐条判,而不是给总分 | 改动小得多,且先解决「验收颗粒度太粗」这个更痛的点;独立开发循环(每个子任务各起一个 work-graph run)留作后续再评估 |
  63 +| 什么粒度的 goal 该拆 | **由 task-planning agent 自己判断**,不写死规则 | 跟它现在「要不要拆多个 task」的判断逻辑同源,交给同一套"交付物/验收边界"标准,不额外发明一套阈值规则 |
  64 +
  65 +由这三条决定组合出的实现方向:**task-planning 判断"值不值得走细粒度验收"是一个粗粒度的开关,真正的子任务清单在 org-planner 写完 acceptance.json 之后才生成**——因为验收条目本身就是规划阶段的产出,task-planning 跑的时候 brd/prd/sdd 还不存在,没法预先知道要拆成哪几条。这意味着"判断要不要拆"和"生成子任务"是两个时间点、两个步骤:
  66 +
  67 +1. **task-planning(goal 创建时)**:新增一个判断维度——这个 task 的验收后续要不要走"逐条子任务"模式,写一个标记(例如 task 行上加 `acceptance_mode: 'holistic' | 'itemized'`,或等价字段),不生成任何子任务行。
  68 +2. **org-planner 收尾(acceptance 产物写完之后,org-graph 流程内新增一步)**:如果上一步标记是 `itemized`,读验收条目,逐条 `createTask({parentTaskId, ...})` 生成子任务行,挂在同一个 goal 下。
  69 +3. **`coder-validator`(work-graph 内既有节点,就是本 session 全程调试的那个 agent)**:判断逻辑从"给整个 task 一个总分"改成"遍历子任务逐条判",每条子任务各自 success/fail,父任务的终态由子任务汇总(全 success → success,否则看具体子任务)。
  70 +4. **看板 UI**:`TaskBoard.vue` / `GoalTaskPane.vue` 加父子任务树形渲染,只读展示,不涉及交互改动。
  71 +
  72 +## 实现细节决策(2026-08-31 定案)
  73 +
  74 +| 问题 | 决定 | 理由 |
  75 +|------|------|------|
  76 +| 标记字段怎么设计 | **`tasks` 表加 `acceptance_mode` 字段**,取值 `'holistic' \| 'itemized'`,命名风格对齐既有 `execution_mode`(manual/auto),由 task-planning 生成 task 行时一并写入 | 不额外发明隐式推导规则,创建时就能在库里直接看到这条 task 走哪种验收模式,调试/展示都直观 |
  77 +| 部分子任务失败时父任务终态 | **必须全部子任务 success,父任务才 success**;任一子任务 fail → 父任务 fail | 跟当前"一次性给总分、不合格就是不合格"的严谨程度保持一致,也呼应 [34 号文档](./34-validator-scored-without-real-deliverable.md) 关于验证不能打折的教训;`optional` 子验收项留作后续需要时再加,先不预留这层复杂度 |
  78 +| 子任务要不要复用现成的状态语义 | **复用**(pending/ready/running/success/fail/blocked 这套状态词汇) | 直接复用能白拿状态流转、看板渲染的既有约定,不用另起一套并行的状态语义 |
  79 +
  80 +> **落地时的订正**(技术设计阶段核实后发现):上一行"复用现成状态机"字面上写的是 `graph_run_nodes` 那套,但子任务本质是 `tasks` 表的行(带 `parent_task_id`),不是某个 work-graph run 内部的节点实例——它没有自己独立的 run。所以真正落地时复用的是 **`tasks.status` 这套既有枚举**(`queued/running/blocked/completed/failed/cancelled`),只是**沿用同一套状态词汇的精神**(有限状态、终态幂等、`settleNode`/`settleTaskFromRun` 那一类条件更新闸门),不是字面上去 `graph_run_nodes` 表插行。见下方技术实现计划 Phase 3。
  81 +
  82 +> **另一处订正**:上面第 3 步原来写的是"org-validator",但技术设计阶段查证后发现 **`org-validator` 和 `coder-validator` 是两个不同目录、不同职责的独立 agent**——`org-validator` 是 org-graph 顶层节点(整体验收),`coder-validator` 是 org-coder 这一层 work-graph 内部的节点(就是本 session 全程调试的那个,`work-validator/v1` 输出格式)。真正产出逐项验收证据、需要按子任务逐条判的是 **`coder-validator`**,已在上面订正。
  83 +
  84 +至此,43 号文档的方案层面已经全部定案。下面是核实过具体代码形态后的技术实现计划。
  85 +
  86 +---
  87 +
  88 +## 技术实现计划(2026-08-31,代码形态已核实,未开工)
  89 +
  90 +### Phase 0 · Schema
  91 +
  92 +- `tasks` 表加 `acceptance_mode` 列,`CHECK (acceptance_mode IN ('holistic','itemized')) DEFAULT 'holistic'`——语法上照抄 `execution_mode` 已有的 CHECK 写法(`work-graph-db.js:533`),迁移方式照抄 `parent_task_id` 自己当年落地时用的 `hasColumn` 判断 + `ALTER TABLE` 兜底那一段(`work-graph-db.js:626-627`),同一个文件里已有两份现成模板可抄,不用发明新的迁移写法。
  93 +- `createTask({...})` 签名(`work-graph-db.js:1861-1873`)加 `parentTaskId`、`acceptanceMode` 两个参数,INSERT 列表(`work-graph-db.js:1902-1907`)跟着加。`parent_task_id` 列本身早就存在(是 Agent Chat 那条派单流程在用),这里只是**第一次让 goal→task 流水线自己也写这一列**——顺手确认一下现有读它的 `task-service.js:66-81` 那段任务树构建逻辑,会不会因为多了一类新来源的父子行而产生非预期的合并/展示(大概率不会,因为按 `company_id`/`goalId` 天然分域,但落地前应该跑一次它的既有测试)。
  94 +- 守卫测试:图流水线创建的顶层 task(`insertTasksForGoal` 路径)`parent_task_id` 仍是 `NULL`;子任务的 `parent_task_id` 非空且指向父任务、`acceptance_mode` 恒为 `'holistic'`(子任务不允许再递归拆一层)。
  95 +
  96 +### Phase 1 · task-planning 判断 `acceptance_mode`
  97 +
  98 +`task-planning` 目前是纯"回复 JSON 代码块"模式(`system/agents/task-planning/tools.md:5`),产出 4 字段 `[{seq, title, description, depends_on}]`(`agent.md:120-142`),而且**硬规则 5**(`agent.md:10`)明确禁止 LLM 多写字段——"服务端生成,你写了会被当成脏字段打回",`mode`/`priority`/`budget` 这些运营字段都是 `company.js:1396-1429 buildTaskPlans()` 在服务端按 goal 级默认值统一注入的(`company.js:1421-1423`),不是 LLM 决定的。
  99 +
  100 +`acceptance_mode` 要不要拆本质上是一次语义判断(跟"要不要拆多个 task"用的是同一种判断力,这正是当初选"由 agent 自己判断、不写死规则"的理由),**没法**像 `mode`/`priority`/`budget` 那样简单地由服务端注入默认值——所以这里必须**显式在硬规则 5 上开一个例外**:
  101 +
  102 +- `agent.md` 的 JSON schema 从 4 字段扩到 5 字段,加 `acceptance_mode`,并在硬规则 5 旁边补一句明确的例外说明(为什么这一个字段跟其它运营字段不同,避免以后被"这些字段都是服务端生成"的惯例带偏、被误当脏字段清掉)。
  103 +- `buildTaskPlans()`(`company.js:1396-1429`)改成**透传** LLM 给的 `acceptance_mode`,而不是像其它字段那样用服务端默认值覆盖;LLM 没给这个字段时兜底 `'holistic'`(保守,不引入新行为,除非规划阶段明确判断要拆)。
  104 +
  105 +### Phase 2 · org-planner 收尾生成子任务(⚠️ 有一个真实风险要先处理)
  106 +
  107 +org-planner 的 4 阶段 FSM(`system/agents/org-planner/tools.md:19-23`,`node bin/lemon.js task transition --phase {plan|execute|verify}`)产出的验收清单叫 **`acceptance.md`**(不是 `.json`)——Markdown 表格 `| # | 验收项 | 判定方式 | pass 条件 |`(`tools.md:44-50`),一行一条验收项,是子任务清单最自然的数据源。这**不是**同名的 R-19-B `acceptance.json` 契约(`goal-contract.js:76` 定义的 `benchmark|test_data|test_script` 三段式,由另外三个独立的 `goal-benchmark`/`goal-test-data`/`goal-test-script` 节点产出)——两者名字像、来源和用途完全不同,写代码时一定要认清楚读的是哪一份。
  108 +
  109 +**⚠️ 已知风险,动工前必须先确认**:本 session 全程调试的 snakegame-co 这条真实任务,`acceptance.md` 和 `acceptance.json` **实际上都没有被真正当成验收依据用**——`coder-validator` 最终判定用的是自己临时写的一套 Playwright 测试(A.1-A.5),`goal-contract.js` 那份 `acceptance.json` 甚至报了 `note:"goal-contract.js cannot parse index.html as JSON...treat as no task-level contract"`,被当场判定"这个任务没有 task 级契约"直接跳过。如果子任务清单要建在 `acceptance.md` 之上,得先跑一次真实回归确认它确实会被稳定产出、格式跟文档描述一致——不能假设它已经可靠,这跟 33 号文档"建立在一个从来没人真正产出过的 `kind='log'` 之上"是同一类风险,值得先花一次真跑单独验证,而不是直接在上面盖楼。
  110 +
  111 +如果验证通过,新增步骤:org-planner 的 `verify` 阶段落盘 `acceptance.md` 之后,插入一步"物化子任务"——仅当父任务 `acceptance_mode==='itemized'` 时,逐行读表格,为每一条验收项调用 `createTask({parentTaskId: 当前 task.id, acceptanceMode:'holistic', title: 该行"验收项"文本, description: 该行"判定方式"+"pass 条件", ...})`。
  112 +
  113 +两个待确认的实现细节:
  114 +
  115 +- 这一步该是给 org-planner 加一个新工具(让它自己触发"物化子任务"),还是挂在服务端某个 hook 上(`verify` 阶段落盘后自动触发,agent 无感)?倾向后者——子任务生成是纯数据搬运,不需要占用一次 agent 调用去决定"要不要生成"(`acceptance_mode` 这个语义判断已经在 Phase 1 做完了),机械地转换 Markdown 表格到 task 行不需要 LLM 参与。
  116 +- 本次调研**没能确认** org-planner"完成→进入 org-coder"具体由哪个信号触发(`verify` transition 本身,还是 `tools.md:59-65` 那个额外的 `task/report` 调用)——插入新步骤前必须先查清楚,避免插错时机打断现有的阶段流转。
  117 +
  118 +### Phase 3 · coder-validator 改成逐条判 + 回写子任务状态
  119 +
  120 +关键发现:`coder-validator` 现在产出的 `checks[]`/`verdict` 结构化 JSON(就是本 session 里 `attempt-2.json` 那种形状),**目前没有任何服务端代码解析它**——`graph-engine.js` 判定这个节点成功与否只看 executor 返回的顶层 `status` 字段,`checks[]` 纯粹是给人看的审计文本,`goal-contract.js` 走的是完全独立的另一条 CLI 路径(自己生成 `verdict_id`),跟 `coder-validator` 的输出没有关系。
  121 +
  122 +所以"逐条判"要真正影响子任务状态,**不能只是把 agent 提示词写得更细**——必须新增一个真实的工具调用:`coder-validator` 每判完一条验收项,调用一次新工具(例如 `report-subtask-result`),带上子任务 id + success/fail,服务端据此走一次 `tasks` 表的条件更新(复用 `settleTaskFromRun` 同款的"终态幂等闸门"思路:已经是终态的子任务不能被覆盖)。这跟本 repo 一贯的做法一致——状态改变走真实工具调用,不解析自由文本 JSON(`checks[]` 格式在 `org-validator`/`coder-validator`/`planner-validator`/`testdata-validator`/`benchmark-validator` 五个 validator 之间只是"约定俗成、没有强制"的 `work-validator/v1`,不是哪里都强制校验的 schema,解析它不安全)。
  123 +
  124 +父任务终态:全部子任务 `completed` → 父任务按现有 `settleNode` 逻辑判 success;任一子任务 `failed` → 父任务 fail(Phase 0 就已经决定)。
  125 +
  126 +### Phase 4 · 看板树形渲染
  127 +
  128 +- `web/src/components/FileNode.vue` 是现成的自递归 `<script setup>` 组件模板(`v-for="child in node.children"` + `depth*14+6` 缩进),照这个结构写一个 `TaskNode.vue`,不用发明新模式。
  129 +- `GoalTaskPane.vue:78-131` 现在是纯平铺 `<ul class="tasks"><li v-for="t in g.tasks || []">`,改成先按 `parentTaskId` 把 `g.tasks` 分组建成树,再用 `TaskNode` 递归渲染。`TaskBoard.vue` 本身不用改——它是纯外壳,全权委托给 `GoalTaskPane.vue`(`TaskBoard.vue:23-32`)。
  130 +- `taskView()`(`company.js:994-1031`,`GET /:companyId/goals?withTasks=1` 等多个路由共用的唯一整形函数)要把 `parent_task_id`、`acceptance_mode` 加进输出字段列表——目前两个字段都完全没有出现在响应里,包括未使用的形式都没有。
  131 +
  132 +### 建议的实施顺序
  133 +
  134 +**Phase 0 → 1 → 4 → 2 → 3。**
  135 +
  136 +Phase 4(看板渲染)提前到 Phase 2/3 之前:先用手工插入的测试数据验证树形展示对不对,把"看得见"和"能自动生成"两件事解耦,避免一次性大集成、出问题不知道是生成的问题还是展示的问题。
  137 +
  138 +Phase 2 排在 Phase 3 之前,且内部先做"acceptance.md 是否真的可靠"这次独立验证——这是唯一一个建在未经证实的既有产物之上的环节,值得单独花一次真跑确认,再决定要不要照原计划往下建。
server/index.js
@@ -18,6 +18,11 @@ const app = express(); @@ -18,6 +18,11 @@ const app = express();
18 const server = http.createServer(app); 18 const server = http.createServer(app);
19 const pluginManager = new PluginManager(); 19 const pluginManager = new PluginManager();
20 20
  21 +// 42 号文档 Phase 1a:本次进程的启动时刻,早于它的 graph_run_nodes.status='running' 一律是
  22 +// 上一个进程实例留下的孤儿(这个新进程刚起,不可能自己已经有节点在跑)。模块顶层捕获一次,
  23 +// 不要在 sweepOrphanedRunningNodes 调用点现取——那样每次重试都会把截止线往后挪。
  24 +const PROCESS_STARTED_AT = new Date().toISOString();
  25 +
21 // ─── 中间件 ─── 26 // ─── 中间件 ───
22 app.use(cors(config.cors)); 27 app.use(cors(config.cors));
23 app.use(cookieParser()); 28 app.use(cookieParser());
@@ -160,6 +165,17 @@ async function start() { @@ -160,6 +165,17 @@ async function start() {
160 const taskSettle = require('./services/task-settle'); 165 const taskSettle = require('./services/task-settle');
161 taskSettle.resumePendingGoalValidations() 166 taskSettle.resumePendingGoalValidations()
162 .catch(err => console.error('[Startup] 重推 goal 验收失败:', err.message)); 167 .catch(err => console.error('[Startup] 重推 goal 验收失败:', err.message));
  168 + // 42 号文档 Phase 1a:上一个进程实例被换代码重启杀掉时,正在跑的 graph_run_nodes
  169 + // 会留在 status='running'(背后进程已死,行本身不会自己变)——ceo 的两条唤醒路径都
  170 + // 只认 status='blocked',对这个状态完全不可见。开机先清一遍,早于下面几个「补一次
  171 + // 全量扫描」的钩子,让被它们观察的下游(task/goal 是不是卡住了)看到的是清好的状态。
  172 + try {
  173 + const engine = require('./services/graph-engine');
  174 + const { scanned, swept } = engine.sweepOrphanedRunningNodes(PROCESS_STARTED_AT);
  175 + console.log(`[Startup] 🧟 孤儿 graph 节点清扫:扫到 ${scanned} 条,处理 ${swept.length} 条`);
  176 + } catch (err) {
  177 + console.error('[Startup] 孤儿 graph 节点清扫失败:', err.message);
  178 + }
163 // T-mt622nil:上面那条只补「收敛钩子已经跑过、卡在验收/汇报半路」的。更前面那一步—— 179 // T-mt622nil:上面那条只补「收敛钩子已经跑过、卡在验收/汇报半路」的。更前面那一步——
164 // 钩子压根没被触发过(进程重启 / 异常吞掉 / 事件丢失)——task 早到终态但 goal 永远停在 180 // 钩子压根没被触发过(进程重启 / 异常吞掉 / 事件丢失)——task 早到终态但 goal 永远停在
165 // active,只能靠人工 OKR 巡检才发现(e2e-demo 公司 G-mt2r62ia-73af 卡了 48 小时)。 181 // active,只能靠人工 OKR 巡检才发现(e2e-demo 公司 G-mt2r62ia-73af 卡了 48 小时)。
server/routes/agent-chat.js
@@ -1067,6 +1067,30 @@ function buildUploadContext(id, convId) { @@ -1067,6 +1067,30 @@ function buildUploadContext(id, convId) {
1067 return contextBlock; 1067 return contextBlock;
1068 } 1068 }
1069 1069
  1070 +// 模型上下文预算 —— 现在跑的都是 1M 上下文的模型,历史记录分一半,剩下留给 system prompt / 工具产出 / 回复。
  1071 +// 换更小上下文的模型时改 AGENT_MODEL_CONTEXT_TOKENS 就行,不用改这段逻辑。
  1072 +const MODEL_CONTEXT_TOKENS = parseInt(process.env.AGENT_MODEL_CONTEXT_TOKENS || '1000000', 10);
  1073 +const HISTORY_TOKEN_BUDGET = Math.floor(MODEL_CONTEXT_TOKENS * 0.5);
  1074 +// ponytail: 4 字符≈1 token 的粗估算,不是真实分词器;预算掐得准不准全看这条,
  1075 +// 真出现估算偏差导致截断过早/过晚,再换成真实 tokenizer(如 tiktoken)计数
  1076 +const CHARS_PER_TOKEN = 4;
  1077 +function estimateTokens(text) {
  1078 + return Math.ceil(text.length / CHARS_PER_TOKEN);
  1079 +}
  1080 +
  1081 +/** 按 token 预算从最新往最老收,超预算就把更老的历史丢掉(至少保留最近一条) */
  1082 +function truncateHistoryToBudget(history, budget) {
  1083 + const kept = [];
  1084 + let used = 0;
  1085 + for (let i = history.length - 1; i >= 0; i--) {
  1086 + const cost = estimateTokens(history[i].content);
  1087 + if (kept.length > 0 && used + cost > budget) break;
  1088 + used += cost;
  1089 + kept.unshift(history[i]);
  1090 + }
  1091 + return kept;
  1092 +}
  1093 +
1070 function buildPrompt(agentDir, agentId, convId, userMessage, isNewSession) { 1094 function buildPrompt(agentDir, agentId, convId, userMessage, isNewSession) {
1071 // 注入上传文件上下文(按寻址名取,与上传端点的落点是同一份推导) 1095 // 注入上传文件上下文(按寻址名取,与上传端点的落点是同一份推导)
1072 const uploadContext = buildUploadContext(agentId, convId); 1096 const uploadContext = buildUploadContext(agentId, convId);
@@ -1086,12 +1110,11 @@ function buildPrompt(agentDir, agentId, convId, userMessage, isNewSession) { @@ -1086,12 +1110,11 @@ function buildPrompt(agentDir, agentId, convId, userMessage, isNewSession) {
1086 return `${systemPrompt}${cronInstructions}\n\n---\n\n## 用户消息\n\n${enrichedMessage}`; 1110 return `${systemPrompt}${cronInstructions}\n\n---\n\n## 用户消息\n\n${enrichedMessage}`;
1087 } 1111 }
1088 1112
1089 - // 场景 C: 恢复上下文 — 注入历史摘要  
1090 - const recentHistory = history.slice(-20);  
1091 - const historyText = recentHistory.map(m => { 1113 + // 场景 C: 恢复上下文 — 按模型上下文动态算预算,装得下就整段注入,装不下从最早的开始丢
  1114 + const boundedHistory = truncateHistoryToBudget(history, HISTORY_TOKEN_BUDGET);
  1115 + const historyText = boundedHistory.map(m => {
1092 const role = m.role === 'user' ? 'Human' : 'Assistant'; 1116 const role = m.role === 'user' ? 'Human' : 'Assistant';
1093 - const content = m.content.length > 500 ? m.content.slice(0, 500) + '...(截断)' : m.content;  
1094 - return `${role}: ${content}`; 1117 + return `${role}: ${m.content}`;
1095 }).join('\n\n'); 1118 }).join('\n\n');
1096 1119
1097 return `${systemPrompt}${cronInstructions}\n\n---\n\n## 之前的对话历史(供你参考以保持上下文连贯)\n\n${historyText}\n\n---\n\n## 用户新消息\n\n${enrichedMessage}`; 1120 return `${systemPrompt}${cronInstructions}\n\n---\n\n## 之前的对话历史(供你参考以保持上下文连贯)\n\n${historyText}\n\n---\n\n## 用户新消息\n\n${enrichedMessage}`;
@@ -2916,3 +2939,6 @@ module.exports.saveMeta = saveMeta; @@ -2916,3 +2939,6 @@ module.exports.saveMeta = saveMeta;
2916 module.exports.appendHistory = appendHistory; 2939 module.exports.appendHistory = appendHistory;
2917 module.exports.logMessage = logMessage; 2940 module.exports.logMessage = logMessage;
2918 module.exports.flushLogBufferForConv = flushLogBufferForConv; 2941 module.exports.flushLogBufferForConv = flushLogBufferForConv;
  2942 +module.exports.buildPrompt = buildPrompt;
  2943 +module.exports.truncateHistoryToBudget = truncateHistoryToBudget;
  2944 +module.exports.estimateTokens = estimateTokens;
server/routes/company.js
@@ -191,8 +191,16 @@ router.get(&#39;/:companyId&#39;, (req, res) =&gt; { @@ -191,8 +191,16 @@ router.get(&#39;/:companyId&#39;, (req, res) =&gt; {
191 }); 191 });
192 192
193 // PUT /api/company/:companyId 193 // PUT /api/company/:companyId
  194 +// 42 号文档 Phase 3:body 里带 target_dir 时,companyService.update() 会做真实的路径校验
  195 +// (复用 path-resolver.validateLinkedTarget,敏感路径黑名单 / linked-new 非空 / linked-existing
  196 +// 不存在等都在这一步拦,不用等到真正跑任务分配项目根才发现),校验失败抛出,这里接住转 400。
194 router.put('/:companyId', (req, res) => { 197 router.put('/:companyId', (req, res) => {
195 - const company = companyService.update(req.params.companyId, req.body); 198 + let company;
  199 + try {
  200 + company = companyService.update(req.params.companyId, req.body);
  201 + } catch (err) {
  202 + return res.status(400).json({ error: err.message });
  203 + }
196 if (!company) return res.status(404).json({ error: '公司不存在' }); 204 if (!company) return res.status(404).json({ error: '公司不存在' });
197 res.json({ success: true, company }); 205 res.json({ success: true, company });
198 }); 206 });
@@ -1014,6 +1022,7 @@ function taskView(t, progress = null, positions = null) { @@ -1014,6 +1022,7 @@ function taskView(t, progress = null, positions = null) {
1014 queuePosition: (positions && positions.get(t.id)) || null, 1022 queuePosition: (positions && positions.get(t.id)) || null,
1015 blockedReason: t.blocked_reason, 1023 blockedReason: t.blocked_reason,
1016 reportTo: t.report_to, 1024 reportTo: t.report_to,
  1025 + assigneeAgentId: t.assignee_agent_id || null,
1017 createdAt: t.created_at, 1026 createdAt: t.created_at,
1018 queuedAt: t.queued_at, 1027 queuedAt: t.queued_at,
1019 startedAt: t.started_at, 1028 startedAt: t.started_at,
server/routes/dream.js
1 /** 1 /**
2 - * dream —— 经验汇流的 REST 入口([29-DREAM](../../docs/todo/system-design/29-dream-design.md))。 2 + * dream —— 经验汇流的 REST 入口([29-DREAM](../../docs/done/system-design/29-dream-design.md))。
3 * 3 *
4 * 只做一件事:把 `dream-service.js` 已有的能力(`dreamOnce` / `dreamSweep` / 4 * 只做一件事:把 `dream-service.js` 已有的能力(`dreamOnce` / `dreamSweep` /
5 * `auditDistilled` / `definitionWritability`)从「只能靠脚本或定时器触发」 5 * `auditDistilled` / `definitionWritability`)从「只能靠脚本或定时器触发」
server/routes/graph-run.js
@@ -377,6 +377,81 @@ router.post(&#39;/:id/nodes/:nodeId/retry&#39;, h((req, res) =&gt; { @@ -377,6 +377,81 @@ router.post(&#39;/:id/nodes/:nodeId/retry&#39;, h((req, res) =&gt; {
377 })); 377 }));
378 378
379 /** 379 /**
  380 + * POST /api/graph-runs/:id/nodes/:nodeId/force-fail — 「强制判失败」(42 号文档 Phase 1b)
  381 + * body: { failReason?: string }(默认 'operator_intervention',刻意不是 'infra_flake'——
  382 + * 那个值有特殊语义:原地重跑、不烧计数,人工判定不该被误当成一次性抖动)
  383 + * 可用条件:节点 status = 'running'
  384 + *
  385 + * `retry` 端点管不到 running 的节点(那是它自己的既有约定,不是这次要改的)——这个端点补的
  386 + * 正是那个缺口:人类或 ceo 判断"这个节点已经不可能再自己完成了"时的手动出口。转 fail 之后
  387 + * 交给引擎既有的失败路由(selectFailEdge/handleBackwardEdge),不发明新路径。
  388 + */
  389 +router.post('/:id/nodes/:nodeId/force-fail', h((req, res) => {
  390 + const run = loadRun(req, res);
  391 + if (!run) return;
  392 +
  393 + const { nodeId } = req.params;
  394 + let nodeRun = getRunNode(nodeId);
  395 + if (!nodeRun || nodeRun.run_id !== run.id) nodeRun = getRunNodeByDefId(run.id, nodeId);
  396 + if (!nodeRun) {
  397 + return res.status(404).json({ error: `节点 ${nodeId} 不属于 run ${run.id}` });
  398 + }
  399 +
  400 + if (nodeRun.status !== 'running') {
  401 + return res.status(400).json({
  402 + error: `「强制判失败」在节点 status='${nodeRun.status}' 下不可用,仅当节点 status = 'running' 时可用(fail/blocked 请用 retry 端点)`,
  403 + op: 'force-fail',
  404 + nodeStatus: nodeRun.status,
  405 + allowed: ['running'],
  406 + });
  407 + }
  408 +
  409 + const { failReason } = req.body || {};
  410 + res.json(engine.forceFailNode(run.id, nodeRun.id, failReason || undefined));
  411 +}));
  412 +
  413 +/**
  414 + * POST /api/graph-runs/phase-note — 42 号文档 Phase 4:planning/reflection 轨迹落进 timeline。
  415 + * body: { artifact: string, phase?: string, agentId?: string }
  416 + *
  417 + * `bin/lemon.js task transition` 在打 legacy 的 `/api/agent/:agentId/task/transition`
  418 + * 之外,额外(best-effort、不影响主流程)打这一个端点。它不需要 `:id`——调用方(跑在
  419 + * work graph 节点里的 agent)不知道自己的 runId,只知道自己刚写的 `--artifact` 绝对路径;
  420 + * 这个路径的形状(`.../runs/{rootRunId}/nodes/{nodeRunId}/...`)本身就带着两个 ID
  421 + * (节点私有产物目录的既有约定,见 `graph-engine.js` 的 `nodeArtifactDir`/`runRootDir`),
  422 + * 从路径里解出来就行,不用改任何 agent.md 的提示词、也不用改 `AgentManager` 往子进程塞
  423 + * 新的环境变量。
  424 + *
  425 + * 解不出来 / 解出来的 rootRunId 不对应真实存在的 run(legacy 会话式 agent 调用这条命令时
  426 + * 就是这样——它们的 `--artifact` 压根不是这个路径形状):安静跳过,返回 `{ok:false,
  427 + * reason:'not-a-graph-artifact'}`,不是错误——这条端点存在的前提就是"大多数调用方都用不上它"。
  428 + *
  429 + * 只做**追加**:不碰 `graph_run_nodes`/`graph_runs` 任何一行,不影响 needs/produces 契约、
  430 + * 不影响 validator 的判定依据(39 号文档 §七.2 定案的边界——private 产物不能变成契约输入)。
  431 + */
  432 +router.post('/phase-note', h((req, res) => {
  433 + const { artifact, phase, agentId } = req.body || {};
  434 + if (typeof artifact !== 'string' || !artifact) {
  435 + return res.json({ ok: false, reason: 'no-artifact' });
  436 + }
  437 +
  438 + const m = artifact.match(/[\\/]runs[\\/]([^\\/]+)[\\/]nodes[\\/]([^\\/]+)[\\/]/);
  439 + if (!m) return res.json({ ok: false, reason: 'not-a-graph-artifact' });
  440 + const [, rootRunId, nodeRunId] = m;
  441 +
  442 + if (!getRun(rootRunId)) return res.json({ ok: false, reason: 'not-a-graph-artifact' });
  443 +
  444 + const sse = require('../services/graph-run-sse');
  445 + const event = sse.emitEvent(
  446 + rootRunId, 'agent.phase',
  447 + `${agentId || '(未知 agent)'} 阶段流转 → ${phase || '(未知阶段)'}`,
  448 + { agentId: agentId || null, phase: phase || null, artifact },
  449 + { ref: nodeRunId, kind: 'agent.phase' }
  450 + );
  451 + res.json({ ok: true, rootRunId, nodeRunId, timelineEventId: event && event.timeline && event.timeline.ts });
  452 +}));
  453 +
  454 +/**
380 * POST /api/graph-runs/:id/budget — 「追加预算」(运维操作,不在 §四.C 五个操作之列) 455 * POST /api/graph-runs/:id/budget — 「追加预算」(运维操作,不在 §四.C 五个操作之列)
381 * body: { total_agent_calls?, total_tokens?, wallclock_ms?, resume?: boolean } 456 * body: { total_agent_calls?, total_tokens?, wallclock_ms?, resume?: boolean }
382 * 可用条件:run_status = 'blocked' 457 * 可用条件:run_status = 'blocked'
server/services/company-service.js
@@ -357,6 +357,26 @@ class CompanyService { @@ -357,6 +357,26 @@ class CompanyService {
357 } 357 }
358 if (updates.sop !== undefined) company.sop = updates.sop; 358 if (updates.sop !== undefined) company.sop = updates.sop;
359 if (updates.heartbeat !== undefined) company.heartbeat = { ...company.heartbeat, ...updates.heartbeat }; 359 if (updates.heartbeat !== undefined) company.heartbeat = { ...company.heartbeat, ...updates.heartbeat };
  360 +
  361 + // 42 号文档 Phase 3:target_dir 的配置入口。校验复用 path-resolver 的 validateLinkedTarget
  362 + // ——39 号文档 P5 落地时就是从这里抽出来的共享函数,graph-engine.js 的 ensureProjectRoot
  363 + // 用它做运行时校验,这里在配置阶段就先做一遍,不用等第一次真的分配项目根才发现路径不对。
  364 + // 校验失败直接抛出(调用方 try/catch 转 400),不静默吞掉、不写进一半的脏状态——
  365 + // 抛出发生在 _saveCompany() 之前,本次 update 里其它字段也不会被落盘(同一次保存,要么全写要么不写)。
  366 + if (updates.target_dir !== undefined) {
  367 + if (updates.target_dir === null || updates.target_dir === '') {
  368 + company.target_dir = null;
  369 + company.target_mode = null;
  370 + } else {
  371 + // 缺省 'linked-existing':与 graph-engine.js 的 readCompanyTargetConfig 同一条默认值口径,
  372 + // 两处判据分叉是这个仓库反复栽过的坑("两套体系不统一"),不能各写一份。
  373 + const mode = updates.target_mode === 'linked-new' ? 'linked-new' : 'linked-existing';
  374 + const { validateLinkedTarget } = require('./devloop/path-resolver');
  375 + company.target_dir = validateLinkedTarget(updates.target_dir, mode);
  376 + company.target_mode = mode;
  377 + }
  378 + }
  379 +
360 company.updatedAt = new Date().toISOString(); 380 company.updatedAt = new Date().toISOString();
361 381
362 this._saveCompany(company); 382 this._saveCompany(company);
server/services/dream-service.js
1 /** 1 /**
2 - * dream —— 经验汇流([29-DREAM](../../docs/todo/system-design/29-dream-design.md))。 2 + * dream —— 经验汇流([29-DREAM](../../docs/done/system-design/29-dream-design.md))。
3 * 3 *
4 * 定时把**所有 agent 在所有公司**的原始记录提炼成不含公司特定信息的经验, 4 * 定时把**所有 agent 在所有公司**的原始记录提炼成不含公司特定信息的经验,
5 * 沉淀到本体上,让经验跨公司汇流而不泄漏任何一家的具体信息。 5 * 沉淀到本体上,让经验跨公司汇流而不泄漏任何一家的具体信息。
server/services/graph-engine.js
@@ -2905,6 +2905,53 @@ function stopWallclockWatchdog() { @@ -2905,6 +2905,53 @@ function stopWallclockWatchdog() {
2905 } 2905 }
2906 2906
2907 /** 2907 /**
  2908 + * 42 号文档 Phase 1a:开机孤儿节点清扫。
  2909 + *
  2910 + * `graph_run_nodes.status='running'` 这一行不代表背后真的有个活的进程——它只是「上次问到的
  2911 + * 状态」。服务器重启(比如换代码生效)会把正在跑的 Claude CLI 子进程连带杀掉,但这一行留在
  2912 + * `running`,永远不会自己变成 `blocked`:`ceo` 的两条唤醒路径(事件驱动 + 30 分钟兜底扫描)
  2913 + * 判据都只认 `status='blocked'`,对这个状态完全不可见(39 号文档 §七.1 真实撞出来的)。
  2914 + *
  2915 + * 不发明新状态:这本质上跟 `infra_flake`(基础设施打断,跟设计/实现对不对无关)是同一类事,
  2916 + * 服务器重启只是这个桶里更严重的一个成员——原地重跑的熔断线(`INFRA_FLAKE_HARD_CAP`)现成能用。
  2917 + * 复用 `routeFailure` 的 infra_flake 分支,不另起一套判定/熔断逻辑;调用形状照抄 `advanceOne`
  2918 + * 里「先 `transitionNode` 落 fail_reason,再取新鲜的 `nodeRun`/`def` 传给 `routeFailure`」那一段。
  2919 + *
  2920 + * @param {string} cutoffIso 判定孤儿的时间线——`started_at` 早于这个时刻的 running 节点判定为
  2921 + * 孤儿(本次进程启动前就已经在跑,不可能是本进程自己起的)。调用方传「本次进程启动时间」;
  2922 + * 测试用任意时刻构造场景。
  2923 + * @returns {{scanned:number, swept:Array<{runId:string, nodeRunId:string, infraRerun:boolean}>}}
  2924 + */
  2925 +function sweepOrphanedRunningNodes(cutoffIso) {
  2926 + const rows = getDb()
  2927 + .prepare(`SELECT id, run_id FROM graph_run_nodes WHERE status = 'running' AND started_at IS NOT NULL AND started_at < ?`)
  2928 + .all(cutoffIso);
  2929 +
  2930 + const swept = [];
  2931 + for (const row of rows) {
  2932 + try {
  2933 + const run = getRun(row.run_id);
  2934 + if (!run) continue; // run 行本身没了(不该发生,防御性跳过)
  2935 + const def = defIndex(run);
  2936 +
  2937 + const changed = sse.transitionNode(row.run_id, row.id, 'running', 'fail', {
  2938 + failReason: INFRA_FLAKE_REASON,
  2939 + failNodeRef: null, outputRef: null, agentRunId: null, childRunId: null, completedAt: now(),
  2940 + }, { summary: `开机孤儿节点清扫:服务器重启前处于 running,背后进程已不存在,判 infra_flake 交给既有熔断/重跑逻辑` });
  2941 + if (!changed) continue; // 0 行 = 已经被别的路径结算过了,不重复处理
  2942 +
  2943 + const nodeRun = getRunNode(row.id);
  2944 + const out = { activated: [], backwardEdgeId: null, loop: null, contractDiff: null, crossLayer: null, reset: [], infraRerun: false };
  2945 + routeFailure(row.run_id, def, nodeRun, out, null);
  2946 + swept.push({ runId: row.run_id, nodeRunId: row.id, infraRerun: out.infraRerun });
  2947 + } catch (err) {
  2948 + console.error(`[graph-engine] sweepOrphanedRunningNodes node=${row.id} 异常: ${err.message}`);
  2949 + }
  2950 + }
  2951 + return { scanned: rows.length, swept };
  2952 +}
  2953 +
  2954 +/**
2908 * 扣减(「只增不减」,§四.A.3)。始终写 root run 那一行。 2955 * 扣减(「只增不减」,§四.A.3)。始终写 root run 那一行。
2909 * ponytail: 读-改-写不是原子的,当前单进程串行驱动(runAuto 一次只推一个节点)下够用; 2956 * ponytail: 读-改-写不是原子的,当前单进程串行驱动(runAuto 一次只推一个节点)下够用;
2910 * 升级路径是把自增下沉成一条 `UPDATE ... SET budget_json = json_set(...)` 的 SQL。 2957 * 升级路径是把自增下沉成一条 `UPDATE ... SET budget_json = json_set(...)` 的 SQL。
@@ -3439,6 +3486,52 @@ function retryNode(runId, nodeRunId, { reopenRun = true, remediationNote = null @@ -3439,6 +3486,52 @@ function retryNode(runId, nodeRunId, { reopenRun = true, remediationNote = null
3439 }; 3486 };
3440 } 3487 }
3441 3488
  3489 +/**
  3490 + * 42 号文档 Phase 1b:「强制判失败以便恢复」。
  3491 + *
  3492 + * `retryNode` 只接受 `fail`/`blocked` 的节点——但真实运维里会遇到一种它管不到的情形:节点
  3493 + * `status='running'`,人(或 `ceo`)判断它已经不可能再自己完成了(比如背后进程确实卡死,
  3494 + * 不是 Phase 1a 那种"重启后一定是孤儿",而是进程本身还在但没有任何进展)。在这条修复之前,
  3495 + * 唯一的恢复手段是直接调 `graph-run-sse.transitionNode` 这个内部函数——对人类操作者或 `ceo`
  3496 + * 都不现实,两者都只能走 HTTP。
  3497 + *
  3498 + * 语义:"我确认这个节点救不回来了,把它判 fail,交给既有的 fail→retry/backward-edge 路径
  3499 + * 处理"——不发明新的路由逻辑,`routeFailure` 该怎么选边还是怎么选边(默认原因不是
  3500 + * `infra_flake`,所以不会走原地重跑那条特判,是走 `selectFailEdge`/`handleBackwardEdge`
  3501 + * 的常规路径)。
  3502 + *
  3503 + * @param {string} runId
  3504 + * @param {string} nodeRunId
  3505 + * @param {string} [failReason='operator_intervention'] 默认值刻意不是 `infra_flake`——那个
  3506 + * 值有特殊语义(原地重跑、不烧计数),人工判定"这个真的救不回来了"不该被误当成一次性抖动。
  3507 + * @returns {{ok:boolean, reason?:string, nodeStatus?:string, runId?:string, nodeRunId?:string}}
  3508 + */
  3509 +function forceFailNode(runId, nodeRunId, failReason = 'operator_intervention') {
  3510 + const nodeRun = getRunNode(nodeRunId);
  3511 + if (!nodeRun || nodeRun.run_id !== runId) throw new Error(`节点不属于该 run: ${nodeRunId}`);
  3512 + if (nodeRun.status !== 'running') {
  3513 + return { ok: false, reason: 'not-force-failable', nodeStatus: nodeRun.status };
  3514 + }
  3515 +
  3516 + const changed = sse.transitionNode(runId, nodeRunId, 'running', 'fail', {
  3517 + failReason,
  3518 + failNodeRef: null, outputRef: null, agentRunId: null, childRunId: null, completedAt: now(),
  3519 + }, { summary: `人工「强制判失败」:running → fail(failReason=${failReason}),交给既有失败路由处理` });
  3520 + if (!changed) return { ok: false, reason: 'taken-by-other', nodeStatus: (getRunNode(nodeRunId) || {}).status };
  3521 +
  3522 + const run = getRun(runId);
  3523 + const def = defIndex(run);
  3524 + const fresh = getRunNode(nodeRunId);
  3525 + const out = { activated: [], backwardEdgeId: null, loop: null, contractDiff: null, crossLayer: null, reset: [], infraRerun: false };
  3526 + routeFailure(runId, def, fresh, out, null);
  3527 +
  3528 + return {
  3529 + ok: true, runId, nodeRunId,
  3530 + nodeStatus: (getRunNode(nodeRunId) || {}).status,
  3531 + runStatus: (getRun(runId) || {}).run_status,
  3532 + };
  3533 +}
  3534 +
3442 // ──────────────────────────────────────────────────────────────── 3535 // ────────────────────────────────────────────────────────────────
3443 // §四.C 末段:重试后级联下游的判据 = contract_hash 变没变 3536 // §四.C 末段:重试后级联下游的判据 = contract_hash 变没变
3444 // ──────────────────────────────────────────────────────────────── 3537 // ────────────────────────────────────────────────────────────────
@@ -4775,6 +4868,9 @@ module.exports = { @@ -4775,6 +4868,9 @@ module.exports = {
4775 descendantRuns, 4868 descendantRuns,
4776 landPause, 4869 landPause,
4777 4870
  4871 + // 42 号文档 Phase 1b:running 节点的人工/ceo 强制判失败出口(retryNode 管不到 running)
  4872 + forceFailNode,
  4873 +
4778 // 任务级总预算(§四.A.3) 4874 // 任务级总预算(§四.A.3)
4779 checkBudget, 4875 checkBudget,
4780 spendBudget, 4876 spendBudget,
@@ -4793,6 +4889,9 @@ module.exports = { @@ -4793,6 +4889,9 @@ module.exports = {
4793 startWallclockWatchdog, 4889 startWallclockWatchdog,
4794 stopWallclockWatchdog, 4890 stopWallclockWatchdog,
4795 4891
  4892 + // 42 号文档 Phase 1a:开机孤儿节点清扫(服务器重启杀掉 in-flight 进程,节点行留在 running)
  4893 + sweepOrphanedRunningNodes,
  4894 +
4796 // 重试级联判据(§四.C 末段) 4895 // 重试级联判据(§四.C 末段)
4797 diffContracts, 4896 diffContracts,
4798 applyRetryCascade, 4897 applyRetryCascade,
server/services/graph-seed-service.js
@@ -458,8 +458,10 @@ function seedAgents() { @@ -458,8 +458,10 @@ function seedAgents() {
458 // **只写不降级**:config.json 里的 status 是本体的**出生默认值**,而毕业与否是运行时 458 // **只写不降级**:config.json 里的 status 是本体的**出生默认值**,而毕业与否是运行时
459 // 从磁盘定义学到的事实(promoteDraftIfComplete / reconcile 的 E 分支)。原样 upsert 的话, 459 // 从磁盘定义学到的事实(promoteDraftIfComplete / reconcile 的 E 分支)。原样 upsert 的话,
460 // 每次进程重启 seedAgents 都会把已经转正的行按 config.json 打回 draft/training —— 460 // 每次进程重启 seedAgents 都会把已经转正的行按 config.json 打回 draft/training ——
461 - // 闸门抬起来一次、下次启动又落下去,而且一处报错都没有(code-planner 的 config.json  
462 - // 至今写着 "training")。所以:库里已经脱离未毕业域的行,不许再被推回去;其余照写。 461 + // 闸门抬起来一次、下次启动又落下去,而且一处报错都没有(真实撞过的样本是 code-planner,
  462 + // 它的 config.json 当时写着 "training",见 `graduation-criteria-reality.test.js`;
  463 + // 后来 config.json 已经改成了毕业态的 "enabled",这条注释只留问题形状,不再钉死具体值,
  464 + // 避免又变成一处会随源码变化过期的死引用)。所以:库里已经脱离未毕业域的行,不许再被推回去;其余照写。
463 const status = (existing && isUngraduatedStatus(configStatus) && !isUngraduatedStatus(existing.status)) 465 const status = (existing && isUngraduatedStatus(configStatus) && !isUngraduatedStatus(existing.status))
464 ? existing.status 466 ? existing.status
465 : configStatus; 467 : configStatus;
server/services/work-graph-db.js
@@ -2558,10 +2558,23 @@ function findFiles({ companyId, taskId = null, kinds = null }) { @@ -2558,10 +2558,23 @@ function findFiles({ companyId, taskId = null, kinds = null }) {
2558 return rows.filter((r) => want.has(r.kind)); 2558 return rows.filter((r) => want.has(r.kind));
2559 } 2559 }
2560 2560
  2561 +/**
  2562 + * kind='log' 永远不能被声明成 required:`indexNodeOutputs()` 的三级认领规则
  2563 + * (文件名匹配声明 kind → 已知文件名 → 唯一无歧义兜底)对 test/integration 类节点
  2564 + * 结构性地认不出一个 kind='log' 的文件——它们落盘的是 coder.log/test.log 之类,
  2565 + * 全部被归到 kind='artifact'。required:true 的 log 契约因此**永远无法满足**,
  2566 + * 触发 requiredInputsMissing() 把节点打成 missing_required_input 死循环
  2567 + * (33 号文档 + snakegame-co T-mtfh2kuf-7c31 复现两次:一次模板 executor.json 里
  2568 + * 手滑漏了 required:false,一次 workgraph-planning agent 的 tools.md 例句没跟着改)。
  2569 + * 与其指望每个模板作者 / 每次 LLM 规划都记得写对,不如把这条底线收在唯一的落库口。
  2570 + */
  2571 +const NEVER_REQUIRED_KINDS = new Set(['log']);
  2572 +
2561 /** 在边上声明「这条边要传什么 kind」。规划时写,只有 kind 没有实例。 */ 2573 /** 在边上声明「这条边要传什么 kind」。规划时写,只有 kind 没有实例。 */
2562 function declareEdgeContract({ edgeLayer, edgeId, kind, required = true, role = 'produces' }) { 2574 function declareEdgeContract({ edgeLayer, edgeId, kind, required = true, role = 'produces' }) {
2563 if (!['org', 'work'].includes(edgeLayer)) throw new Error(`edgeLayer 非法: ${edgeLayer}`); 2575 if (!['org', 'work'].includes(edgeLayer)) throw new Error(`edgeLayer 非法: ${edgeLayer}`);
2564 if (!edgeId || !kind) throw new Error('declareEdgeContract 需要 edgeId / kind'); 2576 if (!edgeId || !kind) throw new Error('declareEdgeContract 需要 edgeId / kind');
  2577 + const effectiveRequired = required && !NEVER_REQUIRED_KINDS.has(kind);
2565 // role 默认 produces:出边声明产出是压倒性多数,needs 必须显式写。 2578 // role 默认 produces:出边声明产出是压倒性多数,needs 必须显式写。
2566 // 去重键仍是 (layer, edge, kind) 而不含 role —— 同一条边对同一个 kind 既声明 produces 2579 // 去重键仍是 (layer, edge, kind) 而不含 role —— 同一条边对同一个 kind 既声明 produces
2567 // 又声明 needs 是自相矛盾的输入(「我产它」和「我要别人产它」), 2580 // 又声明 needs 是自相矛盾的输入(「我产它」和「我要别人产它」),
@@ -2574,7 +2587,7 @@ function declareEdgeContract({ edgeLayer, edgeId, kind, required = true, role = @@ -2574,7 +2587,7 @@ function declareEdgeContract({ edgeLayer, edgeId, kind, required = true, role =
2574 ON CONFLICT(edge_layer, edge_id, kind) DO NOTHING 2587 ON CONFLICT(edge_layer, edge_id, kind) DO NOTHING
2575 `) 2588 `)
2576 .run( 2589 .run(
2577 - newId('gec'), edgeLayer, String(edgeId), kind, required ? 1 : 0, 2590 + newId('gec'), edgeLayer, String(edgeId), kind, effectiveRequired ? 1 : 0,
2578 role === 'needs' ? 'needs' : 'produces', new Date().toISOString() 2591 role === 'needs' ? 'needs' : 'produces', new Date().toISOString()
2579 ); 2592 );
2580 } 2593 }
server/tests/unit/agent-chat-history-context.test.js 0 → 100644
  1 +/**
  2 + * Guard: 同一 convId 恢复上下文时(buildPrompt 场景 C)按模型上下文 token 预算动态注入历史,
  3 + * 不再是固定的 slice(-20) + 单条 500 字截断 —— 预算内整段塞进去,超预算才从最早的开始丢。
  4 + * 否则 ACP 进程重启/空闲超时后,早于第 20 条的历史会静默丢失,agent 会"忘了"之前聊过什么。
  5 + */
  6 +const fs = require('fs');
  7 +const path = require('path');
  8 +
  9 +const { buildPrompt, appendHistory, truncateHistoryToBudget, estimateTokens } = require('../../routes/agent-chat');
  10 +
  11 +const agentId = 'master';
  12 +const agentDir = path.resolve(__dirname, `../../../system/agents/${agentId}`);
  13 +
  14 +describe('agent-chat buildPrompt 场景 C:同 convId 恢复上下文', () => {
  15 + it('注入完整历史,不按 slice(-20) 或单条 500 字截断', () => {
  16 + const convId = `test-history-guard-${Date.now()}`;
  17 + const convDir = path.resolve(__dirname, `../../../system/agents/${agentId}/_global/conversations/${convId}`);
  18 + try {
  19 + const longFirstMessage = 'X'.repeat(600); // 超过原来单条 500 字截断阈值
  20 + appendHistory(agentId, convId, { role: 'user', content: longFirstMessage });
  21 + for (let i = 0; i < 25; i++) {
  22 + appendHistory(agentId, convId, { role: 'assistant', content: `filler-${i}` });
  23 + }
  24 +
  25 + const prompt = buildPrompt(agentDir, agentId, convId, '新消息', true);
  26 +
  27 + expect(prompt).toContain(longFirstMessage);
  28 + expect(prompt).not.toContain('...(截断)');
  29 + } finally {
  30 + fs.rmSync(convDir, { recursive: true, force: true });
  31 + }
  32 + });
  33 +});
  34 +
  35 +describe('truncateHistoryToBudget:预算超支时按 token 从最早的开始丢', () => {
  36 + it('预算装不下全部历史时,丢最老的,留最近的', () => {
  37 + const history = [
  38 + { role: 'user', content: 'oldest' },
  39 + { role: 'assistant', content: 'middle' },
  40 + { role: 'user', content: 'newest' },
  41 + ];
  42 + const budget = estimateTokens('middle') + estimateTokens('newest'); // 刚好够后两条,装不下 oldest
  43 +
  44 + const kept = truncateHistoryToBudget(history, budget);
  45 +
  46 + expect(kept.map(m => m.content)).toEqual(['middle', 'newest']);
  47 + });
  48 +
  49 + it('单条消息本身就超预算时,仍然至少保留最近一条,不会返回空', () => {
  50 + const history = [{ role: 'user', content: 'X'.repeat(10000) }];
  51 +
  52 + const kept = truncateHistoryToBudget(history, 1);
  53 +
  54 + expect(kept).toHaveLength(1);
  55 + });
  56 +});
server/tests/unit/ceo-created-task-gates.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — ceo 自主创建的 task 仍然受既有闸门约束(42 号文档 Phase 2,39 号文档 §四.4)
  3 + *
  4 + * 39 号文档 §四.4 拍板:"ceo 全权自主 enqueue,但 max_parallel_tasks / DEFAULT_TASK_BUDGET /
  5 + * goal 级 org-validator 这几道闸门必须照样拦住它"——这几道闸门挂在数据层/调度器层,理论上
  6 + * 不认"谁创建的",天然覆盖,但落地时没有补一条测试钉死这一点(§六"已知遗留"记过一笔)。
  7 + * 这份文件补上,走 ceo 实际会用的通路(POST /tasks draft:true + created_by:'ceo' → /enqueue),
  8 + * 不抄近道直接摆数据库行。
  9 + *
  10 + * 隔离手法与 task-entry-draft-mode.test.js / goal-task-acceptance.test.js 一致。
  11 + */
  12 +const fs = require('fs');
  13 +const os = require('os');
  14 +const path = require('path');
  15 +
  16 +const TEST_DB_PATH = '/tmp/test-ceo-created-task-gates.sqlite';
  17 +const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
  18 +const COMPANIES_DIR = path.resolve(__dirname, '../../../data/companies');
  19 +const MODULES = [
  20 + '../../services/graph-db',
  21 + '../../services/work-graph-db',
  22 + '../../services/org-graph-service',
  23 + '../../services/graph-run-sse',
  24 + '../../services/graph-engine',
  25 + '../../services/task-settle',
  26 + '../../services/goal-validation',
  27 + '../../routes/company',
  28 +];
  29 +
  30 +let wdb;
  31 +let orgSvc;
  32 +let engine;
  33 +let server;
  34 +let base;
  35 +const createdCompanies = [];
  36 +
  37 +async function call(method, url, body) {
  38 + const r = await fetch(base + url, {
  39 + method,
  40 + headers: { 'Content-Type': 'application/json' },
  41 + body: body === undefined ? undefined : JSON.stringify(body),
  42 + });
  43 + return [r.status, await r.json()];
  44 +}
  45 +
  46 +function ensureAgents(ids) {
  47 + const db = wdb.getDb();
  48 + const now = new Date().toISOString();
  49 + const ins = db.prepare(`
  50 + INSERT OR IGNORE INTO system_agents
  51 + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
  52 + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)
  53 + `);
  54 + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now);
  55 +}
  56 +
  57 +/** 真实写一份 company.json(不是 mock)——maxActiveRootRuns() 直接读盘,绕不过去。 */
  58 +function makeCompany(companyId, extra = {}) {
  59 + const dir = path.join(COMPANIES_DIR, companyId);
  60 + fs.mkdirSync(dir, { recursive: true });
  61 + fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify({ id: companyId, name: companyId, ...extra }));
  62 + createdCompanies.push(companyId);
  63 +
  64 + ensureAgents(['org-planner', 'org-coder', 'org-validator']);
  65 + expect(
  66 + orgSvc.writeGraph(companyId, {
  67 + nodes: ['org-planner', 'org-coder', 'org-validator'],
  68 + edges: [
  69 + { from: 'org-planner', to: 'org-coder', condition: 'success' },
  70 + { from: 'org-coder', to: 'org-validator', condition: 'success' },
  71 + ],
  72 + }).ok
  73 + ).toBe(true);
  74 +}
  75 +
  76 +describe('ceo 自主创建的 task 仍然受既有闸门约束(39 号文档 §四.4)', () => {
  77 + beforeAll(async () => {
  78 + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 本来就没有 */ } }
  79 + process.env.GRAPH_DB_PATH = TEST_DB_PATH;
  80 + process.env.GRAPH_RUNS_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ceo-task-gates-runs-'));
  81 + for (const m of MODULES) delete require.cache[require.resolve(m)];
  82 +
  83 + wdb = require('../../services/work-graph-db');
  84 + orgSvc = require('../../services/org-graph-service');
  85 + engine = require('../../services/graph-engine');
  86 +
  87 + const express = require('express');
  88 + const app = express();
  89 + app.use(express.json());
  90 + app.use('/api/company', require('../../routes/company'));
  91 + await new Promise((resolve) => { server = app.listen(0, resolve); });
  92 + base = `http://127.0.0.1:${server.address().port}/api/company`;
  93 + });
  94 +
  95 + afterAll(() => {
  96 + if (server) server.close();
  97 + for (const cid of createdCompanies) {
  98 + fs.rmSync(path.join(COMPANIES_DIR, cid), { recursive: true, force: true });
  99 + }
  100 + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 已经删了 */ } }
  101 + try { fs.rmSync(process.env.GRAPH_RUNS_DIR, { recursive: true, force: true }); } catch { /* ignore */ }
  102 + delete process.env.GRAPH_DB_PATH;
  103 + delete process.env.GRAPH_RUNS_DIR;
  104 + for (const m of MODULES) delete require.cache[require.resolve(m)];
  105 + });
  106 +
  107 + // ──────────────────────────────────────────────────────────────
  108 + // 1. max_parallel_tasks:坑满了,ceo 建的任务一样排队,不能插队
  109 + // ──────────────────────────────────────────────────────────────
  110 + it('max_parallel_tasks=1 时坑已被占,ceo 走 draft+enqueue 建的第二条任务落 queued,不会插队执行', async () => {
  111 + const cid = 'ceo-gate-slots';
  112 + makeCompany(cid, { max_parallel_tasks: 1 });
  113 +
  114 + // 人类下达第一条,占满唯一的执行位
  115 + const [s1, taskA] = await call('POST', `/${cid}/tasks`, { task: '人类下达的任务,占坑' });
  116 + expect(s1).toBe(201);
  117 + expect(wdb.getTask(taskA.taskId).status).toBe('running');
  118 +
  119 + // ceo 走它自己的通路:draft 建 → enqueue
  120 + const [s2, taskB] = await call('POST', `/${cid}/tasks`, {
  121 + task: 'ceo 自主提的第二条任务', draft: true, created_by: 'ceo',
  122 + });
  123 + expect(s2).toBe(201);
  124 + expect(taskB.status).toBe('draft');
  125 +
  126 + const [s3, enqueued] = await call('POST', `/${cid}/tasks/${taskB.taskId}/enqueue`);
  127 + expect(s3).toBe(201);
  128 +
  129 + // 坑满:这条任务必须落 queued,不能因为是 ceo 建的就获得一个额外的执行位
  130 + const row = wdb.getTask(taskB.taskId);
  131 + expect(row.status).toBe('queued');
  132 + expect(enqueued.status).toBe('queued');
  133 + const run = wdb.getRun(row.current_run_id);
  134 + expect(run.run_status).toBe('queued');
  135 + });
  136 +
  137 + // ──────────────────────────────────────────────────────────────
  138 + // 2. DEFAULT_TASK_BUDGET:预算给得再小,ceo 建的任务也不会被特殊放宽
  139 + // ──────────────────────────────────────────────────────────────
  140 + it('ceo 建的任务预算耗尽照样判 budget-exhausted,不会因为创建者是 agent 就获得额外预算', async () => {
  141 + const cid = 'ceo-gate-budget';
  142 + makeCompany(cid);
  143 +
  144 + const tinyBudget = { total_agent_calls: 0, total_tokens: 0, wallclock_ms: 7_200_000 };
  145 + const [s1, task] = await call('POST', `/${cid}/tasks`, {
  146 + task: 'ceo 自主提的任务,预算给得极小',
  147 + draft: true,
  148 + created_by: 'ceo',
  149 + budget: tinyBudget,
  150 + });
  151 + expect(s1).toBe(201);
  152 +
  153 + const [s2] = await call('POST', `/${cid}/tasks/${task.taskId}/enqueue`);
  154 + expect(s2).toBe(201);
  155 +
  156 + const run = wdb.getRun(wdb.getTask(task.taskId).current_run_id);
  157 + expect(run.budget_json).toBeTruthy();
  158 + const parsed = JSON.parse(run.budget_json);
  159 + expect(parsed.total_agent_calls).toBe(0); // 存进去的确实是那份小预算,没有被谁悄悄改大
  160 +
  161 + // checkBudget 不认「谁建的」,只认预算数字本身
  162 + const gate = engine.checkBudget(run);
  163 + expect(gate.ok).toBe(false);
  164 + expect(gate.exhausted).toContain('total_agent_calls');
  165 + });
  166 +
  167 + // ──────────────────────────────────────────────────────────────
  168 + // 3. goal 级 org-validator:ceo 建的多 task goal 全部 completed 后,收敛验收照样跑一次
  169 + // ──────────────────────────────────────────────────────────────
  170 + it('ceo 建的两条 task 全 completed 后,goal 级 org-validator 照样被调一次(不因创建者是 agent 而跳过)', async () => {
  171 + const cid = 'ceo-gate-goal-validation';
  172 + makeCompany(cid);
  173 +
  174 + const goal = wdb.createGoal({ companyId: cid, title: '两步都由 ceo 建的目标', description: '测试用' });
  175 +
  176 + const [s1, taskA] = await call('POST', `/${cid}/tasks`, {
  177 + task: 'ceo 建的第一条子任务', draft: true, created_by: 'ceo', goal_id: goal.id,
  178 + });
  179 + expect(s1).toBe(201);
  180 + await call('POST', `/${cid}/tasks/${taskA.taskId}/enqueue`);
  181 +
  182 + const [s2, taskB] = await call('POST', `/${cid}/tasks`, {
  183 + task: 'ceo 建的第二条子任务', draft: true, created_by: 'ceo', goal_id: goal.id,
  184 + });
  185 + expect(s2).toBe(201);
  186 + await call('POST', `/${cid}/tasks/${taskB.taskId}/enqueue`);
  187 +
  188 + expect(wdb.getTask(taskA.taskId).created_by).toBe('ceo');
  189 + expect(wdb.getTask(taskB.taskId).created_by).toBe('ceo');
  190 +
  191 + const validation = require('../../services/goal-validation');
  192 + const seen = [];
  193 + validation.setValidateGoal(async (ctx) => {
  194 + seen.push(ctx);
  195 + return { pass: true, verdict: '两条子任务合起来确实达成了目标', tokens: 42 };
  196 + });
  197 +
  198 + try {
  199 + const sse = require('../../services/graph-run-sse');
  200 + const runA = wdb.getTask(taskA.taskId).current_run_id;
  201 + let runB = wdb.getTask(taskB.taskId).current_run_id;
  202 + expect(wdb.getRun(runB).run_status).toBe('queued'); // 同公司同一时间只有一个执行位
  203 +
  204 + // 先让 runA 收尾,空出执行位。调度器出队走的是 setImmediate(挂在 transitionRun 钩子上),
  205 + // 这条用例不测调度时序本身(那是 task-slots-parallel.test.js 的职责)——坑空之后直接
  206 + // 用 updateRunStatus 把 runB 推 ready,走的还是真实的准入事务,只是不等 setImmediate。
  207 + sse.transitionRun(runA, wdb.getRun(runA).run_status, 'completed', {}, { summary: '用例直推终态' });
  208 + wdb.updateRunStatus(runB, 'queued', 'ready');
  209 + expect(wdb.getRun(runB).run_status).toBe('ready'); // 坑已空出,推 ready 成功
  210 + sse.transitionRun(runB, 'ready', 'running', {}, { summary: '用例直推终态' });
  211 + sse.transitionRun(runB, 'running', 'completed', {}, { summary: '用例直推终态' });
  212 +
  213 + const { isPendingVerdict } = require('../../services/task-settle');
  214 + for (let i = 0; i < 50; i += 1) {
  215 + const v = wdb.getGoal(goal.id).verdict;
  216 + if (v && !isPendingVerdict(v)) break;
  217 + await new Promise((r) => setImmediate(r));
  218 + }
  219 +
  220 + expect(seen).toHaveLength(1); // 一个 goal 只验一次,即便两条子任务都是 ceo 建的
  221 + expect(seen[0].goal.id).toBe(goal.id);
  222 + expect(seen[0].tasks.map((t) => t.created_by).sort()).toEqual(['ceo', 'ceo']);
  223 + expect(wdb.getGoal(goal.id).status).toBe('done');
  224 + } finally {
  225 + validation.setValidateGoal(null);
  226 + }
  227 + });
  228 +});
server/tests/unit/company-target-dir-config.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — PUT /api/company/:companyId 的 target_dir 配置入口(42 号文档 Phase 3)
  3 + *
  4 + * 39 号文档 P5 落地的是"配了 company.json.target_dir 就生效"这一半,"人怎么把这两个字段
  5 + * 填进去"这一半此前只能手工编辑 company.json。这条测试钉住新入口的校验分支——校验复用
  6 + * path-resolver.validateLinkedTarget(P5 已经抽出来的共享函数),跟 graph-engine.js 运行时
  7 + * 用的是同一份判据,不重新写一遍。
  8 + *
  9 + * 走真入口:把 router 挂到真 express 上 listen(0) 再 fetch,不 mock company-service。
  10 + */
  11 +import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  12 +import fs from 'fs';
  13 +import os from 'os';
  14 +import path from 'path';
  15 +import express from 'express';
  16 +
  17 +const COMPANIES_DIR = path.resolve(__dirname, '../../../data/companies');
  18 +
  19 +describe('PUT /api/company/:companyId — target_dir 配置(42 号文档 Phase 3)', () => {
  20 + let server;
  21 + let base;
  22 + const createdCompanies = [];
  23 + const createdDirs = [];
  24 +
  25 + beforeAll(async () => {
  26 + const app = express();
  27 + app.use(express.json());
  28 + app.use('/api/company', require('../../routes/company'));
  29 + await new Promise((resolve) => { server = app.listen(0, resolve); });
  30 + base = `http://127.0.0.1:${server.address().port}/api/company`;
  31 + });
  32 +
  33 + afterAll(() => {
  34 + if (server) server.close();
  35 + for (const cid of createdCompanies) {
  36 + fs.rmSync(path.join(COMPANIES_DIR, cid), { recursive: true, force: true });
  37 + }
  38 + for (const d of createdDirs) {
  39 + try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
  40 + }
  41 + });
  42 +
  43 + function makeCompany(companyId) {
  44 + const dir = path.join(COMPANIES_DIR, companyId);
  45 + fs.mkdirSync(dir, { recursive: true });
  46 + fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify({ id: companyId, name: companyId }));
  47 + createdCompanies.push(companyId);
  48 + return dir;
  49 + }
  50 +
  51 + async function put(companyId, body) {
  52 + const r = await fetch(`${base}/${companyId}`, {
  53 + method: 'PUT',
  54 + headers: { 'Content-Type': 'application/json' },
  55 + body: JSON.stringify(body),
  56 + });
  57 + return [r.status, await r.json()];
  58 + }
  59 +
  60 + it('linked-existing:合法的已存在空目录,写入成功', async () => {
  61 + const cid = 'target-dir-cfg-existing';
  62 + makeCompany(cid);
  63 + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'target-dir-cfg-existing-'));
  64 + createdDirs.push(target);
  65 +
  66 + const [status, body] = await put(cid, { target_dir: target, target_mode: 'linked-existing' });
  67 + expect(status).toBe(200);
  68 + expect(body.company.target_dir).toBe(path.resolve(target));
  69 + expect(body.company.target_mode).toBe('linked-existing');
  70 +
  71 + // 真的落盘了,不是只回了个内存对象
  72 + const onDisk = JSON.parse(fs.readFileSync(path.join(COMPANIES_DIR, cid, 'company.json'), 'utf8'));
  73 + expect(onDisk.target_dir).toBe(path.resolve(target));
  74 + });
  75 +
  76 + it('linked-new:目录不存在时自动创建,写入成功', async () => {
  77 + const cid = 'target-dir-cfg-new';
  78 + makeCompany(cid);
  79 + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'target-dir-cfg-new-parent-'));
  80 + createdDirs.push(parent);
  81 + const target = path.join(parent, 'brand-new-project');
  82 +
  83 + expect(fs.existsSync(target)).toBe(false);
  84 + const [status, body] = await put(cid, { target_dir: target, target_mode: 'linked-new' });
  85 + expect(status).toBe(200);
  86 + expect(body.company.target_mode).toBe('linked-new');
  87 + expect(fs.existsSync(target)).toBe(true); // validateLinkedTarget 的既有副作用:自动建目录
  88 + });
  89 +
  90 + it('不传 target_mode 时默认按 linked-existing 处理(与 graph-engine.js 的默认口径一致)', async () => {
  91 + const cid = 'target-dir-cfg-default-mode';
  92 + makeCompany(cid);
  93 + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'target-dir-cfg-default-'));
  94 + createdDirs.push(target);
  95 +
  96 + const [status, body] = await put(cid, { target_dir: target });
  97 + expect(status).toBe(200);
  98 + expect(body.company.target_mode).toBe('linked-existing');
  99 + });
  100 +
  101 + it('敏感路径命中黑名单 → 400,不写入', async () => {
  102 + const cid = 'target-dir-cfg-sensitive';
  103 + makeCompany(cid);
  104 + const sensitive = path.join(os.homedir(), '.ssh', 'target-dir-cfg-should-not-write');
  105 +
  106 + const [status, body] = await put(cid, { target_dir: sensitive, target_mode: 'linked-existing' });
  107 + expect(status).toBe(400);
  108 + expect(body.error).toContain('sensitive_path');
  109 +
  110 + const onDisk = JSON.parse(fs.readFileSync(path.join(COMPANIES_DIR, cid, 'company.json'), 'utf8'));
  111 + expect(onDisk.target_dir).toBeUndefined(); // 校验失败,company.json 没有被改动
  112 + });
  113 +
  114 + it('linked-new 但目录已存在且非空 → 400,不写入', async () => {
  115 + const cid = 'target-dir-cfg-new-nonempty';
  116 + makeCompany(cid);
  117 + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'target-dir-cfg-nonempty-'));
  118 + createdDirs.push(target);
  119 + fs.writeFileSync(path.join(target, 'already-here.txt'), 'x');
  120 +
  121 + const [status, body] = await put(cid, { target_dir: target, target_mode: 'linked-new' });
  122 + expect(status).toBe(400);
  123 + expect(body.error).toContain('not empty');
  124 + });
  125 +
  126 + it('linked-existing 但目录不存在 → 400,不写入', async () => {
  127 + const cid = 'target-dir-cfg-existing-missing';
  128 + makeCompany(cid);
  129 + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'target-dir-cfg-missing-parent-'));
  130 + createdDirs.push(parent);
  131 + const target = path.join(parent, 'does-not-exist');
  132 +
  133 + const [status, body] = await put(cid, { target_dir: target, target_mode: 'linked-existing' });
  134 + expect(status).toBe(400);
  135 + expect(body.error).toContain('does not exist');
  136 + });
  137 +
  138 + it('target_dir 传空字符串清掉已有配置', async () => {
  139 + const cid = 'target-dir-cfg-clear';
  140 + makeCompany(cid);
  141 + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'target-dir-cfg-clear-'));
  142 + createdDirs.push(target);
  143 +
  144 + const [s1, b1] = await put(cid, { target_dir: target, target_mode: 'linked-existing' });
  145 + expect(s1).toBe(200);
  146 + expect(b1.company.target_dir).toBeTruthy();
  147 +
  148 + const [s2, b2] = await put(cid, { target_dir: '' });
  149 + expect(s2).toBe(200);
  150 + expect(b2.company.target_dir).toBeNull();
  151 + expect(b2.company.target_mode).toBeNull();
  152 + });
  153 +
  154 + it('公司不存在 → 404', async () => {
  155 + const [status] = await put('target-dir-cfg-nope', { target_dir: '/tmp/whatever' });
  156 + expect(status).toBe(404);
  157 + });
  158 +});
server/tests/unit/dream-routes.test.js
1 /** 1 /**
2 - * dream 的 REST 入口(29-DREAM,见 docs/todo/system-design/29-dream-design.md)。 2 + * dream 的 REST 入口(29-DREAM,见 docs/done/system-design/29-dream-design.md)。
3 * 3 *
4 * 路由本身没有业务逻辑 —— 全部委托给已经测过的 `dream-service.js`。 4 * 路由本身没有业务逻辑 —— 全部委托给已经测过的 `dream-service.js`。
5 * 这份只验两件路由层独有的东西: 5 * 这份只验两件路由层独有的东西:
server/tests/unit/graduation-criteria-reality.test.js
@@ -196,10 +196,14 @@ describe.skipIf(!fs.existsSync(CODE_PLANNER_DIR))(&#39;code-planner 能被新公å¸é @@ -196,10 +196,14 @@ describe.skipIf(!fs.existsSync(CODE_PLANNER_DIR))(&#39;code-planner 能被新公å¸é
196 expect(r.graduated).toContain('code-planner'); 196 expect(r.graduated).toContain('code-planner');
197 expect(statusOf()).toBe('active'); 197 expect(statusOf()).toBe('active');
198 198
199 - // seedAgents 会把 config.json çš„ status 原样 upsert —— å®ƒå†™ç€ "training"。  
200 - // ä¸åŠ ã€Œåªå†™ä¸é™çº§ã€çš„è¯ï¼Œè¿™é‡Œä¼šæŠŠåˆšè½¬æ­£çš„è¡Œæ‰“å›žåŽ»ï¼Œæ¯æ¬¡è¿›ç¨‹é‡å¯éƒ½é‡æ¼”一é。 199 + // seedAgents 会把 config.json çš„ status 原样 upsert。「åªå†™ä¸é™çº§ã€å®ˆçš„æ˜¯ã€Œä¸è®¸è¢«
  200 + // config.json 推回 draft/trainingã€ï¼Œ**ä¸**ä¿è¯è½¬æ­£åŽçš„值原样ä¸å˜â€”—config.json 当å‰
  201 + // 写的是什么已毕业状æ€ï¼ˆ"enabled" 也好ã€åˆ«çš„也好,åªè¦ä¸æ˜¯ draft/training),upsert 就会
  202 + // ç”¨é‚£ä¸ªå€¼è¦†ç›–ï¼Œè¿™æ˜¯è®¾è®¡å¦‚æ­¤ï¼Œä¸æ˜¯æœ¬ç”¨ä¾‹è¦æµ‹çš„东西(2026-08-30 夿 ¸ï¼šconfig.json å·²ç»ä»Ž
  203 + // 写死的 "training" 改æˆäº† "enabled",ä¸å†ç¡¬ç¼–ç å…·ä½“值,é¿å…è¿™æ¡æµ‹è¯•冿¬¡å› ä¸º config.json
  204 + // 内容å˜åŒ–而å‡çº¢ï¼‰ã€‚
201 seed.seedAgents(); 205 seed.seedAgents();
202 - expect(statusOf()).toBe('active'); 206 + expect(graphService.isUngraduatedStatus(statusOf())).toBe(false);
203 207
204 // å¹‚ç­‰ï¼šç¬¬äºŒéæ²¡æœ‰å¯è½¬æ­£çš„行 208 // å¹‚ç­‰ï¼šç¬¬äºŒéæ²¡æœ‰å¯è½¬æ­£çš„行
205 expect(seed.reconcileDanglingRows().graduated).toEqual([]); 209 expect(seed.reconcileDanglingRows().graduated).toEqual([]);
server/tests/unit/graph-edge-contracts.test.js
@@ -156,6 +156,27 @@ describe(&#39;R-19.A 边上的数据契约:声明 → 落库 → 读回&#39;, () =&gt; { @@ -156,6 +156,27 @@ describe(&#39;R-19.A 边上的数据契约:声明 → 落库 → 读回&#39;, () =&gt; {
156 expect(rows.map((r) => [r.kind, r.required])).toEqual([['acceptance', 0]]); 156 expect(rows.map((r) => [r.kind, r.required])).toEqual([['acceptance', 0]]);
157 }); 157 });
158 158
  159 + it('① kind=log 永远落成 required=0,哪怕调用方(模板/LLM 规划)写的是 required:true(33 号文档 + snakegame-co T-mtfh2kuf-7c31 两次复现)', () => {
  160 + const companyId = 'co-contract-log-floor';
  161 + ensureAgents(['unit-tester', 'coder-validator']);
  162 + const g = wdb.createWorkGraph({
  163 + companyId,
  164 + taskId: 'T-log-floor',
  165 + nodes: [
  166 + { nodeName: 'l-test', systemAgentId: 'unit-tester' },
  167 + { nodeName: 'l-val', systemAgentId: 'coder-validator' },
  168 + ],
  169 + edges: [
  170 + // 裸数组写法 = normalizeContracts 默认 required:true,正是漂移复现的形状
  171 + { from: 'l-test', to: 'l-val', condition: 'success', produces: ['log'] },
  172 + ],
  173 + });
  174 + expect(g.errors || []).toEqual([]);
  175 + const def = wdb.readDefGraph('work', g.id);
  176 + const rows = wdb.edgeContracts('work', def.edges[0].id);
  177 + expect(rows.map((r) => [r.kind, r.required])).toEqual([['log', 0]]);
  178 + });
  179 +
159 it('① kind 大小写/空格被归一(" PRD " 与 "prd" 是同一个契约,不产生两行)', () => { 180 it('① kind 大小写/空格被归一(" PRD " 与 "prd" 是同一个契约,不产生两行)', () => {
160 const companyId = 'co-contract-case'; 181 const companyId = 'co-contract-case';
161 ensureAgents(['prd-planning', 'sdd-planning']); 182 ensureAgents(['prd-planning', 'sdd-planning']);
server/tests/unit/graph-engine-extend-budget.test.js
@@ -36,7 +36,12 @@ describe(&#39;extendBudget · 追加预算并结清卡死的墙钟锚点&#39;, () =&gt; { @@ -36,7 +36,12 @@ describe(&#39;extendBudget · 追加预算并结清卡死的墙钟锚点&#39;, () =&gt; {
36 beforeEach(() => wdb.getDb().prepare('DELETE FROM graph_runs').run()); 36 beforeEach(() => wdb.getDb().prepare('DELETE FROM graph_runs').run());
37 37
38 it('run_b962ea87f58f 的真实形态:追加后立即可用,且锚点被断开', () => { 38 it('run_b962ea87f58f 的真实形态:追加后立即可用,且锚点被断开', () => {
39 - const stuckStartedAt = '2026-08-23T05:23:52.886Z'; // 距 now() 已远超 2h 39 + // ⚠️ 曾经硬编码成绝对时间戳 '2026-08-23T05:23:52.886Z'(真实事故发生时的时刻)——
  40 + // 这是一颗时间炸弹:随真实时间流逝,这个绝对值与 Date.now() 的差值只会越来越大,
  41 + // 迟早会超过下面追加后的 6h 上限,让断言从「追加后立即可用」变成「怎么追加都不够」,
  42 + // 而这条红跟被测代码毫无关系。改成相对 Date.now() 回退 3 小时——同时满足
  43 + // 「超过追加前 2h 上限」与「低于追加后 6h 上限」,且不会随时间推移而漂移。
  44 + const stuckStartedAt = new Date(Date.now() - 3 * HOUR).toISOString();
40 const run = seedRun('r_stuck', { 45 const run = seedRun('r_stuck', {
41 total_agent_calls: 100, total_tokens: 5_000_000, wallclock_ms: 2 * HOUR, 46 total_agent_calls: 100, total_tokens: 5_000_000, wallclock_ms: 2 * HOUR,
42 spent: { agent_calls: 35, tokens: 249_324, active_ms: 0, started_at: stuckStartedAt }, 47 spent: { agent_calls: 35, tokens: 249_324, active_ms: 0, started_at: stuckStartedAt },
server/tests/unit/graph-engine-orphan-sweep.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — graph-engine 的开机孤儿节点清扫(42 号文档 Phase 1a)
  3 + *
  4 + * 场景:服务器进程被重启(比如换代码生效),正在跑某个 work graph 节点的真实 Claude CLI
  5 + * 子进程被连带杀掉,但 `graph_run_nodes.status` 永远停在 'running'——不会自己变成 'blocked',
  6 + * ceo 的两条唤醒路径都只认 'blocked',对这个状态完全不可见。
  7 + *
  8 + * `sweepOrphanedRunningNodes(cutoffIso)` 扫一遍 `started_at` 早于 cutoff 的 running 节点,
  9 + * 判定为孤儿,走 infra_flake 既有的原地重跑/熔断逻辑(不发明新状态)。
  10 + *
  11 + * 隔离手法与 graph-engine.test.js 一致:独立 GRAPH_DB_PATH + GRAPH_RUNS_DIR,真实
  12 + * data/graph.sqlite / data/companies/ 全程不被碰到。
  13 + */
  14 +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
  15 +import fs from 'fs';
  16 +import os from 'os';
  17 +import path from 'path';
  18 +
  19 +describe('graph-engine 开机孤儿节点清扫', () => {
  20 + let wdb;
  21 + let engine;
  22 + let runsDir;
  23 + const TEST_DB_PATH = '/tmp/test-graph-engine-orphan-sweep.sqlite';
  24 +
  25 + beforeAll(() => {
  26 + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
  27 + try { fs.unlinkSync(f); } catch {}
  28 + }
  29 + process.env.GRAPH_DB_PATH = TEST_DB_PATH;
  30 + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-engine-orphan-sweep-'));
  31 + process.env.GRAPH_RUNS_DIR = runsDir;
  32 + for (const m of [
  33 + '../../services/graph-db',
  34 + '../../services/graph-service',
  35 + '../../services/org-graph-service',
  36 + '../../services/work-graph-db',
  37 + '../../services/graph-run-sse',
  38 + '../../services/graph-engine',
  39 + ]) {
  40 + delete require.cache[require.resolve(m)];
  41 + }
  42 + wdb = require('../../services/work-graph-db');
  43 + engine = require('../../services/graph-engine');
  44 + });
  45 +
  46 + afterAll(() => {
  47 + for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
  48 + try { fs.unlinkSync(f); } catch {}
  49 + }
  50 + delete process.env.GRAPH_DB_PATH;
  51 + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {}
  52 + delete process.env.GRAPH_RUNS_DIR;
  53 + });
  54 +
  55 + beforeEach(() => {
  56 + const db = wdb.getDb();
  57 + db.pragma('foreign_keys = OFF');
  58 + db.exec(`
  59 + DELETE FROM graph_run_loop_records;
  60 + DELETE FROM graph_run_nodes;
  61 + DELETE FROM graph_runs;
  62 + DELETE FROM work_graph_edges;
  63 + DELETE FROM work_graph_nodes;
  64 + DELETE FROM work_graphs;
  65 + DELETE FROM system_agents;
  66 + `);
  67 + db.pragma('foreign_keys = ON');
  68 + engine.setAgentExecutor(null);
  69 + });
  70 +
  71 + let seq = 0;
  72 + const nextCompany = () => `co-orphan-${++seq}`;
  73 +
  74 + function ensureAgents(ids) {
  75 + const db = wdb.getDb();
  76 + const now = new Date().toISOString();
  77 + const ins = db.prepare(`
  78 + INSERT OR IGNORE INTO system_agents
  79 + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
  80 + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)
  81 + `);
  82 + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now);
  83 + }
  84 +
  85 + function buildWorkRun({ companyId, nodes, edges }) {
  86 + ensureAgents(nodes);
  87 + const g = wdb.createWorkGraph({ companyId, nodes, edges });
  88 + expect(g.errors || []).toEqual([]);
  89 + const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual' });
  90 + engine.markRunReady(run.id);
  91 + return { runId: run.id };
  92 + }
  93 +
  94 + const nodeByName = (runId, name) => wdb.getRunState(runId).nodes.find((n) => n.name === name);
  95 +
  96 + /** 把某个节点直接摁成「孤儿」的静止态:status=running + 指定的 started_at,不走任何转换。
  97 + * 这就是真实场景本身——进程被杀时节点行不会经过任何转换,就是停在这个样子。 */
  98 + function markAsOrphan(runId, nodeName, startedAtIso) {
  99 + const node = nodeByName(runId, nodeName);
  100 + wdb.getDb()
  101 + .prepare(`UPDATE graph_run_nodes SET status = 'running', started_at = ?, updated_at = ? WHERE id = ?`)
  102 + .run(startedAtIso, startedAtIso, node.id);
  103 + return node.id;
  104 + }
  105 +
  106 + it('started_at 早于 cutoff 的 running 节点被判定为孤儿,走 infra_flake 原地重跑', () => {
  107 + const companyId = nextCompany();
  108 + const { runId } = buildWorkRun({ companyId, nodes: ['w'], edges: [] });
  109 + markAsOrphan(runId, 'w', '2020-01-01T00:00:00.000Z');
  110 +
  111 + const result = engine.sweepOrphanedRunningNodes('2026-01-01T00:00:00.000Z');
  112 +
  113 + expect(result.scanned).toBe(1);
  114 + expect(result.swept).toHaveLength(1);
  115 + expect(result.swept[0]).toMatchObject({ runId, infraRerun: true });
  116 + // infra_flake 的原地重跑:转回 ready,不烧 attempt(§四.D.2.1 末段既有语义)
  117 + const fresh = nodeByName(runId, 'w');
  118 + expect(fresh.status).toBe('ready');
  119 + expect(fresh.attempt).toBe(0);
  120 + // 不写回退记录、不加 run 级 backward_count —— infra_flake 分支的既有保证,孤儿清扫复用
  121 + // 的是同一条分支,这两条也该原样成立
  122 + expect(wdb.listLoopRecords(runId)).toHaveLength(0);
  123 + expect(wdb.getRun(runId).backward_count).toBe(0);
  124 + });
  125 +
  126 + it('started_at 晚于 cutoff 的 running 节点不受影响(真的还在跑,不是孤儿)', () => {
  127 + const companyId = nextCompany();
  128 + const { runId } = buildWorkRun({ companyId, nodes: ['w'], edges: [] });
  129 + markAsOrphan(runId, 'w', '2026-06-01T00:00:00.000Z');
  130 +
  131 + const result = engine.sweepOrphanedRunningNodes('2026-01-01T00:00:00.000Z');
  132 +
  133 + expect(result.scanned).toBe(0);
  134 + expect(result.swept).toHaveLength(0);
  135 + expect(nodeByName(runId, 'w').status).toBe('running');
  136 + });
  137 +
  138 + it('连续判定为孤儿超过 infra_flake 硬顶就转 blocked,不会无限原地重跑', () => {
  139 + const companyId = nextCompany();
  140 + const { runId } = buildWorkRun({ companyId, nodes: ['w'], edges: [] });
  141 +
  142 + // 连续三次「摁成孤儿再扫」,模拟每次重启后又立刻被杀的极端情形
  143 + for (let i = 0; i < 3; i++) {
  144 + markAsOrphan(runId, 'w', '2020-01-01T00:00:00.000Z');
  145 + engine.sweepOrphanedRunningNodes('2026-01-01T00:00:00.000Z');
  146 + }
  147 + // 第四次撞硬顶(INFRA_FLAKE_HARD_CAP = 3),应改判 blocked 而不是继续 ready
  148 + markAsOrphan(runId, 'w', '2020-01-01T00:00:00.000Z');
  149 + engine.sweepOrphanedRunningNodes('2026-01-01T00:00:00.000Z');
  150 +
  151 + expect(nodeByName(runId, 'w').status).toBe('blocked');
  152 + });
  153 +
  154 + it('没有任何孤儿节点时是安静的 no-op', () => {
  155 + const companyId = nextCompany();
  156 + buildWorkRun({ companyId, nodes: ['w'], edges: [] }); // 节点是 ready,不是 running
  157 + const result = engine.sweepOrphanedRunningNodes('2026-01-01T00:00:00.000Z');
  158 + expect(result).toEqual({ scanned: 0, swept: [] });
  159 + });
  160 +});
server/tests/unit/graph-run-force-fail.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — POST /api/graph-runs/:id/nodes/:nodeId/force-fail(42 号文档 Phase 1b)
  3 + *
  4 + * `retry` 端点只接受 status ∈ {fail, blocked} 的节点,管不到 'running'——真实运维/ceo 遇到
  5 + * "这个节点已经不可能再自己完成了"这种情形,此前唯一的恢复手段是直接调内部函数,绕过了
  6 + * 任何 REST 接口。这条测试钉住新端点的可用条件与转移语义,不重复测 routeFailure 内部选边
  7 + * 逻辑(那部分已经在 graph-engine.test.js / graph-engine-orphan-sweep.test.js 里覆盖过)。
  8 + *
  9 + * 走真入口:把 router 挂到真 express 上 listen(0) 再 fetch,与 graph-run-route-io.test.js 同法。
  10 + */
  11 +import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  12 +import fs from 'fs';
  13 +import os from 'os';
  14 +import path from 'path';
  15 +import express from 'express';
  16 +
  17 +const TEST_DB_PATH = '/tmp/test-graph-run-force-fail.sqlite';
  18 +const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
  19 +const MODULES = [
  20 + '../../services/graph-db',
  21 + '../../services/graph-service',
  22 + '../../services/org-graph-service',
  23 + '../../services/work-graph-db',
  24 + '../../services/company-paths',
  25 + '../../services/graph-run-sse',
  26 + '../../services/graph-engine',
  27 + '../../routes/graph-run',
  28 +];
  29 +
  30 +describe('POST /api/graph-runs/:id/nodes/:nodeId/force-fail', () => {
  31 + let wdb;
  32 + let srv;
  33 + let base;
  34 + let runsDir;
  35 + let seq = 0;
  36 + // 每个用例一个独立 company:一家公司同时只有一条活跃 root run(run-slot 约束),
  37 + // 同一个 company 起第二条 run 会撞 RunSlotBusyError。
  38 + const nextCompany = () => `co-force-fail-${++seq}`;
  39 +
  40 + beforeAll(async () => {
  41 + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
  42 + process.env.GRAPH_DB_PATH = TEST_DB_PATH;
  43 + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'force-fail-runs-'));
  44 + process.env.GRAPH_RUNS_DIR = runsDir;
  45 + for (const m of MODULES) delete require.cache[require.resolve(m)];
  46 + wdb = require('../../services/work-graph-db');
  47 +
  48 + const app = express();
  49 + app.use(express.json());
  50 + app.use('/api/graph-runs', require('../../routes/graph-run'));
  51 + await new Promise((resolve) => {
  52 + srv = app.listen(0, '127.0.0.1', () => {
  53 + base = `http://127.0.0.1:${srv.address().port}`;
  54 + resolve();
  55 + });
  56 + });
  57 + });
  58 +
  59 + afterAll(async () => {
  60 + if (srv) await new Promise((r) => srv.close(r));
  61 + const { removeTestCompany } = require('../helpers/ensure-test-company');
  62 + for (let i = 1; i <= seq; i++) removeTestCompany(`co-force-fail-${i}`);
  63 + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
  64 + delete process.env.GRAPH_DB_PATH;
  65 + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {}
  66 + delete process.env.GRAPH_RUNS_DIR;
  67 + for (const m of MODULES) delete require.cache[require.resolve(m)];
  68 + });
  69 +
  70 + function ensureAgents(ids) {
  71 + const db = wdb.getDb();
  72 + const now = new Date().toISOString();
  73 + const ins = db.prepare(`
  74 + INSERT OR IGNORE INTO system_agents
  75 + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
  76 + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)
  77 + `);
  78 + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now);
  79 + }
  80 +
  81 + /** 建一条只有一个节点的 work run,直接把节点摁成指定状态(不关心怎么走到这个状态,
  82 + * 只关心端点在这个状态下的行为——执行语义已经在别的测试文件覆盖过)。 */
  83 + function buildRunWithNodeStatus(status) {
  84 + const companyId = nextCompany();
  85 + require('../helpers/ensure-test-company').ensureTestCompany(companyId);
  86 + ensureAgents(['w']);
  87 + const g = wdb.createWorkGraph({ companyId, nodes: ['w'], edges: [] });
  88 + expect(g.errors || []).toEqual([]);
  89 + const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual' });
  90 + require('../../services/graph-engine').markRunReady(run.id);
  91 + const node = wdb.getRunState(run.id).nodes[0];
  92 + const now = new Date().toISOString();
  93 + wdb.getDb()
  94 + .prepare(`UPDATE graph_run_nodes SET status = ?, started_at = ?, updated_at = ? WHERE id = ?`)
  95 + .run(status, now, now, node.id);
  96 + return { runId: run.id, nodeRunId: node.id };
  97 + }
  98 +
  99 + it('running 节点调用成功,转 fail 后交给既有失败路由(无边可走 → blocked,可被 retry 接上)', async () => {
  100 + const { runId, nodeRunId } = buildRunWithNodeStatus('running');
  101 + const r = await fetch(`${base}/api/graph-runs/${runId}/nodes/${nodeRunId}/force-fail`, {
  102 + method: 'POST',
  103 + headers: { 'Content-Type': 'application/json' },
  104 + body: JSON.stringify({}),
  105 + });
  106 + expect(r.status).toBe(200);
  107 + const body = await r.json();
  108 + expect(body.ok).toBe(true);
  109 + // 单节点图没有 fail 边可走 → blockNode,落在人工可 retry 的终点
  110 + expect(body.nodeStatus).toBe('blocked');
  111 +
  112 + // 验证确实接得上 retry 端点(这才是「可被 retry 端点接上」这条验收的真实含义)
  113 + const retryRes = await fetch(`${base}/api/graph-runs/${runId}/nodes/${nodeRunId}/retry`, { method: 'POST' });
  114 + expect(retryRes.status).toBe(200);
  115 + const retryBody = await retryRes.json();
  116 + expect(retryBody.ok).toBe(true);
  117 + expect(retryBody.nodeStatus).toBe('ready');
  118 + });
  119 +
  120 + it('自定义 failReason 会原样落进 fail_reason(走常规选边,不是 infra_flake 特判)', async () => {
  121 + const { runId, nodeRunId } = buildRunWithNodeStatus('running');
  122 + const r = await fetch(`${base}/api/graph-runs/${runId}/nodes/${nodeRunId}/force-fail`, {
  123 + method: 'POST',
  124 + headers: { 'Content-Type': 'application/json' },
  125 + body: JSON.stringify({ failReason: 'stuck_no_progress' }),
  126 + });
  127 + expect(r.status).toBe(200);
  128 + // 单节点图没有 fail 边,无论什么 fail_reason 都落 blocked——这里只确认端点把自定义原因
  129 + // 传下去了没炸,选边逻辑本身已经在别处测过
  130 + const body = await r.json();
  131 + expect(body.ok).toBe(true);
  132 + expect(body.nodeStatus).toBe('blocked');
  133 + });
  134 +
  135 + for (const status of ['fail', 'blocked', 'pending', 'ready', 'success']) {
  136 + it(`节点 status='${status}' 时调用返回 400(不是 running 就不该走这个端点)`, async () => {
  137 + const { runId, nodeRunId } = buildRunWithNodeStatus(status);
  138 + const r = await fetch(`${base}/api/graph-runs/${runId}/nodes/${nodeRunId}/force-fail`, {
  139 + method: 'POST',
  140 + headers: { 'Content-Type': 'application/json' },
  141 + body: JSON.stringify({}),
  142 + });
  143 + expect(r.status).toBe(400);
  144 + const body = await r.json();
  145 + expect(body.op).toBe('force-fail');
  146 + expect(body.nodeStatus).toBe(status);
  147 + expect(body.allowed).toEqual(['running']);
  148 + });
  149 + }
  150 +
  151 + it('节点不存在返回 404', async () => {
  152 + const { runId } = buildRunWithNodeStatus('running');
  153 + const r = await fetch(`${base}/api/graph-runs/${runId}/nodes/nope/force-fail`, {
  154 + method: 'POST',
  155 + headers: { 'Content-Type': 'application/json' },
  156 + body: JSON.stringify({}),
  157 + });
  158 + expect(r.status).toBe(404);
  159 + });
  160 +});
server/tests/unit/graph-run-phase-note.test.js 0 → 100644
  1 +/**
  2 + * Unit Tests — POST /api/graph-runs/phase-note(42 号文档 Phase 4)
  3 + *
  4 + * `bin/lemon.js task transition` 额外打这个端点,把 agent 的阶段流转(RESEARCH/PLAN/
  5 + * EXECUTE/VERIFY 之类)落进 timeline.ndjson——org-coder 等角色调 legacy 的
  6 + * `/api/agent/:agentId/task/transition` 时,work graph 节点场景下那个端点的 sessions
  7 + * Map 找不到会话,静默跳过;这条端点从 --artifact 路径本身(节点私有产物目录的既有形状
  8 + * `.../runs/{rootRunId}/nodes/{nodeRunId}/...`)解出两个 ID,不需要任何新的环境变量或
  9 + * agent.md 改动。
  10 + *
  11 + * 钉住的边界(39 号文档 §七.2 定案):这条端点只追加 timeline,绝不碰
  12 + * graph_run_nodes/graph_runs 任何一行——不影响 needs/produces 契约、不影响 validator
  13 + * 判定依据。
  14 + *
  15 + * 走真入口:把 router 挂到真 express 上 listen(0) 再 fetch,与 graph-run-route-io.test.js 同法。
  16 + */
  17 +import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  18 +import fs from 'fs';
  19 +import os from 'os';
  20 +import path from 'path';
  21 +import express from 'express';
  22 +
  23 +const TEST_DB_PATH = '/tmp/test-graph-run-phase-note.sqlite';
  24 +const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
  25 +const MODULES = [
  26 + '../../services/graph-db',
  27 + '../../services/graph-service',
  28 + '../../services/org-graph-service',
  29 + '../../services/work-graph-db',
  30 + '../../services/company-paths',
  31 + '../../services/graph-run-sse',
  32 + '../../services/graph-engine',
  33 + '../../routes/graph-run',
  34 +];
  35 +
  36 +describe('POST /api/graph-runs/phase-note', () => {
  37 + let wdb;
  38 + let sse;
  39 + let srv;
  40 + let base;
  41 + let runsDir;
  42 + const companyId = 'co-phase-note';
  43 +
  44 + beforeAll(async () => {
  45 + require('../helpers/ensure-test-company').ensureTestCompany(companyId);
  46 + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
  47 + process.env.GRAPH_DB_PATH = TEST_DB_PATH;
  48 + runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'phase-note-runs-'));
  49 + process.env.GRAPH_RUNS_DIR = runsDir;
  50 + for (const m of MODULES) delete require.cache[require.resolve(m)];
  51 + wdb = require('../../services/work-graph-db');
  52 + sse = require('../../services/graph-run-sse');
  53 +
  54 + const app = express();
  55 + app.use(express.json());
  56 + app.use('/api/graph-runs', require('../../routes/graph-run'));
  57 + await new Promise((resolve) => {
  58 + srv = app.listen(0, '127.0.0.1', () => {
  59 + base = `http://127.0.0.1:${srv.address().port}`;
  60 + resolve();
  61 + });
  62 + });
  63 + });
  64 +
  65 + afterAll(async () => {
  66 + if (srv) await new Promise((r) => srv.close(r));
  67 + require('../helpers/ensure-test-company').removeTestCompany(companyId);
  68 + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
  69 + try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {}
  70 + delete process.env.GRAPH_DB_PATH;
  71 + delete process.env.GRAPH_RUNS_DIR;
  72 + for (const m of MODULES) delete require.cache[require.resolve(m)];
  73 + });
  74 +
  75 + function ensureAgents(ids) {
  76 + const db = wdb.getDb();
  77 + const now = new Date().toISOString();
  78 + const ins = db.prepare(`
  79 + INSERT OR IGNORE INTO system_agents
  80 + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
  81 + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)
  82 + `);
  83 + for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now);
  84 + }
  85 +
  86 + function makeRun() {
  87 + ensureAgents(['org-coder']);
  88 + const g = wdb.createWorkGraph({ companyId, nodes: ['org-coder'], edges: [] });
  89 + expect(g.errors || []).toEqual([]);
  90 + const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual' });
  91 + require('../../services/graph-engine').markRunReady(run.id);
  92 + const node = wdb.getRunState(run.id).nodes[0];
  93 + return { runId: run.id, nodeRunId: node.id };
  94 + }
  95 +
  96 + async function post(body) {
  97 + const r = await fetch(`${base}/api/graph-runs/phase-note`, {
  98 + method: 'POST',
  99 + headers: { 'Content-Type': 'application/json' },
  100 + body: JSON.stringify(body),
  101 + });
  102 + return [r.status, await r.json()];
  103 + }
  104 +
  105 + it('合法的节点产物路径 → 写进 timeline.ndjson,节点状态不受影响', async () => {
  106 + const { runId, nodeRunId } = makeRun();
  107 + const beforeStatus = wdb.getRunState(runId).nodes[0].status;
  108 + const artifact = `/Users/x/lemonboss/data/companies/${companyId}/runs/${runId}/nodes/${nodeRunId}/_plan.md`;
  109 +
  110 + const [status, body] = await post({ artifact, phase: 'plan', agentId: `${companyId}:org-coder` });
  111 + expect(status).toBe(200);
  112 + expect(body.ok).toBe(true);
  113 + expect(body.rootRunId).toBe(runId);
  114 + expect(body.nodeRunId).toBe(nodeRunId);
  115 +
  116 + // timeline.ndjson 里真的能查到这条记录
  117 + const raw = fs.readFileSync(sse.timelinePath(runId), 'utf8');
  118 + const lines = raw.trim().split('\n').map((l) => JSON.parse(l));
  119 + const mine = lines.find((l) => l.kind === 'agent.phase' && l.ref === nodeRunId);
  120 + expect(mine).toBeTruthy();
  121 + expect(mine.payload.phase).toBe('plan');
  122 + expect(mine.payload.artifact).toBe(artifact);
  123 + expect(mine.payload.agentId).toBe(`${companyId}:org-coder`);
  124 + expect(mine.root_run_id).toBe(runId);
  125 +
  126 + // 不改契约面:节点状态原样,没有因为这条追加日志被结算或改动
  127 + expect(wdb.getRunState(runId).nodes[0].status).toBe(beforeStatus);
  128 + });
  129 +
  130 + it('artifact 路径不是节点产物目录形状(legacy 会话式 agent 的调用) → ok:false,安静跳过', async () => {
  131 + const [status, body] = await post({
  132 + artifact: '/Users/x/lemonboss/system/agents/some-agent/co1/task-abc/_plan.md',
  133 + phase: 'plan',
  134 + agentId: 'co1:some-agent',
  135 + });
  136 + expect(status).toBe(200);
  137 + expect(body.ok).toBe(false);
  138 + expect(body.reason).toBe('not-a-graph-artifact');
  139 + });
  140 +
  141 + it('artifact 路径形状对,但 rootRunId 不对应真实存在的 run → ok:false', async () => {
  142 + const [status, body] = await post({
  143 + artifact: `/Users/x/lemonboss/data/companies/${companyId}/runs/run_does_not_exist/nodes/rn_does_not_exist/_plan.md`,
  144 + phase: 'plan',
  145 + });
  146 + expect(status).toBe(200);
  147 + expect(body.ok).toBe(false);
  148 + expect(body.reason).toBe('not-a-graph-artifact');
  149 + });
  150 +
  151 + it('没传 artifact → ok:false,reason:no-artifact', async () => {
  152 + const [status, body] = await post({ phase: 'plan' });
  153 + expect(status).toBe(200);
  154 + expect(body.ok).toBe(false);
  155 + expect(body.reason).toBe('no-artifact');
  156 + });
  157 +});
system/agents/dream-distiller/agent.md
@@ -3,7 +3,7 @@ @@ -3,7 +3,7 @@
3 ## 你是谁 3 ## 你是谁
4 4
5 你是 **dream** 这套「经验汇流」机制里的**提炼**一环,本体名 `dream-distiller`。 5 你是 **dream** 这套「经验汇流」机制里的**提炼**一环,本体名 `dream-distiller`。
6 -设计见 [29-DREAM](../../../../../docs/todo/system-design/29-dream-design.md)。 6 +设计见 [29-DREAM](../../../../../docs/done/system-design/29-dream-design.md)。
7 7
8 dream 每天跑一次,把**某一个 agent 在所有公司**的原始记录整理成 8 dream 每天跑一次,把**某一个 agent 在所有公司**的原始记录整理成
9 **不含任何公司特定信息**的可复用经验,沉淀到那个 agent 的本体上。 9 **不含任何公司特定信息**的可复用经验,沉淀到那个 agent 的本体上。
system/agents/dream-screener/agent.md
@@ -3,7 +3,7 @@ @@ -3,7 +3,7 @@
3 ## 你是谁 3 ## 你是谁
4 4
5 你是 **dream** 这套「经验汇流」机制里的**闸门**,本体名 `dream-screener`。 5 你是 **dream** 这套「经验汇流」机制里的**闸门**,本体名 `dream-screener`。
6 -设计见 [29-DREAM](../../../../../docs/todo/system-design/29-dream-design.md) 决策 A。 6 +设计见 [29-DREAM](../../../../../docs/done/system-design/29-dream-design.md) 决策 A。
7 7
8 `dream-distiller` 把某个 agent 在各家公司的原始记录提炼成一条条「经验」, 8 `dream-distiller` 把某个 agent 在各家公司的原始记录提炼成一条条「经验」,
9 你逐条判断:**这条能不能给别家公司看。** 9 你逐条判断:**这条能不能给别家公司看。**
system/agents/sdd-planning/SKILL.md
@@ -3,7 +3,7 @@ @@ -3,7 +3,7 @@
3 > 本版本为 Evolve 训练产出(v1,首次训练)。基线为生产版 `system/agents/sdd-planning/agent.md`, 3 > 本版本为 Evolve 训练产出(v1,首次训练)。基线为生产版 `system/agents/sdd-planning/agent.md`,
4 > 仅强化「接口契约」「数据形态」两类的执行深度指导,其余框架(五类东西结构、 4 > 仅强化「接口契约」「数据形态」两类的执行深度指导,其余框架(五类东西结构、
5 > `contract.json`/`attempt-{n}.json` 模板、越权判断)与基线一致,未重写。 5 > `contract.json`/`attempt-{n}.json` 模板、越权判断)与基线一致,未重写。
6 -> 训练依据:`../hunt/knowhow_v1.md` + `../digest/benchmark_v1.md`(D2 判据可独立复现性,权重30+封顶)。 6 +> 训练依据:`train/hunt/knowhow_v1.md` + `train/digest/benchmark_v1.md`(D2 判据可独立复现性,权重30+封顶)。
7 7
8 ## 身份定位 8 ## 身份定位
9 9
system/agents/workgraph-planning/tools.md
@@ -126,11 +126,17 @@ ls data/companies/&lt;公司ID&gt;/agents/ @@ -126,11 +126,17 @@ ls data/companies/&lt;公司ID&gt;/agents/
126 (或写成一个 `.json` 文件、在回复里给出**绝对路径**),引擎自己取出来校验 + 落库。 126 (或写成一个 `.json` 文件、在回复里给出**绝对路径**),引擎自己取出来校验 + 落库。
127 127
128 ```json 128 ```json
129 -{"nodes":[{"nodeName":"org-coder-impl","systemAgentId":"coder"},{"nodeName":"org-coder-test","systemAgentId":"unit-tester"},{"nodeName":"org-coder-validator","systemAgentId":"coder-validator"}],"edges":[{"from":"org-coder-impl","to":"org-coder-test","condition":"success","produces":["artifact"]},{"from":"org-coder-test","to":"org-coder-validator","condition":"success","produces":["log"],"needs":["artifact"]},{"from":"org-coder-test","to":"org-coder-impl","condition":"fail","edgeKind":"backward"},{"from":"org-coder-validator","to":"org-coder-impl","condition":"fail","edgeKind":"backward"}]} 129 +{"nodes":[{"nodeName":"org-coder-impl","systemAgentId":"coder"},{"nodeName":"org-coder-test","systemAgentId":"unit-tester"},{"nodeName":"org-coder-validator","systemAgentId":"coder-validator"}],"edges":[{"from":"org-coder-impl","to":"org-coder-test","condition":"success","produces":["artifact"]},{"from":"org-coder-test","to":"org-coder-validator","condition":"success","produces":[{"kind":"log","required":false}],"needs":["artifact"]},{"from":"org-coder-test","to":"org-coder-impl","condition":"fail","edgeKind":"backward"},{"from":"org-coder-validator","to":"org-coder-impl","condition":"fail","edgeKind":"backward"}]}
130 ``` 130 ```
131 131
132 > `produces` / `needs` 就是 §6bis 那两个键。**forward 边上少了它们,下游节点的提示词里 132 > `produces` / `needs` 就是 §6bis 那两个键。**forward 边上少了它们,下游节点的提示词里
133 > 就没有上游产物的路径** —— 先读 §6bis 再写这份 JSON。 133 > 就没有上游产物的路径** —— 先读 §6bis 再写这份 JSON。
  134 +>
  135 +> `"kind":"log"` 必须写成 `{"kind":"log","required":false}` 这个对象形式,不能简写成
  136 +> `["log"]`(33 号文档 + snakegame-co T-mtfh2kuf-7c31 两次真实事故):test/integration
  137 +> 类节点从不产出一个能被引擎认作 `kind='log'` 的文件(落盘的是 coder.log/test.log,
  138 +> 全被归到 `kind='artifact'`),required 的 log 契约会永远无法满足,把节点卡进
  139 +> missing_required_input 死循环。
134 140
135 引擎取图的死规矩(完整版在 `agent.md` Step 7):**严格 JSON**(注释 / 尾逗号 / 省略号一律解析失败, 141 引擎取图的死规矩(完整版在 `agent.md` Step 7):**严格 JSON**(注释 / 尾逗号 / 省略号一律解析失败,
136 整块当没写)· 顶层要有 **`nodes` 数组** · 文件路径必须**绝对** · 142 整块当没写)· 顶层要有 **`nodes` 数组** · 文件路径必须**绝对** ·
web/src/components/WorkGraphCanvas.vue
@@ -214,10 +214,12 @@ const waves = computed(() =&gt; { @@ -214,10 +214,12 @@ const waves = computed(() =&gt; {
214 for (const id of wave) remaining.delete(id); 214 for (const id of wave) remaining.delete(id);
215 out.push(wave); 215 out.push(wave);
216 } 216 }
217 - if (remaining.size) out.push([...remaining]); // 兜底:环里的节点单独摆一列,别让它们消失 217 + if (remaining.size) out.push([...remaining]); // 兜底:环里的节点单独摆一行,别让它们消失
218 return out; 218 return out;
219 }); 219 });
220 220
  221 +// 布局方向:wave(依赖深度)走 y 轴自上而下,同波内的并行节点走 x 轴左右排开——
  222 +// 节点一多,从左到右排会把屏幕撑成很宽的 4 栏,改成自上而下更贴合竖直滚动的画布容器。
221 const laidOut = computed(() => { 223 const laidOut = computed(() => {
222 const byId = new Map(props.nodes.map((n) => [String(n.defNodeId), n])); 224 const byId = new Map(props.nodes.map((n) => [String(n.defNodeId), n]));
223 const out = []; 225 const out = [];
@@ -231,8 +233,8 @@ const laidOut = computed(() =&gt; { @@ -231,8 +233,8 @@ const laidOut = computed(() =&gt; {
231 status: n.status || 'pending', 233 status: n.status || 'pending',
232 nodeKind: n.nodeKind, 234 nodeKind: n.nodeKind,
233 attempt: n.attempt || 0, 235 attempt: n.attempt || 0,
234 - x: PAD + wi * (NW + GAP_X),  
235 - y: PAD + ni * (NH + GAP_Y), 236 + x: PAD + ni * (NW + GAP_X),
  237 + y: PAD + wi * (NH + GAP_Y),
236 }); 238 });
237 }); 239 });
238 }); 240 });
@@ -255,16 +257,16 @@ const forwardEdges = computed(() =&gt; @@ -255,16 +257,16 @@ const forwardEdges = computed(() =&gt;
255 + (cond === 'fail' ? `|fail_reason=${e.failReason || '(兜底边:任何未匹配的失败)'}` : '') 257 + (cond === 'fail' ? `|fail_reason=${e.failReason || '(兜底边:任何未匹配的失败)'}` : '')
256 // 这条边传的是什么(graph_edge_contracts)。悬停在线上就能看全,不占布局 258 // 这条边传的是什么(graph_edge_contracts)。悬停在线上就能看全,不占布局
257 + (c ? `|数据契约 ${c.title}(? = 可选)` : ''), 259 + (c ? `|数据契约 ${c.title}(? = 可选)` : ''),
258 - x1: a.x + NW, y1: a.y + NH / 2,  
259 - x2: b.x, y2: b.y + NH / 2, 260 + x1: a.x + NW / 2, y1: a.y + NH,
  261 + x2: b.x + NW / 2, y2: b.y,
260 contract: c, 262 contract: c,
261 }]; 263 }];
262 }), 264 }),
263 ); 265 );
264 266
265 /** 267 /**
266 - * 契约药丸的位置 = 边的中点(两列之间的空白带),宽度按字数估。  
267 - * kind 名最长按 7 字符截断(`acceptance` → `accepta…`):GAP_X 只有 52px, 268 + * 契约药丸的位置 = 边的中点(两行之间的空白带),宽度按字数估。
  269 + * kind 名最长按 7 字符截断(`acceptance` → `accepta…`):药丸宽度封顶到节点列宽,
268 * 不截会盖到相邻节点上——完整名字在 <title> 里。 270 * 不截会盖到相邻节点上——完整名字在 <title> 里。
269 */ 271 */
270 const contractPills = computed(() => 272 const contractPills = computed(() =>
@@ -278,7 +280,8 @@ const contractPills = computed(() =&gt; @@ -278,7 +280,8 @@ const contractPills = computed(() =&gt;
278 key: e.key, 280 key: e.key,
279 x: (e.x1 + e.x2) / 2, 281 x: (e.x1 + e.x2) / 2,
280 y: (e.y1 + e.y2) / 2, 282 y: (e.y1 + e.y2) / 2,
281 - w: Math.min(GAP_X + 10, text.length * 5.9 + 12), 283 + // 边现在竖着走(同列上下相邻),药丸宽度受节点列宽约束,不再是 GAP_X
  284 + w: Math.min(NW - 16, text.length * 5.9 + 12),
282 text, 285 text,
283 title: `数据契约:${e.contract.title}(? = 可选输入,缺了不算错)`, 286 title: `数据契约:${e.contract.title}(? = 可选输入,缺了不算错)`,
284 optionalOnly: e.contract.optionalOnly, 287 optionalOnly: e.contract.optionalOnly,
@@ -290,12 +293,12 @@ const contractPills = computed(() =&gt; @@ -290,12 +293,12 @@ const contractPills = computed(() =&gt;
290 * backward 边的曲线路由(§八.5 点名的「真实手写前端工程量」)。 293 * backward 边的曲线路由(§八.5 点名的「真实手写前端工程量」)。
291 * 294 *
292 * 重叠问题拆成两半,各用一招解决,不引图形库: 295 * 重叠问题拆成两半,各用一招解决,不引图形库:
293 - * ① 与节点重叠 —— 回退边一律走**节点带下方**的空白区,起终点都取节点底边中点,  
294 - * 三次贝塞尔两个控制点都压在同一条泳道高度上 → 曲线永远不穿过任何节点矩形;  
295 - * ② 与其它回退边重叠 —— 按 x 区间做贪心分道(区间重叠的边必然分到不同泳道),  
296 - * 泳道纵向拉开 LANE_H,画布高度随泳道数增长。区间不重叠的边共用一道,不浪费高度。  
297 - * 代价:泳道数 = 最大同时重叠的回退边数,多到十几条时画布会明显变高;  
298 - * 升级路径是改成上下双侧分流(奇偶道走节点带上方),本版不做。 296 + * ① 与节点重叠 —— 回退边一律走**节点带右侧**的空白区,起终点都取节点右边中点,
  297 + * 三次贝塞尔两个控制点都压在同一条泳道横坐标上 → 曲线永远不穿过任何节点矩形;
  298 + * ② 与其它回退边重叠 —— 按 y 区间做贪心分道(区间重叠的边必然分到不同泳道),
  299 + * 泳道横向拉开 LANE_H,画布宽度随泳道数增长。区间不重叠的边共用一道,不浪费宽度。
  300 + * 代价:泳道数 = 最大同时重叠的回退边数,多到十几条时画布会明显变宽;
  301 + * 升级路径是改成左右双侧分流(奇偶道走节点带左侧),本版不做。
299 */ 302 */
300 const backwardEdges = computed(() => { 303 const backwardEdges = computed(() => {
301 const raw = []; 304 const raw = [];
@@ -304,13 +307,13 @@ const backwardEdges = computed(() =&gt; { @@ -304,13 +307,13 @@ const backwardEdges = computed(() =&gt; {
304 const a = posById.value.get(String(e.from)); 307 const a = posById.value.get(String(e.from));
305 const b = posById.value.get(String(e.to)); 308 const b = posById.value.get(String(e.to));
306 if (!a || !b) return; 309 if (!a || !b) return;
307 - const sx = a.x + NW / 2, sy = a.y + NH;  
308 - const tx = b.x + NW / 2, ty = b.y + NH; 310 + const sx = a.x + NW, sy = a.y + NH / 2;
  311 + const tx = b.x + NW, ty = b.y + NH / 2;
309 raw.push({ key: e.id || `${e.from}-${e.to}-${i}`, edgeId: e.id, sx, sy, tx, ty, 312 raw.push({ key: e.id || `${e.from}-${e.to}-${i}`, edgeId: e.id, sx, sy, tx, ty,
310 - lo: Math.min(sx, tx), hi: Math.max(sx, tx) }); 313 + lo: Math.min(sy, ty), hi: Math.max(sy, ty) });
311 }); 314 });
312 315
313 - // 贪心分道:按区间左端排序,塞进第一条不与已占区间重叠的泳道 316 + // 贪心分道:按区间上端排序,塞进第一条不与已占区间重叠的泳道
314 const lanes = []; 317 const lanes = [];
315 raw.sort((p, q) => p.lo - q.lo); 318 raw.sort((p, q) => p.lo - q.lo);
316 for (const e of raw) { 319 for (const e of raw) {
@@ -321,13 +324,13 @@ const backwardEdges = computed(() =&gt; { @@ -321,13 +324,13 @@ const backwardEdges = computed(() =&gt; {
321 } 324 }
322 325
323 return raw.map((e) => { 326 return raw.map((e) => {
324 - const laneY = nodeBandH.value + PAD + (e.lane + 1) * LANE_H; 327 + const laneX = nodeBandW.value + PAD + (e.lane + 1) * LANE_H;
325 return { 328 return {
326 ...e, 329 ...e,
327 - d: `M${e.sx},${e.sy} C${e.sx},${laneY} ${e.tx},${laneY} ${e.tx},${e.ty}`,  
328 - // 三次贝塞尔 t=0.5 处的坐标(两个控制点同 y,纵向化简为 (sy+ty+6*laneY)/8)  
329 - mx: (e.sx + e.tx) / 2,  
330 - my: (e.sy + e.ty + 6 * laneY) / 8, 330 + d: `M${e.sx},${e.sy} C${laneX},${e.sy} ${laneX},${e.ty} ${e.tx},${e.ty}`,
  331 + // 三次贝塞尔 t=0.5 处的坐标(两个控制点同 x,横向化简为 (sx+tx+6*laneX)/8)
  332 + mx: (e.sx + e.tx + 6 * laneX) / 8,
  333 + my: (e.sy + e.ty) / 2,
331 loopCount: latestLoopCount(e.edgeId), 334 loopCount: latestLoopCount(e.edgeId),
332 }; 335 };
333 }); 336 });
@@ -350,21 +353,21 @@ const anomalyNodes = computed(() =&gt; laidOut.value.filter((n) =&gt; n.status === &#39;bl @@ -350,21 +353,21 @@ const anomalyNodes = computed(() =&gt; laidOut.value.filter((n) =&gt; n.status === &#39;bl
350 const anomalyBand = computed(() => { 353 const anomalyBand = computed(() => {
351 if (!backwardEdges.value.length) return null; 354 if (!backwardEdges.value.length) return null;
352 return { 355 return {
353 - x: PAD / 2,  
354 - y: nodeBandH.value + PAD / 2,  
355 - w: W.value - PAD,  
356 - h: laneCount.value * LANE_H + LANE_H / 2, 356 + x: nodeBandW.value + PAD / 2,
  357 + y: PAD / 2,
  358 + w: laneCount.value * LANE_H + LANE_H / 2,
  359 + h: H.value - PAD,
357 }; 360 };
358 }); 361 });
359 362
360 -const nodeBandH = computed(() => { 363 +const nodeBandW = computed(() => {
361 const maxRow = Math.max(...waves.value.map((w) => w.length), 1); 364 const maxRow = Math.max(...waves.value.map((w) => w.length), 1);
362 - return PAD + maxRow * (NH + GAP_Y) - GAP_Y; 365 + return PAD + maxRow * (NW + GAP_X) - GAP_X;
363 }); 366 });
364 367
365 -const W = computed(() =>  
366 - Math.max(320, PAD * 2 + Math.max(waves.value.length, 1) * (NW + GAP_X) - GAP_X));  
367 -const H = computed(() => nodeBandH.value + PAD + laneCount.value * LANE_H + (laneCount.value ? LANE_H : 0)); 368 +const W = computed(() => nodeBandW.value + PAD + laneCount.value * LANE_H + (laneCount.value ? LANE_H : 0));
  369 +const H = computed(() =>
  370 + Math.max(320, PAD * 2 + Math.max(waves.value.length, 1) * (NH + GAP_Y) - GAP_Y));
368 371
369 function trim(s) { 372 function trim(s) {
370 const str = String(s ?? ''); 373 const str = String(s ?? '');
web/src/components/graph-console/GraphRunPane.vue
@@ -28,6 +28,7 @@ @@ -28,6 +28,7 @@
28 <button class="btn" :disabled="busy || !can('pause')" @click="act('pause')">⏸ 暂停</button> 28 <button class="btn" :disabled="busy || !can('pause')" @click="act('pause')">⏸ 暂停</button>
29 <button class="btn" :disabled="busy || !can('stop')" @click="act('stop')">⏹ 停止</button> 29 <button class="btn" :disabled="busy || !can('stop')" @click="act('stop')">⏹ 停止</button>
30 <button class="btn retry" :disabled="busy || !canRetry" @click="onRetry">🔁 重试</button> 30 <button class="btn retry" :disabled="busy || !canRetry" @click="onRetry">🔁 重试</button>
  31 + <button class="btn budget" :disabled="busy || !canExtendBudget" @click="openBudgetModal">💰 追加预算</button>
31 <span v-if="nextNodes.length === 1" class="next-hint">下一步将执行 <code>{{ nextNode.name || nextNode.defNodeId }}</code></span> 32 <span v-if="nextNodes.length === 1" class="next-hint">下一步将执行 <code>{{ nextNode.name || nextNode.defNodeId }}</code></span>
32 <span v-else-if="nextNodes.length > 1" class="next-hint">自动模式下一批最多并发 <code>{{ nextNodes.length }}</code> 个: 33 <span v-else-if="nextNodes.length > 1" class="next-hint">自动模式下一批最多并发 <code>{{ nextNodes.length }}</code> 个:
33 <code v-for="n in nextNodes" :key="n.defNodeId">{{ n.name || n.defNodeId }}</code></span> 34 <code v-for="n in nextNodes" :key="n.defNodeId">{{ n.name || n.defNodeId }}</code></span>
@@ -67,6 +68,35 @@ @@ -67,6 +68,35 @@
67 </div> 68 </div>
68 </div> 69 </div>
69 </div> 70 </div>
  71 +
  72 + <!-- 追加预算:撞顶(尤其是墙钟)之前只能开发者工具手搓 fetch 调 POST /:id/budget,
  73 + 界面上没有入口。三维各自是**增量**(叠加在当前上限上,不是替换),
  74 + 留空的维度不追加——后端 extendBudget 本来就是这个语义(§四.C)。 -->
  75 + <div v-if="showBudgetModal" class="modal-back" @click.self="showBudgetModal = false">
  76 + <div class="modal">
  77 + <h3>追加预算</h3>
  78 + <p class="lead">这条 run 已经 blocked。三个维度都是往当前上限上加,留空 = 不追加那一维。</p>
  79 + <ul class="est" v-if="budget">
  80 + <li>当前剩余:<b>{{ remain.calls }}</b> 次调用 · <b>{{ fmtNum(remain.tokens) }}</b> tokens</li>
  81 + </ul>
  82 + <label class="budget-field">墙钟时长(小时)
  83 + <input type="number" min="0" step="1" v-model.number="budgetForm.hours" placeholder="例如 8" />
  84 + </label>
  85 + <label class="budget-field">调用次数
  86 + <input type="number" min="0" step="1" v-model.number="budgetForm.calls" placeholder="留空不追加" />
  87 + </label>
  88 + <label class="budget-field">tokens
  89 + <input type="number" min="0" step="1" v-model.number="budgetForm.tokens" placeholder="留空不追加" />
  90 + </label>
  91 + <label class="budget-resume">
  92 + <input type="checkbox" v-model="budgetForm.resume" /> 追加后立即启动(沿用当前的 手动/自动 选择)
  93 + </label>
  94 + <div class="modal-btns">
  95 + <button class="btn" @click="showBudgetModal = false">取消</button>
  96 + <button class="btn go" :disabled="!budgetHasAnyDelta" @click="submitBudget">追加</button>
  97 + </div>
  98 + </div>
  99 + </div>
70 </div> 100 </div>
71 </template> 101 </template>
72 102
@@ -116,6 +146,8 @@ const err = ref(&#39;&#39;); @@ -116,6 +146,8 @@ const err = ref(&#39;&#39;);
116 const notice = ref(''); 146 const notice = ref('');
117 const mode = ref('manual'); 147 const mode = ref('manual');
118 const confirmAuto = ref(false); 148 const confirmAuto = ref(false);
  149 +const showBudgetModal = ref(false);
  150 +const budgetForm = ref({ hours: 8, calls: '', tokens: '', resume: true });
119 const sseStatus = ref('idle'); 151 const sseStatus = ref('idle');
120 152
121 let es = null; 153 let es = null;
@@ -138,6 +170,14 @@ const nextNodes = computed(() =&gt; { @@ -138,6 +170,14 @@ const nextNodes = computed(() =&gt; {
138 const nextNode = computed(() => nextNodes.value[0] || null); // 手动模式「下一步」提示仍用单数 170 const nextNode = computed(() => nextNodes.value[0] || null); // 手动模式「下一步」提示仍用单数
139 const selectedNode = computed(() => nodes.value.find((n) => String(n.defNodeId) === selectedDefId.value) || null); 171 const selectedNode = computed(() => nodes.value.find((n) => String(n.defNodeId) === selectedDefId.value) || null);
140 const canRetry = computed(() => selectedNode.value && ['fail', 'blocked'].includes(selectedNode.value.status)); 172 const canRetry = computed(() => selectedNode.value && ['fail', 'blocked'].includes(selectedNode.value.status));
  173 +// 与后端 ensureStatus 同一张表(§四.C):/budget 只在 run_status='blocked' 时可用;
  174 +// budget 为 null 说明这一层不是挂预算的 root run(扣减与熔断只在 root run 上生效),
  175 +// 追加了也没有意义,按钮直接不亮
  176 +const canExtendBudget = computed(() => !!budget.value && run.value?.run_status === 'blocked');
  177 +const budgetHasAnyDelta = computed(() => {
  178 + const f = budgetForm.value;
  179 + return [f.hours, f.calls, f.tokens].some((v) => Number(v) > 0);
  180 +});
141 181
142 const remain = computed(() => { 182 const remain = computed(() => {
143 const b = budget.value || {}; 183 const b = budget.value || {};
@@ -277,6 +317,21 @@ function onStart() { @@ -277,6 +317,21 @@ function onStart() {
277 } 317 }
278 function onRetry() { if (canRetry.value) act('retry'); } 318 function onRetry() { if (canRetry.value) act('retry'); }
279 319
  320 +function openBudgetModal() {
  321 + budgetForm.value = { hours: 8, calls: '', tokens: '', resume: true };
  322 + showBudgetModal.value = true;
  323 +}
  324 +function submitBudget() {
  325 + const f = budgetForm.value;
  326 + const patch = {};
  327 + if (Number(f.hours) > 0) patch.wallclock_ms = Number(f.hours) * 3600 * 1000;
  328 + if (Number(f.calls) > 0) patch.total_agent_calls = Number(f.calls);
  329 + if (Number(f.tokens) > 0) patch.total_tokens = Number(f.tokens);
  330 + patch.resume = !!f.resume;
  331 + showBudgetModal.value = false;
  332 + act('budget', patch);
  333 +}
  334 +
280 function onSelect(node) { 335 function onSelect(node) {
281 selectedDefId.value = String(node.defNodeId); 336 selectedDefId.value = String(node.defNodeId);
282 emit('select', node); 337 emit('select', node);
@@ -374,6 +429,7 @@ function fmtNum(n) { return (n || 0).toLocaleString(); } @@ -374,6 +429,7 @@ function fmtNum(n) { return (n || 0).toLocaleString(); }
374 .btn:disabled { opacity:.4; cursor:not-allowed; } 429 .btn:disabled { opacity:.4; cursor:not-allowed; }
375 .btn.go { background:#1a7f37; border-color:#1a7f37; color:#fff; } 430 .btn.go { background:#1a7f37; border-color:#1a7f37; color:#fff; }
376 .btn.retry { border-color:#c96a00; color:#c96a00; } 431 .btn.retry { border-color:#c96a00; color:#c96a00; }
  432 +.btn.budget { border-color:#8a5cd6; color:#8a5cd6; }
377 .next-hint { font-size:11px; color:#666; } 433 .next-hint { font-size:11px; color:#666; }
378 .next-hint code { background:#eef4ff; padding:1px 5px; border-radius:3px; } 434 .next-hint code { background:#eef4ff; padding:1px 5px; border-radius:3px; }
379 .notice { color: var(--text-secondary); background: var(--bg-overlay); border: 1px dashed var(--border-primary); 435 .notice { color: var(--text-secondary); background: var(--bg-overlay); border: 1px dashed var(--border-primary);
@@ -389,4 +445,7 @@ function fmtNum(n) { return (n || 0).toLocaleString(); } @@ -389,4 +445,7 @@ function fmtNum(n) { return (n || 0).toLocaleString(); }
389 .est { font-size:12.5px; color:#444; padding-left:18px; margin:0 0 16px; } 445 .est { font-size:12.5px; color:#444; padding-left:18px; margin:0 0 16px; }
390 .est li { margin-bottom:4px; } 446 .est li { margin-bottom:4px; }
391 .modal-btns { display:flex; justify-content:flex-end; gap:8px; } 447 .modal-btns { display:flex; justify-content:flex-end; gap:8px; }
  448 +.budget-field { display:flex; align-items:center; justify-content:space-between; gap:10px; font-size:12.5px; color:#444; margin-bottom:8px; }
  449 +.budget-field input { width:140px; font-size:12.5px; padding:3px 6px; border:1px solid #ddd; border-radius:4px; }
  450 +.budget-resume { display:flex; align-items:center; gap:6px; font-size:12.5px; color:#444; margin:4px 0 14px; }
392 </style> 451 </style>
web/src/components/task-board/TaskDetailModal.vue 0 → 100644
  1 +<!--
  2 + 任务详情/新建弹窗 —— TaskTable.vue 专用。`task` 为 null 时是新建模式。
  3 +
  4 + 增删改查落地方式:
  5 + - 读:`task` prop 本身就是 taskView() 的完整形状,摊平自 TaskBoard 已经拉好的 goals 树,
  6 + 不再单独请求(后端目前也没有单任务 GET 端点)。
  7 + - 改:PATCH /:cid/tasks/:taskId(title/priority 随时可改;description/mode 在 queuedAt
  8 + 非空后会被服务端 409 拒绝,这里提前禁用输入框并给出同样的理由)。
  9 + - 增:POST /:cid/tasks,固定带 draft:true —— 先建草稿,不自动派发。
  10 + - 删:DELETE /:cid/tasks/:taskId(归档,运行中/排队中会 409,错误原样显示)。
  11 + - 状态流转(下达/开始/取消/重跑/强制放行/停止):`taskActions()` 给可执行操作,
  12 + 这里的 runAction() 是 GoalTaskPane.vue 的 runTaskAction() 的等价物,端点表照抄不重新设计。
  13 +-->
  14 +<template>
  15 + <div class="overlay" @click.self="$emit('close')">
  16 + <div class="modal">
  17 + <header class="head">
  18 + <b>{{ isCreate ? '新建任务' : '任务详情' }}</b>
  19 + <button class="x" @click="$emit('close')">×</button>
  20 + </header>
  21 +
  22 + <div class="body">
  23 + <template v-if="!isCreate">
  24 + <div class="row">
  25 + <span class="dot" :style="dotStyle(taskStyle(task.status))"></span>
  26 + {{ taskStyle(task.status).label }}
  27 + <span v-if="queueBadge(task)" class="qb" :title="queueBadge(task).title">{{ queueBadge(task).text }}</span>
  28 + <span class="tid">{{ task.taskId }}</span>
  29 + </div>
  30 + <p v-if="task.status === 'blocked'" class="blocked-hint">{{ blockedLabel(task) || '挂起,原因未知' }}</p>
  31 + </template>
  32 +
  33 + <label class="field">
  34 + <span class="lbl">标题</span>
  35 + <input v-model="form.title" class="in" :placeholder="isCreate ? '(留空则用描述自动生成)' : ''" />
  36 + </label>
  37 +
  38 + <label class="field">
  39 + <span class="lbl">任务描述{{ frozen ? '(已下达,不可改)' : '' }}</span>
  40 + <textarea v-model="form.description" class="in ta" rows="4" :disabled="frozen"
  41 + placeholder="要交给 agent 执行的完整描述"></textarea>
  42 + </label>
  43 +
  44 + <div class="grid2">
  45 + <label class="field">
  46 + <span class="lbl">优先级</span>
  47 + <input v-model="form.priority" class="in" placeholder="P1" />
  48 + </label>
  49 + <label class="field">
  50 + <span class="lbl">执行模式{{ frozen ? '(已下达,不可改)' : '' }}</span>
  51 + <select v-model="form.mode" class="in" :disabled="frozen">
  52 + <option value="manual">manual(需人点「立即开始」)</option>
  53 + <option value="auto">auto(有空位自动开跑)</option>
  54 + </select>
  55 + </label>
  56 + </div>
  57 +
  58 + <label v-if="isCreate" class="field">
  59 + <span class="lbl">所属目标</span>
  60 + <select v-model="form.goalId" class="in">
  61 + <option value="">(自动新建一个同名目标)</option>
  62 + <option v-for="g in goals" :key="g.goalId" :value="g.goalId">{{ g.title || g.goalId }}</option>
  63 + </select>
  64 + </label>
  65 +
  66 + <template v-if="!isCreate">
  67 + <div class="grid2">
  68 + <div class="field ro"><span class="lbl">所属目标</span><span>{{ goalTitle }}</span></div>
  69 + <div class="field ro"><span class="lbl">负责 Agent</span><span>{{ assigneeLabel }}</span></div>
  70 + </div>
  71 + <div class="grid2">
  72 + <div class="field ro"><span class="lbl">创建时间</span><span>{{ fmt(task.createdAt) }}</span></div>
  73 + <div class="field ro"><span class="lbl">下达时间</span><span>{{ fmt(task.queuedAt) }}</span></div>
  74 + </div>
  75 + <div class="grid2">
  76 + <div class="field ro"><span class="lbl">开始时间</span><span>{{ fmt(task.startedAt) }}</span></div>
  77 + <div class="field ro"><span class="lbl">结束时间</span><span>{{ fmt(task.finishedAt) }}</span></div>
  78 + </div>
  79 + <div v-if="task.dependsOn && task.dependsOn.length" class="field ro">
  80 + <span class="lbl">依赖任务</span>
  81 + <span>{{ task.dependsOn.join('、') }}</span>
  82 + </div>
  83 + <div v-if="task.budget" class="field ro">
  84 + <span class="lbl">预算</span>
  85 + <span class="mono">{{ JSON.stringify(task.budget) }}</span>
  86 + </div>
  87 + <router-link class="graph-link" :to="`/${companyId}/graph-console?goal=${task.goalId}&task=${task.taskId}`">
  88 + 🕸️ 查看执行图
  89 + </router-link>
  90 + </template>
  91 +
  92 + <p v-if="error" class="err">{{ error }}</p>
  93 + </div>
  94 +
  95 + <footer class="foot">
  96 + <div class="actions" v-if="!isCreate">
  97 + <button v-if="task.status === 'draft'" class="btn" :disabled="busy" @click="enqueue">下达</button>
  98 + <button
  99 + v-for="act in actions" :key="act.key"
  100 + class="btn" :class="{ danger: act.danger }"
  101 + :disabled="act.disabled || busy" :title="act.title"
  102 + @click="runAction(act)"
  103 + >{{ act.label }}</button>
  104 + </div>
  105 + <div class="spacer"></div>
  106 + <button class="btn ghost" @click="$emit('close')">取消</button>
  107 + <button class="btn primary" :disabled="busy || !form.description.trim()" @click="save">
  108 + {{ isCreate ? '创建' : '保存' }}
  109 + </button>
  110 + </footer>
  111 + </div>
  112 + </div>
  113 +</template>
  114 +
  115 +<script setup>
  116 +import { ref, reactive, computed } from 'vue';
  117 +import {
  118 + taskStyle, queueBadge, blockedLabel, taskActions, parseBlockedReason,
  119 +} from '../graph-console/goal-task-model.js';
  120 +
  121 +const props = defineProps({
  122 + companyId: { type: String, required: true },
  123 + task: { type: Object, default: null },
  124 + goals: { type: Array, default: () => [] },
  125 + agents: { type: Array, default: () => [] },
  126 +});
  127 +const emit = defineEmits(['close', 'saved']);
  128 +
  129 +const isCreate = computed(() => !props.task);
  130 +/** 已经下达过(queuedAt 非空):description/mode 服务端会 409,界面提前禁用 */
  131 +const frozen = computed(() => !isCreate.value && !!props.task.queuedAt);
  132 +
  133 +const form = reactive({
  134 + title: props.task?.title || '',
  135 + description: props.task?.description || '',
  136 + priority: props.task?.priority || 'P1',
  137 + mode: props.task?.mode || 'manual',
  138 + goalId: '',
  139 +});
  140 +
  141 +const goalTitle = computed(() => {
  142 + const t = props.task;
  143 + if (!t) return '';
  144 + const g = (props.goals || []).find((x) => x.goalId === t.goalId);
  145 + return (g && g.title) || t.goalId;
  146 +});
  147 +
  148 +const assigneeLabel = computed(() => {
  149 + const t = props.task;
  150 + if (!t || !t.assigneeAgentId) return '未分配';
  151 + if (t.assigneeAgentId === 'org-graph') return '组织图执行';
  152 + const a = (props.agents || []).find((x) => x.id === t.assigneeAgentId);
  153 + return (a && a.name) || t.assigneeAgentId;
  154 +});
  155 +
  156 +const actions = computed(() => (props.task ? taskActions(props.task) : []));
  157 +
  158 +const busy = ref(false);
  159 +const error = ref('');
  160 +
  161 +async function call(method, url, body) {
  162 + const r = await fetch(url, {
  163 + method,
  164 + headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
  165 + body: body === undefined ? undefined : JSON.stringify(body),
  166 + });
  167 + const d = await r.json().catch(() => ({}));
  168 + if (!r.ok) throw new Error([d.error || `HTTP ${r.status}`, d.hint].filter(Boolean).join(' · '));
  169 + return d;
  170 +}
  171 +
  172 +async function guarded(fn) {
  173 + if (busy.value) return;
  174 + busy.value = true;
  175 + error.value = '';
  176 + try {
  177 + await fn();
  178 + emit('saved');
  179 + } catch (e) {
  180 + error.value = e.message;
  181 + } finally {
  182 + busy.value = false;
  183 + }
  184 +}
  185 +
  186 +function save() {
  187 + const cid = props.companyId;
  188 + if (isCreate.value) {
  189 + return guarded(() => call('POST', `/api/company/${cid}/tasks`, {
  190 + task: form.description.trim(),
  191 + goal_id: form.goalId || undefined,
  192 + priority: form.priority || undefined,
  193 + mode: form.mode,
  194 + draft: true,
  195 + }));
  196 + }
  197 + const patch = { title: form.title, priority: form.priority };
  198 + if (!frozen.value) {
  199 + patch.description = form.description;
  200 + patch.mode = form.mode;
  201 + }
  202 + return guarded(() => call('PATCH', `/api/company/${cid}/tasks/${props.task.taskId}`, patch));
  203 +}
  204 +
  205 +function enqueue() {
  206 + return guarded(() => call('POST', `/api/company/${props.companyId}/tasks/${props.task.taskId}/enqueue`));
  207 +}
  208 +
  209 +/** taskActions() 给的 key → 具体端点,逐条抄自 GoalTaskPane.vue 的 runTaskAction */
  210 +function runAction(act) {
  211 + if (act.disabled || busy.value) return;
  212 + const cid = props.companyId;
  213 + const t = props.task;
  214 + return guarded(async () => {
  215 + if (act.key === 'stop') {
  216 + await call('POST', `/api/graph-runs/${t.runId}/stop`);
  217 + return;
  218 + }
  219 + if (act.key === 'release') {
  220 + const p = parseBlockedReason(t.blockedReason);
  221 + const deps = t.dependsOn || [];
  222 + const next = deps.filter((id) => id !== (p && p.upstreamId));
  223 + if (next.length === deps.length) {
  224 + throw new Error(`放行失败:blocked 原因指向 ${p && p.upstreamId},但这条任务的依赖里没有它(依赖刚被改过?刷新再看)`);
  225 + }
  226 + await call('PATCH', `/api/company/${cid}/tasks/${t.taskId}`, { depends_on: next });
  227 + return;
  228 + }
  229 + if (act.key === 'start') {
  230 + await call('POST', `/api/company/${cid}/tasks/${t.taskId}/start`);
  231 + return;
  232 + }
  233 + if (act.key === 'cancel') {
  234 + await call('POST', `/api/company/${cid}/tasks/${t.taskId}/cancel`);
  235 + return;
  236 + }
  237 + if (act.key === 'rerun') {
  238 + await call('POST', `/api/company/${cid}/tasks/${t.taskId}/rerun`);
  239 + return;
  240 + }
  241 + if (act.key === 'rerun-upstream') {
  242 + if (!act.upstreamId) throw new Error('这条任务的 blocked 原因里没有上游 id,重跑不了');
  243 + await call('POST', `/api/company/${cid}/tasks/${act.upstreamId}/rerun`);
  244 + return;
  245 + }
  246 + // archive —— 归档后这条 task 会从 goals 树里消失,父组件在 @saved 里发现找不到就会关掉弹窗
  247 + await call('DELETE', `/api/company/${cid}/tasks/${t.taskId}`);
  248 + });
  249 +}
  250 +
  251 +function dotStyle(s) {
  252 + return s.hollow ? { background: 'transparent', borderColor: s.color } : { background: s.color, borderColor: s.color };
  253 +}
  254 +function fmt(iso) {
  255 + if (!iso) return '—';
  256 + try { return new Date(iso).toLocaleString('zh-CN'); } catch { return iso; }
  257 +}
  258 +</script>
  259 +
  260 +<style scoped>
  261 +.overlay { position: fixed; inset: 0; background: rgba(0,0,0,.35); display: flex; align-items: center; justify-content: center; z-index: 100; }
  262 +.modal { width: min(560px, 92vw); max-height: 88vh; display: flex; flex-direction: column; background: #fff; border-radius: 10px; box-shadow: 0 10px 40px rgba(0,0,0,.2); }
  263 +
  264 +.head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid #eee; }
  265 +.head b { font-size: 14px; }
  266 +.x { border: none; background: none; font-size: 18px; cursor: pointer; color: #999; line-height: 1; }
  267 +.x:hover { color: #333; }
  268 +
  269 +.body { padding: 14px 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; font-size: 12px; }
  270 +.row { display: flex; align-items: center; gap: 6px; color: #444; }
  271 +.tid { margin-left: auto; color: #bbb; font-size: 11px; font-family: monospace; }
  272 +.blocked-hint { margin: 0; color: #d33; font-size: 11px; }
  273 +.qb { color: #7aa7dd; font-size: 11px; }
  274 +
  275 +.field { display: flex; flex-direction: column; gap: 4px; }
  276 +.field.ro span:last-child { color: #333; }
  277 +.lbl { font-size: 11px; color: #888; }
  278 +.in { font: inherit; font-size: 12px; padding: 6px 9px; border: 1px solid #ddd; border-radius: 6px; background: #fff; color: #1a1a1a; }
  279 +.in:disabled { background: #f7f7f9; color: #999; }
  280 +.ta { resize: vertical; font-family: inherit; }
  281 +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
  282 +.mono { font-family: monospace; font-size: 11px; color: #555; }
  283 +.graph-link { align-self: flex-start; font-size: 11px; color: #2b6cb0; text-decoration: none; }
  284 +.graph-link:hover { text-decoration: underline; }
  285 +
  286 +.err { margin: 0; color: #dc2626; font-size: 11px; }
  287 +
  288 +.foot { display: flex; align-items: center; gap: 6px; padding: 12px 16px; border-top: 1px solid #eee; flex-wrap: wrap; }
  289 +.actions { display: flex; gap: 6px; flex-wrap: wrap; }
  290 +.spacer { flex: 1; }
  291 +.btn { border: 1px solid #ddd; background: #fff; border-radius: 6px; font-size: 12px; padding: 6px 12px; cursor: pointer; }
  292 +.btn:disabled { opacity: .5; cursor: not-allowed; }
  293 +.btn.danger { color: #b91c1c; }
  294 +.btn.primary { background: #2b6cb0; border-color: transparent; color: #fff; font-weight: 600; }
  295 +.btn.ghost { background: transparent; }
  296 +</style>
web/src/components/task-board/TaskTable.vue 0 → 100644
  1 +<!--
  2 + 任务看板的表格视图 —— TaskBoard.vue 专用,不是 GoalTaskPane 的替代品(那个还在
  3 + GraphConsole 左栏服役,两边分开维护)。数据摊平自 goals[].tasks[],不额外请求。
  4 +-->
  5 +<template>
  6 + <div class="tt">
  7 + <div class="toolbar">
  8 + <input v-model="search" class="in search" placeholder="搜索任务标题 / 描述…" />
  9 + <select v-model="statusFilter" class="in sel">
  10 + <option value="">全部状态</option>
  11 + <option v-for="s in STATUS_OPTIONS" :key="s" :value="s">{{ taskStyle(s).label }}</option>
  12 + </select>
  13 + <select v-model="goalFilter" class="in sel">
  14 + <option value="">全部目标</option>
  15 + <option v-for="g in goals" :key="g.goalId" :value="g.goalId">{{ g.title || g.goalId }}</option>
  16 + </select>
  17 + <select v-model="assigneeFilter" class="in sel">
  18 + <option value="">全部负责人</option>
  19 + <option v-for="o in assigneeOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
  20 + </select>
  21 + <span class="count">{{ sorted.length }} / {{ flatTasks.length }} 条</span>
  22 + <button class="btn primary" @click="$emit('create')">+ 新建任务</button>
  23 + </div>
  24 +
  25 + <div class="scroll">
  26 + <table>
  27 + <thead>
  28 + <tr>
  29 + <th class="c-status" @click="toggleSort('status')">状态{{ sortArrow('status') }}</th>
  30 + <th class="c-desc">任务描述</th>
  31 + <th class="c-goal">所属目标</th>
  32 + <th class="c-assignee">负责 Agent</th>
  33 + <th class="c-priority" @click="toggleSort('priority')">优先级{{ sortArrow('priority') }}</th>
  34 + <th class="c-time" @click="toggleSort('createdAt')">创建时间{{ sortArrow('createdAt') }}</th>
  35 + <th class="c-time" @click="toggleSort('startedAt')">开始时间{{ sortArrow('startedAt') }}</th>
  36 + <th class="c-time" @click="toggleSort('finishedAt')">结束时间{{ sortArrow('finishedAt') }}</th>
  37 + </tr>
  38 + </thead>
  39 + <tbody>
  40 + <tr v-if="loading && !flatTasks.length"><td colspan="8" class="empty">加载中…</td></tr>
  41 + <tr v-else-if="!sorted.length"><td colspan="8" class="empty">没有匹配的任务</td></tr>
  42 + <tr
  43 + v-for="t in sorted" :key="t.taskId"
  44 + class="row" :class="{ blocked: t.status === 'blocked' }"
  45 + @click="$emit('select', t)"
  46 + >
  47 + <td class="c-status">
  48 + <span class="dot" :class="{ pulse: taskStyle(t.status).pulse }" :style="dotStyle(taskStyle(t.status))"></span>
  49 + {{ taskStyle(t.status).label }}
  50 + <span v-if="queueBadge(t)" class="qb" :title="queueBadge(t).title">{{ queueBadge(t).text }}</span>
  51 + </td>
  52 + <td class="c-desc">
  53 + <div class="title">{{ t.title || '(无标题)' }}</div>
  54 + <div v-if="t.description" class="desc" :title="t.description">{{ t.description }}</div>
  55 + <div v-if="t.status === 'blocked'" class="blocked-line">{{ blockedLabel(t, siblingsOf(t)) || '挂起' }}</div>
  56 + </td>
  57 + <td class="c-goal">{{ t.goalTitle }}</td>
  58 + <td class="c-assignee">{{ assigneeLabel(t) }}</td>
  59 + <td class="c-priority">{{ t.priority || '—' }}</td>
  60 + <td class="c-time">{{ fmt(t.createdAt) }}</td>
  61 + <td class="c-time">{{ fmt(t.startedAt) }}</td>
  62 + <td class="c-time">{{ fmt(t.finishedAt) }}</td>
  63 + </tr>
  64 + </tbody>
  65 + </table>
  66 + </div>
  67 + </div>
  68 +</template>
  69 +
  70 +<script setup>
  71 +import { ref, computed } from 'vue';
  72 +import { taskStyle, queueBadge, blockedLabel } from '../graph-console/goal-task-model.js';
  73 +
  74 +const props = defineProps({
  75 + companyId: { type: String, required: true },
  76 + goals: { type: Array, default: () => [] },
  77 + agents: { type: Array, default: () => [] },
  78 + loading: { type: Boolean, default: false },
  79 +});
  80 +defineEmits(['select', 'create']);
  81 +
  82 +const STATUS_OPTIONS = ['draft', 'queued', 'running', 'completed', 'failed', 'blocked', 'cancelled'];
  83 +
  84 +const flatTasks = computed(() => {
  85 + const out = [];
  86 + for (const g of props.goals || []) {
  87 + for (const t of g.tasks || []) out.push({ ...t, goalTitle: g.title || g.goalId });
  88 + }
  89 + return out;
  90 +});
  91 +
  92 +/** 同 goal 下的兄弟任务,blockedLabel 解析「上游 #N 未完成」要用 */
  93 +function siblingsOf(t) {
  94 + const g = (props.goals || []).find((x) => x.goalId === t.goalId);
  95 + return (g && g.tasks) || [];
  96 +}
  97 +
  98 +function dotStyle(s) {
  99 + return s.hollow ? { background: 'transparent', borderColor: s.color } : { background: s.color, borderColor: s.color };
  100 +}
  101 +
  102 +const agentNameById = computed(() => {
  103 + const m = new Map();
  104 + for (const a of props.agents || []) m.set(a.id, a.name || a.id);
  105 + return m;
  106 +});
  107 +
  108 +/** assignee_agent_id 是 null(还没派)/ 'org-graph'(被组织图接管,没有真实派单对象)/ 真实 role_name 三种 */
  109 +function assigneeLabel(t) {
  110 + if (!t.assigneeAgentId) return '未分配';
  111 + if (t.assigneeAgentId === 'org-graph') return '组织图执行';
  112 + return agentNameById.value.get(t.assigneeAgentId) || t.assigneeAgentId;
  113 +}
  114 +
  115 +const assigneeOptions = computed(() => {
  116 + const seen = new Map(); // value -> label
  117 + for (const t of flatTasks.value) {
  118 + const v = t.assigneeAgentId || '__none__';
  119 + if (!seen.has(v)) seen.set(v, assigneeLabel(t));
  120 + }
  121 + return [...seen.entries()].map(([value, label]) => ({ value, label }));
  122 +});
  123 +
  124 +const search = ref('');
  125 +const statusFilter = ref('');
  126 +const goalFilter = ref('');
  127 +const assigneeFilter = ref('');
  128 +
  129 +const filtered = computed(() => {
  130 + let list = flatTasks.value;
  131 + const q = search.value.trim().toLowerCase();
  132 + if (q) list = list.filter((t) => (t.title || '').toLowerCase().includes(q) || (t.description || '').toLowerCase().includes(q));
  133 + if (statusFilter.value) list = list.filter((t) => t.status === statusFilter.value);
  134 + if (goalFilter.value) list = list.filter((t) => t.goalId === goalFilter.value);
  135 + if (assigneeFilter.value) list = list.filter((t) => (t.assigneeAgentId || '__none__') === assigneeFilter.value);
  136 + return list;
  137 +});
  138 +
  139 +const sortKey = ref('createdAt');
  140 +const sortDir = ref('desc');
  141 +
  142 +function toggleSort(key) {
  143 + if (sortKey.value === key) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
  144 + else { sortKey.value = key; sortDir.value = 'asc'; }
  145 +}
  146 +function sortArrow(key) {
  147 + if (sortKey.value !== key) return '';
  148 + return sortDir.value === 'asc' ? ' ▲' : ' ▼';
  149 +}
  150 +
  151 +/** 状态按中文标签排序(用户比较的是眼睛看到的字,不是内部枚举值),其余字段原值比较 */
  152 +function sortValue(t, key) {
  153 + if (key === 'status') return taskStyle(t.status).label;
  154 + return t[key] == null ? '' : t[key];
  155 +}
  156 +
  157 +const sorted = computed(() => {
  158 + const list = filtered.value.slice();
  159 + const dir = sortDir.value === 'asc' ? 1 : -1;
  160 + list.sort((a, b) => {
  161 + const av = sortValue(a, sortKey.value);
  162 + const bv = sortValue(b, sortKey.value);
  163 + if (av < bv) return -1 * dir;
  164 + if (av > bv) return 1 * dir;
  165 + return 0;
  166 + });
  167 + return list;
  168 +});
  169 +
  170 +function fmt(iso) {
  171 + if (!iso) return '—';
  172 + try { return new Date(iso).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); }
  173 + catch { return iso; }
  174 +}
  175 +</script>
  176 +
  177 +<style scoped>
  178 +.tt { display: flex; flex-direction: column; gap: 10px; height: 100%; min-height: 0; }
  179 +
  180 +.toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
  181 +.in { font: inherit; font-size: 12px; padding: 6px 9px; border: 1px solid #ddd; border-radius: 6px; background: #fff; color: #1a1a1a; }
  182 +.search { flex: 1 1 220px; min-width: 160px; }
  183 +.sel { flex: 0 0 auto; }
  184 +.count { font-size: 11px; color: #999; white-space: nowrap; }
  185 +.btn { border: 1px solid #ddd; background: #fff; border-radius: 6px; font-size: 12px; padding: 6px 12px; cursor: pointer; }
  186 +.btn.primary { background: #2b6cb0; border-color: transparent; color: #fff; font-weight: 600; }
  187 +
  188 +.scroll { flex: 1 1 auto; overflow: auto; border: 1px solid #eee; border-radius: 8px; }
  189 +table { width: 100%; border-collapse: collapse; font-size: 12px; }
  190 +thead th {
  191 + position: sticky; top: 0; background: #fafafa; text-align: left; padding: 8px 10px;
  192 + border-bottom: 1px solid #eee; font-weight: 600; color: #555; white-space: nowrap; cursor: pointer; user-select: none;
  193 +}
  194 +thead th:hover { color: #2b6cb0; }
  195 +.c-status:not(th), td.c-status { cursor: default; }
  196 +tbody td { padding: 8px 10px; border-bottom: 1px solid #f4f4f6; vertical-align: top; }
  197 +.row { cursor: pointer; }
  198 +.row:hover { background: #f7f9fc; }
  199 +.row.blocked { background: #fffaf9; box-shadow: inset 2px 0 0 #d33; }
  200 +.empty { text-align: center; color: #999; padding: 24px 0; }
  201 +
  202 +.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid transparent; margin-right: 5px; vertical-align: middle; }
  203 +.dot.pulse { animation: pulse 1.6s ease-in-out infinite; }
  204 +@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
  205 +.qb { margin-left: 6px; font-size: 10px; color: #7aa7dd; }
  206 +
  207 +.c-desc { min-width: 220px; max-width: 360px; }
  208 +.title { font-weight: 600; color: #1a1a1a; }
  209 +.desc { margin-top: 2px; color: #888; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  210 +.blocked-line { margin-top: 2px; color: #d33; font-size: 11px; }
  211 +.c-goal, .c-assignee { color: #555; white-space: nowrap; }
  212 +.c-priority { white-space: nowrap; }
  213 +.c-time { white-space: nowrap; color: #666; }
  214 +
  215 +@media (max-width: 900px) {
  216 + .c-goal, .c-priority { display: none; }
  217 +}
  218 +</style>
web/src/views/CompanyDashboard.vue
@@ -123,6 +123,44 @@ @@ -123,6 +123,44 @@
123 </div> 123 </div>
124 </div> 124 </div>
125 125
  126 + <!-- Target Dir Card(42 号文档 Phase 3:公司级目标代码目录配置) -->
  127 + <div class="overview-card">
  128 + <div class="overview-card-header">
  129 + <h3>📁 目标代码目录</h3>
  130 + </div>
  131 + <p style="font-size:13px; color:var(--text-secondary); margin:0 0 12px;">
  132 + 配了之后,这家公司所有任务的交付物代码都固定落在这个目录,不再逐任务分配临时沙盒
  133 + (类似 Claude Code 选工作目录)。留空则维持默认的沙盒模式。
  134 + </p>
  135 + <div class="overview-info-grid" style="grid-template-columns: 1fr;">
  136 + <div class="info-item">
  137 + <span class="info-label">目标目录(绝对路径)</span>
  138 + <input
  139 + v-model="targetDirForm.target_dir"
  140 + type="text"
  141 + placeholder="/Users/you/projects/my-app"
  142 + style="padding:6px 10px; border:1px solid var(--border-color); border-radius:6px; font-size:13px; width:100%; box-sizing:border-box;"
  143 + />
  144 + </div>
  145 + <div class="info-item">
  146 + <span class="info-label">模式</span>
  147 + <select v-model="targetDirForm.target_mode" style="padding:6px 10px; border:1px solid var(--border-color); border-radius:6px; font-size:13px;">
  148 + <option value="linked-existing">linked-existing(接管一个已存在的目录)</option>
  149 + <option value="linked-new">linked-new(新建/空目录)</option>
  150 + </select>
  151 + </div>
  152 + </div>
  153 + <div class="overview-actions">
  154 + <button class="btn-sm" @click="saveTargetDir" :disabled="isSavingTargetDir">
  155 + {{ isSavingTargetDir ? '⏳ 保存中...' : '💾 保存' }}
  156 + </button>
  157 + <span v-if="targetDirError" style="color:var(--status-failed); font-size:13px;">{{ targetDirError }}</span>
  158 + <span v-else-if="selectedCompany?.target_dir" style="color:var(--text-secondary); font-size:13px;">
  159 + 当前生效:{{ selectedCompany.target_dir }}({{ selectedCompany.target_mode }})
  160 + </span>
  161 + </div>
  162 + </div>
  163 +
126 <!-- Agent Status Table --> 164 <!-- Agent Status Table -->
127 <div class="overview-card agents-card"> 165 <div class="overview-card agents-card">
128 <div class="overview-card-header"> 166 <div class="overview-card-header">
@@ -739,7 +777,7 @@ @@ -739,7 +777,7 @@
739 </template> 777 </template>
740 778
741 <script setup> 779 <script setup>
742 -import { ref, computed, onMounted, watch, onUnmounted } from 'vue' 780 +import { ref, reactive, computed, onMounted, watch, onUnmounted } from 'vue'
743 import { useRouter, useRoute } from 'vue-router' 781 import { useRouter, useRoute } from 'vue-router'
744 import axios from 'axios' 782 import axios from 'axios'
745 import AppNav from '@/components/AppNav.vue' 783 import AppNav from '@/components/AppNav.vue'
@@ -756,6 +794,11 @@ const companyStatus = ref(null) @@ -756,6 +794,11 @@ const companyStatus = ref(null)
756 const isStarting = ref(false) 794 const isStarting = ref(false)
757 const isStopping = ref(false) 795 const isStopping = ref(false)
758 const selectedCompany = ref(null) 796 const selectedCompany = ref(null)
  797 +// 42 号文档 Phase 3:target_dir 配置表单本地状态,不直接双向绑到 selectedCompany——
  798 +// 保存失败时输入框不该跟着回滚成服务端的旧值,用户还得看着自己刚打的字重打一遍。
  799 +const targetDirForm = reactive({ target_dir: '', target_mode: 'linked-existing' })
  800 +const isSavingTargetDir = ref(false)
  801 +const targetDirError = ref('')
759 const companyAgents = ref([]) 802 const companyAgents = ref([])
760 const pipelines = ref([]) 803 const pipelines = ref([])
761 const pipelineRuns = ref([]) 804 const pipelineRuns = ref([])
@@ -972,6 +1015,14 @@ function goToTaskBoard() { @@ -972,6 +1015,14 @@ function goToTaskBoard() {
972 router.push(`/${selectedCompany.value.id}/task-board`) 1015 router.push(`/${selectedCompany.value.id}/task-board`)
973 } 1016 }
974 1017
  1018 +// 42 号文档 Phase 3:公司切换 / 首次加载时,把表单同步成服务端当前值——只在 id 变化时同步,
  1019 +// 不watch 整个 selectedCompany,否则用户正在改的输入框会被别的字段刷新(比如心跳状态轮询)打断。
  1020 +watch(() => selectedCompany.value?.id, () => {
  1021 + targetDirForm.target_dir = selectedCompany.value?.target_dir || ''
  1022 + targetDirForm.target_mode = selectedCompany.value?.target_mode || 'linked-existing'
  1023 + targetDirError.value = ''
  1024 +}, { immediate: true })
  1025 +
975 watch(() => route?.params, async (params) => { 1026 watch(() => route?.params, async (params) => {
976 if (!params) return 1027 if (!params) return
977 if (route.name === 'CompanyDetail' && params.id) { 1028 if (route.name === 'CompanyDetail' && params.id) {
@@ -1154,6 +1205,22 @@ async function stopCompany() { @@ -1154,6 +1205,22 @@ async function stopCompany() {
1154 isStopping.value = false 1205 isStopping.value = false
1155 } 1206 }
1156 1207
  1208 +async function saveTargetDir() {
  1209 + if (!selectedCompany.value) return
  1210 + isSavingTargetDir.value = true
  1211 + targetDirError.value = ''
  1212 + try {
  1213 + const { data } = await axios.put(`/api/company/${selectedCompany.value.id}`, {
  1214 + target_dir: targetDirForm.target_dir,
  1215 + target_mode: targetDirForm.target_mode,
  1216 + })
  1217 + selectedCompany.value = data.company
  1218 + } catch (e) {
  1219 + targetDirError.value = e.response?.data?.error || e.message
  1220 + }
  1221 + isSavingTargetDir.value = false
  1222 +}
  1223 +
1157 async function deleteCompany(companyId) { 1224 async function deleteCompany(companyId) {
1158 if (!confirm('确认要删除该公司吗?该操作仅会从列表中移除公司声明。')) return 1225 if (!confirm('确认要删除该公司吗?该操作仅会从列表中移除公司声明。')) return
1159 try { 1226 try {
web/src/views/TaskBoard.vue
1 <!-- 1 <!--
2 任务看板 —— goal/task 两级模型的独立页面(39 号文档 §二 P2)。 2 任务看板 —— goal/task 两级模型的独立页面(39 号文档 §二 P2)。
3 3
4 - 在此之前 goal/task 只能在 GraphConsole 左栏的窄侧栏里看,这里把同一份组件  
5 - (GoalTaskPane + goal-task-model.js)铺满整页,数据层原样不动,不重写。  
6 -  
7 - 点一条 task 不在本页画执行图(那是 GraphConsole 的活),而是跳过去 ——  
8 - `/:id/graph-console?goal=...&task=...` 是 GraphConsole 已经支持的选中态复原路径  
9 - (见该文件 onMounted 里 `route.query.goal` / `route.query.task` 那两行),不新开一条协议。 4 + 表格视图(TaskTable + TaskDetailModal),数据层沿用原来那份(GET /goals?withTasks=1,
  5 + 20s 轮询),只是不再走 GoalTaskPane 那套折叠列表 —— GoalTaskPane 还在 GraphConsole
  6 + 左栏服役,两边分开维护,改这里不会动到那边。
10 --> 7 -->
11 <template> 8 <template>
12 <div class="board"> 9 <div class="board">
@@ -20,43 +17,60 @@ @@ -20,43 +17,60 @@
20 </header> 17 </header>
21 18
22 <div class="body"> 19 <div class="body">
23 - <GoalTaskPane 20 + <TaskTable
24 :company-id="companyId" 21 :company-id="companyId"
25 :goals="goals" 22 :goals="goals"
  23 + :agents="agents"
26 :loading="goalsLoading" 24 :loading="goalsLoading"
27 - :selected-task-id="selectedTaskId"  
28 - :selected-goal-id="selectedGoalId"  
29 - :slots="slots"  
30 - @select="goToDetail"  
31 - @refresh="loadGoals" 25 + @select="openDetail"
  26 + @create="openCreate"
32 /> 27 />
33 </div> 28 </div>
  29 +
  30 + <TaskDetailModal
  31 + v-if="showModal"
  32 + :company-id="companyId"
  33 + :task="modalTask"
  34 + :goals="goals"
  35 + :agents="agents"
  36 + @close="closeModal"
  37 + @saved="onSaved"
  38 + />
34 </div> 39 </div>
35 </template> 40 </template>
36 41
37 <script setup> 42 <script setup>
38 import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; 43 import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
39 -import { useRoute, useRouter } from 'vue-router';  
40 -import GoalTaskPane from '@/components/graph-console/GoalTaskPane.vue'; 44 +import { useRoute } from 'vue-router';
  45 +import TaskTable from '@/components/task-board/TaskTable.vue';
  46 +import TaskDetailModal from '@/components/task-board/TaskDetailModal.vue';
41 47
42 const route = useRoute(); 48 const route = useRoute();
43 -const router = useRouter();  
44 const props = defineProps({ id: { type: String, default: '' } }); 49 const props = defineProps({ id: { type: String, default: '' } });
45 const companyId = computed(() => props.id || route.params.id); 50 const companyId = computed(() => props.id || route.params.id);
46 51
47 const company = ref(null); 52 const company = ref(null);
48 const goals = ref([]); 53 const goals = ref([]);
49 const goalsLoading = ref(false); 54 const goalsLoading = ref(false);
50 -const slots = ref(null);  
51 -const selectedGoalId = ref(''); 55 +const agents = ref([]);
  56 +
52 const selectedTaskId = ref(''); 57 const selectedTaskId = ref('');
  58 +const showCreate = ref(false);
  59 +const showModal = computed(() => showCreate.value || !!selectedTaskId.value);
  60 +/** 从当前 goals 树里现查,而不是缓存点击那一刻的快照——刷新之后弹窗跟着显示最新状态 */
  61 +const modalTask = computed(() => {
  62 + if (!selectedTaskId.value) return null;
  63 + for (const g of goals.value) {
  64 + const t = (g.tasks || []).find((x) => x.taskId === selectedTaskId.value);
  65 + if (t) return t;
  66 + }
  67 + return null;
  68 +});
53 69
54 let treePoll = null; 70 let treePoll = null;
55 71
56 onMounted(async () => { 72 onMounted(async () => {
57 - selectedGoalId.value = String(route.query.goal || '');  
58 - selectedTaskId.value = String(route.query.task || '');  
59 - await Promise.all([loadCompany(), loadGoals()]); 73 + await Promise.all([loadCompany(), loadGoals(), loadAgents()]);
60 // 与 GraphConsole 同款兜底:没有公司级 SSE 通道,靠轮询看到别处发生的状态变化 74 // 与 GraphConsole 同款兜底:没有公司级 SSE 通道,靠轮询看到别处发生的状态变化
61 treePoll = setInterval(() => { loadGoals(); }, 20000); 75 treePoll = setInterval(() => { loadGoals(); }, 20000);
62 }); 76 });
@@ -76,18 +90,37 @@ async function loadGoals() { @@ -76,18 +90,37 @@ async function loadGoals() {
76 if (!r.ok) return; 90 if (!r.ok) return;
77 const j = await r.json(); 91 const j = await r.json();
78 goals.value = j.goals || []; 92 goals.value = j.goals || [];
79 - slots.value = j.slots || null;  
80 } catch { /* 轮询失败保留上一份树 */ } finally { 93 } catch { /* 轮询失败保留上一份树 */ } finally {
81 goalsLoading.value = false; 94 goalsLoading.value = false;
82 } 95 }
83 } 96 }
84 97
85 -/** 点一条 task:跳到执行控制台看它的图,选中态通过 query 带过去 */  
86 -function goToDetail({ goalId, taskId }) {  
87 - selectedGoalId.value = goalId || '';  
88 - selectedTaskId.value = taskId || '';  
89 - if (!taskId) return;  
90 - router.push({ path: `/${companyId.value}/graph-console`, query: { goal: goalId || '', task: taskId } }); 98 +/** agent 花名册不常变,拉一次即可,不用跟 goals 一起轮询 */
  99 +async function loadAgents() {
  100 + try {
  101 + const r = await fetch(`/api/company/${companyId.value}/agents`);
  102 + if (r.ok) agents.value = await r.json();
  103 + } catch { /* 拉不到就退回显示 assignee 原始 id,不影响其余功能 */ }
  104 +}
  105 +
  106 +function openDetail(t) {
  107 + showCreate.value = false;
  108 + selectedTaskId.value = t.taskId;
  109 +}
  110 +function openCreate() {
  111 + selectedTaskId.value = '';
  112 + showCreate.value = true;
  113 +}
  114 +function closeModal() {
  115 + showCreate.value = false;
  116 + selectedTaskId.value = '';
  117 +}
  118 +
  119 +/** 弹窗里任一增删改查动作成功后:重新拉 goals;新建 / 归档导致弹窗没有对象可显示时关掉它 */
  120 +async function onSaved() {
  121 + await loadGoals();
  122 + if (showCreate.value) { closeModal(); return; }
  123 + if (selectedTaskId.value && !modalTask.value) closeModal();
91 } 124 }
92 </script> 125 </script>
93 126
@@ -104,7 +137,7 @@ function goToDetail({ goalId, taskId }) { @@ -104,7 +137,7 @@ function goToDetail({ goalId, taskId }) {
104 .to-console { text-decoration: none; color: #2b6cb0; font-size: 12px; font-weight: 600; } 137 .to-console { text-decoration: none; color: #2b6cb0; font-size: 12px; font-weight: 600; }
105 .to-console:hover { text-decoration: underline; } 138 .to-console:hover { text-decoration: underline; }
106 139
107 -.body { flex: 1 1 auto; overflow-y: auto; padding: 16px 24px; max-width: 900px; width: 100%; margin: 0 auto; box-sizing: border-box; } 140 +.body { flex: 1 1 auto; overflow-y: auto; padding: 16px 24px; box-sizing: border-box; }
108 141
109 @media (max-width: 700px) { 142 @media (max-width: 700px) {
110 .body { padding: 12px; } 143 .body { padding: 12px; }