feat: harden autonomy lifecycle, OOM bounds, and provider-boundary finalization

This PR consolidates a coordinated batch of fixes around autonomy run/flow lifecycle, scheduled task deduplication, provider-boundary state finalization, and matching memory-bound treatments for adjacent long-running subsystems (REPL fullscreen scrollback, skill-search/skill-learning runtime activation). All changes were developed and reviewed together because they touched the same lifecycle invariants and were uncovered by the same long-running session reproductions.

## Lifecycle correctness

- Queued autonomy prompts are not injected unless the persisted run was successfully claimed; queued run claiming is now terminal-safe so a once-consumed/cancelled/failed run can not slip back into `queued`.
- Autonomy run/flow finalization happens on completion, provider error, generator close, and cancellation — not just the happy path. New `src/__tests__/queryAutonomyProviderBoundary.test.ts` covers these provider-boundary transitions.
- `requestManagedAutonomyFlowCancel` and `resumeManagedAutonomyFlowPrompt` carry `rootDir` and `currentDir` explicitly across detached async boundaries (proactive-tick, cron, daemon restart) instead of inferring from process state.
- Active runs/flows are protected from janitor pruning so a running step can not be garbage-collected mid-flight (`src/utils/autonomyAuthority.ts`).
- Heartbeat parser now ignores fenced code blocks; the two-phase commit window for autonomy state transitions is documented in `docs/internals/autonomy-jira.md`.

## Ownership and dedup

- `src/utils/autonomyRuns.ts`: ownership stamping (run id + rootDir carried end-to-end), source-based dedup against active runs.
- `src/hooks/useScheduledTasks.ts`: scheduled ticks deduplicate against runs already active on the same source label.
- `src/utils/processUserInput/processSlashCommand.tsx`: forked slash commands now thread the autonomy `runId` so completion finalizers can find the originating run for deferred completion.
- New `src/utils/autonomyQueueLifecycle.ts` and tests collect the queue-side lifecycle invariants in one place.

## Memory bounds (related, same review pass)

- `src/screens/REPL.tsx`: caps fullscreen scrollback after the compact boundary and updates trailing progress rows in place. Long-running fullscreen sessions could otherwise retain thousands of post-compaction messages and duplicate progress rows, keeping Ink trees alive long after their useful context had moved on.
- `src/services/skillSearch/*` and `src/services/skillLearning/*`: runtime activation is strictly opt-in via existing env toggles; session caches are capped so long-running processes can not grow them forever. Build presence is preserved so operators can still discover and opt into the slash commands.

## CI / test contract

- `tests/integration/dependency-overrides.test.ts`: smoke test no longer drives Mermaid's browser renderer; it validates the package-resolution contract directly so CI does not regress on unrelated browser timing.
- New `tests/integration/autonomy-lifecycle-user-flow.test.ts`: end-to-end CLI subprocess flow exercising `status --deep`, `flows`, `flow <id>`, `flow resume`, `flow cancel` against persisted state.
- `src/entrypoints/cli.tsx`: `claude autonomy …` routes through an entrypoint fast path that reuses the slash-command formatter without booting the full interactive CLI. Stdout is flushed before forced exit so coverage subprocesses do not terminate with empty stdout.
- `packages/builtin-tools/src/tools/RemoteTriggerTool/__tests__/RemoteTriggerTool.test.ts`: stabilized to prevent audit flake under coverage.

## Tests added

- `src/__tests__/queryAutonomyProviderBoundary.test.ts`
- `src/hooks/__tests__/useScheduledTasks.test.ts`
- `src/utils/__tests__/autonomyAuthority.test.ts`
- `src/utils/__tests__/autonomyFlows.test.ts` (extended)
- `src/utils/__tests__/autonomyPersistence.test.ts` (extended)
- `src/utils/__tests__/autonomyQueueLifecycle.test.ts`
- `src/utils/__tests__/autonomyRuns.test.ts` (extended)
- `src/utils/processUserInput/__tests__/processSlashCommand.test.ts`
- `tests/integration/autonomy-lifecycle-user-flow.test.ts`

## Docs

