diff --git a/packages/adapters/claude/src/hooks.ts b/packages/adapters/claude/src/hooks.ts index c9d68583..f40d8950 100644 --- a/packages/adapters/claude/src/hooks.ts +++ b/packages/adapters/claude/src/hooks.ts @@ -12,9 +12,13 @@ import { HOOK_SH } from "@middle/core"; * - `SessionStart` → `session.started` carries `session_id`/`transcript_path` * and triggers the launch→drive transition. * - `Stop` → `agent.stopped` is the turn boundary the workflow classifies. - * `SubagentStop` also normalizes to `agent.stopped` (per the taxonomy); the - * dispatcher correlates by session, so a subagent turn boundary is treated as a - * stop signal for the session. + * `SubagentStop` maps to its own `agent.subagent-stopped` event — NOT + * `agent.stopped`. A subagent (e.g. an Explore agent the implementer spawned) + * finishing is not the *main* agent's turn boundary; the main agent is still + * working. Conflating the two let the first subagent's completion resolve + * `awaitStop`, which classified a `bare-stop` and tore the workflow down + * mid-research. `agent.subagent-stopped` is recorded but never resolves the + * stop awaiter. */ const CLAUDE_EVENT_MAP: ReadonlyArray<[claudeEvent: string, normalized: NormalizedEvent]> = [ ["SessionStart", "session.started"], @@ -23,7 +27,7 @@ const CLAUDE_EVENT_MAP: ReadonlyArray<[claudeEvent: string, normalized: Normaliz ["PostToolUse", "tool.post"], ["Notification", "agent.notification"], ["Stop", "agent.stopped"], - ["SubagentStop", "agent.stopped"], + ["SubagentStop", "agent.subagent-stopped"], ["SessionEnd", "session.ended"], ]; diff --git a/packages/adapters/claude/test/adapter.test.ts b/packages/adapters/claude/test/adapter.test.ts index 188a072a..09be4fbf 100644 --- a/packages/adapters/claude/test/adapter.test.ts +++ b/packages/adapters/claude/test/adapter.test.ts @@ -341,7 +341,7 @@ describe("installHooks", () => { expect(cmd("PostToolUse")).toBe(`"${abs}" tool.post`); expect(cmd("Notification")).toBe(`"${abs}" agent.notification`); expect(cmd("Stop")).toBe(`"${abs}" agent.stopped`); - expect(cmd("SubagentStop")).toBe(`"${abs}" agent.stopped`); + expect(cmd("SubagentStop")).toBe(`"${abs}" agent.subagent-stopped`); expect(cmd("SessionEnd")).toBe(`"${abs}" session.ended`); }); diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 69a79de9..b4b02141 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -11,6 +11,7 @@ export type NormalizedEvent = | "tool.failed" | "agent.notification" | "agent.stopped" + | "agent.subagent-stopped" | "session.ended" | "rate-limit.detected"; @@ -28,6 +29,7 @@ export const NORMALIZED_EVENTS: readonly NormalizedEvent[] = [ "tool.failed", "agent.notification", "agent.stopped", + "agent.subagent-stopped", "session.ended", "rate-limit.detected", ]; diff --git a/packages/dispatcher/test/hook-server.test.ts b/packages/dispatcher/test/hook-server.test.ts index 743f4e37..4f98431f 100644 --- a/packages/dispatcher/test/hook-server.test.ts +++ b/packages/dispatcher/test/hook-server.test.ts @@ -68,6 +68,21 @@ describe("HookServer — Stop", () => { const payload = await pending; expect(payload.reason).toBe("turn-end"); }); + + test("a subagent stop does NOT resolve awaitStop — only the main agent's Stop does", async () => { + // Regression: SubagentStop normalizes to agent.subagent-stopped, not + // agent.stopped. A spawned Explore agent finishing must not be mistaken for + // the main agent's turn boundary (which tore the workflow down mid-research). + const pending = server.awaitStop("middle-6", 300); + await postHook("agent.subagent-stopped", "middle-6", { reason: "subagent-done" }); + // the subagent stop is accepted but does not satisfy the stop awaiter + await expect(pending).rejects.toThrow(); + + // the main agent's real Stop does resolve it + const next = server.awaitStop("middle-6", 1000); + await postHook("agent.stopped", "middle-6", { reason: "turn-end" }); + expect((await next).reason).toBe("turn-end"); + }); }); describe("HookServer — HMAC auth + event validation (with store)", () => { diff --git a/planning/middle-management-build-spec.md b/planning/middle-management-build-spec.md index 8f04bc17..fc7e2b8f 100644 --- a/planning/middle-management-build-spec.md +++ b/planning/middle-management-build-spec.md @@ -808,14 +808,15 @@ All adapters emit these. The hook script POSTs `{type, sessionName, payload}` to | `tool.post` | PostToolUse | command hook (success) | | `tool.failed` | PostToolUseFailure | command hook (failure) | | `agent.notification` | Notification | n/a | -| `agent.stopped` | Stop / SubagentStop | turn-end hook | +| `agent.stopped` | Stop | turn-end hook | +| `agent.subagent-stopped` | SubagentStop | (subagent turn-end) | | `session.ended` | SessionEnd | shutdown hook | | `rate-limit.detected` | (synthetic from Stop) | (synthetic from Stop) | Two events are **load-bearing for dispatch**, not merely observational: - `session.started` carries `session_id` and `transcript_path` in its payload. It is how the dispatcher discovers the on-disk transcript at all, and it triggers the launch→drive transition (enter auto mode, confirm readiness, send the prompt). -- `agent.stopped` is the turn boundary the workflow reacts to. Because the interactive process does not exit between turns, this — not a process exit — is the signal the dispatcher classifies (`classifyStop`). +- `agent.stopped` is the turn boundary the workflow reacts to. Because the interactive process does not exit between turns, this — not a process exit — is the signal the dispatcher classifies (`classifyStop`). It is fired only by the **main** agent's Stop. A `SubagentStop` (e.g. an Explore agent the implementer spawned finishing) is **not** a main-agent turn boundary — the main agent is still working — so it maps to the separate, observational `agent.subagent-stopped` and never resolves the workflow's stop awaiter. Conflating the two would let the first subagent's completion classify a premature `bare-stop` and tear the dispatch down mid-work. The hook script is uniform across both: