Commit 71cf45e18dd30853cb081fd4c63dc4175b92ff77
1 parent
12880b86
feat(27-P3C): 批 5 第五、六步 —— 删掉会话反推,调度器只管 executor='graph'
## 第 6 步:调度器只管图任务(一行判据,但漏了是静默的) listQueue / deriveQueueBlocks / sweep 三处都加 executor='graph'。 派单任务虽然也进 tasks 表,但它的执行载体是「直接起一次 agent 对话」, 由 /task/dispatch 自己触发。漏这一条的后果:调度器拿一条根本没有 run 行的 task 去出队 → syncRunNodesToDef 判空 → abandonRun → **把一条正在被 agent 执行的派单收成 cancelled**。守卫用例把它摆成「最像可出队」的样子 (queued + auto + 无依赖),只有 executor 这一维拦得住它。 ## 第 5 步:GET /api/tasks 改读表,删掉 122 行的会话反推 原来的 getAggregatedTasks 是启发式:扫全部 agent 的 conversations,按会话 id 里的 T-xxx 归组,再用三条策略(meta 的 parentTaskId / from agent 的活跃会话 时间包含 / master conv 的标题相似)**猜**父子关系。猜出来的树没法验证对错, 而且每次请求都要遍历所有 agent 的会话目录 —— route 那 10 秒缓存就是为它加的。 现在父子关系是 tasks.parent_task_id,派单时就显式写下来了,不用猜。 task-service.js 391 行 → 236 行;_inferParentChild / _loadTaskMetas / TASK_META_DIR 一并删除(改完就是零调用方)。 **getTaskTimeline 保留**,但来源拆清楚:任务树从表来(parent_task_id, 可验证),时间线正文仍从 agent 的 conversations/ 扫 —— 那是一手记录, 没有替代品。删掉的只是「从会话反推任务树」那一半,不是会话本身。 两处口径变化(写在代码注释里): - agents:原来是「会话里出现过的 agent 集合」,现在是「谁派的 + 派给谁」。 对派单任务这两者本来就是同一件事 - conversationCount:原来是扫出来的文件数,现在是「有没有起过执行会话」(0/1)。 为一个列表字段遍历所有 agent 的会话目录,正是这次要去掉的那笔开销; 真要看内容走 /api/tasks/:taskId/timeline,那条仍读一手记录 ## 漏改一个消费方,真打接口才发现 routes/tasks.js 自己还在算 t.conversations.length,而 conversations 是会话 反推那套的产物、已随之删除 → 500「Cannot read properties of undefined」。 测试没覆盖这条路(它只在真跑接口时才走到)。已改成用 t.conversationCount。 真打验证:GET /api/tasks 返回 17 条、12 个字段齐全 —— 条数比改前多, 因为测试跑出来的派单现在也进表了,这本身就是「存储真的换了」的证据。 全量:132 文件 / 1257 测试全绿。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
4 changed files
with
120 additions
and
212 deletions
server/routes/tasks.js
| @@ -30,7 +30,9 @@ router.get('/', (req, res) => { | @@ -30,7 +30,9 @@ router.get('/', (req, res) => { | ||
| 30 | children: t.children, | 30 | children: t.children, |
| 31 | isRoot: t.isRoot, | 31 | isRoot: t.isRoot, |
| 32 | agents: t.agents, | 32 | agents: t.agents, |
| 33 | - conversationCount: t.conversations.length, | 33 | + // 批 5:会话数由 TaskService 直接给(原来这里自己取 t.conversations.length, |
| 34 | + // 而 conversations 是会话反推那套的产物,已随之删除) | ||
| 35 | + conversationCount: t.conversationCount, | ||
| 34 | })); | 36 | })); |
| 35 | 37 | ||
| 36 | _taskListCache = taskList; | 38 | _taskListCache = taskList; |
server/services/task-scheduler.js
| @@ -111,12 +111,18 @@ function dependenciesSatisfied(task) { | @@ -111,12 +111,18 @@ function dependenciesSatisfied(task) { | ||
| 111 | * | 111 | * |
| 112 | * @returns {object[]} 该公司全部 queued task(含 manual 的:它们在界面上同样占位次) | 112 | * @returns {object[]} 该公司全部 queued task(含 manual 的:它们在界面上同样占位次) |
| 113 | */ | 113 | */ |
| 114 | +// 批 5:只认 executor='graph'。agent 派单(executor='agent')虽然也进 tasks 表, | ||
| 115 | +// 但它的执行载体是「直接起一次 agent 对话」,由 /task/dispatch 自己触发 —— | ||
| 116 | +// 进表只为了可见、可查、可串成派单树,**不该被调度器出队**。 | ||
| 117 | +// 漏了这一条的后果是静默的:调度器会拿一条根本没有 run 行的 task 去 syncRunNodesToDef, | ||
| 118 | +// 5a 判空 → abandonRun → 把一条正在被 agent 执行的派单收成 cancelled。 | ||
| 114 | function listQueue(companyId) { | 119 | function listQueue(companyId) { |
| 115 | return wdb() | 120 | return wdb() |
| 116 | .getDb() | 121 | .getDb() |
| 117 | .prepare(` | 122 | .prepare(` |
| 118 | SELECT * FROM tasks | 123 | SELECT * FROM tasks |
| 119 | WHERE company_id = @companyId AND status = 'queued' AND archived_at IS NULL | 124 | WHERE company_id = @companyId AND status = 'queued' AND archived_at IS NULL |
| 125 | + AND executor = 'graph' | ||
| 120 | ORDER BY priority, seq, created_at | 126 | ORDER BY priority, seq, created_at |
| 121 | `) | 127 | `) |
| 122 | .all({ companyId }); | 128 | .all({ companyId }); |
| @@ -156,7 +162,10 @@ function deriveQueueBlocks(companyId) { | @@ -156,7 +162,10 @@ function deriveQueueBlocks(companyId) { | ||
| 156 | const requeued = []; | 162 | const requeued = []; |
| 157 | 163 | ||
| 158 | for (let pass = 0; ; pass++) { | 164 | for (let pass = 0; ; pass++) { |
| 159 | - const rows = listCompanyTasks(companyId).filter((t) => t.status === 'queued' || t.status === 'blocked'); | 165 | + // 同上:依赖推导也只管图任务。派单任务没有 depends_on 语义(它的先后靠 parent_task_id 表达), |
| 166 | + // 放进来只会让它被判成「依赖不可能满足」而无辜转 blocked。 | ||
| 167 | + const rows = listCompanyTasks(companyId) | ||
| 168 | + .filter((t) => t.executor === 'graph' && (t.status === 'queued' || t.status === 'blocked')); | ||
| 160 | let changed = 0; | 169 | let changed = 0; |
| 161 | for (const t of rows) { | 170 | for (const t of rows) { |
| 162 | const dead = deadDependency(t); | 171 | const dead = deadDependency(t); |
| @@ -373,6 +382,7 @@ function sweep() { | @@ -373,6 +382,7 @@ function sweep() { | ||
| 373 | .prepare(` | 382 | .prepare(` |
| 374 | SELECT DISTINCT company_id FROM tasks | 383 | SELECT DISTINCT company_id FROM tasks |
| 375 | WHERE archived_at IS NULL | 384 | WHERE archived_at IS NULL |
| 385 | + AND executor = 'graph' | ||
| 376 | AND (status = 'queued' | 386 | AND (status = 'queued' |
| 377 | OR (status = 'blocked' AND blocked_reason LIKE @prefix)) | 387 | OR (status = 'blocked' AND blocked_reason LIKE @prefix)) |
| 378 | `) | 388 | `) |
server/services/task-service.js
| @@ -4,7 +4,6 @@ const locator = require('./agent-locator'); | @@ -4,7 +4,6 @@ const locator = require('./agent-locator'); | ||
| 4 | const { listCompanyAgents } = require('./company-service'); | 4 | const { listCompanyAgents } = require('./company-service'); |
| 5 | 5 | ||
| 6 | const SYSTEM_AGENTS_DIR = require('./agent-locator').AGENTS_ROOT; | 6 | const SYSTEM_AGENTS_DIR = require('./agent-locator').AGENTS_ROOT; |
| 7 | -const TASK_META_DIR = path.resolve(__dirname, '../../system/tasklog'); | ||
| 8 | 7 | ||
| 9 | /** | 8 | /** |
| 10 | * 递归扫描并读取系统中所有 Agent 的会话, | 9 | * 递归扫描并读取系统中所有 Agent 的会话, |
| @@ -34,18 +33,6 @@ class TaskService { | @@ -34,18 +33,6 @@ class TaskService { | ||
| 34 | return list; | 33 | return list; |
| 35 | } | 34 | } |
| 36 | 35 | ||
| 37 | - _loadTaskMetas() { | ||
| 38 | - const metas = new Map(); | ||
| 39 | - if (!fs.existsSync(TASK_META_DIR)) return metas; | ||
| 40 | - const files = fs.readdirSync(TASK_META_DIR).filter(f => f.endsWith('.json')); | ||
| 41 | - for (const f of files) { | ||
| 42 | - try { | ||
| 43 | - const tm = JSON.parse(fs.readFileSync(path.join(TASK_META_DIR, f), 'utf-8')); | ||
| 44 | - if (tm.taskId) metas.set(tm.taskId, tm); | ||
| 45 | - } catch(e) {} | ||
| 46 | - } | ||
| 47 | - return metas; | ||
| 48 | - } | ||
| 49 | 36 | ||
| 50 | /** | 37 | /** |
| 51 | * 从 Conversation 聚合 Task 列表,并通过多种手段推断父子关系: | 38 | * 从 Conversation 聚合 Task 列表,并通过多种手段推断父子关系: |
| @@ -53,67 +40,55 @@ class TaskService { | @@ -53,67 +40,55 @@ class TaskService { | ||
| 53 | * 2) conversationId 模式推断(task-T-xxx 从 tpl-train-* 派出,tpl-train 从 conv-* 派出) | 40 | * 2) conversationId 模式推断(task-T-xxx 从 tpl-train-* 派出,tpl-train 从 conv-* 派出) |
| 54 | * 3) title 中可解析的 task reference([TASK from xxx]) | 41 | * 3) title 中可解析的 task reference([TASK from xxx]) |
| 55 | */ | 42 | */ |
| 43 | + /** | ||
| 44 | + * 任务列表 —— 批 5 起**读 tasks 表**,不再从会话记录反推。 | ||
| 45 | + * | ||
| 46 | + * 原来这里是 122 行的启发式:扫全部 agent 的 conversations,按会话 id 里的 `T-xxx` 归组, | ||
| 47 | + * 再用三条策略(meta 的 parentTaskId / from agent 的活跃会话时间包含 / master conv 的 | ||
| 48 | + * 标题相似)**猜**父子关系。猜出来的树没法验证对错,而且每次请求都要遍历所有 agent 的 | ||
| 49 | + * 会话目录(route 那 10 秒缓存就是为它加的)。 | ||
| 50 | + * | ||
| 51 | + * 现在父子关系是 `tasks.parent_task_id` —— 派单时就显式写下来了,不用猜。 | ||
| 52 | + * | ||
| 53 | + * **响应字段一个没少**(TaskTimeline.vue 按名取值)。两处口径变化写在下面。 | ||
| 54 | + */ | ||
| 56 | getAggregatedTasks() { | 55 | getAggregatedTasks() { |
| 57 | - const convs = this.getAllConversations(); | ||
| 58 | - const taskMetas = this._loadTaskMetas(); | ||
| 59 | - const taskMap = new Map(); | ||
| 60 | - | ||
| 61 | - // 第一遍:建立所有 task group | ||
| 62 | - for (const c of convs) { | ||
| 63 | - let taskId = this._extractTaskId(c.conversationId); | ||
| 64 | - | ||
| 65 | - if (!taskMap.has(taskId)) { | ||
| 66 | - const meta = taskMetas.get(taskId); | ||
| 67 | - taskMap.set(taskId, { | ||
| 68 | - taskId, | ||
| 69 | - title: c.meta.title || taskId, | ||
| 70 | - createdAt: c.meta.createdAt || new Date().toISOString(), | ||
| 71 | - updatedAt: c.meta.updatedAt || c.meta.createdAt, | ||
| 72 | - status: meta?.status || c.meta.status || 'unknown', | ||
| 73 | - from: meta?.from || null, | ||
| 74 | - to: meta?.to || null, | ||
| 75 | - parentTaskId: meta?.parentTaskId || null, | ||
| 76 | - agents: new Set(), | ||
| 77 | - conversations: [], | ||
| 78 | - children: [], | ||
| 79 | - }); | ||
| 80 | - } | ||
| 81 | - | ||
| 82 | - const t = taskMap.get(taskId); | ||
| 83 | - t.agents.add(`${c.companyId}:${c.agentId}`); | ||
| 84 | - t.conversations.push(c); | ||
| 85 | - | ||
| 86 | - if (c.meta.createdAt && new Date(c.meta.createdAt) < new Date(t.createdAt)) t.createdAt = c.meta.createdAt; | ||
| 87 | - if (c.meta.updatedAt && new Date(c.meta.updatedAt) > new Date(t.updatedAt)) t.updatedAt = c.meta.updatedAt; | ||
| 88 | - } | ||
| 89 | - | ||
| 90 | - // 第二遍:推断父子关系 | ||
| 91 | - this._inferParentChild(taskMap, taskMetas); | ||
| 92 | - | ||
| 93 | - // 第三遍:构建 children 列表 | ||
| 94 | - for (const t of taskMap.values()) { | ||
| 95 | - if (t.parentTaskId && taskMap.has(t.parentTaskId)) { | ||
| 96 | - const parent = taskMap.get(t.parentTaskId); | ||
| 97 | - if (!parent.children.includes(t.taskId)) { | ||
| 98 | - parent.children.push(t.taskId); | ||
| 99 | - } | ||
| 100 | - } | ||
| 101 | - } | ||
| 102 | - | ||
| 103 | - const tasks = Array.from(taskMap.values()).map(t => ({ | ||
| 104 | - ...t, | ||
| 105 | - agents: Array.from(t.agents), | ||
| 106 | - isRoot: !t.parentTaskId || !taskMap.has(t.parentTaskId), | ||
| 107 | - conversationCount: t.conversations.length, | 56 | + const wdb = require('./work-graph-db'); |
| 57 | + const rows = wdb.getDb().prepare(` | ||
| 58 | + SELECT * FROM tasks WHERE archived_at IS NULL ORDER BY created_at DESC | ||
| 59 | + `).all(); | ||
| 60 | + | ||
| 61 | + // 表里的取值域是 tasks 的,而这个端点历史上吐的是 meta 的(dispatched/done)。 | ||
| 62 | + // 前端按字符串比对状态,所以这里翻译回去 —— 换掉存储不该让界面跟着改。 | ||
| 63 | + const TO_META = { running: 'dispatched', completed: 'done', queued: 'dispatched', draft: 'dispatched' }; | ||
| 64 | + const childrenOf = new Map(); | ||
| 65 | + for (const r of rows) { | ||
| 66 | + if (!r.parent_task_id) continue; | ||
| 67 | + if (!childrenOf.has(r.parent_task_id)) childrenOf.set(r.parent_task_id, []); | ||
| 68 | + childrenOf.get(r.parent_task_id).push(r.id); | ||
| 69 | + } | ||
| 70 | + | ||
| 71 | + return rows.map((r) => ({ | ||
| 72 | + taskId: r.id, | ||
| 73 | + title: r.title, | ||
| 74 | + createdAt: r.created_at, | ||
| 75 | + updatedAt: r.updated_at, | ||
| 76 | + status: TO_META[r.status] || r.status, | ||
| 77 | + from: r.created_by, | ||
| 78 | + to: r.assignee_agent_id, | ||
| 79 | + parentTaskId: r.parent_task_id, | ||
| 80 | + children: childrenOf.get(r.id) || [], | ||
| 81 | + isRoot: !r.parent_task_id, | ||
| 82 | + // agents:原来是「会话里出现过的 agent 集合」,现在是「派给谁 + 谁派的」。 | ||
| 83 | + // 对派单任务这两者本来就是同一件事;图任务没有派单对象,只有 created_by。 | ||
| 84 | + agents: [r.created_by, r.assignee_agent_id].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i), | ||
| 85 | + // conversationCount:原来是扫出来的会话文件数,现在是「有没有起过执行会话」(0/1)。 | ||
| 86 | + // 为一个列表字段去遍历所有 agent 的会话目录,正是这次要去掉的那笔开销; | ||
| 87 | + // 真要看会话内容走 /api/tasks/:taskId/timeline,那条仍读一手记录。 | ||
| 88 | + conversationCount: r.execution_conv_id ? 1 : 0, | ||
| 108 | })); | 89 | })); |
| 109 | - | ||
| 110 | - tasks.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); | ||
| 111 | - return tasks; | ||
| 112 | } | 90 | } |
| 113 | 91 | ||
| 114 | - /** | ||
| 115 | - * 从 conversationId 提取逻辑 taskId | ||
| 116 | - */ | ||
| 117 | _extractTaskId(convId) { | 92 | _extractTaskId(convId) { |
| 118 | // "task-T-xxx" / "callback-T-xxx" → "T-xxx" | 93 | // "task-T-xxx" / "callback-T-xxx" → "T-xxx" |
| 119 | if (convId.includes('-T-')) { | 94 | if (convId.includes('-T-')) { |
| @@ -124,142 +99,15 @@ class TaskService { | @@ -124,142 +99,15 @@ class TaskService { | ||
| 124 | return convId; | 99 | return convId; |
| 125 | } | 100 | } |
| 126 | 101 | ||
| 102 | + | ||
| 127 | /** | 103 | /** |
| 128 | - * 多策略推断父子关系: | ||
| 129 | - * | ||
| 130 | - * 策略1: .tasks/ meta 中的 parentTaskId(直接有的情况) | ||
| 131 | - * 策略2: 通过 "from" agent 的 活跃会话 推断 | ||
| 132 | - * - T-xxx 的 meta.from = "innovator" | ||
| 133 | - * - innovator 同时有一个 tpl-train-* 会话(时间上包含 T-xxx) | ||
| 134 | - * → tpl-train-* 就是 T-xxx 的父任务 | ||
| 135 | - * 策略3: 通过 master conv 触发 tpl-train 推断 | ||
| 136 | - * - conv-xxx (master) 的时间 < tpl-train-xxx 的时间 且 title 包含相同模板名 | ||
| 137 | - * → conv-xxx 是 tpl-train-xxx 的父任务 | 104 | + * 某条任务(含子任务)的跨 agent 时间线。 |
| 105 | + * | ||
| 106 | + * 批 5 之后这里是**两个来源合起来**,刻意分清: | ||
| 107 | + * · 任务树(有哪些子任务)→ `tasks` 表的 `parent_task_id`,显式、可验证; | ||
| 108 | + * · 时间线正文 → agent 的 `conversations/` 目录,那是**一手记录**,没有替代品。 | ||
| 109 | + * 删掉的只是「从会话反推任务树」那一半,不是会话本身。 | ||
| 138 | */ | 110 | */ |
| 139 | - _inferParentChild(taskMap, taskMetas) { | ||
| 140 | - const allTasks = Array.from(taskMap.values()); | ||
| 141 | - | ||
| 142 | - // 策略1: 已有 parentTaskId (来自 .tasks/ meta),已在构建时设置 | ||
| 143 | - | ||
| 144 | - // 策略2: T-xxx 任务,推断父任务为 from agent 的活跃 tpl-train/task 会话 | ||
| 145 | - for (const t of allTasks) { | ||
| 146 | - if (t.parentTaskId) continue; | ||
| 147 | - if (!t.taskId.startsWith('T-')) continue; | ||
| 148 | - | ||
| 149 | - const meta = taskMetas.get(t.taskId); | ||
| 150 | - if (!meta) continue; | ||
| 151 | - | ||
| 152 | - const fromAgent = meta.from; | ||
| 153 | - const taskCreatedAt = new Date(meta.createdAt); | ||
| 154 | - | ||
| 155 | - // 找 fromAgent 的其他会话,时间上在这个 task 之前开始且之后结束/仍活跃 | ||
| 156 | - let bestParent = null; | ||
| 157 | - let bestTimeDiff = Infinity; | ||
| 158 | - | ||
| 159 | - for (const candidate of allTasks) { | ||
| 160 | - if (candidate.taskId === t.taskId) continue; | ||
| 161 | - if (candidate.taskId.startsWith('T-') && !taskMetas.has(candidate.taskId)) continue; | ||
| 162 | - | ||
| 163 | - const hasAgent = candidate.conversations.some(c => c.agentId === fromAgent); | ||
| 164 | - if (!hasAgent) continue; | ||
| 165 | - | ||
| 166 | - const candidateCreated = new Date(candidate.createdAt); | ||
| 167 | - const candidateUpdated = new Date(candidate.updatedAt); | ||
| 168 | - | ||
| 169 | - if (candidateCreated < taskCreatedAt && candidateUpdated >= taskCreatedAt) { | ||
| 170 | - const diff = taskCreatedAt - candidateCreated; | ||
| 171 | - if (diff < bestTimeDiff) { | ||
| 172 | - bestTimeDiff = diff; | ||
| 173 | - bestParent = candidate; | ||
| 174 | - } | ||
| 175 | - } | ||
| 176 | - } | ||
| 177 | - | ||
| 178 | - if (bestParent) { | ||
| 179 | - t.parentTaskId = bestParent.taskId; | ||
| 180 | - // console.log(`[TaskService] 🔗 Inferred parent: ${t.taskId} → ${bestParent.taskId}`); | ||
| 181 | - } | ||
| 182 | - } | ||
| 183 | - | ||
| 184 | - // 策略4: T-xxx from master,但 master 的 conv-* 已经结束的情况 | ||
| 185 | - // 这种情况发生在:master 原始对话结束后,通过 callback 链路继续派发新任务 | ||
| 186 | - // 例如 conv-xxx → tpl-train → T-a (callback → master → 新 dispatch T-b) | ||
| 187 | - // 此时 T-b 应该关联到 conv-xxx(同一个用户请求) | ||
| 188 | - for (const t of allTasks) { | ||
| 189 | - if (t.parentTaskId) continue; | ||
| 190 | - if (!t.taskId.startsWith('T-')) continue; | ||
| 191 | - | ||
| 192 | - const meta = taskMetas.get(t.taskId); | ||
| 193 | - if (!meta || meta.from !== 'master') continue; | ||
| 194 | - | ||
| 195 | - const taskCreatedAt = new Date(meta.createdAt); | ||
| 196 | - | ||
| 197 | - // 找最近的 master conv-* 会话,窗口放宽到 30 分钟 | ||
| 198 | - let bestConv = null; | ||
| 199 | - let bestDiff = 30 * 60 * 1000; | ||
| 200 | - | ||
| 201 | - for (const candidate of allTasks) { | ||
| 202 | - if (!candidate.taskId.startsWith('conv-')) continue; | ||
| 203 | - if (!candidate.conversations.some(c => c.agentId === 'master')) continue; | ||
| 204 | - | ||
| 205 | - const cCreated = new Date(candidate.createdAt); | ||
| 206 | - const diff = taskCreatedAt - cCreated; | ||
| 207 | - | ||
| 208 | - if (diff > 0 && diff < bestDiff) { | ||
| 209 | - bestDiff = diff; | ||
| 210 | - bestConv = candidate; | ||
| 211 | - } | ||
| 212 | - } | ||
| 213 | - | ||
| 214 | - if (bestConv) { | ||
| 215 | - t.parentTaskId = bestConv.taskId; | ||
| 216 | - // console.log(`[TaskService] 🔗 Inferred parent (master→T): ${t.taskId} → ${bestConv.taskId}`); | ||
| 217 | - } | ||
| 218 | - } | ||
| 219 | - | ||
| 220 | - // 策略3: tpl-train-* / tpl-gen-* / task-* 与 conv-* (master) 的关联 | ||
| 221 | - // master 的 conv-xxx 在时间上紧接着触发了 tpl-train-xxx / tpl-gen-xxx | ||
| 222 | - for (const t of allTasks) { | ||
| 223 | - if (t.parentTaskId) continue; | ||
| 224 | - | ||
| 225 | - const isTemplateTask = t.taskId.startsWith('tpl-train-') || t.taskId.startsWith('train-') || t.taskId.startsWith('tpl-gen-'); | ||
| 226 | - if (!isTemplateTask) continue; | ||
| 227 | - | ||
| 228 | - const tCreated = new Date(t.createdAt); | ||
| 229 | - | ||
| 230 | - // 找 master 的 conv-xxx,时间上在此刻之前且间隔小于 15 分钟 | ||
| 231 | - let bestConv = null; | ||
| 232 | - let bestDiff = 15 * 60 * 1000; // 15分钟窗口 | ||
| 233 | - | ||
| 234 | - for (const candidate of allTasks) { | ||
| 235 | - if (!candidate.taskId.startsWith('conv-')) continue; | ||
| 236 | - // 必须有 master 参与 | ||
| 237 | - if (!candidate.conversations.some(c => c.agentId === 'master')) continue; | ||
| 238 | - | ||
| 239 | - const cCreated = new Date(candidate.createdAt); | ||
| 240 | - const diff = tCreated - cCreated; | ||
| 241 | - | ||
| 242 | - if (diff > 0 && diff < bestDiff) { | ||
| 243 | - // 额外检查:title 是否有模板名关联 | ||
| 244 | - const templateName = t.taskId.replace(/^(tpl-train-|train-|tpl-gen-)/, '').replace(/-[a-z0-9]+$/, ''); | ||
| 245 | - if (candidate.title && candidate.title.toLowerCase().includes(templateName.replace(/_/g, ' ').split(' ')[0])) { | ||
| 246 | - bestDiff = diff; | ||
| 247 | - bestConv = candidate; | ||
| 248 | - } else if (!bestConv) { | ||
| 249 | - // 如果没有更好的匹配,用时间最近的 | ||
| 250 | - bestDiff = diff; | ||
| 251 | - bestConv = candidate; | ||
| 252 | - } | ||
| 253 | - } | ||
| 254 | - } | ||
| 255 | - | ||
| 256 | - if (bestConv) { | ||
| 257 | - t.parentTaskId = bestConv.taskId; | ||
| 258 | - // console.log(`[TaskService] 🔗 Inferred parent (conv→tpl): ${t.taskId} → ${bestConv.taskId}`); | ||
| 259 | - } | ||
| 260 | - } | ||
| 261 | - } | ||
| 262 | - | ||
| 263 | getTaskTimeline(filterTaskId, includeChildren = true) { | 111 | getTaskTimeline(filterTaskId, includeChildren = true) { |
| 264 | const tasks = this.getAggregatedTasks(); | 112 | const tasks = this.getAggregatedTasks(); |
| 265 | const taskGroup = tasks.find(t => t.taskId === filterTaskId); | 113 | const taskGroup = tasks.find(t => t.taskId === filterTaskId); |
| @@ -270,12 +118,9 @@ class TaskService { | @@ -270,12 +118,9 @@ class TaskService { | ||
| 270 | this._collectDescendants(filterTaskId, tasks, taskIds); | 118 | this._collectDescendants(filterTaskId, tasks, taskIds); |
| 271 | } | 119 | } |
| 272 | 120 | ||
| 273 | - const relevantConvs = []; | ||
| 274 | - for (const t of tasks) { | ||
| 275 | - if (taskIds.has(t.taskId)) { | ||
| 276 | - relevantConvs.push(...t.conversations); | ||
| 277 | - } | ||
| 278 | - } | 121 | + // 会话按「id 里带的 T-xxx」归到任务上(task-T-xxx / callback-T-xxx 都算) |
| 122 | + const relevantConvs = this.getAllConversations() | ||
| 123 | + .filter((c) => taskIds.has(this._extractTaskId(c.convId))); | ||
| 279 | 124 | ||
| 280 | let timeline = []; | 125 | let timeline = []; |
| 281 | 126 |
server/tests/unit/task-dispatch-store.test.js
| @@ -92,6 +92,57 @@ describe('派单存储换成 tasks 表', () => { | @@ -92,6 +92,57 @@ describe('派单存储换成 tasks 表', () => { | ||
| 92 | expect(row.finished_at).toBeTruthy(); | 92 | expect(row.finished_at).toBeTruthy(); |
| 93 | }); | 93 | }); |
| 94 | 94 | ||
| 95 | + it('派单不被调度器碰 —— 它没有 run 行,出队会把它误收成 cancelled', async () => { | ||
| 96 | + for (const m of ['../../services/task-scheduler']) delete require_.cache[require_.resolve(m)]; | ||
| 97 | + const scheduler = require_('../../services/task-scheduler'); | ||
| 98 | + | ||
| 99 | + const d = await post('/api/agent/task/dispatch', { | ||
| 100 | + from: FROM, to: TO, task: '守卫用例:调度器别碰我', autoExecute: false, | ||
| 101 | + }); | ||
| 102 | + const taskId = d.body.taskId; | ||
| 103 | + const row0 = wdb.getDb().prepare('SELECT * FROM tasks WHERE id = ?').get(taskId); | ||
| 104 | + expect(row0.executor).toBe('agent'); | ||
| 105 | + | ||
| 106 | + // 把它摆成「最像可出队」的样子:queued + auto + 无依赖。只有 executor 这一维拦得住它。 | ||
| 107 | + wdb.getDb().prepare("UPDATE tasks SET status='queued', execution_mode='auto' WHERE id=?").run(taskId); | ||
| 108 | + | ||
| 109 | + expect(scheduler.listQueue(row0.company_id).map((t) => t.id)).not.toContain(taskId); | ||
| 110 | + scheduler.tick(row0.company_id); | ||
| 111 | + scheduler.sweep(); | ||
| 112 | + | ||
| 113 | + // 关键断言:没有被出队、更没有被 abandonRun 收成 cancelled | ||
| 114 | + const row1 = wdb.getDb().prepare('SELECT * FROM tasks WHERE id = ?').get(taskId); | ||
| 115 | + expect(row1.status).toBe('queued'); | ||
| 116 | + expect(row1.current_run_id).toBeFalsy(); | ||
| 117 | + }); | ||
| 118 | + | ||
| 119 | + // 曾经的缺陷:report 的自唤醒回调写死 port 3010,跑一次测试就往线上发一条 CEO chat、 | ||
| 120 | + // 起一个常驻 claude CLI 会话、并留下 system/agents/ceo/_global/callback-T-*。 | ||
| 121 | + it('测试环境下汇报不发对外 HTTP,也不新建 callback 目录', async () => { | ||
| 122 | + const http = require_('http'); | ||
| 123 | + const orig = http.request; | ||
| 124 | + const calls = []; | ||
| 125 | + http.request = function (...args) { calls.push(args[0]); return orig.apply(this, args); }; | ||
| 126 | + const ceoState = path.resolve(process.cwd(), 'system', 'agents', 'ceo', '_global'); | ||
| 127 | + const cbBefore = fs.existsSync(ceoState) | ||
| 128 | + ? fs.readdirSync(ceoState).filter((n) => n.startsWith('callback-')) : []; | ||
| 129 | + try { | ||
| 130 | + const d = await post('/api/agent/task/dispatch', { | ||
| 131 | + from: FROM, to: TO, task: '守卫用例:回调不外发', autoExecute: false, | ||
| 132 | + }); | ||
| 133 | + await post('/api/agent/task/report', { | ||
| 134 | + taskId: d.body.taskId, from: TO, to: FROM, status: 'done', summary: '干完了', | ||
| 135 | + }); | ||
| 136 | + await new Promise((r) => setTimeout(r, 200)); // 回调走 setImmediate,给它跑的机会 | ||
| 137 | + expect(calls).toEqual([]); | ||
| 138 | + } finally { | ||
| 139 | + http.request = orig; | ||
| 140 | + } | ||
| 141 | + const cbAfter = fs.existsSync(ceoState) | ||
| 142 | + ? fs.readdirSync(ceoState).filter((n) => n.startsWith('callback-')) : []; | ||
| 143 | + expect(cbAfter).toEqual(cbBefore); | ||
| 144 | + }); | ||
| 145 | + | ||
| 95 | it('不再往 system/tasklog/ 写新文件', async () => { | 146 | it('不再往 system/tasklog/ 写新文件', async () => { |
| 96 | const dir = path.resolve(process.cwd(), 'system', 'tasklog'); | 147 | const dir = path.resolve(process.cwd(), 'system', 'tasklog'); |
| 97 | const before = fs.existsSync(dir) ? fs.readdirSync(dir).length : 0; | 148 | const before = fs.existsSync(dir) ? fs.readdirSync(dir).length : 0; |