Commit 8f4c1b6369dcb58d402008636565e5104341ac44

Authored by yibo
1 parent 3353b174

fix(27-P3C): 收掉 P3C 欠着的四条

## ① system/tasklog/ 改名保留(§十二.5)
派单存储统一进 tasks 表后,旧目录改名为 system/tasklog.bak-unify/ 而不是
直接删 —— 跑顺一个版本再清。回填脚本改成**两处都认**(先原位置再备份位置,
顺序不能反:万一有人从备份恢复出一份新的 tasklog,那份才是要回填的)。

## ② agent 提示词里的端口 3000 → 3010
packages/agent-core/base/ 下那几份是**所有 agent 的工具说明**,印着
curl http://localhost:3000/... 而实际端口是 3010。agent 照着打拿到的是
连接被拒、不是「端口错了」,它多半会去猜别的原因。

这不是假想:批 5 回填出来的历史派单正文里,有人手写了「(端口 3010,
不是 3000)」在纠正它 —— 靠人肉在每条任务书里打补丁。

守卫 agent-docs-port.test.js:扫 base 模板与 agent 本体,禁 localhost:3000。
判据刻意宽松(不校验「必须等于 config.port」—— 端口可被 PORT 覆盖,写死会在
部署环境误报)。带「样本非空」前置断言。第一版扫出 5 条误报,全是某 agent
目录下 Python venv 里 playwright 的 ThirdPartyNotices —— 那是第三方文件不是
提示词,已把 venv/site-packages/workspace/conversations/train 一并跳过。

## ③ 边界守卫不再把并行会话的改动算到 agent 头上(§15.5 第 1 条)
守卫的前提是「窗口内新增的仓库改动 = 这个节点写的」。多会话共用 worktree 时
这个前提是假的 —— git status 的差集里没有「谁写的」这一维。实测已发生:
批 3 的开发改动把一条 run 判成 boundary_escape 卡住。

可检测的信号:节点**开跑那一刻**仓库就已经有源码目录的未提交改动 → 有人正在
这个 worktree 上开发 → 窗口内新增的源码改动无法归属 → **降级为只记证据、
不判罚**(与既有的 unattributable 桶同一语义),而不是关掉守卫:证据照留,
人一眼看得出「这次没判罚,因为当时仓库是脏的」。

BOUNDARY_GUARD=strict 强制判罚(CI / 干净环境),=record 一律只记。
守卫 8 例,含关键对照:仓库脏时不判、干净时同样一处越界仍判 fail ——
守卫没被削弱,只是不再乱扣帽子。

## ④ TaskTimeline 的标签不再撒谎
conversationCount 在批 5 之后只会是 0/1(「有没有起过执行会话」),而界面
写的是 "Convs: N" —— 读的人会以为这条任务只有一次对话。改成有/无的表达。
agents 的语义变化在注释里说明。

注:innovator-hunt/todo.md 与 reddit-scraper/todo.md 里的端口也改了,
但它们是 gitignored 的运行态文件,不入库。

全量:134 文件 / 1268 测试全绿,web:build 通过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.gitignore
@@ -18,6 +18,8 @@ bot.log @@ -18,6 +18,8 @@ bot.log
18 # 运行态**目录**的规则见文件末尾的白名单块(28 §5.5):深度 2 现在是任意公司 id, 18 # 运行态**目录**的规则见文件末尾的白名单块(28 §5.5):深度 2 现在是任意公司 id,
19 # 没法再像以前那样逐个枚举 conversations/ logs/ workspace/ … 19 # 没法再像以前那样逐个枚举 conversations/ logs/ workspace/ …
20 system/tasklog/ 20 system/tasklog/
  21 +# 批 5 把派单存储统一进 tasks 表后,旧目录改名保留一版再清(27-P3C §十二.5)
  22 +system/tasklog.bak-unify/
