Commit 32227f12f44ceef9d5e23586add4e57ead592278

Authored by yibo
1 parent 01267e8a

fix(tasks): 人类任务被 graph-engine 接管跑图时补写 assignee_agent_id

POST /:cid/tasks -> enqueueTaskRun 入队时从未写过 assignee_agent_id,
COO 心跳巡检靠 tasks.assignee_agent_id(the "to" field) 判断任务有没有
人接手,读到永远的 NULL 就把正常跑图的任务误报成孤儿任务
(本周已发生两次:T-mt25asz4、T-mt5d3mi9-57cf)。

- enqueueTaskRun 入队时补写占位哨兵 'org-graph'(GRAPH_TAKEOVER_ASSIGNEE)
- settleTaskFromRun 收敛时用 COALESCE 兜底,只在仍为空时补,不覆盖
  派单式任务(agent-chat dispatch 流程)已经写好的真实 assignee
- TASK_PATCH_COLUMNS 补上 assigneeAgentId,否则 updateTask 写不进这列

T-mt5dkvjm

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
server/routes/company.js
@@ -1076,6 +1076,10 @@ function enqueueTaskRun({ companyId, taskRow, budget }) { @@ -1076,6 +1076,10 @@ function enqueueTaskRun({ companyId, taskRow, budget }) {
1076 startedAt: null, 1076 startedAt: null,
1077 finishedAt: null, 1077 finishedAt: null,
1078 blockedReason: null, 1078 blockedReason: null,
  1079 + // 人类任务从这一刻起被 graph-engine 接管跑图,不再是「无人认领」(T-mt5dkvjm):
  1080 + // 没有真实派单对象就写占位哨兵,COO 心跳巡检靠 to 字段判断时才不会把它当孤儿误报。
  1081 + // 只在还没有真实值时才写,避免盖掉别的派单流程已经写好的 assignee。
  1082 + assigneeAgentId: taskRow.assignee_agent_id || wdb.GRAPH_TAKEOVER_ASSIGNEE,
1079 }); 1083 });
1080 return { runId, run: wdb.getRun(runId) || {}, artifact, task }; 1084 return { runId, run: wdb.getRun(runId) || {}, artifact, task };
1081 } catch (err) { 1085 } catch (err) {
server/services/work-graph-db.js
@@ -1925,6 +1925,7 @@ const TASK_PATCH_COLUMNS = { @@ -1925,6 +1925,7 @@ const TASK_PATCH_COLUMNS = {
1925 budgetJson: 'budget_json', 1925 budgetJson: 'budget_json',
1926 currentRunId: 'current_run_id', 1926 currentRunId: 'current_run_id',
1927 attempt: 'attempt', 1927 attempt: 'attempt',
  1928 + assigneeAgentId: 'assignee_agent_id',
1928 reportTo: 'report_to', 1929 reportTo: 'report_to',
1929 blockedReason: 'blocked_reason', 1930 blockedReason: 'blocked_reason',
1930 queuedAt: 'queued_at', 1931 queuedAt: 'queued_at',
@@ -2009,6 +2010,14 @@ const TASK_TERMINAL_STATUS = [&#39;completed&#39;, &#39;failed&#39;, &#39;cancelled&#39;]; @@ -2009,6 +2010,14 @@ const TASK_TERMINAL_STATUS = [&#39;completed&#39;, &#39;failed&#39;, &#39;cancelled&#39;];
2009 const GOAL_TERMINAL_STATUS = ['done', 'failed', 'cancelled', 'archived']; 2010 const GOAL_TERMINAL_STATUS = ['done', 'failed', 'cancelled', 'archived'];
2010 2011
2011 /** 2012 /**
  2013 + * 人类任务被 graph-engine 接管跑图时,`assignee_agent_id` 没有真实派单对象可写——
  2014 + * 占位成这个哨兵值,让 COO 心跳巡检的「to 是否为 null」判据不再把「有图在跑」误判成「没人认领」
  2015 + * (T-mt5dkvjm)。`enqueueTaskRun` 入队时写一次,这里(run 收敛)做兜底 COALESCE:
  2016 + * 只在该列仍是 NULL 时才补,不覆盖派单式任务(agent-chat 的 dispatch 流程)已经写好的真实值。
  2017 + */
  2018 +const GRAPH_TAKEOVER_ASSIGNEE = 'org-graph';
  2019 +
  2020 +/**
2012 * run 收敛 → task 回写的**条件更新**(27-P3C §五.1.1 的钩子用,缺陷 F1)。 2021 * run 收敛 → task 回写的**条件更新**(27-P3C §五.1.1 的钩子用,缺陷 F1)。
2013 * 2022 *
2014 * 两个 WHERE 条件各挡一类事故,缺一不可: 2023 * 两个 WHERE 条件各挡一类事故,缺一不可:
@@ -2024,7 +2033,8 @@ function settleTaskFromRun(taskId, runId, status) { @@ -2024,7 +2033,8 @@ function settleTaskFromRun(taskId, runId, status) {
2024 const now = new Date().toISOString(); 2033 const now = new Date().toISOString();
2025 return getDb() 2034 return getDb()
2026 .prepare(` 2035 .prepare(`
2027 - UPDATE tasks SET status = @status, blocked_reason = NULL, finished_at = @now, updated_at = @now 2036 + UPDATE tasks SET status = @status, blocked_reason = NULL, finished_at = @now, updated_at = @now,
  2037 + assignee_agent_id = COALESCE(assignee_agent_id, '${GRAPH_TAKEOVER_ASSIGNEE}')
2028 WHERE id = @id 2038 WHERE id = @id
2029 AND current_run_id = @runId 2039 AND current_run_id = @runId
2030 AND status NOT IN (${TASK_TERMINAL_STATUS.map((s) => `'${s}'`).join(', ')}) 2040 AND status NOT IN (${TASK_TERMINAL_STATUS.map((s) => `'${s}'`).join(', ')})
@@ -2650,6 +2660,7 @@ module.exports = { @@ -2650,6 +2660,7 @@ module.exports = {
2650 taskProgress, 2660 taskProgress,
2651 maxTaskSeq, 2661 maxTaskSeq,
2652 settleTaskFromRun, 2662 settleTaskFromRun,
  2663 + GRAPH_TAKEOVER_ASSIGNEE,
2653 // 批 3 排队:三条都是条件更新,调用方靠 changes 判「是不是我做的」 2664 // 批 3 排队:三条都是条件更新,调用方靠 changes 判「是不是我做的」
2654 claimQueuedTask, 2665 claimQueuedTask,
2655 blockQueuedTask, 2666 blockQueuedTask,
server/tests/unit/goal-task-acceptance.test.js
@@ -152,6 +152,26 @@ describe(&#39;27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)&#39;, () =&gt; { @@ -152,6 +152,26 @@ describe(&#39;27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)&#39;, () =&gt; {
152 }); 152 });
153 153
154 // ────────────────────────────────────────────────────────────── 154 // ──────────────────────────────────────────────────────────────
  155 + // T-mt5dkvjm · assignee_agent_id 不再是永远的 NULL
  156 + // ──────────────────────────────────────────────────────────────
  157 +
  158 + it('POST /:cid/tasks 入队后 assignee_agent_id 不是 NULL —— COO 心跳巡检靠 to 字段判「有没有人接」,一直是 null 会被误报成孤儿任务(T-mt5dkvjm)', async () => {
  159 + const cid = newCompany();
  160 + const [status, body] = await call('POST', `/api/company/${cid}/tasks`, { task: '起个图跑任务' });
  161 + expect(status).toBe(201);
  162 +
  163 + // 入队当下就该有占位 assignee —— 这正是 task-service.getAggregatedTasks 回给 COO 的 `to` 字段
  164 + expect(wdb.getTask(body.taskId).assignee_agent_id).toBe(wdb.GRAPH_TAKEOVER_ASSIGNEE);
  165 +
  166 + // run 收敛之后也不能被冲回 null:settleTaskFromRun 的 COALESCE 只在为空时兜底,不覆盖已有值
  167 + const sse = require('../../services/graph-run-sse');
  168 + expect(sse.transitionRun(body.runId, 'running', 'completed', {}, { summary: '用例直推终态' })).toBeTruthy();
  169 + const settled = wdb.getTask(body.taskId);
  170 + expect(settled.status).toBe('completed');
  171 + expect(settled.assignee_agent_id).toBe(wdb.GRAPH_TAKEOVER_ASSIGNEE);
  172 + });
  173 +
  174 + // ──────────────────────────────────────────────────────────────
155 // 验收 12b · 写侧与读侧落在同一个 run 目录(路径缺陷 A) 175 // 验收 12b · 写侧与读侧落在同一个 run 目录(路径缺陷 A)
156 // ────────────────────────────────────────────────────────────── 176 // ──────────────────────────────────────────────────────────────
157 177