Commit 12880b86207bf7b354cefe25a8707b6e23fac9cf

Authored by yibo
1 parent b9495f57

feat(27-P3C): 批 5 第三步 —— /task/dispatch 的存储换成 tasks 表

**本文件除了 saveTaskMeta / loadTaskMeta 的内脏和两处扫目录,其余一行没动。**
/task/dispatch 与 /task/report 的 URL、请求体、响应体形态全部原样 —— 它们
印在 packages/agent-core/base/common/tools-base.md 里,是所有 agent 的工具
说明,改一个字等于同时改所有 agent 的提示词。

## 换掉文件存储的三个理由
- 去重要「readdirSync 整个 tasklog + 逐个 JSON.parse」,任务一多就是 O(n) 次
  磁盘读,且没有索引可用
- 派单任务在库里看不见 —— 控制台任务列表读 tasks 表,派单那套是平行世界
- 同一个 T- 前缀两套 id 体系,读日志时分不清

## 落点
- saveTaskMeta / loadTaskMeta 变成 tasks 表的适配器(upsert + 反向映射),
  meta 的形状一个字段不少:调用方按字段名取值,形状变了就是契约变了
- 去重与父任务推断改成带索引的 SQL(idx_tasks_assignee),**判据一个字没改**,
  换的只是从哪儿把候选捞出来。查不动时留声不静默 —— 静默跳过等于去重形同虚设
- 加 summary / execution_conv_id 两列(用户选的方案 A)
- sourceConvId **删掉**:全仓零读取方,正是本仓库反复立案的「实现在、零调用方」,
  不往新表里再搬一份
- objectiveId 不单独存:它恒等于 okr node id 的首段(OkrService.parseNodeId 与
  reportRunOutcome 同一口径),且两处读取方都是 `if (objId && meta.nodeId)`

## 真打过一遍,不只是测试绿
照 tools-base.md 印的请求体原样 POST:200 + 老形态响应;再打一次 → 409 且
existingTaskId 正确;落库 executor='agent' / assignee=qa-judge / status=running,
自动建的 goal 标题「来自 ceo 的派单」、okr_node_id 落对;/task/report 之后
status→completed、summary 与 finished_at 都在;loadTaskMeta 往返出来的形状与
老 JSON 逐字一致,objectiveId 从 nodeId 首段推出 O-001;system/tasklog/ 不再
产生新文件(仍是回填前那 4 个)。

守卫:server/tests/unit/task-dispatch-store.test.js(4 例,盯的是「形状没变」)

全量:132 文件 / 1255 测试全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
server/routes/agent-chat.js
... ... @@ -1659,17 +1659,110 @@ router.post(&#39;/scaffold&#39;, async (req, res) =&gt; {
1659 1659 * autoExecute: true, // 是否自动异步触发执行
1660 1660 * }
1661 1661 */
1662   -// ── Task 元数据持久化目录 ──
1663   -const TASK_META_DIR = path.resolve(__dirname, '../../system/tasklog');
1664   -if (!fs.existsSync(TASK_META_DIR)) fs.mkdirSync(TASK_META_DIR, { recursive: true });
  1662 +// ── Task 元数据:存储换成 tasks 表(27-P3C 批 5 §十二)─────────────────
  1663 +//
  1664 +// **本文件除了这两个函数的内脏,其余一行都没动。** `/task/dispatch` 与 `/task/report` 的
  1665 +// URL、请求体、响应体形态全部原样 —— 它们印在 packages/agent-core/base/common/tools-base.md
  1666 +// 里,是**所有 agent 的工具说明**,改一个字等于同时改所有 agent 的提示词。
  1667 +//
  1668 +// 原来的存储是 system/tasklog/{taskId}.json 一任务一文件。换掉它的三个理由:
  1669 +// · 去重要「扫整个目录 + 逐个 JSON.parse」,任务一多就是 O(n) 次磁盘读,且没有索引可用;
  1670 +// · 派单任务在库里看不见 —— 控制台的任务列表读的是 tasks 表,派单那套完全是平行世界;
  1671 +// · 同一个 T- 前缀两套 id 体系,读日志时分不清。
  1672 +//
  1673 +// **meta 的形状不变**(调用方按字段名取值),只是落点从文件换成表。
  1674 +const DISPATCH_COMPANY = '_global'; // 派单没有 company 这一维,全局 agent 的保留 id(28 §5.1)
  1675 +
  1676 +/** meta.status ↔ tasks.status。两边取值域不同,来回都要过一次翻译 */
  1677 +const META_TO_TASK_STATUS = { dispatched: 'running', done: 'completed', failed: 'failed', blocked: 'blocked', cancelled: 'cancelled' };
  1678 +const TASK_TO_META_STATUS = { running: 'dispatched', completed: 'done', queued: 'dispatched', draft: 'dispatched' };
  1679 +
  1680 +/** 正文首行当标题;派单正文把换行写成 <br>,先还原再取 */
  1681 +function dispatchTitle(task) {
  1682 + const line = String(task || '').replace(/<br\s*\/?>/gi, '\n').split('\n')[0].trim();
  1683 + if (!line) return '(无标题派单)';
  1684 + return line.length > 60 ? `${line.slice(0, 60)}…` : line;
  1685 +}
