Skip to content

Commit d873a09

Browse files
authored
feat(runtime): add idle_cached lifecycle + Claude session resume
Adds the Claude-only idle_cached lifecycle with session-id capture/resume support, stale-session cold-start fallback, supervisor wake-path support, docs, and regression tests for Claude Code stale-resume wording.
1 parent 1fd03d5 commit d873a09

12 files changed

Lines changed: 808 additions & 57 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ Agent definitions live in a single JSON file:
123123

124124
Each agent entry has:
125125
- `name` — unique identifier used on the message bus.
126-
- `lifecycle``24/7` (continuous respawn loop) or `on-demand` (idle until triggered).
126+
- `lifecycle``24/7` (continuous respawn loop), `on-demand` (idle until triggered), or `idle_cached` (idle until triggered, but the prior Claude `session_id` is preserved across triggers via `claude --resume` so context survives idle periods). **`idle_cached` is Claude-only**: pairing it with `runtime: codex` (or `WANMAN_RUNTIME=codex`) is rejected at startup since Codex has no equivalent resume mechanism in this runtime.
127127
- `model` — usually an abstract tier (`high` or `standard`); the runtime adapter maps it to Claude or Codex defaults, with environment overrides available.
128128
- `systemPrompt` — baked-in persona/mission; agents also auto-discover shared skill files at `~/.claude/skills/`.
129129
- Optional `cron`, `events`, and `tools` fields — see the architecture doc for the full schema.

docs/architecture.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,33 @@ start()
6464

6565
- Initial state is `idle`; no CPU is spent until something pokes it.
6666
- A steer-priority message or a cron tick triggers a single execution, after which the agent falls back to `idle`.
67+
- Each spawn is stateless: the next trigger gets a fresh CLI session.
6768

68-
### 2.3 Agent states
69+
### 2.3 `idle_cached` — idle, but with resumed context (Claude-only)
70+
71+
```
72+
start()
73+
+-> state = 'idle' (no CLI subprocess running)
74+
+-> lastSessionId = null
75+
76+
trigger() / handleSteer()
77+
+- relay.recv()
78+
+- spawnClaudeCode({ resumeSessionId: lastSessionId }) # claude --resume <id>
79+
+- onSessionId(id => lastSessionId = id) # captured from system/init
80+
+- wait()
81+
+- if resumeMissed(): # stale session
82+
lastSessionId = null
83+
respawn without --resume # cold-start fallback
84+
+- state = 'idle' again (but lastSessionId is preserved)
85+
```
86+
87+
- Same idle CPU profile as `on-demand`, but conversation context survives idle periods because the next spawn passes the captured Claude `session_id` as `--resume <id>`.
88+
- The first trigger always cold-starts (no captured session yet).
89+
- If the local Claude CLI has dropped the session id (rotated, manually deleted, etc.), the runtime detects the failure via stderr + exit code, clears the cached id, and re-spawns once without `--resume`. No agent gets stranded.
90+
- Useful for stateful long-running roles where keeping a process alive forever would be wasteful but losing context every trigger is also wrong (e.g. a "support" agent that should remember the customer between messages).
91+
- **Claude-only.** The resume mechanism depends on `claude --resume` and Claude Code's `system/init` session id; Codex has no equivalent in this runtime today. Pairing `idle_cached` with `runtime: codex` (or letting `WANMAN_RUNTIME=codex` flip the effective runtime) is rejected at supervisor startup so the misconfig surfaces loudly instead of silently degrading to `on-demand` semantics.
92+
93+
### 2.4 Agent states
6994

7095
| State | Meaning |
7196
|-------|---------|

packages/core/src/types.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
1-
/** Agent lifecycle mode */
2-
export type AgentLifecycle = '24/7' | 'on-demand';
1+
/**
2+
* Agent lifecycle mode.
3+
*
4+
* - `24/7` — continuous respawn loop. The agent boots a fresh CLI
5+
* subprocess every iteration. Use for always-on roles.
6+
* - `on-demand` — idle until triggered (cron / steer / message). Each
7+
* trigger starts a fresh, stateless run.
8+
* - `idle_cached` — idle until triggered (like `on-demand`), but the runtime
9+
* remembers the previous Claude `session_id` and resumes
10+
* it on the next trigger via `claude --resume`. Combines
11+
* "no CPU when idle" with "preserved conversation context".
12+
* **Claude-only.** The resume mechanism depends on Claude
13+
* Code's `system/init` session id and `--resume` flag;
14+
* Codex has no equivalent in this runtime today, so the
15+
* supervisor rejects `idle_cached` paired with a non-Claude
16+
* runtime at startup rather than letting it silently
17+
* degrade to `on-demand` semantics.
18+
*/
19+
export type AgentLifecycle = '24/7' | 'on-demand' | 'idle_cached';
320

