# Issue 2a — async-inform reply doesn't wake an idle parent

**Severity:** CRITICAL — silently breaks the documented orchestration contract.
**Engine scope:** Both engines (openai + claude-cli).
**Discovery:** user-reported. Parent fires `async_inform:true`, ends its turn, sub finishes — reply sits in queue forever. The "kept calling sleep so I got woken up" anecdote is the symptom: drain only fires inside an active `runChat`.

---

## Context

When a parent ends its turn before a sub-agent's async reply arrives, the reply lands in `agent_messages` (target_session_id = parent's session). Currently nothing fires another `runChat` on the parent, so `drainNonFollowup` never runs again. The reply sits until the next time a HUMAN sends the parent a chat — at which point it's drained as part of that turn. That is **NOT** the documented contract: docs say "delivered as a new user message," implying autonomous delivery.

This violates the autonomous-orchestration use case (the user's actual workflow: "I was working with an agent, he made two concurrent calls to two different sub-agents... and then said 'Waiting for both to respond.' Both finished, parent never informed.").

---

## Root cause

Two enqueue paths write parent-bound rows and then stop:

1. [core/async-inform.js:84-98](core/async-inform.js#L84) — `dispatchAsyncInformTurn`'s msg#1 final-text delivery to caller's session.
2. [core/queue.js:142-163](core/queue.js#L142) — `postCorrelatedResponse`'s msg#2+ auto-deliver branch.

Plus a third lurking related path:
3. [core/queue.js:189-198](core/queue.js#L189) — `notifyTaskSubscribers` (task-completion notifications). Same idle-parent rotting issue.

**No code anywhere consults `runningSessions.has(callerSession.id)` to decide whether the parent is currently looping.** `drainNonFollowup` is only called inside `runLoop` ([core/loop.js:334, 788](core/loop.js#L334)) and inside the claude-engine generator ([engines/claude-engine.js:483](engines/claude-engine.js#L483)). When the parent's runChat already ended, the row sits in SQLite indefinitely.

---

## Design

### (a) New module `core/wake.js`

Exports two functions; in-memory state lives in this file.

```
_pendingWakes:        Set<sessionId>          // wakes scheduled via setImmediate, not yet fired
_wakingNow:           Set<sessionId>          // wakes currently executing runChat
_sessionRunContext:   Map<sid, {cwd, settings}>  // last-known runChat context for restart of idle session
```

**`recordSessionRunContext(sid, { cwd, settings })`** — called by router right before each `runLoop` / `runClaudeSession` starts. The wake helper needs this to invoke `runChat` for an idle session whose current `cwd` and `settings` are not on the session row.

**`wakeIdleTargetIfNeeded({ targetSessionId })`** — the trigger. Logic:

1. Resolve `session = db.getSession(targetSessionId)`. If absent, status `closed`, or `mode !== 'chat'` → no-op (daemons + subagent-mode short-lived sessions don't get auto-woken).
2. If `_wakingNow.has(sid) || _pendingWakes.has(sid)` → no-op (dedup).
3. If `runningSessions.has(sid)` → no-op (an active loop will drain via its next `drainNonFollowup`).
4. Add to `_pendingWakes`. Schedule `setImmediate(async () => …)`.
5. Inside the setImmediate:
   - **Re-check** `runningSessions.has(sid)` — a real user message may have started a runChat in the meantime. If yes, drop the wake and clear `_pendingWakes`.
   - Otherwise, move from `_pendingWakes` → `_wakingNow`.
   - Resolve `{cwd, settings}` from `_sessionRunContext.get(sid)`; fallback to `session.instance_folder` + `loadSettings({ cwd: session.instance_folder })` if absent (e.g. process restarted between turns).
   - `await runChat({ agentName: session.agent_name, message: null, sessionId: sid, continueFlag: true, cwd, settings })`.
6. In `finally`: clear `_wakingNow`. **Probe** the DB — `db.getPendingAgentMessages(session.agent_name)` filtered to this `target_session_id` AND `status='pending'`. If any rows still exist (could happen if sub-agents replied during the wake's own turn AFTER the last drainNonFollowup), schedule one more wake.
7. On error (closed session race, budget cap, engine error): log via `console.error('[wake]', err.message)` and discard. Never throw — caller is in a fire-and-forget context.

### (b) Tying into existing serialization

`_runChatLoopLocks` ([core/router.js:124, 339-344](core/router.js#L124)) already serializes concurrent `runChat` for the same sid. The wake helper benefits automatically:

- Real user message races a wake → both go through the lock; only one body executes; the second waiter re-checks state and routes correctly (or detects the queue is already drained).
- Multiple wakes for the same sid are dedup'd by `_pendingWakes`/`_wakingNow` BEFORE the lock.

### (c) `continueFlag: true, message: null` semantics

Already used by the race-recovery recursion at [core/router.js:200-203](core/router.js#L200). `_doRunChatBody` handles this shape ([router.js:426](core/router.js#L426): `if (!continueFlag || message)` skips user-message-add). `runLoop` iteration 1 calls `drainNonFollowup` ([loop.js:334](core/loop.js#L334)) which picks up the queued rows.

For claude-cli sessions, `runClaudeChat` threads the same flags. Engine generator's drain at [claude-engine.js:483](engines/claude-engine.js#L483) picks queued rows. Crucially, this is a **fresh `runClaudeSession` query** — not mid-run injection (which is the separate Issue 2d) — so the SDK injection bug doesn't apply.

### (d) Termination audit (parent ⇄ sub bounce)

A wake-driven runChat is structurally identical to a normal turn. There is **no automatic re-fire** on the parent unless the sub-agent itself sends another reply. If both sides bounce indefinitely, that's an LLM/policy issue capped by the existing token / wall-time / spawn-depth budgets. We add no new recursion.

The post-wake DB probe (step 6) is the only "self-trigger" — and it only fires if rows are STILL pending, which means the previous wake didn't pick them up. It's bounded: each wake drains rows; if none added during the wake, probe finds zero, stops.

---

## File-by-file changes

### NEW: `core/wake.js`

Implements the helper described in (a). Lazy-requires `./router` and `../infrastructure/database` to avoid circular import.

### Modified: `core/queue.js`

- After the inner `db.enqueueAgentMessage(...)` in `postCorrelatedResponse`'s auto-deliver branch (around line 161), call `wakeIdleTargetIfNeeded({ targetSessionId: row.from_session_id })`.
- Apply same call inside `notifyTaskSubscribers` after each subscription enqueue (around line 189). Fixes the related latent bug for task-completion notifications.

Lazy-require `./wake` (queue.js → wake.js → router.js → queue.js cycle).

### Modified: `core/async-inform.js`

- After the final-text `db.enqueueAgentMessage(...)` (around line 96): call wake helper with `targetSessionId: callerSessionId`.
- After the error-path enqueue (around line 132): same.

Lazy-require `./wake`.

### Modified: `core/router.js`

- In `_doRunChatBody`, just before the `for await (const event of runLoop(...))` (around line 456), call `recordSessionRunContext(sid, { cwd, settings })`.
- In `runClaudeChat`, similarly before `for await (const event of runClaudeSession(...))` (around line 578).

`runChat` itself needs no change — `continueFlag:true, message:null` shape is already handled.

### Documentation

- `docs/api/03-chat.md` (or `docs/guide/09-multi-agent.md`) — clarify that the runtime now wakes idle parents automatically. Update the existing async_inform paragraph: "delivered to you as a new user message" is no longer a deferred promise — a fresh runChat fires within milliseconds of the sub-agent's reply landing.

---

## Test design

All tests run against `http://localhost:5050`, use `test/e2e/client.js`. New file: `test/e2e/scenario-3h-async-wake.js`.

For each test using the assertion "parent woke and saw the message," parents are instructed via the orchestrator chat to call `memory_write({ scope: "agent", content: "wake_proof_<sessionId>:<received text>" })` whenever they see a `[Message from <name>]:` user message they didn't request synchronously. This gives an observable side-effect.

### Scenario 1 — Idle-parent wake (openai engine, msg#1)
1. Spawn `assistant` (`moonshotai/kimi-k2.5`) as parent P.
2. P calls `agent_spawn(agent="coder", instance_name="sub", initial_message={message:"reply with 'sub-finished'", async_inform:true})`. P's chat ends after returning the static notice.
3. Wait 5s. Confirm P's session not in `GET /sessions/streaming`.
4. Wait up to 60s, polling P's memory file for `wake_proof_<sid>`. Assert: a new user-role message in P's session matching `[Message from sub ...]: ... sub-finished`, AND `wake_proof_*` key exists. The latter proves P's runChat actually executed and the LLM saw the message.

### Scenario 2 — Idle-parent wake (claude-cli engine)
Same as Scenario 1 but P=`cc-haiku`, sub=`cc-sonnet`. Asserts the wake fires through `runClaudeChat` → fresh `runClaudeSession`.

### Scenario 3 — msg#2+ mid-run reply wakes idle parent
1. Spawn parent P (`assistant`), sub `coder` doing a ~30s sleeping task.
2. P calls `agent_spawn(... async_inform:true ...)` to start sub. P's turn ends.
3. Test driver fires a SECOND chat to P telling it to send `agent_message(sessionId=sub, message="status?", async_inform:true)` then end. P's second turn ends; sub still running.
4. Sub's mid-run reply lands → `postCorrelatedResponse` auto-deliver enqueues into P's session → wake fires.
5. Assert P logged the mid-run text (which carries `<system-reminder>This is a mid-run reply...`) via memory_write. When sub later finishes, assert the final reply also wakes P.

### Scenario 4 — No wake when target is already busy
1. Parent P running a long turn (instruct via chat to call `tool_search` 8 times in a row before final response).
2. While P loops, a sub's reply enqueues to P's session.
3. Assert: only ONE `chat.iteration` event sequence observed for P (the existing one) — no second wake-driven turn. The drain happens INSIDE P's already-running loop.

### Scenario 5 — Multiple replies same tick → single wake
1. Parent P idle. Spawn 3 subs each set to reply quickly.
2. Three auto-delivers land within ~10ms.
3. Assert exactly ONE wake-driven runChat (count session-stream events for P over a 30s window). All three deliveries drain inside that one turn.

### Scenario 6 — No infinite loop on parent⇄sub bounce
1. P system prompt: "When you receive a message from your sub, reply with 'pong' via `agent_message(async_inform:true)`."
2. Sub: same with 'ping'.
3. Each side has `max_iterations: 4` in their `budget_override`.
4. Start the chain. Assert each side has ≤ small bounded number of turns; budget cap or natural agent stop ends it.

### Scenario 7 — Wake races with real user message
1. P idle. Sub finishes; auto-delivery enqueues row.
2. Within 50ms, fire real `POST /agents/assistant/chat` with `sessionId=P_sid`.
3. Assert: only one `runLoop` runs at a time (via `_runChatLoopLocks`); both messages appear in P's history; no duplicate drains.

### Scenario 8 — Closed-session safety
1. Sub finishes its reply, but parent's session was just `DELETE /sessions/:id`'d (status='closed').
2. Assert wake helper sees `status==='closed'` and no-ops; no error logs for `[wake]`.

### Scenario 9 — Cancellation in flight
1. Wake-driven runChat starts. Mid-iteration, hit `POST /sessions/:P_sid/cancel`.
2. Assert cancel propagates via `cancelRegistry`; `runningSessions.unregister` clears entry; `_wakingNow` clears in finally. Subsequent enqueue triggers a new wake (no leak).

---

## Risks / edge cases

- **Cancellation in flight.** `runChat` already wires `cancelRegistry.register('session:'+sid)`. Wake's runChat inherits this; on abort, finally blocks run, `_wakingNow` clears. Verified by Scenario 9.
- **Closed-status parent.** Explicit guard in `wakeIdleTargetIfNeeded`. Without it, `_doRunChatBody` would throw `SESSION_CLOSED` (swallowed) — but pre-checking is cleaner.
- **Mid-tool-execution parent.** `runningSessions.has(sid)` returns true → wake no-ops. The currently-running loop's next `drainNonFollowup` picks the row.
- **Daemon-mode and subagent-mode targets.** Guard: only wake `mode === 'chat'`. Daemons and subagent-mode sessions have different lifecycles; auto-resuming them is wrong semantics.
- **Process restart.** In-memory dedup maps lost. Acceptable: next chat naturally drains. Future improvement: startup sweep of pending agent_messages rows.
- **claude-cli engine specifics.** Wake creates a fresh `runClaudeSession` (previous runtime torn down at end-of-turn; `_activeRuntimes.delete` runs in finally at engines/claude-engine.js:634). The drain at engines/claude-engine.js:483 picks queued rows on first iteration. Does NOT use `pushToExistingSession` (the broken mid-run injection — Issue 2d).
- **Trace IDs.** Wake-driven runChat doesn't pass `traceId`, so a fresh trace_id is generated. Acceptable for now; document. Future: persist `session.trace_id` and reuse to keep cross-turn correlation.
- **Compaction.** Wake-driven turn loads the full session history; queued rows append as user messages — same as a real turn. No special handling.
- **Test flakiness.** Sub-agents on real LLM calls have variable latency. Use generous timeouts (60s+) and poll, don't sleep blind.

---

## Implementation workflow

1. Create `core/wake.js`.
2. Modify `core/queue.js`, `core/async-inform.js`, `core/router.js` per the file-by-file section.
3. Write `test/e2e/scenario-3h-async-wake.js` per the test design.
4. Run scenarios 1–9. Fix until all pass.
5. Run criticizer subagent on diff + test results.
6. Apply criticizer-driven fixes if any.
7. Notify user.

## Files touched

| File | Type |
|------|------|
| `core/wake.js` | NEW |
| `core/queue.js` | MODIFIED |
| `core/async-inform.js` | MODIFIED |
| `core/router.js` | MODIFIED |
| `test/e2e/scenario-3h-async-wake.js` | NEW |
| `docs/guide/09-multi-agent.md` | MODIFIED (note autonomous wake) |
| `docs/api/03-chat.md` | MODIFIED (clarify async_inform delivery contract) |

---

## Dependencies on other plans

- **Issue 2d** (claude-cli mid-run injection). 2a's claude-cli wake path uses fresh `runClaudeSession` — no mid-run injection — so 2a is independent. But if 2d's resolution buffers async-delivered messages until end-of-turn for claude-cli, then 2a's wake mechanism becomes the primary delivery path on claude-cli (no more mid-run async deliveries). Document the interaction in 2d's plan.

---

## Criticizer-driven addenda (post-review v2)

### Additional enqueue sites that need wake calls

The original plan covered 2 enqueue sites + 1 task-related (queue.js's `notifyTaskSubscribers`). The criticizer identified 3 more relevant sites:

- **[tools/agent_message.js:108-118](tools/agent_message.js#L108)** — msg#2+ enqueue from caller side (when caller fires async_inform but target has in-flight dispatch). The row's `target_session_id` is the SUB-AGENT (the dispatch target), and the dispatcher's `runChat` is currently active for that target → `runningSessions.has(target)` is true → wake helper no-ops. **No change needed**, but call the helper anyway as belt-and-suspenders (idempotent).
- **[tools/agent_message.js:243-250](tools/agent_message.js#L243)** — sync enqueue with merged correlation IDs (sync-after-async coordination). Same reasoning: target session is the one running the dispatch; helper no-ops via `runningSessions` check. **Call helper for consistency**.
- **[tools/task_respond.js:24-29](tools/task_respond.js#L24)** — task-respond enqueue. **NO targetSessionId** — it's task-mode only and uses agent-name targeting. **Skip wake** entirely; task-mode lifecycle is different.

### Fixed Scenario 6 — `max_iterations` is NOT in `budget_override`

`budget_override` only accepts `{ max_tokens, max_wall_seconds, max_spawn_depth }` (per [utils/budget.js:33-37](utils/budget.js#L33)). `max_iterations` lives in `modeConfig.maxIterations` (per [core/loop.js:189](core/loop.js#L189)) and is set in `agent.json` mode config, NOT per-call.

**Test fix.** For Scenario 6 (parent⇄sub bounce), modify the agents' `agent.json` files to set `chat.maxIterations: 4` directly, OR (less invasive) use the existing pre-set agents and rely on `max_tokens: 1000` in `budget_override` as the bound — token cap will fire instead of iteration cap. Either works.

### Documentation reconciliation

[docs/guide/09-multi-agent.md](docs/guide/09-multi-agent.md) currently says async_inform replies are "delivered to you as a new user message" without specifying timing. Update to say:

> The runtime now wakes idle parents automatically. When the sub-agent's reply lands in the parent's queue and the parent's chat loop has already ended, a fresh `runChat` fires within milliseconds, drains the queued reply at iteration 1, and the agent processes it as a normal user message.

Also clarify Scenario 6 reality: **bounce loops are bounded by `chat.maxIterations` per session**, not by a global anti-recursion guard.

### Wake helper safety: explicit `instance_folder` reload

If `_sessionRunContext` is missing (process restart), the fallback uses `session.instance_folder` for `cwd` and reloads settings via `loadSettings({ cwd: session.instance_folder })`. The `instance_folder` column on `sessions` table is verified to exist and contain useful paths (per [infrastructure/database.js:78](infrastructure/database.js#L78)). Document that this fallback path will use whatever settings live on disk now — possibly different from what the original turn used. Acceptable tradeoff.
