Commit be8de85b743a8e9ec651f1eb79e531ddd10fafa1

Authored by yibo
1 parent e1206459

docs(27-P3C): 抓到一处路径分叉,就在 task-goal.json 的核心链路上

并行会话把 system/agents 那一族收敛到 locator.AGENTS_ROOT 之后
(e120645,13 处跨 5 文件,我复验 3/3 通过),顺着同一个模式查
company.js 里的 data/companies 那一族,撞见一个真的分叉:

  写 company.js:writeTaskGoalArtifact
     path.resolve(__dirname,'../..','data','companies',...)   不认 GRAPH_RUNS_DIR
  读 graph-engine.js:runRootDir(describeTask / goal-contract 都走它)
     RUNS_ROOT ? path.join(RUNS_ROOT, root_run_id) : ...      认
  三 company-paths.js:companyRootDir
     runsRoot ? path.join(runsRoot,'_companies',cid) : ...    认,但形状又不同

同一个事实三种拼法。GRAPH_RUNS_DIR 一旦设上(目前只有测试在用),
写进 A 处、从 B 处读,describeTask 拿到 null 后静默退回兜底文案
「执行图节点任务」—— agent 从此拿不到真实任务描述,且没有任何报错。
goal-contract.test.js 的注释佐证读取方那套形状才是约定。

生产环境两边恰好都退到同一个默认分支,所以今天不发作。但批 1 会把
artifact 落盘点从「下达时」前移到「入队时」,正好要动这个函数 ——
修的时机就是那时。新增验收 12b 专门验它。

方法论也记一条:我第一次只 grep 了 TEMPLATES_DIR 这个名字,因此漏
掉同一文件里语义相同但叫 DATA_DIR 的 3 处。同义异名的副本按名字
grep 永远找不到,要按值 grep。