421
/** Agent runtime backend */
522
export type AgentRuntime = 'claude' | 'codex';

packages/runtime/src/__tests__/agent-process.test.ts

Lines changed: 207 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,21 @@ function deferred<T>() {
2121
// Per-call wait deferreds
2222
let waitDeferreds: Array<ReturnType<typeof deferred<number>>>
2323
let eventHandlers: Array<((event: Record<string, unknown>) => void) | undefined>
24+
/** Per-call session-id reporter. Tests call this to simulate Claude emitting
25+
* a system/init session id, which AgentProcess captures for idle_cached. */
26+
let sessionIdReporters: Array<(sessionId: string) => void>
27+
/** Per-call exit handler — AgentProcess registers one to read resumeMissed
28+
* after the process exits. The mock spawn invokes it on wait()-resolve. */
29+
let exitReporters: Array<(code: number) => void>
30+
/** Per-call resumeMissed flag — tests flip this via `setSpawnResumeMissed`
31+
* to simulate Claude refusing the requested --resume id. */
32+
let resumeMissedFlags: boolean[]
2433
let spawnCallCount: number
2534

35+
function setSpawnResumeMissed(idx: number, missed: boolean) {
36+
resumeMissedFlags[idx] = missed
37+
}
38+
2639
const mockKill = vi.fn()
2740
const codexStartRunMock = vi.hoisted(() => vi.fn())
2841

@@ -33,15 +46,30 @@ vi.mock('../claude-code.js', () => ({
3346
waitDeferreds[idx] = deferred<number>()
3447
}
3548
const handlers: { event?: (event: Record<string, unknown>) => void } = {}
49+
const sessionIdHandlers: Array<(id: string) => void> = []
50+
const exitHandlers: Array<(code: number) => void> = []
3651
eventHandlers[idx] = (event) => handlers.event?.(event)
52+
sessionIdReporters[idx] = (id) => {
53+
for (const h of sessionIdHandlers) h(id)
54+
}
55+
exitReporters[idx] = (code) => {
56+
for (const h of exitHandlers) h(code)
57+
}
3758
return {
3859
proc: { pid: 12345, stdin: { end: vi.fn() } },
39-
wait: vi.fn(() => waitDeferreds[idx]!.promise),
60+
wait: vi.fn(async () => {
61+
const code = await waitDeferreds[idx]!.promise
62+
// Mirror real adapter behaviour: onExit fires after wait resolves.
63+
for (const h of exitHandlers) h(code)
64+
return code
65+
}),
4066
kill: mockKill,
4167
sendMessage: vi.fn(),
4268
onEvent: vi.fn((handler) => { handlers.event = handler }),
4369
onResult: vi.fn(),
44-
onExit: vi.fn(),
70+
onSessionId: vi.fn((handler) => { sessionIdHandlers.push(handler) }),
71+
onExit: vi.fn((handler) => { exitHandlers.push(handler) }),
72+
resumeMissed: vi.fn(() => resumeMissedFlags[idx] ?? false),
4573
}
4674
}),
4775
}))
@@ -106,6 +134,9 @@ describe('AgentProcess', () => {
106134
codexStartRunMock.mockClear()
107135
waitDeferreds = []
108136
eventHandlers = []
137+
sessionIdReporters = []
138+
exitReporters = []
139+
resumeMissedFlags = []
109140
spawnCallCount = 0
110141
relay = makeRelay()
111142
})
@@ -130,6 +161,48 @@ describe('AgentProcess', () => {
130161
expect(agent.state).toBe('idle')
131162
})
132163

