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
4 changes: 3 additions & 1 deletion docs/design/background-agent-runtime-generations.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ Each ACP bridge channel has one of three states: `active`, `draining`, or `dying

The bridge keeps at most two OS-live generations. If both slots are occupied and neither can accept fresh work, admission fails with `503 runtime_recycling` until an older generation exits. Dying generations remain tracked until process exit so synchronous shutdown can still reach them.

This changes no persisted Session format and adds no public timeout configuration. Non-cooperative Agent detection and the child-to-daemon recycle request are connected in the following stacked PR.
After a logical watchdog abort, the Agent gets a fixed five-second cooperative exit window. If it still has not settled, its registry entry and sidecar become failed once while the underlying run keeps its concurrency slot. The terminal notification is recorded and displayed without starting another model turn, then the trusted child-to-daemon route requests recycle for the Session's owner generation. A late Agent settlement releases the physical slot but cannot replace the failed terminal state.

This changes no persisted Session format and adds no public timeout configuration.
29 changes: 18 additions & 11 deletions packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3710,6 +3710,22 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
);
}

async function requestRuntimeRecycleForSession(
sessionId: string,
): Promise<void> {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
const owner = channelInfoForEntry(entry);
if (!owner || owner.state === 'dying') {
throw new SessionNotFoundError(sessionId);
}
await retireChannelAfterSessionsDrain(
owner,
`runtime recycle requested by session ${JSON.stringify(sessionId)}`,
);
if (!owner.isDying) await ensureChannel();
}

async function retireChannelOnTimeout(
ci: ChannelInfo,
error: unknown,
Expand Down Expand Up @@ -4671,6 +4687,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// nothing else would settle what its last drain missed.
settleMidTurnQueueAfterGoalTurn,
opts.onCreateCurrentSessionScheduledTask,
requestRuntimeRecycleForSession,
);
const rawConnection = new ClientSideConnection(
() =>
Expand Down Expand Up @@ -9484,17 +9501,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
},

async requestRuntimeRecycle(sessionId) {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
const owner = channelInfoForEntry(entry);
if (!owner || owner.state === 'dying') {
throw new SessionNotFoundError(sessionId);
}
await retireChannelAfterSessionsDrain(
owner,
`runtime recycle requested by session ${JSON.stringify(sessionId)}`,
);
if (!owner.isDying) await ensureChannel();
await requestRuntimeRecycleForSession(sessionId);
},

get pendingPermissionCount() {
Expand Down
23 changes: 22 additions & 1 deletion packages/acp-bridge/src/bridgeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -915,6 +915,8 @@ export class BridgeClient implements Client {
*/
private readonly onGoalTurnEnded?: (sessionId: string) => void,
private readonly onCreateCurrentSessionScheduledTask?: CurrentSessionScheduledTaskCreateHandler,
/** Owner-scoped runtime recycle; wired only by the managed daemon bridge. */
private readonly onRuntimeRecycle?: (sessionId: string) => Promise<void>,
) {}

async requestPermission(
Expand Down Expand Up @@ -1314,7 +1316,9 @@ export class BridgeClient implements Client {
* `qwen/control/client_mcp/message` (reverse tool channel),
* `qwen/control/create-sub-session` (the `create_sub_session` tool → daemon
* spawns a sub-session and, for `'first-turn'`, returns its first-turn
* result), and `craft/drainMidTurnQueue`: the ACP child calls the last one
* result), `qwen/control/session/runtime/recycle` (trusted owner-generation
* recycle after an Agent ignores abort), and `craft/drainMidTurnQueue`: the
* ACP child calls the last one
* between tool batches to pull any messages the browser queued mid-turn. We splice the per-session
* queue, return them to the child as the response, and — when non-empty —
* publish a `mid_turn_message_injected` SSE frame so the browser can move
Expand All @@ -1328,6 +1332,23 @@ export class BridgeClient implements Client {
method: string,
params: Record<string, unknown>,
): Promise<Record<string, unknown>> {
if (method === SERVE_CONTROL_EXT_METHODS.sessionRuntimeRecycle) {
if (!this.onRuntimeRecycle) throw RequestError.methodNotFound(method);
const sessionId = params['sessionId'];
if (
typeof sessionId !== 'string' ||
!this.ownsSession(sessionId) ||
!this.resolveEntry(sessionId) ||
params['reason'] !== 'unresponsive_agent'
) {
throw RequestError.invalidParams(
undefined,
'Invalid unresponsive Agent runtime recycle request.',
);
}
await this.onRuntimeRecycle(sessionId);
return { accepted: true };
}
// Reverse tool channel (issue #5626, Phase 2): the child's session
// `McpClientManager` routes a client-hosted MCP server's
// `sendSdkMcpMessage` UP to the parent through this method. We hand the
Expand Down
2 changes: 2 additions & 0 deletions packages/acp-bridge/src/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ export const SERVE_CONTROL_EXT_METHODS = {
sessionLiveConversation: 'qwen/control/session/live-conversation',
sessionLiveTranscript: 'qwen/control/session/live-transcript',
sessionBackgroundNotification: 'qwen/control/session/background_notification',
/** Private child→daemon request for an abort-ignoring background Agent. */
sessionRuntimeRecycle: 'qwen/control/session/runtime/recycle',
sessionArtifactsPersist: 'qwen/control/session/artifacts/persist',
workspaceMcpRestart: 'qwen/control/workspace/mcp/restart',
workspaceMcpManage: 'qwen/control/workspace/mcp/manage',
Expand Down
52 changes: 48 additions & 4 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9505,14 +9505,12 @@ export class Session implements SessionContext {
(entry
? buildBackgroundEntryLabel(entry, { includePrefix: false })
: undefined);
this.#enqueueBackgroundNotification({
const item: BackgroundNotificationQueueItem = {
displayText,
modelText,
taskId: meta.agentId,
status: meta.status,
kind: 'agent',
continuesTodoStopGuardWorkChain:
this.#agentContinuesTodoStopGuardWorkChain(meta.agentId),
toolUseId: meta.toolUseId,
todoWorkChainId: meta.todoWorkChainId,
label: label ? truncateNotificationLabel(label) : undefined,
Expand All @@ -9523,6 +9521,15 @@ export class Session implements SessionContext {
),
}
: undefined,
};
if (meta.recordOnly) {
void this.#recordUnresponsiveAgentNotification(item);
return;
}
this.#enqueueBackgroundNotification({
...item,
continuesTodoStopGuardWorkChain:
this.#agentContinuesTodoStopGuardWorkChain(meta.agentId),
});
},
);
Expand Down Expand Up @@ -9710,6 +9717,42 @@ export class Session implements SessionContext {
void this.#drainNotificationQueue();
}

async #recordUnresponsiveAgentNotification(
item: BackgroundNotificationQueueItem,
): Promise<void> {
this.activeNotificationAcceptances.add(item.taskId);
this.#activeWorkChanged();
try {
const accepted = await this.#persistDaemonBackgroundNotification(
item,
false,
);
if (accepted && !this.disposed && !this.closing) {
await this.#emitBackgroundNotificationDisplay(item);
}
} catch (error) {
debugLogger.warn(
`Unresponsive Agent notification failed [session ${this.sessionId}, task ${item.taskId}]: ${this.#formatError(error)}`,
);
} finally {
try {
await this.client.extMethod(
SERVE_CONTROL_EXT_METHODS.sessionRuntimeRecycle,
{
sessionId: this.sessionId,
reason: 'unresponsive_agent',
},
);
} catch (error) {
debugLogger.warn(
`Unresponsive Agent runtime recycle failed [session ${this.sessionId}, task ${item.taskId}]: ${this.#formatError(error)}`,
);
}
this.activeNotificationAcceptances.delete(item.taskId);
this.#activeWorkChanged();
}
}

