Ctrl K
ComunidadGuías…

test/jobs-restart.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { loadConfig, saveActiveSession, recoverCrashedSessions, sessionAgent, sessionPermissionFlags, sessionOrigin } from '../server/config.js';
import { config, orphans } from '../server/state.js';
import { CONFIG_PATH, CONFIG_DIR } from '../server/state.js';

// loadConfig reads CONFIG_PATH, which test/setup.js has already redirected to a
// throwaway directory via AGENT007_CONFIG_DIR.
function writeConfig(jobs) {
  mkdirSync(CONFIG_DIR, { recursive: true });
  writeFileSync(CONFIG_PATH, JSON.stringify({
    version: 1, repos: [], orphans: [], activeSessions: [], jobs,
  }));
}

afterEach(() => {
  try { rmSync(CONFIG_PATH); } catch {}
});

describe('restart recovery for in-flight jobs', () => {
  it('keeps a job that already has a branch in in-progress', () => {
    // The agent may have pushed and opened the PR before the server died.
    // Sending it back to To do would dispatch a second agent onto a `-2`
    // branch to redo work that is already up for review, and open a duplicate
    // PR. checkPullRequests can still resolve it from the branch alone.
    writeConfig([{
      id: 'j1', title: 'shipped it', repoPath: '/r', state: 'in-progress',
      agentSessionId: 'session-9', agentName: 'Viper',
      branchName: 'bill/add-thing', startedAt: '2026-08-27T00:00:00Z',
    }]);
    loadConfig();
    const job = config.jobs[0];
    expect(job.state).toBe('in-progress');
    expect(job.branchName).toBe('bill/add-thing');
    // The dead session link must go, or the cap would count a PTY that is gone.
    expect(job.agentSessionId).toBeNull();
    // The NAME is history — "Viper did this work" stays true across a restart,
    // and it is the credit the card exists to show. Only the session link,
    // which cannot survive the process, is dropped.
    expect(job.agentName).toBe('Viper');
    expect(job.startedAt).toBe('2026-08-27T00:00:00Z');
    expect(job.lastError).toMatch(/bill\/add-thing/);
  });

  // Nothing watches a no-PR card, so its note must not say the board is.
  it('points a no-PR job at re-adopting its agent, not at a PR watch', () => {
    writeConfig([{
      id: 'j1', title: 'research', repoPath: '/r', state: 'in-progress', requiresPr: false,
      agentSessionId: 'session-9', agentName: 'Viper', branchName: 'bill/research',
    }]);
    loadConfig();
    expect(config.jobs[0].lastError).toMatch(/re-adopt/);
    expect(config.jobs[0].lastError).not.toMatch(/watching for its PR/);
  });

  it('requeues a job that never got as far as a branch', () => {
    writeConfig([{
      id: 'j2', title: 'never started', repoPath: '/r', state: 'in-progress',
      agentSessionId: 'session-1', agentName: 'Apex', branchName: null,
      startedAt: '2026-08-27T00:00:00Z',
    }]);
    loadConfig();
    const job = config.jobs[0];
    expect(job.state).toBe('todo');
    expect(job.agentSessionId).toBeNull();
    expect(job.startedAt).toBeNull();
  });

  it('leaves todo and review jobs alone', () => {
    writeConfig([
      { id: 'a', title: 'queued', repoPath: '/r', state: 'todo' },
      { id: 'b', title: 'done', repoPath: '/r', state: 'review', prNumber: 3, branchName: 'bill/x' },
    ]);
    loadConfig();
    expect(config.jobs.map(j => j.state)).toEqual(['todo', 'review']);
    expect(config.jobs[1].prNumber).toBe(3);
  });

  it('survives a config with no jobs key at all', () => {
    mkdirSync(CONFIG_DIR, { recursive: true });
    writeFileSync(CONFIG_PATH, JSON.stringify({ version: 1, repos: [] }));
    loadConfig();
    expect(config.jobs).toEqual([]);
  });
});