1665 1686  
1666 1687 function saveTaskMeta(taskId, meta) {
1667   - fs.writeFileSync(path.join(TASK_META_DIR, `${taskId}.json`), JSON.stringify(meta, null, 2), 'utf-8');
  1688 + const wdb = require('../services/work-graph-db');
  1689 + const db = wdb.getDb();
  1690 + const now = new Date().toISOString();
  1691 + const status = META_TO_TASK_STATUS[meta.status] || 'running';
  1692 + const existing = db.prepare('SELECT id, goal_id FROM tasks WHERE id = ?').get(taskId);
  1693 +
  1694 + if (existing) {
  1695 + // 更新路径:/task/report 的写法是「loadTaskMeta → 改几个字段 → 整个对象写回」,
  1696 + // 所以这里是全量 upsert 而不是增量 patch。
  1697 + db.prepare(`
  1698 + UPDATE tasks SET status = @status, summary = @summary, finished_at = @done,
  1699 + description = @desc, priority = @prio, updated_at = @now
  1700 + WHERE id = @id
  1701 + `).run({
  1702 + id: taskId, status, summary: meta.summary || null, done: meta.completedAt || null,
  1703 + desc: String(meta.task || ''), prio: meta.priority || 'P1', now,
  1704 + });
  1705 + return;
  1706 + }
  1707 +
  1708 + // 新建路径:每条派单自带一个 goal —— tasks.goal_id 是 NOT NULL 真外键,而派单没有
  1709 + // 「人提的需求」那一层。与 §六.3 给 POST /:cid/tasks 设计的兼容路径同一条,
  1710 + // 不给 goal_id 开可空的口子。
  1711 + const goalId = wdb.genGoalId();
  1712 + const createdAt = meta.createdAt || now;
  1713 + db.prepare(`
  1714 + INSERT INTO goals (id, company_id, title, description, status, priority,
  1715 + okr_node_id, created_by, report_to, created_at, updated_at, dispatched_at)
  1716 + VALUES (@id, @cid, @title, @desc, 'active', @prio, @okr, @by, @by, @at, @at, @at)
  1717 + `).run({
  1718 + id: goalId, cid: DISPATCH_COMPANY, title: `来自 ${meta.from || '?'} 的派单`,
  1719 + desc: String(meta.task || ''), prio: meta.priority || 'P1',
  1720 + okr: meta.nodeId || null, by: meta.from || null, at: createdAt,
  1721 + });
  1722 + db.prepare(`
  1723 + INSERT INTO tasks (id, goal_id, company_id, seq, title, description, status,
  1724 + execution_mode, depends_on, attempt, priority, created_by,
  1725 + executor, assignee_agent_id, parent_task_id, summary, execution_conv_id,
  1726 + created_at, updated_at, queued_at, started_at)
  1727 + VALUES (@id, @gid, @cid, 1, @title, @desc, @status,
  1728 + 'manual', '[]', 1, @prio, @by,
  1729 + 'agent', @to, @parent, @summary, @conv,
  1730 + @at, @at, @at, @at)
  1731 + `).run({
  1732 + id: taskId, gid: goalId, cid: DISPATCH_COMPANY, title: dispatchTitle(meta.task),
  1733 + desc: String(meta.task || ''), status, prio: meta.priority || 'P1',
  1734 + by: meta.from || null, to: meta.to || null, parent: meta.parentTaskId || null,
  1735 + summary: meta.summary || null, conv: meta.executionConvId || null, at: createdAt,
  1736 + });
1668 1737 }
  1738 +
