Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,8 +314,12 @@ main agent's, its `AskUserQuestion` parked the workspace idle while background s
were still working (the park then swallowing their busy signals until the next prompt),
and any tool it tried after an idle `Stop` would trip the `PreToolUse`-while-idle rule with
no `Stop` to follow. Only the main agent's `PreToolUse(AskUserQuestion)` parks, and only its
`PostToolUse`/`PostToolUseFailure(AskUserQuestion)` unparks; sub-agent tool activity still
reaches the status through `PostToolUse`. Pinned against a real TUI by the `suggestionfork`
`PostToolUse`/`PostToolUseFailure(AskUserQuestion)` or its turn-ending `Stop` unparks;
sub-agent tool activity still reaches the status through `PostToolUse`. The `Stop` covers a
question Claude drops without a `PostToolUse` (a message arriving mid-tool can start a second
turn branch that asks it, and the first branch then ends the turn): no question can outlive
the turn, and a `Stop` that lifts a park while background tasks keep the workspace busy goes
busy instead of leaving it parked idle. Pinned against a real TUI by the `suggestionfork`
scenario in `claude/server-manager.boundary.test.ts`.

### Busy→Idle Edge for Untracked Turns (Claude Code)
Expand Down
31 changes: 31 additions & 0 deletions src/modules/agent-module/claude/server-manager.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2405,6 +2405,37 @@ describe("ClaudeCodeServerManager integration", () => {
expect(statusChanges).toEqual(["idle", "busy", "idle", "busy"]);
});

it("the main agent's Stop lifts a dropped AskUserQuestion park while sub-agents run", async () => {
// Real trace: a message arriving mid-tool started a second turn branch that
// asked a question; the first branch then ended the turn and the question
// vanished with no PostToolUse. The Stop must unpark, and the still-running
// sub-agents keep the workspace busy instead of their activity being
// suppressed to idle.
const { port, statusChanges } = await start();
await sendHook(port, "PreToolUse", { workspacePath: WS, tool_name: "AskUserQuestion" });
expect(lastStatus(statusChanges)).toBe("idle");

await sendHook(port, "Stop", stopWith([subagentTask()]));
expect(lastStatus(statusChanges)).toBe("busy");

// The idle_prompt echo stays suppressed; the sub-agents' end still goes idle.
await sendHook(port, "Notification", { workspacePath: WS, notification_type: "idle_prompt" });
await sendHook(port, "UserPromptSubmit", { workspacePath: WS });
await sendHook(port, "Stop", stopWith([]));
expect(statusChanges).toEqual(["idle", "busy", "idle", "busy", "idle"]);
});

it("the main agent's Stop lifts a dropped park with nothing running (stays idle)", async () => {
const { port, statusChanges } = await start();
await sendHook(port, "PreToolUse", { workspacePath: WS, tool_name: "AskUserQuestion" });
await sendHook(port, "Stop", stopWith([]));
expect(lastStatus(statusChanges)).toBe("idle");

// No park is left behind: a later sub-agent-free tool call is not suppressed.
await sendHook(port, "PreToolUse", { workspacePath: WS, tool_name: "Bash" });
expect(lastStatus(statusChanges)).toBe("busy");
});

it("Stop without sub-agents still transitions to idle normally", async () => {
const port = await serverManager.startServer(testPath("/workspace/feature-a").toNative(), {
workspaceRef: refOf(testPath("/workspace/feature-a").toNative()),
Expand Down
12 changes: 11 additions & 1 deletion src/modules/agent-module/claude/server-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1124,12 +1124,22 @@ export class ClaudeCodeServerManager implements AgentServerManager {
// auth, max-tokens — and the payload omits the field), so it always goes idle
// to surface the stuck main agent regardless of background work; clear the
// stash there.
//
// The main agent's Stop also ends any AskUserQuestion park: its turn is over,
// so no question of its own can still be open. Claude can drop a question
// without a PostToolUse (seen when a message arriving mid-tool started a
// second turn branch that asked it, and the first branch then ended the
// turn), and a park left behind would suppress every busy signal from the
// still-running background tasks. So a Stop that lifts a park while tasks
// keep the workspace busy goes busy rather than staying parked idle.
if (hookName === "Stop") {
const tasks = Array.isArray(payload.background_tasks) ? payload.background_tasks : [];
const busyTasks = tasks.filter((task) => taskKeepsBusy(task));
const wasParked = state.awaitingUserInputResolution === true;
state.awaitingUserInputResolution = false;
state.busyForBackgroundTasks = busyTasks.length > 0;
if (busyTasks.length > 0) {
newStatus = null;
newStatus = wasParked ? "busy" : null;
this.logger.scoped({ path: normalizedPath }).debug("Idle suppressed for background tasks", {
tasks: busyTasks.map((task) => task.command ?? task.agent_type ?? task.type).join(", "),
});
Expand Down
Loading