- `docs/agent/sur-loop-scheduled-oom.md`: System Understanding Report covering the scheduled/loop OOM problem, the call graphs investigated, and the lifecycle invariants this PR establishes.
- `docs/agent/sur-skill-overflow-bugs.md`: SUR for the related skill-overflow context.
- `docs/internals/autonomy-jira.md`: documents the two-phase commit window and ownership stamping invariants.
- `docs/memory-leak-audit.md`: audit notes covering the REPL/scrollback and skill-search bounds.

## Invariants this PR establishes

1. Queued autonomy prompts are not injected unless the persisted run was successfully claimed.
2. Terminal run/flow states are terminal — completion, failure, and cancellation all finalize state regardless of which provider/error path triggered them.
3. Autonomy run/flow `rootDir` is carried explicitly across detached async boundaries instead of inferred from a shared singleton.
4. State-only CLI subcommands (`autonomy status|runs|flows|flow …`) bypass full interactive bootstrap so they do not hold unrelated handles open.
5. REPL fullscreen scrollback and skill-search/skill-learning session caches are explicitly bounded.

## Validation

```bash
bun run typecheck
CI=true GITHUB_ACTIONS=true bun test            # 3996 pass / 0 fail across 305 files
bun test src/__tests__/queryAutonomyProviderBoundary.test.ts \
         src/hooks/__tests__/useScheduledTasks.test.ts \
         src/utils/__tests__/autonomy{Runs,Flows,Authority,QueueLifecycle,Persistence}.test.ts \
         src/utils/processUserInput/__tests__/processSlashCommand.test.ts \
         tests/integration/autonomy-lifecycle-user-flow.test.ts
```

## Origin

This PR is the consolidated, upstream-targeted version of two fork-side review PRs (fix/loop-scheduled-autonomy-oom and fix/autonomy-lifecycle). The fork-side review history is preserved at https://github.com/amDosion/claude-code-bast/pull/7 . The fork's own internal `chore: keep fork current with upstream` sync commits and the `docs: update contributors` automation are intentionally not included in this PR.

The autonomy CLI handler `rootDir` threading that the fork added (78f64d8a, 98d04ddb) is intentionally omitted here because upstream `a2cfaf91` (fix: 修复 RemoteTriggerTool 和 autonomy 测试的全量运行失败) already performed the equivalent change with an additional `currentDir` option. Keeping the upstream version avoids regressing that improvement.
This commit is contained in:
unraid
2026-04-29 14:04:27 +08:00
parent 4f1649e249
commit f2e9af4927
51 changed files with 4885 additions and 971 deletions

View File

@@ -5,6 +5,7 @@ import {
AUTONOMY_DIR,
buildAutonomyTurnPrompt,
loadAutonomyAuthority,
parseHeartbeatAuthorityTasks,
resetAutonomyAuthorityForTests,
} from '../autonomyAuthority'
import {
@@ -238,4 +239,79 @@ describe('autonomyAuthority', () => {
expect(prompt).not.toContain('- weekly-report (7d): Ship the weekly report')
expect(prompt).not.toContain('- gather (')
})
test('parseHeartbeatAuthorityTasks ignores tasks: literals inside markdown code fences', () => {
const content = [
'# HEARTBEAT.md',
'',
'```yaml',
'tasks:',
' - name: not-a-real-task',
' interval: 1m',
' prompt: "would-be-shadowed"',
'```',
'',
'tasks:',
' - name: real-task',
' interval: 30m',
' prompt: "Real prompt"',
].join('\n')
const parsed = parseHeartbeatAuthorityTasks(content)
expect(parsed).toHaveLength(1)
expect(parsed[0]).toMatchObject({
name: 'real-task',
interval: '30m',
prompt: 'Real prompt',
})
})
test('parseHeartbeatAuthorityTasks ignores tasks: literals inside tilde markdown code fences', () => {
const content = [
'# HEARTBEAT.md',
'',
'~~~yaml',
'tasks:',
' - name: not-a-real-task',
' interval: 1m',
' prompt: "would-be-shadowed"',
'~~~',
'',
'tasks:',
' - name: real-task',
' interval: 30m',
' prompt: "Real prompt"',
].join('\n')
const parsed = parseHeartbeatAuthorityTasks(content)
expect(parsed).toHaveLength(1)
expect(parsed[0]).toMatchObject({
name: 'real-task',
interval: '30m',
prompt: 'Real prompt',
})
})
test('parseHeartbeatAuthorityTasks parses real tasks even when documentation precedes them', () => {
const content = [
'# Heartbeat docs',
'',
'See `tasks:` below — the parser keys on the literal at column 0.',
'',
'tasks:',
' - name: weekly',
' interval: 7d',
' prompt: "Ship report"',
].join('\n')
const parsed = parseHeartbeatAuthorityTasks(content)
// Inline `tasks:` mention does NOT collide because it's not at column 0
// on its own line — the existing line.trim() === 'tasks:' guard handles
// that case. This test pins the behaviour.
expect(parsed).toHaveLength(1)
expect(parsed[0]?.name).toBe('weekly')
})
})