1669 1739 function loadTaskMeta(taskId) {
1670   - const fp = path.join(TASK_META_DIR, `${taskId}.json`);
1671   - if (!fs.existsSync(fp)) return null;
1672   - try { return JSON.parse(fs.readFileSync(fp, 'utf-8')); } catch { return null; }
  1740 + const wdb = require('../services/work-graph-db');
  1741 + const row = wdb.getDb().prepare(`
  1742 + SELECT t.*, g.okr_node_id FROM tasks t JOIN goals g ON g.id = t.goal_id WHERE t.id = ?
  1743 + `).get(taskId);
  1744 + if (!row) return null;
  1745 + // objectiveId 不单独存:它恒等于 okr node id 的首段(OkrService.parseNodeId 与
  1746 + // company.js:reportRunOutcome 都是这个口径),而且它只在与 nodeId 一起用时才有意义
  1747 + // (两处读取方都是 `if (objId && meta.nodeId)`)。
  1748 + const nodeId = row.okr_node_id || null;
  1749 + const objectiveId = nodeId ? String(nodeId).split('.')[0] : null;
  1750 + return {
  1751 + taskId: row.id,
  1752 + from: row.created_by,
  1753 + to: row.assignee_agent_id,
  1754 + task: row.description,
  1755 + priority: row.priority,
  1756 + objectiveId,
  1757 + goalId: objectiveId, // 旧字段名,保留供存量读取方兼容(27-NAMING §六)
  1758 + nodeId,
  1759 + parentTaskId: row.parent_task_id,
  1760 + executionConvId: row.execution_conv_id,
  1761 + createdAt: row.created_at,
  1762 + status: TASK_TO_META_STATUS[row.status] || row.status,
  1763 + completedAt: row.finished_at,
  1764 + summary: row.summary,
  1765 + };
1673 1766 }
1674 1767  
1675 1768 router.post('/task/dispatch', async (req, res) => {
... ... @@ -1688,33 +1781,40 @@ router.post(&#39;/task/dispatch&#39;, async (req, res) =&gt; {
1688 1781 const now = new Date().toISOString().slice(0, 16).replace('T', ' ');
1689 1782  
1690 1783 // ── 任务去重检查:相同接收方 30 分钟内是否有相似任务已分发 ──
  1784 + // 批 5:从「readdirSync 整个 tasklog + 逐个 JSON.parse」换成一条带索引的 SQL
  1785 + // (idx_tasks_assignee = company_id + assignee_agent_id + created_at)。
  1786 + // **判据本身一个字没改** —— 仍是「同一接收方 + 30 分钟内 + 仍在执行 + 正文互相包含」,
  1787 + // 换的只是从哪儿把候选捞出来。响应体形态也原样(agent 的提示词里印着它)。
1691 1788 const DUPLICATE_WINDOW_MS = 30 * 60 * 1000;
1692 1789 try {
1693   - const taskFiles = fs.readdirSync(TASK_META_DIR).filter(f => f.endsWith('.json'));
1694   - for (const tf of taskFiles) {
1695   - try {
1696   - const tm = JSON.parse(fs.readFileSync(path.join(TASK_META_DIR, tf), 'utf-8'));
1697   - if (tm.to === to && tm.status === 'dispatched') {
1698   - const age = Date.now() - new Date(tm.createdAt).getTime();
1699   - if (age < DUPLICATE_WINDOW_MS) {
1700   - const normNew = task.toLowerCase().replace(/[\s\n\r]+/g, ' ').trim();
1701   - const normExist = (tm.task || '').toLowerCase().replace(/<br>/g, ' ').replace(/[\s\n\r]+/g, ' ').trim();
1702   - const isDuplicate = normNew === normExist || normNew.includes(normExist) || normExist.includes(normNew);
1703   - if (isDuplicate) {
1704   - console.log(`[TaskDispatch] 🚫 去重拦截: ${from} → ${to} 与 ${tm.taskId} 重复`);
1705   - return res.status(409).json({
1706   - success: false,
1707   - error: 'Duplicate task detected',
1708   - existingTaskId: tm.taskId,
1709   - existingTask: tm.task,
1710   - message: `去重拦截:${to} 在 30 分钟内已有相似任务 ${tm.taskId} 处于进行中,请勿重复分发。`,
1711   - });
1712   - }
1713   - }
1714   - }
1715   - } catch(e) {}
  1790 + const wdb = require('../services/work-graph-db');
  1791 + const since = new Date(Date.now() - DUPLICATE_WINDOW_MS).toISOString();
  1792 + const rows = wdb.getDb().prepare(`
  1793 + SELECT id, description FROM tasks
  1794 + WHERE company_id = @cid AND assignee_agent_id = @to
  1795 + AND executor = 'agent' AND status = 'running'
  1796 + AND created_at > @since AND archived_at IS NULL
  1797 + ORDER BY created_at DESC
  1798 + `).all({ cid: DISPATCH_COMPANY, to, since });
  1799 + const normNew = task.toLowerCase().replace(/[\s\n\r]+/g, ' ').trim();
  1800 + for (const r of rows) {
  1801 + const normExist = String(r.description || '').toLowerCase()
  1802 + .replace(/<br>/g, ' ').replace(/[\s\n\r]+/g, ' ').trim();
  1803 + if (normNew === normExist || normNew.includes(normExist) || normExist.includes(normNew)) {
  1804 + console.log(`[TaskDispatch] 🚫 去重拦截: ${from} → ${to} 与 ${r.id} 重复`);
  1805 + return res.status(409).json({
  1806 + success: false,
  1807 + error: 'Duplicate task detected',
  1808 + existingTaskId: r.id,
  1809 + existingTask: r.description,
  1810 + message: `去重拦截:${to} 在 30 分钟内已有相似任务 ${r.id} 处于进行中,请勿重复分发。`,
  1811 + });
  1812 + }
1716 1813 }
1717   - } catch(e) {}
  1814 + } catch (e) {
  1815 + // 去重查不动不该让派单整个失败,但**必须留声** —— 静默跳过等于去重形同虚设
  1816 + console.error(`[TaskDispatch] ⚠️ 去重检查失败,本次放行: ${e.message}`);
  1817 + }
1718 1818  
1719 1819 // 净化多行文本和竖线,防止破坏 Markdown 表格格式
1720 1820 const safeTask = task.replace(/\n/g, '<br>').replace(/\|/g, '&#124;');
... ... @@ -1723,28 +1823,25 @@ router.post(&#39;/task/dispatch&#39;, async (req, res) =&gt; {
1723 1823 // 如果调用者没有显式传 parentTaskId,则尝试从 .tasks/ 目录下
1724 1824 // 查找 from agent 正在执行的(status=dispatched 且 to=from)的 task
1725 1825 let parentTaskId = explicitParent || null;
1726   - let sourceConvId = null;
1727 1826 if (!parentTaskId) {
  1827 + // 批 5:同样从扫目录换成一条 SQL,判据不变 ——「派给 from 且仍在执行」里最近的一条,
  1828 + // 那就是 from 此刻正在干的活,新派的这条挂在它下面。排序交给 SQL(原来是把全部候选
  1829 + // 读进内存再 sort,目录一大就是白读一遍)。
1728 1830 try {
1729   - const taskFiles = fs.readdirSync(TASK_META_DIR).filter(f => f.endsWith('.json'));
1730   - // 找到 "to === from" 且尚未完成的任务 → 这就是 from agent 正在执行的父任务
1731   - let candidates = [];
1732   - for (const tf of taskFiles) {
1733   - try {
1734   - const tm = JSON.parse(fs.readFileSync(path.join(TASK_META_DIR, tf), 'utf-8'));
1735   - if (tm.to === from && tm.status === 'dispatched') {
1736   - candidates.push(tm);
1737   - }
1738   - } catch(e) {}
1739   - }
1740   - // 如果有多个,取最近创建的
1741   - if (candidates.length > 0) {
1742   - candidates.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
1743   - parentTaskId = candidates[0].taskId;
1744   - sourceConvId = candidates[0].executionConvId || null;
  1831 + const wdb = require('../services/work-graph-db');
  1832 + const parent = wdb.getDb().prepare(`
  1833 + SELECT id FROM tasks
  1834 + WHERE company_id = @cid AND assignee_agent_id = @from
  1835 + AND executor = 'agent' AND status = 'running' AND archived_at IS NULL
  1836 + ORDER BY created_at DESC LIMIT 1
  1837 + `).get({ cid: DISPATCH_COMPANY, from });
  1838 + if (parent) {
  1839 + parentTaskId = parent.id;
1745 1840 console.log(`[TaskDispatch] 🔗 Auto-detected parentTaskId: ${parentTaskId} (${from} is executing it)`);
1746 1841 }
1747   - } catch(e) { /* ignore */ }
  1842 + } catch (e) {
  1843 + console.error(`[TaskDispatch] ⚠️ 父任务推断失败,本次不挂父: ${e.message}`);
  1844 + }
1748 1845 }
1749 1846  
1750 1847 // 1. 写入目标 Agent 的 todo.md(公司态:`co-alpha:ceo` 与 `co-beta:ceo` 各写各的)
... ... @@ -1788,7 +1885,6 @@ router.post(&#39;/task/dispatch&#39;, async (req, res) =&gt; {
1788 1885 goalId: objectiveId || null, // 旧字段名,保留供存量读取方兼容
1789 1886 nodeId: nodeId || null,
1790 1887 parentTaskId: parentTaskId || null,
1791   - sourceConvId: sourceConvId || null,
1792 1888 executionConvId,
1793 1889 createdAt: new Date().toISOString(),
1794 1890 status: 'dispatched',
... ...
server/services/work-graph-db.js
... ... @@ -504,6 +504,11 @@ function getDb() {
504 504 executor TEXT NOT NULL DEFAULT 'graph'
505 505 CHECK (executor IN ('graph','agent')),
506 506 assignee_agent_id TEXT, -- executor='agent' 时派给谁(dispatch 的 to)
  507 + -- 下面两列只对 executor='agent' 有意义(图任务用 current_run_id / task-goal.json):
  508 + summary TEXT, -- 汇报摘要(/task/report 的 summary)
  509 + execution_conv_id TEXT, -- 派单起的那次对话 id(task-{taskId})
  510 + -- ⚠️ 旧 meta 里还有个 sourceConvId,全仓**零读取方**,借这次统一删掉 ——
  511 + -- 「实现在、零调用方」是本仓库反复立案的惯犯模式,不再往新表里搬一份。
507 512 -- 派单链:A 让 B 干活、B 又拆给 C。与 depends_on 不是一回事 ——
508 513 -- depends_on 是同级任务之间的先后,parent_task_id 是上下级的派生。
509 514 parent_task_id TEXT REFERENCES tasks(id),
... ... @@ -559,6 +564,12 @@ function getDb() {
559 564 if (!hasColumn(db, 'tasks', 'parent_task_id')) {
560 565 db.exec(`ALTER TABLE tasks ADD COLUMN parent_task_id TEXT`);
561 566 }
  567 + if (!hasColumn(db, 'tasks', 'summary')) {
  568 + db.exec(`ALTER TABLE tasks ADD COLUMN summary TEXT`);
  569 + }
  570 + if (!hasColumn(db, 'tasks', 'execution_conv_id')) {
  571 + db.exec(`ALTER TABLE tasks ADD COLUMN execution_conv_id TEXT`);
  572 + }
562 573 // ⚠️ 这条索引**必须建在 ALTER 之后**,不能放进上面的建表块:
563 574 // 建表块整体先于迁移块执行,而存量库上 `CREATE TABLE IF NOT EXISTS tasks` 是 no-op ——
564 575 // 表还是批 4 的形态、没有 assignee_agent_id,那一句 CREATE INDEX 会直接抛
... ... @@ -1760,6 +1771,8 @@ const TASK_PATCH_COLUMNS = {
1760 1771 startedAt: 'started_at',
1761 1772 finishedAt: 'finished_at',
1762 1773 archivedAt: 'archived_at',
  1774 + summary: 'summary',
  1775 + executionConvId: 'execution_conv_id',
1763 1776 };
1764 1777  
1765 1778 /**
... ...
server/tests/unit/task-dispatch-store.test.js 0 → 100644
  1 +/**
  2 + * 批 5 第 3 步:`/task/dispatch` 的存储从 system/tasklog/*.json 换成 tasks 表。
  3 + *
  4 + * **这一步唯一不能坏的是契约**:`/task/dispatch` 与 `/task/report` 的 URL、请求体、响应体
  5 + * 印在 `packages/agent-core/base/common/tools-base.md` 里,是所有 agent 的工具说明 ——
  6 + * 改一个字等于同时改所有 agent 的提示词。所以这份用例盯的是「形状没变」,不是「实现对不对」。
  7 + */
  8 +import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  9 +import { createRequire } from 'module';
  10 +import fs from 'fs';
  11 +import os from 'os';
  12 +import path from 'path';
  13 +const require_ = createRequire(import.meta.url);
  14 +
  15 +describe('派单存储换成 tasks 表', () => {
  16 + let wdb, app, srv, base;
  17 +
  18 + beforeAll(async () => {
  19 + process.env.GRAPH_DB_PATH = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'dispatch-store-')), 'graph.sqlite');
  20 + for (const m of ['../../services/work-graph-db', '../../services/graph-db', '../../routes/agent-chat']) {
  21 + delete require_.cache[require_.resolve(m)];
  22 + }
  23 + wdb = require_('../../services/work-graph-db');
  24 + const express = require_('express');
  25 + app = express();
  26 + app.use(express.json());
  27 + app.use('/api/agent', require_('../../routes/agent-chat'));
  28 + await new Promise((r) => { srv = app.listen(0, '127.0.0.1', r); });
  29 + base = `http://127.0.0.1:${srv.address().port}`;
  30 + });
  31 + afterAll(() => { try { srv?.close(); } catch {} });
  32 +
  33 + const post = async (p, body) => {
  34 + const r = await fetch(base + p, {
  35 + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
  36 + });
  37 + return { status: r.status, body: await r.json().catch(() => ({})) };
  38 + };
  39 +
  40 + /** 用真实存在的两个本体(端点会校验目录存在) */
  41 + const FROM = 'ceo';
  42 + const TO = 'qa-judge';
  43 +
  44 + it('派单落进 tasks 表,且响应体是老形态(agent 提示词里印着它)', async () => {
  45 + const r = await post('/api/agent/task/dispatch', {
  46 + from: FROM, to: TO, task: '守卫用例:形态检查', priority: 'P1',
  47 + goalId: 'O-900', nodeId: 'O-900.1.1', autoExecute: false,
  48 + });
  49 + expect(r.status).toBe(200);
  50 + // 响应体的字段一个都不能少 —— 少了就是改了所有 agent 的工具说明
  51 + for (const k of ['success', 'taskId', 'from', 'to', 'task', 'priority', 'autoExecute', 'message']) {
  52 + expect(r.body).toHaveProperty(k);
  53 + }
  54 + expect(r.body.success).toBe(true);
  55 +
  56 + const row = wdb.getDb().prepare('SELECT * FROM tasks WHERE id = ?').get(r.body.taskId);
  57 + expect(row).toBeTruthy();
  58 + expect(row.executor).toBe('agent'); // 调度器据此跳过它(图任务才排队)
  59 + expect(row.assignee_agent_id).toBe(TO);
  60 + expect(row.created_by).toBe(FROM);
  61 + expect(row.status).toBe('running'); // meta 的 dispatched ↔ 表的 running
  62 +
  63 + // 每条派单自带一个 goal:goal_id 是 NOT NULL 真外键,派单没有「人提的需求」那一层
  64 + const goal = wdb.getDb().prepare('SELECT * FROM goals WHERE id = ?').get(row.goal_id);
  65 + expect(goal.title).toContain(FROM);
  66 + expect(goal.okr_node_id).toBe('O-900.1.1'); // objectiveId 由它的首段推导,不单独存
  67 + });
  68 +
  69 + it('去重仍然拦得住,且 409 的响应体形态没变', async () => {
  70 + const body = { from: FROM, to: TO, task: '守卫用例:去重', autoExecute: false };
  71 + const first = await post('/api/agent/task/dispatch', body);
  72 + expect(first.status).toBe(200);
  73 + const dup = await post('/api/agent/task/dispatch', body);
  74 + expect(dup.status).toBe(409);
  75 + for (const k of ['success', 'error', 'existingTaskId', 'existingTask', 'message']) {
  76 + expect(dup.body).toHaveProperty(k);
  77 + }
  78 + expect(dup.body.existingTaskId).toBe(first.body.taskId);
  79 + });
  80 +
  81 + it('汇报回路:状态 / summary / 完成时间都落到表上', async () => {
  82 + const d = await post('/api/agent/task/dispatch', {
  83 + from: FROM, to: TO, task: '守卫用例:汇报回路', autoExecute: false,
  84 + });
  85 + const rep = await post('/api/agent/task/report', {
  86 + taskId: d.body.taskId, from: TO, to: FROM, status: 'done', summary: '干完了',
  87 + });
  88 + expect(rep.status).toBe(200);
  89 + const row = wdb.getDb().prepare('SELECT * FROM tasks WHERE id = ?').get(d.body.taskId);
  90 + expect(row.status).toBe('completed'); // meta 的 done ↔ 表的 completed
  91 + expect(row.summary).toBe('干完了');
  92 + expect(row.finished_at).toBeTruthy();
  93 + });
  94 +
  95 + it('不再往 system/tasklog/ 写新文件', async () => {
  96 + const dir = path.resolve(process.cwd(), 'system', 'tasklog');
  97 + const before = fs.existsSync(dir) ? fs.readdirSync(dir).length : 0;
  98 + await post('/api/agent/task/dispatch', { from: FROM, to: TO, task: '守卫用例:不写文件', autoExecute: false });
  99 + const after = fs.existsSync(dir) ? fs.readdirSync(dir).length : 0;
  100 + expect(after).toBe(before);
  101 + });
  102 +});
... ...