Commit 12880b86207bf7b354cefe25a8707b6e23fac9cf
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>
Showing
3 changed files
with
261 additions
and
50 deletions
server/routes/agent-chat.js
| ... | ... | @@ -1659,17 +1659,110 @@ router.post('/scaffold', async (req, res) => { |
| 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('/task/dispatch', async (req, res) => { |
| 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, '|'); |
| ... | ... | @@ -1723,28 +1823,25 @@ router.post('/task/dispatch', async (req, res) => { |
| 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('/task/dispatch', async (req, res) => { |
| 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 | +}); | ... | ... |