diff --git a/.gitignore b/.gitignore index 6cb6a82..869236d 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,8 @@ bot.log # 运行态**目录**的规则见文件末尾的白名单块(28 §5.5):深度 2 现在是任意公司 id, # 没法再像以前那样逐个枚举 conversations/ logs/ workspace/ … system/tasklog/ +# 批 5 把派单存储统一进 tasks 表后,旧目录改名保留一版再清(27-P3C §十二.5) +system/tasklog.bak-unify/ # ── Agent 自写的运行态文件(27-P3B W13 提出,28 批次 0 根治)───────────── # system agent 的 Claude CLI workDir **就是它自己的源码目录** system/agents//, diff --git a/packages/agent-core/base/common/tools-base.md b/packages/agent-core/base/common/tools-base.md index b597b9e..05a3181 100644 --- a/packages/agent-core/base/common/tools-base.md +++ b/packages/agent-core/base/common/tools-base.md @@ -13,7 +13,7 @@ ### dispatch_task — 向下级 Agent 分发任务 ```bash -curl -X POST http://localhost:3000/api/agent/task/dispatch \ +curl -X POST http://localhost:3010/api/agent/task/dispatch \ -H "Content-Type: application/json" \ -d '{"from":"YOUR_AGENT_ID","to":"TARGET_AGENT_ID","task":"任务描述","priority":"P1","goalId":"G-001","nodeId":"G-001.1.1","autoExecute":true}' ``` @@ -29,7 +29,7 @@ curl -X POST http://localhost:3000/api/agent/task/dispatch \ ### report_task — 向上级汇报任务完成 ```bash -curl -X POST http://localhost:3000/api/agent/task/report \ +curl -X POST http://localhost:3010/api/agent/task/report \ -H "Content-Type: application/json" \ -d '{"taskId":"T-xxx","from":"YOUR_AGENT_ID","to":"SENDER_AGENT_ID","status":"done","summary":"完成摘要","goalId":"G-001","nodeId":"G-001.1.1"}' ``` @@ -39,5 +39,5 @@ curl -X POST http://localhost:3000/api/agent/task/report \ ### 查看 Agent 列表 ```bash -curl -s http://localhost:3000/api/agent/list +curl -s http://localhost:3010/api/agent/list ``` diff --git a/packages/agent-core/base/cxo/agent-base.md b/packages/agent-core/base/cxo/agent-base.md index a77d6b0..8e825b6 100644 --- a/packages/agent-core/base/cxo/agent-base.md +++ b/packages/agent-core/base/cxo/agent-base.md @@ -10,12 +10,12 @@ ```bash # Step 1: 通过 agent-creator 创建员工 -curl -X POST http://localhost:3000/api/agent/task/dispatch \ +curl -X POST http://localhost:3010/api/agent/task/dispatch \ -H "Content-Type: application/json" \ -d '{"from":"YOUR_ID","to":"agent-creator","task":"创建 XX Agent。要求:id, type, 能力描述, 性格特征","priority":"P0","autoExecute":true}' # Step 2: 创建完成后,分发任务给新员工 -curl -X POST http://localhost:3000/api/agent/task/dispatch \ +curl -X POST http://localhost:3010/api/agent/task/dispatch \ -H "Content-Type: application/json" \ -d '{"from":"YOUR_ID","to":"NEW_AGENT_ID","task":"具体任务","priority":"P0","autoExecute":true}' ``` diff --git a/scripts/migrate-runs-to-goal-task.js b/scripts/migrate-runs-to-goal-task.js index c27461b..6877743 100644 --- a/scripts/migrate-runs-to-goal-task.js +++ b/scripts/migrate-runs-to-goal-task.js @@ -220,7 +220,21 @@ function main() { // 批 5:system/tasklog/*.json → tasks 表(executor='agent') // ════════════════════════════════════════════════════════════════ -const TASKLOG_DIR = path.join(REPO_ROOT, 'system', 'tasklog'); +/** + * 派单旧存储的位置。 + * + * 批 5 把它统一进 `tasks` 表之后,目录**改名**为 `system/tasklog.bak-unify/` 而不是直接删 + * (§十二.5:跑顺一个版本再清)。所以这里两处都认:先看原位置(还没改名的库), + * 再看备份位置(改过名的)。**顺序不能反** —— 万一有人从备份恢复出一份新的 tasklog, + * 那份才是要回填的。 + */ +function tasklogDir() { + for (const d of ['tasklog', 'tasklog.bak-unify']) { + const full = path.join(REPO_ROOT, 'system', d); + if (fs.existsSync(full)) return full; + } + return path.join(REPO_ROOT, 'system', 'tasklog'); // 都不在时返回原位置,下面 existsSync 会跳过 +} /** * 派单任务落在哪家公司名下。 @@ -258,7 +272,7 @@ function tasklogAlreadyMigrated(db, taskId) { function migrateTasklog(db, file) { let meta; try { - meta = JSON.parse(fs.readFileSync(path.join(TASKLOG_DIR, file), 'utf-8')); + meta = JSON.parse(fs.readFileSync(path.join(tasklogDir(), file), 'utf-8')); } catch (e) { stats.tasklogBad += 1; log(`⚠️ ${file} 解析失败,跳过: ${e.message}`); @@ -309,6 +323,7 @@ function migrateTasklog(db, file) { } function migrateAllTasklog(db) { + const TASKLOG_DIR = tasklogDir(); if (!fs.existsSync(TASKLOG_DIR)) { log('system/tasklog/ 不存在,跳过派单回填'); return; } const files = fs.readdirSync(TASKLOG_DIR).filter((f) => f.endsWith('.json')).sort(); stats.tasklogFiles = files.length; diff --git a/server/services/graph-engine.js b/server/services/graph-engine.js index b48bf76..de4b226 100644 --- a/server/services/graph-engine.js +++ b/server/services/graph-engine.js @@ -2989,6 +2989,40 @@ function beginBoundaryGuard(ctx) { */ const AGENT_REGISTRY_TREE_RE = /^system\/agents\//; +/** + * 「这个 worktree 还有别人在写」—— 判罚前必须先回答的问题。 + * + * 守卫的前提是「本次调用窗口内新出现的仓库改动 = 这个节点写的」。多会话共用同一个 + * worktree 时这个前提**是假的**:`git status` 的差集里没有「谁写的」这一维, + * 一个并行的开发会话改 `server/services/x.js`,会被原样记到当时正在跑的那个节点头上。 + * 实测已经发生过:27-P3C 批 3 的开发改动把一条 run 判成了 `boundary_escape` 卡住 + * (§15.5 第 1 条)。 + * + * **可检测的信号**:如果节点**开跑那一刻**宿主仓库就已经有源码目录的未提交改动, + * 那说明有人正在这个 worktree 上开发 —— 此后窗口内新增的源码改动,是他的可能性 + * 至少不低于是 agent 的。这不是猜,是快照里读得出来的事实。 + * + * 这种情况下**降级为只记证据、不判罚**(与 `unattributable` 桶同一语义), + * 而不是关掉守卫:证据照留,人一眼能看出「这次没判罚,因为当时仓库是脏的」。 + * + * `BOUNDARY_GUARD` 可覆盖:`strict` = 无论仓库脏不脏都判罚(CI / 干净环境用); + * `record` = 一律只记不判。缺省是上面那条自动判断。 + */ +const JUDGED_SOURCE_RE = /^(server|web|packages|scripts)\//; + +function sharedWorktreeReason(before) { + const mode = process.env.BOUNDARY_GUARD; + if (mode === 'strict') return null; + if (mode === 'record') return 'BOUNDARY_GUARD=record:本次一律只记证据、不判罚'; + const dirty = String(before == null ? '' : before) + .split('\n').map((l) => l.trim()).filter(Boolean) + .map((l) => l.replace(/^\S{1,2}\s+/, '').trim().replace(/^"|"$/g, '')) + .filter((p) => JUDGED_SOURCE_RE.test(p)); + if (!dirty.length) return null; + return `本节点开跑时宿主仓库已有 ${dirty.length} 处源码未提交改动(如 ${dirty[0]}),` + + '说明有别的会话正在这个 worktree 上开发;窗口内新增的源码改动无法归属到本节点,只记证据不判罚'; +} + function splitUnattributable(entries) { const escapes = []; const unattributable = []; @@ -3019,7 +3053,21 @@ function applyBoundaryGuard(ctx, before, outcome, injectedAfter = undefined) { if (before === null || before === undefined) return outcome; const after = injectedAfter === undefined ? hostRepoStatus() : injectedAfter; if (after === null) return outcome; - const { escapes, unattributable } = splitUnattributable(diffRepoStatus(before, after)); + let { escapes, unattributable } = splitUnattributable(diffRepoStatus(before, after)); + + // 多会话共用 worktree 时,源码改动无法归属到本节点 —— 全部降级为「只记证据」 + const sharedReason = escapes.length ? sharedWorktreeReason(before) : null; + if (sharedReason) { + timelineNote(ctx, 'node.boundary', + `本次调用窗口内有 ${escapes.length} 处仓库改动,但无法归属到本节点,不计入越界`, { + node_run_id: ctx.nodeRunId, attempt: ctx.attempt, violation: null, + entries: escapes.slice(0, 20), reason: sharedReason, + note: '守卫的前提是「窗口内新增的改动 = 本节点写的」,而 git status 的差集里没有' + + '「谁写的」这一维。要在干净 worktree 上强制判罚,设 BOUNDARY_GUARD=strict。', + }); + unattributable = unattributable.concat(escapes); + escapes = []; + } if (unattributable.length) { timelineNote(ctx, 'node.boundary', @@ -3824,6 +3872,7 @@ module.exports = { applyBoundaryGuard, // 归属判定:agent 运行态文件不算越界(真跑冒烟的断点就在这,见函数上方注释) splitUnattributable, + sharedWorktreeReason, // 选边 / 熔断 / 透传 selectFailEdge, diff --git a/server/tests/unit/agent-docs-port.test.js b/server/tests/unit/agent-docs-port.test.js new file mode 100644 index 0000000..f336fb9 --- /dev/null +++ b/server/tests/unit/agent-docs-port.test.js @@ -0,0 +1,65 @@ +/** + * agent 提示词里印的端口必须是真的。 + * + * 为什么要一条守卫:`packages/agent-core/base/` 下那几份是**所有 agent 的工具说明** + * (composed 进每个 agent 的提示词),里面印着 `curl http://localhost:3000/api/agent/...`, + * 而实际端口是 **3010**(server/config.js)。agent 照着打就连不上,而它拿到的是 + * 连接被拒、不是一句「端口错了」—— 它多半会去猜别的原因。 + * + * 这不是假想:批 5 回填出来的历史派单正文里,有人手写了 + * 「完成后调用 report_task 汇报给 innovator(端口 3010,不是 3000)」来纠正它 —— + * 说明这个错误已经真咬过人,而且是靠人肉在每条任务书里打补丁。 + * + * 判据刻意宽松:只禁 `localhost:3000`(那个确定是错的),不去校验「必须等于 + * config.port」—— 端口可被 PORT 覆盖,写死校验反而会在部署环境里误报。 + */ +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'module'; +import fs from 'fs'; +import path from 'path'; +const require_ = createRequire(import.meta.url); + +const ROOT = path.resolve(__dirname, '../../..'); +const SCAN = ['packages/agent-core/base', 'system/agents']; +// agent 目录下会长出运行态产物:Python venv、node_modules、下载的浏览器驱动…… +// 那些是**第三方文件,不是提示词**,扫进来只会拿 playwright 的 ThirdPartyNotices 当违例 +// (实测第一版就是这么红的)。守卫要盯的是人写给 agent 看的东西。 +const SKIP_DIR = /(^|\/)(node_modules|\.git|dist|coverage|venv|\.venv|site-packages|__pycache__|workspace|conversations|train)(\/|$)/; + +function walk(dir, out = []) { + if (!fs.existsSync(dir) || SKIP_DIR.test(dir)) return out; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, e.name); + if (e.isDirectory()) walk(full, out); + else if (/\.(md|json|txt)$/.test(e.name)) out.push(full); + } + return out; +} + +describe('agent 提示词里的端口', () => { + const files = SCAN.flatMap((d) => walk(path.join(ROOT, d))); + + it('扫描样本非空 —— 防止路径写错时本测试退化成永远通过', () => { + // 少了这条前置断言,SCAN 里的路径一旦写错就会以「零违例」的形式全绿, + // 而一条永远通过的守卫和没有守卫是一回事。 + expect(files.length).toBeGreaterThan(50); + }); + + it('不许再出现 localhost:3000(实际端口是 3010)', () => { + const offenders = []; + for (const f of files) { + const src = fs.readFileSync(f, 'utf-8'); + src.split('\n').forEach((line, i) => { + if (line.includes('localhost:3000') || line.includes('127.0.0.1:3000')) { + offenders.push(`${path.relative(ROOT, f)}:${i + 1} → ${line.trim().slice(0, 90)}`); + } + }); + } + expect(offenders).toEqual([]); + }); + + it('config.port 的缺省确实是 3010(守卫的前提,变了要先改这里)', () => { + const cfg = require_('../../config'); + expect(cfg.port).toBe(3010); + }); +}); diff --git a/server/tests/unit/boundary-guard-shared-worktree.test.js b/server/tests/unit/boundary-guard-shared-worktree.test.js new file mode 100644 index 0000000..8d42029 --- /dev/null +++ b/server/tests/unit/boundary-guard-shared-worktree.test.js @@ -0,0 +1,82 @@ +/** + * 边界守卫在**多会话共用 worktree** 时不许乱判罚(27-P3C §15.5 第 1 条)。 + * + * 守卫的前提是「本次调用窗口内新出现的仓库改动 = 这个节点写的」。 + * 多会话共用同一个 worktree 时这个前提是假的 —— `git status` 的差集里 + * **没有「谁写的」这一维**。实测已经发生过:批 3 的开发改动把一条 run 判成 + * boundary_escape 卡住,而那些文件是并行的开发会话写的。 + * + * 可检测的信号:节点**开跑那一刻**仓库就已经有源码目录的未提交改动 → + * 有人正在这个 worktree 上开发 → 窗口内新增的源码改动无法归属 → 只记证据不判罚。 + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { createRequire } from 'module'; +const require_ = createRequire(import.meta.url); +const engine = require_('../../services/graph-engine'); + +const OLD = process.env.BOUNDARY_GUARD; +afterEach(() => { + if (OLD === undefined) delete process.env.BOUNDARY_GUARD; + else process.env.BOUNDARY_GUARD = OLD; +}); + +describe('边界守卫 · 多会话 worktree', () => { + it('开跑时仓库干净 → 照常判罚(CI / 干净环境不受影响)', () => { + delete process.env.BOUNDARY_GUARD; + expect(engine.sharedWorktreeReason('')).toBeNull(); + // 只有 agent 运行态的脏,不算「有人在开发」 + expect(engine.sharedWorktreeReason('?? system/agents/foo/\n M data/companies/x/company.json')).toBeNull(); + }); + + it('开跑时源码目录已脏 → 认定有别的会话在写,降级为只记证据', () => { + delete process.env.BOUNDARY_GUARD; + const reason = engine.sharedWorktreeReason(' M server/services/task-scheduler.js\n M web/src/views/GraphConsole.vue'); + expect(reason).toBeTruthy(); + expect(reason).toContain('server/services/task-scheduler.js'); // 点名证据,不是一句空话 + }); + + it('路径含空格/中文被 git 加了引号时也认得出来', () => { + delete process.env.BOUNDARY_GUARD; + expect(engine.sharedWorktreeReason(' M "server/services/新 文件.js"')).toBeTruthy(); + }); + + it('未跟踪的新源码文件(?? 两字符状态位)同样算', () => { + delete process.env.BOUNDARY_GUARD; + expect(engine.sharedWorktreeReason('?? server/services/brand-new.js')).toBeTruthy(); + }); + + it('BOUNDARY_GUARD=strict 强制判罚,脏仓库也不放过', () => { + process.env.BOUNDARY_GUARD = 'strict'; + expect(engine.sharedWorktreeReason(' M server/services/task-scheduler.js')).toBeNull(); + }); + + it('BOUNDARY_GUARD=record 一律只记不判,干净仓库也是', () => { + process.env.BOUNDARY_GUARD = 'record'; + expect(engine.sharedWorktreeReason('')).toBeTruthy(); + }); + + it('降级之后 outcome 不被改成 fail,且证据仍在 escapes 之外留档', () => { + delete process.env.BOUNDARY_GUARD; + const notes = []; + const ctx = { + nodeRunId: 'rn_1', attempt: 1, projectRoot: '/tmp/proj', + // timelineNote 走的是 sse,这里只验 outcome 不被降级;证据面由上面几条覆盖 + }; + const before = ' M server/services/task-scheduler.js'; // 开跑时就脏 + const after = before + '\n M server/services/graph-engine.js'; // 窗口内又多一处 + const out = engine.applyBoundaryGuard(ctx, before, { status: 'success', output: 'ok' }, after); + expect(out.status).toBe('success'); // ← 修之前这里会被判成 fail + expect(out.failReason).toBeFalsy(); + expect(notes).toEqual([]); + }); + + it('对照:仓库开跑时是干净的 → 同样一处越界仍然判 fail', () => { + delete process.env.BOUNDARY_GUARD; + const out = engine.applyBoundaryGuard( + { nodeRunId: 'rn_2', attempt: 1, projectRoot: '/tmp/proj' }, + '', { status: 'success', output: 'ok' }, ' M server/services/graph-engine.js', + ); + expect(out.status).toBe('fail'); + expect(out.failReason).toBe('boundary_escape'); + }); +}); diff --git a/system/agents/hr/agent.md b/system/agents/hr/agent.md index 8e30951..1b93a2a 100644 --- a/system/agents/hr/agent.md +++ b/system/agents/hr/agent.md @@ -67,7 +67,7 @@ node bin/lemon.js task dispatch --from <你自己ID> --to innovator-001 --task " 当被心跳唤醒或收到 CTO/COO 查询时,执行管理检查: ```bash # 查看公司整体状态和所有员工心跳 -curl -s http://localhost:3000/api/company/<公司ID>/status | node -e " +curl -s http://localhost:3010/api/company/<公司ID>/status | node -e " const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); d.agents.forEach(a => console.log(a.id, a.heartbeatActive ? '✅在线' : '❌离线', 'interval:'+a.heartbeatInterval+'min')); " diff --git a/system/agents/qa-judge/agent.md b/system/agents/qa-judge/agent.md index 5e902ef..53fdcaf 100644 --- a/system/agents/qa-judge/agent.md +++ b/system/agents/qa-judge/agent.md @@ -415,7 +415,7 @@ Step 6: 溯源分类 → 粗心错误 vs 能力缺陷判定 **REWORK 场景**: ```bash -curl -X POST http://localhost:3000/api/agent/task/dispatch \ +curl -X POST http://localhost:3010/api/agent/task/dispatch \ -H "Content-Type: application/json" \ -d '{ "from": "ceo", @@ -431,7 +431,7 @@ curl -X POST http://localhost:3000/api/agent/task/dispatch \ **RETRAIN_REQUIRED 场景**: ```bash -curl -X POST http://localhost:3000/api/agent/task/dispatch \ +curl -X POST http://localhost:3010/api/agent/task/dispatch \ -H "Content-Type: application/json" \ -d '{ "from": "ceo", diff --git a/web/src/views/TaskTimeline.vue b/web/src/views/TaskTimeline.vue index 3c9cacc..1506e61 100644 --- a/web/src/views/TaskTimeline.vue +++ b/web/src/views/TaskTimeline.vue @@ -48,10 +48,16 @@ +
- Convs: {{ task.conversationCount }} - - · {{ formatTime(task.updatedAt) }} + + + {{ formatTime(task.updatedAt) }}