describe('restart recovery for a running agent', () => {
  beforeEach(() => orphans.clear());
  afterEach(() => orphans.clear());

  it('remembers which CLI the agent ran, so the orphan resumes with the right one', () => {
    // The worktree must exist or the crashed session is skipped as gone.
    const wt = mkdtempSync(join(tmpdir(), 'a007-wt-'));
    const wt2 = mkdtempSync(join(tmpdir(), 'a007-wt-'));
    const wt3 = mkdtempSync(join(tmpdir(), 'a007-wt-'));
    try {
      writeConfig([]);
      loadConfig();
      saveActiveSession({ name: 'Onyx', command: 'codex --dangerously-bypass-approvals-and-sandbox "do it"', repoPath: '/r', repoSlug: 'r', worktreePath: wt, branchName: 'b/onyx', color: '#000', cocktail: 'onyx' });
      saveActiveSession({ name: 'Viper', command: 'claude --permission-mode auto "do it"', repoPath: '/r', repoSlug: 'r', worktreePath: wt2, branchName: 'b/viper', color: '#000', cocktail: 'viper' });
      expect(config.activeSessions.map(s => s.agent)).toEqual(['codex', 'claude']);
      // And the permission flags each was started with, for a re-spawn that
      // has no job card to ask.
      expect(config.activeSessions.map(s => s.permissionFlags)).toEqual([['--dangerously-bypass-approvals-and-sandbox'], ['--permission-mode', 'auto']]);
      saveActiveSession({ name: 'Dispatched', command: 'codex "job"', spawnedBy: 'board', jobId: 'job-7', approvalsToBillion: true, repoPath: '/r', repoSlug: 'r', worktreePath: wt3, branchName: 'b/dispatched', color: '#000', cocktail: 'dispatched' });
      expect(config.activeSessions.map(s => s.origin)).toEqual(['user', 'user', 'board']);

      // What the next start does with that record.
      loadConfig();
      recoverCrashedSessions();
      const byName = Object.fromEntries([...orphans.values()].map(o => [o.name, o]));
      expect(byName.Onyx.agent).toBe('codex');
      expect(byName.Onyx.permissionFlags).toEqual(['--dangerously-bypass-approvals-and-sandbox']);
      expect(byName.Viper.agent).toBe('claude');
      expect(byName.Viper.permissionFlags).toEqual(['--permission-mode', 'auto']);
      expect(byName.Dispatched.origin).toBe('board');
      // The card it worked on and where its approvals go survive the restart.
      expect(byName.Dispatched.jobId).toBe('job-7');
      expect(byName.Dispatched.approvalsToBillion).toBe(true);
      expect(byName.Onyx.jobId).toBeNull();
      expect(byName.Onyx.approvalsToBillion).toBe(false);
      expect(byName.Onyx.origin).toBe('user');
      expect(config.orphans.find(o => o.name === 'Onyx').permissionFlags).toEqual(['--dangerously-bypass-approvals-and-sandbox']);
      expect(config.orphans.find(o => o.name === 'Onyx').agent).toBe('codex');   // persisted, for the restart after this one
    } finally {
      rmSync(wt, { recursive: true, force: true });
      rmSync(wt2, { recursive: true, force: true });
      rmSync(wt3, { recursive: true, force: true });
    }
  });

  it('prefers the session\'s own note over its command, and derives one only when there is none', () => {
    expect(sessionAgent({ agent: 'codex', command: 'bash -lc codex' })).toBe('codex');
    expect(sessionAgent({ agent: 'claude', command: 'codex resume' })).toBe('claude');
    expect(sessionAgent({ agent: null, command: 'codex resume' })).toBeNull();
    expect(sessionAgent({ command: 'claude --continue' })).toBe('claude');
    expect(sessionAgent({ command: 'gemini' })).toBeNull();
    expect(sessionAgent({})).toBeNull();
  });

  it('records flags for a session that owns them, and none for a board dispatch', async () => {
    // A board dispatch runs under its card's mode, which the board resolves
    // again at every re-spawn against its current setting; freezing the
    // dispatch-time flags on the session would let a bypass card retired long
    // ago come back as a bypass agent after the board was tightened.
    const { createSessionFromConfig } = await import('../server/pty.js');
    const spawn = (extra) => createSessionFromConfig({
      sessionId: `s-${Math.random().toString(36).slice(2)}`, name: 'T', color: '#000',
      command: process.platform === 'win32' ? 'cmd /c exit' : 'true', ...extra,
    }, () => {});
    // The command is a stand-in; the flags are judged from its text only when the CLI is one of ours,
    // so pass them in the way the ws re-adopt does and check the rule, not the parser.
    const own = spawn({ spawnedBy: 'user', permissionFlags: ['--sandbox', 'read-only'] });
    const board = spawn({ spawnedBy: 'board', permissionFlags: undefined });
    const passed = spawn({ spawnedBy: 'board', permissionFlags: ['--approve-for-me'] });
    try {
      expect(own.session.permissionFlags).toEqual(['--sandbox', 'read-only']);
      expect(board.session.permissionFlags).toEqual([]);
      expect(passed.session.permissionFlags).toEqual(['--approve-for-me']);   // an explicit answer wins
      // Lineage: a board dispatch is 'board'; a re-adopt opened as a user tab
      // keeps the orphan's origin it was handed.
      expect(own.session.origin).toBe('user');
      expect(board.session.origin).toBe('board');
      const readopted = spawn({ spawnedBy: undefined, origin: 'board', permissionFlags: [] });
      try { expect(readopted.session.origin).toBe('board'); expect(readopted.session.spawnedBy).toBe('user'); }
      finally { try { readopted.session.pty.kill(); } catch {} clearInterval(readopted.session.stateCheckInterval); }
      // And on the records: the active-session entry and the orphan it becomes.
      expect(sessionOrigin({ spawnedBy: 'board' })).toBe('board');
      expect(sessionOrigin({ origin: 'board', spawnedBy: 'user' })).toBe('board');
      expect(sessionOrigin({ spawnedBy: 'user' })).toBe('user');
      expect(sessionOrigin({})).toBe('user');
    } finally {
      for (const r of [own, board, passed]) { try { r.session.pty.kill(); } catch {} clearInterval(r.session.stateCheckInterval); }
    }
  });

  it('tolerates a session record written before the CLI was noted', () => {
    const wt = mkdtempSync(join(tmpdir(), 'a007-wt-'));
    try {
      mkdirSync(CONFIG_DIR, { recursive: true });
      writeFileSync(CONFIG_PATH, JSON.stringify({
        version: 1, repos: [], orphans: [], jobs: [],
        activeSessions: [{ name: 'Old', repoPath: '/r', repoSlug: 'r', worktreePath: wt, branchName: 'b/old', color: '#000' }],
      }));
      loadConfig();
      recoverCrashedSessions();
      expect([...orphans.values()][0].agent).toBeNull();
      expect([...orphans.values()][0].permissionFlags).toEqual([]);
    } finally {
      rmSync(wt, { recursive: true, force: true });
    }
  });

  it('passes a stored record\'s flags through the allowlist again, and only with a CLI of ours', () => {
    // config.json is hand-editable: what comes back onto the resume argv is
    // whatever the allowlist lets through, not whatever the file says. Each
    // record needs its own live worktree, or recovery skips it as gone.
    const wts = Array.from({ length: 5 }, () => mkdtempSync(join(tmpdir(), 'a007-wt-')));
    try {
      mkdirSync(CONFIG_DIR, { recursive: true });
      writeFileSync(CONFIG_PATH, JSON.stringify({
        version: 1, repos: [], orphans: [], jobs: [],
        activeSessions: [
          { name: 'Junk', agent: 'codex', permissionFlags: ['--sandbox', 'nope', '--approve-for-me', '--rm', '-rf', '/'], worktreePath: wts[0], branchName: 'b/junk' },
          { name: 'Text', agent: 'codex', permissionFlags: '--approve-for-me', worktreePath: wts[1], branchName: 'b/text' },
          { name: 'Cross', agent: 'claude', permissionFlags: ['--sandbox', 'read-only'], worktreePath: wts[2], branchName: 'b/cross' },
          { name: 'Alien', agent: 'gemini', permissionFlags: ['--permission-mode', 'plan'], worktreePath: wts[3], branchName: 'b/alien' },
          { name: 'Older', agent: 'claude', worktreePath: wts[4], branchName: 'b/older' },   // written before the flags were noted
        ],
      }));
      loadConfig();
      recoverCrashedSessions();
      const byName = Object.fromEntries([...orphans.values()].map(o => [o.name, [o.agent, o.permissionFlags]]));
      expect(byName).toEqual({
        Junk: ['codex', ['--approve-for-me']],
        Text: ['codex', []],
        Cross: ['claude', []],
        Alien: [null, []],
        Older: ['claude', []],
      });
      // And that is what is persisted for the restart after this one.
      expect(config.orphans.find(o => o.name === 'Junk').permissionFlags).toEqual(['--approve-for-me']);
      expect(config.activeSessions).toEqual([]);
    } finally {
      for (const wt of wts) rmSync(wt, { recursive: true, force: true });
    }
  });

  it('takes the flags a session carries over its command, and reads the command only when it carries none', () => {
    // Every PTY session carries them (createSessionFromConfig reads them off
    // the command at spawn); a bare object, as a test or an older caller
    // builds one, gets them read off the command the same way.
    expect(sessionPermissionFlags({ permissionFlags: ['--approve-for-me'], command: 'codex -s read-only' })).toEqual(['--approve-for-me']);
    expect(sessionPermissionFlags({ permissionFlags: [], command: 'codex -s read-only' })).toEqual([]);
    expect(sessionPermissionFlags({ command: 'codex -s read-only' })).toEqual(['--sandbox', 'read-only']);
    expect(sessionPermissionFlags({ permissionFlags: '--approve-for-me', command: 'claude --permission-mode plan' })).toEqual(['--permission-mode', 'plan']);
    expect(sessionPermissionFlags({ command: 'bash -lc "codex -s read-only"' })).toEqual([]);
    expect(sessionPermissionFlags({ command: 'gemini --yolo' })).toEqual([]);
    expect(sessionPermissionFlags({})).toEqual([]);

    // A re-adopted session's own note goes onto its record verbatim, whatever
    // its command says: the note came off the command at spawn, and the
    // record is re-checked against the allowlist on the way back in.
    const wt = mkdtempSync(join(tmpdir(), 'a007-wt-'));
    try {
      writeConfig([]);
      loadConfig();
      saveActiveSession({ name: 'Back', command: 'codex resume --sandbox read-only', permissionFlags: ['--sandbox', 'read-only'], agent: 'codex', worktreePath: wt, branchName: 'b/back' });
      saveActiveSession({ name: 'Guess', command: 'codex resume --sandbox read-only', permissionFlags: ['--sandbox', 'read-only'], agent: null, worktreePath: wt, branchName: 'b/guess' });
      saveActiveSession({ name: 'Bare', worktreePath: wt, branchName: 'b/bare' });
      expect(config.activeSessions.map(s => [s.name, s.agent, s.permissionFlags])).toEqual([
        ['Back', 'codex', ['--sandbox', 'read-only']],
        ['Guess', null, ['--sandbox', 'read-only']],
        ['Bare', null, []],
      ]);
    } finally {
      rmSync(wt, { recursive: true, force: true });
    }
  });

  it('notes a CLI only for a command that is literally one, and codex by the binary alone', () => {
    // A re-adopted agent already on `codex resume` and one spawned as
    // /opt/homebrew/bin/codex each record what their own re-adopt would need.
    // A plain terminal tab, a gemini, and a Codex started from inside a shell
    // record nothing: the note outranks every other witness at re-adopt time,
    // and "claude" by default would have sent a `bash -lc codex` back up as
    // Claude Code, past the job card and the transcripts that knew better. A
    // session with no worktree has nothing to re-adopt and is not recorded.
    const wt = mkdtempSync(join(tmpdir(), 'a007-wt-'));
    try {
      writeConfig([]);
      loadConfig();
      saveActiveSession({ name: 'Bare', worktreePath: wt, branchName: 'b/bare' });
      saveActiveSession({ name: 'Back', command: 'codex resume', worktreePath: wt, branchName: 'b/back' });
      saveActiveSession({ name: 'Brew', command: '/opt/homebrew/bin/codex --model o3', worktreePath: wt, branchName: 'b/brew' });
      saveActiveSession({ name: 'Gemini', command: 'gemini', worktreePath: wt, branchName: 'b/gemini' });
      saveActiveSession({ name: 'Shell', command: 'bash -lc codex', worktreePath: wt, branchName: 'b/shell' });
      // A re-adopted orphan whose CLI was only guessed carries agent: null on
      // the session itself, and that provenance beats its resume command —
      // otherwise a wrong guess would be recorded as fact on the next close.
      saveActiveSession({ name: 'Guess', command: 'codex resume', agent: null, worktreePath: wt, branchName: 'b/guess' });
      saveActiveSession({ name: 'NoTree', command: 'codex' });
      expect(config.activeSessions.map(s => [s.name, s.agent])).toEqual([
        ['Bare', null], ['Back', 'codex'], ['Brew', 'codex'], ['Gemini', null], ['Shell', null], ['Guess', null],
      ]);
    } finally {
      rmSync(wt, { recursive: true, force: true });
    }
  });
});

