Ctrl K
ComunidadGuías…

__tests__/operator-resume.test.js

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

Archivo original ↗Preguntar a KiBot
/**
 * Auto-resume: a job whose journal says `running` when the process comes back is picked up with its
 * task list, budget and a digest of its last steps; the model is told it resumed; a job interrupted
 * MAX_RESUMES times is marked interrupted instead of coming back forever. Journals live in a temp dir.
 */
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { OperatorRun, interruptedJobs, MAX_RESUMES } from '../src/operator/harness.js';
import { Registry } from '../src/operator/registry.js';

const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'op-'));
const scripted = (turns) => { let i = 0; return async ({ messages }) => { const t = turns[Math.min(i, turns.length - 1)]; i++; return typeof t === 'function' ? t(messages) : t; }; };
const call = (name, args) => ({ content: '', toolCalls: [{ name, args }] });

function reg() {
  const r = new Registry();
  r.register('read_thing', 'read', { type: 'object', properties: { id: { type: 'string' } } }, async ({ id }) => ({ id, value: 'v' + id }));
  return r;
}

/** A journal as a roll leaves it: status running, two tasks (one done), a few events. */
function interruptedJournal(dir, extra = {}) {
  const rec = {
    id: 'op-test-1', goal: 'prove b', status: 'running', iterations: 3, budget: 190, resumes: 0,
    tasks: [{ i: 1, title: 'read a', done: true, note: 'a is va' }, { i: 2, title: 'prove b', done: false }],
    events: [{ t: 1, kind: 'thought', text: 'reading a' }, { t: 2, kind: 'tool', name: 'read_thing', args: { id: 'a' } }, { t: 3, kind: 'result', name: 'read_thing', text: '{"value":"va"}' }],
    startedAt: 1000, endedAt: 0, error: null, messages: [{ role: 'system', content: 'sys' }, { role: 'tool', content: 'stray' }], ...extra,
  };
  fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(path.join(dir, rec.id + '.json'), JSON.stringify(rec));
  return rec;
}

describe('operator auto-resume', () => {
  it('lists only journals left running, oldest first, and retires one resumed too often', () => {
    const dir = tmp();
    interruptedJournal(dir);
    interruptedJournal(dir, { id: 'op-test-0', startedAt: 500 });
    interruptedJournal(dir, { id: 'op-done', status: 'done' });
    interruptedJournal(dir, { id: 'op-tired', resumes: MAX_RESUMES });
    const jobs = interruptedJobs(dir);
    expect(jobs.map((j) => j.id)).toEqual(['op-test-0', 'op-test-1']);
    const tired = JSON.parse(fs.readFileSync(path.join(dir, 'op-tired.json'), 'utf8'));
    expect(tired.status).toBe('interrupted'); expect(tired.finalLine).toMatch(/not resumed again/);
  });

  it('resumes with the task list, budget, a digest of the last steps and the raw transcript NOT replayed', async () => {
    const dir = tmp(); const rec = interruptedJournal(dir);
    let firstMessages = null;
    const chat = scripted([
      (messages) => { firstMessages = messages.map((m) => ({ ...m })); return call('read_thing', { id: 'b' }); },
      call('update_task', { index: 1, done: true, note: 'b is vb' }),
      call('finish', { status: 'done', summary: 'b proven', report: { evidence: 'vb' } }),
    ]);
    const run = new OperatorRun({ goal: 'ignored — the journal wins', restore: interruptedJobs(dir)[0], chat, registry: reg(), systemPrompt: 'sys', persistDir: dir });
    expect(run.id).toBe(rec.id); expect(run.goal).toBe('prove b'); expect(run.budget).toBe(190); expect(run.iterations).toBe(3);
    const v = await run.run();
    expect(v.status).toBe('done'); expect(v.resumes).toBe(1); expect(v.iterations).toBe(6);
    // the model saw: system, orientation with the carried task list, then the resume digest — no stray tool turn
    expect(firstMessages[0].role).toBe('system');
    expect(firstMessages[1].content).toMatch(/\[x\] 1\. read a — a is va/); expect(firstMessages[1].content).toMatch(/\[ \] 2\. prove b/);
    expect(firstMessages[2].content).toMatch(/^\[Resumed\]/); expect(firstMessages[2].content).toMatch(/call read_thing/); expect(firstMessages[2].content).toMatch(/resume 1, 3 iterations/);
    expect(firstMessages.some((m) => m.content === 'stray')).toBe(false);
    // the journal is the same file, now done, carrying the resume count and the resume event
    const j = JSON.parse(fs.readFileSync(path.join(dir, rec.id + '.json'), 'utf8'));
    expect(j.status).toBe('done'); expect(j.resumes).toBe(1); expect(j.events.some((e) => e.kind === 'resume')).toBe(true);
    expect(fs.readdirSync(dir)).toEqual([rec.id + '.json']);
  });

  it('counts resumes across restarts so the cap is reached', () => {
    const dir = tmp(); interruptedJournal(dir, { resumes: MAX_RESUMES - 1 });
    const run = new OperatorRun({ goal: 'x', restore: interruptedJobs(dir)[0], chat: async () => ({ content: '', toolCalls: [] }), registry: reg(), systemPrompt: 'sys', persistDir: dir });
    run.status = 'running'; run._persist();   // as a roll would leave it
    const j = JSON.parse(fs.readFileSync(path.join(dir, 'op-test-1.json'), 'utf8'));
    expect(j.resumes).toBe(MAX_RESUMES);
    expect(interruptedJobs(dir)).toEqual([]);   // next boot: retired, not resumed a fourth time
  });
});