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
15 changes: 15 additions & 0 deletions docs/design/background-agent-runtime-generations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Background Agent runtime generations

## Problem

A background Agent that does not settle after cancellation can leave its ACP child usable enough to answer transport probes but unsafe for fresh work. Replacing that child must not move its existing Sessions, create unbounded children, or route new work back to the draining generation.

## Design

Each ACP bridge channel has one of three states: `active`, `draining`, or `dying`. Existing Session entries continue to route through their recorded channel while it drains. Existing timeout retirement paths and explicit recycle requests mark only the affected generation as draining; fresh work then creates a new active generation.

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.

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.
68 changes: 48 additions & 20 deletions packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ import {
InvalidRewindTargetError,
PromptDeadlineExceededError,
BridgeChannelQuarantinedError,
BridgeRuntimeRecyclingError,
McpAuthenticationInProgressError,
StandaloneSessionSpawnError,
} from './bridgeErrors.js';
Expand Down Expand Up @@ -1055,9 +1056,10 @@ interface ChannelInfo {
* `killAllSync` must still find the channel during the SIGTERM
* grace window to fire SIGKILL on `process.exit(1)`. `aliveChannels`
* holds the dying entry until `channel.exited` fires (OS-level
* reap); `isDying` is the "available-for-new-spawns" half of the
* two-bit (alive, dying) state.
* reap). Draining generations retain their Session owners but cannot accept
* fresh work; dying generations are unavailable while the OS reaps them.
*/
state: 'active' | 'draining' | 'dying';
isDying: boolean;
/** Existing sessions stay usable, but no fresh session work may enter. */
isQuarantined: boolean;
Expand Down Expand Up @@ -2749,7 +2751,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
| { channel: ChannelInfo; reason: BridgeChannelUnavailableReason }
| undefined => {
for (const ci of aliveChannels) {
if (ci.isDying) continue;
if (ci.state !== 'active') continue;
if (ci.isQuarantined) {
return { channel: ci, reason: 'restore_cleanup_failed' };
}
Expand Down Expand Up @@ -3004,8 +3006,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// same-workspace attach under `single` scope reuses). Thread-scope
// sessions add to `byId` but don't displace `defaultEntry`.
let defaultEntry: SessionEntry | undefined;
// `channelInfo` is the SINGLE attach-available channel. Cleared
// ONLY by the `channel.exited` handler (see below) when the OS
// `channelInfo` is the newest generation. It is attach-available only while
// active, and is cleared ONLY by its `channel.exited` handler when the OS
// reaps the underlying child process. Teardown initiators
// (`killSession` last-session-leaving — via `startIdleTimer` ->
// `killChannelWithLog` / `reapPendingEmptyChannel`,
Expand Down Expand Up @@ -3694,17 +3696,36 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
ci: ChannelInfo,
context: string,
): Promise<void> {
if (ci.isDying) return;
if (ci.state === 'dying') return;
if (hasNoSessionWork(ci)) {
await killChannelWithLog(ci, context);
return;
}
if (ci.state === 'draining') return;
ci.state = 'draining';
if (channelInfo === ci) cancelIdleTimer();
ci.retireWhenSessionsDrain = true;
writeStderrLine(
`qwen serve: ${context}; deferring channel retirement until ${ci.sessionIds.size} active session(s) drain`,
);
}

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 @@ -4493,8 +4514,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// would either hang or land the caller with a sessionId that
// immediately 404s on every follow-up.
cancelIdleTimer();
if (channelInfo && !channelInfo.isDying) return channelInfo;
if (channelInfo?.state === 'active') return channelInfo;
if (inFlightChannelSpawn) return await inFlightChannelSpawn;
if (aliveChannels.size >= 2) throw new BridgeRuntimeRecyclingError();

const promise = (async () => {
const privateParentCapability = randomBytes(32).toString('base64url');
Expand Down Expand Up @@ -4554,7 +4576,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// instead of throwing. Surface that ambiguity loudly.
(sessionId) => {
if (sessionId) return byId.get(sessionId);
if (channelInfo && channelInfo.sessionIds.size > 1) {
if (sessionIds.size > 1) {
throw new Error(
'BridgeClient: ACP call without sessionId on a ' +
'multi-session channel cannot be routed — workspace=' +
Expand Down Expand Up @@ -4648,9 +4670,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
.catch(() => undefined);
},
opts.onChannelDelivery,
() =>
channelInfo?.sessionIds === sessionIds &&
channelInfo.sessionSpawnsInFlight > 0,
() => (infoRef.current?.sessionSpawnsInFlight ?? 0) > 0,
() => liveScreenContextCaptureHandler,
() => liveTaskToolRequestHandler,
() => liveSpeakToUserHandler,
Expand All @@ -4667,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 @@ -4738,7 +4759,13 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
newSessionCleanupFailed: false,
transportFailed: false,
transportFailureInitiatedTeardown: false,
isDying: false,
state: 'active',
get isDying() {
return this.state === 'dying';
},
set isDying(value) {
if (value) this.state = 'dying';
},
isQuarantined: false,
handshakeComplete: false,
};
Expand Down Expand Up @@ -5128,10 +5155,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
}),
onFailure: failChannelLiveness,
isActive: () =>
channelInfo === info &&
aliveChannels.has(info) &&
!info.isDying &&
!shuttingDown,
aliveChannels.has(info) && !info.isDying && !shuttingDown,
});
}
telemetry.metrics?.channelLifecycle('spawn');
Expand Down Expand Up @@ -5348,7 +5372,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// `ensureChannel`, never spawning a fresh one. Tear down the
// empty channel so the next attempt gets a clean spawn.
const channelPath =
channelInfo && !channelInfo.isDying
channelInfo?.state === 'active'
? 'reused'
: inFlightChannelSpawn
? 'joined'
Expand Down Expand Up @@ -6107,7 +6131,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
};