21 23
22 # ── Agent 自写的运行态文件(27-P3B W13 提出,28 批次 0 根治)───────────── 24 # ── Agent 自写的运行态文件(27-P3B W13 提出,28 批次 0 根治)─────────────
23 # system agent 的 Claude CLI workDir **就是它自己的源码目录** system/agents/<id>/, 25 # system agent 的 Claude CLI workDir **就是它自己的源码目录** system/agents/<id>/,
packages/agent-core/base/common/tools-base.md
@@ -13,7 +13,7 @@ @@ -13,7 +13,7 @@
13 13
14 ### dispatch_task — 向下级 Agent 分发任务 14 ### dispatch_task — 向下级 Agent 分发任务
15 ```bash 15 ```bash
16 -curl -X POST http://localhost:3000/api/agent/task/dispatch \ 16 +curl -X POST http://localhost:3010/api/agent/task/dispatch \
17 -H "Content-Type: application/json" \ 17 -H "Content-Type: application/json" \
18 -d '{"from":"YOUR_AGENT_ID","to":"TARGET_AGENT_ID","task":"任务描述","priority":"P1","goalId":"G-001","nodeId":"G-001.1.1","autoExecute":true}' 18 -d '{"from":"YOUR_AGENT_ID","to":"TARGET_AGENT_ID","task":"任务描述","priority":"P1","goalId":"G-001","nodeId":"G-001.1.1","autoExecute":true}'
19 ``` 19 ```
@@ -29,7 +29,7 @@ curl -X POST http://localhost:3000/api/agent/task/dispatch \ @@ -29,7 +29,7 @@ curl -X POST http://localhost:3000/api/agent/task/dispatch \
29 29
30 ### report_task — 向上级汇报任务完成 30 ### report_task — 向上级汇报任务完成
31 ```bash 31 ```bash
32 -curl -X POST http://localhost:3000/api/agent/task/report \ 32 +curl -X POST http://localhost:3010/api/agent/task/report \
33 -H "Content-Type: application/json" \ 33 -H "Content-Type: application/json" \
34 -d '{"taskId":"T-xxx","from":"YOUR_AGENT_ID","to":"SENDER_AGENT_ID","status":"done","summary":"完成摘要","goalId":"G-001","nodeId":"G-001.1.1"}' 34 -d '{"taskId":"T-xxx","from":"YOUR_AGENT_ID","to":"SENDER_AGENT_ID","status":"done","summary":"完成摘要","goalId":"G-001","nodeId":"G-001.1.1"}'
35 ``` 35 ```
@@ -39,5 +39,5 @@ curl -X POST http://localhost:3000/api/agent/task/report \ @@ -39,5 +39,5 @@ curl -X POST http://localhost:3000/api/agent/task/report \
39 39
40 ### 查看 Agent 列表 40 ### 查看 Agent 列表
41 ```bash 41 ```bash
42 -curl -s http://localhost:3000/api/agent/list 42 +curl -s http://localhost:3010/api/agent/list
43 ``` 43 ```
packages/agent-core/base/cxo/agent-base.md
@@ -10,12 +10,12 @@ @@ -10,12 +10,12 @@
10 10
11 ```bash 11 ```bash
12 # Step 1: 通过 agent-creator 创建员工 12 # Step 1: 通过 agent-creator 创建员工
13 -curl -X POST http://localhost:3000/api/agent/task/dispatch \ 13 +curl -X POST http://localhost:3010/api/agent/task/dispatch \
14 -H "Content-Type: application/json" \ 14 -H "Content-Type: application/json" \
15 -d '{"from":"YOUR_ID","to":"agent-creator","task":"创建 XX Agent。要求:id, type, 能力描述, 性格特征","priority":"P0","autoExecute":true}' 15 -d '{"from":"YOUR_ID","to":"agent-creator","task":"创建 XX Agent。要求:id, type, 能力描述, 性格特征","priority":"P0","autoExecute":true}'
16 16
17 # Step 2: 创建完成后,分发任务给新员工 17 # Step 2: 创建完成后,分发任务给新员工
18 -curl -X POST http://localhost:3000/api/agent/task/dispatch \ 18 +curl -X POST http://localhost:3010/api/agent/task/dispatch \
19 -H "Content-Type: application/json" \ 19 -H "Content-Type: application/json" \
20 -d '{"from":"YOUR_ID","to":"NEW_AGENT_ID","task":"具体任务","priority":"P0","autoExecute":true}' 20 -d '{"from":"YOUR_ID","to":"NEW_AGENT_ID","task":"具体任务","priority":"P0","autoExecute":true}'
21 ``` 21 ```
scripts/migrate-runs-to-goal-task.js
@@ -220,7 +220,21 @@ function main() { @@ -220,7 +220,21 @@ function main() {
220 // 批 5:system/tasklog/*.json → tasks 表(executor='agent') 220 // 批 5:system/tasklog/*.json → tasks 表(executor='agent')
221 // ════════════════════════════════════════════════════════════════ 221 // ════════════════════════════════════════════════════════════════
222 222
223 -const TASKLOG_DIR = path.join(REPO_ROOT, 'system', 'tasklog'); 223 +/**
  224 + * 派单旧存储的位置。
  225 + *
  226 + * 批 5 把它统一进 `tasks` 表之后,目录**改名**为 `system/tasklog.bak-unify/` 而不是直接删
  227 + * (§十二.5:跑顺一个版本再清)。所以这里两处都认:先看原位置(还没改名的库),
  228 + * 再看备份位置(改过名的)。**顺序不能反** —— 万一有人从备份恢复出一份新的 tasklog,
  229 + * 那份才是要回填的。
  230 + */
  231 +function tasklogDir() {
  232 + for (const d of ['tasklog', 'tasklog.bak-unify']) {
  233 + const full = path.join(REPO_ROOT, 'system', d);
  234 + if (fs.existsSync(full)) return full;
  235 + }
  236 + return path.join(REPO_ROOT, 'system', 'tasklog'); // 都不在时返回原位置,下面 existsSync 会跳过
  237 +}
224 238
225 /** 239 /**
226 * 派单任务落在哪家公司名下。 240 * 派单任务落在哪家公司名下。
@@ -258,7 +272,7 @@ function tasklogAlreadyMigrated(db, taskId) { @@ -258,7 +272,7 @@ function tasklogAlreadyMigrated(db, taskId) {
258 function migrateTasklog(db, file) { 272 function migrateTasklog(db, file) {
259 let meta; 273 let meta;
260 try { 274 try {
261 - meta = JSON.parse(fs.readFileSync(path.join(TASKLOG_DIR, file), 'utf-8')); 275 + meta = JSON.parse(fs.readFileSync(path.join(tasklogDir(), file), 'utf-8'));
262 } catch (e) { 276 } catch (e) {
263 stats.tasklogBad += 1; 277 stats.tasklogBad += 1;
264 log(`⚠️ ${file} 解析失败,跳过: ${e.message}`); 278 log(`⚠️ ${file} 解析失败,跳过: ${e.message}`);
@@ -309,6 +323,7 @@ function migrateTasklog(db, file) { @@ -309,6 +323,7 @@ function migrateTasklog(db, file) {
309 } 323 }
310 324
311 function migrateAllTasklog(db) { 325 function migrateAllTasklog(db) {
  326 + const TASKLOG_DIR = tasklogDir();
312 if (!fs.existsSync(TASKLOG_DIR)) { log('system/tasklog/ 不存在,跳过派单回填'); return; } 327 if (!fs.existsSync(TASKLOG_DIR)) { log('system/tasklog/ 不存在,跳过派单回填'); return; }
313 const files = fs.readdirSync(TASKLOG_DIR).filter((f) => f.endsWith('.json')).sort(); 328 const files = fs.readdirSync(TASKLOG_DIR).filter((f) => f.endsWith('.json')).sort();
314 stats.tasklogFiles = files.length; 329 stats.tasklogFiles = files.length;
server/services/graph-engine.js
@@ -2989,6 +2989,40 @@ function beginBoundaryGuard(ctx) { @@ -2989,6 +2989,40 @@ function beginBoundaryGuard(ctx) {
2989 */ 2989 */
2990 const AGENT_REGISTRY_TREE_RE = /^system\/agents\//; 2990 const AGENT_REGISTRY_TREE_RE = /^system\/agents\//;
2991 2991
  2992 +/**
  2993 + * 「这个 worktree 还有别人在写」—— 判罚前必须先回答的问题。
  2994 + *
  2995 + * 守卫的前提是「本次调用窗口内新出现的仓库改动 = 这个节点写的」。多会话共用同一个
  2996 + * worktree 时这个前提**是假的**:`git status` 的差集里没有「谁写的」这一维,
  2997 + * 一个并行的开发会话改 `server/services/x.js`,会被原样记到当时正在跑的那个节点头上。
  2998 + * 实测已经发生过:27-P3C 批 3 的开发改动把一条 run 判成了 `boundary_escape` 卡住
  2999 + * (§15.5 第 1 条)。
  3000 + *
  3001 + * **可检测的信号**:如果节点**开跑那一刻**宿主仓库就已经有源码目录的未提交改动,
  3002 + * 那说明有人正在这个 worktree 上开发 —— 此后窗口内新增的源码改动,是他的可能性
  3003 + * 至少不低于是 agent 的。这不是猜,是快照里读得出来的事实。
  3004 + *
  3005 + * 这种情况下**降级为只记证据、不判罚**(与 `unattributable` 桶同一语义),
  3006 + * 而不是关掉守卫:证据照留,人一眼能看出「这次没判罚,因为当时仓库是脏的」。
  3007 + *
  3008 + * `BOUNDARY_GUARD` 可覆盖:`strict` = 无论仓库脏不脏都判罚(CI / 干净环境用);
  3009 + * `record` = 一律只记不判。缺省是上面那条自动判断。
  3010 + */
  3011 +const JUDGED_SOURCE_RE = /^(server|web|packages|scripts)\//;
  3012 +
  3013 +function sharedWorktreeReason(before) {
  3014 + const mode = process.env.BOUNDARY_GUARD;
  3015 + if (mode === 'strict') return null;
  3016 + if (mode === 'record') return 'BOUNDARY_GUARD=record:本次一律只记证据、不判罚';
  3017 + const dirty = String(before == null ? '' : before)
  3018 + .split('\n').map((l) => l.trim()).filter(Boolean)
  3019 + .map((l) => l.replace(/^\S{1,2}\s+/, '').trim().replace(/^"|"$/g, ''))
  3020 + .filter((p) => JUDGED_SOURCE_RE.test(p));
  3021 + if (!dirty.length) return null;
  3022 + return `本节点开跑时宿主仓库已有 ${dirty.length} 处源码未提交改动(如 ${dirty[0]}),`
  3023 + + '说明有别的会话正在这个 worktree 上开发;窗口内新增的源码改动无法归属到本节点,只记证据不判罚';
  3024 +}
  3025 +
2992 function splitUnattributable(entries) { 3026 function splitUnattributable(entries) {
2993 const escapes = []; 3027 const escapes = [];
2994 const unattributable = []; 3028 const unattributable = [];
@@ -3019,7 +3053,21 @@ function applyBoundaryGuard(ctx, before, outcome, injectedAfter = undefined) { @@ -3019,7 +3053,21 @@ function applyBoundaryGuard(ctx, before, outcome, injectedAfter = undefined) {
3019 if (before === null || before === undefined) return outcome; 3053 if (before === null || before === undefined) return outcome;
3020 const after = injectedAfter === undefined ? hostRepoStatus() : injectedAfter; 3054 const after = injectedAfter === undefined ? hostRepoStatus() : injectedAfter;
3021 if (after === null) return outcome; 3055 if (after === null) return outcome;
3022 - const { escapes, unattributable } = splitUnattributable(diffRepoStatus(before, after)); 3056 + let { escapes, unattributable } = splitUnattributable(diffRepoStatus(before, after));
  3057 +
  3058 + // 多会话共用 worktree 时,源码改动无法归属到本节点 —— 全部降级为「只记证据」
  3059 + const sharedReason = escapes.length ? sharedWorktreeReason(before) : null;
  3060 + if (sharedReason) {
  3061 + timelineNote(ctx, 'node.boundary',
  3062 + `本次调用窗口内有 ${escapes.length} 处仓库改动,但无法归属到本节点,不计入越界`, {
  3063 + node_run_id: ctx.nodeRunId, attempt: ctx.attempt, violation: null,
  3064 + entries: escapes.slice(0, 20), reason: sharedReason,
  3065 + note: '守卫的前提是「窗口内新增的改动 = 本节点写的」,而 git status 的差集里没有'
  3066 + + '「谁写的」这一维。要在干净 worktree 上强制判罚,设 BOUNDARY_GUARD=strict。',
  3067 + });
  3068 + unattributable = unattributable.concat(escapes);
  3069 + escapes = [];
  3070 + }
3023 3071
3024 if (unattributable.length) { 3072 if (unattributable.length) {
3025 timelineNote(ctx, 'node.boundary', 3073 timelineNote(ctx, 'node.boundary',
@@ -3824,6 +3872,7 @@ module.exports = { @@ -3824,6 +3872,7 @@ module.exports = {
3824 applyBoundaryGuard, 3872 applyBoundaryGuard,
3825 // 归属判定:agent 运行态文件不算越界(真跑冒烟的断点就在这,见函数上方注释) 3873 // 归属判定:agent 运行态文件不算越界(真跑冒烟的断点就在这,见函数上方注释)
3826 splitUnattributable, 3874 splitUnattributable,
  3875 + sharedWorktreeReason,
3827 3876
3828 // 选边 / 熔断 / 透传 3877 // 选边 / 熔断 / 透传
3829 selectFailEdge, 3878 selectFailEdge,
server/tests/unit/agent-docs-port.test.js 0 → 100644
  1 +/**
  2 + * agent 提示词里印的端口必须是真的。
  3 + *
  4 + * 为什么要一条守卫:`packages/agent-core/base/` 下那几份是**所有 agent 的工具说明**
  5 + * (composed 进每个 agent 的提示词),里面印着 `curl http://localhost:3000/api/agent/...`,
  6 + * 而实际端口是 **3010**(server/config.js)。agent 照着打就连不上,而它拿到的是
  7 + * 连接被拒、不是一句「端口错了」—— 它多半会去猜别的原因。
  8 + *
  9 + * 这不是假想:批 5 回填出来的历史派单正文里,有人手写了
  10 + * 「完成后调用 report_task 汇报给 innovator(端口 3010,不是 3000)」来纠正它 ——
  11 + * 说明这个错误已经真咬过人,而且是靠人肉在每条任务书里打补丁。
  12 + *
  13 + * 判据刻意宽松:只禁 `localhost:3000`(那个确定是错的),不去校验「必须等于
  14 + * config.port」—— 端口可被 PORT 覆盖,写死校验反而会在部署环境里误报。
  15 + */
  16 +import { describe, it, expect } from 'vitest';
  17 +import { createRequire } from 'module';
  18 +import fs from 'fs';
  19 +import path from 'path';
  20 +const require_ = createRequire(import.meta.url);
  21 +
  22 +const ROOT = path.resolve(__dirname, '../../..');
  23 +const SCAN = ['packages/agent-core/base', 'system/agents'];
  24 +// agent 目录下会长出运行态产物:Python venv、node_modules、下载的浏览器驱动……
  25 +// 那些是**第三方文件,不是提示词**,扫进来只会拿 playwright 的 ThirdPartyNotices 当违例
  26 +// (实测第一版就是这么红的)。守卫要盯的是人写给 agent 看的东西。
  27 +const SKIP_DIR = /(^|\/)(node_modules|\.git|dist|coverage|venv|\.venv|site-packages|__pycache__|workspace|conversations|train)(\/|$)/;
  28 +
  29 +function walk(dir, out = []) {
  30 + if (!fs.existsSync(dir) || SKIP_DIR.test(dir)) return out;
  31 + for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
  32 + const full = path.join(dir, e.name);
  33 + if (e.isDirectory()) walk(full, out);
  34 + else if (/\.(md|json|txt)$/.test(e.name)) out.push(full);
  35 + }
  36 + return out;
  37 +}
  38 +
  39 +describe('agent 提示词里的端口', () => {
  40 + const files = SCAN.flatMap((d) => walk(path.join(ROOT, d)));
  41 +
  42 + it('扫描样本非空 —— 防止路径写错时本测试退化成永远通过', () => {
  43 + // 少了这条前置断言,SCAN 里的路径一旦写错就会以「零违例」的形式全绿,
  44 + // 而一条永远通过的守卫和没有守卫是一回事。
  45 + expect(files.length).toBeGreaterThan(50);
  46 + });
  47 +
  48 + it('不许再出现 localhost:3000(实际端口是 3010)', () => {
  49 + const offenders = [];
  50 + for (const f of files) {
  51 + const src = fs.readFileSync(f, 'utf-8');
  52 + src.split('\n').forEach((line, i) => {
  53 + if (line.includes('localhost:3000') || line.includes('127.0.0.1:3000')) {
  54 + offenders.push(`${path.relative(ROOT, f)}:${i + 1} → ${line.trim().slice(0, 90)}`);
  55 + }
  56 + });
  57 + }
  58 + expect(offenders).toEqual([]);
  59 + });
  60 +
  61 + it('config.port 的缺省确实是 3010(守卫的前提,变了要先改这里)', () => {
  62 + const cfg = require_('../../config');
  63 + expect(cfg.port).toBe(3010);
  64 + });
  65 +});
server/tests/unit/boundary-guard-shared-worktree.test.js 0 → 100644
  1 +/**
  2 + * 边界守卫在**多会话共用 worktree** 时不许乱判罚(27-P3C §15.5 第 1 条)。
  3 + *
  4 + * 守卫的前提是「本次调用窗口内新出现的仓库改动 = 这个节点写的」。
  5 + * 多会话共用同一个 worktree 时这个前提是假的 —— `git status` 的差集里
  6 + * **没有「谁写的」这一维**。实测已经发生过:批 3 的开发改动把一条 run 判成
  7 + * boundary_escape 卡住,而那些文件是并行的开发会话写的。
  8 + *
  9 + * 可检测的信号:节点**开跑那一刻**仓库就已经有源码目录的未提交改动 →
  10 + * 有人正在这个 worktree 上开发 → 窗口内新增的源码改动无法归属 → 只记证据不判罚。
  11 + */
  12 +import { describe, it, expect, afterEach } from 'vitest';
  13 +import { createRequire } from 'module';
  14 +const require_ = createRequire(import.meta.url);
  15 +const engine = require_('../../services/graph-engine');
  16 +
  17 +const OLD = process.env.BOUNDARY_GUARD;
  18 +afterEach(() => {
  19 + if (OLD === undefined) delete process.env.BOUNDARY_GUARD;
  20 + else process.env.BOUNDARY_GUARD = OLD;
  21 +});
  22 +
  23 +describe('边界守卫 · 多会话 worktree', () => {
  24 + it('开跑时仓库干净 → 照常判罚(CI / 干净环境不受影响)', () => {
  25 + delete process.env.BOUNDARY_GUARD;
  26 + expect(engine.sharedWorktreeReason('')).toBeNull();
  27 + // 只有 agent 运行态的脏,不算「有人在开发」
  28 + expect(engine.sharedWorktreeReason('?? system/agents/foo/\n M data/companies/x/company.json')).toBeNull();
  29 + });
  30 +
  31 + it('开跑时源码目录已脏 → 认定有别的会话在写,降级为只记证据', () => {
  32 + delete process.env.BOUNDARY_GUARD;
  33 + const reason = engine.sharedWorktreeReason(' M server/services/task-scheduler.js\n M web/src/views/GraphConsole.vue');
  34 + expect(reason).toBeTruthy();
  35 + expect(reason).toContain('server/services/task-scheduler.js'); // 点名证据,不是一句空话
  36 + });
  37 +
  38 + it('路径含空格/中文被 git 加了引号时也认得出来', () => {
  39 + delete process.env.BOUNDARY_GUARD;
  40 + expect(engine.sharedWorktreeReason(' M "server/services/新 文件.js"')).toBeTruthy();
  41 + });
  42 +
  43 + it('未跟踪的新源码文件(?? 两字符状态位)同样算', () => {
  44 + delete process.env.BOUNDARY_GUARD;
  45 + expect(engine.sharedWorktreeReason('?? server/services/brand-new.js')).toBeTruthy();
  46 + });
  47 +
  48 + it('BOUNDARY_GUARD=strict 强制判罚,脏仓库也不放过', () => {
  49 + process.env.BOUNDARY_GUARD = 'strict';
  50 + expect(engine.sharedWorktreeReason(' M server/services/task-scheduler.js')).toBeNull();
  51 + });
  52 +
  53 + it('BOUNDARY_GUARD=record 一律只记不判,干净仓库也是', () => {
  54 + process.env.BOUNDARY_GUARD = 'record';
  55 + expect(engine.sharedWorktreeReason('')).toBeTruthy();
  56 + });
  57 +
  58 + it('降级之后 outcome 不被改成 fail,且证据仍在 escapes 之外留档', () => {
  59 + delete process.env.BOUNDARY_GUARD;
  60 + const notes = [];
  61 + const ctx = {
  62 + nodeRunId: 'rn_1', attempt: 1, projectRoot: '/tmp/proj',
  63 + // timelineNote 走的是 sse,这里只验 outcome 不被降级;证据面由上面几条覆盖
  64 + };
  65 + const before = ' M server/services/task-scheduler.js'; // 开跑时就脏
  66 + const after = before + '\n M server/services/graph-engine.js'; // 窗口内又多一处
  67 + const out = engine.applyBoundaryGuard(ctx, before, { status: 'success', output: 'ok' }, after);
  68 + expect(out.status).toBe('success'); // ← 修之前这里会被判成 fail
  69 + expect(out.failReason).toBeFalsy();
  70 + expect(notes).toEqual([]);
  71 + });
  72 +
  73 + it('对照:仓库开跑时是干净的 → 同样一处越界仍然判 fail', () => {
  74 + delete process.env.BOUNDARY_GUARD;
  75 + const out = engine.applyBoundaryGuard(
  76 + { nodeRunId: 'rn_2', attempt: 1, projectRoot: '/tmp/proj' },
  77 + '', { status: 'success', output: 'ok' }, ' M server/services/graph-engine.js',
  78 + );
  79 + expect(out.status).toBe('fail');
  80 + expect(out.failReason).toBe('boundary_escape');
  81 + });
  82 +});
system/agents/hr/agent.md
@@ -67,7 +67,7 @@ node bin/lemon.js task dispatch --from &lt;你自己ID&gt; --to innovator-001 --task &quot; @@ -67,7 +67,7 @@ node bin/lemon.js task dispatch --from &lt;你自己ID&gt; --to innovator-001 --task &quot;
67 当被心跳唤醒或收到 CTO/COO 查询时,执行管理检查: 67 当被心跳唤醒或收到 CTO/COO 查询时,执行管理检查:
68 ```bash 68 ```bash
69 # 查看公司整体状态和所有员工心跳 69 # 查看公司整体状态和所有员工心跳
70 -curl -s http://localhost:3000/api/company/<公司ID>/status | node -e " 70 +curl -s http://localhost:3010/api/company/<公司ID>/status | node -e "
71 const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); 71 const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
72 d.agents.forEach(a => console.log(a.id, a.heartbeatActive ? '✅在线' : '❌离线', 'interval:'+a.heartbeatInterval+'min')); 72 d.agents.forEach(a => console.log(a.id, a.heartbeatActive ? '✅在线' : '❌离线', 'interval:'+a.heartbeatInterval+'min'));
73 " 73 "
system/agents/qa-judge/agent.md
@@ -415,7 +415,7 @@ Step 6: 溯源分类 → 粗心错误 vs 能力缺陷判定 @@ -415,7 +415,7 @@ Step 6: 溯源分类 → 粗心错误 vs 能力缺陷判定
415 415
416 **REWORK 场景**: 416 **REWORK 场景**:
417 ```bash 417 ```bash
418 -curl -X POST http://localhost:3000/api/agent/task/dispatch \ 418 +curl -X POST http://localhost:3010/api/agent/task/dispatch \
419 -H "Content-Type: application/json" \ 419 -H "Content-Type: application/json" \
420 -d '{ 420 -d '{
421 "from": "ceo", 421 "from": "ceo",
@@ -431,7 +431,7 @@ curl -X POST http://localhost:3000/api/agent/task/dispatch \ @@ -431,7 +431,7 @@ curl -X POST http://localhost:3000/api/agent/task/dispatch \
431 431
432 **RETRAIN_REQUIRED 场景**: 432 **RETRAIN_REQUIRED 场景**:
433 ```bash 433 ```bash
434 -curl -X POST http://localhost:3000/api/agent/task/dispatch \ 434 +curl -X POST http://localhost:3010/api/agent/task/dispatch \
435 -H "Content-Type: application/json" \ 435 -H "Content-Type: application/json" \
436 -d '{ 436 -d '{
437 "from": "ceo", 437 "from": "ceo",
web/src/views/TaskTimeline.vue
@@ -48,10 +48,16 @@ @@ -48,10 +48,16 @@
48 </span> 48 </span>
49 </div> 49 </div>
50 50
  51 + <!-- 27-P3C 批 5:任务列表改读 tasks 表之后,这里两个字段的语义变了 ——
  52 + · conversationCount 从「扫出来的会话文件数」变成「有没有起过执行会话」(0/1)。
  53 + 再写 "Convs: 1" 是**标签在撒谎**(读的人会以为这条任务只有一次对话),
  54 + 所以改成有/无的表达,没有就不占位。真要看内容点进去走 timeline。
  55 + · agents 从「会话里出现过的 agent 集合」变成「谁派的 + 派给谁」,最多两个,
  56 + 上面那段 slice(0,4) + "+N" 因此不会再触发;留着不碍事,也不值得为它冒改动风险。 -->
51 <div class="task-card-meta"> 57 <div class="task-card-meta">
52 - Convs: {{ task.conversationCount }}  
53 - <template v-if="task.children && task.children.length"> · 🔗 {{ task.children.length }} subtask(s)</template>  
54 - · {{ formatTime(task.updatedAt) }} 58 + <template v-if="task.conversationCount">💬 有执行会话 · </template>
  59 + <template v-if="task.children && task.children.length">🔗 {{ task.children.length }} subtask(s) · </template>
  60 + {{ formatTime(task.updatedAt) }}
55 </div> 61 </div>
56 </div> 62 </div>
57 63