diff --git a/packages/coding-agent/.changes/autonomous-continuation-subagent-gate.md b/packages/coding-agent/.changes/autonomous-continuation-subagent-gate.md new file mode 100644 index 000000000..4a3b5efd4 --- /dev/null +++ b/packages/coding-agent/.changes/autonomous-continuation-subagent-gate.md @@ -0,0 +1 @@ +- Autonomous mode now holds timer-driven continuations while subagents run, mirroring the goal continuation gate: child replies and exit notices wake the parent, so idle status-check turns no longer consume continuation budget. The held continuation is delivered when descendants settle, and a configurable keep-alive valve (default one continuation per 25 minutes of continuous subagent activity, `/autonomous on --subagent-keep-alive-ms `, `0` disables) lets the parent check for hung children. diff --git a/packages/coding-agent/docs/long-running-agents.md b/packages/coding-agent/docs/long-running-agents.md index edd115f0f..e06729f2e 100644 --- a/packages/coding-agent/docs/long-running-agents.md +++ b/packages/coding-agent/docs/long-running-agents.md @@ -233,6 +233,18 @@ not name becomes unlimited, so `/autonomous on --max-tokens 100,000` runs until that token budget is spent. With no budget flags at all, the configured or default limits still apply. Repeating `--gate` appends another gate. +While subagents are running, autonomous mode holds its timer-driven +continuations instead of re-prompting the waiting parent: child replies and +exit notices are the real wake-up signals, so idle status-check turns no +longer burn continuation budget. The held continuation is delivered once the +descendants settle, mirroring how goal continuations wait for subagent work. +A slow keep-alive valve still fires one continuation per window of continuous +subagent activity so the parent can inspect and unblock hung children (for +example, stopped processes); the default window is 25 minutes and +`--subagent-keep-alive-ms 0` disables it. Keep the window below any +configured wall-clock budget so the valve fires before `--timeout-ms` caps +the run. + Autonomous mode supports limits for continuations, assistant turns, tokens, and wall-clock duration. Gate commands run before the session may finish; a failed gate returns its bounded output to the agent for another attempt. Prime Agent avoids rerunning the same failed gate when the workspace has not changed. Goals and autonomous mode are complementary but different: diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index cc7a7ea3f..7722f459b 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -85,13 +85,19 @@ import { type AutonomousRuntimeState, addAutonomousContinuation, addAutonomousUsage, + autonomousLimitReason, autonomousStatus, + createAutonomousContinuationMessage, + createAutonomousGateFailureContinuationMessage, createAutonomousRuntimeState, + createAutonomousSubagentKeepAliveMessage, isUnlimitedAutonomousLimit, + MAX_SUBAGENT_KEEP_ALIVE_MS, nextAutonomousContinuation, refreshAutonomousQualityGates, setAutonomousEnabled, setAutonomousLimits, + shouldAutonomouslyContinue, UNLIMITED_AUTONOMOUS_LIMIT, } from "./autonomous.js"; import { type BashResult, executeBashWithOperations } from "./bash-executor.js"; @@ -1044,7 +1050,7 @@ function parseGoalBudgetValue(value: string): number { const AUTONOMOUS_STATUS_NUMBER_FORMAT = new Intl.NumberFormat("en-US"); const AUTONOMOUS_BUDGET_USAGE = - "Usage: /autonomous [status|off] or /autonomous on [--max-continuations ] [--max-turns ] [--max-tokens ] [--timeout-ms ] [--gate ] [--gate-retries ] [--gate-timeout-ms ]"; + "Usage: /autonomous [status|off] or /autonomous on [--max-continuations ] [--max-turns ] [--max-tokens ] [--timeout-ms ] [--gate ] [--gate-retries ] [--gate-timeout-ms ] [--subagent-keep-alive-ms ]"; // `/autonomous` budget flags mirror the `--autonomous-*` CLI options. The CLI // spelling (`--autonomous-max-continuations`) is accepted as an alias so the @@ -1057,8 +1063,23 @@ const AUTONOMOUS_BUDGET_FLAGS: ReadonlySet = new Set([ "gate", "gate-retries", "gate-timeout-ms", + "subagent-keep-alive-ms", ]); +/** Keep-alive windows accept 0 (disable the valve) or a positive integer. */ +function parseSubagentKeepAliveMs(value: string): number { + const digits = value.replace(/[,_]/g, ""); + if (digits === "0" || /^[1-9]\d*$/.test(digits)) { + const parsed = Number(digits); + if (parsed <= MAX_SUBAGENT_KEEP_ALIVE_MS) { + return parsed; + } + } + throw new Error( + `--subagent-keep-alive-ms must be 0 or a positive integer up to ${MAX_SUBAGENT_KEEP_ALIVE_MS}. ${AUTONOMOUS_BUDGET_USAGE}`, + ); +} + function parseAutonomousBudgetInt(flag: string, value: string, allowUnlimited = false): number { if (allowUnlimited && value.toLowerCase() === "unlimited") { return UNLIMITED_AUTONOMOUS_LIMIT; @@ -1124,6 +1145,9 @@ function parseAutonomousBudgetOptions(tokens: string[]): AgentAutonomousConfig { case "timeout-ms": config.timeoutMs = parseAutonomousBudgetInt(flag, value, true); break; + case "subagent-keep-alive-ms": + config.subagentKeepAliveMs = parseSubagentKeepAliveMs(value); + break; } } if (gateCommands.length > 0) { @@ -1279,6 +1303,19 @@ export class AgentSession { private _autonomousState: AutonomousRuntimeState; private _autonomousContinuationSuppressionDepth = 0; private _autonomousContinuationSuppressedMessages = new WeakSet(); + // Held autonomous continuation owed while descendant work runs; mirrors + // _goalContinuationAwaitsRlmWork. Child replies and exit notices are the + // real wake-up signals, so timer-driven continuations pause instead of + // re-prompting a waiting parent (and pause without consuming budget). + private _autonomousContinuationAwaitsRlmWork = false; + private _autonomousSubagentKeepAliveTimer: ReturnType | undefined = undefined; + // In-flight gate evaluation for an owed continuation; holds the promise so + // settlement sites never double-fire the resume. + private _autonomousContinuationResumeTask: Promise | undefined = undefined; + // Monotonic count of admitted RLM child terminal notices; differencing + // against the arrival epoch separates sibling notices (benign for the + // owed continuation) from user-driven admissions. + private _rlmTerminalNoticeAdmissionCount = 0; private _compactionAbortController: AbortController | undefined = undefined; private _autoCompactionAbortController: AbortController | undefined = undefined; @@ -2234,7 +2271,14 @@ export class AgentSession { const timeBudget = isUnlimitedAutonomousLimit(status.limits.timeoutMs) ? "unlimited" : `${AUTONOMOUS_STATUS_NUMBER_FORMAT.format(Math.round(status.limits.timeoutMs / 1000))}s`; - return `[autonomous-status: ${state}]\n\nContinuations: ${formatCount(status.continuationsUsed)}/${formatCount(status.limits.maxContinuations)}. Turns: ${formatCount(status.turnsUsed)}/${formatCount(status.limits.maxTurns)}. Tokens: ${formatCount(status.tokensUsed)}/${formatCount(status.limits.maxTokens)}. Time: ${elapsedSeconds}s/${timeBudget}. Gates: ${gateSummary}.`; + const subagentKeepAliveMs = status.subagentKeepAliveMs ?? 0; + const keepAlive = + subagentKeepAliveMs > 0 + ? subagentKeepAliveMs >= 60_000 + ? `${AUTONOMOUS_STATUS_NUMBER_FORMAT.format(Math.round(subagentKeepAliveMs / 60_000))}m` + : `${AUTONOMOUS_STATUS_NUMBER_FORMAT.format(subagentKeepAliveMs)}ms` + : "off"; + return `[autonomous-status: ${state}]\n\nContinuations: ${formatCount(status.continuationsUsed)}/${formatCount(status.limits.maxContinuations)}. Turns: ${formatCount(status.turnsUsed)}/${formatCount(status.limits.maxTurns)}. Tokens: ${formatCount(status.tokensUsed)}/${formatCount(status.limits.maxTokens)}. Time: ${elapsedSeconds}s/${timeBudget}. Gates: ${gateSummary}. Subagent keep-alive: ${keepAlive}.`; } private _emitAutonomousStatus(): void { @@ -2265,9 +2309,17 @@ export class AgentSession { if (command.kind === "on") { setAutonomousEnabled(this._autonomousState, true, { cwd: this._cwd }); setAutonomousLimits(this._autonomousState, command.config); + // Re-sync the keep-alive with the new window: a 0 setting must + // disarm an already-armed timer, and a shortened window must not + // keep the old one pending. + this._disarmAutonomousSubagentKeepAlive(); + if (this._autonomousContinuationAwaitsRlmWork) { + this._armAutonomousSubagentKeepAlive(); + } } else if (command.kind === "off") { setAutonomousEnabled(this._autonomousState, false); this._clearQueuedAutonomousContinuations(); + this._clearAutonomousContinuationAwait(); } this._emitAutonomousStatus(); return true; @@ -2365,6 +2417,220 @@ export class AgentSession { } } + /** + * Hold the timer-driven autonomous continuation while descendant work is + * unsettled, mirroring the goal gate: delegating and ending the turn is + * correct behavior, and child replies and exit notices are the real + * wake-up signals. The owed continuation is delivered when descendants + * settle without consuming the continuation budget while it waits. An + * active goal holds its own continuation, so the held continuation is + * not double-queued behind it. + */ + private _holdAutonomousContinuationForRlmWork(message: AssistantMessage): boolean { + if (!this._autonomousState.enabled) { + return false; + } + if (message.stopReason === "error" || message.stopReason === "aborted") { + return false; + } + if (autonomousLimitReason(this._autonomousState)) { + // The run is over: hold nothing so the hook can apply the limit. + return false; + } + if (!this._hasUnsettledRlmQuiescenceWork()) { + return false; + } + // An active goal's own continuation gate owns the wake-up discipline; + // drop any owed continuation so both are never queued. + if (this._goalOwnsContinuationWakeup()) { + this._clearAutonomousContinuationAwait(); + return true; + } + this._autonomousContinuationAwaitsRlmWork = true; + this._armAutonomousSubagentKeepAlive(); + return true; + } + + /** True while an active goal's continuation loop owns the session wake-ups. */ + private _goalOwnsContinuationWakeup(): boolean { + return this._goalState.status === "active" && !!this._goalState.objective; + } + + /** Deliver the owed continuation once descendant work settles. */ + private _maybeResumeAutonomousContinuationAfterRlmWork(): void { + if (!this._autonomousContinuationAwaitsRlmWork) return; + if (this._disposed || this._disposing || this._hasUnsettledRlmQuiescenceWork()) return; + if (!this._autonomousState.enabled || this._goalOwnsContinuationWakeup()) { + this._clearAutonomousContinuationAwait(); + return; + } + // Keep the deferral while admission is paused or the pump is suspended + // (post-abort); the pause release and resumeQueuedWork retry. + if (this._sessionInputAdmissionPauses.size > 0 || this._sessionInputPumpSuspended) return; + if (this._autonomousContinuationResumeTask) return; + this._autonomousContinuationResumeTask = this._resumeOwedAutonomousContinuation().finally(() => { + this._autonomousContinuationResumeTask = undefined; + }); + } + + /** + * Deliver the owed continuation, evaluating configured quality gates first + * so a settlement never spends a turn when the gates already pass. Counted + * at delivery like a goal continuation; a failed admission rolls the count + * back and keeps the deferral for the pause-release retry. + */ + private async _resumeOwedAutonomousContinuation(): Promise { + const snapshot = this._snapshotAutonomousRuntimeState(); + const beforeGates = { + arrivalEpoch: this._sessionInputArrivalEpoch, + noticeAdmissions: this._rlmTerminalNoticeAdmissionCount, + }; + try { + // agent_end clears the live field, so fall back to the transcript; + // either way the session's last assistant turn decides the gate run. + const lastAssistantMessage = + this._lastAssistantMessage ?? this._findLastAssistantInMessages(this.agent.state.messages); + if (!lastAssistantMessage) { + if (autonomousLimitReason(this._autonomousState)) { + // The run is over; no continuation is owed anymore. + this._clearAutonomousContinuationAwait(); + return; + } + addAutonomousContinuation(this._autonomousState); + this._admitOwedAutonomousContinuation(createAutonomousContinuationMessage(this._autonomousState)); + return; + } + // Configured quality gates decide whether the run is already done. + // The decision runs before any accounting is written so a stale + // drop never spends a continuation or clobbers a user reset. + const decision = await shouldAutonomouslyContinue(this._autonomousState, lastAssistantMessage, { + cwd: this._cwd, + signal: this.agent.signal, + }); + if (!decision.shouldContinue) { + this._clearAutonomousContinuationAwait(); + return; + } + // Re-validate after the gate await: mode-off, a goal takeover, or + // any user-driven admission (finished or queued) must not be + // bypassed by a stale continuation. Sibling terminal notices are + // the exception: this owed continuation is exactly the wake that + // reads them. + const admissions = this._sessionInputArrivalEpoch - beforeGates.arrivalEpoch; + const noticeAdmissions = this._rlmTerminalNoticeAdmissionCount - beforeGates.noticeAdmissions; + const userDrivenAdmissions = admissions > noticeAdmissions; + if ( + this._disposed || + this._disposing || + !this._autonomousState.enabled || + this._goalOwnsContinuationWakeup() || + userDrivenAdmissions + ) { + this._clearAutonomousContinuationAwait(); + return; + } + addAutonomousContinuation(this._autonomousState); + const message = + (decision.reason === "gate_failed" + ? createAutonomousGateFailureContinuationMessage(this._autonomousState) + : undefined) ?? createAutonomousContinuationMessage(this._autonomousState); + this._admitOwedAutonomousContinuation(message); + } catch { + // Admission can race a new pause; roll back so the retry re-counts. + this._restoreAutonomousRuntimeSnapshot(snapshot); + } + } + + /** Admit an already-built owed continuation behind pending notices. */ + private _admitOwedAutonomousContinuation(message: UserMessage): void { + const normalized = normalizeMessageContent(message.content); + // No front: a settling child's terminal notice must be read first. + this._admitSessionInput( + this._createPreparedTurnAction("followUp", normalized.text, normalized.images, { + message, + resumeIfIdle: true, + }), + ); + this._clearAutonomousContinuationAwait(); + } + + /** + * Deliver the keep-alive continuation while subagents are still active. + * Returns false when admission raced a pause so the window can re-arm. + */ + private _deliverAutonomousSubagentKeepAlive(): boolean { + if (autonomousLimitReason(this._autonomousState)) { + // The run is over; no keep-alive is owed anymore. + this._clearAutonomousContinuationAwait(); + return true; + } + const snapshot = this._snapshotAutonomousRuntimeState(); + try { + addAutonomousContinuation(this._autonomousState); + this._admitOwedAutonomousContinuation(createAutonomousSubagentKeepAliveMessage(this._autonomousState)); + return true; + } catch { + // Admission can race a new pause; roll back so the retry re-counts. + this._restoreAutonomousRuntimeSnapshot(snapshot); + return false; + } + } + + /** One keep-alive continuation per window of continuous subagent activity. */ + private _armAutonomousSubagentKeepAlive(): void { + if (this._autonomousSubagentKeepAliveTimer !== undefined) return; + const keepAliveMs = this._autonomousState.subagentKeepAliveMs; + if (!keepAliveMs || keepAliveMs <= 0) return; + const timer = setTimeout(() => { + this._autonomousSubagentKeepAliveTimer = undefined; + this._fireAutonomousSubagentKeepAlive(); + }, keepAliveMs); + // A pending keep-alive must not hold the event loop open on its own. + timer.unref(); + this._autonomousSubagentKeepAliveTimer = timer; + } + + private _disarmAutonomousSubagentKeepAlive(): void { + if (this._autonomousSubagentKeepAliveTimer === undefined) return; + clearTimeout(this._autonomousSubagentKeepAliveTimer); + this._autonomousSubagentKeepAliveTimer = undefined; + } + + private _clearAutonomousContinuationAwait(): void { + this._autonomousContinuationAwaitsRlmWork = false; + this._disarmAutonomousSubagentKeepAlive(); + } + + /** + * Safety valve for hung children: while subagents stay active past the + * keep-alive window, wake the parent so it can inspect and unblock them + * (a stopped SIGTTIN child never delivers its exit notice). + */ + private _fireAutonomousSubagentKeepAlive(): void { + if (!this._autonomousContinuationAwaitsRlmWork) return; + if (this._disposed || this._disposing) return; + if (!this._hasUnsettledRlmQuiescenceWork()) { + // Descendants settled while the keep-alive was pending; the normal + // resume path owns delivery. + this._maybeResumeAutonomousContinuationAfterRlmWork(); + return; + } + if (!this._autonomousState.enabled || this._goalOwnsContinuationWakeup()) { + this._clearAutonomousContinuationAwait(); + return; + } + // Keep the deferral while admission is paused or the pump is suspended + // (post-abort); the pause release and resumeQueuedWork retry. + if (this._sessionInputAdmissionPauses.size > 0 || this._sessionInputPumpSuspended) { + this._armAutonomousSubagentKeepAlive(); + return; + } + if (!this._deliverAutonomousSubagentKeepAlive()) { + // Admission raced a pause; retry after another window. + this._armAutonomousSubagentKeepAlive(); + } + } + private _runOrQueueGoalContext(kind: "continuation" | "objective_updated", images?: ImageContent[]): void { if (!this._goalState.objective) return; this._ensureGoalRuntimeActive(); @@ -3051,6 +3317,11 @@ export class AgentSession { if (queuedMessage && this._postCompactionContinuationMessages.includes(queuedMessage)) { return queuedMessage; } + // Hold the post-compaction continuation while descendants are unsettled; + // the owed continuation is delivered when they settle. + if (this._holdAutonomousContinuationForRlmWork(message)) { + return undefined; + } const snapshot = this._snapshotAutonomousRuntimeState(); const arrivalEpoch = this._sessionInputArrivalEpoch; const autonomousMessage = await nextAutonomousContinuation(this._autonomousState, message, { @@ -3605,6 +3876,12 @@ export class AgentSession { ) { return []; } + // Delegating and ending the turn is correct behavior; hold the + // continuation until descendants settle instead of re-prompting a + // waiting parent, mirroring the goal gate above. + if (this._holdAutonomousContinuationForRlmWork(context.message)) { + return []; + } const autonomousSnapshot = this._snapshotAutonomousRuntimeState(); const autonomousMessage = await nextAutonomousContinuation(this._autonomousState, context.message, { cwd: this._cwd, @@ -4367,6 +4644,7 @@ export class AgentSession { clearTimeout(timer); } this._scheduledAutoRefineTimers.clear(); + this._disarmAutonomousSubagentKeepAlive(); this._serializedPlanInFlight = undefined; this._serializedExplicitRefineOptions = undefined; this._pendingRequestedRefine = undefined; @@ -4937,6 +5215,7 @@ export class AgentSession { try { const result = this._admitSessionInput(action, { wake: false }); if (!result.accepted) throw new Error("RLM child terminal notice was not admitted."); + this._rlmTerminalNoticeAdmissionCount++; } catch (error) { this._durableRlmTerminalNoticeActionIds.delete(action.id); throw error; @@ -7056,6 +7335,7 @@ export class AgentSession { this._notifySessionInputCheckpointChange(); this._flushDeferredRlmTerminalNotices(); this._maybeResumeGoalContinuationAfterRlmWork(); + this._maybeResumeAutonomousContinuationAfterRlmWork(); this._scheduleSessionInputPump(); }, }; @@ -7173,6 +7453,7 @@ export class AgentSession { resumeQueuedWork(): boolean { this._resumeSessionInputAdmission(); this._maybeResumeGoalContinuationAfterRlmWork(); + this._maybeResumeAutonomousContinuationAfterRlmWork(); this._scheduleSessionInputPump(); return this._hasSelectableSessionInput(); } @@ -10131,6 +10412,7 @@ export class AgentSession { this._unsettledRlmChildRuns.delete(run); run.settlement.resolve(); this._maybeResumeGoalContinuationAfterRlmWork(); + this._maybeResumeAutonomousContinuationAfterRlmWork(); } private _cancelActiveRlmChildRuns(reason: string): void { @@ -10559,6 +10841,7 @@ export class AgentSession { run.deletionReservation.resolve(); this._unsettledRlmChildRuns.delete(run); this._maybeResumeGoalContinuationAfterRlmWork(); + this._maybeResumeAutonomousContinuationAfterRlmWork(); } private _observeRlmRunDeletionCleanup( @@ -11537,6 +11820,7 @@ export class AgentSession { run.settlement.resolve(); this._unsettledRlmChildRuns.delete(run); this._maybeResumeGoalContinuationAfterRlmWork(); + this._maybeResumeAutonomousContinuationAfterRlmWork(); } } })().catch(() => undefined); diff --git a/packages/coding-agent/src/core/autonomous.ts b/packages/coding-agent/src/core/autonomous.ts index c5efd3606..0dab26ce4 100644 --- a/packages/coding-agent/src/core/autonomous.ts +++ b/packages/coding-agent/src/core/autonomous.ts @@ -14,6 +14,13 @@ export interface AgentAutonomousConfig { timeoutMs?: number; continuationPrompt?: string; gates?: AgentAutonomousGateConfig; + /** + * While subagents run, timer-driven continuations are held (child messages + * and exit notices are the real wake-up signals). This window allows one + * keep-alive continuation of continuous subagent activity so the parent + * can still check for hung children. `0` disables the keep-alive valve. + */ + subagentKeepAliveMs?: number; } export interface AgentAutonomousGateConfig { @@ -35,17 +42,19 @@ export interface AgentAutonomousStatus { turnsUsed: number; tokensUsed: number; startedAt?: number; - limits: Required>; + limits: Required>; gates: Required; gateAttempts: Record; lastGateFailure?: AgentAutonomousGateFailure; + /** Configured subagent keep-alive window; 0 disables the keep-alive valve. */ + subagentKeepAliveMs?: number; } export const DEFAULT_AUTONOMOUS_CONTINUATION_PROMPT = "No human input is available in autonomous mode. Continue working until the host evaluator, verifier, or configured autonomous limits stop the run. If you were asking the user a question, make a reasonable assumption and verify it. If you believe you are blocked, prove it with host-observable evidence, preserve that evidence, and keep looking for safe progress while budget remains. Do not end the session yourself; the verifier/evaluator decides completion when configured gates pass."; export const DEFAULT_AUTONOMOUS_LIMITS: Required< - Omit + Omit > = { maxContinuations: 3, maxTurns: 12, @@ -59,6 +68,22 @@ export const DEFAULT_AUTONOMOUS_GATES: Required = { timeoutMs: 5 * 60 * 1000, }; +/** + * Default subagent keep-alive window: while subagents run, one continuation + * per 25 minutes of continuous activity still fires so the parent can check + * for hung or stopped children instead of sleeping until they finish. Kept + * strictly below the default wall-clock budget (30 minutes) so the valve + * fires before the run's timeout caps it; keep custom windows below any + * configured --timeout-ms for the same reason. + */ +export const DEFAULT_AUTONOMOUS_SUBAGENT_KEEP_ALIVE_MS = 25 * 60 * 1000; + +/** + * Largest keep-alive window `setTimeout` accepts: Node clamps larger delays + * to 1 ms, which would turn a huge window into a keep-alive storm. + */ +export const MAX_SUBAGENT_KEEP_ALIVE_MS = 2_147_483_647; + /** * JSON-safe sentinel meaning "no cap". Limit checks compare usage against the * configured value, so this stays finite and serializes to JSON while no @@ -79,12 +104,14 @@ export interface AutonomousRuntimeState { turnsUsed: number; tokensUsed: number; startedAt?: number; - limits: Required>; + limits: Required>; continuationPrompt: string; gates: Required; gateAttempts: Record; lastGateFailure?: GateFailure; lastGateFailureSnapshot?: GitWorktreeSnapshot; + /** Configured subagent keep-alive window; 0 disables the keep-alive valve. */ + subagentKeepAliveMs: number; } export type AutonomousLimitReason = "maxContinuations" | "maxTurns" | "maxTokens" | "timeoutMs"; @@ -139,9 +166,21 @@ export function createAutonomousRuntimeState( gateAttempts: {}, lastGateFailure: undefined, lastGateFailureSnapshot: undefined, + subagentKeepAliveMs: normalizeSubagentKeepAliveMs(config?.subagentKeepAliveMs), }; } +/** + * `undefined` (and invalid values) fall back to the default window; an + * explicit `0` disables the subagent keep-alive valve entirely. + */ +function normalizeSubagentKeepAliveMs(value: number | undefined): number { + if (value === 0) { + return 0; + } + return Math.min(normalizeLimit(value, DEFAULT_AUTONOMOUS_SUBAGENT_KEEP_ALIVE_MS), MAX_SUBAGENT_KEEP_ALIVE_MS); +} + export function setAutonomousEnabled( state: AutonomousRuntimeState, enabled: boolean, @@ -187,6 +226,9 @@ export function setAutonomousLimits(state: AutonomousRuntimeState, config?: Agen state.gates.maxRetries = normalizeLimit(config.gates.maxRetries, state.gates.maxRetries); state.gates.timeoutMs = normalizeLimit(config.gates.timeoutMs, state.gates.timeoutMs); } + if (config.subagentKeepAliveMs !== undefined) { + state.subagentKeepAliveMs = normalizeSubagentKeepAliveMs(config.subagentKeepAliveMs); + } } export function autonomousStatus(state: AutonomousRuntimeState): AgentAutonomousStatus { @@ -200,6 +242,7 @@ export function autonomousStatus(state: AutonomousRuntimeState): AgentAutonomous gates: { ...state.gates, commands: [...state.gates.commands] }, gateAttempts: { ...state.gateAttempts }, lastGateFailure: state.lastGateFailure ? { ...state.lastGateFailure } : undefined, + subagentKeepAliveMs: state.subagentKeepAliveMs, }; } @@ -257,6 +300,79 @@ export async function nextAutonomousContinuation( }; } +/** + * Build the continuation message for a held autonomous continuation that is + * now being delivered (subagents settled, or the keep-alive window elapsed). + * Budget accounting is the caller's job so a failed admission can roll it + * back, mirroring how goal continuations account at delivery. + */ +export function createAutonomousContinuationMessage( + state: AutonomousRuntimeState, + timestamp = Date.now(), +): UserMessage { + return { + role: "user", + content: [ + { + type: "text", + text: `[autonomous-continuation]\n\n${state.continuationPrompt}`, + }, + ], + timestamp, + }; +} + +/** + * Build the gate-failure continuation message for a resume whose quality + * gates failed, mirroring the hook's gate-failure continuation text. + */ +export function createAutonomousGateFailureContinuationMessage( + state: AutonomousRuntimeState, + timestamp = Date.now(), +): UserMessage | undefined { + const failure = state.lastGateFailure; + if (!failure) { + return undefined; + } + return { + role: "user", + content: [ + { + type: "text", + text: buildAutonomousGateFailureContinuation(failure, state.gates.maxRetries, timestamp), + }, + ], + timestamp, + }; +} + +/** + * Keep-alive continuation delivered while subagents are still active: the + * parent gets a bounded chance to inspect and unblock hung children (for + * example, SIGTTIN-stopped processes) instead of sleeping until they finish. + */ +export function createAutonomousSubagentKeepAliveMessage( + state: AutonomousRuntimeState, + timestamp = Date.now(), +): UserMessage { + const minutes = Math.max(1, Math.round(state.subagentKeepAliveMs / 60_000)); + return { + role: "user", + content: [ + { + type: "text", + text: + `[autonomous-continuation: subagent-keep-alive]\n\n` + + `Subagents have been running for at least ${minutes} minute${minutes === 1 ? "" : "s"} ` + + `without a reply or exit being delivered. Check their status (for example agent_observe, ` + + `rlm.list_subagents, or process inspection) and cancel or unblock any that are hung; ` + + `then continue working.`, + }, + ], + timestamp, + }; +} + export async function shouldAutonomouslyContinue( state: AutonomousRuntimeState, message: AssistantMessage, diff --git a/packages/coding-agent/test/autonomous-continuation-subagent-gate.test.ts b/packages/coding-agent/test/autonomous-continuation-subagent-gate.test.ts new file mode 100644 index 000000000..806f49478 --- /dev/null +++ b/packages/coding-agent/test/autonomous-continuation-subagent-gate.test.ts @@ -0,0 +1,510 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AgentSession } from "../src/core/agent-session.js"; +import { + type AutonomousRuntimeState, + createAutonomousRuntimeState, + DEFAULT_AUTONOMOUS_LIMITS, + DEFAULT_AUTONOMOUS_SUBAGENT_KEEP_ALIVE_MS, +} from "../src/core/autonomous.js"; + +type FakeAssistantMessage = { role: "assistant"; stopReason: string }; + +type FakeSession = { + _autonomousState: AutonomousRuntimeState; + _autonomousContinuationAwaitsRlmWork: boolean; + _autonomousSubagentKeepAliveTimer: ReturnType | undefined; + _autonomousContinuationResumeTask: Promise | undefined; + _lastAssistantMessage: { role: "assistant"; stopReason: string } | undefined; + agent: { signal: AbortSignal | undefined; state: { messages: unknown[] } }; + _disposed: boolean; + _disposing: boolean; + _sessionInputAdmissionPauses: Set; + _sessionInputPumpSuspended: boolean; + _sessionInputArrivalEpoch: number; + _cwd: string | undefined; + queuedActionCount: number; + _goalState: { status: string; objective?: string }; + _goalAccountingStartedAt: number | undefined; + _getGoalContinuationMessages: () => Promise; + _autonomousContinuationSuppressionDepth: number; + _autonomousContinuationSuppressedMessages: WeakSet; + _hasUnsettledRlmQuiescenceWork: () => boolean; + _rlmTerminalNoticeAdmissionCount: number; + _admitSessionInput: ReturnType; + _createPreparedTurnAction: ReturnType; + _snapshotAutonomousRuntimeState: () => unknown; + _restoreAutonomousRuntimeSnapshot: (snapshot: unknown) => void; +}; + +const getContinuationMessages = Reflect.get(AgentSession.prototype, "_getContinuationMessages") as ( + this: FakeSession, + context: { message: FakeAssistantMessage; newMessages: unknown[] }, + signal?: AbortSignal, +) => Promise; +const holdForRlmWork = Reflect.get(AgentSession.prototype, "_holdAutonomousContinuationForRlmWork") as ( + this: FakeSession, + message: FakeAssistantMessage, +) => boolean; +const maybeResume = Reflect.get(AgentSession.prototype, "_maybeResumeAutonomousContinuationAfterRlmWork") as ( + this: FakeSession, +) => void; +const fireKeepAlive = Reflect.get(AgentSession.prototype, "_fireAutonomousSubagentKeepAlive") as ( + this: FakeSession, +) => void; +const queueThresholdContinuation = Reflect.get( + AgentSession.prototype, + "_queueAutonomousContinuationForThresholdCompaction", +) as (this: FakeSession, message: FakeAssistantMessage) => Promise; +const snapshotRuntimeState = Reflect.get(AgentSession.prototype, "_snapshotAutonomousRuntimeState") as ( + this: FakeSession, +) => unknown; +const restoreRuntimeSnapshot = Reflect.get(AgentSession.prototype, "_restoreAutonomousRuntimeSnapshot") as ( + this: FakeSession, + snapshot: unknown, +) => void; + +function fakeSession(overrides: Partial = {}): FakeSession { + const session: FakeSession = { + _autonomousState: createAutonomousRuntimeState({ enabled: true, maxContinuations: 5 }), + _autonomousContinuationAwaitsRlmWork: false, + _autonomousSubagentKeepAliveTimer: undefined, + _autonomousContinuationResumeTask: undefined, + _lastAssistantMessage: { role: "assistant", stopReason: "stop" }, + agent: { signal: undefined, state: { messages: [] } }, + _disposed: false, + _disposing: false, + _sessionInputAdmissionPauses: new Set(), + _sessionInputPumpSuspended: false, + _sessionInputArrivalEpoch: 0, + _cwd: undefined, + queuedActionCount: 0, + _goalState: { status: "idle" }, + _goalAccountingStartedAt: undefined, + _getGoalContinuationMessages: async () => [], + _autonomousContinuationSuppressionDepth: 0, + _autonomousContinuationSuppressedMessages: new WeakSet(), + _hasUnsettledRlmQuiescenceWork: () => false, + _rlmTerminalNoticeAdmissionCount: 0, + _admitSessionInput: vi.fn(), + _createPreparedTurnAction: vi.fn( + (schedule: string, _text: string, _images: unknown, options: Record) => ({ + schedule, + options, + }), + ), + _snapshotAutonomousRuntimeState: () => undefined, + _restoreAutonomousRuntimeSnapshot: () => undefined, + ...overrides, + }; + // The gate, resume, and keep-alive methods run for real: assign the + // prototype functions as own properties so internal `this.` calls land on + // the fake session. + const realMethods = [ + "_holdAutonomousContinuationForRlmWork", + "_maybeResumeAutonomousContinuationAfterRlmWork", + "_resumeOwedAutonomousContinuation", + "_goalOwnsContinuationWakeup", + "_deliverAutonomousSubagentKeepAlive", + "_admitOwedAutonomousContinuation", + "_findLastAssistantInMessages", + "_armAutonomousSubagentKeepAlive", + "_disarmAutonomousSubagentKeepAlive", + "_clearAutonomousContinuationAwait", + "_fireAutonomousSubagentKeepAlive", + ] as const; + for (const method of realMethods) { + (session as unknown as Record)[method] = Reflect.get(AgentSession.prototype, method); + } + // Real prototype helpers read the live state object, so keep the snapshot + // pair bound to the fake session. + session._snapshotAutonomousRuntimeState = () => snapshotRuntimeState.call(session); + session._restoreAutonomousRuntimeSnapshot = (snapshot: unknown) => restoreRuntimeSnapshot.call(session, snapshot); + return session; +} + +const stoppedTurn = { role: "assistant", stopReason: "stop" } as FakeAssistantMessage; +const context = { message: stoppedTurn, newMessages: [] }; + +describe("autonomous continuation vs active subagents", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("schedules a continuation and counts it when no descendant work is pending", async () => { + const session = fakeSession(); + const messages = await getContinuationMessages.call(session, context); + expect(messages).toHaveLength(1); + expect(session._autonomousState.continuationsUsed).toBe(1); + expect(session._autonomousContinuationAwaitsRlmWork).toBe(false); + }); + + it("holds the timer-driven continuation while a child runs without spending budget", async () => { + vi.useFakeTimers(); + const session = fakeSession({ + _hasUnsettledRlmQuiescenceWork: () => true, + }); + const messages = await getContinuationMessages.call(session, context); + expect(messages).toEqual([]); + expect(session._autonomousContinuationAwaitsRlmWork).toBe(true); + expect(session._autonomousState.continuationsUsed).toBe(0); + expect(vi.getTimerCount()).toBe(1); + }); + + it("holds without queueing behind an active goal continuation", () => { + vi.useFakeTimers(); + const session = fakeSession({ + _hasUnsettledRlmQuiescenceWork: () => true, + }); + session._autonomousContinuationAwaitsRlmWork = true; + session._goalState = { status: "active", objective: "ship it" }; + expect(holdForRlmWork.call(session, stoppedTurn)).toBe(true); + // The goal's own continuation loop takes over the owed continuation. + expect(session._autonomousContinuationAwaitsRlmWork).toBe(false); + expect(vi.getTimerCount()).toBe(0); + expect(session._autonomousState.continuationsUsed).toBe(0); + }); + + it("drops a held continuation when a goal takes over at settlement", () => { + const session = fakeSession({ _autonomousContinuationAwaitsRlmWork: true }); + session._goalState = { status: "active", objective: "ship it" }; + maybeResume.call(session); + expect(session._admitSessionInput).not.toHaveBeenCalled(); + expect(session._autonomousContinuationAwaitsRlmWork).toBe(false); + expect(session._autonomousState.continuationsUsed).toBe(0); + }); + + it("drops a pending keep-alive when a goal takes over while children stay active", () => { + vi.useFakeTimers(); + const session = fakeSession({ + _hasUnsettledRlmQuiescenceWork: () => true, + _autonomousState: createAutonomousRuntimeState({ enabled: true, subagentKeepAliveMs: 1_000 }), + }); + expect(holdForRlmWork.call(session, stoppedTurn)).toBe(true); + session._goalState = { status: "active", objective: "ship it" }; + vi.advanceTimersByTime(1_000); + expect(session._admitSessionInput).not.toHaveBeenCalled(); + expect(session._autonomousContinuationAwaitsRlmWork).toBe(false); + expect(session._autonomousState.continuationsUsed).toBe(0); + }); + + it("does not hold the continuation for aborted or errored turns", async () => { + const session = fakeSession({ _hasUnsettledRlmQuiescenceWork: () => true }); + for (const stopReason of ["aborted", "error"]) { + const held = holdForRlmWork.call(session, { role: "assistant", stopReason }); + expect(held).toBe(false); + } + expect(session._autonomousContinuationAwaitsRlmWork).toBe(false); + await expect( + getContinuationMessages.call(session, { + message: { role: "assistant", stopReason: "aborted" }, + newMessages: [], + }), + ).resolves.toEqual([]); + expect(session._autonomousState.continuationsUsed).toBe(0); + expect(session._autonomousContinuationAwaitsRlmWork).toBe(false); + }); + + it("resumes the held continuation exactly once, unqueued, idle-waking, and counted", async () => { + const session = fakeSession({ _autonomousContinuationAwaitsRlmWork: true }); + maybeResume.call(session); + maybeResume.call(session); + await session._autonomousContinuationResumeTask; + maybeResume.call(session); + await session._autonomousContinuationResumeTask; + expect(session._admitSessionInput).toHaveBeenCalledTimes(1); + const [action] = session._admitSessionInput.mock.calls[0]!; + expect((action as { options: { resumeIfIdle: boolean } }).options.resumeIfIdle).toBe(true); + expect(session._autonomousState.continuationsUsed).toBe(1); + expect(session._autonomousContinuationAwaitsRlmWork).toBe(false); + }); + + it("keeps the deferral while descendant work remains", () => { + const session = fakeSession({ + _autonomousContinuationAwaitsRlmWork: true, + _hasUnsettledRlmQuiescenceWork: () => true, + }); + maybeResume.call(session); + expect(session._admitSessionInput).not.toHaveBeenCalled(); + expect(session._autonomousContinuationAwaitsRlmWork).toBe(true); + expect(session._autonomousState.continuationsUsed).toBe(0); + }); + + it("keeps the deferral while admission is paused and retries after release", async () => { + const session = fakeSession({ _autonomousContinuationAwaitsRlmWork: true }); + session._sessionInputAdmissionPauses.add(Symbol("pause")); + maybeResume.call(session); + expect(session._admitSessionInput).not.toHaveBeenCalled(); + expect(session._autonomousContinuationAwaitsRlmWork).toBe(true); + + session._sessionInputAdmissionPauses.clear(); + maybeResume.call(session); + await session._autonomousContinuationResumeTask; + expect(session._admitSessionInput).toHaveBeenCalledTimes(1); + expect(session._autonomousContinuationAwaitsRlmWork).toBe(false); + }); + + it("keeps the deferral while the pump is suspended after an abort", () => { + const session = fakeSession({ + _autonomousContinuationAwaitsRlmWork: true, + _sessionInputPumpSuspended: true, + }); + maybeResume.call(session); + expect(session._admitSessionInput).not.toHaveBeenCalled(); + expect(session._autonomousContinuationAwaitsRlmWork).toBe(true); + }); + + it("keeps the deferral and rolls back the count when admission throws", async () => { + const session = fakeSession({ + _autonomousContinuationAwaitsRlmWork: true, + _admitSessionInput: vi.fn(() => { + throw new Error("admission race"); + }), + }); + maybeResume.call(session); + await session._autonomousContinuationResumeTask; + expect(session._autonomousContinuationAwaitsRlmWork).toBe(true); + expect(session._autonomousState.continuationsUsed).toBe(0); + }); + + it("drops the held continuation when autonomous mode is disabled", () => { + const session = fakeSession({ _autonomousContinuationAwaitsRlmWork: true }); + session._autonomousState.enabled = false; + maybeResume.call(session); + expect(session._admitSessionInput).not.toHaveBeenCalled(); + expect(session._autonomousContinuationAwaitsRlmWork).toBe(false); + }); + + it("drops the held continuation when limits are already reached", async () => { + const session = fakeSession({ + _autonomousContinuationAwaitsRlmWork: true, + _autonomousState: createAutonomousRuntimeState({ enabled: true, maxContinuations: 1 }), + }); + session._autonomousState.continuationsUsed = 1; + maybeResume.call(session); + await session._autonomousContinuationResumeTask; + expect(session._admitSessionInput).not.toHaveBeenCalled(); + expect(session._autonomousContinuationAwaitsRlmWork).toBe(false); + expect(session._autonomousState.continuationsUsed).toBe(1); + }); + + it("skips the owed continuation when configured gates pass at settlement", async () => { + const session = fakeSession({ + _autonomousContinuationAwaitsRlmWork: true, + _cwd: "/tmp", + _autonomousState: createAutonomousRuntimeState({ + enabled: true, + maxContinuations: 5, + gates: { commands: ["true"] }, + }), + }); + maybeResume.call(session); + await session._autonomousContinuationResumeTask; + expect(session._admitSessionInput).not.toHaveBeenCalled(); + expect(session._autonomousContinuationAwaitsRlmWork).toBe(false); + expect(session._autonomousState.continuationsUsed).toBe(0); + }); + + it("delivers a gate-failure continuation when configured gates fail at settlement", async () => { + const session = fakeSession({ + _autonomousContinuationAwaitsRlmWork: true, + _cwd: "/tmp", + _autonomousState: createAutonomousRuntimeState({ + enabled: true, + maxContinuations: 5, + gates: { commands: ["false"] }, + }), + }); + maybeResume.call(session); + await session._autonomousContinuationResumeTask; + expect(session._admitSessionInput).toHaveBeenCalledTimes(1); + const [action] = session._admitSessionInput.mock.calls[0]!; + const message = (action as { options: { message: { content: Array<{ text: string }> } } }).options.message; + expect(message.content[0]!.text).toContain("[autonomous-continuation: gate-failed]"); + expect(session._autonomousState.continuationsUsed).toBe(1); + expect(session._autonomousContinuationAwaitsRlmWork).toBe(false); + }); + + it("fires one keep-alive continuation per window while children stay active", () => { + vi.useFakeTimers(); + const session = fakeSession({ + _hasUnsettledRlmQuiescenceWork: () => true, + _autonomousState: createAutonomousRuntimeState({ enabled: true, subagentKeepAliveMs: 1_000 }), + }); + expect(holdForRlmWork.call(session, stoppedTurn)).toBe(true); + vi.advanceTimersByTime(1_000); + expect(session._admitSessionInput).toHaveBeenCalledTimes(1); + const [action] = session._admitSessionInput.mock.calls[0]!; + expect((action as { options: { resumeIfIdle: boolean } }).options.resumeIfIdle).toBe(true); + expect(session._autonomousState.continuationsUsed).toBe(1); + // Children are still active: the next turn end re-holds and re-arms. + expect(session._autonomousContinuationAwaitsRlmWork).toBe(false); + expect(holdForRlmWork.call(session, stoppedTurn)).toBe(true); + vi.advanceTimersByTime(999); + expect(session._admitSessionInput).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(1); + expect(session._admitSessionInput).toHaveBeenCalledTimes(2); + expect(session._autonomousState.continuationsUsed).toBe(2); + }); + + it("delivers the plain owed continuation, not a keep-alive, when children settle first", async () => { + vi.useFakeTimers(); + const session = fakeSession({ + _hasUnsettledRlmQuiescenceWork: () => true, + _autonomousState: createAutonomousRuntimeState({ enabled: true, subagentKeepAliveMs: 1_000 }), + }); + expect(holdForRlmWork.call(session, stoppedTurn)).toBe(true); + session._hasUnsettledRlmQuiescenceWork = () => false; + fireKeepAlive.call(session); + await session._autonomousContinuationResumeTask; + const [action] = session._admitSessionInput.mock.calls[0]!; + const message = (action as { options: { message: { content: Array<{ text: string }> } } }).options.message; + expect(message.content[0]!.text).toContain("[autonomous-continuation]"); + expect(message.content[0]!.text).not.toContain("subagent-keep-alive"); + expect(session._autonomousState.continuationsUsed).toBe(1); + }); + + it("never arms a keep-alive when the valve is disabled with 0", () => { + vi.useFakeTimers(); + const session = fakeSession({ + _hasUnsettledRlmQuiescenceWork: () => true, + _autonomousState: createAutonomousRuntimeState({ enabled: true, subagentKeepAliveMs: 0 }), + }); + expect(holdForRlmWork.call(session, stoppedTurn)).toBe(true); + expect(vi.getTimerCount()).toBe(0); + expect(session._autonomousState.subagentKeepAliveMs).toBe(0); + }); + + it("retries the keep-alive after the window when admission is paused", () => { + vi.useFakeTimers(); + const session = fakeSession({ + _hasUnsettledRlmQuiescenceWork: () => true, + _autonomousState: createAutonomousRuntimeState({ enabled: true, subagentKeepAliveMs: 1_000 }), + }); + expect(holdForRlmWork.call(session, stoppedTurn)).toBe(true); + session._sessionInputAdmissionPauses.add(Symbol("pause")); + vi.advanceTimersByTime(1_000); + expect(session._admitSessionInput).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(1); + session._sessionInputAdmissionPauses.clear(); + vi.advanceTimersByTime(1_000); + expect(session._admitSessionInput).toHaveBeenCalledTimes(1); + }); + + it("keeps the default keep-alive window under the default wall-clock budget", () => { + // A window at or above the timeout would be blocked by the wall-clock + // limit at the fire time, so the valve could never wake the parent. + expect(DEFAULT_AUTONOMOUS_SUBAGENT_KEEP_ALIVE_MS).toBeGreaterThan(0); + expect(DEFAULT_AUTONOMOUS_SUBAGENT_KEEP_ALIVE_MS).toBeLessThan(DEFAULT_AUTONOMOUS_LIMITS.timeoutMs); + }); + + it("does not hold the continuation once limits are reached", async () => { + const session = fakeSession({ + _hasUnsettledRlmQuiescenceWork: () => true, + _autonomousState: createAutonomousRuntimeState({ enabled: true, maxContinuations: 1 }), + }); + session._autonomousState.continuationsUsed = 1; + expect(holdForRlmWork.call(session, stoppedTurn)).toBe(false); + await expect(getContinuationMessages.call(session, context)).resolves.toEqual([]); + expect(session._autonomousContinuationAwaitsRlmWork).toBe(false); + expect(session._autonomousState.continuationsUsed).toBe(1); + }); + + it("drops a stale owed continuation when user-driven work arrives during the gate evaluation", async () => { + const session = fakeSession({ + _autonomousContinuationAwaitsRlmWork: true, + _cwd: "/tmp", + _autonomousState: createAutonomousRuntimeState({ + enabled: true, + maxContinuations: 5, + gates: { commands: ["false"] }, + }), + }); + maybeResume.call(session); + // A user prompt is admitted (and may even finish) while the gate + // command is still running; the epoch sees it either way. + session._sessionInputArrivalEpoch++; + await session._autonomousContinuationResumeTask; + expect(session._admitSessionInput).not.toHaveBeenCalled(); + expect(session._autonomousContinuationAwaitsRlmWork).toBe(false); + // Nothing was spent on the dropped continuation. + expect(session._autonomousState.continuationsUsed).toBe(0); + }); + + it("delivers past sibling terminal notices admitted during the gate evaluation", async () => { + const session = fakeSession({ + _autonomousContinuationAwaitsRlmWork: true, + _cwd: "/tmp", + _autonomousState: createAutonomousRuntimeState({ + enabled: true, + maxContinuations: 5, + gates: { commands: ["false"] }, + }), + }); + maybeResume.call(session); + // A second child exits while the gate command is still running: the + // notice admission is not user-driven, and the owed continuation is + // the wake that reads it. + session._sessionInputArrivalEpoch++; + session._rlmTerminalNoticeAdmissionCount++; + await session._autonomousContinuationResumeTask; + expect(session._admitSessionInput).toHaveBeenCalledTimes(1); + expect(session._autonomousState.continuationsUsed).toBe(1); + expect(session._autonomousContinuationAwaitsRlmWork).toBe(false); + }); + + it("keeps a user-reset budget when dropping a stale owed continuation", async () => { + const session = fakeSession({ + _autonomousContinuationAwaitsRlmWork: true, + _cwd: "/tmp", + _autonomousState: createAutonomousRuntimeState({ + enabled: true, + maxContinuations: 5, + gates: { commands: ["false"] }, + }), + }); + maybeResume.call(session); + // The user resets the run while the gate command is still running. + session._autonomousState.continuationsUsed = 0; + session._autonomousState.startedAt = (session._autonomousState.startedAt ?? 0) + 1; + session._sessionInputArrivalEpoch++; + await session._autonomousContinuationResumeTask; + expect(session._admitSessionInput).not.toHaveBeenCalled(); + expect(session._autonomousContinuationAwaitsRlmWork).toBe(false); + // The reset budget is untouched: no phantom continuation was spent. + expect(session._autonomousState.continuationsUsed).toBe(0); + }); + + it("evaluates gates from the transcript after agent_end clears the live last-assistant field", async () => { + const session = fakeSession({ + _autonomousContinuationAwaitsRlmWork: true, + _cwd: "/tmp", + _lastAssistantMessage: undefined, + agent: { signal: undefined, state: { messages: [stoppedTurn] } }, + _autonomousState: createAutonomousRuntimeState({ + enabled: true, + maxContinuations: 5, + gates: { commands: ["true"] }, + }), + }); + maybeResume.call(session); + await session._autonomousContinuationResumeTask; + expect(session._admitSessionInput).not.toHaveBeenCalled(); + expect(session._autonomousContinuationAwaitsRlmWork).toBe(false); + expect(session._autonomousState.continuationsUsed).toBe(0); + }); + + it("holds the threshold-compaction continuation while a child runs", async () => { + const session = fakeSession({ + _hasUnsettledRlmQuiescenceWork: () => true, + }) as FakeSession & { + _queuedAutonomousThresholdContinuations: Map; + _postCompactionContinuationMessages: unknown[]; + }; + session._queuedAutonomousThresholdContinuations = new Map(); + session._postCompactionContinuationMessages = []; + const queued = await queueThresholdContinuation.call(session, stoppedTurn); + expect(queued).toBeUndefined(); + expect(session._autonomousContinuationAwaitsRlmWork).toBe(true); + expect(session._autonomousState.continuationsUsed).toBe(0); + }); +}); diff --git a/packages/coding-agent/test/suite/agent-session-autonomous.test.ts b/packages/coding-agent/test/suite/agent-session-autonomous.test.ts index 5d110d607..21c197036 100644 --- a/packages/coding-agent/test/suite/agent-session-autonomous.test.ts +++ b/packages/coding-agent/test/suite/agent-session-autonomous.test.ts @@ -331,6 +331,8 @@ describe("AgentSession autonomous mode", () => { "/autonomous on --max-continuations", "/autonomous on --speed 10", "/autonomous on --gate-retries unlimited", + "/autonomous on --subagent-keep-alive-ms 3000000000", + "/autonomous on --subagent-keep-alive-ms -5", "/autonomous off --max-continuations 2", ]) { await harness.session.prompt(input); @@ -343,6 +345,8 @@ describe("AgentSession autonomous mode", () => { expect.stringContaining("Missing value for --max-continuations"), expect.stringContaining("Unknown autonomous budget flag: --speed"), expect.stringContaining("--gate-retries must be a positive integer."), + expect.stringContaining("--subagent-keep-alive-ms must be 0 or a positive integer up to 2147483647"), + expect.stringContaining("--subagent-keep-alive-ms must be 0 or a positive integer up to 2147483647"), expect.stringContaining("Unexpected autonomous argument: --max-continuations"), ]); @@ -781,3 +785,144 @@ describe("AgentSession autonomous mode", () => { }); }); }); + +describe("AgentSession autonomous continuations vs subagents", () => { + const harnesses: Harness[] = []; + let childGate: { promise: Promise; resolve: () => void } | undefined; + + function createGate(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve }; + } + + afterEach(() => { + childGate?.resolve(); + childGate = undefined; + while (harnesses.length > 0) { + harnesses.pop()?.cleanup(); + } + }); + + async function createGatedChildParent(options: { + keepAliveMs?: number; + maxContinuations?: number; + }): Promise { + childGate = createGate(); + const child = await createHarness({}); + harnesses.push(child); + child.setResponses([ + async () => { + await childGate!.promise; + return fauxAssistantMessage("child result"); + }, + ]); + const parent = await createHarness({ + rlmDepth: 0, + rlmMaxDepth: 1, + autonomous: { + enabled: true, + maxContinuations: options.maxContinuations ?? 1, + subagentKeepAliveMs: options.keepAliveMs, + }, + subagentRuntimeHost: { + createRlmSubagentRuntime: async () => ({ session: child.session }), + deleteRlmSubagentRuntime: async () => {}, + }, + }); + harnesses.push(parent); + return parent; + } + + it("holds the timer continuation while a subagent runs and resumes it at settlement", async () => { + const parent = await createGatedChildParent({}); + parent.setResponses([ + fauxAssistantMessage("delegated to the child; waiting"), + fauxAssistantMessage("read the child exit notice"), + fauxAssistantMessage("parent resumed and continued"), + ]); + + await parent.session.runRlmChild("child task", { name: "worker" }); + await expect.poll(() => parent.session.hasRunningRlmChildren()).toBe(true); + + await parent.session.prompt("kick off"); + + // Held while the child runs: no continuation turn, budget untouched. + expect(getUserTexts(parent)).toEqual(["kick off"]); + expect(parent.session.getAutonomousStatus()).toMatchObject({ continuationsUsed: 0 }); + + childGate!.resolve(); + + // The exit notice delivers first; the owed continuation wakes the idle + // parent and is counted once. + await expect + .poll(() => getAssistantTexts(parent)) + .toEqual(["delegated to the child; waiting", "read the child exit notice", "parent resumed and continued"]); + expect(getUserTexts(parent)).toEqual(["kick off", expect.stringContaining("[autonomous-continuation]")]); + expect(parent.session.getAutonomousStatus()).toMatchObject({ continuationsUsed: 1 }); + expect(parent.session.hasRunningRlmChildren()).toBe(false); + }); + + it("accepts a bounded keep-alive window flag and reports it in the status", async () => { + const harness = await createHarness(); + harnesses.push(harness); + + await harness.session.prompt("/autonomous on --subagent-keep-alive-ms 250"); + + expect(harness.getPendingResponseCount()).toBe(0); + expect(harness.session.getAutonomousStatus()).toMatchObject({ + enabled: true, + subagentKeepAliveMs: 250, + }); + const statusMessages = harness.session.messages.filter( + (message) => message.role === "custom" && message.customType === "autonomous_status", + ); + expect(getMessageText(statusMessages.at(-1))).toContain("Subagent keep-alive: 250ms."); + }); + + it("disarms a pending keep-alive when the valve is disabled mid-hold", async () => { + const parent = await createGatedChildParent({ keepAliveMs: 25, maxContinuations: 2 }); + parent.setResponses([fauxAssistantMessage("delegated to the child; waiting")]); + + await parent.session.runRlmChild("child task", { name: "worker" }); + await expect.poll(() => parent.session.hasRunningRlmChildren()).toBe(true); + + await parent.session.prompt("kick off"); + expect(getUserTexts(parent)).toEqual(["kick off"]); + + await parent.session.prompt("/autonomous on --subagent-keep-alive-ms 0"); + + // The previously armed 25 ms window is gone; no keep-alive turn fires. + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(getAssistantTexts(parent)).toEqual(["delegated to the child; waiting"]); + expect(getUserTexts(parent)).toEqual(["kick off"]); + expect(parent.session.getAutonomousStatus()).toMatchObject({ continuationsUsed: 0 }); + expect(parent.session.hasRunningRlmChildren()).toBe(true); + }); + + it("fires one keep-alive continuation while a subagent stays active past the window", async () => { + const parent = await createGatedChildParent({ keepAliveMs: 25, maxContinuations: 2 }); + parent.setResponses([ + fauxAssistantMessage("delegated to the child; waiting"), + fauxAssistantMessage("checked on the still-running child"), + ]); + + await parent.session.runRlmChild("child task", { name: "worker" }); + await expect.poll(() => parent.session.hasRunningRlmChildren()).toBe(true); + + await parent.session.prompt("kick off"); + expect(getUserTexts(parent)).toEqual(["kick off"]); + expect(parent.session.getAutonomousStatus()).toMatchObject({ continuationsUsed: 0 }); + + await expect + .poll(() => getAssistantTexts(parent), { timeout: 5_000 }) + .toEqual(["delegated to the child; waiting", "checked on the still-running child"]); + + const userTexts = getUserTexts(parent); + expect(userTexts[1]).toContain("[autonomous-continuation: subagent-keep-alive]"); + expect(parent.session.getAutonomousStatus()).toMatchObject({ continuationsUsed: 1 }); + expect(parent.session.hasRunningRlmChildren()).toBe(true); + }); +});