describe('restart recovery for finished cards', () => {
  it('drops the dead agent link on review and done cards, keeping the credit', () => {
    // Session ids come from a counter that restarts at zero, so a stale
    // `session-3` on an archived card can later name a completely unrelated
    // live agent — and deleting that card would kill it and release its
    // worktree. The name stays: it is the record of who did the work.
    writeConfig([
      {
        id: 'j1', title: 'in review', repoPath: '/r', state: 'review',
        agentSessionId: 'session-3', agentName: 'Viper',
        branchName: 'bill/a', prNumber: 1, reviewAt: '2026-08-27T00:00:00Z',
      },
      {
        id: 'j2', title: 'archived', repoPath: '/r', state: 'done',
        agentSessionId: 'session-3', agentName: 'Apex',
        branchName: 'bill/b', prNumber: 2,
        prMergedAt: '2026-08-27T00:00:00Z', doneAt: '2026-08-27T00:00:01Z',
      },
    ]);
    loadConfig();
    for (const job of config.jobs) expect(job.agentSessionId).toBeNull();
    expect(config.jobs[0].agentName).toBe('Viper');
    expect(config.jobs[1].agentName).toBe('Apex');
    // Nothing else about a finished card moves.
    expect(config.jobs[1].state).toBe('done');
    expect(config.jobs[1].prMergedAt).toBe('2026-08-27T00:00:00Z');
  });

});

