task-blocked-reason.test.js
6.12 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
/**
* Unit Tests — blocked 的 task 必须自己说得出为什么卡住
*
* 背景:run 收敛成 blocked 时 `settleRootRun` 把 task 也写成 blocked,但**只写状态不写原因**。
* 症状是纯视觉的、不报错:任务列表那一行只剩一个橙色的「挂起」,
* 而 `GoalTaskPane` 的红色原因行读的是 `tasks.blocked_reason` —— 空的就整行不渲染。
* 于是「为什么卡住」在任务列表上无法回答,人必须逐层点进 org 图 → work 图 → 节点详情。
*
* 钉三件事:
* 1. blocked 回写时,原因落到了 task 行上,且带得出「哪个节点 + 什么 fail_reason」;
* 2. 原因**绝不以 `UPSTREAM_BLOCK_PREFIX` 开头** —— 带了就会被 task-scheduler 当成
* 「依赖推导出来的 blocked」自动推回 queued,而这类 blocked 本来就等着人来处置
* (work-graph-db.noteTaskBlockedReason 的 ⚠️ 就是这一条,这里把它变成会红的判据);
* 3. completed / failed 那些回写**不碰** blocked_reason(它只服务于「要人动手」这一种)。
*
* 隔离手法与 graph-run-slot-recovery.test.js 一致:先设 GRAPH_DB_PATH 再 require。
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import fs from 'fs';
describe('blocked task 的原因回写', () => {
let wdb;
let settle;
const TEST_DB_PATH = '/tmp/test-blocked-reason.sqlite';
const files = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
beforeAll(() => {
for (const f of files) { try { fs.unlinkSync(f); } catch { /* 没有就算了 */ } }
process.env.GRAPH_DB_PATH = TEST_DB_PATH;
for (const m of [
'../../services/graph-db',
'../../services/org-graph-service',
'../../services/work-graph-db',
'../../services/graph-run-sse',
'../../services/task-settle',
]) {
delete require.cache[require.resolve(m)];
}
wdb = require('../../services/work-graph-db');
settle = require('../../services/task-settle');
});
afterAll(() => {
for (const f of files) { try { fs.unlinkSync(f); } catch { /* 同上 */ } }
delete process.env.GRAPH_DB_PATH;
});
beforeEach(() => {
const db = wdb.getDb();
db.pragma('foreign_keys = OFF');
db.exec(`
DELETE FROM graph_run_nodes;
DELETE FROM graph_runs;
DELETE FROM tasks;
DELETE FROM goals;
DELETE FROM work_graph_edges;
DELETE FROM work_graph_nodes;
DELETE FROM work_graphs;
DELETE FROM system_agents;
`);
db.pragma('foreign_keys = ON');
});
let seq = 0;
/**
* 造出「一条 root run 正卡在某个节点上」这个局面。
* 直接写 graph_run_nodes 的状态列:本文件验的是回写那一跳,不是引擎怎么走到 blocked 的。
*/
function scene({ nodeStatus = 'blocked', failReason = 'boundary_escape', note = null } = {}) {
const companyId = `blockedreason-co-${++seq}`;
const now = new Date().toISOString();
const db = wdb.getDb();
db.prepare(`
INSERT OR IGNORE INTO system_agents
(id, name, scope, owner_company_id, source_kind, legacy_path, status, created_at, updated_at)
VALUES ('sdd-planning', 'sdd-planning', 'public', NULL, 'global_agent', 'system/agents/sdd-planning', 'active', ?, ?)
`).run(now, now);
const graph = wdb.createWorkGraph({ companyId, nodes: ['sdd-planning'], edges: [] });
expect(graph.errors || []).toEqual([]);
const goal = wdb.createGoal({ companyId, title: '做一个官网', description: '测试用' });
const task = wdb.createTask({
goalId: goal.id, companyId, title: '做一个官网', description: '测试用', executionMode: 'manual',
});
const run = wdb.createRun({
graphKind: 'work', graphId: graph.id, companyId, goalId: goal.id, taskId: task.id,
executionMode: 'manual', runStatus: 'running',
});
wdb.syncRunNodesToDef(run.id);
wdb.updateTask(task.id, { status: 'running', currentRunId: run.id, startedAt: now });
const nodeRow = db.prepare(`SELECT id FROM graph_run_nodes WHERE run_id = ?`).get(run.id);
db.prepare(`
UPDATE graph_run_nodes SET status = ?, fail_reason = ?, remediation_note = ?, updated_at = ?
WHERE id = ?
`).run(nodeStatus, failReason, note, now, nodeRow.id);
return { taskId: task.id, run: wdb.getRun(run.id) };
}
it('① blocked 回写会把「卡在哪个节点、什么 fail_reason」写到 task 行上', () => {
const { taskId, run } = scene({ failReason: 'boundary_escape' });
expect(settle.settleRootRun(run, 'blocked')).toEqual({ task: 'blocked', goal: null });
const task = wdb.getTask(taskId);
expect(task.status).toBe('blocked');
// 节点名与 fail_reason 都要带得出来 —— 少一个,人在任务列表上就还得点进去才知道去哪看
expect(task.blocked_reason).toContain('sdd-planning');
expect(task.blocked_reason).toContain('boundary_escape');
});
it('② 原因绝不以 upstream_failed: 开头(否则会被调度器自动推回 queued)', () => {
const { taskId, run } = scene();
settle.settleRootRun(run, 'blocked');
expect(wdb.getTask(taskId).blocked_reason.startsWith('upstream_failed:')).toBe(false);
});
it('③ fail_reason 为 null 是合法常态(走兜底边),此时仍要写出原因而不是留白', () => {
const { taskId, run } = scene({ failReason: null });
settle.settleRootRun(run, 'blocked');
const reason = wdb.getTask(taskId).blocked_reason;
expect(reason).toBeTruthy();
expect(reason).toContain('sdd-planning');
});
it('④ 判词太长要截断 —— task 行是一条小字,不是日志窗口', () => {
const { taskId, run } = scene({ note: '现象:'.padEnd(600, '啰') });
settle.settleRootRun(run, 'blocked');
expect(wdb.getTask(taskId).blocked_reason.length).toBeLessThan(300);
});
it('⑤ completed 回写不碰 blocked_reason', () => {
const { taskId, run } = scene({ nodeStatus: 'success', failReason: null });
settle.settleRootRun(run, 'completed');
const task = wdb.getTask(taskId);
expect(task.status).toBe('completed');
expect(task.blocked_reason || '').toBe('');
});
});