boundary-guard-shared-worktree.test.js 3.87 KB
/**
 * 边界守卫在**多会话共用 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');
  });
});