View File

@@ -126,6 +126,14 @@ describe('listAutonomyFlows', () => {
runCount: 0,
ownerKey: DEFAULT_AUTONOMY_OWNER_KEY,
currentDir: tempDir,
boundary: [
' src/utils/** ',
'/absolute/not-allowed',
'src\\windows',
'../outside',
'src/utils/**',
'docs/*.md',
],
stateJson: {
currentStepIndex: 0,
steps: [
@@ -147,6 +155,7 @@ describe('listAutonomyFlows', () => {
expect(flows).toHaveLength(1)
expect(flows[0]?.flowId).toBe('flow-1')
expect(flows[0]?.syncMode).toBe('managed')
expect(flows[0]?.boundary).toEqual(['src/utils/**', 'docs/*.md'])
expect(flows[0]?.stateJson?.steps).toHaveLength(1)
})
@@ -191,6 +200,64 @@ describe('listAutonomyFlows', () => {
const flows = await listAutonomyFlows(tempDir)
expect(flows).toEqual([])
})
test('persistence pruning keeps active flows ahead of recent terminal history', async () => {
const flows: AutonomyFlowRecord[] = [
{
flowId: 'old-active',
flowKey: 'managed:scheduled-task:old-active',
syncMode: 'managed',
ownerKey: DEFAULT_AUTONOMY_OWNER_KEY,
revision: 1,
trigger: 'scheduled-task',
status: 'queued',
goal: 'old active',
rootDir: tempDir,
currentDir: tempDir,
runCount: 0,
createdAt: 1,
updatedAt: 1,
},
...Array.from({ length: 100 }, (_, index) => ({
flowId: `history-${index}`,
flowKey: `managed:scheduled-task:history-${index}`,
syncMode: 'managed' as const,
ownerKey: DEFAULT_AUTONOMY_OWNER_KEY,
revision: 1,
trigger: 'scheduled-task' as const,
status: 'succeeded' as const,
goal: `history ${index}`,
rootDir: tempDir,
currentDir: tempDir,
runCount: 1,
createdAt: 1_000 + index,
updatedAt: 1_000 + index,
endedAt: 2_000 + index,
})),
]
const flowsPath = resolveAutonomyFlowsPath(tempDir)
await mkdir(join(tempDir, AUTONOMY_DIR), { recursive: true })
await writeFile(
flowsPath,
`${JSON.stringify({ flows }, null, 2)}\n`,
'utf-8',
)
await startManagedAutonomyFlow({
trigger: 'scheduled-task',
goal: 'fresh active',
steps: TWO_STEPS,
rootDir: tempDir,
currentDir: tempDir,
sourceId: 'fresh-active',
nowMs: 9_999,
})
const persisted = await listAutonomyFlows(tempDir)
expect(persisted).toHaveLength(100)
expect(persisted.some(flow => flow.flowId === 'old-active')).toBe(true)
expect(persisted.some(flow => flow.flowId === 'history-0')).toBe(false)
})
})
describe('startManagedAutonomyFlow', () => {
@@ -225,6 +292,49 @@ describe('startManagedAutonomyFlow', () => {
expect(result!.nextStep!.step.name).toBe('gather')
})
test('normalizes and preserves boundary across completed flow restarts', async () => {
const first = await startManagedAutonomyFlow({
trigger: 'scheduled-task',
goal: 'Scoped flow',
steps: [{ name: 'only', prompt: 'Do it' }],
rootDir: tempDir,
sourceId: 'scoped-src',
boundary: [' src/utils/** ', 'src\\bad', '/absolute', 'docs/*.md'],
nowMs: 1000,
})
const flowId = first!.flow.flowId
expect(first!.flow.boundary).toEqual(['src/utils/**', 'docs/*.md'])
await queueManagedAutonomyFlowStepRun({
flowId,
stepId: first!.nextStep!.step.stepId,
stepIndex: 0,
runId: 'run-1',
rootDir: tempDir,
nowMs: 2000,
})
await markManagedAutonomyFlowStepCompleted({
flowId,
runId: 'run-1',
rootDir: tempDir,
nowMs: 3000,
})
const restarted = await startManagedAutonomyFlow({
trigger: 'scheduled-task',
goal: 'Scoped flow',
steps: [{ name: 'only', prompt: 'Do it again' }],
rootDir: tempDir,
sourceId: 'scoped-src',
nowMs: 4000,
})
expect(restarted!.started).toBe(true)
expect(restarted!.flow.flowId).toBe(flowId)
expect(restarted!.flow.boundary).toEqual(['src/utils/**', 'docs/*.md'])
})
test('sets status=waiting when first step has waitFor', async () => {
const result = await startManagedAutonomyFlow({
trigger: 'scheduled-task',

View File

@@ -54,6 +54,25 @@ describe('withAutonomyPersistenceLock', () => {
).rejects.toThrow('inner failure')
})
test('releases same-root lock bookkeeping after success and failure', async () => {
const {
getAutonomyPersistenceLockCountForTests,
withAutonomyPersistenceLock,
} = await import('../autonomyPersistence')
expect(getAutonomyPersistenceLockCountForTests()).toBe(0)
await withAutonomyPersistenceLock(tempDir, async () => 'ok')
expect(getAutonomyPersistenceLockCountForTests()).toBe(0)
await expect(
withAutonomyPersistenceLock(tempDir, async () => {
throw new Error('inner failure')
}),
).rejects.toThrow('inner failure')
expect(getAutonomyPersistenceLockCountForTests()).toBe(0)
})
test('serializes concurrent calls on the same rootDir', async () => {
const { withAutonomyPersistenceLock } = await import(
'../autonomyPersistence'

View File

@@ -0,0 +1,279 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { createTempDir, cleanupTempDir } from '../../../tests/mocks/file-system'
import { getAttachmentMessages } from '../attachments'
import {
createAutonomyQueuedPrompt,
createProactiveAutonomyCommands,
getAutonomyRunById,
markAutonomyRunCancelled,
startManagedAutonomyFlowFromHeartbeatTask,
} from '../autonomyRuns'
import { getAutonomyFlowById, listAutonomyFlows } from '../autonomyFlows'
import {
cancelQueuedAutonomyCommands,
claimConsumableQueuedAutonomyCommands,
finalizeAutonomyCommandsForTurn,
partitionConsumableQueuedAutonomyCommands,
} from '../autonomyQueueLifecycle'
import {
enqueue,
getCommandsByMaxPriority,
remove as removeFromQueue,
resetCommandQueue,
} from '../messageQueueManager'
let tempDir = ''
let extraTempDirs: string[] = []
beforeEach(async () => {
tempDir = await createTempDir('autonomy-queue-lifecycle-')
extraTempDirs = []
resetCommandQueue()
})
afterEach(async () => {
resetCommandQueue()
if (tempDir) {
await cleanupTempDir(tempDir)
}
for (const extraTempDir of extraTempDirs) {
await cleanupTempDir(extraTempDir)
}
})
describe('autonomyQueueLifecycle', () => {
async function consumeQueuedAutonomyAttachmentTurn() {
const previousDisableAttachments =
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = '1'
try {
const snapshot = getCommandsByMaxPriority('later')
const claim = await claimConsumableQueuedAutonomyCommands(
snapshot,
tempDir,
)
removeFromQueue(claim.staleCommands)
removeFromQueue(claim.claimedCommands)
const attachments = []
for await (const attachment of getAttachmentMessages(
null,
{} as never,
null,
claim.attachmentCommands,
[],
)) {
attachments.push(attachment)
}
const consumedCommands = claim.attachmentCommands.filter(
command =>
(command.mode === 'prompt' || command.mode === 'task-notification') &&
!claim.claimedCommands.includes(command),
)
removeFromQueue(consumedCommands)
const nextCommands = await finalizeAutonomyCommandsForTurn({
commands: claim.claimedCommands,
outcome: { type: 'completed' },
currentDir: tempDir,
priority: 'later',
})
for (const command of nextCommands) {
enqueue(command)
}
return { attachments, runningRunIds: claim.claimedRunIds, nextCommands }
} finally {
if (previousDisableAttachments === undefined) {
delete process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS
} else {
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = previousDisableAttachments
}
}
}
test('filters stale autonomy commands before mid-turn attachment consumption', async () => {
const command = await createAutonomyQueuedPrompt({
basePrompt: 'scheduled prompt',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
})
expect(command).not.toBeNull()
const initial = await partitionConsumableQueuedAutonomyCommands(
[command!],
tempDir,
)
expect(initial.attachmentCommands).toHaveLength(1)
expect(initial.staleCommands).toHaveLength(0)
await markAutonomyRunCancelled(command!.autonomy!.runId, tempDir)
const afterCancel = await partitionConsumableQueuedAutonomyCommands(
[command!],
tempDir,
)
expect(afterCancel.attachmentCommands).toHaveLength(0)
expect(afterCancel.staleCommands).toHaveLength(1)
})
test('cancels proactive commands that are created but dropped before enqueue', async () => {
const commands = await createProactiveAutonomyCommands({
basePrompt: '<tick>12:00:00</tick>',
rootDir: tempDir,
currentDir: tempDir,
})
expect(commands).toHaveLength(1)
const queuedRun = await getAutonomyRunById(
commands[0]!.autonomy!.runId,
tempDir,
)
expect(queuedRun!.status).toBe('queued')
await cancelQueuedAutonomyCommands({ commands, rootDir: tempDir })
const cancelledRun = await getAutonomyRunById(
commands[0]!.autonomy!.runId,
tempDir,
)
expect(cancelledRun!.status).toBe('cancelled')
})
test('uses command rootDir when claiming after project context changes', async () => {
const otherProjectDir = await createTempDir('autonomy-other-project-')
extraTempDirs.push(otherProjectDir)
const command = await createAutonomyQueuedPrompt({
basePrompt: 'scheduled prompt',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
})
expect(command).not.toBeNull()
expect(command!.autonomy?.rootDir).toBe(tempDir)
const claim = await claimConsumableQueuedAutonomyCommands(
[command!],
otherProjectDir,
)
const originalRun = await getAutonomyRunById(
command!.autonomy!.runId,
tempDir,
)
const wrongProjectRun = await getAutonomyRunById(
command!.autonomy!.runId,
otherProjectDir,
)
expect(claim.claimedRunIds).toEqual([command!.autonomy!.runId])
expect(claim.attachmentCommands).toHaveLength(1)
expect(originalRun!.status).toBe('running')
expect(wrongProjectRun).toBeNull()
})
test('advances a managed flow consumed as a queued attachment', 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 weekly report' },
],
},
rootDir: tempDir,
currentDir: tempDir,
})
expect(command).not.toBeNull()
const claim = await claimConsumableQueuedAutonomyCommands(
[command!],
tempDir,
)
const runningRunIds = claim.claimedRunIds
expect(runningRunIds).toEqual([command!.autonomy!.runId])
const nextCommands = await finalizeAutonomyCommandsForTurn({
commands: claim.claimedCommands,
outcome: { type: 'completed' },
currentDir: tempDir,
priority: 'later',
})
const [flow] = await listAutonomyFlows(tempDir)
const detail = await getAutonomyFlowById(flow!.flowId, tempDir)
const run = await getAutonomyRunById(command!.autonomy!.runId, tempDir)
expect(run!.status).toBe('completed')
expect(nextCommands).toHaveLength(1)
expect(nextCommands[0]!.autonomy?.flowId).toBe(flow!.flowId)
expect(detail!.stateJson!.steps.map(step => step.status)).toEqual([
'completed',
'queued',
])
})
test('keeps managed autonomy flow coherent across queued attachment turns', async () => {
const firstCommand = await startManagedAutonomyFlowFromHeartbeatTask({
task: {
name: 'weekly-report',
interval: '7d',
prompt: 'Ship the weekly report',
steps: [
{ name: 'gather', prompt: 'Gather weekly inputs' },
{ name: 'draft', prompt: 'Draft weekly report' },
],
},
rootDir: tempDir,
currentDir: tempDir,
})
expect(firstCommand).not.toBeNull()
enqueue(firstCommand!)
const firstTurn = await consumeQueuedAutonomyAttachmentTurn()
const queuedAfterFirstTurn = getCommandsByMaxPriority('later')
const [flowAfterFirstTurn] = await listAutonomyFlows(tempDir)
const firstRun = await getAutonomyRunById(
firstCommand!.autonomy!.runId,
tempDir,
)
expect(firstTurn.attachments).toHaveLength(1)
expect(firstTurn.attachments[0]!.attachment?.type).toBe('queued_command')
expect(firstTurn.runningRunIds).toEqual([firstCommand!.autonomy!.runId])
expect(firstTurn.nextCommands).toHaveLength(1)
expect(queuedAfterFirstTurn).toHaveLength(1)
expect(queuedAfterFirstTurn[0]!.autonomy?.flowId).toBe(
flowAfterFirstTurn!.flowId,
)
expect(firstRun!.status).toBe('completed')
expect(
flowAfterFirstTurn!.stateJson!.steps.map(step => step.status),
).toEqual(['completed', 'queued'])
const secondCommand = queuedAfterFirstTurn[0]!
const secondTurn = await consumeQueuedAutonomyAttachmentTurn()
const queuedAfterSecondTurn = getCommandsByMaxPriority('later')
const finalFlow = await getAutonomyFlowById(
flowAfterFirstTurn!.flowId,
tempDir,
)
const secondRun = await getAutonomyRunById(
secondCommand.autonomy!.runId,
tempDir,
)
expect(secondTurn.attachments).toHaveLength(1)
expect(secondTurn.runningRunIds).toEqual([secondCommand.autonomy!.runId])
expect(secondTurn.nextCommands).toHaveLength(0)
expect(queuedAfterSecondTurn).toHaveLength(0)
expect(secondRun!.status).toBe('completed')
expect(finalFlow!.status).toBe('succeeded')
expect(finalFlow!.stateJson!.steps.map(step => step.status)).toEqual([
'completed',
'completed',
])
})
})

View File

@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { existsSync, readFileSync } from 'fs'
import { mkdir, writeFile } from 'fs/promises'
import { join } from 'path'
import { join, resolve as resolvePath } from 'path'
import {
resetStateForTests,
setCwdState,
@@ -8,17 +9,23 @@ import {
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 {
@@ -95,7 +102,9 @@ describe('autonomyRuns', () => {
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')
})
@@ -118,7 +127,7 @@ describe('autonomyRuns', () => {
expect(command!.value).toContain('nested authority')
})
test('markAutonomyRunRunning/completed/failed update persisted lifecycle state for plain runs', async () => {
test('markAutonomyRunRunning/completed update persisted lifecycle state for plain runs', async () => {
const command = await createAutonomyQueuedPrompt({
basePrompt: '<tick>12:00:00</tick>',
trigger: 'proactive-tick',
@@ -134,7 +143,9 @@ describe('autonomyRuns', () => {
runId,
status: 'running',
startedAt: 100,
ownerProcessId: process.pid,
})
expect(runs[0]?.ownerSessionId).toBeString()
await markAutonomyRunCompleted(runId, tempDir, 200)
runs = await listAutonomyRuns(tempDir)
@@ -143,9 +154,22 @@ describe('autonomyRuns', () => {
status: 'completed',
endedAt: 200,
})
})
test('markAutonomyRunFailed updates a non-terminal run', async () => {
const command = await createAutonomyQueuedPrompt({
basePrompt: '<tick>12:00:00</tick>',
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)
runs = await listAutonomyRuns(tempDir)
const runs = await listAutonomyRuns(tempDir)
expect(runs[0]).toMatchObject({
runId,
status: 'failed',
@@ -154,6 +178,348 @@ describe('autonomyRuns', () => {
})
})
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: '<tick>12:00:00</tick>',
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<Record<string, unknown>>
}
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',
@@ -223,6 +589,53 @@ describe('autonomyRuns', () => {
)
})
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 })
@@ -418,4 +831,27 @@ describe('autonomyRuns', () => {
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)
})
})