这是本文档攒到的第五个同类实例。同一个事实只能有一处持有者 ——
路径也是事实。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/todo/system-design/27-p3c-goal-task-model.md
@@ -485,11 +485,27 @@ for (const t of queuedTasks) { @@ -485,11 +485,27 @@ for (const t of queuedTasks) {
485 485
486 全部挂在 `server/routes/company.js`。错误响应沿用现有 `{ error, hint, ... }` 形态。 486 全部挂在 `server/routes/company.js`。错误响应沿用现有 `{ error, hint, ... }` 形态。
487 487
488 -> **批 1 顺手收敛一处**(不是为改而改 —— 批 1 本来就要大改这个文件):`company.js` 里 `const TEMPLATES_DIR` 被**重复定义了 7 次**,其中 6 处硬编码 `path.resolve(__dirname, '../../system/agents')`,只有 1 处(`:220`)用了权威访问器 `locator.AGENTS_ROOT`。2026-08-18 的 agent 目录重构刚把这个路径搬过一次 —— **下次再搬,改 6 处、漏 1 处就是一个静默 bug**。收敛成模块级的一份 `locator.AGENTS_ROOT` 即可。 488 +> **⚠️ 批 1 必须修一处路径分叉 —— 它就在本文档的核心链路上。**
489 > 489 >
490 -> 这与本文档反复点名的是**同一个病**:`canDispatch` 的取值域被前端复述过一份(§六.3 的教训)、`ACTIVE_ROOT_RUN_STATUS` 在两个文件里逐字重复(§九.1)、`isUngraduatedStatus()` 的取值域不许在别处复述(§四.9)。**同一个事实只能有一处持有者 —— 路径也是事实。** 490 +> `task-goal.json` 的**写入方与读取方用的不是同一套路径规则**:
491 > 491 >
492 -> (顺带记一条**不做**的:`company-service.js` 的 `const TEMPLATES_DIR = locator.AGENTS_ROOT` 名字已过期,读的人会以为它指 `system/templates`。但改名要动 50 处、跨 6 个文件、零功能收益 —— 不值得,留着这条记录说明它是已知的、不是没人看见。) 492 +> | | 位置 | 认不认 `GRAPH_RUNS_DIR` |
  493 +> |---|---|---|
  494 +> | **写** | `company.js` 的 `writeTaskGoalArtifact` → `path.resolve(__dirname,'../..','data','companies',cid,'runs',rid,'artifacts')` | ❌ **不认** |
  495 +> | **读** | `graph-engine.js` 的 `runRootDir`(`describeTask` 与 `goal-contract` 都走它)→ `RUNS_ROOT ? path.join(RUNS_ROOT, root_run_id) : …` | ✅ 认 |
  496 +> | 第三种 | `company-paths.js` 的 `companyRootDir` → `runsRoot ? path.join(runsRoot,'_companies',cid) : …` | ✅ 认,但**形状又不一样** |
  497 +>
  498 +> 同一个事实**三种拼法**。`GRAPH_RUNS_DIR` 一旦被设上(目前只有测试在用),写进 A 处、从 B 处读 —— `describeTask` 拿到 null,**静默退回兜底文案「执行图节点任务」**,agent 从此拿不到真实任务描述,而**没有任何报错**。`goal-contract.test.js` 的注释可以佐证读取方那套形状才是约定:`{GRAPH_RUNS_DIR}/{rootRunId}/artifacts/task-goal.json`。
  499 +>
  500 +> 生产环境两边恰好都退到同一个默认分支,所以今天不发作 —— 但**批 1 会把 artifact 的落盘点从「下达时」前移到「入队时」**(§四.3 / §四.4 的描述冻结点),正好要动这个函数。**修的时机就是那时**:让写入方复用读取方的同一个 helper(`runRootDir` 提到 `company-paths` 或直接导出),三种拼法收敛成一种。
  501 +>
  502 +> **`system/agents` 那一族已经不用管了** —— 2026-08-18 的 `e120645` 已把全仓库 13 处(跨 5 个文件,含 `company.js` 里 6 个 `TEMPLATES_DIR` + 3 个同义异名的 `DATA_DIR`)收敛到 `locator.AGENTS_ROOT`,并新增 `server/tests/unit/agents-root-single-holder.test.js` 锁住不变式。**批 1 新增的代码必须遵守它**:不许自己拼「以 `../` 开头、以 `system/agents` 结尾」的字面量,否则这条测试会红。
  503 +>
  504 +> 这些全是**同一个病**的不同实例,本文档已经攒到第五个:`canDispatch` 的取值域被前端复述(§六.3)、`ACTIVE_ROOT_RUN_STATUS` 在两文件逐字重复(§九.1)、`isUngraduatedStatus()` 的取值域(§四.9)、`system/agents` 的 13 份路径、以及上面这三种 run 目录拼法。**同一个事实只能有一处持有者 —— 路径也是事实。**
  505 +>
  506 +> 方法论也记一条:我第一次只 grep 了 `TEMPLATES_DIR` 这个**名字**,因此漏掉了同一文件里语义相同但叫 `DATA_DIR` 的 3 处。**同义异名的副本按名字 grep 永远找不到,要按值 / 按路径字面量 grep。**
  507 +>
  508 +> (一条**刻意不做**的:`company-service.js` 的 `const TEMPLATES_DIR = locator.AGENTS_ROOT` 取值已正确、只是名字过期,读的人会以为它指 `system/templates`。改名要动 50 处、跨 6 个文件、零功能收益 —— 不值得。留着这条记录,是为了说明它**是已知的,不是没人看见**。)
493 509
494 ### 6.1 先做一件清理:退役 OKR 的 `/goals` 别名 510 ### 6.1 先做一件清理:退役 OKR 的 `/goals` 别名
495 511
@@ -598,6 +614,7 @@ for (const t of queuedTasks) { @@ -598,6 +614,7 @@ for (const t of queuedTasks) {
598 10. 列表端点在 20 goal × 3 task 的公司上**不 N+1**(`graph_run_nodes` 的进度用一次 `GROUP BY` 出)。 614 10. 列表端点在 20 goal × 3 task 的公司上**不 N+1**(`graph_run_nodes` 的进度用一次 `GROUP BY` 出)。
599 11. `/api/goals` 与 `/api/company/:cid/goals` 的 OKR 别名退役后,`OkrDashboard` / `CompanyDashboard` / `bin/lemon.js` 全部功能正常(它们走的是 `/api/okr`)。 615 11. `/api/goals` 与 `/api/company/:cid/goals` 的 OKR 别名退役后,`OkrDashboard` / `CompanyDashboard` / `bin/lemon.js` 全部功能正常(它们走的是 `/api/okr`)。
600 12. **回填**:跑迁移脚本后,既存的每条 root run 在左栏都以 goal + task 的形式出现,描述从 `task-goal.json` 读回(§四.8)。**这条必须在批 1 就验** —— 不验就等于批 1 让历史任务更看不见了。 616 12. **回填**:跑迁移脚本后,既存的每条 root run 在左栏都以 goal + task 的形式出现,描述从 `task-goal.json` 读回(§四.8)。**这条必须在批 1 就验** —— 不验就等于批 1 让历史任务更看不见了。
  617 +12b. **路径分叉已修**:在 `GRAPH_RUNS_DIR` 指向临时目录的条件下走一遍「下达 → 入队 → 读回描述」,`describeTask` 拿到的是**真实任务描述**而不是兜底文案「执行图节点任务」(验 §六 那处三种拼法已收敛为一种)。
601 13. **停止即出队**:`max=1`,任务 A 跑着、B 排队,对 A 点「停止」→ **B 在几秒内自动起跑**(验证 §五.1.1 的漏斗钩子覆盖了 `stopRun` 这条不走 `settleRun` 的路径),不是等 60s 兜底。 618 13. **停止即出队**:`max=1`,任务 A 跑着、B 排队,对 A 点「停止」→ **B 在几秒内自动起跑**(验证 §五.1.1 的漏斗钩子覆盖了 `stopRun` 这条不走 `settleRun` 的路径),不是等 60s 兜底。
602 14. **预算耗尽即出队**:把 A 的预算调到极小让它烧成 `blocked` → B 同样自动起跑。 619 14. **预算耗尽即出队**:把 A 的预算调到极小让它烧成 `blocked` → B 同样自动起跑。
603 15. **排队期间 replan**:B 排队时点「重新规划组织」改动 org graph → B 出队后**按新图跑**,没有孤儿节点、没有卡死(验证 `syncRunNodesToDef`)。 620 15. **排队期间 replan**:B 排队时点「重新规划组织」改动 org graph → B 出队后**按新图跑**,没有孤儿节点、没有卡死(验证 `syncRunNodesToDef`)。