164+
it('rejects idle_cached + declared codex runtime at construction', () => {
165+
expect(() => new AgentProcess(
166+
makeDef({ name: 'support', lifecycle: 'idle_cached', runtime: 'codex' }),
167+
relay,
168+
'/tmp/work',
169+
)).toThrow(/idle_cached only works with the Claude runtime/)
170+
})
171+
172+
it('rejects idle_cached when WANMAN_RUNTIME forces effective runtime to codex', () => {
173+
const original = process.env['WANMAN_RUNTIME']
174+
process.env['WANMAN_RUNTIME'] = 'codex'
175+
try {
176+
expect(() => new AgentProcess(
177+
makeDef({ name: 'support', lifecycle: 'idle_cached' }),
178+
relay,
179+
'/tmp/work',
180+
)).toThrow(/forced by WANMAN_RUNTIME=codex/)
181+
} finally {
182+
if (original === undefined) delete process.env['WANMAN_RUNTIME']
183+
else process.env['WANMAN_RUNTIME'] = original
184+
}
185+
})
186+
187+
it('accepts idle_cached + claude runtime', () => {
188+
const agent = new AgentProcess(
189+
makeDef({ name: 'support', lifecycle: 'idle_cached', runtime: 'claude' }),
190+
relay,
191+
'/tmp/work',
192+
)
193+
expect(agent.state).toBe('idle')
194+
expect(agent.definition.lifecycle).toBe('idle_cached')
195+
})
196+
197+
it('accepts idle_cached with default runtime (claude)', () => {
198+
const agent = new AgentProcess(
199+
makeDef({ name: 'support', lifecycle: 'idle_cached' }),
200+
relay,
201+
'/tmp/work',
202+
)
203+
expect(agent.state).toBe('idle')
204+
})
205+
133206
it('should store definition', () => {
134207
const def = makeDef({ name: 'my-agent' })
135208
const agent = new AgentProcess(def, relay, '/tmp/work')
@@ -143,6 +216,12 @@ describe('AgentProcess', () => {
143216
await agent.start()
144217
expect(agent.state).toBe('idle')
145218
})
219+
220+
it('should stay idle for idle_cached agents (no respawn loop)', async () => {
221+
const agent = new AgentProcess(makeDef({ lifecycle: 'idle_cached' }), relay, '/tmp')
222+
await agent.start()
223+
expect(agent.state).toBe('idle')
224+
})
146225
})
147226

