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,17 +1659,110 @@ router.post('/scaffold', async (req, res) => { | ||
| 1659 | * autoExecute: true, // 是否自动异步触发执行 | 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 | function saveTaskMeta(taskId, meta) { | 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 | function loadTaskMeta(taskId) { | 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 | router.post('/task/dispatch', async (req, res) => { | 1768 | router.post('/task/dispatch', async (req, res) => { |
| @@ -1688,33 +1781,40 @@ router.post('/task/dispatch', async (req, res) => { | @@ -1688,33 +1781,40 @@ router.post('/task/dispatch', async (req, res) => { | ||
| 1688 | const now = new Date().toISOString().slice(0, 16).replace('T', ' '); | 1781 | const now = new Date().toISOString().slice(0, 16).replace('T', ' '); |
| 1689 | 1782 | ||
| 1690 | // ── 任务去重检查:相同接收方 30 分钟内是否有相似任务已分发 ── | 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 | const DUPLICATE_WINDOW_MS = 30 * 60 * 1000; | 1788 | const DUPLICATE_WINDOW_MS = 30 * 60 * 1000; |
| 1692 | try { | 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 | // 净化多行文本和竖线,防止破坏 Markdown 表格格式 | 1819 | // 净化多行文本和竖线,防止破坏 Markdown 表格格式 |
| 1720 | const safeTask = task.replace(/\n/g, '<br>').replace(/\|/g, '|'); | 1820 | const safeTask = task.replace(/\n/g, '<br>').replace(/\|/g, '|'); |
| @@ -1723,28 +1823,25 @@ router.post('/task/dispatch', async (req, res) => { | @@ -1723,28 +1823,25 @@ router.post('/task/dispatch', async (req, res) => { | ||
| 1723 | // 如果调用者没有显式传 parentTaskId,则尝试从 .tasks/ 目录下 | 1823 | // 如果调用者没有显式传 parentTaskId,则尝试从 .tasks/ 目录下 |
| 1724 | // 查找 from agent 正在执行的(status=dispatched 且 to=from)的 task | 1824 | // 查找 from agent 正在执行的(status=dispatched 且 to=from)的 task |
| 1725 | let parentTaskId = explicitParent || null; | 1825 | let parentTaskId = explicitParent || null; |
| 1726 | - let sourceConvId = null; | ||
| 1727 | if (!parentTaskId) { | 1826 | if (!parentTaskId) { |
| 1827 | + // 批 5:同样从扫目录换成一条 SQL,判据不变 ——「派给 from 且仍在执行」里最近的一条, | ||
| 1828 | + // 那就是 from 此刻正在干的活,新派的这条挂在它下面。排序交给 SQL(原来是把全部候选 | ||
| 1829 | + // 读进内存再 sort,目录一大就是白读一遍)。 | ||
| 1728 | try { | 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 | console.log(`[TaskDispatch] 🔗 Auto-detected parentTaskId: ${parentTaskId} (${from} is executing it)`); | 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 | // 1. 写入目标 Agent 的 todo.md(公司态:`co-alpha:ceo` 与 `co-beta:ceo` 各写各的) | 1847 | // 1. 写入目标 Agent 的 todo.md(公司态:`co-alpha:ceo` 与 `co-beta:ceo` 各写各的) |
| @@ -1788,7 +1885,6 @@ router.post('/task/dispatch', async (req, res) => { | @@ -1788,7 +1885,6 @@ router.post('/task/dispatch', async (req, res) => { | ||
| 1788 | goalId: objectiveId || null, // 旧字段名,保留供存量读取方兼容 | 1885 | goalId: objectiveId || null, // 旧字段名,保留供存量读取方兼容 |
| 1789 | nodeId: nodeId || null, | 1886 | nodeId: nodeId || null, |
| 1790 | parentTaskId: parentTaskId || null, | 1887 | parentTaskId: parentTaskId || null, |
| 1791 | - sourceConvId: sourceConvId || null, | ||
| 1792 | executionConvId, | 1888 | executionConvId, |
| 1793 | createdAt: new Date().toISOString(), | 1889 | createdAt: new Date().toISOString(), |
| 1794 | status: 'dispatched', | 1890 | status: 'dispatched', |
server/services/work-graph-db.js
| @@ -504,6 +504,11 @@ function getDb() { | @@ -504,6 +504,11 @@ function getDb() { | ||
| 504 | executor TEXT NOT NULL DEFAULT 'graph' | 504 | executor TEXT NOT NULL DEFAULT 'graph' |
| 505 | CHECK (executor IN ('graph','agent')), | 505 | CHECK (executor IN ('graph','agent')), |
| 506 | assignee_agent_id TEXT, -- executor='agent' 时派给谁(dispatch 的 to) | 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 | -- 派单链:A 让 B 干活、B 又拆给 C。与 depends_on 不是一回事 —— | 512 | -- 派单链:A 让 B 干活、B 又拆给 C。与 depends_on 不是一回事 —— |
| 508 | -- depends_on 是同级任务之间的先后,parent_task_id 是上下级的派生。 | 513 | -- depends_on 是同级任务之间的先后,parent_task_id 是上下级的派生。 |
| 509 | parent_task_id TEXT REFERENCES tasks(id), | 514 | parent_task_id TEXT REFERENCES tasks(id), |
| @@ -559,6 +564,12 @@ function getDb() { | @@ -559,6 +564,12 @@ function getDb() { | ||
| 559 | if (!hasColumn(db, 'tasks', 'parent_task_id')) { | 564 | if (!hasColumn(db, 'tasks', 'parent_task_id')) { |
| 560 | db.exec(`ALTER TABLE tasks ADD COLUMN parent_task_id TEXT`); | 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 | // ⚠️ 这条索引**必须建在 ALTER 之后**,不能放进上面的建表块: | 573 | // ⚠️ 这条索引**必须建在 ALTER 之后**,不能放进上面的建表块: |
| 563 | // 建表块整体先于迁移块执行,而存量库上 `CREATE TABLE IF NOT EXISTS tasks` 是 no-op —— | 574 | // 建表块整体先于迁移块执行,而存量库上 `CREATE TABLE IF NOT EXISTS tasks` 是 no-op —— |
| 564 | // 表还是批 4 的形态、没有 assignee_agent_id,那一句 CREATE INDEX 会直接抛 | 575 | // 表还是批 4 的形态、没有 assignee_agent_id,那一句 CREATE INDEX 会直接抛 |
| @@ -1760,6 +1771,8 @@ const TASK_PATCH_COLUMNS = { | @@ -1760,6 +1771,8 @@ const TASK_PATCH_COLUMNS = { | ||
| 1760 | startedAt: 'started_at', | 1771 | startedAt: 'started_at', |
| 1761 | finishedAt: 'finished_at', | 1772 | finishedAt: 'finished_at', |
| 1762 | archivedAt: 'archived_at', | 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 | +}); |