const liveChannelInfo = (): ChannelInfo | undefined => {
if (!channelInfo || channelInfo.isDying) return undefined;
if (channelInfo?.state !== 'active') return undefined;
return channelInfo;
};

Expand Down Expand Up @@ -9430,7 +9454,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
},

isChannelLive() {
return !!liveChannelInfo();
return liveChannelInfo() !== undefined;
},

getWorkspaceRuntimeLifecycleSnapshot() {
Expand All @@ -9447,7 +9471,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
}
const starting = inFlightChannelSpawn !== undefined;
const stopping = Array.from(aliveChannels).some(
(candidate) => candidate.isDying,
(candidate) => candidate.state !== 'active',
);
const reservedWork =
runtimeOperationReservations > 0 ||
Expand Down Expand Up @@ -9476,6 +9500,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
};
},

async requestRuntimeRecycle(sessionId) {
await requestRuntimeRecycleForSession(sessionId);
},

get pendingPermissionCount() {
return permissionMediator.pendingCount;
},
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
11 changes: 11 additions & 0 deletions packages/acp-bridge/src/bridgeErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,17 @@ export class BridgeChannelQuarantinedError extends Error {
}
}

export class BridgeRuntimeRecyclingError extends Error {
readonly code = 'runtime_recycling';

constructor() {
super(
'The ACP runtime is recycling; retry after an older generation exits',
);
this.name = 'BridgeRuntimeRecyclingError';
}
}

export class InvalidRewindTargetError extends Error {
readonly sessionId: string;
constructor(sessionId: string, message?: string) {
Expand Down
17 changes: 10 additions & 7 deletions packages/acp-bridge/src/bridgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2448,13 +2448,10 @@ export interface AcpSessionBridge extends WorkspaceEventBridge {
readonly sessionCount: number;

/**
* Whether an ACP channel is currently live (spawned and not dying).
* Distinct from `sessionCount > 0`: a channel can be live with zero
* attached sessions during the cold-spawn window, and conversely a
* killed channel may briefly retain sessions before reaping. Consumers
* that need true channel liveness (e.g. the workspace service's
* `acpChannelLive` envelope field) must use this rather than the
* session count.
* Whether an ACP channel is active and can accept fresh workspace work.
* Distinct from `sessionCount > 0`: a channel can be active with zero
* attached sessions during the cold-spawn window, while a draining
* generation still owns existing sessions but cannot accept new work.
*/
isChannelLive(): boolean;

Expand All @@ -2465,6 +2462,12 @@ export interface AcpSessionBridge extends WorkspaceEventBridge {
*/
getWorkspaceRuntimeLifecycleSnapshot?(): BridgeWorkspaceRuntimeLifecycleSnapshot;

/**
* Stop admitting fresh work to the generation that owns `sessionId` and
* prepare a replacement without moving existing Sessions between children.
*/
requestRuntimeRecycle?(sessionId: string): Promise<void>;

/** Number of sessions with an active prompt. */
readonly activePromptCount: number;

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
Loading
Loading