diff --git a/server/routes/company.js b/server/routes/company.js index 493fe6c..5f04402 100644 --- a/server/routes/company.js +++ b/server/routes/company.js @@ -1076,6 +1076,10 @@ function enqueueTaskRun({ companyId, taskRow, budget }) { startedAt: null, finishedAt: null, blockedReason: null, + // 人类任务从这一刻起被 graph-engine 接管跑图,不再是「无人认领」(T-mt5dkvjm): + // 没有真实派单对象就写占位哨兵,COO 心跳巡检靠 to 字段判断时才不会把它当孤儿误报。 + // 只在还没有真实值时才写,避免盖掉别的派单流程已经写好的 assignee。 + assigneeAgentId: taskRow.assignee_agent_id || wdb.GRAPH_TAKEOVER_ASSIGNEE, }); return { runId, run: wdb.getRun(runId) || {}, artifact, task }; } catch (err) { diff --git a/server/services/work-graph-db.js b/server/services/work-graph-db.js index 903dba0..41b853c 100644 --- a/server/services/work-graph-db.js +++ b/server/services/work-graph-db.js @@ -1925,6 +1925,7 @@ const TASK_PATCH_COLUMNS = { budgetJson: 'budget_json', currentRunId: 'current_run_id', attempt: 'attempt', + assigneeAgentId: 'assignee_agent_id', reportTo: 'report_to', blockedReason: 'blocked_reason', queuedAt: 'queued_at', @@ -2009,6 +2010,14 @@ const TASK_TERMINAL_STATUS = ['completed', 'failed', 'cancelled']; const GOAL_TERMINAL_STATUS = ['done', 'failed', 'cancelled', 'archived']; /** + * 人类任务被 graph-engine 接管跑图时,`assignee_agent_id` 没有真实派单对象可写—— + * 占位成这个哨兵值,让 COO 心跳巡检的「to 是否为 null」判据不再把「有图在跑」误判成「没人认领」 + * (T-mt5dkvjm)。`enqueueTaskRun` 入队时写一次,这里(run 收敛)做兜底 COALESCE: + * 只在该列仍是 NULL 时才补,不覆盖派单式任务(agent-chat 的 dispatch 流程)已经写好的真实值。 + */ +const GRAPH_TAKEOVER_ASSIGNEE = 'org-graph'; + +/** * run 收敛 → task 回写的**条件更新**(27-P3C §五.1.1 的钩子用,缺陷 F1)。 * * 两个 WHERE 条件各挡一类事故,缺一不可: @@ -2024,7 +2033,8 @@ function settleTaskFromRun(taskId, runId, status) { const now = new Date().toISOString(); return getDb() .prepare(` - UPDATE tasks SET status = @status, blocked_reason = NULL, finished_at = @now, updated_at = @now + UPDATE tasks SET status = @status, blocked_reason = NULL, finished_at = @now, updated_at = @now, + assignee_agent_id = COALESCE(assignee_agent_id, '${GRAPH_TAKEOVER_ASSIGNEE}') WHERE id = @id AND current_run_id = @runId AND status NOT IN (${TASK_TERMINAL_STATUS.map((s) => `'${s}'`).join(', ')}) @@ -2650,6 +2660,7 @@ module.exports = { taskProgress, maxTaskSeq, settleTaskFromRun, + GRAPH_TAKEOVER_ASSIGNEE, // 批 3 排队:三条都是条件更新,调用方靠 changes 判「是不是我做的」 claimQueuedTask, blockQueuedTask, diff --git a/server/tests/unit/goal-task-acceptance.test.js b/server/tests/unit/goal-task-acceptance.test.js index 6935292..392a732 100644 --- a/server/tests/unit/goal-task-acceptance.test.js +++ b/server/tests/unit/goal-task-acceptance.test.js @@ -152,6 +152,26 @@ describe('27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)', () => { }); // ────────────────────────────────────────────────────────────── + // T-mt5dkvjm · assignee_agent_id 不再是永远的 NULL + // ────────────────────────────────────────────────────────────── + + it('POST /:cid/tasks 入队后 assignee_agent_id 不是 NULL —— COO 心跳巡检靠 to 字段判「有没有人接」,一直是 null 会被误报成孤儿任务(T-mt5dkvjm)', async () => { + const cid = newCompany(); + const [status, body] = await call('POST', `/api/company/${cid}/tasks`, { task: '起个图跑任务' }); + expect(status).toBe(201); + + // 入队当下就该有占位 assignee —— 这正是 task-service.getAggregatedTasks 回给 COO 的 `to` 字段 + expect(wdb.getTask(body.taskId).assignee_agent_id).toBe(wdb.GRAPH_TAKEOVER_ASSIGNEE); + + // run 收敛之后也不能被冲回 null:settleTaskFromRun 的 COALESCE 只在为空时兜底,不覆盖已有值 + const sse = require('../../services/graph-run-sse'); + expect(sse.transitionRun(body.runId, 'running', 'completed', {}, { summary: '用例直推终态' })).toBeTruthy(); + const settled = wdb.getTask(body.taskId); + expect(settled.status).toBe('completed'); + expect(settled.assignee_agent_id).toBe(wdb.GRAPH_TAKEOVER_ASSIGNEE); + }); + + // ────────────────────────────────────────────────────────────── // 验收 12b · 写侧与读侧落在同一个 run 目录(路径缺陷 A) // ──────────────────────────────────────────────────────────────