describe('stale agent links after a restart', () => {
  it('clears the session link on a review job too, not just in-flight ones', () => {
    // Session ids are only unique within a process generation, so a link kept
    // on a finished card could resolve to an unrelated agent after a restart.
    writeConfig([{
      id: 'r1', title: 'shipped', repoPath: '/r', state: 'review',
      agentSessionId: 'session-5', agentName: 'Shadow',
      branchName: 'bill/x', prNumber: 20,
    }]);
    loadConfig();
    const job = config.jobs[0];
    expect(job.agentSessionId).toBeNull();
    expect(job.agentName).toBe('Shadow');   // credit kept
    expect(job.state).toBe('review');
    expect(job.prNumber).toBe(20);
  });

  it('clears it on a todo job as well', () => {
    writeConfig([{ id: 't1', title: 'queued', repoPath: '/r', state: 'todo', agentSessionId: 'session-2' }]);
    loadConfig();
    expect(config.jobs[0].agentSessionId).toBeNull();
  });
});

describe('a schedule saved mid-run by a server from before v0.4.9.0', () => {
  it('goes back to To do, naming the dead run\'s branch', () => {
    writeConfig([{
      id: 's1', title: 'Daily digest', repoPath: '/r', type: 'scheduled', schedule: '0 9 * * *',
      state: 'in-progress', agentSessionId: 'session-9', agentName: 'Viper', lastRunSessionId: 'session-8',
      branchName: 'bill/digest', runCount: 4,
    }]);
    loadConfig();
    const job = config.jobs[0];
    expect(config.jobs).toHaveLength(1);
    expect(job.state).toBe('todo');
    expect(job.branchName).toBeNull();
    expect(job.runCount).toBe(4);
    expect(job.lastRunSessionId).toBeUndefined();
    expect(job.lastError).toMatch(/bill\/digest/);
  });

  it('goes back to To do from Review too, and says nothing of a branch it never had', () => {
    writeConfig([{
      id: 's2', title: 'Daily digest', repoPath: '/r', type: 'scheduled', schedule: '@daily',
      state: 'review', agentSessionId: 'session-3', lastError: null,
    }]);
    loadConfig();
    expect(config.jobs[0].state).toBe('todo');
    expect(config.jobs[0].lastError).toBeNull();
  });
});