task-settle.js
8.97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
/**
* root run 收敛 → task / goal 状态回写 + OKR 写一次 + report_to 唤醒(27-P3C 缺陷 F1 / F2)。
*
* ── 为什么要有这个模块(F1)──
* 批 1 落地时,`updateTask` 的全部调用方只做三件事:把 task 置 running、写 archived_at、
* 用户 PATCH。**没有任何一条路径写 completed / failed / blocked 或 finished_at。**
* 于是一条正常跑完的 run 留下的 task 永远停在 `running`:
* · `DELETE /:cid/goals/:goalId` 永久 409「下还有正在执行的 task」,
* 而它给的自救 hint(`POST /api/graph-runs/{id}/stop`)对一条 completed 的 run 会因
* `RUN_TRANSITIONS.completed = ['ready']` 抛 InvalidTransitionError → 400。自救路径本身是死路;
* · 列表里所有历史任务永远显示「执行中」—— 正是 27-P3C 开篇要修的「历史任务看不见」的镜像版本。
*
* ── 为什么挂在 `graph-run-sse.transitionRun` 上而不是枚举路径(§五.1.1)──
* 设计文档已经论证过:`stopRun` 与「预算耗尽 → blocked」这两条最常见的释放路径**都不走 settleRun**
* (`stopRun` 的注释自己写着「不经过 settleRun」)。枚举一次漏一次,而漏掉的症状是
* 「任务静默躺着、界面上跟正常执行一模一样」。`transitionRun` 是全仓库 run 状态转换的唯一漏斗。
*
* ── 为什么 OKR 要挪到 goal 层(F2)──
* §四.7 禁令一让新 run 不再带 `okr_node_id`(否则一个 goal 下 N 个 task 会反复覆写同一个 OKR 节点,
* 一条 failed 的尾巴能把已经 done 的节点翻掉)。但批 1 只做了「不写」的那一半:`goals.okr_node_id`
* 有写侧、有展示,**零读侧** —— 调用方传了、响应回显了(假确认)、run 跑完了,OKR 节点原地不动,
* 日志一行都没有。这里补上另一半:goal 收敛时按 goal 的 okr_node_id 写**一次**。
*
* 全模块的副作用都由 `work-graph-db` 的两个条件更新(`settleTaskFromRun` / `settleGoalStatus`)
* 把闸门,谁抢到 changes=1 谁才继续往下做 —— 并行收敛的多个 task 会同时走到这里。
*/
/**
* run 终态 → task 状态。
* `stopped → cancelled`:人工主动停止不是执行失败,把它记成 failed 会污染 goal 的验收判定
* (下面 settleGoal 只认「全部 completed 才 done」,两者都进 failed 分支,但 task 行上要分得清)。
*
* `blocked → blocked` 保留,是因为界面必须看得见「卡住了,等人加预算」——
* 但它**不是终态**(见 `work-graph-db.TASK_TERMINAL_STATUS`):它只写 task 行,不触发 goal 收敛,
* run 恢复后跑完还会被 completed 覆盖回去。
*/
const RUN_TO_TASK_STATUS = {
completed: 'completed',
failed: 'failed',
stopped: 'cancelled',
blocked: 'blocked',
};
/**
* 一条 root run 收敛了:回写它的 task,必要时再收敛整个 goal。
*
* **不 throw** 是调用方的硬要求(钩子挂在状态转换的尾巴上),但本函数只做同步 DB 写,
* 异常统一由 `graph-run-sse` 那层的 try/catch 兜住 —— 在这里再吞一次会让真 bug 连日志都没有。
*
* @param {object} run graph_runs 的整行(调用方已保证 parent_run_id 为空 = root run)
* @param {string} to 转入的 run 状态
* @returns {{task: string, goal: string|null}|null} null = 这次没有产生任何回写(幂等 no-op)
*/
function settleRootRun(run, to) {
const wdb = require('./work-graph-db');
const taskStatus = RUN_TO_TASK_STATUS[to];
// task_id 为空的 root run 是存量数据(goals/tasks 表出现之前建的),没有回写对象
if (!taskStatus || !run || !run.task_id) return null;
if (!wdb.settleTaskFromRun(run.task_id, run.id, taskStatus)) return null;
const task = wdb.getTask(run.task_id);
return { task: taskStatus, goal: task && task.goal_id ? settleGoalIfAllTasksDone(task.goal_id) : null };
}
/**
* goal 级收敛:这个 goal 的 task 是不是都到终态了?是则定判词并做**一次**对外汇报。
*
* 判词口径(§四.10 的验收本体属批 2,这里只做状态收敛):
* · 全部 completed → `done`
* · 有任何一条不是 completed(failed / cancelled)→ `failed`
* draft 的 task 算「还没到终态」,所以批 1 那种「一次只跑一条、其余留 draft」的 goal
* 会一直等到最后一条也跑完才收敛 —— 这正是想要的:需求没做完就不该判它达成了。
*
* **`blocked` 同样算「还没到终态」**(第二轮复核 缺陷 2,靠 `TASK_TERMINAL_STATUS` 不含它实现):
* 挂起等人加预算 ≠ 需求失败。收敛一次是**不可撤销**的 —— 它写 OKR 节点、唤醒上级 agent,
* 而 goal 收敛后进 GOAL_TERMINAL_STATUS,人加完预算把 run 跑完也再改不回 done。
* 所以有 blocked 在就一条都不写:goal 停在 `active`,等它恢复后跑完再一次性收敛。
*
* @returns {string|null} 本次定下的 goal 状态;null = 还没到终态,或收敛权被别人抢走
*/
function settleGoalIfAllTasksDone(goalId) {
const wdb = require('./work-graph-db');
const tasks = wdb.listGoalTasks(goalId);
if (!tasks.length) return null;
if (tasks.some((t) => !wdb.TASK_TERMINAL_STATUS.includes(t.status))) return null;
const status = tasks.every((t) => t.status === 'completed') ? 'done' : 'failed';
// CAS:并行收敛的多个 task 会同时算出「全都到终态了」,只有抢到的这一个才做下面的副作用
if (!wdb.settleGoalStatus(goalId, status)) return null;
const goal = wdb.getGoal(goalId);
if (goal) reportGoalOutcome(goal, status, tasks);
return status;
}
/**
* goal 收敛的对外汇报 —— 粒度是 **goal**:「人提的这个需求到底算不算做到了」。
* 上级 agent 收到的 taskId 是 `G-xxx`。
*
* 沿用它那个 either/or:有 `report_to` 就走 `/api/agent/task/report`(那条链路自己会更新 OKR 节点、
* 推进 Pipeline、唤醒上级),没有就直接 `OkrService.updateNode`。两条分支都会写到 OKR,
* 所以这里绝不能写成「先写 OKR 再汇报」—— 那就是同一个节点被写两次。
*
* **这是全系统唯一的对外汇报出口**(第二轮复核 缺陷 3)。company.js 里那条 run 级的
* `reportRunOutcome` 已经删了:批 1 的唯一形态是 1 goal : 1 task(`POST /:cid/tasks` 自动建 goal、
* `createTask` 继承 `goal.report_to`),两条汇报会先后打到同一个 `/api/agent/task/report`,
* 而它第 5 步会 `setImmediate` 自调 `/api/agent/{to}/chat` —— 同一件事把上级叫醒两次、
* 白烧一轮 LLM,其中一次带的还是它从没见过的 `G-` id。留 goal 级的理由见 company.js 那段注释。
*
* `from` = **交付者**,取 org graph 的末节点(`terminalAgentId`),不是 `created_by`:
* `created_by` 是「谁下的单」,`POST /:cid/tasks` 里默认就是字符串 `'human'`,
* 而下游拿它去 `readStateFile('human','todo.md')` / `findActiveRunStep('human')` 全部落空 ——
* 不报错,只是汇报里的执行者信息整段是假的。图为空时退回 `report_to`(自己汇报给自己,
* 至少是个真实存在的 agent),绝不退回 `created_by`。
*/
function reportGoalOutcome(goal, status, tasks) {
const bad = tasks.filter((t) => t.status !== 'completed');
const summary = (
`goal ${goal.id}「${goal.title}」收敛为 ${status}:共 ${tasks.length} 个 task,`
+ (bad.length ? `未完成 ${bad.map((t) => `${t.id}=${t.status}`).join('、')}` : '全部完成')
).slice(0, 500);
// okr_node_id 形如 O-001.1.1,第一段就是 objectiveId(同 OkrService.parseNodeId 口径)
const okrNodeId = goal.okr_node_id || null;
const objectiveId = okrNodeId ? String(okrNodeId).split('.')[0] : null;
if (goal.report_to) {
const from = require('./org-graph-service').terminalAgentId(goal.company_id) || goal.report_to;
const PORT = require('../config').port || 3000;
fetch(`http://127.0.0.1:${PORT}/api/agent/task/report`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
taskId: goal.id,
from,
to: goal.report_to,
status,
summary,
objectiveId,
nodeId: okrNodeId,
}),
})
// fetch 不因 4xx/5xx reject,不显式看 res.ok 会把地址写错这类问题完全静默吞掉
.then((r) => {
if (!r.ok) console.error(`[TaskSettle] goal 汇报失败: HTTP ${r.status} goal=${goal.id}`);
})
.catch((e) => console.error(`[TaskSettle] goal 汇报失败(网络): ${e.message}`));
return;
}
if (objectiveId && okrNodeId) {
const { OkrService } = require('./okr-service');
new OkrService(goal.company_id).updateNode(objectiveId, okrNodeId, { status, summary });
console.log(`[TaskSettle] 🎯 ${goal.company_id} OKR ${okrNodeId} → ${status}(goal ${goal.id})`);
}
}
module.exports = {
settleRootRun,
settleGoalIfAllTasksDone,
RUN_TO_TASK_STATUS,
};