/** * 边界守卫在**多会话共用 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'); }); });