import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { existsSync, readFileSync } from 'fs' import { mkdir, writeFile } from 'fs/promises' import { join, resolve as resolvePath } from 'path' import { resetStateForTests, setCwdState, setOriginalCwd, setProjectRoot, } from '../../bootstrap/state' import { createAutonomyRun, formatAutonomyRunsList, formatAutonomyRunsStatus, listAutonomyRuns, createAutonomyQueuedPrompt, createAutonomyQueuedPromptIfNoActiveSource, createProactiveAutonomyCommands, finalizeAutonomyRunCompleted, getAutonomyRunById, hasActiveAutonomyRunForSource, markAutonomyRunCompleted, markAutonomyRunCancelled, markAutonomyRunFailed, markAutonomyRunRunning, recoverManagedAutonomyFlowPrompt, resolveAutonomyRunsPath, STALE_ACTIVE_RUN_ERROR_PREFIX, startManagedAutonomyFlowFromHeartbeatTask, } from '../autonomyRuns' import { formatAutonomyFlowsList, getAutonomyFlowById, listAutonomyFlows, } from '../autonomyFlows' import { AUTONOMY_DIR, resetAutonomyAuthorityForTests, } from '../autonomyAuthority' import { resetCommandQueue } from '../messageQueueManager' import { cleanupTempDir, createTempDir, createTempSubdir, writeTempFile, } from '../../../tests/mocks/file-system' const AGENTS_REL = join(AUTONOMY_DIR, 'AGENTS.md') const HEARTBEAT_REL = join(AUTONOMY_DIR, 'HEARTBEAT.md') let tempDir = '' beforeEach(async () => { tempDir = await createTempDir('autonomy-runs-') resetStateForTests() resetAutonomyAuthorityForTests() resetCommandQueue() setOriginalCwd(tempDir) setProjectRoot(tempDir) }) afterEach(async () => { resetStateForTests() resetAutonomyAuthorityForTests() resetCommandQueue() if (tempDir) { await cleanupTempDir(tempDir) } }) describe('autonomyRuns', () => { test('createAutonomyQueuedPrompt records a queued automatic run and returns a prompt command', async () => { const currentDir = await createTempSubdir(tempDir, 'nested') await writeTempFile(tempDir, AGENTS_REL, 'root authority') const command = await createAutonomyQueuedPrompt({ basePrompt: 'Review nightly report', trigger: 'scheduled-task', rootDir: tempDir, currentDir, sourceId: 'cron-1', sourceLabel: 'nightly-report', workload: 'cron', }) const runs = await listAutonomyRuns(tempDir) const flows = await listAutonomyFlows(tempDir) expect(command).not.toBeNull() expect(command!.mode).toBe('prompt') expect(command!.isMeta).toBe(true) expect(command!.autonomy?.trigger).toBe('scheduled-task') expect(command!.autonomy?.sourceId).toBe('cron-1') expect(command!.origin).toBeDefined() expect(command!.value).toContain('root authority') expect(runs).toHaveLength(1) expect(runs[0]).toMatchObject({ runId: command!.autonomy?.runId, runtime: 'automatic', trigger: 'scheduled-task', status: 'queued', ownerKey: 'main-thread', sourceId: 'cron-1', sourceLabel: 'nightly-report', ownerProcessId: process.pid, }) expect(runs[0]?.ownerSessionId).toBeString() expect(flows).toHaveLength(0) expect(resolveAutonomyRunsPath(tempDir)).toContain('.claude') }) test('createAutonomyQueuedPrompt defaults currentDir to the active cwd for nested authority', async () => { const nestedDir = await createTempSubdir(tempDir, 'nested') await writeTempFile(tempDir, AGENTS_REL, 'root authority') await writeTempFile(nestedDir, AGENTS_REL, 'nested authority') setOriginalCwd(nestedDir) setCwdState(nestedDir) const command = await createAutonomyQueuedPrompt({ basePrompt: '12:00:00', trigger: 'proactive-tick', rootDir: tempDir, }) expect(command).not.toBeNull() expect(command!.value).toContain('root authority') expect(command!.value).toContain('nested authority') }) test('markAutonomyRunRunning/completed update persisted lifecycle state for plain runs', async () => { const command = await createAutonomyQueuedPrompt({ basePrompt: '12:00:00', trigger: 'proactive-tick', rootDir: tempDir, currentDir: tempDir, }) expect(command).not.toBeNull() const runId = command!.autonomy!.runId await markAutonomyRunRunning(runId, tempDir, 100) let runs = await listAutonomyRuns(tempDir) expect(runs[0]).toMatchObject({ runId, status: 'running', startedAt: 100, ownerProcessId: process.pid, }) expect(runs[0]?.ownerSessionId).toBeString() await markAutonomyRunCompleted(runId, tempDir, 200) runs = await listAutonomyRuns(tempDir) expect(runs[0]).toMatchObject({ runId, status: 'completed', endedAt: 200, }) }) test('markAutonomyRunFailed updates a non-terminal run', async () => { const command = await createAutonomyQueuedPrompt({ basePrompt: '12:00:00', trigger: 'proactive-tick', rootDir: tempDir, currentDir: tempDir, }) expect(command).not.toBeNull() const runId = command!.autonomy!.runId await markAutonomyRunRunning(runId, tempDir, 100) await markAutonomyRunFailed(runId, 'boom', tempDir, 300) const runs = await listAutonomyRuns(tempDir) expect(runs[0]).toMatchObject({ runId, status: 'failed', endedAt: 300, error: 'boom', }) }) test('terminal runs are not revived by stale lifecycle updates', async () => { const command = await createAutonomyQueuedPrompt({ basePrompt: 'scheduled prompt', trigger: 'scheduled-task', rootDir: tempDir, currentDir: tempDir, }) expect(command).not.toBeNull() const runId = command!.autonomy!.runId await markAutonomyRunCancelled(runId, tempDir, 100) const revived = await markAutonomyRunRunning(runId, tempDir, 200) const completed = await markAutonomyRunCompleted(runId, tempDir, 300) const failed = await markAutonomyRunFailed( runId, 'late failure', tempDir, 400, ) const persisted = await getAutonomyRunById(runId, tempDir) expect(revived).toBeNull() expect(completed).toBeNull() expect(failed).toBeNull() expect(persisted).toMatchObject({ status: 'cancelled', endedAt: 100, }) expect(persisted!.error).toBeUndefined() }) test('hasActiveAutonomyRunForSource only treats queued and running scheduled runs as active', async () => { const command = await createAutonomyQueuedPrompt({ basePrompt: 'scheduled prompt', trigger: 'scheduled-task', rootDir: tempDir, currentDir: tempDir, sourceId: 'cron-1', sourceLabel: 'nightly', }) expect(command).not.toBeNull() const runId = command!.autonomy!.runId await expect( hasActiveAutonomyRunForSource({ trigger: 'scheduled-task', sourceId: 'cron-1', rootDir: tempDir, }), ).resolves.toBe(true) await markAutonomyRunRunning(runId, tempDir, 100) await expect( hasActiveAutonomyRunForSource({ trigger: 'scheduled-task', sourceId: 'cron-1', rootDir: tempDir, }), ).resolves.toBe(true) await expect( hasActiveAutonomyRunForSource({ trigger: 'scheduled-task', sourceId: 'cron-2', rootDir: tempDir, }), ).resolves.toBe(false) await markAutonomyRunCompleted(runId, tempDir, 200) await expect( hasActiveAutonomyRunForSource({ trigger: 'scheduled-task', sourceId: 'cron-1', rootDir: tempDir, }), ).resolves.toBe(false) const failedCommand = await createAutonomyQueuedPrompt({ basePrompt: 'scheduled prompt', trigger: 'scheduled-task', rootDir: tempDir, currentDir: tempDir, sourceId: 'cron-1', }) expect(failedCommand).not.toBeNull() await markAutonomyRunFailed( failedCommand!.autonomy!.runId, 'boom', tempDir, 300, ) await expect( hasActiveAutonomyRunForSource({ trigger: 'scheduled-task', sourceId: 'cron-1', rootDir: tempDir, }), ).resolves.toBe(false) }) test('createAutonomyQueuedPromptIfNoActiveSource atomically skips duplicate active scheduled sources', async () => { const [first, second] = await Promise.all([ createAutonomyQueuedPromptIfNoActiveSource({ basePrompt: 'scheduled prompt', trigger: 'scheduled-task', rootDir: tempDir, currentDir: tempDir, sourceId: 'cron-1', }), createAutonomyQueuedPromptIfNoActiveSource({ basePrompt: 'scheduled prompt', trigger: 'scheduled-task', rootDir: tempDir, currentDir: tempDir, sourceId: 'cron-1', }), ]) const created = [first, second].filter(command => command !== null) const runs = await listAutonomyRuns(tempDir) expect(created).toHaveLength(1) expect(runs).toHaveLength(1) expect(runs[0]).toMatchObject({ trigger: 'scheduled-task', status: 'queued', sourceId: 'cron-1', }) }) test('createAutonomyQueuedPromptIfNoActiveSource scopes dedup by ownerKey', async () => { const first = await createAutonomyQueuedPromptIfNoActiveSource({ basePrompt: 'scheduled prompt', trigger: 'scheduled-task', rootDir: tempDir, currentDir: tempDir, sourceId: 'cron-1', ownerKey: 'owner-a', }) const second = await createAutonomyQueuedPromptIfNoActiveSource({ basePrompt: 'scheduled prompt', trigger: 'scheduled-task', rootDir: tempDir, currentDir: tempDir, sourceId: 'cron-1', ownerKey: 'owner-b', }) const runs = await listAutonomyRuns(tempDir) expect(first).not.toBeNull() expect(second).not.toBeNull() expect(runs).toHaveLength(2) expect(new Set(runs.map(run => run.ownerKey))).toEqual( new Set(['owner-a', 'owner-b']), ) }) test('createAutonomyQueuedPromptIfNoActiveSource does not advance heartbeat last-run state on dedup skip (two-phase commit invariant)', async () => { await writeTempFile( tempDir, HEARTBEAT_REL, [ 'tasks:', ' - name: inbox', ' interval: 30m', ' prompt: "Check inbox"', ].join('\n'), ) // Seed an active queued run for cron-1 so the next dedup attempt skips. await mkdir(join(tempDir, AUTONOMY_DIR), { recursive: true }) await writeFile( resolveAutonomyRunsPath(tempDir), `${JSON.stringify( { runs: [ { runId: 'preexisting-active', runtime: 'automatic', trigger: 'scheduled-task', status: 'queued', rootDir: tempDir, currentDir: tempDir, sourceId: 'cron-1', promptPreview: 'still queued', createdAt: 100, ownerProcessId: process.pid, ownerSessionId: 'self', }, ], }, null, 2, )}\n`, 'utf-8', ) const skipped = await createAutonomyQueuedPromptIfNoActiveSource({ basePrompt: 'scheduled prompt', trigger: 'scheduled-task', rootDir: tempDir, currentDir: tempDir, sourceId: 'cron-1', }) expect(skipped).toBeNull() // If the dedup skip wrongly advanced heartbeat state, the next // proactive-tick prompt would NOT include the inbox task. Verify it // still does. const followUp = await createAutonomyQueuedPrompt({ basePrompt: '12:00:00', trigger: 'proactive-tick', rootDir: tempDir, currentDir: tempDir, }) expect(followUp).not.toBeNull() expect(followUp!.value).toContain('Due HEARTBEAT.md tasks:') expect(followUp!.value).toContain('- inbox (30m): Check inbox') }) test('createAutonomyQueuedPromptIfNoActiveSource recovers stale active runs from dead owner processes', async () => { await mkdir(join(tempDir, AUTONOMY_DIR), { recursive: true }) await writeFile( resolveAutonomyRunsPath(tempDir), `${JSON.stringify( { runs: [ { runId: 'stale-run', runtime: 'automatic', trigger: 'scheduled-task', status: 'running', rootDir: tempDir, currentDir: tempDir, sourceId: 'cron-1', sourceLabel: 'nightly', promptPreview: 'stale scheduled prompt', createdAt: 100, startedAt: 100, ownerProcessId: 2_147_483_647, ownerSessionId: 'dead-session', }, ], }, null, 2, )}\n`, 'utf-8', ) await expect( hasActiveAutonomyRunForSource({ trigger: 'scheduled-task', sourceId: 'cron-1', rootDir: tempDir, }), ).resolves.toBe(false) const command = await createAutonomyQueuedPromptIfNoActiveSource({ basePrompt: 'scheduled prompt', trigger: 'scheduled-task', rootDir: tempDir, currentDir: tempDir, sourceId: 'cron-1', }) const runs = await listAutonomyRuns(tempDir) expect(command).not.toBeNull() expect(runs).toHaveLength(2) expect(runs[0]).toMatchObject({ trigger: 'scheduled-task', status: 'queued', sourceId: 'cron-1', ownerProcessId: process.pid, }) expect(runs[1]).toMatchObject({ runId: 'stale-run', status: 'failed', endedAt: runs[0]?.createdAt, error: expect.stringContaining('owner process 2147483647'), }) }) test('stale managed-flow run recovery also marks the flow step failed', async () => { const command = await startManagedAutonomyFlowFromHeartbeatTask({ task: { name: 'weekly-report', interval: '7d', prompt: 'Ship the weekly report', steps: [ { name: 'gather', prompt: 'Gather weekly inputs', }, ], }, rootDir: tempDir, currentDir: tempDir, }) expect(command).not.toBeNull() const runId = command!.autonomy!.runId await markAutonomyRunRunning(runId, tempDir, 100) const runsPath = resolveAutonomyRunsPath(tempDir) const file = JSON.parse(readFileSync(runsPath, 'utf-8')) as { runs: Array> } file.runs = file.runs.map(run => run.runId === runId ? { ...run, ownerProcessId: 2_147_483_647 } : run, ) await writeFile(runsPath, `${JSON.stringify(file, null, 2)}\n`, 'utf-8') const replacement = await createAutonomyQueuedPromptIfNoActiveSource({ basePrompt: 'replacement prompt', trigger: 'managed-flow-step', rootDir: tempDir, currentDir: tempDir, sourceId: command!.autonomy!.sourceId!, ownerKey: 'main-thread', }) const [flow] = await listAutonomyFlows(tempDir) const runs = await listAutonomyRuns(tempDir) expect(replacement).not.toBeNull() expect(runs.find(run => run.runId === runId)).toMatchObject({ status: 'failed', error: expect.stringContaining(STALE_ACTIVE_RUN_ERROR_PREFIX), }) expect(flow).toMatchObject({ status: 'failed', blockedRunId: runId, }) expect(flow?.stateJson?.steps[0]).toMatchObject({ status: 'failed', runId, error: expect.stringContaining(STALE_ACTIVE_RUN_ERROR_PREFIX), }) }) test('formatters produce readable status and run listings', async () => { const first = await createAutonomyQueuedPrompt({ basePrompt: 'scheduled prompt', trigger: 'scheduled-task', rootDir: tempDir, currentDir: tempDir, sourceId: 'cron-1', sourceLabel: 'nightly', }) const second = await createAutonomyQueuedPrompt({ basePrompt: '12:00:00', trigger: 'proactive-tick', rootDir: tempDir, currentDir: tempDir, }) expect(first).not.toBeNull() expect(second).not.toBeNull() await markAutonomyRunRunning(first!.autonomy!.runId, tempDir, 100) await markAutonomyRunCompleted(first!.autonomy!.runId, tempDir, 200) await markAutonomyRunFailed( second!.autonomy!.runId, 'stopped', tempDir, 300, ) const runs = await listAutonomyRuns(tempDir) const status = formatAutonomyRunsStatus(runs) const list = formatAutonomyRunsList(runs, 5) const flows = await listAutonomyFlows(tempDir) const flowList = formatAutonomyFlowsList(flows, 5) expect(status).toContain('Autonomy runs: 2') expect(status).toContain('Completed: 1') expect(status).toContain('Failed: 1') expect(list).toContain(first!.autonomy!.runId) expect(list).toContain(second!.autonomy!.runId) expect(list).toContain('nightly') expect(list).toContain('stopped') expect(flowList).toBe('No autonomy flows recorded.') }) test('same-process concurrent run creation does not lose updates', async () => { await Promise.all([ createAutonomyQueuedPrompt({ basePrompt: 'scheduled one', trigger: 'scheduled-task', rootDir: tempDir, currentDir: tempDir, sourceId: 'cron-1', }), createAutonomyQueuedPrompt({ basePrompt: 'scheduled two', trigger: 'scheduled-task', rootDir: tempDir, currentDir: tempDir, sourceId: 'cron-2', }), ]) const runs = await listAutonomyRuns(tempDir) expect(runs).toHaveLength(2) expect(new Set(runs.map(run => run.sourceId))).toEqual( new Set(['cron-1', 'cron-2']), ) }) test('persistence pruning keeps active runs ahead of recent completed history', async () => { const runs = [ { runId: 'old-active', runtime: 'automatic', trigger: 'scheduled-task', status: 'queued', rootDir: tempDir, currentDir: tempDir, ownerKey: 'main-thread', promptPreview: 'old active', createdAt: 1, }, ...Array.from({ length: 200 }, (_, index) => ({ runId: `history-${index}`, runtime: 'automatic', trigger: 'scheduled-task', status: 'completed', rootDir: tempDir, currentDir: tempDir, ownerKey: 'main-thread', promptPreview: `history ${index}`, createdAt: 1_000 + index, endedAt: 2_000 + index, })), ] await mkdir(join(tempDir, AUTONOMY_DIR), { recursive: true }) await writeFile( resolveAutonomyRunsPath(tempDir), `${JSON.stringify({ runs }, null, 2)}\n`, 'utf-8', ) await createAutonomyRun({ trigger: 'scheduled-task', prompt: 'fresh active', rootDir: tempDir, currentDir: tempDir, nowMs: 9_999, }) const persisted = await listAutonomyRuns(tempDir) expect(persisted).toHaveLength(200) expect(persisted.some(run => run.runId === 'old-active')).toBe(true) expect(persisted.some(run => run.runId === 'history-0')).toBe(false) }) test('listAutonomyRuns keeps older persisted records by normalizing missing runtime and owner metadata', async () => { const runsPath = resolveAutonomyRunsPath(tempDir) await mkdir(join(tempDir, '.claude', 'autonomy'), { recursive: true }) await writeFile( runsPath, `${JSON.stringify( { runs: [ { runId: 'legacy-run', trigger: 'scheduled-task', status: 'completed', rootDir: tempDir, promptPreview: 'legacy prompt', createdAt: 123, }, ], }, null, 2, )}\n`, 'utf-8', ) const [legacy] = await listAutonomyRuns(tempDir) expect(legacy).toMatchObject({ runId: 'legacy-run', runtime: 'automatic', ownerKey: 'main-thread', currentDir: tempDir, status: 'completed', }) }) test('createAutonomyQueuedPrompt does not consume heartbeat tasks or create runs when shouldCreate rejects commit', async () => { await writeTempFile( tempDir, HEARTBEAT_REL, [ 'tasks:', ' - name: inbox', ' interval: 30m', ' prompt: "Check inbox"', ].join('\n'), ) const skipped = await createAutonomyQueuedPrompt({ basePrompt: '12:00:00', trigger: 'proactive-tick', rootDir: tempDir, currentDir: tempDir, shouldCreate: () => false, }) const committed = await createAutonomyQueuedPrompt({ basePrompt: '12:01:00', trigger: 'proactive-tick', rootDir: tempDir, currentDir: tempDir, }) const runs = await listAutonomyRuns(tempDir) expect(skipped).toBeNull() expect(committed).not.toBeNull() expect(committed!.value).toContain('Due HEARTBEAT.md tasks:') expect(runs).toHaveLength(1) }) test('createProactiveAutonomyCommands queues one managed flow step command per due HEARTBEAT flow', async () => { await writeTempFile( tempDir, HEARTBEAT_REL, [ 'tasks:', ' - name: inbox', ' interval: 30m', ' prompt: "Check inbox"', ' - name: weekly-report', ' interval: 7d', ' prompt: "Ship the weekly report"', ' steps:', ' - name: gather', ' prompt: "Gather weekly inputs"', ' - name: draft', ' prompt: "Draft the weekly report"', ].join('\n'), ) const commands = await createProactiveAutonomyCommands({ basePrompt: '12:00:00', rootDir: tempDir, currentDir: tempDir, }) const runs = await listAutonomyRuns(tempDir) const flows = await listAutonomyFlows(tempDir) expect(commands).toHaveLength(2) expect(commands[0]!.autonomy?.trigger).toBe('proactive-tick') expect(commands[0]!.value).toContain('- inbox (30m): Check inbox') expect(commands[1]!.autonomy?.trigger).toBe('managed-flow-step') expect(commands[1]!.value).toContain( 'This is step 1/2 of the managed autonomy flow', ) expect(runs).toHaveLength(2) expect(flows).toHaveLength(1) expect(flows[0]).toMatchObject({ status: 'queued', currentStep: 'gather', goal: 'Ship the weekly report', }) }) test('finalizeAutonomyRunCompleted advances managed flows to the next queued step', async () => { const command = await startManagedAutonomyFlowFromHeartbeatTask({ task: { name: 'weekly-report', interval: '7d', prompt: 'Ship the weekly report', steps: [ { name: 'gather', prompt: 'Gather weekly inputs', }, { name: 'draft', prompt: 'Draft the weekly report', }, ], }, rootDir: tempDir, currentDir: tempDir, }) expect(command).not.toBeNull() await markAutonomyRunRunning(command!.autonomy!.runId, tempDir, 100) const nextCommands = await finalizeAutonomyRunCompleted({ runId: command!.autonomy!.runId, rootDir: tempDir, currentDir: tempDir, }) const runs = await listAutonomyRuns(tempDir) const [flow] = await listAutonomyFlows(tempDir) const detail = await getAutonomyFlowById(flow!.flowId, tempDir) expect(nextCommands).toHaveLength(1) expect(nextCommands[0]!.autonomy?.trigger).toBe('managed-flow-step') expect(nextCommands[0]!.value).toContain('Current step: draft') expect(runs).toHaveLength(2) expect(flow).toMatchObject({ status: 'queued', currentStep: 'draft', runCount: 2, }) expect(detail?.stateJson?.steps.map(step => step.status)).toEqual([ 'completed', 'queued', ]) }) test('recoverManagedAutonomyFlowPrompt rehydrates a queued managed step with the same run id', async () => { const command = await startManagedAutonomyFlowFromHeartbeatTask({ task: { name: 'weekly-report', interval: '7d', prompt: 'Ship the weekly report', steps: [ { name: 'gather', prompt: 'Gather weekly inputs', }, { name: 'draft', prompt: 'Draft the weekly report', }, ], }, rootDir: tempDir, currentDir: tempDir, }) const [flow] = await listAutonomyFlows(tempDir) const recovered = await recoverManagedAutonomyFlowPrompt({ flowId: flow!.flowId, rootDir: tempDir, currentDir: tempDir, }) expect(recovered).not.toBeNull() expect(recovered!.autonomy?.runId).toBe(command!.autonomy?.runId) expect(recovered!.autonomy?.flowId).toBe(flow!.flowId) }) test('STALE_ACTIVE_RUN_ERROR_PREFIX stays in sync with HEARTBEAT.md stale-recovery-health task', () => { // The HEARTBEAT.md stale-recovery-health task prompt embeds this prefix // as a literal string. Changing the constant without updating the // heartbeat prompt would silently break the monitor — this test fails // first to force the simultaneous update. const heartbeatPath = resolvePath( import.meta.dir, '..', '..', '..', '.claude', 'autonomy', 'HEARTBEAT.md', ) if (!existsSync(heartbeatPath)) { // .claude/ may be absent in some checkout layouts (e.g., shallow clone // for npm pack). Skip rather than fail in that case. return } const content = readFileSync(heartbeatPath, 'utf8') expect(content).toContain(STALE_ACTIVE_RUN_ERROR_PREFIX) }) })