async enqueueBackgroundNotification(
item: BackgroundNotificationQueueItem,
): Promise<{ accepted: boolean }> {
Expand Down Expand Up @@ -9742,6 +9785,7 @@ export class Session implements SessionContext {

async #persistDaemonBackgroundNotification(
item: BackgroundNotificationQueueItem,
enqueue = true,
): Promise<boolean> {
if (this.disposed || this.closing) return false;
const recording = this.config.getChatRecordingService();
Expand All @@ -9766,7 +9810,7 @@ export class Session implements SessionContext {
}

this.persistedBackgroundNotificationTaskIds.add(item.taskId);
if (!this.disposed && !this.closing) {
if (enqueue && !this.disposed && !this.closing) {
this.#enqueueBackgroundNotification({
...item,
continuesTodoStopGuardWorkChain:
Expand Down
9 changes: 7 additions & 2 deletions packages/cli/src/nonInteractiveCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1489,7 +1489,7 @@ export async function runNonInteractive(
// tool-call chain can push completions onto the queue.
const registry = config.getBackgroundTaskRegistry();
registry.setNotificationCallback((displayText, modelText, meta) => {
localQueue.push({
const item: LocalQueueItem = {
displayText,
modelText,
sendMessageType: SendMessageType.Notification,
Expand All @@ -1506,7 +1506,12 @@ export async function runNonInteractive(
}
: undefined,
},
});
};
if (meta.recordOnly) {
emitNotificationToSdk(item);
return;
}
localQueue.push(item);
});

registry.setRegisterCallback((entry) => {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/hooks/use-llm-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6204,6 +6204,7 @@ export const useLlmStream = (
useEffect(() => {
const registry = config.getBackgroundTaskRegistry();
registry.setNotificationCallback((displayText, modelText, meta) => {
if (meta.recordOnly) return;
notificationQueueRef.current.push({
displayText,
modelText,
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/agents/background-agent-resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1294,6 +1294,7 @@ export class BackgroundAgentResumeService {
stopHookWarning,
);
const stats = getCompletionStats(subagent, liveToolCallCount);
if (registry.get(meta.agentId)?.retainsPhysicalSlot) break;
if (terminateMode === AgentTerminateMode.GOAL) {
const pending = registry.drainMessages(meta.agentId);
if (pending.length > 0) {
Expand Down Expand Up @@ -1359,6 +1360,7 @@ export class BackgroundAgentResumeService {
debugLogger.error(
`[BackgroundAgentResume] Background agent failed: ${errorMessage}`,
);
if (registry.get(meta.agentId)?.retainsPhysicalSlot) return;
if (turnAbortController.signal.aborted && !progressTimeout) {
const stats = getCompletionStats(subagent, liveToolCallCount);
registry.finalizeCancelled(meta.agentId, errorMessage, stats);
Expand Down Expand Up @@ -1403,6 +1405,7 @@ export class BackgroundAgentResumeService {
bgEmitter,
turnAbortController,
() => monitorRegistry.hasRunningForOwner(meta.agentId),
(error) => registry.failUnresponsive(meta.agentId, error.message),
);
// Restore the persisted launch depth so a resumed nested agent keeps
// its original nesting level (and spawn eligibility) instead of
Expand All @@ -1419,7 +1422,10 @@ export class BackgroundAgentResumeService {
target.isFork
? runInForkContext(invocationRunBody)
: invocationRunBody()
).finally(disposeWatchdog);
).finally(() => {
disposeWatchdog();
registry.releaseRetainedPhysicalSlot(meta.agentId);
});
};

const reportUnexpectedBackgroundError = (error: unknown) => {
Expand Down
53 changes: 48 additions & 5 deletions packages/core/src/agents/background-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,8 @@ export interface AgentTask extends TaskBase {
* `running` so `/resume` can recover the work later.
*/
persistedCancellationStatus?: Extract<TaskStatus, 'running' | 'cancelled'>;
/** The underlying run ignored abort and still occupies a physical slot. */
retainsPhysicalSlot?: true;
}

/**
Expand Down Expand Up @@ -398,6 +400,7 @@ export interface NotificationMeta {
toolUseId?: string;
todoWorkChainId?: string;
label?: string;
recordOnly?: true;
}

export type BackgroundNotificationCallback = (
Expand Down Expand Up @@ -914,6 +917,41 @@ export class BackgroundTaskRegistry {
this.drainWaitQueue();
}

failUnresponsive(agentId: string, error: string): void {
const entry = this.agents.get(agentId);
if (
!entry ||
(entry.status !== 'running' && entry.status !== 'cancelled') ||
entry.notified
)
return;

entry.status = 'failed';
entry.endTime = Date.now();
entry.error = error;
entry.retainsPhysicalSlot = true;
if (entry.metaPath) {
patchAgentMeta(entry.metaPath, {
status: 'failed',
lastUpdatedAt: new Date().toISOString(),
lastError: error,
});
}
this.releaseFinishingWaiters(agentId, true);
this.rejectPendingApprovals(entry);
this.emitNotification(entry, true);
this.emitStatusChange(entry);
this.disposeResidentAgent(agentId);
}

releaseRetainedPhysicalSlot(agentId: string): void {
const entry = this.agents.get(agentId);
if (!entry?.retainsPhysicalSlot) return;
delete entry.retainsPhysicalSlot;
this.emitStatusChange(entry);
this.drainWaitQueue();
}

// Cancellation aborts the signal and marks the entry as cancelled, but
// does *not* emit the terminal notification immediately. The natural
// completion path (bgBody) fires complete()/fail()/finalizeCancelled()
Expand Down Expand Up @@ -1286,16 +1324,17 @@ export class BackgroundTaskRegistry {
return Array.from(this.agents.values());
}

// Counts backgrounded agents that still occupy a slot: running, or
// cancelled-but-not-yet-finalized. When `model` is given, only agents on
// that model are counted (per-model cap); otherwise all of them (global).
// Counts backgrounded agents that still occupy a slot: running,
// cancelled-but-not-yet-finalized, or watchdog-terminal but not physically
// settled. When `model` is given, only agents on that model are counted.
private getRunningBackgroundCount(model?: string): number {
let count = 0;
for (const entry of this.agents.values()) {
const occupiesSlot =
entry.isBackgrounded &&
(entry.status === 'running' ||
(entry.status === 'cancelled' && !entry.notified));
(entry.status === 'cancelled' && !entry.notified) ||
entry.retainsPhysicalSlot === true);
if (!occupiesSlot) {
continue;
}
Expand Down Expand Up @@ -1451,6 +1490,8 @@ export class BackgroundTaskRegistry {
* registry right after passing the gate, which suppresses that very
* notification, so blocking on it made the command silently no-op
* when the user cleared immediately after cancelling (issue #5949).
* A watchdog-terminal run is excluded even while its physical slot remains
* reserved: runtime recycling, not Session work retention, owns its teardown.
* Headless holdback loops must keep using `hasUnfinalizedTasks()` so
* every task_started still pairs with a task_notification.
*/
Expand Down Expand Up @@ -1693,7 +1734,7 @@ export class BackgroundTaskRegistry {
return buildBackgroundEntryLabel(entry);
}

private emitNotification(entry: AgentTask): void {
private emitNotification(entry: AgentTask, recordOnly = false): void {
// Mark notified *before* invoking the callback so that a re-entrant
// terminal call inside the callback chain (cancel → complete race)
// sees the flag and short-circuits, rather than firing twice.
Expand Down Expand Up @@ -1772,6 +1813,7 @@ export class BackgroundTaskRegistry {
stats: entry.stats,
toolUseId: entry.toolUseId,
todoWorkChainId: entry.todoWorkChainId,
...(recordOnly ? { recordOnly: true } : {}),
label: buildBackgroundEntryLabel(entry, { includePrefix: false }),
};

Expand Down Expand Up @@ -1833,6 +1875,7 @@ export class BackgroundTaskRegistry {
private pruneTerminalEntries(): void {
const evictable = Array.from(this.agents.values())
.filter((entry) => entry.notified === true)
.filter((entry) => !entry.retainsPhysicalSlot)
.sort(
(a, b) =>
(a.endTime ?? a.startTime) - (b.endTime ?? b.startTime) ||
Expand Down
Loading
Loading