148227
describe('start — 24/7 (runLoop)', () => {
@@ -368,6 +447,132 @@ describe('AgentProcess', () => {
368447
expect(agent.state).toBe('idle')
369448
})
370449

450+
it('on-demand never passes resumeSessionId, even after a session id is observed', async () => {
451+
const { spawnClaudeCode } = await import('../claude-code.js')
452+
waitDeferreds[0] = deferred<number>()
453+
waitDeferreds[1] = deferred<number>()
454+
455+
const agent = new AgentProcess(makeDef({ lifecycle: 'on-demand' }), relay, '/tmp')
456+
457+
// First trigger: relay enqueues msg1, mock fires onSessionId mid-run.
458+
relay.send('alice', 'test-agent', 'message', 'msg1', 'normal')
459+
const t1 = agent.trigger()
460+
await new Promise((r) => setTimeout(r, 5))
461+
sessionIdReporters[0]!('session-from-claude-1')
462+
waitDeferreds[0].resolve(0)
463+
await t1
464+
465+
// Second trigger should still spawn fresh — on-demand discards the id.
466+
relay.send('alice', 'test-agent', 'message', 'msg2', 'normal')
467+
const t2 = agent.trigger()
468+
await new Promise((r) => setTimeout(r, 5))
469+
waitDeferreds[1].resolve(0)
470+
await t2
471+
472+
const calls = vi.mocked(spawnClaudeCode).mock.calls
473+
expect(calls.length).toBe(2)
474+
expect(calls[0]![0]).not.toHaveProperty('resumeSessionId')
475+
expect(calls[1]![0]).not.toHaveProperty('resumeSessionId')
476+
})
477+
})
478+
479+
describe('trigger — idle_cached', () => {
480+
it('skips resumeSessionId on the very first trigger (no session captured yet)', async () => {
481+
const { spawnClaudeCode } = await import('../claude-code.js')
482+
waitDeferreds[0] = deferred<number>()
483+
waitDeferreds[0].resolve(0)
484+
485+
const agent = new AgentProcess(makeDef({ lifecycle: 'idle_cached' }), relay, '/tmp')
486+
relay.send('alice', 'test-agent', 'message', 'first message', 'normal')
487+
488+
await agent.trigger()
489+
490+
const calls = vi.mocked(spawnClaudeCode).mock.calls
491+
expect(calls.length).toBe(1)
492+
expect(calls[0]![0].resumeSessionId).toBeUndefined()
493+
})
494+
495+
it('passes the previously-captured session id as resumeSessionId on the next trigger', async () => {
496+
const { spawnClaudeCode } = await import('../claude-code.js')
497+
waitDeferreds[0] = deferred<number>()
498+
waitDeferreds[1] = deferred<number>()
499+
500+
const agent = new AgentProcess(makeDef({ lifecycle: 'idle_cached' }), relay, '/tmp')
501+
502+
relay.send('alice', 'test-agent', 'message', 'msg1', 'normal')
503+
const t1 = agent.trigger()
504+
await new Promise((r) => setTimeout(r, 5))
505+
sessionIdReporters[0]!('captured-session-xyz')
506+
waitDeferreds[0].resolve(0)
507+
await t1
508+
509+
relay.send('alice', 'test-agent', 'message', 'msg2', 'normal')
510+
const t2 = agent.trigger()
511+
await new Promise((r) => setTimeout(r, 5))
512+
waitDeferreds[1].resolve(0)
513+
await t2
514+
515+
const calls = vi.mocked(spawnClaudeCode).mock.calls
516+
expect(calls.length).toBe(2)
517+
expect(calls[0]![0].resumeSessionId).toBeUndefined()
518+
expect(calls[1]![0].resumeSessionId).toBe('captured-session-xyz')
519+
})
520+
521+
it('falls back to a cold-start retry without resume when the prior session is missing', async () => {
522+
const { spawnClaudeCode } = await import('../claude-code.js')
523+
waitDeferreds[0] = deferred<number>()
524+
waitDeferreds[1] = deferred<number>()
525+
waitDeferreds[2] = deferred<number>()
526+
527+
const agent = new AgentProcess(makeDef({ lifecycle: 'idle_cached' }), relay, '/tmp')
528+
529+
// Run 1: capture a session id.
530+
relay.send('alice', 'test-agent', 'message', 'msg1', 'normal')
531+
const t1 = agent.trigger()
532+
await new Promise((r) => setTimeout(r, 5))
533+
sessionIdReporters[0]!('about-to-go-stale')
534+
waitDeferreds[0].resolve(0)
535+
await t1
536+
537+
// Run 2: simulate Claude rejecting --resume on the next spawn. The
538+
// mock's resumeMissed flag is read inside onExit, which AgentProcess
539+
// checks after wait() resolves.
540+
setSpawnResumeMissed(1, true)
541+
relay.send('alice', 'test-agent', 'message', 'msg2', 'normal')
542+
const t2 = agent.trigger()
543+
await new Promise((r) => setTimeout(r, 5))
544+
waitDeferreds[1].resolve(0)
545+
// Wait for AgentProcess to observe resumeMissed and fire the cold-start.
546+
await new Promise((r) => setTimeout(r, 10))
547+
waitDeferreds[2].resolve(0)
548+
await t2
549+
550+
const calls = vi.mocked(spawnClaudeCode).mock.calls
551+
expect(calls.length).toBe(3)
552+
// Spawn 1: cold start (no session yet)
553+
expect(calls[0]![0].resumeSessionId).toBeUndefined()
554+
// Spawn 2: tried to resume the captured id...
555+
expect(calls[1]![0].resumeSessionId).toBe('about-to-go-stale')
556+
// Spawn 3: cold-start retry after the resume miss
557+
expect(calls[2]![0].resumeSessionId).toBeUndefined()
558+
})
559+
560+
it('handleSteer triggers idle_cached agents the same way it triggers on-demand', async () => {
561+
waitDeferreds[0] = deferred<number>()
562+
waitDeferreds[0].resolve(0)
563+
564+
const agent = new AgentProcess(makeDef({ lifecycle: 'idle_cached' }), relay, '/tmp')
565+
await agent.start()
566+
expect(agent.state).toBe('idle')
567+
568+
relay.send('alice', 'test-agent', 'message', 'urgent', 'steer')
569+
agent.handleSteer()
570+
571+
// Let the async trigger run to completion.
572+
await new Promise((r) => setTimeout(r, 10))
573+
expect(spawnCallCount).toBeGreaterThanOrEqual(1)
574+
})
575+
371576
it('merges per-run environment from envProvider before spawn', async () => {
372577
const { spawnClaudeCode } = await import('../claude-code.js')
373578
waitDeferreds[0] = deferred<number>()

0 commit comments

Comments
 (0)