diff --git a/packages/agent/changelog.d/5321-frame-coalescing-lifecycle.md b/packages/agent/changelog.d/5321-frame-coalescing-lifecycle.md new file mode 100644 index 00000000000..951cce4b47a --- /dev/null +++ b/packages/agent/changelog.d/5321-frame-coalescing-lifecycle.md @@ -0,0 +1,8 @@ +### Fixed + +- Successor, terminal-error, and forced-abort handling now wait for an in-flight published `turn_end` checkpoint consumer before admitting later work or publishing `agent_end`, preventing canonical repeat-rule state from lagging behind terminal lifecycle changes. Tool calls cancelled after their pre-dispatch hook now invoke the cleanup hook exactly once with the authoritative cancellation result. +- External lifecycle emitters now pass a session-owned admission fence before mutating Agent state. Run-bound terminals retain their authoritative attempt scope, while Cursor-native lifecycle emitters resolve the current main-attempt scope on every emission so in-loop retries and later tool turns are not mistaken for retired producers. +- Provider iterators that close with a trailing assistant but no explicit `done`/`error` event now publish the canonical `message_end` before `turn_end` and `agent_end`, preventing session persistence from missing the authoritative final response. +- Provider stream cancellation keeps one signal listener, bounds per-read abort-race reactions, and observes synchronous aborts raised inside provider factories. +- Lossless degraded snapshots preserve only validated, size-bounded transport codes, retry headers, and HTTP/2 diagnostics while dropping unreadable, oversized, or non-cloneable values. +- Normal Agent requests now retain provider-visible prompt-prefix telemetry, and `Agent.reset()` starts a fresh cache lineage. diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index fe1428b50a3..a8d739367a0 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -43,7 +43,6 @@ import { verifyUnicodeEscapeEvidence, } from "@gajae-code/ai/utils/json-parse"; import { $credentialEnv, sanitizeText } from "@gajae-code/utils"; -import { markDesignedError } from "@gajae-code/utils/error-classification"; import * as logger from "@gajae-code/utils/logger"; import { revokeProviderSafetyStop } from "../../ai/src/adapter-internals/provider-safety-stop"; import type { AttemptScope } from "./attempt-scope"; @@ -2100,11 +2099,61 @@ function losslessDetachedClone(value: T): T { ] as const) { const transportDescriptor = Object.getOwnPropertyDescriptor(descriptor.value, transportKey); if (!transportDescriptor || !("value" in transportDescriptor)) continue; - try { - transport[transportKey] = structuredClone(transportDescriptor.value); - } catch { - // Strip only this non-cloneable transport fact. + const transportValue = transportDescriptor.value; + if (transportKey === "kind") { + if (transportValue === "transport") transport.kind = transportValue; + continue; + } + if (transportKey === "status") { + const status = transportFailureFacts({ status: transportValue })?.status; + if (status === transportValue) transport.status = status; + continue; + } + if (transportKey === "http2RstCode") { + const code = transportFailureFacts({ http2RstCode: transportValue })?.http2RstCode; + if (code === transportValue) transport.http2RstCode = code; + continue; + } + if (transportKey === "nativeErrorCode") { + const code = transportFailureFacts({ nativeErrorCode: transportValue })?.nativeErrorCode; + if (code === transportValue) transport.nativeErrorCode = code; + continue; + } + if (transportKey === "code") { + const code = transportFailureFacts({ code: transportValue })?.providerCode; + if (code === transportValue) transport.code = code; + continue; + } + if (transportKey === "providerCode") { + const code = transportFailureFacts({ providerCode: transportValue })?.providerCode; + if (code === transportValue) transport.providerCode = code; + continue; + } + if (transportKey === "openaiErrorCode") { + const code = transportFailureFacts({ openaiErrorCode: transportValue })?.openaiErrorCode; + if (code === transportValue) transport.openaiErrorCode = code; + continue; + } + if (transportKey === "anthropicErrorType") { + const code = transportFailureFacts({ anthropicErrorType: transportValue })?.anthropicErrorType; + if (code === transportValue) transport.anthropicErrorType = code; + continue; } + if (transportKey === "credentialModelUnavailable") { + const credentialModelUnavailable = transportFailureFacts({ + credentialModelUnavailable: transportValue, + })?.credentialModelUnavailable; + if (credentialModelUnavailable === true) transport.credentialModelUnavailable = true; + continue; + } + if (transportKey === "retryAfterMs") { + if (typeof transportValue === "number" && Number.isFinite(transportValue) && transportValue >= 0) { + transport.retryAfterMs = transportValue; + } + continue; + } + const headers = transportFailureFacts({ headers: transportValue })?.headers; + if (headers) transport.headers = headers; } output[key] = transport; } @@ -4395,7 +4444,13 @@ async function runLoopBody( ); const toolResults: ToolResultMessage[] = []; for (const toolCall of toolCalls) { - const result = createAbortedToolResult(toolCall, stream, message.stopReason, message.errorMessage); + const result = createAbortedToolResult( + toolCall, + stream, + message.stopReason, + message.errorMessage, + attemptScope, + ); currentContext.messages.push(result); newMessages.push(result); toolResults.push(result); @@ -4411,6 +4466,7 @@ async function runLoopBody( }); } stream.push({ type: "turn_end", message, toolResults, scope: attemptScope }); + await config.afterTurnEndPublished?.(); publishAgentEnd( stream, config, @@ -4439,6 +4495,7 @@ async function runLoopBody( stream, "error", "Tool calls are disabled during repeated malformed tool-call recovery.", + attemptScope, ); currentContext.messages.push(result); newMessages.push(result); @@ -4491,7 +4548,42 @@ async function runLoopBody( pendingRecovery = undefined; } + const composerRecoveryExhausted = sawComposerBashPolicyBlock && composerBashPolicyRecoveryAttempted; + const malformedRecoveryAvailable = repeatedMalformedToolCall && !malformedToolRecoveryAttempted; + const malformedRecoveryExhausted = + consecutiveMalformedTurns >= MAX_CONSECUTIVE_MALFORMED_TURNS && !malformedRecoveryAvailable; + const policyTerminalCommitted = + !loopSignal.aborted && (composerRecoveryExhausted || malformedRecoveryExhausted); + if (policyTerminalCommitted && composerRecoveryExhausted) { + message.stopReason = "error"; + const recoveryLimitMessage = + "Composer bash policy blocked repository file I/O again after its one automatic recovery turn. Continue with dedicated repository tools."; + message.errorMessage = message.errorMessage + ? `${message.errorMessage} | ${recoveryLimitMessage}` + : recoveryLimitMessage; + } else if (policyTerminalCommitted && malformedRecoveryExhausted) { + message.stopReason = "error"; + const breakerMessage = `Stopping after ${consecutiveMalformedTurns} consecutive turns of malformed tool calls; the model did not produce a usable tool call or answer.`; + message.errorMessage = message.errorMessage + ? `${message.errorMessage} | ${breakerMessage}` + : breakerMessage; + } + stream.push({ type: "turn_end", message, toolResults, scope: attemptScope }); + await config.afterTurnEndPublished?.(); + if (policyTerminalCommitted) { + if (steeringMessagesFromExecution && steeringMessagesFromExecution.length > 0) { + config.requeueSteeringMessages?.(steeringMessagesFromExecution); + } + publishAgentEnd( + stream, + config, + buildAgentEndEvent(newMessages, telemetry, stepCounter.count, "completed", attemptScope), + attemptScope, + ); + stream.end(newMessages); + return; + } if (steeringMessagesFromExecution && steeringMessagesFromExecution.length > 0) { // Same aborted-run guard as the drain below: the steer interrupt unwound @@ -4536,44 +4628,9 @@ async function runLoopBody( if (sawComposerBashPolicyBlock && !composerBashPolicyRecoveryAttempted) { pendingRecovery = { kind: "composer-bash-policy", inserted: false }; composerBashPolicyRecoveryAttempted = true; - } else if (sawComposerBashPolicyBlock) { - message.stopReason = "error"; - const recoveryLimitMessage = - "Composer bash policy blocked repository file I/O again after its one automatic recovery turn. Continue with dedicated repository tools."; - message.errorMessage = message.errorMessage - ? `${message.errorMessage} | ${recoveryLimitMessage}` - : recoveryLimitMessage; - publishAgentEnd( - stream, - config, - buildAgentEndEvent(newMessages, telemetry, stepCounter.count, "completed", attemptScope), - attemptScope, - ); - stream.end(newMessages); - return; } else if (repeatedMalformedToolCall && !malformedToolRecoveryAttempted) { pendingRecovery = { kind: "malformed-tool-call", inserted: false }; malformedToolRecoveryAttempted = true; - } else if (consecutiveMalformedTurns >= MAX_CONSECUTIVE_MALFORMED_TURNS) { - // Deterministic terminal circuit breaker. The one-shot recovery turn - // above already had its chance; if the model is still emitting only - // malformed tool calls after it, the run cannot make progress and must - // stop rather than burn the provider budget. Terminates on consecutive - // count, not argument signatures, so rotating invalid shapes are bounded - // too. - message.stopReason = "error"; - const breakerMessage = `Stopping after ${consecutiveMalformedTurns} consecutive turns of malformed tool calls; the model did not produce a usable tool call or answer.`; - message.errorMessage = message.errorMessage - ? `${message.errorMessage} | ${breakerMessage}` - : breakerMessage; - publishAgentEnd( - stream, - config, - buildAgentEndEvent(newMessages, telemetry, stepCounter.count, "completed", attemptScope), - attemptScope, - ); - stream.end(newMessages); - return; } } @@ -4905,7 +4962,9 @@ async function streamAssistantResponse( const onFactoryAbort = () => resolveFactoryAbort(ABORTED); requestSignal.addEventListener("abort", onFactoryAbort, { once: true }); try { - const responseOrAbort = await Promise.race([responsePromise, factoryAbort]); + const responseOrAbort = requestSignal.aborted + ? ABORTED + : await Promise.race([responsePromise, factoryAbort]); if (responseOrAbort === ABORTED) { const aborted = emitAbortedAssistantMessage(null, false, context, config, stream, scope); await finishChat(aborted); @@ -4943,9 +5002,9 @@ async function streamAssistantResponse( return getResponseResult(); }; - // Keep one listener, but race a fresh promise per read so pending abort - // reactions do not retain every event until the request ends. - let settleReadAbort: (() => void) | undefined; + // Keep one abort listener for the stream, but give each read its own race + // promise so completed reads do not accumulate reactions on a pending promise. + let resolveCurrentAbortRace: ((value: typeof ABORTED) => void) | undefined; let detachAbortListener: (() => void) | undefined; if (requestSignal) { if (requestSignal.aborted) { @@ -4961,31 +5020,38 @@ async function streamAssistantResponse( await finishChat(aborted); return aborted; } - const onAbort = () => settleReadAbort?.(); + const onAbort = () => resolveCurrentAbortRace?.(ABORTED); requestSignal.addEventListener("abort", onAbort, { once: true }); detachAbortListener = () => requestSignal.removeEventListener("abort", onAbort); } try { while (true) { + if (requestSignal?.aborted) { + closeIterator(); + const aborted = emitAbortedAssistantMessage( + partialMessage, + addedPartial, + context, + config, + stream, + scope, + ); + await finishChat(aborted); + return aborted; + } let next: IteratorResult; if (requestSignal) { const { promise, resolve } = Promise.withResolvers(); - let settled = false; - const settleAbort = (): void => { - if (settled) return; - settled = true; - resolve(ABORTED); - config.onAbortRaceReactionChange?.(-1); - }; + resolveCurrentAbortRace = resolve; config.onAbortRaceReactionChange?.(1); - settleReadAbort = settleAbort; let result: IteratorResult | typeof ABORTED; try { - result = requestSignal.aborted ? ABORTED : await Promise.race([responseIterator.next(), promise]); + result = await Promise.race([responseIterator.next(), promise]); } finally { - settleAbort(); - settleReadAbort = undefined; + if (resolveCurrentAbortRace === resolve) resolveCurrentAbortRace = undefined; + resolve(ABORTED); + config.onAbortRaceReactionChange?.(-1); } if (result === ABORTED) { closeIterator(); @@ -5136,6 +5202,15 @@ async function streamAssistantResponse( : finished; promoteEmptyResponseStop(trailing, finished, managedAttemptTransaction); if (promptPrefix) trailing.promptPrefix = promptPrefix; + if (!config.fallbackManaged || (trailing.stopReason !== "error" && trailing.stopReason !== "aborted")) { + if (addedPartial) { + context.messages[context.messages.length - 1] = trailing; + } else { + context.messages.push(trailing); + stream.push({ type: "message_start", message: { ...trailing }, scope }); + } + stream.push({ type: "message_end", message: trailing, scope }); + } await finishChat(trailing); return trailing; }); @@ -5277,6 +5352,7 @@ async function executeToolCalls( const records = toolCalls.map(toolCall => { const metadata = acceptedToolCallMetadata.get(toolCall) ?? escapedToolCallMetadata(toolCall); + const cleanupSettled = Promise.withResolvers(); return { toolCall: stripToolCallEvidence(toolCall), metadata, @@ -5290,6 +5366,9 @@ async function executeToolCalls( toolResultMessage: undefined as ToolResultMessage | undefined, resultEmitted: false, argumentValidationFailed: false, + preDispatchEntered: false, + cleanupClaimed: false, + cleanupSettled, }; }); const checkSteering = async (): Promise => { @@ -5448,14 +5527,85 @@ async function executeToolCalls( record.started = true; }; + const settleDispatchedCancellationCleanup = async (record: (typeof records)[number]): Promise => { + if (record.cleanupClaimed) { + await Promise.race([record.cleanupSettled.promise, Bun.sleep(1_000)]); + return; + } + record.cleanupClaimed = true; + try { + if (afterToolCall) { + await Promise.race([ + afterToolCall( + { + assistantMessage, + toolCall: record.toolCall, + args: record.args, + result: { + content: [{ type: "text", text: "Tool call cancelled after dispatch." }], + isError: true, + details: { cancellation: "after_dispatch" }, + }, + isError: true, + context: currentContext, + }, + toolSignal, + ), + Bun.sleep(1_000), + ]); + } + } catch { + // Cancellation is authoritative; the hook is best-effort cleanup only. + } finally { + record.cleanupSettled.resolve(); + } + }; + + const settlePreDispatchCancellationCleanup = async (record: (typeof records)[number]): Promise => { + if (record.cleanupClaimed) { + await Promise.race([record.cleanupSettled.promise, Bun.sleep(1_000)]); + return; + } + record.cleanupClaimed = true; + try { + if (afterToolCall) { + await Promise.race([ + afterToolCall( + { + assistantMessage, + toolCall: record.toolCall, + args: record.args, + result: { + content: [{ type: "text", text: "Tool call cancelled before dispatch." }], + isError: true, + details: { cancellation: "before_dispatch" }, + }, + isError: true, + context: currentContext, + }, + toolSignal, + ), + Bun.sleep(1_000), + ]); + } + } catch { + // Cancellation is authoritative; the hook is best-effort cleanup only. + } finally { + record.cleanupSettled.resolve(); + } + }; + const runTool = async (record: (typeof records)[number], index: number): Promise => { - if (record.skipped || interruptState.triggered) { + if (record.skipped || interruptState.triggered || signal?.aborted) { // Skip both span emission and the collector orphan record here. The // scheduler-task finalizer emits the skipped result and collector record; // the tail sweep below remains a defensive fallback for unexpected throws. record.skipped = true; + record.cleanupClaimed = true; + record.cleanupSettled.resolve(); return; } + record.preDispatchEntered = true; record.toolCall = stripToolCallEvidence(record.toolCall); const { toolCall, tool } = record; @@ -5492,6 +5642,7 @@ async function executeToolCalls( let result: AgentToolResult = { content: [], details: {} }; let isError = false; let caughtError: unknown; + let preDispatchCancellationResult: AgentToolResult | undefined; await runInActiveSpan(toolSpan, async () => { try { @@ -5514,7 +5665,7 @@ async function executeToolCalls( : reason === "ambiguous" ? `The identity of tool call "${toolCall.name}" was ambiguous on the wire (duplicate call id or id/call_id collision), so its arguments cannot be safely attributed. Re-issue the call.` : `Tool call "${toolCall.name}" was cut off before its arguments finished streaming (the response hit its output token limit). The partial arguments cannot be executed. Re-issue the call with complete arguments, splitting the work into smaller steps if needed.`; - throw markDesignedError(new Error(detail)); + throw new Error(detail); } const displaySafeEscapedArguments = escapedArgumentsGuarded && @@ -5542,12 +5693,10 @@ async function executeToolCalls( toolRegistered: tool !== undefined, displaySafeFieldsDeclared: isDisplaySafeEscapedTool(tool), }); - throw markDesignedError( - new Error( - `Tool call "${toolCall.name}" spelled printable text as \\uXXXX escapes instead of literal UTF-8 characters. ` + - `Escaped text cannot be verified — a single wrong hex digit silently becomes a different character — ` + - `so the call was not executed. Re-issue it writing every printable character literally.`, - ), + throw new Error( + `Tool call "${toolCall.name}" spelled printable text as \\uXXXX escapes instead of literal UTF-8 characters. ` + + `Escaped text cannot be verified — a single wrong hex digit silently becomes a different character — ` + + `so the call was not executed. Re-issue it writing every printable character literally.`, ); } if (!tool) { @@ -5566,12 +5715,10 @@ async function executeToolCalls( // naming that guess hits a tool the model never asked for, which is // worse than the dead end it would replace. const base = `Tool ${toolCall.name} not found`; - throw markDesignedError( - new Error( - isToolDiscoveryCallable(tools) - ? `${base}. If you are unsure whether this tool exists or how to use it, call \`${TOOL_DISCOVERY_NAME}\` to discover and activate the matching tool, then retry.` - : base, - ), + throw new Error( + isToolDiscoveryCallable(tools) + ? `${base}. If you are unsure whether this tool exists or how to use it, call \`${TOOL_DISCOVERY_NAME}\` to discover and activate the matching tool, then retry.` + : base, ); } @@ -5630,14 +5777,23 @@ async function executeToolCalls( effectiveArgs, toolContext, ); - // Preparation is complete. A successful publication is the only transition - // that marks this record dispatched; intrinsic invocation then consumes locals. - publishToolDispatch(record, startEvent); - const execution = intrinsicReflectApply(execute, tool, invocationArguments); - const rawResult = await execution; - const coerced = coerceToolResult(rawResult); - result = coerced.result; - if (coerced.malformed || result.isError) isError = true; + if (toolSignal.aborted) { + record.skipped = true; + preDispatchCancellationResult = { + content: [{ type: "text", text: "Tool call cancelled before dispatch." }], + isError: true, + details: { cancellation: "before_dispatch" }, + }; + } else { + // Preparation is complete. A successful publication is the only transition + // that marks this record dispatched; intrinsic invocation then consumes locals. + publishToolDispatch(record, startEvent); + const execution = intrinsicReflectApply(execute, tool, invocationArguments); + const rawResult = await execution; + const coerced = coerceToolResult(rawResult); + result = coerced.result; + if (coerced.malformed || result.isError) isError = true; + } } catch (e) { caughtError = e; result = { @@ -5646,8 +5802,18 @@ async function executeToolCalls( }; isError = true; } + // A pre-dispatch cleanup hook is only part of the cancellation contract. + // Validation failures and beforeToolCall blocks still have a real result + // that must flow through the normal tool-result path without invoking the + // post-execution hook before execution ever started. + if (afterToolCall && preDispatchCancellationResult && !record.started && !record.cleanupClaimed) { + await settlePreDispatchCancellationCleanup(record); + } - if (afterToolCall) { + if (afterToolCall && record.started && (signal?.aborted || toolSignal.aborted)) { + await settleDispatchedCancellationCleanup(record); + } else if (afterToolCall && record.started && !signal?.aborted && !toolSignal.aborted) { + record.cleanupClaimed = true; try { const after = await afterToolCall( { @@ -5675,11 +5841,17 @@ async function executeToolCalls( details: {}, }; isError = true; + } finally { + record.cleanupSettled.resolve(); } } + if (!record.cleanupClaimed) { + record.cleanupClaimed = true; + record.cleanupSettled.resolve(); + } }); - const interrupted = interruptState.triggered; + const interrupted = interruptState.triggered || record.skipped; if (interrupted) { record.skipped = true; emitToolResult(record, createSkippedToolResult(), true); @@ -5781,6 +5953,17 @@ async function executeToolCalls( record.skipped = true; emitToolResult(record, createAbortedToolExecutionResult(), true); } + for (const record of records) { + if (record.started || record.cleanupClaimed) continue; + void settlePreDispatchCancellationCleanup(record); + } + await Promise.all( + records.map(record => + record.started + ? settleDispatchedCancellationCleanup(record) + : settlePreDispatchCancellationCleanup(record), + ), + ); } } finally { signal.removeEventListener("abort", onAbort); @@ -5816,6 +5999,7 @@ function createAbortedToolResult( stream: EventStream, reason: "aborted" | "error", errorMessage?: string, + scope?: AttemptScope, ): ToolResultMessage { toolCall = stripToolCallEvidence(toolCall); const message = reason === "aborted" ? "Tool execution was aborted" : "Tool execution failed due to an error"; @@ -5836,6 +6020,7 @@ function createAbortedToolResult( toolName: toolCall.name, args: toolCall.arguments, intent: toolCall.intent, + scope, }; markNonDispatchedToolEvent(startEvent); stream.push(startEvent); @@ -5845,6 +6030,7 @@ function createAbortedToolResult( toolName: toolCall.name, result, isError: true, + scope, }; markNonDispatchedToolEvent(endEvent); stream.push(endEvent); @@ -5859,8 +6045,8 @@ function createAbortedToolResult( timestamp: Date.now(), }; - stream.push({ type: "message_start", message: toolResultMessage }); - stream.push({ type: "message_end", message: toolResultMessage }); + stream.push({ type: "message_start", message: toolResultMessage, scope }); + stream.push({ type: "message_end", message: toolResultMessage, scope }); return toolResultMessage; } diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index d7300fae423..0f4399ad4f1 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -452,6 +452,8 @@ export interface AgentOptions { onFollowUpConsumed?: AgentLoopConfig["onFollowUpConsumed"]; /** Invoked with the steering messages dequeued mid-run for the current turn (reassignable). */ onSteeringConsumed?: AgentLoopConfig["onSteeringConsumed"]; + /** Waits for durable turn-end consumers before a successor turn is admitted. */ + afterTurnEndPublished?: AgentLoopConfig["afterTurnEndPublished"]; /** * Opt-in OpenTelemetry instrumentation. Passing `{}` enables the loop's @@ -526,8 +528,10 @@ export class Agent { #contextRevision = 0; #attemptAuthority = createAttemptScopeAuthority(); #runHandles = new Map(); + #runScopes = new Map>(); #listeners = new Set<(e: AgentEvent) => void>(); + #externalEventAdmissionFence?: (event: AgentEvent) => boolean; #abortController?: AbortController; #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise; #transformContext?: ( @@ -569,6 +573,8 @@ export class Agent { #resolveRunningPrompt?: () => void; #runSequence = 0; #activeRunId?: number; + #pendingTurnEndPublication?: { runId: number; completion: Promise }; + #forcedAbortPublicationRunId?: number; #activeResourceRunId?: string; #activeResourceCancellationDomain?: RunCancellationDomain; #continuationGeneration = 0; @@ -595,7 +601,7 @@ export class Agent { #telemetry?: AgentLoopConfig["telemetry"]; #appendOnlyContext?: AppendOnlyContextManager; #promptPrefixTracker = new PromptPrefixTracker(); - #mainAttemptScopeObserver?: (scope: AttemptScope) => void; + #mainAttemptScopeObserver?: (scope: AttemptScope, active: boolean) => void; get intentTracing(): boolean { return this.#intentTracing; @@ -603,6 +609,7 @@ export class Agent { /** Buffered Cursor tool results with text length at time of call (for correct ordering) */ #cursorToolResultBuffer: CursorToolResultEntry[] = []; + #cursorSplitTerminalMessages = new WeakSet(); #terminalizedLogicalRunIds = new Set(); #managedLogicalRunOwner?: ManagedLogicalRunId; readonly resourceLedger: RunResourceLedger = createRunResourceLedger(); @@ -613,7 +620,15 @@ export class Agent { /** Mint a side-attempt scope and its authority unregister function. */ mintSideAttemptScope(): { scope: AttemptScope; dispose: () => void } { - return this.#attemptAuthority.mintSide(); + const minted = this.#attemptAuthority.mintSide(); + this.#observeMainAttemptScope(minted.scope); + return { + scope: minted.scope, + dispose: () => { + minted.dispose(); + this.#mainAttemptScopeObserver?.(minted.scope, false); + }, + }; } /** Return the Agent-owned attempt scope authority for session record injection. */ @@ -624,12 +639,16 @@ export class Agent { * Observe each main-attempt scope synchronously, before any provider or * extension-capable lifecycle work can begin. */ - setMainAttemptScopeObserver(observer: ((scope: AttemptScope) => void) | undefined): void { + setMainAttemptScopeObserver(observer: ((scope: AttemptScope, active: boolean) => void) | undefined): void { this.#mainAttemptScopeObserver = observer; } + setExternalEventAdmissionFence(fence: ((event: AgentEvent) => boolean) | undefined): void { + this.#externalEventAdmissionFence = fence; + } + #observeMainAttemptScope(scope: AttemptScope): void { - this.#mainAttemptScopeObserver?.(scope); + this.#mainAttemptScopeObserver?.(scope, true); } streamFn: StreamFn; @@ -649,6 +668,8 @@ export class Agent { onFollowUpConsumed?: AgentLoopConfig["onFollowUpConsumed"]; /** Invoked with the steering messages dequeued mid-run for the current turn. Reassign at any time. */ onSteeringConsumed?: AgentLoopConfig["onSteeringConsumed"]; + /** Waits for durable turn-end consumers before a successor turn is admitted. */ + afterTurnEndPublished?: AgentLoopConfig["afterTurnEndPublished"]; constructor(opts: AgentOptions = {}) { this.#state = { ...this.#state, ...opts.initialState }; @@ -694,6 +715,7 @@ export class Agent { this.beforeToolCall = opts.beforeToolCall; this.onFollowUpConsumed = opts.onFollowUpConsumed; this.onSteeringConsumed = opts.onSteeringConsumed; + this.afterTurnEndPublished = opts.afterTurnEndPublished; this.afterToolCall = opts.afterToolCall; this.#telemetry = opts.telemetry; this.#appendOnlyContext = opts.appendOnlyContext; @@ -1021,6 +1043,7 @@ export class Agent { * unbound external event stays unbound; unproven provenance is `custom`. */ emitExternalEvent(event: AgentEvent) { + if (this.#externalEventAdmissionFence && !this.#externalEventAdmissionFence(event)) return false; switch (event.type) { case "message_start": case "message_update": @@ -1045,14 +1068,31 @@ export class Agent { } this.#emit(event); + return true; + } + + discardRejectedAssistantEvent(message: AssistantMessage): void { + if (this.#state.streamMessage === message) this.#state.streamMessage = null; + if (this.#state.messages.at(-1) === message) this.popMessage(); + } + + restoreStreamMessageForSessionRollback(message: AgentMessage | null): void { + this.#state.streamMessage = message; + } + + isCursorSplitTerminalMessage(message: AssistantMessage): boolean { + return this.#cursorSplitTerminalMessages.has(message); } createExternalEventEmitterForCurrentRun(): ((event: AgentEvent) => void) | undefined { const runId = this.#activeRunId; if (runId === undefined) return undefined; + const logicalRunId = this.#managedLogicalRunOwner ?? runId; return (event: AgentEvent) => { if (this.#activeRunId !== runId) return; - this.emitExternalEvent(event); + const scope = this.#runHandles.get(logicalRunId)?.scope; + if (!event.scope && !scope) return; + this.emitExternalEvent(scope && !event.scope ? { ...event, scope } : event); }; } @@ -1588,6 +1628,10 @@ export class Agent { const targetLogicalRunId = logicalRunId ?? this.#managedLogicalRunOwner ?? this.#activeRunId; const handle = targetLogicalRunId !== undefined ? this.#runHandles.get(targetLogicalRunId) : undefined; const runId = this.#activeRunId; + const pendingTurnEndPublication = + runId !== undefined && this.#pendingTurnEndPublication?.runId === runId + ? this.#pendingTurnEndPublication + : undefined; const managedLogicalRunId = this.#managedLogicalRunOwner; const activeLogicalRunId = managedLogicalRunId ?? runId; if ( @@ -1599,38 +1643,53 @@ export class Agent { } const activeResourceDomain = this.#activeResourceCancellationDomain; const activeResourceRunId = this.#activeResourceRunId; + const resolve = this.#resolveRunningPrompt; const hadActiveRun = runId !== undefined && (this.#runningPrompt !== undefined || this.#state.isStreaming); if (!hadActiveRun) return false; this.#abortController?.abort(reason); this.#continuationGeneration++; this.#attemptAuthority.advanceMain(); - this.#state.isStreaming = false; - this.#state.streamMessage = null; - this.#state.pendingToolCalls = new Set(); this.#abortController = undefined; this.#cursorToolResultBuffer = []; this.#managedLogicalRunOwner = undefined; - const resolve = this.#resolveRunningPrompt; - this.#runningPrompt = undefined; - this.#resolveRunningPrompt = undefined; this.#activeRunId = undefined; this.#activeResourceRunId = undefined; this.#activeResourceCancellationDomain = undefined; - resolve?.(); - this.#finalizeRun( - activeLogicalRunId ?? runId!, - { - type: "agent_end", - messages: [], - stopReason: "cancelled", - scope: handle?.scope, - }, - undefined, - activeResourceDomain, - ); - if (activeResourceRunId) this.resourceLedger.quarantine(activeResourceRunId); + const finalizeForcedAbort = () => { + this.#state.isStreaming = false; + this.#state.streamMessage = null; + this.#state.pendingToolCalls = new Set(); + this.#runningPrompt = undefined; + this.#resolveRunningPrompt = undefined; + resolve?.(); + this.#finalizeRun( + activeLogicalRunId ?? runId!, + { + type: "agent_end", + messages: [], + stopReason: "cancelled", + scope: handle?.scope, + }, + undefined, + activeResourceDomain, + ); + if (activeResourceRunId) this.resourceLedger.quarantine(activeResourceRunId); + }; + + if (pendingTurnEndPublication) { + // A turn_end has already entered the publication barrier. Keep the Agent + // busy until its durable consumer finishes, then publish the forced + // terminal event so it cannot overtake the checkpoint. + this.#forcedAbortPublicationRunId = runId; + void pendingTurnEndPublication.completion.then(() => { + if (this.#forcedAbortPublicationRunId === runId) this.#forcedAbortPublicationRunId = undefined; + finalizeForcedAbort(); + }); + } else { + finalizeForcedAbort(); + } return true; } @@ -1682,9 +1741,9 @@ export class Agent { }, () => { for (const message of request.messages ?? []) { - this.#emit({ type: "message_start", message }); + this.#emit({ type: "message_start", message, scope: handle.scope }); this.appendMessage(message); - this.#emit({ type: "message_end", message }); + this.#emit({ type: "message_end", message, scope: handle.scope }); } }, ); @@ -1701,8 +1760,7 @@ export class Agent { this.#state.error = undefined; this.#steeringQueue = []; this.#followUpQueue = []; - // A reset starts a new provider cache lineage (/new, context clear, handoff): - // its first request must report `initial`, not a mutation of the old session. + // Resetting starts a new provider-cache lineage (/new, context clear, handoff). this.#promptPrefixTracker = new PromptPrefixTracker(); } @@ -1919,6 +1977,9 @@ export class Agent { this.#observeMainAttemptScope(scope); const handle: AttemptRunHandle = { logicalRunId, scope }; this.#runHandles.set(logicalRunId, handle); + const logicalRunScopes = this.#runScopes.get(logicalRunId) ?? new Set(); + logicalRunScopes.add(scope); + this.#runScopes.set(logicalRunId, logicalRunScopes); options?.onRunAccepted?.(handle, { consumedQueuedMessages: options.consumedQueuedMessages ?? [], }); @@ -2047,6 +2108,8 @@ export class Agent { mint: () => { const scope = this.#attemptAuthority.mintMain(); this.#observeMainAttemptScope(scope); + this.#runScopes.get(logicalRunId)?.add(scope); + this.#runHandles.set(logicalRunId, { logicalRunId, scope }); return scope; }, }, @@ -2085,7 +2148,6 @@ export class Agent { : undefined, afterToolCall: this.afterToolCall ? async (ctx, signal) => { - if (this.#activeRunId !== runId) return undefined; const result = await this.afterToolCall?.(ctx, signal); if (this.#activeRunId !== runId) return undefined; return result; @@ -2110,6 +2172,21 @@ export class Agent { onHarmonyLeak: this.#onHarmonyLeak, getToolChoice, getReasoning: () => this.#state.thinkingLevel, + afterTurnEndPublished: async () => { + if (this.#activeRunId !== runId) return; + const publication = Promise.withResolvers(); + const completion = Promise.withResolvers(); + const pendingPublication = { runId, completion: completion.promise }; + this.#pendingTurnEndPublication = pendingPublication; + pendingTurnEndPublications.push(publication); + try { + await publication.promise; + await this.afterTurnEndPublished?.(); + } finally { + if (this.#pendingTurnEndPublication === pendingPublication) this.#pendingTurnEndPublication = undefined; + completion.resolve(); + } + }, getSteeringMessages: async () => { if (this.#activeRunId !== runId) { return []; @@ -2195,6 +2272,10 @@ export class Agent { }; let partial: AgentMessage | null = null; + const pendingTurnEndPublications: Array<{ + promise: Promise; + resolve: () => void; + }> = []; try { const stream = messages @@ -2202,7 +2283,10 @@ export class Agent { : agentLoopContinue(context, config, abortController.signal, this.streamFn, !continuesLogicalRun, scope); for await (const event of stream) { - if (this.#activeRunId !== runId) { + if ( + this.#activeRunId !== runId && + !(event.type === "turn_end" && this.#forcedAbortPublicationRunId === runId) + ) { break; } @@ -2226,7 +2310,7 @@ export class Agent { // Check if this is an assistant message with buffered Cursor tool results. // If so, split the message to emit tool results at the correct position. if (event.message.role === "assistant" && this.#cursorToolResultBuffer.length > 0) { - this.#emitCursorSplitAssistantMessage(event.message as AssistantMessage); + this.#emitCursorSplitAssistantMessage(event.message as AssistantMessage, event.scope); continue; // Skip default emit - split method handles everything } this.#state.streamMessage = null; @@ -2279,6 +2363,7 @@ export class Agent { // Emit to listeners this.#emit(event); + if (event.type === "turn_end") pendingTurnEndPublications.shift()?.resolve(); } if (this.#activeRunId !== runId) { @@ -2452,7 +2537,7 @@ export class Agent { // The documented contract emits the sanitized diagnostic // before the error terminal on this path too (exact-head // review P2). - this.#emit({ type: "agent_failed", error: sanitizeAgentFailure(err) }); + this.#emit({ type: "agent_failed", error: sanitizeAgentFailure(err), scope: ownership.handle.scope }); this.requestRunTerminal(managedLogicalRunOwner ?? runId, { stopReason: "error" }); if (this.#managedLogicalRunOwner === managedLogicalRunOwner) this.#managedLogicalRunOwner = undefined; } @@ -2500,12 +2585,14 @@ export class Agent { if (this.#terminalizedLogicalRunIds.size > 256) { this.#terminalizedLogicalRunIds.delete(this.#terminalizedLogicalRunIds.values().next().value!); } + const runScopes = this.#runScopes.get(logicalRunId); + const terminalScope = runScopes ? [...runScopes].at(-1) : handle?.scope; const terminalEvent: Extract = event ?? { type: "agent_end", messages: [], - scope: handle?.scope, + scope: terminalScope, }; - if (handle) terminalEvent.scope = handle.scope; + if (terminalScope) terminalEvent.scope = terminalScope; // The run is over: nothing will poll the steering queue again. Disown // whatever it still holds — unconditionally, so no ownership exception can // leave an ended run's steering behind for an unrelated run to consume — @@ -2531,6 +2618,8 @@ export class Agent { try { this.resourceLedger.seal(resourceRunId); } finally { + for (const scope of runScopes ?? []) this.#mainAttemptScopeObserver?.(scope, false); + this.#runScopes.delete(logicalRunId); this.#runHandles.delete(logicalRunId); } } @@ -2556,7 +2645,7 @@ export class Agent { * * Output order: Assistant(preamble) -> ToolResults -> Assistant(continuation) */ - #emitCursorSplitAssistantMessage(assistantMessage: AssistantMessage): void { + #emitCursorSplitAssistantMessage(assistantMessage: AssistantMessage, scope?: AttemptScope): void { const buffer = this.#cursorToolResultBuffer; this.#cursorToolResultBuffer = []; @@ -2564,7 +2653,7 @@ export class Agent { // No tool results, emit normally this.#state.streamMessage = null; this.appendMessage(assistantMessage); - this.#emit({ type: "message_end", message: assistantMessage }); + this.#emit({ type: "message_end", message: assistantMessage, scope }); return; } @@ -2585,13 +2674,13 @@ export class Agent { // Emit assistant message first, then tool results (original behavior but with buffered results) this.#state.streamMessage = null; this.appendMessage(assistantMessage); - this.#emit({ type: "message_end", message: assistantMessage }); + this.#emit({ type: "message_end", message: assistantMessage, scope }); // Emit buffered tool results for (const { toolResult } of buffer) { - this.#emit({ type: "message_start", message: toolResult }); + this.#emit({ type: "message_start", message: toolResult, scope }); this.appendMessage(toolResult); - this.#emit({ type: "message_end", message: toolResult }); + this.#emit({ type: "message_end", message: toolResult, scope }); } return; } @@ -2611,17 +2700,18 @@ export class Agent { ...assistantMessage, content: preambleContent, }; + this.#cursorSplitTerminalMessages.add(assistantMessage); // Emit preamble this.#state.streamMessage = null; this.appendMessage(preambleMessage); - this.#emit({ type: "message_end", message: preambleMessage }); + this.#emit({ type: "message_end", message: preambleMessage, scope }); // Emit buffered tool results for (const { toolResult } of buffer) { - this.#emit({ type: "message_start", message: toolResult }); + this.#emit({ type: "message_start", message: toolResult, scope }); this.appendMessage(toolResult); - this.#emit({ type: "message_end", message: toolResult }); + this.#emit({ type: "message_end", message: toolResult, scope }); } // Emit continuation message (text after tools) if non-empty @@ -2642,9 +2732,9 @@ export class Agent { cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, }; - this.#emit({ type: "message_start", message: continuationMessage }); + this.#emit({ type: "message_start", message: continuationMessage, scope }); this.appendMessage(continuationMessage); - this.#emit({ type: "message_end", message: continuationMessage }); + this.#emit({ type: "message_end", message: continuationMessage, scope }); } } } diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 0ca9917c50f..62d43c3cb68 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -352,6 +352,11 @@ export interface AgentLoopConfig extends SimpleStreamOptions { * these messages are added to the context before the next LLM call. */ getSteeringMessages?: () => Promise; + /** + * Waits for consumers of the published turn_end event to commit any + * canonical per-turn state before the loop admits a successor turn. + */ + afterTurnEndPublished?: () => void | Promise; /** * Returns steering messages that were dequeued for this run but cannot be @@ -843,6 +848,14 @@ export type AgentEvent = /** Present iff `AgentTelemetryConfig` was supplied on this run. */ telemetry?: AgentRunSummary; coverage?: AgentRunCoverage; + /** True if the run ended with a silent abort (not shown to user). */ + silentAbort?: boolean; + /** True if the run ended with a TTSR-triggered abort. */ + ttsrAbort?: boolean; + /** True if terminal persistence failed for this run. */ + terminalPersistenceFailed?: boolean; + /** True if the terminal projection was matched after this run. */ + terminalProjectionMatched?: boolean; scope?: AttemptScope; } // Turn lifecycle - a turn is one assistant response + any tool calls/results @@ -857,7 +870,14 @@ export type AgentEvent = assistantMessageEvent: AssistantMessageEvent; scope?: AttemptScope; } - | { type: "message_end"; message: AgentMessage; scope?: AttemptScope } + | { + type: "message_end"; + message: AgentMessage; + scope?: AttemptScope; + terminalPersistenceFailed?: boolean; + ttsrAbort?: boolean; + silentAbort?: boolean; + } // Tool execution lifecycle | { type: "tool_execution_start"; diff --git a/packages/agent/test/agent-force-abort.test.ts b/packages/agent/test/agent-force-abort.test.ts index e6c99b1f117..d602e09227b 100644 --- a/packages/agent/test/agent-force-abort.test.ts +++ b/packages/agent/test/agent-force-abort.test.ts @@ -116,6 +116,42 @@ describe("Agent.forceAbort", () => { expect(model.calls).toHaveLength(1); }); + it("waits for an in-flight turn-end checkpoint before forced terminalization", async () => { + const model = createMockModel({ responses: [{ content: ["completed turn"] }] }); + const checkpointStarted = Promise.withResolvers(); + const releaseCheckpoint = Promise.withResolvers(); + const order: string[] = []; + const agent = new Agent({ + initialState: { model: model.model, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: model.stream, + afterTurnEndPublished: async () => { + order.push("checkpoint-start"); + checkpointStarted.resolve(); + await releaseCheckpoint.promise; + order.push("checkpoint-complete"); + }, + }); + let forced = false; + agent.subscribe(event => { + if (event.type === "turn_end") { + order.push("turn_end"); + forced = agent.forceAbort("force during turn-end publication"); + } else if (event.type === "agent_end") { + order.push("agent_end"); + } + }); + + const prompt = agent.prompt("finish then force-abort"); + await checkpointStarted.promise; + expect(forced).toBe(true); + expect(order).toEqual(["turn_end", "checkpoint-start"]); + + releaseCheckpoint.resolve(); + await prompt; + + expect(order).toEqual(["turn_end", "checkpoint-start", "checkpoint-complete", "agent_end"]); + }); + it("terminalizes the logical owner when force-aborting a maintenance continuation", async () => { const model = createMockModel(); const pendingContinuation = new AssistantMessageEventStream(); diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 629bd2f5d12..a1c49696c1d 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -141,6 +141,42 @@ describe("agentLoop with AgentMessage", () => { expect(eventTypes).toContain("agent_end"); }); + it("emits message_end when the provider iterator returns a trailing assistant without done", async () => { + const context: AgentContext = { systemPrompt: ["You are helpful."], messages: [], tools: [] }; + const mock = createMockModel(); + const trailing = createAssistantMessage([{ type: "text", text: "trailing final" }]); + const streamFn = () => { + const response = new AssistantMessageEventStream(); + queueMicrotask(() => { + response.push({ type: "start", partial: trailing }); + response.end(trailing); + }); + return response; + }; + const events: AgentEvent[] = []; + const stream = agentLoop( + [createUserMessage("Hello")], + context, + { model: mock.model, convertToLlm: identityConverter }, + undefined, + streamFn, + ); + + for await (const event of stream) events.push(event); + const messages = await stream.result(); + const assistantEnds = events.filter( + (event): event is Extract => + event.type === "message_end" && event.message.role === "assistant", + ); + + expect(assistantEnds).toHaveLength(1); + const terminal = assistantEnds[0]?.message; + if (terminal?.role !== "assistant") throw new Error("Expected trailing assistant message_end"); + expect(terminal.content).toEqual([{ type: "text", text: "trailing final" }]); + expect(events.slice(-3).map(event => event.type)).toEqual(["message_end", "turn_end", "agent_end"]); + expect(messages.filter(message => message.role === "assistant")).toHaveLength(1); + }); + it("emits an aborted assistant message when cancellation happens before provider events", async () => { const context: AgentContext = { systemPrompt: ["You are helpful."], @@ -148,7 +184,15 @@ describe("agentLoop with AgentMessage", () => { tools: [], }; const mock = createMockModel(); - const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter }; + const publicationOrder: string[] = []; + const config: AgentLoopConfig = { + model: mock.model, + convertToLlm: identityConverter, + afterTurnEndPublished: async () => { + await Promise.resolve(); + publicationOrder.push("checkpoint"); + }, + }; const controller = new AbortController(); // The mock provider would reject without a configured response; we want the // agent's abort path to kick in before any event is emitted. Use a raw stream @@ -161,6 +205,7 @@ describe("agentLoop with AgentMessage", () => { for await (const event of stream) { events.push(event); + if (event.type === "agent_end") publicationOrder.push("agent_end"); } const messages = await stream.result(); @@ -171,6 +216,7 @@ describe("agentLoop with AgentMessage", () => { expect(finalMessage.errorMessage).toBe("Request was aborted"); expect(finalMessage.transportFailure?.providerCode).not.toBe("empty_response"); expect(events.map(event => event.type)).toContain("agent_end"); + expect(publicationOrder).toEqual(["checkpoint", "agent_end"]); }); it("should handle custom message types via convertToLlm", async () => { @@ -418,6 +464,37 @@ describe("agentLoop with AgentMessage", () => { expect(agentEnd?.stopReason).toBe("paused"); }); + it("waits for the turn-end checkpoint before admitting a successor turn", async () => { + const context: AgentContext = { systemPrompt: [""], messages: [], tools: [] }; + const mock = createMockModel({ responses: [{ content: ["first"] }, { content: ["second"] }] }); + let checkpointCommitted = false; + let followUpDelivered = false; + const config: AgentLoopConfig = { + model: mock.model, + convertToLlm: identityConverter, + afterTurnEndPublished: async () => { + await Promise.resolve(); + checkpointCommitted = true; + }, + getSteeringMessages: async () => [], + getFollowUpMessages: async () => { + expect(checkpointCommitted).toBe(true); + if (followUpDelivered) return []; + followUpDelivered = true; + checkpointCommitted = false; + return [createUserMessage("continue")]; + }, + }; + + const stream = agentLoop([createUserMessage("start")], context, config, undefined, mock.stream); + for await (const _event of stream) { + // Drain the lifecycle stream. + } + + expect(followUpDelivered).toBe(true); + expect(checkpointCommitted).toBe(true); + }); + it("should handle tool calls and results", async () => { const toolSchema = z.object({ value: z.string() }); const executed: string[] = []; @@ -1293,6 +1370,18 @@ describe("agentLoop with AgentMessage", () => { expect(text).toContain("Tool execution was aborted"); expect(text).not.toContain("Tool execution was aborted.:"); } + const assistantEnds = events.filter( + (event): event is Extract => + event.type === "message_end" && event.message.role === "assistant", + ); + const turnEndIndex = events.findIndex(event => event.type === "turn_end"); + const agentEndIndex = events.findIndex(event => event.type === "agent_end"); + expect(assistantEnds).toHaveLength(1); + expect(events.filter(event => event.type === "turn_end")).toHaveLength(1); + expect(events.filter(event => event.type === "agent_end")).toHaveLength(1); + expect(assistantEnds[0] ? events.indexOf(assistantEnds[0]) : -1).toBeLessThan(turnEndIndex); + expect(turnEndIndex).toBeLessThan(agentEndIndex); + expect(agentEndIndex).toBe(events.length - 1); }); it("does not wait forever for a non-cooperative tool after abort", async () => { const toolSchema = z.object({ value: z.string() }); @@ -1595,6 +1684,7 @@ describe("agentLoopContinue with AgentMessage", () => { it("blocks tool execution when beforeToolCall returns block", async () => { const toolSchema = z.object({ value: z.string() }); const executed: string[] = []; + let afterCalls = 0; const tool: AgentTool = { name: "echo", label: "Echo", @@ -1621,6 +1711,9 @@ describe("agentLoopContinue with AgentMessage", () => { model: mock.model, convertToLlm: identityConverter, beforeToolCall: async () => ({ block: true, reason: "policy: blocked" }), + afterToolCall: async () => { + afterCalls++; + }, }; const events: AgentEvent[] = []; @@ -1630,6 +1723,7 @@ describe("agentLoopContinue with AgentMessage", () => { } expect(executed).toEqual([]); + expect(afterCalls).toBe(0); const toolEnd = events.find(e => e.type === "tool_execution_end"); expect(toolEnd).toBeDefined(); if (toolEnd?.type === "tool_execution_end") { @@ -1638,6 +1732,260 @@ describe("agentLoopContinue with AgentMessage", () => { } }); + it("runs afterToolCall once when cancellation lands before dispatch", async () => { + const toolSchema = z.object({ value: z.string() }); + const abortController = new AbortController(); + const afterCalls: Array<{ result: unknown; isError: boolean }> = []; + let executed = false; + const tool: AgentTool = { + name: "echo", + label: "Echo", + description: "Echo tool", + parameters: toolSchema, + async execute() { + executed = true; + return { content: [{ type: "text", text: "executed" }] }; + }, + }; + + const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] }; + const mock = createMockModel({ + responses: [{ content: [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }] }], + }); + const config: AgentLoopConfig = { + model: mock.model, + convertToLlm: identityConverter, + beforeToolCall: () => { + // Queue the abort after the hook returns but before the await continuation + // can publish the prepared dispatch. + queueMicrotask(() => abortController.abort()); + }, + afterToolCall: async ({ result, isError }) => { + afterCalls.push({ result, isError }); + }, + }; + + const stream = agentLoop( + [createUserMessage("echo something")], + context, + config, + abortController.signal, + mock.stream, + ); + for await (const _event of stream) { + // drain + } + + expect(executed).toBe(false); + expect(afterCalls).toHaveLength(1); + expect(afterCalls[0]).toMatchObject({ + isError: true, + result: { isError: true, details: { cancellation: "before_dispatch" } }, + }); + }); + + it("runs pre-dispatch cleanup once when abort lands inside an awaited hook", async () => { + const toolSchema = z.object({}); + const controller = new AbortController(); + const beforeEntered = Promise.withResolvers(); + const releaseBefore = Promise.withResolvers(); + let executed = false; + let cleanupCalls = 0; + const tool: AgentTool = { + name: "gated", + label: "Gated", + description: "Waits in pre-dispatch", + parameters: toolSchema, + execute: async () => { + executed = true; + return { content: [{ type: "text", text: "executed" }] }; + }, + }; + const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] }; + const mock = createMockModel({ + responses: [{ content: [{ type: "toolCall", id: "tool-1", name: "gated", arguments: {} }] }], + }); + const stream = agentLoop( + [createUserMessage("run")], + context, + { + model: mock.model, + convertToLlm: identityConverter, + beforeToolCall: async () => { + beforeEntered.resolve(); + await releaseBefore.promise; + }, + afterToolCall: async ({ result }) => { + expect(result).toMatchObject({ details: { cancellation: "before_dispatch" } }); + cleanupCalls++; + }, + }, + controller.signal, + mock.stream, + ); + const drained = (async () => { + for await (const _event of stream) { + // drain + } + })(); + await beforeEntered.promise; + controller.abort(); + await drained; + expect(executed).toBe(false); + expect(cleanupCalls).toBe(1); + releaseBefore.resolve(); + await Bun.sleep(0); + expect(cleanupCalls).toBe(1); + }); + + it("bounds transform-triggered abort cleanup before dispatch", async () => { + const toolSchema = z.object({}); + const controller = new AbortController(); + const cleanupStarted = Promise.withResolvers(); + const releaseCleanup = Promise.withResolvers(); + let executed = false; + let cleanupCalls = 0; + const tool: AgentTool = { + name: "prepared", + label: "Prepared", + description: "Aborted by argument transformation before dispatch", + parameters: toolSchema, + execute: async () => { + executed = true; + return { content: [{ type: "text", text: "unexpected execution" }] }; + }, + }; + const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] }; + const mock = createMockModel({ + responses: [{ content: [{ type: "toolCall", id: "tool-1", name: "prepared", arguments: {} }] }], + }); + const events: AgentEvent[] = []; + const stream = agentLoop( + [createUserMessage("run")], + context, + { + model: mock.model, + convertToLlm: identityConverter, + transformToolCallArguments: args => { + controller.abort(); + return args; + }, + afterToolCall: async ({ result }) => { + expect(result).toMatchObject({ details: { cancellation: "before_dispatch" } }); + cleanupCalls++; + cleanupStarted.resolve(); + await releaseCleanup.promise; + }, + }, + controller.signal, + mock.stream, + ); + const drained = (async () => { + for await (const event of stream) events.push(event); + })(); + await cleanupStarted.promise; + const abortSettled = await Promise.race([drained.then(() => true), Bun.sleep(1_500).then(() => false)]); + releaseCleanup.resolve(); + await drained; + + expect(abortSettled).toBe(true); + expect(executed).toBe(false); + expect(cleanupCalls).toBe(1); + expect(events.filter(event => event.type === "agent_end")).toHaveLength(1); + expect(events.filter(event => event.type === "tool_execution_end")).toHaveLength(1); + }); + + it("bounds abort while afterToolCall already owns dispatched cleanup", async () => { + const toolSchema = z.object({}); + const controller = new AbortController(); + const cleanupStarted = Promise.withResolvers(); + const releaseCleanup = Promise.withResolvers(); + let cleanupCalls = 0; + const tool: AgentTool = { + name: "completed", + label: "Completed", + description: "Completes before its cleanup hook settles", + parameters: toolSchema, + execute: async () => ({ content: [{ type: "text", text: "done" }] }), + }; + const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] }; + const mock = createMockModel({ + responses: [{ content: [{ type: "toolCall", id: "tool-1", name: "completed", arguments: {} }] }], + }); + const events: AgentEvent[] = []; + const stream = agentLoop( + [createUserMessage("run")], + context, + { + model: mock.model, + convertToLlm: identityConverter, + afterToolCall: async () => { + cleanupCalls++; + cleanupStarted.resolve(); + await releaseCleanup.promise; + }, + }, + controller.signal, + mock.stream, + ); + const drained = (async () => { + for await (const event of stream) events.push(event); + })(); + await cleanupStarted.promise; + controller.abort(); + const abortSettled = await Promise.race([drained.then(() => true), Bun.sleep(1_500).then(() => false)]); + releaseCleanup.resolve(); + await drained; + + expect(abortSettled).toBe(true); + expect(cleanupCalls).toBe(1); + expect(events.filter(event => event.type === "agent_end")).toHaveLength(1); + expect(events.filter(event => event.type === "tool_execution_end")).toHaveLength(1); + }); + + it("settles dispatched cancellation cleanup before agent_end", async () => { + const toolSchema = z.object({}); + const controller = new AbortController(); + const never = Promise.withResolvers(); + const order: string[] = []; + const tool: AgentTool = { + name: "parked", + label: "Parked", + description: "Never completes", + parameters: toolSchema, + nonAbortable: true, + concurrency: "exclusive", + execute: async () => never.promise, + }; + const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] }; + const mock = createMockModel({ + responses: [ + { + content: [ + { type: "toolCall", id: "tool-1", name: "parked", arguments: {} }, + { type: "toolCall", id: "tool-2", name: "parked", arguments: {} }, + ], + }, + ], + }); + const config: AgentLoopConfig = { + model: mock.model, + convertToLlm: identityConverter, + afterToolCall: async ({ result }) => { + expect(result).toMatchObject({ details: { cancellation: "after_dispatch" } }); + order.push("cleanup"); + }, + }; + + const stream = agentLoop([createUserMessage("run")], context, config, controller.signal, mock.stream); + for await (const event of stream) { + if (event.type === "tool_execution_start") controller.abort(); + if (event.type === "agent_end") order.push("agent_end"); + } + + expect(order).toEqual(["cleanup", "agent_end"]); + }); + it("passes beforeToolCall args mutations into tool.execute without revalidation", async () => { const toolSchema = z.object({ value: z.string() }); const executed: Array = []; diff --git a/packages/agent/test/managed-attempt-transaction.test.ts b/packages/agent/test/managed-attempt-transaction.test.ts index f221d932840..cd4c5d01617 100644 --- a/packages/agent/test/managed-attempt-transaction.test.ts +++ b/packages/agent/test/managed-attempt-transaction.test.ts @@ -1350,6 +1350,70 @@ describe("managed attempt transaction", () => { expect(fallbackCalls).toBe(0); }); + it.each([ + { key: "http2RstCode", invalidKind: "map" }, + { key: "nativeErrorCode", invalidKind: "map" }, + { key: "nativeErrorCode", invalidKind: "string" }, + { key: "code", invalidKind: "string" }, + { key: "providerCode", invalidKind: "string" }, + ] as const)("drops invalid $key ($invalidKind) after a non-cloneable sibling", async ({ key, invalidKind }) => { + const mock = createMockModel(); + const transportFailure = { + kind: "transport" as const, + nonCloneable: () => {}, + http2RstCode: 8, + nativeErrorCode: "ERR_HTTP2_STREAM_ERROR", + }; + let invalidValue: unknown; + if (invalidKind === "string") { + invalidValue = `ERR_HTTP2_${"A".repeat(64 * 1024)}`; + } else { + const oversizedMap = new Map(); + for (let index = 0; index < 512; index++) oversizedMap.set(`diagnostic-${index}`, "x".repeat(256)); + invalidValue = oversizedMap; + } + Object.defineProperty(transportFailure, key, { + value: invalidValue, + enumerable: true, + writable: true, + configurable: true, + }); + expect(() => structuredClone(transportFailure)).toThrow(); + const streamFn = () => { + const stream = new AssistantMessageEventStream(); + queueMicrotask(() => { + stream.push({ + type: "error", + reason: "error", + error: { + ...assistantMessage(mock.model), + stopReason: "error", + errorMessage: "Cursor HTTP/2 request aborted before turnEnded", + transportFailure, + }, + }); + }); + return stream; + }; + const agent = new Agent({ + initialState: { model: mock.model, systemPrompt: ["test"], tools: [], messages: [] }, + streamFn, + }); + + await agent.prompt("run"); + await agent.waitForIdle(); + const message = agent.state.messages.findLast(message => message.role === "assistant"); + const retained = message?.role === "assistant" ? message.transportFailure : undefined; + const expected = { + kind: "transport" as const, + ...(key === "http2RstCode" ? {} : { http2RstCode: 8 }), + ...(key === "nativeErrorCode" ? {} : { nativeErrorCode: "ERR_HTTP2_STREAM_ERROR" }), + }; + expect(retained).toEqual(expected); + expect(transportFailureFacts(retained)).toEqual(expected); + expect(classifyFallbackTrigger(transportFailureFacts(retained))).toEqual({ class: "other" }); + }); + it("stages a non-cloneable provider failure without masking it as a DataCloneError", async () => { // Regression: a provider error message whose payload is not // structured-cloneable (e.g. a live `Headers` in `transportFailure`) diff --git a/packages/agent/test/streaming-perf.test.ts b/packages/agent/test/streaming-perf.test.ts index d464fa4370e..90fb37d6405 100644 --- a/packages/agent/test/streaming-perf.test.ts +++ b/packages/agent/test/streaming-perf.test.ts @@ -132,6 +132,60 @@ it("cleans the pending abort race when the provider rejects", async () => { expect(pendingAbortReactions).toBe(0); }); +it("does not miss an abort raised synchronously by the provider factory", async () => { + const controller = new AbortController(); + const mock = createMockModel(); + const responsePromise = Promise.withResolvers(); + const responseClosed = Promise.withResolvers(); + let closes = 0; + class LateResponse extends AssistantMessageEventStream { + [Symbol.asyncIterator](): AsyncIterator { + return { + next: async () => ({ done: true, value: undefined }), + return: async () => { + closes++; + responseClosed.resolve(); + return { done: true, value: undefined }; + }, + }; + } + } + const lateResponse = new LateResponse(); + lateResponse.end(createAssistantMessage([{ type: "text", text: "answer" }])); + const events = agentLoop( + [createUserMessage("hello")], + { systemPrompt: [], messages: [], tools: [] }, + { model: mock.model, convertToLlm: messages => messages as Message[] }, + controller.signal, + () => { + controller.abort(); + return responsePromise.promise; + }, + ); + let aborted = false; + const completed = (async () => { + for await (const event of events) { + if (event.type === "message_end" && event.message.role === "assistant") + aborted = event.message.stopReason === "aborted"; + } + })(); + const timedOut = Bun.sleep(250).then(() => "timed-out" as const); + const outcome = await Promise.race([ + completed.then( + () => "completed" as const, + () => "failed" as const, + ), + timedOut, + ]); + responsePromise.resolve(lateResponse); + await Promise.race([responseClosed.promise, timedOut]); + await completed.catch(() => {}); + + expect(outcome).toBe("completed"); + expect(aborted).toBe(true); + expect(closes).toBe(1); +}); + it("appends new history and replaces same-length in-place edits", () => { const manager = new AppendOnlyContextManager(); const messages = [createUserMessage("first")]; diff --git a/packages/ai/changelog.d/5321-bounded-transport-failure-facts.md b/packages/ai/changelog.d/5321-bounded-transport-failure-facts.md new file mode 100644 index 00000000000..212507f7e2c --- /dev/null +++ b/packages/ai/changelog.d/5321-bounded-transport-failure-facts.md @@ -0,0 +1,3 @@ +### Fixed + +- Bound normalized transport diagnostic codes and retry-signal header values so oversized metadata is omitted while other transport facts remain available. diff --git a/packages/ai/src/utils/fallback-transport.ts b/packages/ai/src/utils/fallback-transport.ts index 79789bbbde0..e3872eb2f55 100644 --- a/packages/ai/src/utils/fallback-transport.ts +++ b/packages/ai/src/utils/fallback-transport.ts @@ -39,6 +39,9 @@ export const PROVIDER_PROTOCOL_MISMATCH_ERROR_CODE = "provider_protocol_mismatch * the existence gate below. It is always compared case-sensitively. */ export const SERVER_OVERLOADED_PROVIDER_CODE = "server_is_overloaded"; +const MAX_TRANSPORT_CODE_LENGTH = 256; +const MAX_NATIVE_HTTP2_ERROR_CODE_LENGTH = 64; +const MAX_RETAINED_TRANSPORT_HEADER_VALUE_LENGTH = 1024; export type TransportHeaders = Headers | Record; @@ -156,8 +159,8 @@ function finitePositiveInteger(value: unknown): number | undefined { return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; } -function stringValue(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; +function stringValue(value: unknown, maxLength = MAX_TRANSPORT_CODE_LENGTH): string | undefined { + return typeof value === "string" && value.length <= maxLength ? value : undefined; } /** Retry-signal headers retained on transport facts; everything else is dropped. */ @@ -182,7 +185,7 @@ function retainedHeaderRecord(headers: TransportHeaders | undefined): Record MAX_RETAINED_TRANSPORT_HEADER_VALUE_LENGTH) continue; record ??= {}; record[name] = value; } @@ -190,7 +193,13 @@ function retainedHeaderRecord(headers: TransportHeaders | undefined): Record MAX_RETAINED_TRANSPORT_HEADER_VALUE_LENGTH + ) + continue; const name = key.toLowerCase(); if (!RETAINED_TRANSPORT_HEADER_SET.has(name)) continue; record ??= {}; @@ -246,8 +255,13 @@ export function transportFailureFacts( const headers = retainedHeaderRecord(rawHeaders); const normalizedCode = providerCode?.toLowerCase(); const http2RstCode = finiteNonNegativeInteger(propertyOf(value, "http2RstCode")); - const nativeCode = stringValue(propertyOf(value, "nativeErrorCode")) ?? stringValue(propertyOf(value, "code")); - const nativeErrorCode = nativeCode && /^ERR_HTTP2_[A-Z_]+$/.test(nativeCode) ? nativeCode : undefined; + const nativeCode = + stringValue(propertyOf(value, "nativeErrorCode"), MAX_NATIVE_HTTP2_ERROR_CODE_LENGTH) ?? + stringValue(propertyOf(value, "code")); + const nativeErrorCode = + nativeCode && nativeCode.length <= MAX_NATIVE_HTTP2_ERROR_CODE_LENGTH && /^ERR_HTTP2_[A-Z_]+$/.test(nativeCode) + ? nativeCode + : undefined; const requestBytes = finiteNonNegativeInteger(propertyOf(value, "requestBytes")); const firstEventElapsedMs = finiteNonNegativeInteger(propertyOf(value, "firstEventElapsedMs")); const firstEventTimeoutMs = finiteNonNegativeInteger(propertyOf(value, "firstEventTimeoutMs")); diff --git a/packages/ai/test/http2-failure-facts.test.ts b/packages/ai/test/http2-failure-facts.test.ts index 7379a785f40..71fcfe701c4 100644 --- a/packages/ai/test/http2-failure-facts.test.ts +++ b/packages/ai/test/http2-failure-facts.test.ts @@ -12,6 +12,29 @@ describe("HTTP/2 diagnostic facts", () => { expect(JSON.stringify(facts)).not.toContain("private native message"); }); + it("drops oversized native codes from diagnostic facts", () => { + const facts = transportFailureFacts({ nativeErrorCode: `ERR_HTTP2_${"A".repeat(64 * 1024)}` }); + expect(facts).toBeUndefined(); + expect(classifyFallbackTrigger(facts)).toEqual({ class: "other" }); + }); + + it("bounds provider code aliases and retained headers while preserving reset diagnostics", () => { + const oversizedCode = `ERR_HTTP2_${"A".repeat(64 * 1024)}`; + const codeAliasFacts = transportFailureFacts({ http2RstCode: 8, code: oversizedCode }); + expect(codeAliasFacts?.http2RstCode).toBe(8); + expect(codeAliasFacts?.nativeErrorCode).toBeUndefined(); + expect(codeAliasFacts?.providerCode).toBeUndefined(); + expect(classifyFallbackTrigger(codeAliasFacts)).toEqual({ class: "other" }); + + const providerCodeFacts = transportFailureFacts({ http2RstCode: 8, providerCode: oversizedCode }); + expect(providerCodeFacts?.providerCode).toBeUndefined(); + const headerFacts = transportFailureFacts({ + http2RstCode: 8, + headers: { "retry-after": "1".repeat(64 * 1024) }, + }); + expect(headerFacts?.headers).toBeUndefined(); + }); + it("round-trips reset diagnostics without interpreting them as HTTP status", () => { const facts = transportFailureFacts({ http2RstCode: 8 }); expect(facts).toMatchObject({ kind: "transport", http2RstCode: 8 }); diff --git a/packages/coding-agent/changelog.d/5321-frame-coalescing-lifecycle.md b/packages/coding-agent/changelog.d/5321-frame-coalescing-lifecycle.md new file mode 100644 index 00000000000..cd46a032717 --- /dev/null +++ b/packages/coding-agent/changelog.d/5321-frame-coalescing-lifecycle.md @@ -0,0 +1,12 @@ +### Fixed + +- Tool cleanup now remains bound to the exact retained lineage and TTSR bucket that originated the execution, including pre-dispatch validation failures. Workflow subskill updates fail closed on stale removal and superseded same-session refreshes, repeat-state mutations roll back on per-tool or interrupt-path persistence failure and failed session switches, and a failed turn-end repeat checkpoint blocks successor work until the durable write is retried. Interrupt resume gates always settle, failed switches restore predecessor checkpoint/rewind authority, and committed history replacements invalidate it. Owned completion delivery rejects foreign session endpoints, settles registrations through their immutable endpoint manager, and retires terminal registrations when acknowledged results become stale during formatting. +- Delayed execution receipts, bash artifacts, model/profile/reasoning controls, goal and interview continuations, and idle-yield delivery now retain their originating session identity across asynchronous work. Terminal-persistence recovery and session transitions fence each final mutation; retryable transition races retain yield claims for exactly-once delivery after rollback, while committed identity changes drop them. +- Managed retry terminals now correlate their raw `agent_end` assistant with the already committed scoped `message_end` instead of duplicating successful output in live and durable history. Context promotion may enter its causal model-selection admission without waiting on its own `agent_end`, explicit `todo_write` persistence failures retain their structured recovery path and correct the optimistic TUI state, post-transition user and custom-turn ingress is identity-fenced across asynchronous setup, delayed durable and direct reasoning controls cannot mutate across persistence or successor-context fences, the Settings UI routes reasoning changes through that recovery-aware transaction, and tree navigation settles predecessor jobs plus hidden next-turn drains before selecting the new branch. +- SDK model and configuration mutations now reconcile a pending terminal-persistence failure before changing live session state, matching the existing prompt admission barrier. +- Graceful interactive shutdown now waits for its exact final forced render generation under a bounded deadline before restoring the terminal, so a settling raster write cannot make the last assistant or status frame disappear from scrollback. +- Same-transcript rebuilds, including tree navigation and thinking-visibility changes, now preserve and rebind the active streaming assistant instead of disposing its pending projection. `AgentSession` canonically commits a session-identity-bound latest partial before publishing an `agent_end` without `message_end`; failed terminal persistence creates one identity-bound, exact-once recovery barrier shared by direct, custom, queued, retry, and continuation admissions, while history rewrites remain blocked until reconciliation rebuilds the transcript. Structurally cloned, unscoped, or otherwise stale producers are rejected before Agent mutation after identity replacement; silent and TTSR cancellation classifications remain immutable through asynchronous and deobfuscated delivery. +- Managed iTerm cursor refreshes now cancel with the raster/TUI lifecycle and have a bounded command deadline, so stalled tmux coordination cannot wedge teardown or block later terminal cleanup. +- Managed task-enrollment reads now use cross-platform workflow locking instead of Linux-only private publication, keeping broker startup safe when no enrollment record exists. +- Async yield queues preserve FIFO suffixes and retry deadlines across dispatcher failures, active turns, and stale scheduled wakes; idle owned-completion admission revalidates session identity and transition state after persistence reconciliation before minting a fresh turn lineage. +- Existing multi-workflow snapshots are migrated before per-skill state becomes authoritative, preserving concurrent workflows and approval guards during later state updates. diff --git a/packages/coding-agent/src/config/file-lock.ts b/packages/coding-agent/src/config/file-lock.ts index 51fcf86120e..b653f5bb747 100644 --- a/packages/coding-agent/src/config/file-lock.ts +++ b/packages/coding-agent/src/config/file-lock.ts @@ -26,6 +26,8 @@ export interface FileLockOptions { staleMs?: number; retries?: number; retryDelayMs?: number; + /** Require the lock parent to exist instead of creating it during acquisition. */ + createParent?: boolean; signal?: AbortSignal; onAcquired?: () => void; onContended?: () => void; @@ -96,6 +98,7 @@ const DEFAULT_OPTIONS: Required< staleMs: 10_000, retries: 50, retryDelayMs: 100, + createParent: true, }; /** @@ -1999,10 +2002,13 @@ async function tryAcquireLock( previousOwnerHostIds: readonly string[], ownerToken = crypto.randomUUID(), onAcquired?: () => void, + createParent = true, ): Promise { - await ensureLockParent(path.dirname(lockPath)); - const afterParentMkdir = FileLockTestHooks.afterParentMkdir; - if (afterParentMkdir) await afterParentMkdir(lockPath); + if (createParent) { + await ensureLockParent(path.dirname(lockPath)); + const afterParentMkdir = FileLockTestHooks.afterParentMkdir; + if (afterParentMkdir) await afterParentMkdir(lockPath); + } const pendingPath = `${lockPath}.pending.${process.pid}.${crypto.randomUUID()}`; const owner = lockInfo(ownerHostId, ownerToken); let removePending = false; @@ -2498,10 +2504,11 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions throw new Error("previousOwnerHostIds must contain only non-empty identities"); const opts = { ...DEFAULT_OPTIONS, ...options }; const orphanTransitionAgeMs = Math.max(REMOVAL_TRANSITION_GRACE_MS, opts.retries * opts.retryDelayMs); + const createParent = opts.createParent !== false; if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted"); const lockPath = getLockPath(filePath); - await ensureLockParent(path.dirname(lockPath)); + if (createParent) await ensureLockParent(path.dirname(lockPath)); try { await reapOrphanedLockStagingDirs(lockPath); } catch (error) { @@ -2526,6 +2533,7 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions opts.previousOwnerHostIds ?? [], ownerToken, opts.onAcquired, + createParent, ); if (isFileLockOrphanTransition(result)) throw new FileLockAcquireError( diff --git a/packages/coding-agent/src/export/ttsr.ts b/packages/coding-agent/src/export/ttsr.ts index e693fc79bb1..224484c348b 100644 --- a/packages/coding-agent/src/export/ttsr.ts +++ b/packages/coding-agent/src/export/ttsr.ts @@ -505,6 +505,15 @@ export class TtsrManager { this.#messageCount = Math.max(0, Math.floor(messageCount)); } + /** Replace persisted repeat state after a committed session identity transition. */ + replacePersistedState(records: string[] | TtsrInjectionRecord[], messageCount: number): void { + if (this.#isDisabled()) return; + this.#buffers.clear(); + this.#injectionRecords.clear(); + this.#messageCount = Math.max(0, Math.floor(messageCount)); + this.restoreInjected(records); + } + /** Get settings. */ getSettings(): Required { return this.#settings; diff --git a/packages/coding-agent/src/gjc-runtime/state-writer.ts b/packages/coding-agent/src/gjc-runtime/state-writer.ts index 80c99c42e2b..6dd4590810b 100644 --- a/packages/coding-agent/src/gjc-runtime/state-writer.ts +++ b/packages/coding-agent/src/gjc-runtime/state-writer.ts @@ -139,6 +139,13 @@ export interface StateWriterOptions { * `withWorkflowStateLock`). Skip re-acquisition to avoid self-deadlock. */ lockHeld?: boolean; + /** Caller already holds the session-wide active-entry store lock. */ + activeStateScopeLockHeld?: boolean; +} + +export interface WorkflowStateLockOptions extends StateWriterOptions { + /** Avoid creating a missing target parent while acquiring a read-side lock. */ + createMissingParents?: boolean; } export class StateWriteConflictError extends Error { @@ -666,6 +673,7 @@ async function closePrivatePublicationContext(context: PrivatePublicationContext async function preparePrivateDirectory( filePath: string, options: StateWriterOptions, + createMissingParents = true, ): Promise { if (process.platform !== "linux") throw new Error("private durable publication requires Linux"); const boundary = resolveGjcTarget(options.privateDurable!.directory, cwdForOptions(options)); @@ -701,11 +709,13 @@ async function preparePrivateDirectory( const childPath = path.join(directoryPath(parent), segment); directory = path.join(directory, segment); let created = false; - try { - await fs.mkdir(childPath, { mode: 0o700 }); - created = true; - } catch (error) { - if (!isErrno(error, "EEXIST")) throw error; + if (createMissingParents) { + try { + await fs.mkdir(childPath, { mode: 0o700 }); + created = true; + } catch (error) { + if (!isErrno(error, "EEXIST")) throw error; + } } let createdIdentity: { dev: number; ino: number } | undefined; if (created) { @@ -1117,12 +1127,16 @@ export async function writeTextAtomic(targetPath: string, text: string, options? export async function withWorkflowStateLock( targetPath: string, fn: () => Promise, - options?: StateWriterOptions, + options?: WorkflowStateLockOptions, ): Promise { const filePath = resolveGjcTarget(targetPath, cwdForOptions(options)); - const privateContext = options?.privateDurable ? await preparePrivateDirectory(filePath, options) : undefined; + const createMissingParents = options?.createMissingParents !== false; + const privateContext = options?.privateDurable + ? await preparePrivateDirectory(filePath, options, createMissingParents) + : undefined; + const lockOptions = createMissingParents ? options?.lock : { ...options?.lock, createParent: false }; try { - return await lockResolvedWorkflowTarget(filePath, fn, options?.lock); + return await lockResolvedWorkflowTarget(filePath, fn, lockOptions, createMissingParents); } finally { if (privateContext) await closePrivatePublicationContext(privateContext); } @@ -1132,10 +1146,12 @@ async function lockResolvedWorkflowTarget( filePath: string, fn: () => Promise, lockOptions?: FileLockOptions, + createMissingParents = true, ): Promise { // `withFileLock` creates the lock dir next to the target with a non-recursive - // mkdir, so the parent directory must exist before the lock is acquired. - await fs.mkdir(path.dirname(filePath), { recursive: true }); + // mkdir, so normal callers create the parent. Read-only callers can instead + // require it to remain present and let a concurrent removal fail with ENOENT. + if (createMissingParents) await fs.mkdir(path.dirname(filePath), { recursive: true }); return withFileLock(filePath, fn, lockOptions); } @@ -1356,6 +1372,113 @@ export async function writeActiveEntry( return result; } +export async function withActiveStateScopeLock( + cwd: string, + sessionScope: string | ActiveSessionScope | undefined, + fn: () => Promise, +): Promise { + const lockTarget = `${layoutActiveSnapshotPath(path.resolve(cwd), requireSessionId(sessionScope, "active state lock"))}.entries`; + return lockResolvedWorkflowTarget(lockTarget, fn); +} + +/** Update an active entry only while it still exactly matches the observed predecessor. */ +export async function updateActiveEntryIfExact( + cwd: string, + sessionScope: string | ActiveSessionScope | undefined, + skill: string, + expected: SkillActiveEntry, + replacement: SkillActiveEntry, +): Promise { + const filePath = activeEntryPath(path.resolve(cwd), sessionScope, skill); + return withActiveStateScopeLock(cwd, sessionScope, () => + lockResolvedWorkflowTarget(filePath, async () => { + const current = await readJsonIfPresent(filePath); + if (!Bun.deepEquals(current, expected)) { + return { path: filePath, written: false, reason: "stale-skip", revision: persistedStateRevision(current) }; + } + const result = await writeGuardedResolvedJsonAtomic(filePath, replacement, { + cwd, + policy: "cache", + sourceRevision: persistedSourceRevision(current) + 1, + lockHeld: true, + }); + invalidateActiveStateCacheForScope(cwd, sessionScope); + return result; + }), + ); +} + +/** Merge active subskills against the authoritative raw entry under the active-store transaction. */ +export async function mergeActiveEntrySubskills( + cwd: string, + sessionScope: string | ActiveSessionScope, + skill: string, + expected: SkillActiveEntry, + activeSubskills: SkillActiveEntry["active_subskills"], + updatedAt: string, +): Promise<{ predecessor: SkillActiveEntry | undefined; result: GuardedWriteResult }> { + const filePath = activeEntryPath(path.resolve(cwd), sessionScope, skill); + return withActiveStateScopeLock(cwd, sessionScope, () => + lockResolvedWorkflowTarget(filePath, async () => { + const current = await readJsonIfPresent(filePath); + const predecessor = + current && typeof current === "object" && !Array.isArray(current) + ? (current as SkillActiveEntry) + : undefined; + if (!predecessor || !Bun.deepEquals(predecessor, expected)) { + return { + predecessor, + result: { + path: filePath, + written: false, + reason: "stale-skip", + revision: persistedStateRevision(current), + }, + }; + } + const replacement: SkillActiveEntry = { + ...predecessor, + skill, + active_subskills: activeSubskills, + updated_at: updatedAt, + }; + const result = await writeGuardedResolvedJsonAtomic(filePath, replacement, { + cwd, + policy: "cache", + sourceRevision: persistedSourceRevision(current) + 1, + lockHeld: true, + }); + invalidateActiveStateCacheForScope(cwd, sessionScope); + return { predecessor, result }; + }), + ); +} + +/** Replace an exact caller-owned active entry with its predecessor under one lock. */ +export async function restoreActiveEntryIfOwned( + cwd: string, + sessionScope: string | ActiveSessionScope, + receipt: GuardedStateWriteReceipt, + predecessor: SkillActiveEntry, +): Promise { + return withActiveStateScopeLock(cwd, sessionScope, () => + lockResolvedWorkflowTarget(receipt.path, async () => { + const current = await readJsonIfPresent(receipt.path); + if (!matchesGuardedStateWriteReceipt(current, receipt)) return false; + const restored = await writeGuardedResolvedJsonAtomic(receipt.path, predecessor, { + cwd, + policy: "cache", + sourceRevision: persistedSourceRevision(current) + 1, + advanceSourceRevision: true, + lockHeld: true, + }); + if (!restored.written) return false; + invalidateActiveStateCacheForScope(cwd, sessionScope); + return true; + }), + ); +} + export async function removeActiveEntry( cwd: string, sessionScope: string | ActiveSessionScope | undefined, diff --git a/packages/coding-agent/src/hooks/skill-state.ts b/packages/coding-agent/src/hooks/skill-state.ts index 88147498671..d6be544ef54 100644 --- a/packages/coding-agent/src/hooks/skill-state.ts +++ b/packages/coding-agent/src/hooks/skill-state.ts @@ -10,10 +10,13 @@ import { ModeStateSchema, SkillActiveStateSchema } from "../gjc-runtime/state-sc import { deleteIfOwned, type GuardedStateWriteReceipt, + type GuardedWriteResult, guardedStateWriteReceipt, matchesGuardedStateWriteReceipt, + mergeActiveEntrySubskills, readActiveEntries, rebuildActiveSnapshot, + restoreActiveEntryIfOwned, writeActiveEntry, writeGuardedJsonAtomic, writeGuardedWorkflowEnvelopeAtomic, @@ -589,12 +592,69 @@ export async function ensureWorkflowSkillActivationSeed( if (!isGjcWorkflowSkill(skill)) return { state: null, seeded: false, rollback: noRollback }; const resolvedSessionId = await resolveBoundarySessionId(input.cwd, input.sessionId); const existing = await readVisibleSkillActiveState(input.cwd, resolvedSessionId, input.stateDir); - const alreadyActive = listActiveSkills(existing).some( + let existingEntry = listActiveSkills(existing).find( entry => entry.skill === skill && (existing ? entryMatchesContext(entry, existing, resolvedSessionId, input.threadId) : true), ); - if (alreadyActive) return { state: existing, seeded: false, rollback: noRollback }; + if (existingEntry) { + let merged: { predecessor: SkillActiveEntry | undefined; result: GuardedWriteResult }; + let mergedWrite: GuardedStateWriteReceipt | undefined; + while (true) { + if ( + input.activeSubskills === undefined || + Bun.deepEquals(existingEntry.active_subskills, input.activeSubskills) + ) { + return { state: existing, seeded: false, rollback: noRollback }; + } + merged = await mergeActiveEntrySubskills( + input.cwd, + { sessionId: resolvedSessionId }, + skill, + existingEntry, + input.activeSubskills, + input.nowIso ?? new Date().toISOString(), + ); + mergedWrite = guardedStateWriteReceipt(merged.result); + if (mergedWrite) break; + if (merged.result.written || merged.result.reason !== "stale-skip") { + throw new Error(`Workflow subskill activation write was not persisted: ${skill}`); + } + existingEntry = merged.predecessor; + if (!existingEntry) return { state: existing, seeded: false, rollback: noRollback }; + } + const rawPredecessor = merged.predecessor; + if (!rawPredecessor) return { state: existing, seeded: false, rollback: noRollback }; + const rollback = async (): Promise => { + const restored = await restoreActiveEntryIfOwned( + input.cwd, + { sessionId: resolvedSessionId }, + mergedWrite, + rawPredecessor, + ); + if (!restored) return false; + await rebuildActiveSnapshot(input.cwd, { sessionId: resolvedSessionId }, { cwd: input.cwd }); + return true; + }; + try { + await rebuildActiveSnapshot(input.cwd, { sessionId: resolvedSessionId }, { cwd: input.cwd }); + } catch (error) { + await rollback(); + throw error; + } + const mergedEntry = mergedWrite.stamped as SkillActiveEntry; + return { + state: existing + ? { + ...existing, + active_subskills: input.activeSubskills, + active_skills: listActiveSkills(existing).map(entry => (entry.skill === skill ? mergedEntry : entry)), + } + : null, + seeded: true, + rollback, + }; + } const seed = await seedSkillActivationState(skill, `/skill:${skill}`, "gjc-skill-invocation", { cwd: input.cwd, sessionId: resolvedSessionId, diff --git a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts index 0acc7c48a3c..5dd708cd031 100644 --- a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts +++ b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts @@ -283,7 +283,7 @@ export class GajaePetWidget { #itermOwner = `gajae-pet-${Math.random().toString(36).slice(2)}`; #itermGeneration = 0; #itermSubmitPending = false; - #syncManagedItermCursor: (row: number, column: number) => Promise; + #syncManagedItermCursor: (row: number, column: number, signal: AbortSignal) => Promise; constructor(options: { ui: TUI; @@ -292,7 +292,7 @@ export class GajaePetWidget { floorContainer: Container; isWorking: () => boolean; getComposerBottomOffset: () => number; - syncManagedItermCursor: (row: number, column: number) => Promise; + syncManagedItermCursor: (row: number, column: number, signal: AbortSignal) => Promise; forcePixelProtocol?: "sixel" | "kitty"; autoFlexGapMs?: [number, number] | null; }) { @@ -793,7 +793,8 @@ export class GajaePetWidget { ), afterPrefix: mode === "managed" - ? async () => (current() ? await this.#syncManagedItermCursor(rect.row, rect.column) : false) + ? async signal => + current() ? await this.#syncManagedItermCursor(rect.row, rect.column, signal) : false : undefined, replayPrefix: mode === "managed" ? new TextEncoder().encode(cursorPosition) : undefined, records: encodedRecords, diff --git a/packages/coding-agent/src/modes/components/iterm-pet-transport.ts b/packages/coding-agent/src/modes/components/iterm-pet-transport.ts index 90c7d2aa9e4..d78baa452b6 100644 --- a/packages/coding-agent/src/modes/components/iterm-pet-transport.ts +++ b/packages/coding-agent/src/modes/components/iterm-pet-transport.ts @@ -5,6 +5,7 @@ export const PET_CAPABILITY_DRAIN_MAX_MS = 100; export const PET_CAPABILITY_QUIESCENCE_MS = 25; export const PET_CAPABILITY_QUERY_TIMEOUT_MS = 1000; export const PET_TOPOLOGY_POLL_MS = 250; +export const PET_MANAGED_CURSOR_REFRESH_TIMEOUT_MS = 250; export type PetTransportMode = "direct" | "managed"; export type PetUnavailableReason = | "not-iterm2" @@ -55,7 +56,7 @@ export type PetTransportOutput = Readonly<{ ): Promise; }>; export type PetTmuxResult = Readonly<{ status: number; stdout: string; stderr?: string }>; -export type PetTmuxRunner = (argv: readonly string[]) => Promise; +export type PetTmuxRunner = (argv: readonly string[], signal?: AbortSignal) => Promise; export type PetTmuxTopology = Readonly<{ clients: number; paneId?: string; @@ -160,8 +161,12 @@ export function createNativePetTransport(o: { }; const tmuxCommand = managed ? resolveGjcTmuxCommand(env) : undefined; const tmux: PetTmuxRunner | undefined = managed - ? async argv => { - const p = Bun.spawn([tmuxCommand!, ...argv], { stdout: "pipe", stderr: "pipe" }); + ? async (argv, signal) => { + const p = Bun.spawn([tmuxCommand!, ...argv], { + stdout: "pipe", + stderr: "pipe", + ...(signal ? { signal, timeout: PET_MANAGED_CURSOR_REFRESH_TIMEOUT_MS } : {}), + }); return { status: await p.exited, stdout: await new Response(p.stdout).text(), @@ -245,7 +250,8 @@ export class ItermPetTransport { get availability() { return { available: this.#available, mode: this.#mode, reason: this.#reason, epoch: this.#epoch }; } - async refreshManagedClient(row: number, column: number): Promise { + async refreshManagedClient(row: number, column: number, lifecycleSignal?: AbortSignal): Promise { + if (lifecycleSignal?.aborted) return false; if (this.#mode === "direct") return true; if (!Number.isInteger(row) || row < 0 || !Number.isInteger(column) || column < 0) return false; if (!this.#available || !this.#tmux || this.#observedClientId === undefined || this.#paneId === undefined) @@ -253,36 +259,49 @@ export class ItermPetTransport { if (this.#expectedClientId !== undefined && this.#expectedClientId !== this.#observedClientId) return false; const clientId = this.#observedClientId; const epoch = this.#epoch; - const deadline = this.#clock.now() + 250; + const deadline = this.#clock.now() + PET_MANAGED_CURSOR_REFRESH_TIMEOUT_MS; + const timeoutController = new AbortController(); + const timeout = this.#clock.setTimeout(() => timeoutController.abort(), PET_MANAGED_CURSOR_REFRESH_TIMEOUT_MS); + const signal = lifecycleSignal + ? AbortSignal.any([lifecycleSignal, timeoutController.signal]) + : timeoutController.signal; const isCurrent = () => !this.#disposed && + !signal.aborted && this.#available && this.#tmux !== undefined && this.#epoch === epoch && this.#observedClientId === clientId && (this.#expectedClientId === undefined || this.#expectedClientId === clientId); - while (this.#clock.now() <= deadline) { - if (!isCurrent()) return false; - try { - const pane = result( - await this.#tmux(["display-message", "-p", "-t", this.#paneId, "#{cursor_y}\t#{cursor_x}"]), - ); - if (!isCurrent() || pane.status !== 0) return false; - const match = /^([0-9]+)\t([0-9]+)$/.exec(pane.stdout.trim()); - if (!match) return false; - const observedRow = Number(match[1]); - const observedColumn = Number(match[2]); - if (!Number.isSafeInteger(observedRow) || !Number.isSafeInteger(observedColumn)) return false; - if (observedRow === row && observedColumn === column) - return result(await this.#tmux(["refresh-client", "-t", clientId])).status === 0 && isCurrent(); - } catch { - return false; + try { + while (this.#clock.now() <= deadline) { + if (!isCurrent()) return false; + try { + const pane = result( + await this.#tmux(["display-message", "-p", "-t", this.#paneId, "#{cursor_y}\t#{cursor_x}"], signal), + ); + if (!isCurrent() || pane.status !== 0) return false; + const match = /^([0-9]+)\t([0-9]+)$/.exec(pane.stdout.trim()); + if (!match) return false; + const observedRow = Number(match[1]); + const observedColumn = Number(match[2]); + if (!Number.isSafeInteger(observedRow) || !Number.isSafeInteger(observedColumn)) return false; + if (observedRow === row && observedColumn === column) { + return ( + result(await this.#tmux(["refresh-client", "-t", clientId], signal)).status === 0 && isCurrent() + ); + } + } catch { + return false; + } + const { promise, resolve } = Promise.withResolvers(); + this.#clock.setTimeout(resolve, 10); + await promise; } - const { promise, resolve } = Promise.withResolvers(); - this.#clock.setTimeout(resolve, 10); - await promise; + return false; + } finally { + this.#clock.clearTimeout(timeout); } - return false; } subscribe(cb: (a: PetTransportAvailability) => void) { this.#listeners.add(cb); diff --git a/packages/coding-agent/src/modes/components/settings-selector.ts b/packages/coding-agent/src/modes/components/settings-selector.ts index 5934878c538..5fecef86c4d 100644 --- a/packages/coding-agent/src/modes/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/components/settings-selector.ts @@ -1517,6 +1517,8 @@ export interface SettingsCallbacks { * candidate could not be loaded, leaving the submenu open. */ onThemeCommit?: (path: "theme.dark" | "theme.light", theme: string, previousTheme: string) => Promise; + /** Persist and apply reasoning effort through the session's recovery-aware control transaction. */ + onThinkingLevelCommit?: (level: ThinkingLevelValue) => Promise; /** Called to live-preview the gajae pet skin while browsing the pet setting. */ onPetPreview?: (mode: string) => void; /** @@ -1941,6 +1943,13 @@ export class SettingsSelectorComponent extends Container { }); return; } + if (def.path === "defaultThinkingLevel") { + if (!this.callbacks.onThinkingLevelCommit) return; + void this.callbacks.onThinkingLevelCommit(value as ThinkingLevelValue).then(accepted => { + if (accepted) done(value); + }); + return; + } if (def.path === "pet.mode") { // The shared pet commit policy rechecks capability immediately // before mutation and persists only on acceptance; the settings diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index bff9dbe12a2..d3ba448d6b5 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -26,7 +26,7 @@ import type { PlanApprovalDetails } from "../../plan-mode/approved-plan"; import { completionNotifyDisabledByEnv } from "../../sdk/bus/config"; import { summaryFromMessage } from "../../sdk/bus/helpers"; import type { AgentSessionEvent } from "../../session/agent-session"; -import { type CustomMessage, isSilentAbort, readPendingDisplayTag } from "../../session/messages"; +import { type CustomMessage, isSilentAbort, readPendingDisplayTag, SILENT_ABORT_MARKER } from "../../session/messages"; import { transferSessionMessageIdentity } from "../../session/session-manager"; import type { ResolveToolDetails } from "../../tools/resolve"; import { computeIrcSplitWidths, getIrcSidebarSemanticToken } from "../components/irc-sidebar"; @@ -249,8 +249,37 @@ export class EventController { /** Session/transcript replacement invalidates callbacks before removing old children. */ resetAssistantTextPresentation(): void { + const detachedComponent = this.ctx.streamingComponent; + const detachedMessage = this.ctx.streamingMessage; this.#sessionPresentationEpoch += 1; this.#cancelAssistantTextPresentation(); + // Some rebuild callers detach the live component before invoking this reset and + // reattach it immediately afterward. Advance its ownership epoch here so that + // reattachment remains eligible without requiring a second controller hook. + if ( + detachedComponent && + detachedMessage?.role === "assistant" && + !this.ctx.chatContainer.hasLiveChild(detachedComponent) + ) { + this.#assistantLifetimes.set(detachedComponent, { + sessionIdentity: this.ctx.session, + sessionEpoch: this.#sessionPresentationEpoch, + }); + } + } + + /** Rebind a detached live assistant after an in-place transcript rebuild. */ + rebindAssistantTextPresentation(): void { + const component = this.ctx.streamingComponent; + if (!component) return; + this.#assistantLifetimes.set(component, { + sessionIdentity: this.ctx.session, + sessionEpoch: this.#sessionPresentationEpoch, + }); + const message = this.ctx.streamingMessage; + if (message?.role === "assistant" && !this.#assistantTextSuspended) { + this.#queueAssistantText(component, message); + } } #isLiveAssistant(component: AssistantMessageComponent): boolean { @@ -720,6 +749,11 @@ export class EventController { } async #handleNotice(event: Extract): Promise { + if (event.source === "terminal-persistence-recovered") { + this.ctx.rebuildChatFromMessages("reconcile-same-transcript"); + this.#recordVisibleTranscriptMutation(); + return; + } const message = event.source ? `${event.source}: ${event.message}` : event.message; if (event.level === "error") { this.ctx.showError(message); @@ -858,6 +892,14 @@ export class EventController { async #handleMessageEnd(event: Extract): Promise { if (event.message.role === "user") return; if (event.message.role === "assistant") this.#cancelAssistantTextPresentation(); + if (event.message.role === "assistant" && event.terminalPersistenceFailed === true) { + if (this.ctx.streamingComponent) this.ctx.chatContainer.removeChild(this.ctx.streamingComponent); + this.ctx.streamingComponent = undefined; + this.ctx.streamingMessage = undefined; + this.#recordVisibleTranscriptMutation(); + this.ctx.ui.requestRender(); + return; + } if (this.ctx.streamingComponent && event.message.role === "assistant") { if (this.ctx.streamingMessage?.role === "assistant") { transferSessionMessageIdentity([this.ctx.streamingMessage], [event.message]); @@ -866,7 +908,7 @@ export class EventController { let errorMessage: string | undefined; const aborted = this.ctx.streamingMessage.stopReason === "aborted"; const silentlyAborted = aborted && isSilentAbort(this.ctx.streamingMessage.errorMessage); - const ttsrSilenced = aborted && this.ctx.session.isTtsrAbortPending; + const ttsrSilenced = aborted && event.ttsrAbort === true; if (aborted && !silentlyAborted && !ttsrSilenced) { // Real user-cancel / network / provider abort: surface the standard // operator-facing label. AgentSession.#handleAgentEvent already stamped @@ -1060,15 +1102,62 @@ export class EventController { } async #handleAgentEnd(event: Extract): Promise { - this.#flushAssistantText(); - this.#cancelAssistantTextPresentation(); - this.ctx.setWorkingMessage(undefined); - stopInteractiveActivityIndicator(this.ctx, { foregroundSettled: true }); - if (this.ctx.streamingComponent) { - this.ctx.chatContainer.removeChild(this.ctx.streamingComponent); + if (event.terminalProjectionMatched === false && this.ctx.streamingComponent) return; + const orphanComponent = this.ctx.streamingComponent; + const orphanMessage = this.ctx.streamingMessage?.role === "assistant" ? this.ctx.streamingMessage : undefined; + if (orphanComponent && orphanMessage && event.terminalPersistenceFailed !== true) { + // An agent_end without message_end still owns the in-flight assistant. Commit + // its latest partial text as a historical component instead of removing it + // before a frame can paint it. A terminal payload, when present, is more + // authoritative than the last delta but remains on the same live component. + const authoritativeMessage = [...event.messages] + .reverse() + .find((message): message is AssistantMessage => message.role === "assistant"); + const finalMessage = + authoritativeMessage ?? + (event.stopReason === "cancelled" ? { ...orphanMessage, stopReason: "aborted" as const } : orphanMessage); + if (finalMessage !== orphanMessage) { + transferSessionMessageIdentity([orphanMessage], [finalMessage]); + this.ctx.streamingMessage = finalMessage; + } + this.#cancelAssistantTextPresentation(); + const aborted = finalMessage.stopReason === "aborted"; + if (aborted && event.silentAbort && !isSilentAbort(finalMessage.errorMessage)) { + finalMessage.errorMessage = SILENT_ABORT_MARKER; + } + const silentlyAborted = aborted && isSilentAbort(finalMessage.errorMessage); + const ttsrSilenced = aborted && event.ttsrAbort === true; + if (aborted && !silentlyAborted && !ttsrSilenced) { + finalMessage.errorMessage = buildAbortDisplayMessage({ + errorMessage: finalMessage.errorMessage, + retryAttempt: this.ctx.session.retryAttempt, + }); + } + const displayMessage = + silentlyAborted || ttsrSilenced ? { ...finalMessage, stopReason: "stop" as const } : finalMessage; + orphanComponent.updateContent(displayMessage, { streaming: false }); + if (finalMessage.stopReason !== "aborted" && finalMessage.stopReason !== "error") { + for (const [toolCallId, component] of this.ctx.pendingTools.entries()) { + component.setArgsComplete(toolCallId); + this.#consumeToolVisibleChange(component); + } + } + this.#lastAssistantComponent = orphanComponent; + orphanComponent.setUsageInfo(finalMessage.usage); this.ctx.streamingComponent = undefined; this.ctx.streamingMessage = undefined; + } else { + this.#flushAssistantText(); + this.#cancelAssistantTextPresentation(); + if (orphanComponent) { + this.ctx.chatContainer.removeChild(orphanComponent); + this.ctx.streamingComponent = undefined; + this.ctx.streamingMessage = undefined; + this.#recordVisibleTranscriptMutation(); + } } + this.ctx.setWorkingMessage(undefined); + stopInteractiveActivityIndicator(this.ctx, { foregroundSettled: true }); await this.ctx.planModeController.flushPendingModelSwitch(); if (this.ctx.isStopped?.()) return; for (const toolCallId of Array.from(this.ctx.pendingTools.keys())) { diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 74d56827976..f0db429ed5a 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -2513,19 +2513,13 @@ export class InputController { this.ctx.hideThinkingBlock = hideThinkingBlock; this.ctx.session.setThinkingVisibility(hideThinkingBlock ? "hidden" : "visible"); - // Rebuild chat from session messages - // Detach the live streaming component before the disposing clear() so the - // component we re-add below is not torn down (detach != dispose). - if (this.ctx.streamingComponent) { - this.ctx.chatContainer.detachChild(this.ctx.streamingComponent); - } + // The shared same-transcript rebuild owns live-assistant detach/rebind. this.ctx.rebuildChatFromMessages("reconcile-same-transcript"); - // If streaming, re-add the streaming component with updated visibility and re-render + // If streaming, update the restored component's visibility and current projection. if (this.ctx.streamingComponent && this.ctx.streamingMessage) { this.ctx.streamingComponent.setHideThinkingBlock(this.ctx.hideThinkingBlock); this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage, { streaming: true }); - this.ctx.chatContainer.addChild(this.ctx.streamingComponent); } this.ctx.showStatus(`Thinking blocks: ${this.ctx.hideThinkingBlock ? "hidden" : "visible"}`); diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 297465087cd..2c538f190b3 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -1985,6 +1985,20 @@ export class SelectorController { this.#refreshThemeUi(); return true; }, + onThinkingLevelCommit: async level => { + try { + await this.ctx.session.setThinkingLevelForControl(level as ThinkingLevel, true); + this.ctx.statusLine.invalidate(); + this.ctx.updateEditorBorderColor(); + this.ctx.updateEditorTopBorder(); + void this.ctx.notifyConfigChanged?.(); + this.ctx.ui.requestRender(); + return true; + } catch (error) { + this.ctx.showError(error instanceof Error ? error.message : String(error)); + return false; + } + }, onPetPreview: mode => { this.ctx.previewPetMode(mode as PetMode); }, @@ -3326,6 +3340,10 @@ export class SelectorController { return; } + // Retire predecessor: clear streaming component from abandoned branch before rebuilding. + // This prevents the ghost of the old partial answer from appearing in the selected transcript. + this.#clearTransientSessionUi(); + // Update UI — pass the context built by navigateTree to skip a second O(N) walk. this.ctx.rebuildInitialMessages("reconcile-same-transcript", result.sessionContext); await this.ctx.reloadTodos(); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 58f3b9513b5..4bb7e3777f9 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -231,6 +231,7 @@ export function resolveActivityIndicatorMessage( } const WELCOME_RESERVED_CONTAINER_CHILD_LIMIT = 8; const COMPOSER_RIGHT_GUTTER_WIDTH = 1; +const GRACEFUL_SHUTDOWN_RENDER_COMMIT_TIMEOUT_MS = 1000; const IRC_SIDEBAR_TOGGLE_SHADOWING_ACTIONS: readonly AppKeybinding[] = [ "app.plan.toggle", @@ -1562,18 +1563,52 @@ export class InteractiveMode implements InteractiveModeContext { getComposerBottomOffset: () => this.petFloorContainer.render(this.ui.terminal.columns).length + this.hookWidgetContainerBelow.render(this.ui.terminal.columns).length, - syncManagedItermCursor: (row, column) => - this.#itermPetTransport?.refreshManagedClient(row, column) ?? Promise.resolve(false), + syncManagedItermCursor: (row, column, signal) => + this.#itermPetTransport?.refreshManagedClient(row, column, signal) ?? Promise.resolve(false), }); } rebuildChatFromMessages(policy: TranscriptRebuildPolicy): void { if (!this.#initialTranscriptPainted) return; + const preservedStreamingAssistant = this.#detachStreamingAssistantForRebuild(policy); prepareTranscriptRebuild(this.ui, policy); this.resetAssistantTextPresentation(); this.chatContainer.clear(); const context = this.session.buildDisplaySessionContext(); this.renderSessionContext(context); + this.#restoreStreamingAssistantAfterRebuild(preservedStreamingAssistant); + } + + #detachStreamingAssistantForRebuild( + policy: TranscriptRebuildPolicy, + ): { component: AssistantMessageComponent; message: AssistantMessage } | undefined { + const component = + policy === "reconcile-same-transcript" && + this.streamingComponent && + this.streamingMessage && + this.chatContainer.hasLiveChild(this.streamingComponent) + ? this.streamingComponent + : undefined; + if (!component || !this.streamingMessage) return undefined; + if (getSessionMessageEntryId(this.streamingMessage)) { + this.streamingComponent = undefined; + this.streamingMessage = undefined; + return undefined; + } + // A live provider response is not persisted until message_end. Detach it before + // clear() so same-transcript rebuilds cannot dispose pending text-frame ownership. + this.chatContainer.detachChild(component); + return { component, message: this.streamingMessage }; + } + + #restoreStreamingAssistantAfterRebuild( + preserved: { component: AssistantMessageComponent; message: AssistantMessage } | undefined, + ): void { + if (!preserved) return; + this.streamingComponent = preserved.component; + this.streamingMessage = preserved.message; + addChatChild(this, preserved.component); + this.#eventController.rebindAssistantTextPresentation(); } #sanitizeTodoText(text: string): string { @@ -1817,13 +1852,14 @@ export class InteractiveMode implements InteractiveModeContext { } if (this.isInitialized) { - this.ui.requestRender(true); + const finalRenderGeneration = this.ui.requestRenderWithGeneration(true, "shutdown"); + // A scheduled frame is not necessarily painted: raster ingress may still + // own the terminal queue. Wait for the exact forced generation to commit + // before stop() advances the lifecycle fence, but keep terminal restoration + // bounded if a raster producer is permanently stuck. + await this.ui.waitForRenderCommit(finalRenderGeneration, GRACEFUL_SHUTDOWN_RENDER_COMMIT_TIMEOUT_MS); } - // Wait for any pending renders to complete - // requestRender() uses process.nextTick(), so we wait one tick - await new Promise(resolve => process.nextTick(resolve)); - // Drain any in-flight Kitty key release events before stopping. // This prevents escape sequences from leaking to the parent shell over slow SSH. await this.ui.terminal.drainInput(1000); @@ -2189,12 +2225,14 @@ export class InteractiveMode implements InteractiveModeContext { rebuildInitialMessages( policy: TranscriptRebuildPolicy, - prebuiltContext?: SessionContext, + rebuiltContext?: SessionContext, options?: { preserveExistingChat?: boolean }, ): void { if (!this.#initialTranscriptPainted) return; + const preservedStreamingAssistant = this.#detachStreamingAssistantForRebuild(policy); prepareTranscriptRebuild(this.ui, policy); - this.#uiHelpers.renderInitialMessages(prebuiltContext, options); + this.#uiHelpers.renderInitialMessages(rebuiltContext, options); + this.#restoreStreamingAssistantAfterRebuild(preservedStreamingAssistant); } renderInitialMessages(prebuiltContext?: SessionContext, options?: { preserveExistingChat?: boolean }): void { this.#uiHelpers.renderInitialMessages(prebuiltContext, options); diff --git a/packages/coding-agent/src/prompts/tools/read.md b/packages/coding-agent/src/prompts/tools/read.md index 9ab32fbecd2..720b3e9a0e5 100644 --- a/packages/coding-agent/src/prompts/tools/read.md +++ b/packages/coding-agent/src/prompts/tools/read.md @@ -73,7 +73,7 @@ For `.sqlite`, `.sqlite3`, `.db`, `.db3`: `agent://`, `artifact://`, `rule://`, and `local://.md` resolve transparently and accept the same line selectors as filesystem paths. Use `artifact://` to recover full output that a previous bash/eval/tool result spilled or truncated. -Bundled skills have no filesystem home, so the skill tool and skill discovery report them as `embedded:gjc/skills//SKILL.md`; read that identifier verbatim. Bundled skill fragments, when surfaced, likewise use `embedded:gjc/skill-fragments/...` identifiers. +Bundled skills have no filesystem home, so the skill tool and skill discovery report them as `embedded:gjc/skills//SKILL.md`; read that identifier verbatim. Bundled skill fragments, when surfaced, likewise use `embedded:gjc/skill-fragments/…` identifiers. - Always include `path`; never call `read` with `{}`. diff --git a/packages/coding-agent/src/sdk/broker/managed-task-dag.ts b/packages/coding-agent/src/sdk/broker/managed-task-dag.ts index ffbd2ff245d..583e586961b 100644 --- a/packages/coding-agent/src/sdk/broker/managed-task-dag.ts +++ b/packages/coding-agent/src/sdk/broker/managed-task-dag.ts @@ -1631,47 +1631,48 @@ export async function loadManagedEnrollmentIndex(agentDir: string): Promise { const agent = await fs.realpath(agentDir); const target = managedEnrollmentIndexPath(agent); - // Managed enrollment can only be published on Linux (private durable publication). Elsewhere - // an absent index is the only reachable state; do not let the Linux-only lock turn it into a - // startup failure for brokers that never used task.dag. Only a proven ENOENT beneath real - // directories is absent: any symlink, non-directory ancestor, or non-file leaf fails closed below. - if (process.platform !== "linux" && (await isAbsentBeneathRealDirectories(agent, target))) - return { controlRoots: [], establishedRoots: [], publishingRoots: [], nativeIdentities: [], byRoot: {} }; - // Existing enrollment reads remain cross-process locked on every platform; privateDurable - // is only valid for the Linux publication path. - const lockOptions = - process.platform === "linux" - ? { cwd: agent, privateDurable: { directory: path.dirname(target) } } - : { cwd: agent }; + const enrollmentDirectory = path.dirname(target); + const emptyRecord: ManagedEnrollmentRecord = { + controlRoots: [], + establishedRoots: [], + publishingRoots: [], + nativeIdentities: [], + byRoot: {}, + }; try { - return await withWorkflowStateLock(target, () => loadEnrollmentIndexUnderLock(target), lockOptions); + // Do not acquire a lock for a missing directory: the generic lock helper + // creates its parent, which would poison a later guarded write's mode check. + const gjcStat = await fs.lstat(path.join(agent, ".gjc")); + if (!gjcStat.isDirectory() || gjcStat.isSymbolicLink()) + throw new Error("managed enrollment parent is not a safe directory"); + const enrollmentStat = await fs.lstat(enrollmentDirectory); + if ( + !enrollmentStat.isDirectory() || + enrollmentStat.isSymbolicLink() || + !within(agent, await fs.realpath(enrollmentDirectory)) + ) + throw new Error("managed enrollment directory is not a safe directory"); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") - return { controlRoots: [], establishedRoots: [], publishingRoots: [], nativeIdentities: [], byRoot: {} }; + if ((error as NodeJS.ErrnoException).code === "ENOENT") return emptyRecord; + throw error; + } + try { + // Existing-only locking prevents a concurrent removal after lstat from + // recreating the enrollment directory. Linux also revalidates its private mode. + return await withWorkflowStateLock( + target, + () => loadEnrollmentIndexUnderLock(target), + process.platform === "linux" + ? { cwd: agent, createMissingParents: false, privateDurable: { directory: enrollmentDirectory } } + : { cwd: agent, createMissingParents: false }, + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return emptyRecord; if (error instanceof Error && error.message === "corrupt managed enrollment index") throw error; throw new Error("corrupt managed enrollment index"); } } -/** - * True only when some component of `target` below `root` is missing and every component before it - * is a real directory (not a symlink). lstat never follows the component it inspects, so walking - * one component at a time keeps a symlinked ancestor from masking a present or corrupt namespace. - */ -async function isAbsentBeneathRealDirectories(root: string, target: string): Promise { - let current = root; - for (const part of path.relative(root, target).split(path.sep)) { - current = path.join(current, part); - try { - const stat = await fs.lstat(current); - if (current === target || !stat.isDirectory()) return false; - } catch (error) { - return (error as NodeJS.ErrnoException).code === "ENOENT"; - } - } - return false; -} - export async function recordManagedEnrollment( agentDir: string, controlRoot: string, diff --git a/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json b/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json index 0a2fbb86b29..eab13b28d89 100644 --- a/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json +++ b/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json @@ -2958,99 +2958,99 @@ } }, { - "sourceId": "agent_session:setCoordinatorRuntimeStateFileForTests", + "sourceId": "agent_session:trackPostPromptTaskForTests", "sourceFile": "packages/coding-agent/src/session/agent-session.ts", "sourceKind": "agent_session", "decision": "exclude", - "rationale": "test-only coordinator runtime state file setup seam, not a user-facing SDK control seam", + "rationale": "test-only post-prompt task seam, not a user-facing SDK control seam", "exclusionMetadata": { "adapterMappings": "not_applicable", "testIds": "not_applicable" } }, { - "sourceId": "agent_session:getCoordinatorRuntimeStateFileForTests", + "sourceId": "agent_session:requestWorkerIntegrationForTests", "sourceFile": "packages/coding-agent/src/session/agent-session.ts", "sourceKind": "agent_session", "decision": "exclude", - "rationale": "read-only test-only coordinator runtime state file accessor, not a user-facing SDK control seam", + "rationale": "test-only worker integration seam, not a user-facing SDK control seam", "exclusionMetadata": { "adapterMappings": "not_applicable", "testIds": "not_applicable" } }, { - "sourceId": "agent_session:trackPostPromptTaskForTests", + "sourceId": "agent_session:closeWriterStrict", "sourceFile": "packages/coding-agent/src/session/agent-session.ts", "sourceKind": "agent_session", "decision": "exclude", - "rationale": "test-only post-prompt task seam, not a user-facing SDK control seam", + "rationale": "internal ACP lifecycle teardown plumbing, not a user-facing control seam", "exclusionMetadata": { "adapterMappings": "not_applicable", "testIds": "not_applicable" } }, { - "sourceId": "agent_session:requestWorkerIntegrationForTests", + "sourceId": "agent_session:disposeChildSubprocesses", "sourceFile": "packages/coding-agent/src/session/agent-session.ts", "sourceKind": "agent_session", "decision": "exclude", - "rationale": "test-only worker integration seam, not a user-facing SDK control seam", + "rationale": "internal accessor/plumbing, not a user-facing control seam", "exclusionMetadata": { "adapterMappings": "not_applicable", "testIds": "not_applicable" } }, { - "sourceId": "agent_session:closeWriterStrict", + "sourceId": "agent_session:waitForIdle", "sourceFile": "packages/coding-agent/src/session/agent-session.ts", "sourceKind": "agent_session", "decision": "exclude", - "rationale": "internal ACP lifecycle teardown plumbing, not a user-facing control seam", + "rationale": "internal accessor/plumbing, not a user-facing control seam", "exclusionMetadata": { "adapterMappings": "not_applicable", "testIds": "not_applicable" } }, { - "sourceId": "agent_session:disposeChildSubprocesses", + "sourceId": "agent_session:awaitSessionSettlement", "sourceFile": "packages/coding-agent/src/session/agent-session.ts", "sourceKind": "agent_session", "decision": "exclude", - "rationale": "internal accessor/plumbing, not a user-facing control seam", + "rationale": "internal agent-event settlement barrier, not a user-facing SDK control seam", "exclusionMetadata": { "adapterMappings": "not_applicable", "testIds": "not_applicable" } }, { - "sourceId": "agent_session:waitForIdle", + "sourceId": "agent_session:runWithPromptAdmissionForTests", "sourceFile": "packages/coding-agent/src/session/agent-session.ts", "sourceKind": "agent_session", "decision": "exclude", - "rationale": "internal accessor/plumbing, not a user-facing control seam", + "rationale": "test-only prompt admission seam, not a user-facing SDK control seam", "exclusionMetadata": { "adapterMappings": "not_applicable", "testIds": "not_applicable" } }, { - "sourceId": "agent_session:awaitSessionSettlement", + "sourceId": "agent_session:setCoordinatorRuntimeStateFileForTests", "sourceFile": "packages/coding-agent/src/session/agent-session.ts", "sourceKind": "agent_session", "decision": "exclude", - "rationale": "internal agent-event settlement barrier, not a user-facing SDK control seam", + "rationale": "test-only coordinator runtime state file setup seam, not a user-facing SDK control seam", "exclusionMetadata": { "adapterMappings": "not_applicable", "testIds": "not_applicable" } }, { - "sourceId": "agent_session:runWithPromptAdmissionForTests", + "sourceId": "agent_session:getCoordinatorRuntimeStateFileForTests", "sourceFile": "packages/coding-agent/src/session/agent-session.ts", "sourceKind": "agent_session", "decision": "exclude", - "rationale": "test-only prompt admission seam, not a user-facing SDK control seam", + "rationale": "read-only test-only coordinator runtime state file accessor, not a user-facing SDK control seam", "exclusionMetadata": { "adapterMappings": "not_applicable", "testIds": "not_applicable" diff --git a/packages/coding-agent/src/sdk/session.ts b/packages/coding-agent/src/sdk/session.ts index 1dd6a428286..6338a5845ca 100644 --- a/packages/coding-agent/src/sdk/session.ts +++ b/packages/coding-agent/src/sdk/session.ts @@ -271,6 +271,23 @@ type McpNotificationEntry = { uri: string; }; +function settleOwnedAsyncResultEntry(entry: AsyncResultEntry): void { + const registration = entry.ownedCompletion?.registration; + if (!registration) return; + // The registration's endpoint is immutable; do not consult the process-global + // manager, which may belong to a concurrent session with the same job id. + const manager = AsyncJobManager.forEndpoint(registration.endpointId); + const job = manager?.getJob(registration.jobId); + if ( + job?.generation === registration.jobGeneration && + job.status !== "completed" && + job.status !== "cancelled" && + job.status !== "failed" + ) + return; + unregisterOwnedRegistration(registration); +} + /** Capture the cursor edit grant before the model-facing edit entry is removed. */ export function captureCursorEditTool( toolRegistry: ReadonlyMap, @@ -5530,9 +5547,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const sessionAsyncJobManager = asyncJobManager; if (sessionAsyncJobManager) { session.yieldQueue.register("async-result", { + onDrop: entry => { + sessionAsyncJobManager.releaseDeliveryClaim(entry.generation); + settleOwnedAsyncResultEntry(entry); + }, + // YieldQueue calls this only after streaming/idle injection succeeds; + // admission retries therefore retain the claim with the queued entry. + onDelivered: entry => sessionAsyncJobManager.releaseDeliveryClaim(entry.generation), isStale: entry => { const stale = sessionAsyncJobManager.isDeliverySuppressed(entry.jobId, entry.generation); - if (stale) sessionAsyncJobManager.releaseDeliveryClaim(entry.generation); + if (stale) { + sessionAsyncJobManager.releaseDeliveryClaim(entry.generation); + settleOwnedAsyncResultEntry(entry); + } return stale; }, // Build one message per ownership origin so an owned-scope drop of @@ -5543,15 +5570,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} ? `${entry.ownedCompletion.lineageIdHash}\u0000${entry.ownedCompletion.promptAttemptEpoch}` : "ordinary", build: entries => { - try { - return buildAsyncResultBatchMessage(entries); - } finally { - for (const entry of entries) sessionAsyncJobManager.releaseDeliveryClaim(entry.generation); - } + return buildAsyncResultBatchMessage(entries); }, }); } session.yieldQueue.register("mcp-notification", { + preserveAcrossIdentity: true, build: buildMcpNotificationBatchMessage, }); diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 4fdffcf8b0a..b95c23ca595 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -32,6 +32,7 @@ import { type AgentState, type AgentTerminalOwnerContext, type AgentTool, + type AgentToolCall, assertImagePlaceholdersHavePayload, type ContextMaintenanceResult, canContinuePersistedHistory, @@ -380,7 +381,6 @@ import type { HindsightSessionState } from "../hindsight/state"; import { buildSkillStopOutput, ensureWorkflowSkillActivationSeed, - ensureWorkflowSkillActivationState, type WorkflowSkillActivationSeed, } from "../hooks/skill-state"; import { initializeLocalRoot, type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls"; @@ -417,12 +417,11 @@ import type { NotificationSessionController } from "../sdk/bus/session-control"; import { buildSyntheticModelId, syntheticNamespaceCollision } from "../sdk/model-profile-model"; import { sanitizePromptFailure } from "../sdk/prompt-failure"; import type { SecretObfuscator } from "../secrets/obfuscator"; -import { formatNoCredentialOnboardingError, NoModelSelectedError } from "../setup/model-onboarding-guidance"; +import { formatNoCredentialOnboardingError, formatNoModelOnboardingError, NoModelSelectedError } from "../setup/model-onboarding-guidance"; import { isCanonicalGjcWorkflowSkill, isWorkflowContinuationInert, readVisibleSkillActiveState, - syncSkillActiveState, } from "../skill-state/active-state"; import { assertWorkflowMutationAllowed, isWorkflowMutationTool } from "../skill-state/workflow-mutation-guard"; import { invalidateHostMetadata } from "../ssh/connection-manager"; @@ -553,6 +552,7 @@ import { classifyOwnedEnvelope, isOwnedCompletionEnvelope, isOwnedCompletionEnvelopeAllowed, + type LineageBinding, lookupOwnedRegistration, lookupTerminalScope, mintTurnLineageIdHash, @@ -824,8 +824,24 @@ class ToolOutputPruneRollbackError extends Error { /** Session-specific events that extend the core AgentEvent */ export type AutoCompactionContinuationSkipReason = "auto_continue_disabled_non_resumable_tail"; +type AgentEndSessionEvent = Extract & { + /** Immutable abort-display classification captured before asynchronous UI dispatch. */ + readonly silentAbort?: boolean; + readonly ttsrAbort?: boolean; + readonly terminalPersistenceFailed?: boolean; + readonly terminalProjectionMatched?: boolean; +}; + +type MessageEndSessionEvent = Extract & { + readonly silentAbort?: boolean; + readonly ttsrAbort?: boolean; + readonly terminalPersistenceFailed?: boolean; +}; + export type AgentSessionEvent = - | AgentEvent + | Exclude + | AgentEndSessionEvent + | MessageEndSessionEvent | { type: "auto_compaction_start"; reason: "threshold" | "overflow" | "idle"; action: "context-full" | "handoff" } | { type: "auto_compaction_end"; @@ -1245,7 +1261,6 @@ type SendUserMessageDispatchOptions = SendUserMessageOptions & { trackSubmission?: boolean; expectedSdkRunToken?: string; }; - type InternalPromptOptions = PromptOptions & { sdkRunToken?: string }; interface SdkContinuationOwnership { scope: AttemptScope; @@ -2605,6 +2620,10 @@ function exactMcpTransportOf(entry: unknown): ExactMcpTransport { if (typeof record.url === "string") return "http"; return "unknown"; } +type SessionSelectionIdentity = { + readonly sessionId: string; + readonly sessionIdentityEpoch: number; +}; class SessionRunCancellationDomainBridge implements RunCancellationDomainBridge { #domains = new Map(); #released = new Set(); @@ -2725,11 +2744,14 @@ function deobfuscateSessionContext(context: SessionContext, obfuscator: SecretOb interface CanonicalMessageAdmissionSlot { promise: Promise; released: boolean; + error?: unknown; } interface CanonicalMessageAdmission { predecessor: CanonicalMessageAdmissionSlot; + slot: CanonicalMessageAdmissionSlot; release: () => void; + fail: (error: unknown) => void; } type CoordinatorRuntimeStatePersistContext = { @@ -3111,8 +3133,16 @@ export class AgentSession { } #settleTrackedQueuedInputTerminal(scope: AttemptScope | undefined): void { - if (scope === undefined) return; - const logicalRunId = this.#logicalRunIdByAttemptScope.get(scope); + // Identity transitions own the old queued work and terminalize it at their + // explicit pre-disconnect boundary; an abort terminal inside that window is + // not successful completion for tracked inputs that will be removed/rearmed. + if (scope === undefined || this.#sessionTransitionKind !== undefined) return; + const scopeKey = this.#attemptScopeKey(scope); + const activeLogicalRunId = + this.#activeAttemptScope !== undefined && this.#attemptScopeKey(this.#activeAttemptScope) === scopeKey + ? this.#activeLogicalRunId + : undefined; + const logicalRunId = this.#logicalRunIdByAttemptScope.get(scope) ?? activeLogicalRunId; const states = (logicalRunId === undefined ? undefined : this.#trackedQueuedInputsByLogicalRunId.get(logicalRunId)) ?? this.#trackedQueuedInputsByAttemptScope.get(scope); @@ -3121,11 +3151,13 @@ export class AgentSession { if (state.terminalSettled) continue; state.terminalSettled = true; this.#clearTrackedQueuedInputQueueOwnership(state); + // A logical run can rotate attempt scopes before its terminal event; + // keep the receipt bound to the attempt that consumed this submission. state.terminal.resolve({ submissionId: state.submission.submissionId, delivery: state.delivery, disposition: "completed", - attemptScope: this.#scopeRef(scope), + attemptScope: this.#scopeRef(state.attemptScope ?? scope), }); this.#forgetTrackedQueuedInputOwnership(state); this.#trackedQueuedInputs.delete(state.submission.submissionId); @@ -3585,10 +3617,20 @@ export class AgentSession { get #hasCleanRetryReplaySafety(): boolean { return this.#retryReplayEpoch > 0 && this.#retryReplayUnsafeEpoch !== this.#retryReplayEpoch; } - #bindAttemptScope(scope: AttemptScope | undefined): void { + #bindAttemptScope(scope: AttemptScope | undefined, active = true): void { if (!scope) return; + if (!active) { + this.#currentSessionIdentityAttemptScopeKeys.delete(this.#attemptScopeKey(scope)); + return; + } + this.#currentSessionIdentityAttemptScopeKeys.add(this.#attemptScopeKey(scope)); this.#attemptRecordStore.register(scope); this.#attemptRecordStore.establishClean(scope); + if (scope.lineage === "main" && this.#activeLogicalRunId !== undefined) { + this.#activeAttemptScope = scope; + if (this.#activeSdkRunToken !== undefined) + this.#sdkRunTokensByAttemptScope.set(scope, this.#activeSdkRunToken); + } } #isRetryScopeClean(scope: AttemptScope | undefined): boolean { return scope !== undefined && this.#attemptRecordStore.isClean(scope); @@ -3669,6 +3711,8 @@ export class AgentSession { message: BashExecutionMessage; onPersisted?: () => void; appendedToAgent: boolean; + sessionId: string; + sessionIdentityEpoch: number; }> = []; /** * Set by a fold and consumed once by the pause checkpoint, so a fold ends its @@ -3799,13 +3843,19 @@ export class AgentSession { message: PythonExecutionMessage; onPersisted?: () => void; appendedToAgent: boolean; + sessionId: string; + sessionIdentityEpoch: number; }> = []; #activeEvalExecutions = new Set>(); #evalExecutionDisposing = false; // Background-channel IRC exchanges queued while the recipient was streaming. // Drained into history (via emitExternalEvent) once the recipient becomes idle. - #pendingBackgroundExchanges: CustomMessage[][] = []; + #pendingBackgroundExchanges: Array<{ + messages: CustomMessage[]; + sessionId: string; + sessionIdentityEpoch: number; + }> = []; #scheduledBackgroundExchangeFlush = false; // Agent identity + registry for IRC relay forwarding to the main session UI. #agentId: string | undefined; @@ -3878,6 +3928,8 @@ export class AgentSession { #skillsSettings: SkillsSettings | undefined; #activeSkillState: { skill: string; sessionId?: string } | undefined; #restoredWorkflowSkillState: { skill: string; sessionId: string } | undefined; + readonly #skillStateSynchronizations = new Set>(); + #subskillToolRefreshAfterTransition = false; // Model registry for API key resolution #modelRegistry: ModelRegistry; @@ -3947,6 +3999,7 @@ export class AgentSession { #discoverableToolAllowedNames: ReadonlySet | undefined; #gjcSubskillToolNames = new Set(); #gjcSubskillToolSignature: string | undefined; + #gjcSubskillToolRefreshGeneration = 0; #defaultSelectedMCPServerNames = new Set(); #defaultSelectedMCPToolNames = new Set(); #mandatoryMCPToolNames = new Set(); @@ -3965,6 +4018,10 @@ export class AgentSession { * These are folded into the matched tool call's `toolResult` content as an * in-band system reminder, instead of spawning a separate follow-up turn. */ #perToolTtsrInjections = new Map(); + #toolLifecycleOwnership = new WeakMap< + AgentToolCall, + { endpointId: string; binding?: LineageBinding; rules?: Rule[] } + >(); #ttsrAbortPending = false; #ttsrRetryToken = 0; #ttsrResumePromise: Promise | undefined = undefined; @@ -4176,6 +4233,26 @@ export class AgentSession { }); } + #captureSessionSelectionIdentity(): SessionSelectionIdentity { + return { sessionId: this.sessionId, sessionIdentityEpoch: this.#sessionIdentityEpoch }; + } + + #isSessionSelectionIdentityCurrent(identity: SessionSelectionIdentity): boolean { + return identity.sessionId === this.sessionId && identity.sessionIdentityEpoch === this.#sessionIdentityEpoch; + } + + #assertSessionSelectionIdentityCurrent(identity: SessionSelectionIdentity): void { + if (!this.#isSessionSelectionIdentityCurrent(identity)) { + throw new Error("Session changed while selecting model"); + } + } + + #assertSelectionMutationReady(identity: SessionSelectionIdentity): void { + this.#assertNoSessionTransition(); + this.#assertSessionSelectionIdentityCurrent(identity); + this.#assertTerminalPersistenceSettledForHistoryMutation(); + } + #assertSessionAdmissionOpen(): void { if (this.#sessionAdmissionClosing || this.#sessionAdmissionClosed || this.#isDisposed) { throw this.#sessionAdmissionBusyError(); @@ -4224,6 +4301,61 @@ export class AgentSession { } } + #assertNoSessionTransition(): void { + if (this.#sessionTransitionKind === undefined) return; + throw Object.assign(new AgentBusyError("Cannot start a turn while a session transition is in progress."), { + code: "busy", + }); + } + + #assertTransitionIngressAllowed(): void { + if (this.#sessionTransitionKind === undefined) return; + const capability = this.#postCommitTransitionIngress.getStore(); + if ( + capability?.epoch === this.#sessionIdentityEpoch && + this.#activePostCommitTransitionIngressTokens.has(capability.token) + ) + return; + this.#assertNoSessionTransition(); + } + + #isSessionSelectionIdentityAdmitted(identity: SessionSelectionIdentity): boolean { + if (this.#isDisposed) return false; + if (!this.#isSessionSelectionIdentityCurrent(identity)) return false; + if (this.#sessionTransitionKind === undefined) return true; + const capability = this.#postCommitTransitionIngress.getStore(); + return ( + capability?.epoch === this.#sessionIdentityEpoch && + this.#activePostCommitTransitionIngressTokens.has(capability.token) + ); + } + + async #withPostCommitTransitionIngress(run: () => Promise): Promise { + const capability = { epoch: this.#sessionIdentityEpoch, token: Symbol("post-commit-transition-ingress") }; + this.#activePostCommitTransitionIngressTokens.add(capability.token); + try { + return await this.#postCommitTransitionIngress.run(capability, run); + } finally { + this.#activePostCommitTransitionIngressTokens.delete(capability.token); + } + } + + async #withActiveCompactionHook(run: () => Promise): Promise { + const hookToken = Symbol("compaction-hook"); + this.#activeCompactionHookTokens.add(hookToken); + try { + return await this.#compactionHookContext.run(hookToken, run); + } finally { + this.#activeCompactionHookTokens.delete(hookToken); + } + } + + async #awaitSessionTransitionDisposition(expectedIdentityEpoch: number): Promise { + const settlement = this.#sessionTransitionSettlement; + if (settlement) await settlement.promise; + return this.#sessionIdentityEpoch === expectedIdentityEpoch; + } + /** * Single, synchronously-acquired mutex for session-identity transitions * (handoff, manual/automatic compact, new/switch/branch/clear, fork, tree navigation). Acquired @@ -4235,6 +4367,11 @@ export class AgentSession { * exposes an unowned history rewrite. */ #sessionTransitionKind: string | undefined; + #sessionTransitionSettlement: PromiseWithResolvers | undefined; + #postCommitTransitionIngress = new AsyncLocalStorage<{ epoch: number; token: symbol }>(); + #activePostCommitTransitionIngressTokens = new Set(); + #compactionHookContext = new AsyncLocalStorage(); + #activeCompactionHookTokens = new Set(); #queuedDeliveryPendingWhileTransition = false; #deferredAutoContinueDuringTransition: (() => void) | undefined; #coordinatorPersistGeneration = 0; @@ -4304,6 +4441,17 @@ export class AgentSession { if (this.#cancelAndSubmitInProgress) { throw Object.assign(new AgentBusyError(`Cannot start ${kind} during cancel-and-submit.`), { code: "busy" }); } + if (this.#turnEndPersistenceFailure) { + throw Object.assign(new Error("Reconcile repeat-state persistence before changing session history."), { + code: "session_persistence_blocked", + }); + } + if (this.#terminalPersistenceRecovery) { + throw Object.assign(new Error("Reconcile terminal persistence before changing session history."), { + code: "session_persistence_blocked", + }); + } + if (this.#activeSessionAdmission?.kind === "selection") throw this.#sessionAdmissionBusyError(); if (this.#sessionTransitionKind !== undefined) { throw Object.assign( new AgentBusyError( @@ -4312,7 +4460,13 @@ export class AgentSession { { code: "busy" }, ); } + this.#sessionTransitionSettlement = Promise.withResolvers(); this.#sessionTransitionKind = kind; + if (kind !== "auto-compaction") { + this.#promptPreflightCancellationGeneration++; + this.#promptPreflightAbortController.abort(); + this.#promptPreflightAbortController = new AbortController(); + } this.#coordinatorPersistGeneration += 1; this.#wakeFollowUpReservationTransitionWaiters(); } @@ -4336,7 +4490,31 @@ export class AgentSession { } #endSessionTransition(): void { + this.#externalIngressSealed = false; this.#sessionTransitionKind = undefined; + this.#sessionTransitionSettlement?.resolve(); + this.#sessionTransitionSettlement = undefined; + this.yieldQueue.rearmIdle(); + const flushErrors: unknown[] = []; + try { + this.#flushPendingBashMessages(); + } catch (error) { + flushErrors.push(error); + } + try { + this.#flushPendingPythonMessages(); + } catch (error) { + flushErrors.push(error); + } + try { + this.#flushOrSchedulePendingBackgroundExchanges(); + } catch (error) { + flushErrors.push(error); + } + if (this.#subskillToolRefreshAfterTransition) { + this.#subskillToolRefreshAfterTransition = false; + this.#requestSubskillToolReconciliation(); + } const deferredAutoContinue = this.#deferredAutoContinueDuringTransition; this.#deferredAutoContinueDuringTransition = undefined; if (!this.#isDisposed && !this.#sessionAdmissionClosing) deferredAutoContinue?.(); @@ -4346,6 +4524,30 @@ export class AgentSession { this.#scheduleQueuedDelivery(); } } + if (flushErrors.length > 0) + throw new AggregateError(flushErrors, "Deferred session work failed after transition."); + } + + #requestSubskillToolReconciliation(): void { + if (this.#sessionTransitionKind !== undefined) { + this.#subskillToolRefreshAfterTransition = true; + return; + } + if (this.#subskillToolRefreshAfterTransition) return; + this.#subskillToolRefreshAfterTransition = true; + const identity = this.#captureSessionSelectionIdentity(); + queueMicrotask(() => { + this.#subskillToolRefreshAfterTransition = false; + if (this.#sessionTransitionKind !== undefined || !this.#isSessionSelectionIdentityCurrent(identity)) { + this.#requestSubskillToolReconciliation(); + return; + } + void this.refreshGjcSubskillTools(identity).catch(error => { + logger.warn("Failed to reconcile subskill tools after session transition", { + error: error instanceof Error ? error.message : String(error), + }); + }); + }); } #runCommittedSuccessorHook(body: () => Promise): Promise { @@ -4479,12 +4681,27 @@ export class AgentSession { }, ): Promise { const owner = this.#sessionAdmissionContext.getStore(); + const allowCausalSelectionReentry = kind === "selection" && options?.allowPromptContinuationReentry === true; + if (kind === "prompt" && this.#sessionTransitionKind !== undefined) this.#assertTransitionIngressAllowed(); + if (kind === "selection" && this.#sessionTransitionKind !== undefined) this.#assertNoSessionTransition(); if (owner && !owner.released) { if ( continuationAdmission?.entry === owner && continuationAdmission.capability === owner.continuationCapability - ) + ) { + if (kind === "prompt") await this.#reconcileTerminalPersistenceFailure(); + return await body({ release: () => {} }); + } + if (kind === "selection" && (owner.kind === "selection" || owner.kind === "prompt")) { + // Nested selection is causal work owned by the current admission. Do + // not await agent-end reconciliation here: context promotion is often + // reached from the agent-end handler itself, so that await would wait + // on the handler that is currently waiting for this selection. The + // synchronous fence below still rejects an active terminal recovery; + // the outer admission already reconciled before entering this owner. + this.#assertTerminalPersistenceSettledForHistoryMutation(); return await body({ release: () => {} }); + } if (options?.allowPromptContinuationReentry === true && owner.kind === "prompt") { return await body({ release: () => {} }); } @@ -4529,10 +4746,11 @@ export class AgentSession { // injects via appendCustomMessageEntry), so this fences external entrants — // prompt/sendUserMessage/steer/follow-up/triggerTurn all funnel here — without // blocking the handoff's own work or the exempt auto-maintenance owner. - if (kind === "prompt" && this.#handoffTransitionActive) { - throw Object.assign(new AgentBusyError("Cannot start a turn while a handoff is in progress."), { - code: "busy", - }); + if (kind === "prompt" && this.#sessionTransitionKind !== undefined) { + this.#assertTransitionIngressAllowed(); + } + if (kind === "selection" && this.#sessionTransitionKind !== undefined) { + this.#assertNoSessionTransition(); } if (kind === "prompt" && this.#sessionTransitionKind !== undefined) { throw Object.assign( @@ -4578,10 +4796,11 @@ export class AgentSession { } // Re-check the handoff fence after activation: a prompt queued before the // transition began must not start once the fence is up. - if (kind === "prompt" && this.#handoffTransitionActive) { - throw Object.assign(new AgentBusyError("Cannot start a turn while a handoff is in progress."), { - code: "busy", - }); + if (kind === "prompt" && this.#sessionTransitionKind !== undefined) { + this.#assertTransitionIngressAllowed(); + } + if (kind === "selection" && this.#sessionTransitionKind !== undefined) { + this.#assertNoSessionTransition(); } if (kind === "prompt" && this.#sessionTransitionKind !== undefined) { throw Object.assign( @@ -4589,6 +4808,7 @@ export class AgentSession { { code: "busy" }, ); } + if (!allowCausalSelectionReentry) await this.#reconcileTerminalPersistenceFailure(); const release = () => { releaseEntry(); @@ -4599,6 +4819,32 @@ export class AgentSession { } } + async #withSelectionAdmission( + identity: SessionSelectionIdentity, + body: (lease: SessionAdmissionLease) => Promise, + signal?: AbortSignal, + options?: { + allowDuringClosing?: boolean; + closedSelectionTransaction?: symbol; + selectionTransaction?: symbol; + onAfterReadyForTests?: () => Promise; + allowPromptContinuationReentry?: boolean; + }, + ): Promise { + this.#assertNoSessionTransition(); + this.#assertSessionSelectionIdentityCurrent(identity); + return this.#withSessionAdmission( + "selection", + async lease => { + this.#assertSelectionMutationReady(identity); + return body(lease); + }, + signal, + undefined, + options, + ); + } + async #closeSessionAdmission(options?: { waitForActive?: boolean }): Promise { this.#sessionAdmissionClosing = true; const active = this.#activeSessionAdmission; @@ -5568,7 +5814,8 @@ export class AgentSession { if (this.#extensionRunner && typeof this.#extensionRunner.setAttemptRecordStore === "function") { this.#extensionRunner.setAttemptRecordStore(this.#attemptRecordStore); } - this.agent.setMainAttemptScopeObserver(scope => this.#bindAttemptScope(scope)); + this.agent.setMainAttemptScopeObserver((scope, active) => this.#bindAttemptScope(scope, active)); + this.agent.setExternalEventAdmissionFence(event => this.#admitExternalAgentEvent(event)); this.#skills = config.skills ?? []; this.#skillWarnings = config.skillWarnings ?? []; this.#reloadSkills = config.reloadSkills; @@ -5662,6 +5909,12 @@ export class AgentSession { this.yieldQueue = new YieldQueue({ isStreaming: () => this.isStreaming || this.#sessionTransitionKind !== undefined || this.#handoffTransitionActive, + captureIdentity: () => ({ sessionId: this.sessionId, sessionIdentityEpoch: this.#sessionIdentityEpoch }), + isIdentityCurrent: identity => { + if (!identity || typeof identity !== "object") return false; + const token = identity as { sessionId?: unknown; sessionIdentityEpoch?: unknown }; + return token.sessionId === this.sessionId && token.sessionIdentityEpoch === this.#sessionIdentityEpoch; + }, injectStreaming: message => { // Mandated boundary comment (corrected turn semantics): turn-scope // abort blocks only deliveries whose origin is a continuation of the @@ -5705,7 +5958,8 @@ export class AgentSession { logger.warn("Owned streaming follow-up was rejected", { error: String(error) }); }); }, - injectIdle: async (messages, signal) => { + injectIdle: async (messages, signal, identityIsCurrent = () => true) => { + if (messages.length === 0) return "delivered" as const; const sdkRunToken = this.#activeSdkRunToken; // Mandated boundary comment (corrected turn semantics): same origin // split as the streaming injector — an allowed owned-completion @@ -5719,7 +5973,7 @@ export class AgentSession { // must not occupy the bounded registry (review thread P2). if (dropped.length > 0) this.#settleDeliveredOwnedRegistrations(dropped); const first = survivors[0]; - if (!first) return; + if (!first) return "dropped" as const; const settleIfDisposing = (): boolean => { if (!this.#isDisposed && !this.#sessionAdmissionClosing && !this.#disposeAbortController.signal.aborted) return false; @@ -5727,17 +5981,38 @@ export class AgentSession { return true; }; try { - await this.#withSessionAdmission( + const result = await this.#withSessionAdmission( "prompt", async () => { - if (settleIfDisposing()) return; + if (settleIfDisposing()) return "dropped" as const; if (this.isStreaming) { await awaitPromptInvocationPreflight(this.agent.waitForIdle(), signal); - if (settleIfDisposing()) return; + if (settleIfDisposing()) return "dropped" as const; + } + if (!identityIsCurrent()) { + this.#settleDeliveredOwnedRegistrations(survivors); + return "dropped" as const; + } + if (this.#turnEndPersistenceFailure) { + await this.#reconcileTurnEndPersistenceFailure(); + if (!identityIsCurrent()) { + this.#settleDeliveredOwnedRegistrations(survivors); + return "dropped" as const; + } + } + this.#assertNoSessionTransition(); + if (!identityIsCurrent()) { + this.#settleDeliveredOwnedRegistrations(survivors); + return "dropped" as const; } if (survivors.some(message => ownedCompletionResumeAction(message) === "fresh")) this.#resumeFromOwnedCompletion(); if (survivors.length === 1) { + this.#assertNoSessionTransition(); + if (!identityIsCurrent()) { + this.#settleDeliveredOwnedRegistrations(survivors); + return "dropped" as const; + } await this.agent.prompt(first, { ...this.#managedFallbackPromptOptions(), onRunAccepted: (handle: AttemptRunHandle) => { @@ -5745,6 +6020,11 @@ export class AgentSession { }, }); } else { + this.#assertNoSessionTransition(); + if (!identityIsCurrent()) { + this.#settleDeliveredOwnedRegistrations(survivors); + return "dropped" as const; + } await this.agent.prompt(survivors, { ...this.#managedFallbackPromptOptions(), onRunAccepted: (handle: AttemptRunHandle) => { @@ -5752,27 +6032,34 @@ export class AgentSession { }, }); } + return "delivered" as const; }, signal, undefined, { idleDelivery: true }, ); - } finally { - // The owned completions were delivered OR the prompt attempt - // failed (e.g. provider rejection): either way the yield - // queue already drained the entries, so this is their only - // delivery boundary — settle the registrations even on - // failure, otherwise repeated failed idle resumptions leak - // terminal tuples into the global registries until capacity - // is exhausted (review thread P2). + if (result === "dropped") return result; + this.#settleDeliveredOwnedRegistrations(survivors); + return "delivered" as const; + } catch (error) { + if (!identityIsCurrent()) { + this.#settleDeliveredOwnedRegistrations(survivors); + return "dropped" as const; + } + // Only transition/admission busy failures are retryable. Once + // prompt reaches the provider boundary, or rejects for any other + // reason, the drained delivery is terminal and must settle now. + if (["busy", "session_persistence_blocked"].includes(String((error as { code?: unknown })?.code))) + throw error; this.#settleDeliveredOwnedRegistrations(survivors); + return "dropped" as const; } }, - scheduleIdleFlush: (run, onSkip) => { + scheduleIdleFlush: (run, onSkip, requestedDelayMs) => { // The startup barrier already gates injectIdle, so begin waiting on a // pending barrier immediately. Once readiness has settled, ordinary // idle wakes retain the fixed merge window. - const delayMs = this.#startupTurnBarrierPending ? 0 : FOLD_WAKE_MERGE_WINDOW_MS; + const delayMs = requestedDelayMs ?? (this.#startupTurnBarrierPending ? 0 : FOLD_WAKE_MERGE_WINDOW_MS); this.#schedulePostPromptTask( async signal => { await run(signal); @@ -5783,6 +6070,15 @@ export class AgentSession { getIdleFlushSignal: () => this.#postPromptTasksAbortController.signal, }); this.agent.setOnBeforeYield(() => this.yieldQueue.flush("streaming")); + const configuredAfterTurnEndPublished = this.agent.afterTurnEndPublished; + this.agent.afterTurnEndPublished = async () => { + const admission = this.#canonicalMessageAdmissionTail; + if (!admission.released) await admission.promise; + if (admission.error !== undefined) throw admission.error; + // The canonical append is visible before a caller's observer runs. Do not + // suppress that observer or swallow its rejection: both hooks gate the next turn. + await configuredAfterTurnEndPublished?.call(this.agent); + }; // Stop-after-result, never abort: a fold arms this once and the loop ends the // turn at its next checkpoint. Consuming the flag here keeps the pause scoped // to the folded turn instead of pausing everything that follows. The @@ -5896,8 +6192,23 @@ export class AgentSession { }); // Per-tool TTSR reminders are folded into the matched tool's result via this hook. this.agent.afterToolCall = (ctx, signal) => { - settleToolLineageRegistrationWindow(ctx.toolCall.id, this.#ownedRegistrationEndpoint()); - const ttsrResult = this.#ttsrAfterToolCall(ctx); + const ownership = this.#toolLifecycleOwnership.get(ctx.toolCall); + if (!ownership) { + this.#perToolTtsrInjections.delete(ctx.toolCall.id); + return undefined; + } + settleToolLineageRegistrationWindow(ctx.toolCall.id, ownership.endpointId, ownership.binding); + if ( + ctx.result.details && + typeof ctx.result.details === "object" && + typeof (ctx.result.details as { cancellation?: unknown }).cancellation === "string" + ) { + if (this.#perToolTtsrInjections.get(ctx.toolCall.id) === ownership?.rules) { + this.#perToolTtsrInjections.delete(ctx.toolCall.id); + } + return undefined; + } + const ttsrResult = this.#ttsrAfterToolCall(ctx, ownership?.rules); const delegationHintEnabled = this.settings.get("task.delegationHint.mode") === "hint"; this.#delegationHint.setEnabled(delegationHintEnabled); if (delegationHintEnabled) { @@ -5942,10 +6253,24 @@ export class AgentSession { // intentionally survive the tool call: resumed registrations re-use the // original tool call id and must retain the same owned-completion origin. // They are superseded by a rebind on the same id or by bounded eviction. - this.agent.beforeToolCall = ctx => { + this.agent.beforeToolCall = async (ctx, signal) => { + const canonicalAdmission = this.#canonicalMessageAdmissionTail; + if (!canonicalAdmission.released) await canonicalAdmission.promise; + if (canonicalAdmission.error !== undefined) throw canonicalAdmission.error; + if (signal?.aborted) { + return { block: true, reason: "Tool call was cancelled before dispatch." }; + } + if (this.#terminalPersistenceRecovery) { + return { block: true, reason: "Assistant output was not committed to session history." }; + } + if (this.#sessionTransitionKind !== undefined) { + return { block: true, reason: "Session transition is in progress." }; + } const lineageIdHash = this.#turnLineageIdHash; + const endpointId = this.#ownedRegistrationEndpoint(); + let binding: LineageBinding | undefined; if (lineageIdHash) { - bindToolLineage(ctx.toolCall.id, { + binding = bindToolLineage(ctx.toolCall.id, { lineageIdHash, promptAttemptEpoch: this.#promptGeneration, endpointGeneration: this.#terminalEndpointGeneration, @@ -5957,9 +6282,14 @@ export class AgentSession { // sessionManager id is never registered, and the inherited // manager's completion callback resolves registrations via // AsyncJobManager.endpointIdOf(manager) (review thread P1). - endpointId: this.#ownedRegistrationEndpoint(), + endpointId, }); } + this.#toolLifecycleOwnership.set(ctx.toolCall, { + endpointId, + binding, + rules: this.#perToolTtsrInjections.get(ctx.toolCall.id), + }); return undefined; }; // A queued owned-completion follow-up is consumed by the agent loop @@ -6225,13 +6555,12 @@ export class AgentSession { if (next && MCPManager.instance() === undefined) MCPManager.setInstance(next); } - /** Swap named custom tools and their optional mandatory MCP selection. */ + /** Swap named custom tools and, when supplied, their mandatory MCP selection. */ async replaceNamedCustomTools( previousNames: readonly string[], - nextTools: readonly CustomTool[], + nextTools: CustomTool[], options?: { mandatoryMCPToolNames?: readonly string[]; activateNewTools?: boolean }, ): Promise { - const previousSelectedMCPToolNames = this.getSelectedMCPToolNames(); const previous = new Set(previousNames); const previousActive = this.getActiveToolNames(); for (const name of previous) this.#toolRegistry.delete(name); @@ -6251,15 +6580,11 @@ export class AgentSession { ); } this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry()); - await this.#applyActiveToolsByName( - [ - ...previousActive.filter(name => !previous.has(name)), - ...added.filter( - name => previousActive.includes(name) || (options?.activateNewTools !== false && !previous.has(name)), - ), - ], - { previousSelectedMCPToolNames }, - ); + const shouldActivateNewTools = options?.activateNewTools !== false; + await this.#applyActiveToolsByName([ + ...previousActive.filter(name => !previous.has(name)), + ...(shouldActivateNewTools ? added.filter(name => !previous.has(name) || previousActive.includes(name)) : []), + ]); } /** Best-effort accessor for the active skill's `current_phase` field from @@ -6771,6 +7096,19 @@ export class AgentSession { if (failures.length > 0) throw new AggregateError(failures, "Tool session transition cleanup failed."); } + async #runCommittedSessionTransitionCleanups(): Promise { + try { + await this.#runToolSessionTransitionCleanups(); + } catch (error) { + logger.warn("Committed session transition cleanup failed", { error: String(error) }); + this.emitNotice( + "warning", + "The successor session is active, but a predecessor tool cleanup did not finish. Remaining cleanup will retry during disposal.", + "session-transition-cleanup", + ); + } + } + async #runToolSessionCleanups(): Promise { const cleanups = Array.from(this.#toolSessionCleanups); const results = await Promise.allSettled(cleanups.map(async cleanup => await cleanup())); @@ -6895,6 +7233,7 @@ export class AgentSession { lease: OwnerSubagentShutdownLease, ownerId: string, predecessorEndpointId: string, + retirePredecessorRegistrations: boolean, ): void { const finalization = (async () => { while (!this.#isDisposed) { @@ -6905,13 +7244,16 @@ export class AgentSession { if (!(await manager.waitForOwnerInFlightDeliveries(ownerId))) throw new Error("owner_delivery_settlement_timeout"); if (!(await manager.cancelAndSettleOwnerJobs(ownerId))) throw new Error("owner_job_settlement_timeout"); + const predecessorOwner = AsyncJobManager.forEndpoint(predecessorEndpointId); + const mayRetire = + retirePredecessorRegistrations && (predecessorOwner === undefined || predecessorOwner === manager); if (this.#isDisposed) { - retireOwnedRegistrationsForEndpoint(predecessorEndpointId); + if (mayRetire) retireOwnedRegistrationsForEndpoint(predecessorEndpointId); break; } - retireOwnedRegistrationsForEndpoint(predecessorEndpointId); + if (mayRetire) retireOwnedRegistrationsForEndpoint(predecessorEndpointId); this.sessionManager.retireEphemeralArtifactsAfterTransition(); - await this.#runToolSessionTransitionCleanups(); + await this.#runCommittedSessionTransitionCleanups(); manager.finishOwnerSubagentShutdown(lease, "commit"); return; } catch (error) { @@ -7017,7 +7359,14 @@ export class AgentSession { #coordinatorToolObservations = new WeakMap(); #agentEventAdmission = new WeakMap< object, - { scope?: AttemptScope; sdkRunToken?: string; persistGeneration: number; persistBarrier?: Promise } + { + scope?: AttemptScope; + sdkRunToken?: string; + sessionId?: string; + sessionIdentityEpoch?: number; + persistGeneration: number; + persistBarrier?: Promise; + } >(); /** Extension handlers cannot mutate or replace the Agent-claimed run owner. */ #terminalOwnerByExtensionEvent = new WeakMap(); @@ -7054,13 +7403,23 @@ export class AgentSession { } #canonicalMessageAdmissionTail: CanonicalMessageAdmissionSlot = { promise: Promise.resolve(), released: true }; + #turnEndPersistenceFailure: + | { + slot: CanonicalMessageAdmissionSlot; + nextMessageCount: number; + error: unknown; + } + | undefined; #reserveCanonicalMessageAdmission(event: AgentEvent): CanonicalMessageAdmission | undefined { - if (event.type !== "message_end") return undefined; + if (event.type !== "message_end" && event.type !== "turn_end" && event.type !== "agent_end") return undefined; const predecessor = this.#canonicalMessageAdmissionTail; const settled = Promise.withResolvers(); const slot: CanonicalMessageAdmissionSlot = { promise: settled.promise, released: false }; let released = false; + const fail = (error: unknown) => { + if (slot.error === undefined) slot.error = error; + }; const release = () => { if (released) return; released = true; @@ -7072,10 +7431,34 @@ export class AgentSession { // it and leave the first handler awaiting a promise only its own handler // will ever release. this.#canonicalMessageAdmissionTail = slot; - return { predecessor, release }; + return { predecessor, slot, release, fail }; + } + + #rejectStaleAgentEvent(event: AgentEvent): boolean { + const trustedAfterAgentAdmission = this.#trustedExternalEventsAfterAgentAdmission.delete(event); + const scope = (event as AgentEvent & { scope?: AttemptScope }).scope; + const scopeKey = scope ? this.#attemptScopeKey(scope) : undefined; + const recoveryRejected = + this.#terminalPersistenceRecovery !== undefined && + !(event.type === "agent_end" && scopeKey === this.#terminalPersistenceRecovery.attemptScopeKey); + if (trustedAfterAgentAdmission && !recoveryRejected) return false; + const rejected = + recoveryRejected || + (scopeKey !== undefined && this.#retiredSessionIdentityAttemptScopeKeys.has(scopeKey)) || + (this.#sessionIdentityEpoch > 0 && + (scopeKey === undefined || !this.#currentSessionIdentityAttemptScopeKeys.has(scopeKey))); + if (!rejected) return false; + if ( + (event.type === "message_start" || event.type === "message_update" || event.type === "message_end") && + event.message.role === "assistant" + ) { + this.agent.discardRejectedAssistantEvent(event.message); + } + return true; } #trackAgentEvent = (event: AgentEvent): Promise => { + if (this.#rejectStaleAgentEvent(event)) return Promise.resolve(); const eventScope = (event as AgentEvent & { scope?: AttemptScope }).scope; if ((event.type === "agent_start" || event.type === "turn_start") && eventScope !== undefined) { this.#bindAttemptScopeToActiveRun(eventScope); @@ -7083,6 +7466,8 @@ export class AgentSession { this.#agentEventAdmission.set(event, { scope: this.#activeAttemptScope, sdkRunToken: this.#activeSdkRunToken, + sessionId: this.sessionId, + sessionIdentityEpoch: this.#sessionIdentityEpoch, persistGeneration: this.#coordinatorPersistGeneration, persistBarrier: this.#coordinatorRescopeBarrier, }); @@ -7468,10 +7853,109 @@ export class AgentSession { // Track last assistant message for auto-compaction check #lastAssistantMessage: AssistantMessage | undefined = undefined; + #sessionIdentityEpoch = 0; + readonly #currentSessionIdentityAttemptScopeKeys = new Set(); + readonly #retiredSessionIdentityAttemptScopeKeys = new Set(); + readonly #sessionOwnedExternalEvents = new WeakSet(); + readonly #trustedExternalEventsAfterAgentAdmission = new WeakSet(); + #externalIngressSealed = false; + #terminalPersistenceRecovery: + | { + message: Exclude; + entryId: string | undefined; + attemptScopeKey: string | undefined; + sessionId: string; + sessionIdentityEpoch: number; + } + | undefined; + #terminalPersistenceRecoveryPromise: Promise | undefined; + #provisionalAssistantMessage: + | { + message: AssistantMessage; + presentationMessage: AssistantMessage; + promptGeneration: number; + attemptScopeKey: string | undefined; + sessionIdentityEpoch: number; + } + | undefined; + + #advanceSessionIdentityEpoch(): void { + this.#sessionIdentityEpoch++; + this.#provisionalAssistantMessage = undefined; + } + + #attemptScopeKey(scope: AttemptScope): string { + return JSON.stringify([scope.lineage, scope.generation, scope.attemptId]); + } + + #retireCurrentSessionIdentityAttemptScopes(): void { + for (const key of this.#currentSessionIdentityAttemptScopeKeys) { + this.#retiredSessionIdentityAttemptScopeKeys.add(key); + } + this.#currentSessionIdentityAttemptScopeKeys.clear(); + } + + #commitSessionIdentityTransition(): void { + const streamingMessage = this.agent.state.streamMessage; + if (streamingMessage?.role === "assistant") this.agent.discardRejectedAssistantEvent(streamingMessage); + this.#retireCurrentSessionIdentityAttemptScopes(); + this.#advanceSessionIdentityEpoch(); + this.#activeSkillState = undefined; + this.#restoredWorkflowSkillState = undefined; + this.#checkpointState = undefined; + this.#pendingRewindReport = undefined; + this.#turnEndPersistenceFailure = undefined; + this.#canonicalMessageAdmissionTail = { promise: Promise.resolve(), released: true }; + this.#reloadTtsrStateFromSessionManager(); + this.#requestSubskillToolReconciliation(); + this.#retiredSessionIdentityAttemptScopeKeys.clear(); + } + + #reloadTtsrStateFromSessionManager(): void { + if (!this.#ttsrManager) return; + const state = this.sessionManager.getTtsrPersistenceState(); + this.#ttsrManager.replacePersistedState(state.records, state.messageCount); + this.#pendingTtsrInjections = []; + this.#perToolTtsrInjections.clear(); + } + + #admitExternalAgentEvent(event: AgentEvent): boolean { + if (this.#externalIngressSealed) return false; + const scope = (event as AgentEvent & { scope?: AttemptScope }).scope; + const key = scope ? this.#attemptScopeKey(scope) : undefined; + if (this.#terminalPersistenceRecovery) { + return event.type === "agent_end" && key === this.#terminalPersistenceRecovery.attemptScopeKey; + } + if (this.#sessionOwnedExternalEvents.delete(event)) { + this.#trustedExternalEventsAfterAgentAdmission.add(event); + return true; + } + if (!scope) { + if ( + (event.type === "message_start" || event.type === "message_update") && + event.message.role === "assistant" + ) { + return false; + } + if (event.type === "agent_end" && this.#provisionalAssistantMessage) return false; + return this.#sessionIdentityEpoch === 0; + } + const scopedKey = this.#attemptScopeKey(scope); + if (this.#retiredSessionIdentityAttemptScopeKeys.has(scopedKey)) return false; + if (!this.#attemptAuthority.isCurrent(scope)) return false; + if (!this.#currentSessionIdentityAttemptScopeKeys.has(scopedKey)) return false; + return true; + } + + #emitSessionOwnedExternalEvent(event: AgentEvent): void { + this.#sessionOwnedExternalEvents.add(event); + this.agent.emitExternalEvent(event); + } // Admission slot of the last message_end per assistant message, so the // agent_end handler can join the terminal's canonical admission before any // post-turn write reaches the branch. #lastAssistantAdmissionByMessage = new WeakMap(); + #lastAssistantIdentityByMessage = new WeakMap(); // Provider context construction must wait for this chain. Agent event listeners // are synchronous dispatch only; their async work cannot otherwise gate the // next tool-result provider request. @@ -7546,8 +8030,121 @@ export class AgentSession { eventLease?: RunResourceProducerLease, ): Promise => { const attemptScope = (event as AgentEvent & { scope?: AttemptScope }).scope; + let suppressTodoWriteErrorReminder = false; + const eventAdmission = this.#agentEventAdmission.get(event); + const eventSessionIdentity = this.#captureSessionSelectionIdentity(); + const eventIdentityIsCurrent = (): boolean => + eventAdmission?.sessionId === undefined || + (eventAdmission.sessionId === this.sessionId && + eventAdmission.sessionIdentityEpoch === this.#sessionIdentityEpoch); + const agentEndIdentity = event.type === "agent_end" ? eventSessionIdentity : undefined; + const agentEndIdentityIsCurrent = (): boolean => + agentEndIdentity === undefined || + (this.#sessionTransitionKind === undefined && this.#isSessionSelectionIdentityCurrent(agentEndIdentity)); + const attemptScopeKey = attemptScope ? this.#attemptScopeKey(attemptScope) : undefined; + const discardRejectedAssistantEvent = (): void => { + if ( + (event.type === "message_start" || event.type === "message_update" || event.type === "message_end") && + event.message.role === "assistant" + ) { + this.agent.discardRejectedAssistantEvent(event.message); + } + }; + if (attemptScopeKey && this.#retiredSessionIdentityAttemptScopeKeys.has(attemptScopeKey)) { + discardRejectedAssistantEvent(); + return; + } const terminalSdkOwnership = event.type === "agent_end" ? this.#captureSdkContinuationOwnership(attemptScope) : undefined; + if (attemptScopeKey) this.#currentSessionIdentityAttemptScopeKeys.add(attemptScopeKey); + if ( + attemptScope === undefined && + this.#sessionIdentityEpoch > 0 && + ((event.type === "message_start" && event.message.role === "assistant") || + (event.type === "message_update" && event.message.role === "assistant") || + (event.type === "message_end" && event.message.role === "assistant") || + (event.type === "agent_end" && + event.messages.some( + message => message.role === "assistant" && getSessionMessageEntryId(message) === undefined, + ))) + ) { + discardRejectedAssistantEvent(); + return; + } + const terminalSnapshot = event as AgentEvent & { readonly silentAbort?: true; readonly ttsrAbort?: true }; + if (event.type === "agent_end" && this.#terminalPersistenceRecovery) { + Object.defineProperty(event, "terminalPersistenceFailed", { value: true, enumerable: true }); + } + const terminalWasAborted = + (event.type === "agent_end" && event.stopReason === "cancelled") || + (event.type === "message_end" && event.message.role === "assistant" && event.message.stopReason === "aborted"); + const silentTerminal = + terminalWasAborted && + (this.#silentAbortPending || this.#planCompactAbortPending || terminalSnapshot.silentAbort === true); + const ttsrTerminal = terminalWasAborted && (this.#ttsrAbortPending || terminalSnapshot.ttsrAbort === true); + if (silentTerminal && terminalSnapshot.silentAbort !== true) { + Object.defineProperty(event, "silentAbort", { value: true, enumerable: true }); + } + if (ttsrTerminal && terminalSnapshot.ttsrAbort !== true) { + Object.defineProperty(event, "ttsrAbort", { value: true, enumerable: true }); + } + if (event.type === "message_start" && event.message.role === "assistant") { + this.#provisionalAssistantMessage = { + message: structuredClone(event.message), + presentationMessage: event.message, + promptGeneration: this.#promptGeneration, + attemptScopeKey, + sessionIdentityEpoch: this.#sessionIdentityEpoch, + }; + } else if ( + event.type === "message_update" && + event.message.role === "assistant" && + this.#provisionalAssistantMessage?.promptGeneration === this.#promptGeneration && + this.#provisionalAssistantMessage.attemptScopeKey === attemptScopeKey && + this.#provisionalAssistantMessage.sessionIdentityEpoch === this.#sessionIdentityEpoch + ) { + this.#provisionalAssistantMessage.message = structuredClone(event.message); + this.#provisionalAssistantMessage.presentationMessage = event.message; + } + const provisionalAssistant = this.#provisionalAssistantMessage; + const provisionalAttemptMatches = + provisionalAssistant !== undefined && + (provisionalAssistant.attemptScopeKey !== undefined || attemptScopeKey !== undefined + ? provisionalAssistant.attemptScopeKey === attemptScopeKey + : event.type !== "agent_end" && provisionalAssistant.promptGeneration === this.#promptGeneration); + const terminalAssistant = + event.type === "agent_end" + ? [...event.messages].reverse().find((message): message is AssistantMessage => message.role === "assistant") + : undefined; + if (event.type === "agent_end" && provisionalAssistant) { + Object.defineProperty(event, "terminalProjectionMatched", { + value: + provisionalAttemptMatches && provisionalAssistant.sessionIdentityEpoch === this.#sessionIdentityEpoch, + enumerable: true, + }); + } + const orphanAssistant = + event.type === "agent_end" && + event.stopReason !== "maintenance" && + terminalAssistant === undefined && + provisionalAttemptMatches && + provisionalAssistant.sessionIdentityEpoch === this.#sessionIdentityEpoch + ? provisionalAssistant + : undefined; + const unadmittedTerminalAssistant = + event.type === "agent_end" && + event.stopReason !== "maintenance" && + terminalAssistant !== undefined && + !this.agent.isCursorSplitTerminalMessage(terminalAssistant) && + getSessionMessageEntryId(terminalAssistant) === undefined + ? terminalAssistant + : undefined; + if (event.type === "turn_start" || (event.type === "message_end" && event.message.role === "assistant")) { + this.#provisionalAssistantMessage = undefined; + } + if (event.type === "agent_end" && (event as AgentEndSessionEvent).terminalProjectionMatched !== false) { + this.#provisionalAssistantMessage = undefined; + } // These lifecycle boundaries can be delivered without awaiting this listener. // Revoke streaming-edit cache generations before any admission, spill, or @@ -7602,6 +8199,10 @@ export class AgentSession { // when the event dispatcher does not await this listener. await this.#queuePreAdmissionArtifactSpill(event.message); } + if (!eventIdentityIsCurrent()) { + discardRejectedAssistantEvent(); + return; + } // Agent listeners run synchronously, but this handler yields while emitting // session events. Capture the maintenance run identity before that yield so @@ -7685,7 +8286,7 @@ export class AgentSession { } } } - await this.#syncSkillPromptActiveStateSafely(event.message, true); + await this.#syncSkillPromptActiveStateSafely(event.message, true, true, eventSessionIdentity, false); } // Plan-mode → compaction transition: stamp `SILENT_ABORT_MARKER` on the @@ -7704,42 +8305,164 @@ export class AgentSession { event.type === "message_end" && event.message.role === "assistant" && event.message.stopReason === "aborted" && - (this.#planCompactAbortPending || this.#silentAbortPending) + (silentTerminal || ttsrTerminal) ) { (event.message as AssistantMessage).errorMessage = SILENT_ABORT_MARKER; this.agent.touchContext(); - this.#planCompactAbortPending = false; - this.#silentAbortPending = false; - } - - // Canonical persistence follows synchronous message_end reservation order. - // Only the admission predecessor and this event's own pre-admission work are - // inside the lane; release before extension delivery and unrelated post-work. - // An already-released predecessor must not cost a microtask: external emitters - // and tests rely on canonical append being visible synchronously after - // emitExternalEvent returns whenever no admission is actually contended. - // Track the terminal assistant synchronously, before any admission wait: - // externally emitted terminals (host bridges, replays, tests) dispatch - // agent_end immediately after message_end, and the agent_end handler's - // post-turn read must see THIS stop even when this admission is still - // parked behind a contended predecessor — otherwise post-turn logic - // (deep-interview continuation, compaction, retry classification) runs - // against the previous turn's assistant. The per-message admission slot - // also lets agent_end processing wait for this admission to finish, so - // post-turn writes (continuation reminders, compaction rewrites) never - // reorder ahead of the branch entries they respond to. - if (event.type === "message_end" && event.message.role === "assistant") { - this.#lastAssistantMessage = event.message; - this.#lastAssistantAdmissionByMessage.set(event.message, canonicalAdmission); + if (silentTerminal) { + this.#planCompactAbortPending = false; + this.#silentAbortPending = false; + } } - if (event.type === "message_end") { + if ( + event.type === "agent_end" && + (orphanAssistant || unadmittedTerminalAssistant) && + !this.#terminalPersistenceRecovery + ) { if (canonicalAdmission && !canonicalAdmission.predecessor.released) { await canonicalAdmission.predecessor.promise; } - if ( - (event.message.role === "hookMessage" || event.message.role === "custom") && - !(event.message.role === "custom" && event.message.customType === "hindsight-recall") + if (!eventIdentityIsCurrent()) return; + const admittedTerminalAssistant = this.#lastAssistantMessage; + const admittedTerminalIdentity = admittedTerminalAssistant + ? this.#lastAssistantIdentityByMessage.get(admittedTerminalAssistant) + : undefined; + const admittedTerminalScope = admittedTerminalAssistant + ? this.#assistantAttemptScopes.get(admittedTerminalAssistant)?.scope + : undefined; + const correlatedCanonicalAssistant = + unadmittedTerminalAssistant && + admittedTerminalIdentity !== undefined && + this.#isSessionSelectionIdentityCurrent(admittedTerminalIdentity) && + attemptScopeKey !== undefined && + admittedTerminalScope !== undefined && + this.#attemptScopeKey(admittedTerminalScope) === attemptScopeKey && + getSessionMessageEntryId(admittedTerminalAssistant!) !== undefined + ? admittedTerminalAssistant + : undefined; + if (correlatedCanonicalAssistant && terminalAssistant) { + transferSessionMessageIdentity([correlatedCanonicalAssistant], [terminalAssistant]); + } + if (this.#terminalPersistenceRecovery) { + Object.defineProperty(event, "terminalPersistenceFailed", { value: true, enumerable: true }); + } else if ( + !correlatedCanonicalAssistant && + (!unadmittedTerminalAssistant || getSessionMessageEntryId(unadmittedTerminalAssistant) === undefined) + ) { + const recoveredAssistant: AssistantMessage = structuredClone( + unadmittedTerminalAssistant ?? { + ...orphanAssistant!.message, + stopReason: event.stopReason === "cancelled" ? "aborted" : orphanAssistant!.message.stopReason, + ...(silentTerminal || ttsrTerminal ? { errorMessage: SILENT_ABORT_MARKER } : {}), + }, + ); + recoveredAssistant.content = recoveredAssistant.content.filter(block => block.type !== "toolCall"); + if ( + recoveredAssistant.stopReason === "aborted" && + (silentTerminal || ttsrTerminal) && + recoveredAssistant.errorMessage !== SILENT_ABORT_MARKER + ) { + recoveredAssistant.errorMessage = SILENT_ABORT_MARKER; + } + let persistenceFailed = false; + try { + this.sessionManager.appendMessage(recoveredAssistant); + } catch (error) { + if (error instanceof SessionNearLimitAppendError && error.entryRetained) { + this.emitNotice( + "warning", + "Interrupted assistant output is retained in the live session but could not be written durably. Compact or export the session before continuing.", + "session-persistence", + ); + } else { + Object.defineProperty(event, "terminalPersistenceFailed", { value: true, enumerable: true }); + this.#terminalPersistenceRecovery = { + message: recoveredAssistant, + entryId: error instanceof SessionAppendPersistenceError ? error.entryId : undefined, + attemptScopeKey, + sessionId: this.sessionId, + sessionIdentityEpoch: this.#sessionIdentityEpoch, + }; + const failedPresentation = + orphanAssistant?.presentationMessage ?? provisionalAssistant?.presentationMessage; + if (failedPresentation) this.agent.discardRejectedAssistantEvent(failedPresentation); + this.emitNotice( + "error", + "Interrupted assistant output could not be committed to session history. Reconcile session storage before continuing.", + "session-persistence", + ); + persistenceFailed = true; + } + } + if (!persistenceFailed) { + const publishedAssistant = terminalAssistant ?? recoveredAssistant; + if (terminalAssistant) { + terminalAssistant.content = recoveredAssistant.content; + terminalAssistant.stopReason = recoveredAssistant.stopReason; + terminalAssistant.errorMessage = recoveredAssistant.errorMessage; + transferSessionMessageIdentity([recoveredAssistant], [terminalAssistant]); + } + if (!this.agent.state.messages.includes(publishedAssistant)) + this.agent.appendMessage(publishedAssistant); + if (!terminalAssistant) event.messages.push(recoveredAssistant); + const presentationMessage = + orphanAssistant?.presentationMessage ?? + (provisionalAttemptMatches && + provisionalAssistant?.sessionIdentityEpoch === this.#sessionIdentityEpoch + ? provisionalAssistant.presentationMessage + : undefined); + if (presentationMessage && presentationMessage !== recoveredAssistant) { + transferSessionMessageIdentity([recoveredAssistant], [presentationMessage]); + this.agent.discardRejectedAssistantEvent(presentationMessage); + } + this.#lastAssistantMessage = publishedAssistant; + this.#lastAssistantAdmissionByMessage.set(publishedAssistant, canonicalAdmission); + } + if (silentTerminal) { + this.#planCompactAbortPending = false; + this.#silentAbortPending = false; + } + } + } + + // Canonical persistence follows synchronous message_end reservation order. + // Only the admission predecessor and this event's own pre-admission work are + // inside the lane; release before extension delivery and unrelated post-work. + // An already-released predecessor must not cost a microtask: external emitters + // and tests rely on canonical append being visible synchronously after + // emitExternalEvent returns whenever no admission is actually contended. + // Track the terminal assistant synchronously, before any admission wait: + // externally emitted terminals (host bridges, replays, tests) dispatch + // agent_end immediately after message_end, and the agent_end handler's + // post-turn read must see THIS stop even when this admission is still + // parked behind a contended predecessor — otherwise post-turn logic + // (deep-interview continuation, compaction, retry classification) runs + // against the previous turn's assistant. The per-message admission slot + // also lets agent_end processing wait for this admission to finish, so + // post-turn writes (continuation reminders, compaction rewrites) never + // reorder ahead of the branch entries they respond to. + if (event.type === "message_end" && event.message.role === "assistant") { + this.#lastAssistantMessage = event.message; + this.#lastAssistantAdmissionByMessage.set(event.message, canonicalAdmission); + this.#lastAssistantIdentityByMessage.set(event.message, eventSessionIdentity); + this.#assistantAttemptScopes.set(event.message, { + scope: attemptScope, + wasClean: false, + }); + } + + if (event.type === "message_end") { + if (canonicalAdmission && !canonicalAdmission.predecessor.released) { + await canonicalAdmission.predecessor.promise; + } + if (!eventIdentityIsCurrent() || this.#terminalPersistenceRecovery) { + discardRejectedAssistantEvent(); + return; + } + if ( + (event.message.role === "hookMessage" || event.message.role === "custom") && + !(event.message.role === "custom" && event.message.customType === "hindsight-recall") ) { const isRosterReminder = event.message.role === "custom" && event.message.customType === "irc-peer-roster"; if (!isRosterReminder) { @@ -7767,6 +8490,145 @@ export class AgentSession { try { this.sessionManager.appendMessage(event.message); } catch (error) { + let handledTodoPersistenceFailure = false; + if ( + event.message.role === "toolResult" && + event.message.toolName === "todo_write" && + error instanceof SessionAppendPersistenceError && + error.phase === "current_append" + ) { + this.agent.abort(); + const failure = error.persistenceError.message; + const failedEntryId = error.entryId; + this.#syncTodoPhasesFromBranch(); + event.message.isError = true; + event.message.content = [ + { + type: "text", + text: `Todo state persistence failed: ${failure}\nDo not change the payload solely because of this failure. The durable outcome is unknown; reconcile the session state before retrying or continuing.`, + }, + ]; + event.message.details = { + ...(event.message.details && typeof event.message.details === "object" + ? event.message.details + : {}), + phases: this.getTodoPhases(), + failureKind: "persistence", + }; + this.agent.touchContext(); + let recovered = false; + try { + await this.sessionManager.recoverPersistenceFailure(); + recovered = true; + } catch (recoveryError) { + logger.warn("Todo persistence recovery failed", { + error: recoveryError instanceof Error ? recoveryError.message : String(recoveryError), + }); + } + if (recovered) { + const durableTodoResult = this.sessionManager + .getBranch() + .find( + entry => + entry.id === failedEntryId && + entry.type === "message" && + entry.message.role === "toolResult" && + entry.message.toolName === "todo_write", + ); + this.#syncTodoPhasesFromBranch(); + if (durableTodoResult?.type === "message" && durableTodoResult.message.role === "toolResult") { + event.message.content = durableTodoResult.message.content; + event.message.details = durableTodoResult.message.details; + event.message.isError = durableTodoResult.message.isError; + } else { + event.message.details = { + ...(event.message.details && typeof event.message.details === "object" + ? event.message.details + : {}), + phases: this.getTodoPhases(), + failureKind: "persistence", + }; + try { + this.sessionManager.appendMessage(event.message); + this.agent.touchContext(); + } catch (replacementError) { + if (replacementError instanceof SessionNearLimitAppendError) { + this.agent.abort(); + event.message.content = [ + { + type: "text", + text: [ + "Todo state was restored to its prior durable value, but the persistence-error receipt could not be recorded durably.", + replacementError.entryRetained + ? "The receipt remains in live session history and will persist on the next successful write." + : "The receipt could not be retained in live session history.", + "Continue by compacting the session (`/compact`) or exporting to a fresh session (`gjc export `).", + ].join("\n"), + }, + ]; + event.message.details = { + ...(event.message.details && typeof event.message.details === "object" + ? event.message.details + : {}), + failureKind: "persistence", + nearLimitAppend: { + code: replacementError.code, + entryBytes: replacementError.entryBytes, + liveBytes: replacementError.liveBytes, + capBytes: replacementError.capBytes, + entryRetained: replacementError.entryRetained, + }, + }; + this.agent.touchContext(); + } else { + Object.defineProperty(event, "terminalPersistenceFailed", { + value: true, + enumerable: true, + }); + this.#terminalPersistenceRecovery ??= { + message: event.message, + entryId: + replacementError instanceof SessionAppendPersistenceError + ? replacementError.entryId + : undefined, + attemptScopeKey, + sessionId: this.sessionId, + sessionIdentityEpoch: this.#sessionIdentityEpoch, + }; + this.agent.abort(); + this.emitNotice( + "error", + "Agent output could not be committed to session history. Reconcile session storage before continuing.", + "session-persistence", + ); + return; + } + } + } + handledTodoPersistenceFailure = true; + suppressTodoWriteErrorReminder = true; + } + } + if (handledTodoPersistenceFailure) { + // Continue through normal event publication with the durable converted receipt. + } else if (!(error instanceof SessionNearLimitAppendError)) { + Object.defineProperty(event, "terminalPersistenceFailed", { value: true, enumerable: true }); + this.#terminalPersistenceRecovery ??= { + message: event.message, + entryId: error instanceof SessionAppendPersistenceError ? error.entryId : undefined, + attemptScopeKey, + sessionId: this.sessionId, + sessionIdentityEpoch: this.#sessionIdentityEpoch, + }; + if (event.message.role === "assistant") this.agent.discardRejectedAssistantEvent(event.message); + this.agent.abort(); + this.emitNotice( + "error", + "Agent output could not be committed to session history. Reconcile session storage before continuing.", + "session-persistence", + ); + return; + } // Typed near-limit append (#4566): the transcript hit the managed // per-file cap and even the live-entry rewrite could not hold this // entry. The edit/effect itself may already be committed; surface a @@ -7808,69 +8670,6 @@ export class AgentSession { } return; } - if ( - event.message.role !== "toolResult" || - event.message.toolName !== "todo_write" || - !(error instanceof SessionAppendPersistenceError) || - error.phase !== "current_append" - ) { - this.agent.abort(); - throw error; - } - this.agent.abort(); - - const failure = error.persistenceError.message; - const failedEntryId = error.entryId; - this.#syncTodoPhasesFromBranch(); - event.message.isError = true; - event.message.content = [ - { - type: "text", - text: `Todo state persistence failed: ${failure}\nDo not change the payload solely because of this failure. The durable outcome is unknown; reconcile the session state before retrying or continuing.`, - }, - ]; - event.message.details = { - ...(event.message.details && typeof event.message.details === "object" ? event.message.details : {}), - phases: this.getTodoPhases(), - failureKind: "persistence", - }; - this.agent.touchContext(); - let recovered = false; - try { - await this.sessionManager.recoverPersistenceFailure(); - recovered = true; - } catch (recoveryError) { - logger.warn("Todo persistence recovery failed", { - error: recoveryError instanceof Error ? recoveryError.message : String(recoveryError), - }); - } - if (recovered) { - const durableTodoResult = this.sessionManager - .getBranch() - .find( - entry => - entry.id === failedEntryId && - entry.type === "message" && - entry.message.role === "toolResult" && - entry.message.toolName === "todo_write", - ); - this.#syncTodoPhasesFromBranch(); - if (durableTodoResult?.type === "message" && durableTodoResult.message.role === "toolResult") { - event.message.content = durableTodoResult.message.content; - event.message.details = durableTodoResult.message.details; - event.message.isError = durableTodoResult.message.isError; - } else { - event.message.details = { - ...(event.message.details && typeof event.message.details === "object" - ? event.message.details - : {}), - phases: this.getTodoPhases(), - failureKind: "persistence", - }; - this.sessionManager.appendMessage(event.message); - this.agent.touchContext(); - } - } } } canonicalAdmission?.release(); @@ -7881,7 +8680,7 @@ export class AgentSession { // values. The original event.message stays obfuscated so the canonical persistence path above // writes authenticated placeholder tokens to the session file; convertToLlm re-obfuscates outbound // traffic on the next turn. Walks text, thinking, and toolCall arguments/intent. - let displayEvent: AgentEvent = event; + let displayEvent: AgentSessionEvent = event; const obfuscator = this.#obfuscator; if (obfuscator && event.type === "message_end" && event.message.role === "assistant") { const message = event.message; @@ -7892,6 +8691,24 @@ export class AgentSession { displayEvent = { ...event, message: displayMessage }; } } + if (displayEvent !== event) { + if (terminalSnapshot.silentAbort === true) { + Object.defineProperty(displayEvent, "silentAbort", { + value: true, + enumerable: true, + writable: false, + configurable: false, + }); + } + if (terminalSnapshot.ttsrAbort === true) { + Object.defineProperty(displayEvent, "ttsrAbort", { + value: true, + enumerable: true, + writable: false, + configurable: false, + }); + } + } if (event.type === "turn_start") this.#deepInterviewTurnOwnerEpoch = this.#deepInterviewUserIntentEpoch; if (event.type === "turn_start" && this.#goalRuntime.shouldTrackTurnBaseline()) { @@ -7905,6 +8722,10 @@ export class AgentSession { } await this.#emitSessionEvent(displayEvent, eventLease); + if (!eventIdentityIsCurrent() || !agentEndIdentityIsCurrent()) { + discardRejectedAssistantEvent(); + return; + } if (event.type === "message_end" && event.message.role === "assistant") { this.#assistantAttemptScopes.set(event.message, { scope: attemptScope, @@ -7926,9 +8747,43 @@ export class AgentSession { this.#ttsrManager?.resetBuffer(); } + if (event.type === "turn_end" && event.message.role === "assistant") { + if (canonicalAdmission && !canonicalAdmission.predecessor.released) { + await canonicalAdmission.predecessor.promise; + } + if (canonicalAdmission?.predecessor.error !== undefined) throw canonicalAdmission.predecessor.error; + if (!eventIdentityIsCurrent()) return; + const entryId = getSessionMessageEntryId(event.message); + const entry = entryId ? this.sessionManager.getEntryForFidelity(entryId) : undefined; + if (entry?.type === "message" && entry.message.role === "assistant" && event.message.stopReason === "error") { + try { + this.sessionManager.applyEntryMessageUpdates([{ ...entry, message: event.message }]); + await this.sessionManager.rewriteEntries(); + } catch (error) { + canonicalAdmission?.fail(error); + throw error; + } + } + } + // TTSR: Increment message count on turn end (for repeat-after-gap tracking) if (event.type === "turn_end" && this.#ttsrManager) { - this.#ttsrManager.incrementMessageCount(); + if (canonicalAdmission && !canonicalAdmission.predecessor.released) { + await canonicalAdmission.predecessor.promise; + } + if (canonicalAdmission?.predecessor.error !== undefined) throw canonicalAdmission.predecessor.error; + if (!eventIdentityIsCurrent()) return; + const nextMessageCount = this.#ttsrManager.getMessageCount() + 1; + try { + this.sessionManager.appendTtsrInjection([], this.#ttsrManager.getInjectedRecords(), nextMessageCount); + } catch (error) { + if (canonicalAdmission) { + this.#turnEndPersistenceFailure = { slot: canonicalAdmission.slot, nextMessageCount, error }; + canonicalAdmission.fail(error); + } + throw error; + } + this.#ttsrManager.restoreMessageCount(nextMessageCount); } // Finalize the tool-choice queue's in-flight yield after tools have executed. // This must happen at turn_end (not message_end) because onInvoked handlers @@ -7994,14 +8849,36 @@ export class AgentSession { // Schedule retry after a short delay const retryToken = ++this.#ttsrRetryToken; const generation = this.#promptGeneration; + const retrySessionId = this.sessionId; + const retrySessionIdentityEpoch = this.#sessionIdentityEpoch; const targetMessageTimestamp = event.message.role === "assistant" ? event.message.timestamp : undefined; this.#schedulePostPromptTask( async () => { + try { + await this.#reconcileTerminalPersistenceFailure(); + } catch { + this.#ttsrAbortPending = false; + this.#pendingTtsrInjections = []; + this.#perToolTtsrInjections.clear(); + this.#resolveTtsrResume(); + return; + } if (this.#ttsrRetryToken !== retryToken) { this.#resolveTtsrResume(); return; } + if ( + retrySessionId !== this.sessionId || + retrySessionIdentityEpoch !== this.#sessionIdentityEpoch || + this.#sessionTransitionKind !== undefined + ) { + this.#ttsrAbortPending = false; + this.#pendingTtsrInjections = []; + this.#perToolTtsrInjections.clear(); + this.#resolveTtsrResume(); + return; + } const targetAssistantIndex = this.#findTtsrAssistantIndex(targetMessageTimestamp); if (!this.#ttsrAbortPending || this.#promptGeneration !== generation) { @@ -8023,6 +8900,21 @@ export class AgentSession { const injection = this.#getTtsrInjectionContent(); if (injection) { const details = { rules: injection.rules.map(rule => rule.name) }; + try { + this.sessionManager.appendCustomMessageEntry( + "ttsr-injection", + injection.content, + false, + details, + "agent", + ); + } catch { + this.#ttsrAbortPending = false; + this.#pendingTtsrInjections = []; + this.#perToolTtsrInjections.clear(); + this.#resolveTtsrResume(); + return; + } this.agent.appendMessage({ role: "custom", customType: "ttsr-injection", @@ -8032,14 +8924,15 @@ export class AgentSession { attribution: "agent", timestamp: Date.now(), }); - this.sessionManager.appendCustomMessageEntry( - "ttsr-injection", - injection.content, - false, - details, - "agent", - ); - this.#markTtsrInjected(details.rules); + try { + this.#markTtsrInjected(details.rules); + } catch { + this.#ttsrAbortPending = false; + this.#pendingTtsrInjections = []; + this.#perToolTtsrInjections.clear(); + this.#resolveTtsrResume(); + return; + } } await this.#scheduleAgentContinue({ delayMs: 0, @@ -8149,7 +9042,6 @@ export class AgentSession { } if (assistantMsg.stopReason !== "error" && assistantMsg.stopReason !== "aborted") { this.#providerRetryMaxAttempts = undefined; - this.#managedFallbackProviderAttemptCount = 0; } } @@ -8169,7 +9061,7 @@ export class AgentSession { if (toolName === "todo_write" && !isError && Array.isArray(details?.phases)) { this.setTodoPhases(details.phases); } - if (toolName === "todo_write" && isError) { + if (toolName === "todo_write" && isError && !suppressTodoWriteErrorReminder) { const errorText = content?.find(part => part.type === "text")?.text; const payloadRejected = details?.failureKind === "payload_rejected" || details?.failureKind === "argument_validation"; @@ -8233,6 +9125,14 @@ export class AgentSession { // Check auto-retry and auto-compaction after agent completes if (event.type === "agent_end") { + if (!agentEndIdentityIsCurrent()) return; + if (!agentEndIdentity) return; + if ((event as AgentEndSessionEvent).terminalPersistenceFailed === true) { + this.#lastAssistantMessage = undefined; + this.#lastSuccessfulYieldToolCallId = undefined; + this.#resolveRetry(); + return; + } // Cooperative mid-run maintenance interruption (issue #2035). The loop // ended the run losslessly after #runMidRunMaintenance did prune/ // compact/promote; this handler is the SINGLE continuation owner. Resume @@ -8248,6 +9148,7 @@ export class AgentSession { if ( !maintenanceWasDisposed && !this.#isDisposed && + agentEndIdentityIsCurrent() && maintenanceGeneration !== undefined && this.#promptGeneration === maintenanceGeneration ) { @@ -8257,6 +9158,8 @@ export class AgentSession { resourceRunId: activePromptHandle, maintenanceContinuation: true, sdkOwnership: terminalSdkOwnership, + scheduledSessionId: agentEndIdentity.sessionId, + scheduledSessionIdentityEpoch: agentEndIdentity.sessionIdentityEpoch, }); } return; @@ -8267,6 +9170,7 @@ export class AgentSession { // synthetic assistant error that must flow through terminal handling. if (event.maintenanceOutcome === "aborted") return; } + if (!agentEndIdentityIsCurrent()) return; const usage = this.getSessionStats().tokens; await this.#goalRuntime.onAgentEnd({ currentUsage: { @@ -8276,12 +9180,15 @@ export class AgentSession { cacheWrite: usage.cacheWrite, }, }); + if (!agentEndIdentityIsCurrent()) return; if (this.#activeSkillState) { const { skill, sessionId } = this.#activeSkillState; + if (!agentEndIdentityIsCurrent()) return; await this.#syncSkillPromptActiveStateSafely( { customType: SKILL_PROMPT_MESSAGE_TYPE, details: { name: skill } }, false, ); + if (!agentEndIdentityIsCurrent()) return; if (this.#activeSkillState?.skill === skill && this.#activeSkillState.sessionId === sessionId) { this.#activeSkillState = undefined; } @@ -8289,7 +9196,16 @@ export class AgentSession { const fallbackAssistant = [...event.messages] .reverse() .find((message): message is AssistantMessage => message.role === "assistant"); - const msg = this.#lastAssistantMessage ?? fallbackAssistant; + const capturedLastAssistant = this.#lastAssistantMessage; + const capturedLastAssistantIdentity = capturedLastAssistant + ? this.#lastAssistantIdentityByMessage.get(capturedLastAssistant) + : undefined; + const msg = + capturedLastAssistant && + capturedLastAssistantIdentity !== undefined && + this.#isSessionSelectionIdentityCurrent(capturedLastAssistantIdentity) + ? capturedLastAssistant + : fallbackAssistant; this.#lastAssistantMessage = undefined; // Join the terminal's canonical admission before any post-turn write: // an externally emitted terminal dispatches agent_end while its own @@ -8301,6 +9217,7 @@ export class AgentSession { if (terminalAdmission && !terminalAdmission.predecessor.released) { await terminalAdmission.predecessor.promise; } + if (!agentEndIdentityIsCurrent()) return; if (!msg) { this.#lastSuccessfulYieldToolCallId = undefined; this.#resolveRetry(); @@ -8315,8 +9232,10 @@ export class AgentSession { msg.errorMessage?.includes("GitHub Copilot authentication failed") ) { await this.#modelRegistry.authStorage.remove("github-copilot"); + if (!agentEndIdentityIsCurrent()) return; } + if (!agentEndIdentityIsCurrent()) return; if (this.#skipPostTurnMaintenanceAssistantTimestamp === msg.timestamp) { this.#skipPostTurnMaintenanceAssistantTimestamp = undefined; this.#lastSuccessfulYieldToolCallId = undefined; @@ -8325,7 +9244,11 @@ export class AgentSession { if (this.#assistantEndedWithSuccessfulYield(msg)) { this.#lastSuccessfulYieldToolCallId = undefined; - if (msg.stopReason !== "error" && msg.stopReason !== "aborted" && (await this.#checkGoalCompletion(msg))) { + if ( + msg.stopReason !== "error" && + msg.stopReason !== "aborted" && + (await this.#checkGoalCompletion(msg, agentEndIdentity)) + ) { return; } return; @@ -8341,8 +9264,10 @@ export class AgentSession { // Check for retryable errors first (overloaded, rate limit, server errors) // Skip retry for todo reminder continuations - they should fail silently without retrying - const isReminderContinuationError = this.#todoReminderContinuationGeneration === agentEndGeneration && agentEndGeneration !== undefined; + const isReminderContinuationError = + this.#todoReminderContinuationGeneration === agentEndGeneration && agentEndGeneration !== undefined; if (!isReminderContinuationError && this.#isRetryableError(msg)) { + if (!agentEndIdentityIsCurrent()) return; const transportFailure = (msg as AssistantMessage & { transportFailure?: TransportFailureFacts }) .transportFailure; const messageScope = this.#assistantAttemptScopes.get(msg); @@ -8353,6 +9278,7 @@ export class AgentSession { messageScope?.scope ?? event.scope, messageScope?.wasClean ?? false, ); + if (!agentEndIdentityIsCurrent()) return; if (didRetry) return; // Retry was initiated, don't proceed to compaction } // Clear the reminder continuation flag after processing @@ -8370,37 +9296,57 @@ export class AgentSession { attempt, finalError: msg.errorMessage, }); + if (!agentEndIdentityIsCurrent()) return; } this.#resolveRetry(); - if (this.#isDisposed || this.#sessionAdmissionClosing) return; + if (!agentEndIdentityIsCurrent() || this.#isDisposed || this.#sessionAdmissionClosing) return; const compactionTask = this.#schedulePostPromptTask( async () => { if (this.#isDisposed || this.#sessionAdmissionClosing) return; - await this.#checkCompaction(msg, true, undefined, activePromptHandle, undefined, terminalSdkOwnership); + await this.#checkCompaction( + msg, + true, + undefined, + activePromptHandle, + undefined, + terminalSdkOwnership, + agentEndIdentity, + ); + }, + { + resourceRunId: activePromptHandle, + scheduledSessionId: agentEndIdentity.sessionId, + scheduledSessionIdentityEpoch: agentEndIdentity.sessionIdentityEpoch, }, - { resourceRunId: activePromptHandle }, ); await compactionTask; + if (!agentEndIdentityIsCurrent()) return; // Check for incomplete todos only after a final assistant stop, not intermediate tool-use turns. const hasToolCalls = msg.content.some(content => content.type === "toolCall"); if (hasToolCalls) { return; } if (msg.stopReason !== "error" && msg.stopReason !== "aborted") { - if (this.#enforceRewindBeforeYield()) { + if (this.#enforceRewindBeforeYield(agentEndIdentity)) { return; } if ( - (await this.#checkActiveDeepInterviewCompletion(msg, agentEndGeneration, agentEndOwnerEpoch)) !== - "not_applicable" + (await this.#checkActiveDeepInterviewCompletion( + msg, + agentEndGeneration, + agentEndOwnerEpoch, + agentEndIdentity, + )) !== "not_applicable" ) { return; } - if (await this.#checkGoalCompletion(msg)) { + if (!agentEndIdentityIsCurrent()) return; + if (await this.#checkGoalCompletion(msg, agentEndIdentity)) { return; } - await this.#checkTodoCompletion(); + if (!agentEndIdentityIsCurrent()) return; + await this.#checkTodoCompletion(agentEndIdentity); } } }; @@ -8507,6 +9453,8 @@ export class AgentSession { leaseTask?: Promise; selectionFenceGeneration?: number; excludeFromPostPromptRecovery?: boolean; + scheduledSessionId?: string; + scheduledSessionIdentityEpoch?: number; }, ): Promise { const selectionFenceGeneration = @@ -8515,6 +9463,10 @@ export class AgentSession { this.#sessionAdmissionContext.getStore()?.selectionFenceGeneration ?? this.#selectionFenceGeneration; const delayMs = options?.delayMs ?? 0; + const scheduledSessionIdentity: SessionSelectionIdentity = { + sessionId: options?.scheduledSessionId ?? this.sessionId, + sessionIdentityEpoch: options?.scheduledSessionIdentityEpoch ?? this.#sessionIdentityEpoch, + }; const resourceRunId = options?.resourceRunId; const contextualLease = this.#runResourceLeaseContext.getStore(); const parentLease = @@ -8555,6 +9507,13 @@ export class AgentSession { options.onSkip?.(); return; } + if ( + this.#sessionTransitionKind !== undefined || + !this.#isSessionSelectionIdentityCurrent(scheduledSessionIdentity) + ) { + options?.onSkip?.(); + return; + } await this.#selectionFenceGenerationContext.run(selectionFenceGeneration, () => task(signal)); }; const scheduled = reservation?.ok @@ -8606,9 +9565,14 @@ export class AgentSession { sdkOwnership?: SdkContinuationOwnership; /** Disable managed fallback retries for this continuation (used for terminal server-initiated turns). */ disableManagedFallback?: boolean; + /** Internal session identity retained across selection-fence deferral. */ + scheduledSessionId?: string; + scheduledSessionIdentityEpoch?: number; }): Promise { const continuationAdmission = this.#captureScheduledContinuationAdmission(); const scheduledSdkOwnership = options?.sdkOwnership; + const scheduledSessionId = options?.scheduledSessionId ?? this.sessionId; + const scheduledSessionIdentityEpoch = options?.scheduledSessionIdentityEpoch ?? this.#sessionIdentityEpoch; const selectionFenceGeneration = options?.selectionFenceGeneration ?? this.#selectionFenceGenerationContext.getStore() ?? @@ -8631,6 +9595,8 @@ export class AgentSession { selectionFenceGeneration, deferredPredecessorAgentEnd, sdkOwnership: scheduledSdkOwnership, + scheduledSessionId, + scheduledSessionIdentityEpoch, }); } finally { // The recursive call synchronously re-reserved its settlement @@ -8668,6 +9634,8 @@ export class AgentSession { options?.onError?.(error); }; const scheduledGeneration = options?.generation; + let admittedGeneration = scheduledGeneration; + let freshRootMigrated = false; let busyReschedules = 0; const scheduleAttempt = (delayMs = options?.delayMs): Promise => { const leaseSettlement = Promise.withResolvers(); @@ -8683,10 +9651,21 @@ export class AgentSession { skip("aborted_signal"); return false; } - if (scheduledGeneration !== undefined && this.#promptGeneration !== scheduledGeneration) { + if (admittedGeneration !== undefined && this.#promptGeneration !== admittedGeneration) { + skip("generation_changed"); + return false; + } + if ( + this.sessionId !== scheduledSessionId || + this.#sessionIdentityEpoch !== scheduledSessionIdentityEpoch + ) { skip("generation_changed"); return false; } + if (this.#sessionTransitionKind !== undefined) { + skip("handoff_in_progress"); + return false; + } if (this.#cancelAndSubmitInProgress && !options?.allowDuringCancelAndSubmit) { skip("queue_drained"); return false; @@ -8721,7 +9700,7 @@ export class AgentSession { skip("aborted_signal"); return; } - if (scheduledGeneration !== undefined && this.#promptGeneration !== scheduledGeneration) { + if (admittedGeneration !== undefined && this.#promptGeneration !== admittedGeneration) { skip("generation_changed"); return; } @@ -8729,6 +9708,10 @@ export class AgentSession { skip("queue_drained"); return; } + if (this.#sessionTransitionKind !== undefined) { + skip("handoff_in_progress"); + return; + } // Final synchronous boundary before agent.continue* entry; no await // intervenes between here and method entry. A same-turn continuation // of a terminally aborted turn is denied here, while an independent @@ -8736,7 +9719,11 @@ export class AgentSession { // so a cancelled or emptied continuation never leaves the session on // an identity that escapes the fence. Owned-completion deliveries are // NOT affected (they use followUp). - if (this.#hasQueuedFreshRootRequest()) this.#resumeFromOwnedCompletion(); + if (!freshRootMigrated && this.#hasQueuedFreshRootRequest()) { + this.#resumeFromOwnedCompletion(); + admittedGeneration = this.#promptGeneration; + freshRootMigrated = true; + } if (this.#isTurnContinuationBlocked()) { skip("terminal_turn"); return; @@ -8748,6 +9735,40 @@ export class AgentSession { skip("handoff_in_progress"); return; } + if (scheduledSignal.aborted || this.#isDisposed) { + skip("aborted_signal"); + return; + } + if ( + this.sessionId !== scheduledSessionId || + this.#sessionIdentityEpoch !== scheduledSessionIdentityEpoch + ) { + skip("generation_changed"); + return; + } + if (options?.shouldContinue && !options.shouldContinue()) { + skip("queue_drained"); + return; + } + this.#assertNoSessionTransition(); + if (this.#turnEndPersistenceFailure) await this.#reconcileTurnEndPersistenceFailure(); + if (!canContinue()) return; + if (this.#isTurnContinuationBlocked()) { + skip("terminal_turn"); + return; + } + if (this.#handoffTransitionActive) { + skip("handoff_in_progress"); + return; + } + this.#assertNoSessionTransition(); + if ( + this.sessionId !== scheduledSessionId || + this.#sessionIdentityEpoch !== scheduledSessionIdentityEpoch + ) { + skip("generation_changed"); + return; + } const predecessorAgentEnd = this.#claimDeferredAgentEndForContinuation(predecessorAgentEndHold); const predecessorScope = ( @@ -8885,6 +9906,8 @@ export class AgentSession { resourceRunId: options?.resourceRunId, leaseTask: leaseSettlement.promise, selectionFenceGeneration, + scheduledSessionId, + scheduledSessionIdentityEpoch, }, ); }; @@ -8937,9 +9960,47 @@ export class AgentSession { generation: number, resourceRunId?: string, sdkOwnership?: SdkContinuationOwnership, + scheduledSessionIdentity?: SessionSelectionIdentity, + allowOwnedAutoCompactionTransition = false, ): Promise { + const continuationIdentity = scheduledSessionIdentity ?? this.#captureSessionSelectionIdentity(); + if (this.#sessionTransitionKind !== undefined) { + if (!allowOwnedAutoCompactionTransition || this.#sessionTransitionKind !== "auto-compaction") return false; + const precedingDeferredAutoContinue = this.#deferredAutoContinueDuringTransition; + this.#deferredAutoContinueDuringTransition = () => { + precedingDeferredAutoContinue?.(); + // Track the actual retry task before releasing the transition. A bare + // async callback here could outlive waitForIdle without a session work + // lease, or race the transition fence and silently skip the retry. + this.#schedulePostPromptTask( + async signal => { + if (signal.aborted) return; + await this.#scheduleOverflowRetryContinuation( + generation, + resourceRunId, + sdkOwnership, + continuationIdentity, + ); + }, + { + generation, + resourceRunId, + scheduledSessionId: continuationIdentity.sessionId, + scheduledSessionIdentityEpoch: continuationIdentity.sessionIdentityEpoch, + onSkip: () => this.#logCompactionContinuationSkipped("overflow_retry", "aborted_signal"), + }, + ); + }; + return true; + } + if (!this.#isSessionSelectionIdentityCurrent(continuationIdentity)) { + return false; + } this.#stripOverflowFailedTurnForRetry(); const snapshot = await this.#compactionStateSnapshot(); + if (this.#sessionTransitionKind !== undefined || !this.#isSessionSelectionIdentityCurrent(continuationIdentity)) { + return false; + } if ( snapshot.goal?.status === "paused" && !snapshot.queuedMessages && @@ -8954,7 +10015,9 @@ export class AgentSession { generation, suppressPredecessorAgentEnd: true, resourceRunId, - sdkOwnership, + sdkOwnership, + scheduledSessionId: continuationIdentity.sessionId, + scheduledSessionIdentityEpoch: continuationIdentity.sessionIdentityEpoch, onSkip: reason => this.#logCompactionContinuationSkipped("overflow_retry", reason), onError: error => this.#logCompactionContinuationError("overflow_retry", error), }); @@ -8963,7 +10026,15 @@ export class AgentSession { const compactionSettings = this.settings.getGroup("compaction"); if (compactionSettings.autoContinue !== false) { - this.#scheduleAutoContinuePrompt(generation, false, resourceRunId, undefined, undefined, sdkOwnership); + this.#scheduleAutoContinuePrompt( + generation, + false, + resourceRunId, + undefined, + undefined, + sdkOwnership, + continuationIdentity, + ); return true; } @@ -8978,6 +10049,7 @@ export class AgentSession { deferredSelectionFenceGeneration?: number, deferredPredecessorAgentEnd?: AgentSessionEvent, sdkOwnership?: SdkContinuationOwnership, + scheduledSessionIdentity?: SessionSelectionIdentity, ): void { if (this.#sessionTransitionKind !== undefined || this.#handoffTransitionActive) { this.#deferredAutoContinueDuringTransition = () => @@ -8988,10 +10060,14 @@ export class AgentSession { deferredSelectionFenceGeneration, deferredPredecessorAgentEnd, sdkOwnership, + scheduledSessionIdentity, ); return; } const scheduledGeneration = generation; + const continuationIdentity = scheduledSessionIdentity ?? this.#captureSessionSelectionIdentity(); + const continuationIdentityIsCurrent = (): boolean => + this.#sessionTransitionKind === undefined && this.#isSessionSelectionIdentityCurrent(continuationIdentity); const scheduledSdkOwnership = sdkOwnership ?? this.#captureSdkContinuationOwnership( @@ -9001,12 +10077,16 @@ export class AgentSession { signal: AbortSignal, hasPendingNextTurnMessages = false, ): Promise => { + if (!continuationIdentityIsCurrent()) { + this.#logCompactionContinuationSkipped("auto_continue_prompt", "generation_changed"); + return false; + } const snapshot = await this.#compactionStateSnapshot(); if (signal.aborted) { this.#logCompactionContinuationSkipped("auto_continue_prompt", "aborted_signal"); return false; } - if (this.#isDisposed || this.#promptGeneration !== scheduledGeneration) { + if (this.#isDisposed || this.#promptGeneration !== scheduledGeneration || !continuationIdentityIsCurrent()) { this.#logCompactionContinuationSkipped( "auto_continue_prompt", this.#isDisposed ? "session_disposed" : "generation_changed", @@ -9031,7 +10111,7 @@ export class AgentSession { snapshot.lastAssistantStopReason === "length" || snapshot.goal?.status !== "paused"; if (!authorized) this.emitNotice("info", "Auto-continue skipped: no unfinished work detected"); - return authorized; + return authorized && continuationIdentityIsCurrent(); }; const continuationAdmission = this.#captureScheduledContinuationAdmission(); const selectionFenceGeneration = @@ -9054,6 +10134,7 @@ export class AgentSession { selectionFenceGeneration, predecessorAgentEnd, scheduledSdkOwnership, + continuationIdentity, ); } finally { this.#endSelectionFenceDeferralTracking(selectionFenceGeneration); @@ -9076,7 +10157,7 @@ export class AgentSession { this.#logCompactionContinuationSkipped("auto_continue_prompt", "aborted_signal"); return; } - if (this.#promptGeneration !== scheduledGeneration) { + if (!continuationIdentityIsCurrent() || this.#promptGeneration !== scheduledGeneration) { this.#logCompactionContinuationSkipped("auto_continue_prompt", "generation_changed"); return; } @@ -9086,11 +10167,19 @@ export class AgentSession { // prompt; unknown/paused/terminal workflows keep the // generic continuation and latest-user-intent supremacy. const recoverySnapshot = await this.#compactionStateSnapshot(); + if (!continuationIdentityIsCurrent()) { + this.#logCompactionContinuationSkipped("auto_continue_prompt", "generation_changed"); + return; + } const recoveryPrompt = buildWorkflowRecoveryContinuationPrompt( recoverySnapshot.workflowRecovery, recoverySnapshot.activeSkills, ); const promptText = recoveryPrompt ?? autoContinuePrompt; + if (!continuationIdentityIsCurrent()) { + this.#logCompactionContinuationSkipped("auto_continue_prompt", "generation_changed"); + return; + } await this.#promptWithMessage( { role: "developer", @@ -9130,6 +10219,8 @@ export class AgentSession { generation: scheduledGeneration, resourceRunId, selectionFenceGeneration, + scheduledSessionId: continuationIdentity.sessionId, + scheduledSessionIdentityEpoch: continuationIdentity.sessionIdentityEpoch, onSkip: () => { this.#logCompactionContinuationSkipped("auto_continue_prompt", "aborted_signal"); this.#releaseDeferredAgentEndContinuation(predecessorAgentEndHold); @@ -9141,7 +10232,7 @@ export class AgentSession { async #cancelPostPromptTasks(): Promise { this.#postPromptTasksAbortController.abort(); this.#postPromptTasksAbortController = new AbortController(); - this.#resolveTtsrResume(); + this.#clearCancelledTtsrState(); const pendingTasks = Array.from(this.#postPromptTasks); if (pendingTasks.length === 0) { @@ -9166,10 +10257,18 @@ export class AgentSession { this.#postPromptTaskSelectionFenceGenerations.clear(); this.#postPromptTaskRecoveryExcluded.clear(); this.#releaseDeferredAgentEndContinuations(); - this.#resolveTtsrResume(); + this.#clearCancelledTtsrState(); this.#resolvePostPromptTasks(); } + #clearCancelledTtsrState(): void { + this.#ttsrRetryToken++; + this.#ttsrAbortPending = false; + this.#pendingTtsrInjections = []; + this.#perToolTtsrInjections.clear(); + this.#resolveTtsrResume(); + } + /** * Wait for retry, TTSR resume, and any background continuation to settle. * Loops because a TTSR continuation can trigger a retry (or vice-versa), @@ -9258,37 +10357,43 @@ export class AgentSession { if (otherId === toolCallId) continue; for (const rule of otherBucket) claimedElsewhere.add(rule.name); } - const newlyAdded: string[] = []; for (const rule of rules) { if (seen.has(rule.name) || claimedElsewhere.has(rule.name)) continue; bucket.push(rule); seen.add(rule.name); - newlyAdded.push(rule.name); } if (bucket.length === 0) return; this.#perToolTtsrInjections.set(toolCallId, bucket); - // Claim the rules in the TTSR manager so subsequent deltas in this same - // turn (e.g. a sibling tool call's argument stream) don't re-match them. - // Persistence still happens in #ttsrAfterToolCall when the tool actually - // produces a result we can fold the reminder into. - if (newlyAdded.length > 0) { - this.#ttsrManager?.markInjectedByNames(newlyAdded); - } + // Same-turn sibling dedupe is owned by the per-tool buckets above. Do not + // mark the durable repeat gate until a tool actually dispatches and returns; + // cancelled or policy-blocked calls must remain eligible on a later turn. } /** `afterToolCall` hook: fold any per-tool TTSR reminders into the result. */ - #ttsrAfterToolCall(ctx: AfterToolCallContext): AfterToolCallResult | undefined { + #ttsrAfterToolCall(ctx: AfterToolCallContext, expectedRules: Rule[] | undefined): AfterToolCallResult | undefined { const rules = this.#perToolTtsrInjections.get(ctx.toolCall.id); if (!rules || rules.length === 0) return undefined; + if (rules !== expectedRules) return undefined; this.#perToolTtsrInjections.delete(ctx.toolCall.id); const reminder = rules .map(r => prompt.render(ttsrToolReminderTemplate, { name: r.name, path: r.path, content: r.content })) .join("\n\n"); - // The TTSR manager was already claimed at bucket time; only persistence remains. const ruleNames = rules.map(r => r.name.trim()).filter(n => n.length > 0); if (ruleNames.length > 0) { - const records = this.#ttsrManager?.getInjectedRecords().filter(record => ruleNames.includes(record.name)); - this.sessionManager.appendTtsrInjection(ruleNames, records, this.#ttsrManager?.getMessageCount()); + const manager = this.#ttsrManager; + const previousRecords = manager?.getInjectedRecords() ?? []; + const previousMessageCount = manager?.getMessageCount() ?? 0; + manager?.markInjectedByNames(ruleNames); + try { + this.sessionManager.appendTtsrInjection( + ruleNames, + manager?.getInjectedRecords(), + manager?.getMessageCount(), + ); + } catch (error) { + manager?.replacePersistedState(previousRecords, previousMessageCount); + throw error; + } } return { @@ -9314,9 +10419,20 @@ export class AgentSession { if (uniqueRuleNames.length === 0) { return; } - this.#ttsrManager?.markInjectedByNames(uniqueRuleNames); - const records = this.#ttsrManager?.getInjectedRecords().filter(record => uniqueRuleNames.includes(record.name)); - this.sessionManager.appendTtsrInjection(uniqueRuleNames, records, this.#ttsrManager?.getMessageCount()); + const manager = this.#ttsrManager; + const previousRecords = manager?.getInjectedRecords() ?? []; + const previousMessageCount = manager?.getMessageCount() ?? 0; + manager?.markInjectedByNames(uniqueRuleNames); + try { + this.sessionManager.appendTtsrInjection( + uniqueRuleNames, + manager?.getInjectedRecords(), + manager?.getMessageCount(), + ); + } catch (error) { + manager?.replacePersistedState(previousRecords, previousMessageCount); + throw error; + } } #findTtsrAssistantIndex(targetTimestamp: number | undefined): number { @@ -10371,6 +11487,11 @@ export class AgentSession { previousSessionFile: string | undefined, options: { retirePredecessorRegistrations?: boolean } = {}, ): void { + if (this.#turnEndPersistenceFailure) { + throw Object.assign(new Error("Reconcile repeat-state persistence before changing session history."), { + code: "session_persistence_blocked", + }); + } const previousEndpointId = this.#asyncJobEndpointId(previousSessionId, previousSessionFile); const currentEndpointId = this.#asyncJobEndpointId( this.sessionManager.getSessionId(), @@ -10384,7 +11505,10 @@ export class AgentSession { // neither the mapping nor the tuples keyed under it are ours to move // or retire (review thread P1). const ownManager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance(); - if (predecessorOwner !== undefined && predecessorOwner !== ownManager) return; + if (predecessorOwner !== undefined && predecessorOwner !== ownManager) { + this.#commitSessionIdentityTransition(); + return; + } if (predecessorOwner !== undefined) { const rekeyed = AsyncJobManager.rekeyForEndpoint(previousEndpointId, currentEndpointId, predecessorOwner); if (!rekeyed) { @@ -10415,6 +11539,7 @@ export class AgentSession { if (previousEndpointId !== currentEndpointId && options.retirePredecessorRegistrations !== false) { retireOwnedRegistrationsForEndpoint(previousEndpointId); } + this.#commitSessionIdentityTransition(); } #rekeyHindsightMemoryForCurrentSessionId(): void { @@ -10524,20 +11649,6 @@ export class AgentSession { this.#disposeTimeoutMs = Math.max(0, timeoutMs); } - /** Bind coordinator runtime-state persistence to this session in tests. */ - setCoordinatorRuntimeStateFileForTests(stateFile: string): void { - if (!path.isAbsolute(stateFile)) { - throw new Error("Coordinator runtime-state test path must be absolute."); - } - this.#coordinatorRuntimeStateFileOverrideForTests = path.resolve(stateFile); - this.#registerRuntimeStateFinalizer(); - } - - /** Read the test-only coordinator runtime-state path bound to this session. */ - getCoordinatorRuntimeStateFileForTests(): string | undefined { - return this.#coordinatorRuntimeStateFileOverrideForTests; - } - trackPostPromptTaskForTests(task: Promise): void { this.#trackPostPromptTask(task, this.#selectionFenceGeneration); } @@ -10554,9 +11665,6 @@ export class AgentSession { async #dispose(): Promise { const disposeFailures: Array<{ label: string; error: unknown; critical: boolean }> = []; - this.#restoreManagedFallbackGetApiKey?.(); - this.#restoreManagedFallbackGetApiKey = undefined; - this.#managedFallbackNextCredentialOverride = undefined; const awaitDisposeStep = async ( label: string, operation: Promise, @@ -10923,7 +12031,7 @@ export class AgentSession { /** Whether agent is currently streaming a response */ get isStreaming(): boolean { - return this.agent.state.isStreaming || this.#livePromptsInFlight() > 0; + return this.agent.state.isStreaming || this.agent.state.streamMessage !== null || this.#livePromptsInFlight() > 0; } /** Wait until streaming and session settlement work are fully settled. */ @@ -10994,6 +12102,20 @@ export class AgentSession { }); } + /** Bind coordinator runtime-state persistence to this session in tests. */ + setCoordinatorRuntimeStateFileForTests(stateFile: string): void { + if (!path.isAbsolute(stateFile)) { + throw new Error("Coordinator runtime-state test path must be absolute."); + } + this.#coordinatorRuntimeStateFileOverrideForTests = path.resolve(stateFile); + this.#registerRuntimeStateFinalizer(); + } + + /** Read the test-only coordinator runtime-state path bound to this session. */ + getCoordinatorRuntimeStateFileForTests(): string | undefined { + return this.#coordinatorRuntimeStateFileOverrideForTests; + } + /** Enable sidecar write failure reporting for retry-test teardown. */ trackCoordinatorRuntimeStatePersistenceFailuresForTests(): void { this.#coordinatorPersistFailuresForTests = []; @@ -12031,8 +13153,10 @@ export class AgentSession { previousSelectedMCPToolNames?: string[]; previousSelectedDiscoveredBuiltinToolNames?: string[]; nextSelectedDiscoveredBuiltinToolNames?: string[]; + admission?: () => boolean; }, ): Promise { + if (options?.admission?.() === false) return; toolNames = [...new Set([...toolNames.map(name => name.toLowerCase()), ...this.#mandatoryMCPToolNames])]; const previousSelectedMCPToolNames = options?.previousSelectedMCPToolNames ?? this.getSelectedMCPToolNames(); const previousSelectedDiscoveredBuiltinToolNames = @@ -12075,6 +13199,10 @@ export class AgentSession { const built = await this.#runAdmittedBaseSystemPromptRebuild(() => this.#rebuildSystemPrompt!(validToolNames, this.#toolRegistry), ); + if (options?.admission?.() === false) { + if (generation === this.#baseSystemPromptGeneration) this.#pendingAppliedToolSignature = undefined; + return; + } if (this.#isDisposed) { if (generation === this.#baseSystemPromptGeneration) { this.#pendingAppliedToolSignature = undefined; @@ -12311,8 +13439,8 @@ export class AgentSession { options: { selectedMCPToolNames?: string[]; activeMCPToolNames?: string[]; - mandatoryMCPToolNames?: string[]; persistMCPSelection?: boolean; + mandatoryMCPToolNames?: readonly string[]; } = {}, ): Promise { const previousSelectedMCPToolNames = this.getSelectedMCPToolNames(); @@ -12345,12 +13473,12 @@ export class AgentSession { this.#toolRegistry.set(finalTool.name, finalTool); } - this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry()); - if (options.mandatoryMCPToolNames !== undefined) { + if (options.mandatoryMCPToolNames) { this.#mandatoryMCPToolNames = new Set( options.mandatoryMCPToolNames.map(name => name.toLowerCase()).filter(name => this.#toolRegistry.has(name)), ); } + this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry()); this.#pruneSelectedMCPToolNames(); const hasPersistedMCPToolSelection = this.buildDisplaySessionContext().hasPersistedMCPToolSelection; if (options.selectedMCPToolNames) { @@ -12403,11 +13531,23 @@ export class AgentSession { /** * Refresh plugin sub-skill tools after workflow/sub-skill activation or phase changes. */ - async refreshGjcSubskillTools(): Promise { + async refreshGjcSubskillTools(expectedIdentity?: SessionSelectionIdentity): Promise { + const refreshGeneration = ++this.#gjcSubskillToolRefreshGeneration; + const refreshIdentity = expectedIdentity ?? this.#captureSessionSelectionIdentity(); + const identityIsCurrent = (): boolean => this.#isSessionSelectionIdentityAdmitted(refreshIdentity); + const refreshIsCurrent = (): boolean => + identityIsCurrent() && refreshGeneration === this.#gjcSubskillToolRefreshGeneration; + const stopIfStale = (): boolean => { + if (refreshIsCurrent()) return false; + if (!this.#isDisposed) this.#requestSubskillToolReconciliation(); + return true; + }; + if (stopIfStale()) return; const activeState = await readVisibleSkillActiveState( this.sessionManager.getCwd(), this.sessionManager.getSessionId(), ); + if (stopIfStale()) return; const activeSkill = this.#activeSkillState?.skill ?? activeState?.skill ?? @@ -12421,23 +13561,33 @@ export class AgentSession { this.#toolRegistry.delete(name); } this.#gjcSubskillToolNames.clear(); + this.#gjcSubskillToolSignature = undefined; this.#invalidateDiscoveryCaches(); await this.#applyActiveToolsByName( previousActiveToolNames.filter(name => !previousGjcSubskillToolNames.has(name)), + { admission: refreshIsCurrent }, ); + stopIfStale(); return; } const cwd = this.sessionManager.getCwd(); const sessionId = this.#activeSkillState?.sessionId ?? activeState?.session_id ?? this.sessionManager.getSessionId(); - if (this.#gjcSubskillToolNames.size === 0 && !(await this.#hasActiveGjcSubskillTools(parent, sessionId))) return; + if (this.#gjcSubskillToolNames.size === 0) { + const hasActiveSubskillTools = await this.#hasActiveGjcSubskillTools(parent, sessionId); + if (stopIfStale()) return; + if (!hasActiveSubskillTools) return; + } + if (stopIfStale()) return; const phase = await resolveCurrentPhaseForParent({ cwd, sessionId, parent }); + if (stopIfStale()) return; const reservedToolNames = Array.from(this.#toolRegistry.keys()).filter( name => !this.#gjcSubskillToolNames.has(name), ); const customTools = await loadActiveSubskillTools({ cwd, sessionId, parent, phase, reservedToolNames }); + if (stopIfStale()) return; const nextToolNames = customTools.map(tool => tool.name); const uniqueToolNames = new Set(nextToolNames); if (uniqueToolNames.size !== nextToolNames.length) { @@ -12486,7 +13636,9 @@ export class AgentSession { ...autoActivatedGjcSubskillToolNames, ]), ), + { admission: refreshIsCurrent }, ); + stopIfStale(); } /** Whether auto-compaction is currently running */ @@ -12603,6 +13755,11 @@ export class AgentSession { /** Main startup calls this exactly once, after a strict open returned `kind: "opened"`. */ async continuePersistedHistory(): Promise { + await this.#withSessionAdmission("prompt", async () => this.#continuePersistedHistory()); + } + + async #continuePersistedHistory(): Promise { + this.#assertNoHandoffTransition(); this.#assertExternalSessionIngress(); this.#assertRecoveryHydrationPromoted(); this.#removeEphemeralCustomMessages(); @@ -12653,6 +13810,7 @@ export class AgentSession { // Re-check after the awaited preparation: a handoff can engage during the // volatile-context/hindsight awaits above and this would otherwise start a // turn against the session being handed off. + this.#assertNoSessionTransition(); this.#assertExternalSessionIngress(); await this.agent.continue({ ...this.#managedFallbackPromptOptions(), @@ -13227,17 +14385,20 @@ export class AgentSession { } } - #attachAskTool(): void { - if (this.#explicitEmptyToolSelection) return; + #attachAskTool(deferPromptRefresh = false): boolean { + if (this.#explicitEmptyToolSelection) return false; const askTool = this.#toolRegistry.get("ask"); - if (!askTool || this.getActiveToolNames().includes(askTool.name)) return; + if (!askTool || this.getActiveToolNames().includes(askTool.name)) return false; this.#setGuardedAgentTools([...this.agent.state.tools, askTool]); this.#invalidateDiscoveryCaches(); - void this.refreshBaseSystemPrompt().catch(error => { - logger.warn("Failed to refresh system prompt after workflow gate ask tool activation", { - error: error instanceof Error ? error.message : String(error), + if (!deferPromptRefresh) { + void this.refreshBaseSystemPrompt().catch(error => { + logger.warn("Failed to refresh system prompt after workflow gate ask tool activation", { + error: error instanceof Error ? error.message : String(error), + }); }); - }); + } + return true; } get goalRuntime(): GoalRuntime { @@ -13655,7 +14816,9 @@ export class AgentSession { async prompt(text: string, options?: PromptOptions): Promise { const releaseStartupPromptWaiter = this.#reserveStartupPromptWaiter(); try { + if (this.#turnEndPersistenceFailure) await this.#reconcileTurnEndPersistenceFailure(); await this.#promptInternal(text, options, releaseStartupPromptWaiter); + if (this.#turnEndPersistenceFailure) throw this.#turnEndPersistenceFailure.error; // Agent-core converts listener failures into an aborted response. Keep a // rejected persistence fence typed at the public prompt boundary instead // of reporting the misleading provider-level "Request was aborted". @@ -13696,6 +14859,112 @@ export class AgentSession { ); } + async #reconcileTurnEndPersistenceFailure(): Promise { + const failure = this.#turnEndPersistenceFailure; + if (!failure) return; + if (!this.#ttsrManager) { + throw Object.assign( + new Error("Repeat-state persistence must be reconciled before another prompt can start."), + { + code: "session_persistence_blocked", + cause: failure.error, + }, + ); + } + try { + this.sessionManager.appendTtsrInjection([], this.#ttsrManager.getInjectedRecords(), failure.nextMessageCount); + this.#ttsrManager.restoreMessageCount(failure.nextMessageCount); + if (failure.slot.error === failure.error) failure.slot.error = undefined; + this.#turnEndPersistenceFailure = undefined; + } catch (error) { + failure.error = error; + failure.slot.error = error; + throw Object.assign( + new Error("Repeat-state persistence must be reconciled before another prompt can start."), + { + code: "session_persistence_blocked", + cause: error, + }, + ); + } + } + + async #reconcileTerminalPersistenceFailure(): Promise { + await this.#agentEndHandlingPromise; + if (this.#terminalPersistenceRecoveryPromise) return this.#terminalPersistenceRecoveryPromise; + const recovery = this.#terminalPersistenceRecovery; + if (!recovery) return; + const task = (async () => { + try { + if (recovery.sessionId !== this.sessionId || recovery.sessionIdentityEpoch !== this.#sessionIdentityEpoch) { + throw new Error("Terminal persistence recovery belongs to a different session identity."); + } + await this.sessionManager.recoverPersistenceFailure(); + const previousEntryId = recovery.entryId ?? getSessionMessageEntryId(recovery.message); + const recoveredEntry = previousEntryId + ? this.sessionManager.getEntryForFidelity(previousEntryId) + : undefined; + let canonicalMessage = recoveredEntry?.type === "message" ? recoveredEntry.message : undefined; + if (!canonicalMessage) { + this.sessionManager.appendMessage(recovery.message); + canonicalMessage = recovery.message; + } + if ( + canonicalMessage !== recovery.message && + canonicalMessage.role === "assistant" && + recovery.message.role === "assistant" + ) { + transferSessionMessageIdentity([canonicalMessage], [recovery.message]); + } + const liveProjection = this.agent.state.streamMessage; + if (liveProjection?.role === "assistant") this.agent.discardRejectedAssistantEvent(liveProjection); + this.agent.replaceMessages(this.sessionManager.buildSessionContext().messages, { + historyRewrite: { reason: "terminal-persistence-recovery", preserveSeededPrefix: true }, + }); + this.#syncTodoPhasesFromBranch(); + if (recovery.attemptScopeKey !== undefined) { + this.#currentSessionIdentityAttemptScopeKeys.delete(recovery.attemptScopeKey); + this.#retiredSessionIdentityAttemptScopeKeys.add(recovery.attemptScopeKey); + } + this.#terminalPersistenceRecovery = undefined; + queueMicrotask(() => { + try { + this.#flushPendingBashMessages(); + this.#flushPendingPythonMessages(); + } catch (error) { + logger.warn("Deferred execution receipt flush failed after persistence recovery", { + error: error instanceof Error ? error.message : String(error), + }); + } + }); + this.#flushOrSchedulePendingBackgroundExchanges(); + this.emitNotice( + "info", + "Recovered interrupted assistant output into canonical session history.", + "terminal-persistence-recovered", + ); + } catch (error) { + throw Object.assign( + new Error("Session persistence must be reconciled before another prompt can start.", { cause: error }), + { code: "session_persistence_blocked" }, + ); + } + })(); + this.#terminalPersistenceRecoveryPromise = task; + try { + await task; + } finally { + if (this.#terminalPersistenceRecoveryPromise === task) this.#terminalPersistenceRecoveryPromise = undefined; + } + } + + #assertTerminalPersistenceSettledForHistoryMutation(): void { + if (!this.#terminalPersistenceRecovery) return; + throw Object.assign(new Error("Reconcile terminal persistence before rewriting session history."), { + code: "session_persistence_blocked", + }); + } + async #promptInternal( text: string, options: PromptOptions | undefined, @@ -13931,7 +15200,12 @@ export class AgentSession { message: Pick, "customType" | "details">, active: boolean, persistActiveState = true, + expectedIdentity?: SessionSelectionIdentity, + awaitAskPromptRebuild = true, ): Promise { + const identityIsCurrent = (): boolean => + expectedIdentity === undefined || this.#isSessionSelectionIdentityAdmitted(expectedIdentity); + if (!identityIsCurrent()) return; if (message.customType !== SKILL_PROMPT_MESSAGE_TYPE) return; const details = message.details; if (!details || typeof details !== "object") return; @@ -13942,7 +15216,6 @@ export class AgentSession { // observational state-sync below (whose failures are swallowed by // #syncSkillPromptActiveStateSafely): attach ask first so canonical // workflow skills can always call it. - if (active && isCanonicalGjcWorkflowSkill(skill)) this.#attachAskTool(); const sessionId = this.sessionManager.getSessionId(); // Canonical GJC workflow skills (deep-interview, ralplan, ultragoal, autoresearch) // own their `.gjc/state/skill-active-state.json` row through the @@ -13950,43 +15223,58 @@ export class AgentSession { // observer must not overwrite an existing row (that clobbered handoff // lineage `handoff_from`/`handoff_at` and desynced the HUD). But a fresh // `/skill:` invocation has no row yet, so seed `.gjc/state` - // idempotently here: `ensureWorkflowSkillActivationState` writes the + // idempotently here: `ensureWorkflowSkillActivationSeed` writes the // initial mode-state + active row only when the skill is not already // active, so the mutation guard and Stop hook engage immediately instead // of relying on the skill prompt to run its own state-init steps. + const subskillDetails = details as { + subskillActivation?: LoadedSubskillActivation; + subskillActivationSet?: LoadedSubskillActivation[]; + }; + const subskillActivations = + subskillDetails.subskillActivationSet && subskillDetails.subskillActivationSet.length > 0 + ? subskillDetails.subskillActivationSet + : subskillDetails.subskillActivation + ? [subskillDetails.subskillActivation] + : []; + if (active && isCanonicalGjcWorkflowSkill(skill)) { + const attachedAsk = this.#attachAskTool(awaitAskPromptRebuild); + if (awaitAskPromptRebuild) { + if (attachedAsk) await this.refreshBaseSystemPrompt(); + else await this.#waitForAdmittedBaseSystemPromptRebuilds(); + } + if (!identityIsCurrent()) return; + } + let activationSeed: WorkflowSkillActivationSeed | undefined; if (active && persistActiveState) { - await ensureWorkflowSkillActivationState({ + activationSeed = await ensureWorkflowSkillActivationSeed({ cwd: this.sessionManager.getCwd(), skill, sessionId, + activeSubskills: + subskillActivations.length > 0 ? subskillActivations.map(toActiveSubskillEntry) : undefined, }); - const subskillDetails = details as { - subskillActivation?: LoadedSubskillActivation; - subskillActivationSet?: LoadedSubskillActivation[]; - }; - const subskillActivations = - subskillDetails.subskillActivationSet && subskillDetails.subskillActivationSet.length > 0 - ? subskillDetails.subskillActivationSet - : subskillDetails.subskillActivation - ? [subskillDetails.subskillActivation] - : []; - if (subskillActivations.length > 0) { - const skillBoundActivation = subskillDetails.subskillActivation ?? subskillActivations[0]; - await syncSkillActiveState({ - cwd: this.sessionManager.getCwd(), - skill, - active: true, - phase: skillBoundActivation?.phase, - sessionId, - active_subskills: subskillActivations.map(toActiveSubskillEntry), - }); + if (!identityIsCurrent()) { + if (activationSeed.seeded) await activationSeed.rollback(); + return; } } + if (!identityIsCurrent()) return; // In-memory tracking keeps `getActiveSkillState` accurate for the chain guard. this.#restoredWorkflowSkillState = undefined; this.#activeSkillState = active ? { skill, sessionId } : undefined; if (active) { - await this.refreshGjcSubskillTools(); + await this.refreshGjcSubskillTools(expectedIdentity); + if (!identityIsCurrent()) { + if (activationSeed?.seeded) await activationSeed.rollback(); + if (this.#activeSkillState?.skill === skill && this.#activeSkillState.sessionId === sessionId) { + this.#activeSkillState = undefined; + } + await this.refreshGjcSubskillTools(); + return; + } + } else { + await this.refreshGjcSubskillTools(expectedIdentity); } } @@ -13994,13 +15282,25 @@ export class AgentSession { message: Pick, "customType" | "details">, active: boolean, persistActiveState = true, + expectedIdentity?: SessionSelectionIdentity, + awaitAskPromptRebuild = true, ): Promise { + const synchronization = this.#syncSkillPromptActiveState( + message, + active, + persistActiveState, + expectedIdentity, + awaitAskPromptRebuild, + ); + this.#skillStateSynchronizations.add(synchronization); try { - await this.#syncSkillPromptActiveState(message, active, persistActiveState); + await synchronization; } catch { // Skill HUD state is observational; a filesystem write failure must not // interrupt the prompt turn it is visualizing. The native Stop hook still // performs authoritative workflow blocking from persisted state. + } finally { + this.#skillStateSynchronizations.delete(synchronization); } } @@ -14087,6 +15387,7 @@ export class AgentSession { await this.#withSessionAdmission( "prompt", async admission => { + const preflightIdentity = this.#captureSessionSelectionIdentity(); this.#throwIfPromptPreflightCancelled(admissionGeneration, admissionSignal); if (options?.preflightSignal?.aborted) throw promptPreflightCancelledError(); const customMessage: CustomMessage = { @@ -14106,7 +15407,27 @@ export class AgentSession { let durableAcceptanceCompleted = false; const commitAcceptance = async () => { activationSeed = await this.#seedSkillPromptActiveStateSafely(customMessage); - await this.#syncSkillPromptActiveStateSafely(customMessage, true, activationSeed?.seeded !== true); + if (!this.#isSessionSelectionIdentityCurrent(preflightIdentity)) { + await activationSeed?.rollback(); + throw promptPreflightCancelledError(); + } + await this.#syncSkillPromptActiveStateSafely( + customMessage, + true, + activationSeed?.seeded !== true, + preflightIdentity, + ); + if (!this.#isSessionSelectionIdentityCurrent(preflightIdentity)) { + await activationSeed?.rollback(); + const activeSkillState = this.#activeSkillState; + if ( + activeSkillState && + activeSkillState.skill === (customMessage.details as { name?: string } | undefined)?.name && + activeSkillState.sessionId === preflightIdentity.sessionId + ) + this.#activeSkillState = undefined; + throw promptPreflightCancelledError(); + } if (options?.preflightSignal?.aborted) { await activationSeed?.rollback(); throw promptPreflightCancelledError(); @@ -14126,6 +15447,9 @@ export class AgentSession { ...internalOptions, onPreflightAccepted: undefined, onPreflightAcceptCommit: commitAcceptance, + onPreflightCommitted: () => { + durableAcceptanceCompleted = true; + }, admissionLease: admission, resetRetryReplaySafety: true, }); @@ -14136,7 +15460,8 @@ export class AgentSession { } throw error; } finally { - if (!preflightCancelled) await this.#syncSkillPromptActiveStateSafely(customMessage, false); + if (!preflightCancelled && this.#isSessionSelectionIdentityCurrent(preflightIdentity)) + await this.#syncSkillPromptActiveStateSafely(customMessage, false, true, preflightIdentity); } }, options?.preflightSignal, @@ -14162,19 +15487,21 @@ export class AgentSession { skipPostPromptRecoveryWait?: boolean; predecessorAgentEndHold?: symbol; admissionLease?: SessionAdmissionLease; + onPreflightCommitted?: () => void; skipInitialSteeringPoll?: boolean; onRunAccepted?: (handle: AttemptRunHandle) => void; onFinalPreflight?: (context: { hasPendingNextTurnMessages: boolean }) => Promise; resetRetryReplaySafety?: boolean; }, ): Promise { - this.#assertNoHandoffTransition(); + this.#assertTransitionIngressAllowed(); + await this.#reconcileTerminalPersistenceFailure(); if (options?.preflightSignal?.aborted) throw promptPreflightCancelledError(); await awaitPromptInvocationPreflight(this.#agentEndPublicationPromise, options?.preflightSignal); // Re-check after the publication await: a handoff can engage during that // window, and #beginInFlight below would otherwise start a turn against the // session being handed off. - this.#assertNoHandoffTransition(); + this.#assertTransitionIngressAllowed(); const inFlightPrompt = this.#beginInFlight(); // Discard hidden next-turn successors queued by a PREVIOUS turn that a // terminal abort closed. This must run BEFORE the admission bump below: @@ -14248,7 +15575,7 @@ export class AgentSession { // Validate model if (!this.model) { - throw new NoModelSelectedError(); + throw new Error(formatNoModelOnboardingError()); } // Validate API key @@ -14583,6 +15910,7 @@ export class AgentSession { else options?.onPreflightAccepted?.(); this.#assertExternalSessionIngress({ allowCancelAndSubmit: true }); }, + onPreflightCommitted: options?.onPreflightCommitted, }); const activeTerminalScope = this.#activeAttemptScope; const terminalAttemptScope = @@ -14839,6 +16167,7 @@ export class AgentSession { * Queue a steering message to interrupt the agent mid-run. */ async steer(text: string, images?: ImageContent[]): Promise { + this.#assertTransitionIngressAllowed(); const hasUsableImage = images?.some(image => typeof image?.data === "string" && image.data.trim().length > 0) === true; if (typeof text !== "string" || (text.trim().length === 0 && !hasUsableImage)) @@ -14868,6 +16197,7 @@ export class AgentSession { images?: ImageContent[], options?: Pick, ): Promise { + this.#assertTransitionIngressAllowed(); const hasUsableImage = images?.some(image => typeof image?.data === "string" && image.data.trim().length > 0) === true; if (typeof text !== "string" || (text.trim().length === 0 && !hasUsableImage)) @@ -14980,8 +16310,8 @@ export class AgentSession { /** * One-shot preflight-abort binding: an invocation cancelled after its queue * admission must cancel the submission it admitted, so an aborted dispatch - * can never execute later. Explicit and implicitly diverted queue admissions - * share this helper so their cancellation semantics cannot diverge + * can never execute later. Explicit and implicitly diverted queue admissions, + * including follow-up and steer deliveries, share this helper so their cancellation semantics cannot diverge * (exact-head review P1). */ #bindPreflightAbortCancellation( @@ -15411,6 +16741,7 @@ export class AgentSession { } const queuedMessages = [...this.#pendingNextTurnMessages]; + const queuedSessionIdentity = this.#captureSessionSelectionIdentity(); this.#pendingNextTurnMessages = []; // Reclassify deferred envelopes at the drain boundary: a monitor // notification queued via the deferAgentInitiatedTurns branch never ran @@ -15462,8 +16793,12 @@ export class AgentSession { const prependMessages = reclassified.slice(0, -1).map(entry => entry.message); const textContent = this.#getCustomMessageTextContent(message); - await this.#syncSkillPromptActiveStateSafely(message, true); + await this.#syncSkillPromptActiveStateSafely(message, true, true, queuedSessionIdentity); try { + if (!this.#isSessionSelectionIdentityCurrent(queuedSessionIdentity)) { + this.#settleDeliveredOwnedRegistrations(reclassified.map(entry => entry.message)); + return; + } if (this.#isDisposed || this.#sessionAdmissionClosing || this.#disposeAbortController.signal.aborted) { this.#settleDeliveredOwnedRegistrations(reclassified.map(entry => entry.message)); return; @@ -15472,6 +16807,10 @@ export class AgentSession { this.#pendingNextTurnMessages = [...reclassified, ...this.#pendingNextTurnMessages]; return; } + if (!this.#isSessionSelectionIdentityCurrent(queuedSessionIdentity)) { + this.#settleDeliveredOwnedRegistrations(reclassified.map(entry => entry.message)); + return; + } await this.#promptWithMessage(message, textContent, { prependMessages, skipPostPromptRecoveryWait: true, @@ -15492,7 +16831,9 @@ export class AgentSession { this.#pendingNextTurnMessages = [...reclassified, ...this.#pendingNextTurnMessages]; throw error; } finally { - await this.#syncSkillPromptActiveStateSafely(message, false); + if (this.#isSessionSelectionIdentityCurrent(queuedSessionIdentity)) { + await this.#syncSkillPromptActiveStateSafely(message, false, true, queuedSessionIdentity); + } } } @@ -15515,7 +16856,11 @@ export class AgentSession { } #settleOwnedCompletionEnvelope(envelope: OwnedCompletionEnvelope): void { - const manager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance(); + const manager = AsyncJobManager.forEndpoint(envelope.registration.endpointId); + if (!manager) { + unregisterOwnedRegistration(envelope.registration); + return; + } const job = manager?.getJob(envelope.registration.jobId); const status = job?.generation === envelope.registration.jobGeneration ? job?.status : undefined; // Evicted jobs have no live record (job === undefined); terminal statuses @@ -15610,6 +16955,8 @@ export class AgentSession { origin?: "turn" | "external"; }, ): Promise { + this.#assertTransitionIngressAllowed(); + if (this.#terminalPersistenceRecovery) await this.#reconcileTerminalPersistenceFailure(); this.#assertRecoveryHydrationPromoted(); this.#assertExternalSessionIngress(); const appMessage: CustomMessage = { @@ -15621,6 +16968,18 @@ export class AgentSession { attribution: message.attribution ?? "agent", timestamp: Date.now(), }; + const ownedCompletions = (appMessage.details as { ownedCompletions?: unknown } | null | undefined) + ?.ownedCompletions; + if ( + Array.isArray(ownedCompletions) && + ownedCompletions.some( + envelope => + isOwnedCompletionEnvelope(envelope) && + envelope.registration.endpointId !== this.#ownedRegistrationEndpoint(), + ) + ) { + throw new Error("Owned completion belongs to a different session endpoint."); + } const preclaimedUserIntentEpoch = this.#deepInterviewPreclaimedCustomInputEpochs.get(message); this.#deepInterviewPreclaimedCustomInputEpochs.delete(message); if (appMessage.attribution === "user") { @@ -15712,14 +17071,18 @@ export class AgentSession { // Every direct idle admission is a NEW ROOT TURN: allocate a fresh // lineage so the turn never remints the previous turn's identical // lineage+epoch (review thread P1). + const directTurnIdentity = this.#captureSessionSelectionIdentity(); this.#resumeFromOwnedCompletion(); - await this.#syncSkillPromptActiveStateSafely(appMessage, true); + await this.#syncSkillPromptActiveStateSafely(appMessage, true, true, directTurnIdentity); try { + if (!this.#isSessionSelectionIdentityCurrent(directTurnIdentity)) return; await this.#promptWithMessage(appMessage, this.#getCustomMessageTextContent(appMessage), { skipPostPromptRecoveryWait: true, }); } finally { - await this.#syncSkillPromptActiveStateSafely(appMessage, false); + if (this.#isSessionSelectionIdentityCurrent(directTurnIdentity)) { + await this.#syncSkillPromptActiveStateSafely(appMessage, false, true, directTurnIdentity); + } // The direct idle admission bypasses onFollowUpConsumed: // settle any delivered owned-completion envelope so a // terminal registration does not occupy the registry until @@ -15766,14 +17129,18 @@ export class AgentSession { // the turn would remint the previous turn's identical lineage+epoch // and a later scope:"owned" abort could capture the earlier turn's // unrelated jobs (review thread P1). + const directTurnIdentity = this.#captureSessionSelectionIdentity(); this.#resumeFromOwnedCompletion(); - await this.#syncSkillPromptActiveStateSafely(appMessage, true); + await this.#syncSkillPromptActiveStateSafely(appMessage, true, true, directTurnIdentity); try { + if (!this.#isSessionSelectionIdentityCurrent(directTurnIdentity)) return; await this.#promptWithMessage(appMessage, this.#getCustomMessageTextContent(appMessage), { skipPostPromptRecoveryWait: true, }); } finally { - await this.#syncSkillPromptActiveStateSafely(appMessage, false); + if (this.#isSessionSelectionIdentityCurrent(directTurnIdentity)) { + await this.#syncSkillPromptActiveStateSafely(appMessage, false, true, directTurnIdentity); + } // The direct idle admission bypasses onFollowUpConsumed: // settle any delivered owned-completion envelope so a terminal // registration does not occupy the registry until saturation — @@ -15862,6 +17229,7 @@ export class AgentSession { content: string | (TextContent | ImageContent)[], options?: SendUserMessageOptions, ): Promise { + this.#assertTransitionIngressAllowed(); assertLegacySendUserMessageOptions(options); await this.#sendUserMessage(content, options); } @@ -15876,6 +17244,7 @@ export class AgentSession { content: string | (TextContent | ImageContent)[], options: TrackedSendUserMessageOptions, ): Promise { + this.#assertTransitionIngressAllowed(); assertTrackedSendUserMessageOptions(options); const submission = await this.#sendUserMessage(content, options); if (submission === undefined) throw new Error("Tracked user message did not produce a submission handle."); @@ -16662,10 +18031,13 @@ export class AgentSession { compactionEntryId: string, firstKeptEntryId: string, fromExtension?: boolean, + identityIsCurrent?: () => boolean, ): Promise { const eviction = this.sessionManager.evictCompactedContent(firstKeptEntryId, compactionEntryId); if (eviction.evictedEntries > 0) await this.sessionManager.rewriteEntries(); + if (identityIsCurrent?.() === false) return undefined; const sessionContext = this.buildDisplaySessionContext(); + if (identityIsCurrent?.() === false) return undefined; this.agent.replaceMessages(sessionContext.messages, { historyRewrite: { reason: "compaction", preserveSeededPrefix: true }, }); @@ -16681,12 +18053,17 @@ export class AgentSession { | undefined; if (this.#extensionRunner && savedCompactionEntry) { - await this.#runCommittedSuccessorHook(() => - this.#extensionRunner!.emit({ - type: "session_compact", - compactionEntry: savedCompactionEntry, - fromExtension: fromExtension ?? false, - }), + if (identityIsCurrent?.() === false) return undefined; + await this.#withPostCommitTransitionIngress(() => + this.#withActiveCompactionHook(() => + this.#runCommittedSuccessorHook(() => + this.#extensionRunner!.emit({ + type: "session_compact", + compactionEntry: savedCompactionEntry, + fromExtension: fromExtension ?? false, + }), + ), + ), ); } @@ -17288,16 +18665,6 @@ export class AgentSession { ...(registeredScope ? { terminalScope: registeredScope } : {}), }; } - const retainedProof = await this.agent.resourceLedger.waitForSettlement(handle, { graceMs: 0 }); - if ( - retainedProof.status === "settled" || - (retainedProof.status === "unfenced" && retainedProof.reason !== "unknown_run") - ) { - return { - ...retainedProof, - ...(registeredScope ? { terminalScope: registeredScope } : {}), - }; - } return { status: "unfenced", reason: "unknown_run", @@ -17307,17 +18674,9 @@ export class AgentSession { } if (handle === this.agent.activeResourceRunId) this.agent.abort(); const proof = await this.agent.resourceLedger.waitForSettlement(handle, { graceMs: options.graceMs }); - if (proof.status === "unfenced" && proof.reason !== "resources_pending") { - this.agent.resourceLedger.quarantine(handle); - } + if (proof.status === "unfenced") this.agent.resourceLedger.quarantine(handle); // The run's agent_end (if any) consumed the disposition; a settled or - // already-ended run must not leave it for an unrelated later exit. A - // `resources_pending` proof is different: the run is already sealed, and - // the ledger still owns exact promises for the outstanding resources. - // Keep that sealed accounting live so a later resolution of those exact - // tracked promises can remove its entries; this ledger proof does not - // establish that an OS process or remote tool stopped. Quarantining here - // would freeze a stale tombstone even after every tracked promise completed. + // already-ended run must not leave it for an unrelated later exit. this.#disownedSteeringDisposition = undefined; // Rearm surviving owned-completion follow-ups once the abort has // settled: the aborted loop exits before polling the follow-up queue, @@ -17410,6 +18769,7 @@ export class AgentSession { try { return await this.#withSessionAdmission("prompt", async admission => { const queueSnapshot = this.agent.snapshotQueues(); + const cancelSubmitIdentity = this.#captureSessionSelectionIdentity(); const steeringDisplaySnapshot = [...this.#steeringMessages]; const followUpDisplaySnapshot = [...this.#followUpMessages]; const deferredFollowUpSnapshot = [...this.#deferredSdkFollowUps]; @@ -17527,7 +18887,7 @@ export class AgentSession { this.#disownedSteeringDisposition = undefined; revalidateSelected(); if (outcome.kind !== "settled") { - restore(); + if (this.#isSessionSelectionIdentityCurrent(cancelSubmitIdentity)) restore(); if (outcome.kind === "error") { logger.error("Cancel-and-submit abort failed", { cause: outcome.cause }); this.emitNotice( @@ -17538,6 +18898,12 @@ export class AgentSession { } return { kind: "rolled_back", outcome }; } + if (!this.#isSessionSelectionIdentityCurrent(cancelSubmitIdentity)) { + return { + kind: "rolled_back", + outcome: { kind: "error", cause: new Error("Session identity changed during cancellation") }, + }; + } const currentQueues = this.agent.snapshotQueues(); const queuedDuringWindow = { @@ -17628,8 +18994,13 @@ export class AgentSession { : message.role === "user" ? this.#getUserMessageText(message) : text; - await this.refreshGjcSubskillTools(); - if (message.role === "custom") await this.#syncSkillPromptActiveStateSafely(message, true); + const preparationIdentity = cancelSubmitIdentity; + await this.refreshGjcSubskillTools(preparationIdentity); + if (message.role === "custom") + await this.#syncSkillPromptActiveStateSafely(message, true, true, preparationIdentity); + if (!this.#isSessionSelectionIdentityCurrent(preparationIdentity)) { + throw new Error("Session identity changed during cancel-and-submit preparation"); + } if (committedSelection) { const displayTag = message.role === "custom" ? readPendingDisplayTag(message.details) : undefined; if (displayTag) this.#displayDequeueAlreadyHandled = { role: "custom", tag: displayTag }; @@ -17669,7 +19040,8 @@ export class AgentSession { }, }); } finally { - if (message.role === "custom") await this.#syncSkillPromptActiveStateSafely(message, false); + if (message.role === "custom" && this.#isSessionSelectionIdentityCurrent(preparationIdentity)) + await this.#syncSkillPromptActiveStateSafely(message, false, true, preparationIdentity); if (runAccepted) restoreHeldQueue(); } restoreHeldQueue(); @@ -17681,6 +19053,9 @@ export class AgentSession { } revalidateSelected(); this.#displayDequeueAlreadyHandled = undefined; + if (!this.#isSessionSelectionIdentityCurrent(cancelSubmitIdentity)) { + return { kind: "rolled_back", outcome: { kind: "error", cause } }; + } restore(); logger.error("Cancel-and-submit prompt failed before run acceptance", { cause }); this.emitNotice("error", `Unable to send immediately: ${String(cause)}`, "cancel-and-submit"); @@ -17714,6 +19089,7 @@ export class AgentSession { void transition .finally(() => { if (this.#newSessionTransition === transition) this.#newSessionTransition = undefined; + this.#externalIngressSealed = false; this.#endSessionTransition(); }) .catch(() => {}); @@ -17764,6 +19140,8 @@ export class AgentSession { if (!lease) { this.#disconnectFromAgent(); await this.abort(); + if (this.#turnEndPersistenceFailure) await this.#reconcileTurnEndPersistenceFailure(); + this.#externalIngressSealed = true; if (this.isCompacting) { this.abortCompaction(); while (this.isCompacting) { @@ -17794,7 +19172,7 @@ export class AgentSession { // manager so post-transition lineage bindings resolve and owned // aborts classify in the successor session (review thread P1). this.#rekeyJobManagerForSessionIdentity(noLeasePreviousSessionIdentity, noLeasePreviousSessionFile); - await this.#runToolSessionTransitionCleanups(); + await this.#runCommittedSessionTransitionCleanups(); } catch (error) { await exactRetirement?.abort(); throw await discardPreparedNewSessionAfterFailure(this.sessionManager, prepared, error); @@ -17827,6 +19205,8 @@ export class AgentSession { try { manager.runOwnerProducerCleanupsStrict({ ownerId }); await this.abort(); + if (this.#turnEndPersistenceFailure) await this.#reconcileTurnEndPersistenceFailure(); + this.#externalIngressSealed = true; if (this.isCompacting) { this.abortCompaction(); while (this.isCompacting) { @@ -17876,7 +19256,7 @@ export class AgentSession { // manager so post-transition lineage bindings resolve and owned // aborts classify in the successor session (review thread P1). this.#rekeyJobManagerForSessionIdentity(previousSessionIdentity, previousSessionIdentityFile); - await this.#runToolSessionTransitionCleanups(); + await this.#runCommittedSessionTransitionCleanups(); } catch (error) { await exactRetirement?.abort(); throw await discardPreparedNewSessionAfterFailure(this.sessionManager, prepared, error); @@ -17984,12 +19364,14 @@ export class AgentSession { this.#reconnectToAgent(); this.#resetIrcRosterDeliveryState(); if (this.#extensionRunner) { - await this.#runCommittedSuccessorHook(() => - this.#extensionRunner!.emit({ - type: "session_switch", - reason: "new", - previousSessionFile, - }), + await this.#withPostCommitTransitionIngress(() => + this.#runCommittedSuccessorHook(() => + this.#extensionRunner!.emit({ + type: "session_switch", + reason: "new", + previousSessionFile, + }), + ), ); } else { } @@ -18002,9 +19384,18 @@ export class AgentSession { async clearContext(): Promise { this.#beginSessionTransition("clear-context"); try { + this.#externalIngressSealed = true; const sessionId = this.sessionId; this.#disconnectFromAgent(); - await this.abort(); + const compactionHookToken = this.#compactionHookContext.getStore(); + if (compactionHookToken && this.#activeCompactionHookTokens.has(compactionHookToken)) { + this.abortCompaction(); + this.agent.abort(); + } else { + await this.abort(); + await Promise.allSettled([...this.#autoCompactionCompletions]); + } + if (this.#turnEndPersistenceFailure) await this.#reconcileTurnEndPersistenceFailure(); this.#cancelOwnAsyncJobs(); this.#suppressOwnAsyncJobDeliveries(); this.yieldQueue.clear(); @@ -18015,6 +19406,7 @@ export class AgentSession { this.agent.reset(); await this.sessionManager.flush(); this.sessionManager.appendContextClearEntry({ sessionId }); + this.#commitSessionIdentityTransition(); this.setTodoPhases([]); this.#syncAgentSessionId(sessionId); this.#steeringMessages = []; @@ -18081,6 +19473,14 @@ export class AgentSession { return false; } } + if (this.isStreaming) await this.abort(); + await this.awaitSessionSettlement(); + if (this.#turnEndPersistenceFailure) await this.#reconcileTurnEndPersistenceFailure(); + if (this.isCompacting) { + this.abortCompaction(); + while (this.isCompacting) await Bun.sleep(10); + } + this.#externalIngressSealed = true; this.#settleTrackedQueuedInputsBeforeAgentDisconnect(); this.#disconnectFromAgent(); @@ -18158,7 +19558,7 @@ export class AgentSession { error: error instanceof Error ? error.message : String(error), }); } - await this.#runToolSessionTransitionCleanups(); + await this.#runCommittedSessionTransitionCleanups(); } else { // Prepare the copied successor and complete local:// readiness while all // public manager getters remain bound to the predecessor. @@ -18178,7 +19578,7 @@ export class AgentSession { // manager under it (review thread P1). this.#rekeyJobManagerForSessionIdentity(previousSessionIdentity, previousSessionFile); settleForkPredecessorWork(); - await this.#runToolSessionTransitionCleanups(); + await this.#runCommittedSessionTransitionCleanups(); } catch (error) { await exactRetirement?.abort(); throw await discardPreparedNewSessionAfterFailure(this.sessionManager, prepared, error); @@ -18192,18 +19592,21 @@ export class AgentSession { // Emit session_switch event with reason "fork" to hooks if (this.#extensionRunner) { - await this.#runCommittedSuccessorHook(() => - this.#extensionRunner!.emit({ - type: "session_switch", - reason: "fork", - previousSessionFile, - }), + await this.#withPostCommitTransitionIngress(() => + this.#runCommittedSuccessorHook(() => + this.#extensionRunner!.emit({ + type: "session_switch", + reason: "fork", + previousSessionFile, + }), + ), ); } return true; } finally { this.#reconnectToAgent(); + this.#externalIngressSealed = false; this.#endSessionTransition(); } } @@ -18227,13 +19630,18 @@ export class AgentSession { onMutationStarted?: () => void; }, ): Promise { - const previousEditMode = this.#resolveActiveEditMode(); - const apiKey = await this.#modelRegistry.getApiKey(model, this.credentialSessionId); - if (!apiKey) { - throw new Error(`No API key for ${model.provider}/${model.id}`); - } + const identity = this.#captureSessionSelectionIdentity(); + await this.#withSelectionAdmission(identity, async () => { + const previousEditMode = this.#resolveActiveEditMode(); + const apiKey = await this.#modelRegistry.getApiKey(model, this.credentialSessionId); + options?.onMutationStarted?.(); + this.#assertSelectionMutationReady(identity); + if (!apiKey) { + throw new Error(`No API key for ${model.provider}/${model.id}`); + } options?.onMutationStarted?.(); + this.#assertSelectionMutationReady(identity); const cause = options?.cause ?? "user-selection"; if (cause === "user-selection") this.markUserModelSelection(); this.#setModelAuthoritatively(model, cause); @@ -18255,31 +19663,33 @@ export class AgentSession { } this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`); - // Persist configured intent rather than a transient controller index. A pick - // inside an existing chain keeps its deterministic suffix; a different - // thinking choice for the same concrete model becomes the new head followed - // by that concrete entry's tail. Picks outside the chain are one-entry intent. - const configuredChain = this.getConfiguredModelChain(role); - if (configuredChain) { - const selectedSelector = this.#canonicalSelector(model, options?.selector, options?.thinkingLevel); - const exactIndex = configuredChain.indexOf(selectedSelector); - const concreteIndex = configuredChain.findIndex(entry => { - const parsed = parseModelString(entry); - return parsed?.provider === model.provider && parsed.id === model.id; - }); - const entries = - exactIndex !== -1 - ? configuredChain.slice(exactIndex) - : concreteIndex !== -1 - ? [selectedSelector, ...configuredChain.slice(concreteIndex + 1)] - : [selectedSelector]; - this.setConfiguredModelChain(role, entries, "model_selection"); - } - - // Apply the explicitly selected thinking level when the selector supplies one; - // otherwise prefer the model's configured defaultLevel, then preserve the current level. - this.setThinkingLevel(options?.thinkingLevel ?? model.thinking?.defaultLevel ?? this.thinkingLevel); - await this.#syncEditToolModeAfterModelChange(previousEditMode); + // Persist configured intent rather than a transient controller index. A pick + // inside an existing chain keeps its deterministic suffix; a different + // thinking choice for the same concrete model becomes the new head followed + // by that concrete entry's tail. Picks outside the chain are one-entry intent. + const configuredChain = this.getConfiguredModelChain(role); + if (configuredChain) { + const selectedSelector = this.#canonicalSelector(model, options?.selector, options?.thinkingLevel); + const exactIndex = configuredChain.indexOf(selectedSelector); + const concreteIndex = configuredChain.findIndex(entry => { + const parsed = parseModelString(entry); + return parsed?.provider === model.provider && parsed.id === model.id; + }); + const entries = + exactIndex !== -1 + ? configuredChain.slice(exactIndex) + : concreteIndex !== -1 + ? [selectedSelector, ...configuredChain.slice(concreteIndex + 1)] + : [selectedSelector]; + this.setConfiguredModelChain(role, entries, "model_selection"); + } + + // Apply the explicitly selected thinking level when the selector supplies one; + // otherwise prefer the model's configured defaultLevel, then preserve the current level. + this.setThinkingLevel(options?.thinkingLevel ?? model.thinking?.defaultLevel ?? this.thinkingLevel); + await this.#syncEditToolModeAfterModelChange(previousEditMode); + this.#assertSessionSelectionIdentityCurrent(identity); + }); } setActiveModelProfile(name: string | undefined): void { @@ -18480,16 +19890,19 @@ export class AgentSession { * Session-scoped only: does not persist `modelProfile.default`. */ async activateModelProfileForControl(profileName: string): Promise { - this.markUserModelSelection(); - await this.withSdkControlMutation(() => - activateModelProfile({ + const identity = this.#captureSessionSelectionIdentity(); + return this.withSdkControlMutation(async () => { + this.#assertSelectionMutationReady(identity); + this.markUserModelSelection(); + await activateModelProfile({ session: this, modelRegistry: this.#modelRegistry, settings: this.settings, profileName, - }), - ); - return this.getActiveModelProfile() === profileName; + }); + this.#assertSelectionMutationReady(identity); + return this.getActiveModelProfile() === profileName; + }); } /** @@ -18523,11 +19936,13 @@ export class AgentSession { onAfterActivation?: () => void; }, ): Promise<{ changed: boolean; id: string }> { + const identity = this.#captureSessionSelectionIdentity(); this.markUserModelSelection(); // Do not hold selection admission while waiting for a scheduled continuation: // the continuation may need prompt admission to settle the current turn. await this.waitForIdle(); - const canonicalName = await this.#withSessionAdmission("selection", async () => { + this.#assertSessionSelectionIdentityCurrent(identity); + const canonicalName = await this.#withSelectionAdmission(identity, async () => { const profiles = this.#modelRegistry.getModelProfiles(); let canonical: string; try { @@ -18539,6 +19954,7 @@ export class AgentSession { } const priorModel = this.model; options?.onBeforeActivation?.(); + this.#assertSelectionMutationReady(identity); await activateModelProfile( { session: this, @@ -18551,7 +19967,9 @@ export class AgentSession { thinkingLevelOverride: options?.thinkingLevelOverride, }, ); + this.#assertSelectionMutationReady(identity); options?.onAfterActivation?.(); + this.#assertSelectionMutationReady(identity); // A role-only profile has no default model, so the activation never // calls `setModelTemporary` — the only place that consumes the // thinking override. Apply the override to the existing model so the @@ -18614,12 +20032,12 @@ export class AgentSession { * profile activation and default-model selection. */ async withSdkControlMutation(body: () => Promise): Promise { + const identity = this.#captureSessionSelectionIdentity(); // Waiting while selection owns admission deadlocks with a scheduled // continuation queued behind it. Wait before acquiring the mutation lease. await this.waitForIdle(); - return this.#withSessionAdmission("selection", async () => { - return await body(); - }); + this.#assertSessionSelectionIdentityCurrent(identity); + return this.#withSelectionAdmission(identity, body); } /** Return the persisted configured fallback selectors for a model role. */ @@ -18689,8 +20107,6 @@ export class AgentSession { { role: "default", entries: [...entries], origin: "runtime", identity, explicitHead: true }, this.settings.get("fallback.maxAttempts"), ); - this.#providerRetryMaxAttempts = undefined; - this.#managedFallbackProviderAttemptCount = 0; this.#defaultFallbackExhaustedLastTurn = false; this.#seedDefaultFallbackResolutionForController( this.#defaultFallbackController, @@ -18804,97 +20220,114 @@ export class AgentSession { signal?: AbortSignal; shouldMutate?: () => boolean; onMutationStarted?: () => void; + allowPromptContinuationReentry?: boolean; }, // biome-ignore lint/suspicious/noConfusingVoidType: Existing session adapters return Promise; a scope is optional. ): Promise { if (options?.signal?.aborted) return; - const suppliedScope = options?.providerSessionScope; - if (suppliedScope && this.#temporaryProviderSessionScopes.at(-1)?.token !== suppliedScope) return; - const previousEditMode = this.#resolveActiveEditMode(); - const expectedSessionId = this.sessionId; - const apiKey = await this.#modelRegistry.getApiKey(model, this.credentialSessionId); - if (options?.signal?.aborted) return; - if (this.sessionId !== expectedSessionId) { - throw new Error("Session changed while selecting model"); - } - if (!apiKey) { - throw new Error(`No API key for ${model.provider}/${model.id}`); - } - if (suppliedScope && this.#temporaryProviderSessionScopes.at(-1)?.token !== suppliedScope) return; - if (options?.shouldMutate && !options.shouldMutate()) return; - if (options?.cause === "user-selection") this.markUserModelSelection(); - options?.onMutationStarted?.(); - - const isTemporaryOperation = options?.cause === undefined || options.cause === "temporary-operation"; - const autoCreateScope = isTemporaryOperation && !suppliedScope; - const currentAutoScope = this.#currentAutoTemporaryProviderSessionScope(); - const replaceAutoScope = - autoCreateScope && - currentAutoScope !== undefined && - this.#temporaryProviderSessionScopes.at(-1) === currentAutoScope; - if (replaceAutoScope && currentAutoScope) { - await this.#restoreTopTemporaryProviderSessionScope(); - } - const scope = isTemporaryOperation - ? (suppliedScope ?? - (replaceAutoScope && this.model && modelsAreEqual(this.model, model) - ? undefined - : this.#beginTemporaryProviderSessionScope(options?.reason ?? "other", true))) - : undefined; - const ownsScope = scope !== undefined && !suppliedScope; + const identity = this.#captureSessionSelectionIdentity(); try { - if (isTemporaryOperation) { - this.#setAgentModelWithReasoningContext(model); - this.#syncAppendOnlyContext(model); - } else { - this.#setModelAuthoritatively(model, options?.cause ?? "temporary-operation"); - } - if (options?.cause === "user-selection") { - this.#unavailableModelProfile = undefined; - } - this.sessionManager.appendModelChange( - `${model.provider}/${model.id}`, - options?.persistAsSessionDefault ? "default" : "temporary", - ); - this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`); - if (options?.persistAsSessionDefault) { - this.#seedSessionCanonicalVariant(model); - if (options.cause === "user-selection") this.#recordUserCanonicalVariantSelection(); - } - - // Apply explicit thinking level if given; otherwise prefer the model's - // configured defaultLevel; otherwise re-clamp the current level. - this.setThinkingLevel(thinkingLevel ?? model.thinking?.defaultLevel ?? this.thinkingLevel); - if (options?.persistAsSessionDefault === true && options.cause !== "profile-activation") { - // Concrete model selection clears session-scoped profile state (#5919). - // Materialize durable profiles first (if persisted and active), then reset - // without force to preserve durable semantics. - if (this.model && this.settings.get("modelProfile.default") !== undefined) { - this.materializeActiveDefaultModelProfileAssignment(this.model); - } - this.#resetSessionScopedModelProfileState({ preserveDefaultConfiguredChain: true }); - // For user-selection and startup-override causes, also clear stale persisted - // defaults via the legacy path when there is no ownership record. - if (options.cause === "user-selection" || options.cause === "startup-override") { - const ownership = readDurableModelProfileOwnership(this.settings); - if (ownership.version === 0) { - this.#clearActiveModelProfileForConcreteDefault(options.cause); + return await this.#withSelectionAdmission( + identity, + async () => { + const suppliedScope = options?.providerSessionScope; + if (suppliedScope && this.#temporaryProviderSessionScopes.at(-1)?.token !== suppliedScope) return; + const previousEditMode = this.#resolveActiveEditMode(); + const apiKey = await this.#modelRegistry.getApiKey(model, this.credentialSessionId); + if (options?.signal?.aborted) return; + this.#assertSelectionMutationReady(identity); + if (!apiKey) { + throw new Error(`No API key for ${model.provider}/${model.id}`); } - } - const origin = options.cause === "startup-override" ? "startup-override" : "model_selection"; - const effectiveLevel = thinkingLevel ?? model.thinking?.defaultLevel ?? this.thinkingLevel; - this.setConfiguredModelChain( - "default", - [formatModelSelectorValue(`${model.provider}/${model.id}`, effectiveLevel)], - origin, - ); - } - await this.#syncEditToolModeAfterModelChange(previousEditMode); + if (suppliedScope && this.#temporaryProviderSessionScopes.at(-1)?.token !== suppliedScope) return; + if (options?.shouldMutate && !options.shouldMutate()) return; + if (options?.cause === "user-selection") this.markUserModelSelection(); + options?.onMutationStarted?.(); + + const isTemporaryOperation = options?.cause === undefined || options.cause === "temporary-operation"; + const autoCreateScope = isTemporaryOperation && !suppliedScope; + const currentAutoScope = this.#currentAutoTemporaryProviderSessionScope(); + const replaceAutoScope = + autoCreateScope && + currentAutoScope !== undefined && + this.#temporaryProviderSessionScopes.at(-1) === currentAutoScope; + if (replaceAutoScope && currentAutoScope) { + this.#assertSelectionMutationReady(identity); + await this.#restoreTopTemporaryProviderSessionScope(); + if (options?.signal?.aborted) return; + this.#assertSelectionMutationReady(identity); + } + this.#assertSelectionMutationReady(identity); + const scope = isTemporaryOperation + ? (suppliedScope ?? + (replaceAutoScope && this.model && modelsAreEqual(this.model, model) + ? undefined + : this.#beginTemporaryProviderSessionScope(options?.reason ?? "other", true))) + : undefined; + const ownsScope = scope !== undefined && !suppliedScope; + try { + this.#assertSelectionMutationReady(identity); + if (isTemporaryOperation) { + this.#setAgentModelWithReasoningContext(model); + this.#syncAppendOnlyContext(model); + } else { + this.#setModelAuthoritatively(model, options?.cause ?? "temporary-operation"); + } + if (options?.cause === "user-selection") this.#unavailableModelProfile = undefined; + this.sessionManager.appendModelChange( + `${model.provider}/${model.id}`, + options?.persistAsSessionDefault ? "default" : "temporary", + ); + this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`); + if (options?.persistAsSessionDefault) { + this.#seedSessionCanonicalVariant(model); + if (options.cause === "user-selection") this.#recordUserCanonicalVariantSelection(); + } + + // Apply explicit thinking level if given; otherwise prefer the model's + // configured defaultLevel; otherwise re-clamp the current level. + this.setThinkingLevel(thinkingLevel ?? model.thinking?.defaultLevel ?? this.thinkingLevel); + if (options?.persistAsSessionDefault === true && options.cause !== "profile-activation") { + if (this.model && this.settings.get("modelProfile.default") !== undefined) { + this.materializeActiveDefaultModelProfileAssignment(this.model); + } + this.#resetSessionScopedModelProfileState({ preserveDefaultConfiguredChain: true }); + if (options.cause === "user-selection" || options.cause === "startup-override") { + const ownership = readDurableModelProfileOwnership(this.settings); + if (ownership.version === 0) { + this.#clearActiveModelProfileForConcreteDefault(options.cause); + } + } + const origin = options.cause === "startup-override" ? "startup-override" : "model_selection"; + const effectiveLevel = thinkingLevel ?? model.thinking?.defaultLevel ?? this.thinkingLevel; + this.setConfiguredModelChain( + "default", + [formatModelSelectorValue(`${model.provider}/${model.id}`, effectiveLevel)], + origin, + ); + } + await this.#syncEditToolModeAfterModelChange(previousEditMode); + this.#assertSessionSelectionIdentityCurrent(identity); + } catch (error) { + if ( + ownsScope && + this.#isSessionSelectionIdentityCurrent(identity) && + this.#sessionTransitionKind === undefined && + !this.#terminalPersistenceRecovery + ) { + await this.restoreTemporaryProviderSessionScope(scope); + } + throw error; + } + return scope; + }, + options?.signal, + { allowPromptContinuationReentry: options?.allowPromptContinuationReentry }, + ); } catch (error) { - if (ownsScope) await this.restoreTemporaryProviderSessionScope(scope); + if (options?.signal?.aborted) return; throw error; } - return scope; } /** Restore the exact live-model state captured before a failed selector transaction. */ @@ -18902,20 +20335,21 @@ export class AgentSession { model: Model | undefined, thinkingLevel: ThinkingLevel | undefined, ): Promise { - const previousEditMode = this.#resolveActiveEditMode(); - if (model) { - // Restoring a captured live model is compensation, not a new selection: - // the old credential may have disappeared after the forward mutation. - this.#setModelAuthoritatively(model, "rollback"); - this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, "temporary"); - this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`); - } else { + const identity = this.#captureSessionSelectionIdentity(); + return this.#withSelectionAdmission(identity, async () => { + if (model) { + await this.setModelTemporary(model, thinkingLevel, { cause: "rollback", reason: "other" }); + this.#assertSessionSelectionIdentityCurrent(identity); + return; + } + const previousEditMode = this.#resolveActiveEditMode(); + this.#assertSelectionMutationReady(identity); this.#clearActiveRetryFallback(); this.#setModelWithProviderSessionReset(undefined); - this.#syncAppendOnlyContext(undefined); - } - this.setThinkingLevel(thinkingLevel); - await this.#syncEditToolModeAfterModelChange(previousEditMode); + this.setThinkingLevel(thinkingLevel); + await this.#syncEditToolModeAfterModelChange(previousEditMode); + this.#assertSessionSelectionIdentityCurrent(identity); + }); } async #restoreDefaultModelSelectionCommit( @@ -19031,13 +20465,14 @@ export class AgentSession { expectedSessionId: string = this.sessionId, thinkingLevel?: ThinkingLevel, ): Promise { - if (expectedSessionId !== this.sessionId) return false; + const identity = this.#captureSessionSelectionIdentity(); + if (expectedSessionId !== identity.sessionId) return false; try { await this.setModelTemporary(model, thinkingLevel, { persistAsSessionDefault: true, cause: "user-selection", }); - return expectedSessionId === this.sessionId; + return this.#isSessionSelectionIdentityCurrent(identity); } catch { logger.warn("session: model control failed"); return false; @@ -19069,7 +20504,7 @@ export class AgentSession { if (thinkingLevel === ThinkingLevel.Inherit) { throw new Error("Default model selection cannot inherit a thinking level"); } - const expectedSessionId = this.sessionId; + const identity = this.#captureSessionSelectionIdentity(); const selectionTransaction = Symbol("default-model-selection"); const priorSelectionFence = this.#selectionFenceTail; const selectionFence = Promise.withResolvers(); @@ -19083,16 +20518,11 @@ export class AgentSession { void this.#selectionFenceTail.catch(() => {}); try { await priorSelectionFence; - const { effectiveLevel } = await this.#withSessionAdmission( - "selection", + const { effectiveLevel } = await this.#withSelectionAdmission( + identity, async () => { - if (this.sessionId !== expectedSessionId) { - throw new Error("Session changed while selecting model"); - } const apiKey = await this.#modelRegistry.getApiKey(model, this.credentialSessionId); - if (this.sessionId !== expectedSessionId) { - throw new Error("Session changed while selecting model"); - } + this.#assertSelectionMutationReady(identity); if (!apiKey) { throw new Error(`No API key for ${model.provider}/${model.id}`); } @@ -19105,37 +20535,33 @@ export class AgentSession { }; }, undefined, - undefined, { allowDuringClosing: true, selectionTransaction }, ); this.#selectionAwaitingMutationTransaction = selectionTransaction; await this.waitForIdle(selectionFenceGeneration); await options?.onBeforeMutationAdmissionForTests?.(); - return await this.#withSessionAdmission( - "selection", + return await this.#withSelectionAdmission( + identity, async () => { options?.onBeforeMutation?.(); - if (this.sessionId !== expectedSessionId) { - throw new Error("Session changed while selecting model"); - } + this.#assertSelectionMutationReady(identity); await this.sessionManager.flush(); + this.#assertSelectionMutationReady(identity); await this.#waitForAdmittedBaseSystemPromptRebuilds(); - if (this.sessionId !== expectedSessionId) { - throw new Error("Session changed while selecting model"); - } + this.#assertSelectionMutationReady(identity); const expectedMutationRevision = this.#defaultModelSelectionMutationRevision; const preparedSystemPrompt = await this.#prepareDefaultModelSelectionPrompt(model); - if (this.sessionId !== expectedSessionId) { - throw new Error("Session changed while selecting model"); - } + this.#assertSelectionMutationReady(identity); const stage = await this.sessionManager.stageDefaultModelSelection( `${model.provider}/${model.id}`, effectiveLevel, { appendThinkingLevel: true }, ); + this.#assertSelectionMutationReady(identity); let durableCommit: CasReceipt; try { const selector = formatModelSelectorValue(`${model.provider}/${model.id}`, effectiveLevel); + this.#assertSelectionMutationReady(identity); durableCommit = await this.settings.commitAtomicBatchWithCurrent(() => [ { path: "modelRoles.default" as SettingPath, op: "set", value: selector }, ]); @@ -19150,6 +20576,7 @@ export class AgentSession { } throw error; } + this.#assertSelectionMutationReady(identity); if (this.#defaultModelSelectionMutationRevision !== expectedMutationRevision) { await this.#throwDefaultModelSelectionRecovery( new Error("Default model selection was superseded before session promotion"), @@ -19186,6 +20613,7 @@ export class AgentSession { }); } } + this.#assertSelectionMutationReady(identity); // Concrete model selection clears durable profile ownership (#5919). // If there is an existing ownership record (version > 0), clear it. // If there is no ownership record (version === 0), call the legacy path to @@ -19220,11 +20648,11 @@ export class AgentSession { // that no longer match the concrete selection. this.#clearActiveModelProfileForConcreteDefault("user-selection"); } + this.#assertSelectionMutationReady(identity); options?.onAfterMutation?.(); return { provider: model.provider, modelId: model.id, thinkingLevel: effectiveLevel }; }, undefined, - undefined, { allowDuringClosing: true, closedSelectionTransaction: selectionTransaction, @@ -19250,10 +20678,15 @@ export class AgentSession { * @returns The new model info, or undefined if only one model available */ async cycleModel(direction: "forward" | "backward" = "forward"): Promise { - if (this.#scopedModels.length > 0) { - return this.#cycleScopedModel(direction); - } - return this.#cycleAvailableModel(direction); + const identity = this.#captureSessionSelectionIdentity(); + return this.#withSelectionAdmission(identity, async () => { + const result = + this.#scopedModels.length > 0 + ? await this.#cycleScopedModel(direction) + : await this.#cycleAvailableModel(direction); + this.#assertSessionSelectionIdentityCurrent(identity); + return result; + }); } /** Number of configured role-model candidates that can be cycled. */ @@ -19309,38 +20742,43 @@ export class AgentSession { roleOrder: readonly string[], options?: { temporary?: boolean }, ): Promise { - const roleModels = this.#getRoleModelCycleCandidates(roleOrder, this.sessionId); - if (roleModels.length <= 1) return undefined; + const identity = this.#captureSessionSelectionIdentity(); + return this.#withSelectionAdmission(identity, async () => { + const roleModels = this.#getRoleModelCycleCandidates(roleOrder, this.sessionId); + if (roleModels.length <= 1) return undefined; - const currentModel = this.model!; + const currentModel = this.model!; - const lastRole = this.sessionManager.getLastModelChangeRole(); - let currentIndex = lastRole ? roleModels.findIndex(entry => entry.role === lastRole) : -1; - if (currentIndex === -1) { - currentIndex = roleModels.findIndex(entry => modelsAreEqual(entry.model, currentModel)); - } - if (currentIndex === -1) currentIndex = 0; + const lastRole = this.sessionManager.getLastModelChangeRole(); + let currentIndex = lastRole ? roleModels.findIndex(entry => entry.role === lastRole) : -1; + if (currentIndex === -1) { + currentIndex = roleModels.findIndex(entry => modelsAreEqual(entry.model, currentModel)); + } + if (currentIndex === -1) currentIndex = 0; - const nextIndex = (currentIndex + 1) % roleModels.length; - const next = roleModels[nextIndex]; + const nextIndex = (currentIndex + 1) % roleModels.length; + const next = roleModels[nextIndex]; - if (options?.temporary) { - this.markUserModelSelection(); - await this.setModelTemporary(next.model, next.explicitThinkingLevel ? next.thinkingLevel : undefined, { - cause: "temporary-operation", - reason: "temporary-cycle", - }); - } else { - await this.setModel(next.model, next.role, { cause: "user-selection" }); - if (next.explicitThinkingLevel && next.thinkingLevel !== undefined) { - this.setThinkingLevel(next.thinkingLevel); + if (options?.temporary) { + await this.setModelTemporary(next.model, next.explicitThinkingLevel ? next.thinkingLevel : undefined, { + cause: "temporary-operation", + reason: "temporary-cycle", + }); + } else { + await this.setModel(next.model, next.role, { cause: "user-selection" }); + if (next.explicitThinkingLevel && next.thinkingLevel !== undefined) { + this.#assertSelectionMutationReady(identity); + this.setThinkingLevel(next.thinkingLevel); + } + // Materialize only after applying the selected explicit level so the + // durable selector matches the live cycle result after restart. + this.#assertSelectionMutationReady(identity); + this.#clearActiveModelProfileForConcreteDefault("user-selection"); } - // Materialize only after applying the selected explicit level so the - // durable selector matches the live cycle result after restart. - this.#clearActiveModelProfileForConcreteDefault("user-selection"); - } - return { model: next.model, thinkingLevel: this.thinkingLevel, role: next.role }; + this.#assertSessionSelectionIdentityCurrent(identity); + return { model: next.model, thinkingLevel: this.thinkingLevel, role: next.role }; + }); } async #getScopedModelsWithApiKey(): Promise> { @@ -19441,6 +20879,7 @@ export class AgentSession { } setThinkingLevel(level: ThinkingLevel | undefined, persist: boolean = false): void { + this.#assertTerminalPersistenceSettledForHistoryMutation(); this.#applyThinkingLevel(level, persist, false); } @@ -19477,112 +20916,138 @@ export class AgentSession { * Set thinking level from a control surface. Global changes commit before affecting live state. */ async setThinkingLevelForControl(level: ThinkingLevel, persist: boolean): Promise { - const previousThinkingLevel = this.thinkingLevel; - if (!persist) { - this.#applyThinkingLevel( + const identity = this.#captureSessionSelectionIdentity(); + return this.#withSelectionAdmission(identity, async lease => { + if (!persist) { + const previousThinkingLevel = this.thinkingLevel; + this.#assertSelectionMutationReady(identity); + this.#applyThinkingLevel( + level === ThinkingLevel.Inherit ? this.#getInheritedThinkingLevel() : level, + false, + true, + ); + if (level === ThinkingLevel.Inherit || this.thinkingLevel === previousThinkingLevel) { + this.sessionManager.appendThinkingLevelChange(level, true); + } + return; + } + + this.#assertDurableSettingsWritable(); + const effectiveLevel = resolveThinkingLevelForModel( + this.model, level === ThinkingLevel.Inherit ? this.#getInheritedThinkingLevel() : level, - false, - true, ); - if (level === ThinkingLevel.Inherit || this.thinkingLevel === previousThinkingLevel) { - this.sessionManager.appendThinkingLevelChange(level, true); + const persistedLevel = level === ThinkingLevel.Inherit ? getDefault("defaultThinkingLevel") : effectiveLevel; + const mutationRevision = ++this.#thinkingLevelMutationRevision; + const expectedLiveMutationRevision = this.#thinkingLevelLiveMutationRevision; + const expectedSessionId = identity.sessionId; + const expectedModel = this.model; + const expectedContextGeneration = this.#reasoningControlContextGeneration; + lease.release(); + try { + await this.settings.commitAtomicBatch([{ path: "defaultThinkingLevel", op: "set", value: persistedLevel }]); + } catch { + await this.#withSelectionAdmission(identity, async () => { + if ( + mutationRevision === this.#thinkingLevelMutationRevision && + this.#reasoningControlContextGeneration === expectedContextGeneration && + this.#isSessionSelectionIdentityCurrent(identity) && + !this.#terminalPersistenceRecovery && + this.model === expectedModel + ) { + const pending = this.#pendingThinkingLevelControlSuccess; + this.#pendingThinkingLevelControlSuccess = undefined; + if ( + pending && + pending.contextGeneration === expectedContextGeneration && + this.sessionManager.getSessionId() === pending.sessionId && + this.model === pending.model + ) { + this.#assertSelectionMutationReady(identity); + this.setThinkingLevel( + pending.level === ThinkingLevel.Inherit ? this.#getInheritedThinkingLevel() : pending.level, + ); + this.sessionManager.appendThinkingLevelChange(ThinkingLevel.Inherit); + } else { + this.#pendingThinkingLevelControlFailure = { + mutationRevision, + liveMutationRevision: expectedLiveMutationRevision, + sessionId: expectedSessionId, + model: expectedModel, + contextGeneration: expectedContextGeneration, + }; + } + } + }); + throw new Error("Unable to persist reasoning settings."); } - return; - } - this.#assertDurableSettingsWritable(); - const effectiveLevel = resolveThinkingLevelForModel( - this.model, - level === ThinkingLevel.Inherit ? this.#getInheritedThinkingLevel() : level, - ); - const persistedLevel = level === ThinkingLevel.Inherit ? getDefault("defaultThinkingLevel") : effectiveLevel; - const mutationRevision = ++this.#thinkingLevelMutationRevision; - const expectedLiveMutationRevision = this.#thinkingLevelLiveMutationRevision; - const expectedSessionId = this.sessionManager.getSessionId(); - const expectedModel = this.model; - const expectedContextGeneration = this.#reasoningControlContextGeneration; - try { - await this.settings.commitAtomicBatch([{ path: "defaultThinkingLevel", op: "set", value: persistedLevel }]); - } catch { if ( - mutationRevision === this.#thinkingLevelMutationRevision && - this.#reasoningControlContextGeneration === expectedContextGeneration && - this.sessionManager.getSessionId() === expectedSessionId && - this.model === expectedModel - ) { - const pending = this.#pendingThinkingLevelControlSuccess; - this.#pendingThinkingLevelControlSuccess = undefined; + !this.#isSessionSelectionIdentityCurrent(identity) || + this.#reasoningControlContextGeneration !== expectedContextGeneration + ) + return; + await this.#withSelectionAdmission(identity, async () => { + if ( + !this.#isSessionSelectionIdentityCurrent(identity) || + this.#reasoningControlContextGeneration !== expectedContextGeneration || + this.model !== expectedModel + ) + return; if ( - pending && - pending.contextGeneration === expectedContextGeneration && - this.sessionManager.getSessionId() === pending.sessionId && - this.model === pending.model + mutationRevision === this.#thinkingLevelMutationRevision && + this.#thinkingLevelLiveMutationRevision === expectedLiveMutationRevision ) { - this.setThinkingLevel( - pending.level === ThinkingLevel.Inherit ? this.#getInheritedThinkingLevel() : pending.level, - ); + this.#assertSelectionMutationReady(identity); + this.setThinkingLevel(level === ThinkingLevel.Inherit ? this.#getInheritedThinkingLevel() : level); this.sessionManager.appendThinkingLevelChange(ThinkingLevel.Inherit); - } else { - this.#pendingThinkingLevelControlFailure = { - mutationRevision, - liveMutationRevision: expectedLiveMutationRevision, - sessionId: expectedSessionId, - model: expectedModel, - contextGeneration: expectedContextGeneration, - }; + return; } - } - throw new Error("Unable to persist reasoning settings."); - } - - if ( - mutationRevision === this.#thinkingLevelMutationRevision && - this.#thinkingLevelLiveMutationRevision === expectedLiveMutationRevision - ) { - this.setThinkingLevel(level === ThinkingLevel.Inherit ? this.#getInheritedThinkingLevel() : level); - this.sessionManager.appendThinkingLevelChange(ThinkingLevel.Inherit); - return; - } - if ( - mutationRevision !== this.#thinkingLevelMutationRevision || - this.#reasoningControlContextGeneration !== expectedContextGeneration || - this.sessionManager.getSessionId() !== expectedSessionId || - this.model !== expectedModel - ) { - if ( - this.#thinkingLevelLiveMutationRevision === expectedLiveMutationRevision && - this.#reasoningControlContextGeneration === expectedContextGeneration && - this.sessionManager.getSessionId() === expectedSessionId && - this.model === expectedModel && - (this.#pendingThinkingLevelControlSuccess?.mutationRevision ?? -1) < mutationRevision - ) { - this.#pendingThinkingLevelControlSuccess = { - level, - mutationRevision, - sessionId: expectedSessionId, - model: expectedModel, - contextGeneration: expectedContextGeneration, - }; - const failure = this.#pendingThinkingLevelControlFailure; if ( - failure && - failure.mutationRevision === this.#thinkingLevelMutationRevision && - failure.liveMutationRevision === expectedLiveMutationRevision && - failure.sessionId === expectedSessionId && - failure.model === expectedModel && - failure.contextGeneration === expectedContextGeneration + mutationRevision !== this.#thinkingLevelMutationRevision || + this.#reasoningControlContextGeneration !== expectedContextGeneration || + !this.#isSessionSelectionIdentityCurrent(identity) || + this.model !== expectedModel ) { - this.#pendingThinkingLevelControlFailure = undefined; - this.setThinkingLevel( - level === ThinkingLevel.Inherit ? this.#getInheritedThinkingLevel() : effectiveLevel, - ); - this.sessionManager.appendThinkingLevelChange(ThinkingLevel.Inherit); + if ( + this.#thinkingLevelLiveMutationRevision === expectedLiveMutationRevision && + this.#reasoningControlContextGeneration === expectedContextGeneration && + this.#isSessionSelectionIdentityCurrent(identity) && + !this.#terminalPersistenceRecovery && + this.model === expectedModel && + (this.#pendingThinkingLevelControlSuccess?.mutationRevision ?? -1) < mutationRevision + ) { + this.#pendingThinkingLevelControlSuccess = { + level, + mutationRevision, + sessionId: expectedSessionId, + model: expectedModel, + contextGeneration: expectedContextGeneration, + }; + const failure = this.#pendingThinkingLevelControlFailure; + if ( + failure && + failure.mutationRevision === this.#thinkingLevelMutationRevision && + failure.liveMutationRevision === expectedLiveMutationRevision && + failure.sessionId === expectedSessionId && + failure.model === expectedModel && + failure.contextGeneration === expectedContextGeneration + ) { + this.#assertSelectionMutationReady(identity); + this.#pendingThinkingLevelControlFailure = undefined; + this.setThinkingLevel( + level === ThinkingLevel.Inherit ? this.#getInheritedThinkingLevel() : effectiveLevel, + ); + this.sessionManager.appendThinkingLevelChange(ThinkingLevel.Inherit); + } + } + return; } - } - return; - } - this.setThinkingLevel(level === ThinkingLevel.Inherit ? this.#getInheritedThinkingLevel() : effectiveLevel); - this.sessionManager.appendThinkingLevelChange(ThinkingLevel.Inherit); + this.#assertSelectionMutationReady(identity); + this.setThinkingLevel(level === ThinkingLevel.Inherit ? this.#getInheritedThinkingLevel() : effectiveLevel); + this.sessionManager.appendThinkingLevelChange(ThinkingLevel.Inherit); + }); + }); } getThinkingScopeForControl(): "session" | "global config" { @@ -19615,93 +21080,122 @@ export class AgentSession { * Set thinking visibility from a control surface. Global changes commit before affecting live state. */ async setThinkingVisibilityForControl(visibility: "visible" | "hidden", persist: boolean): Promise { - if (!persist) { - this.setThinkingVisibility(visibility); - return; - } + const identity = this.#captureSessionSelectionIdentity(); + return this.#withSelectionAdmission(identity, async lease => { + if (!persist) { + this.#assertSelectionMutationReady(identity); + this.setThinkingVisibility(visibility); + return; + } - this.#assertDurableSettingsWritable(); - const mutationRevision = ++this.#thinkingVisibilityMutationRevision; - const expectedLiveMutationRevision = this.#thinkingVisibilityLiveMutationRevision; - const expectedSessionId = this.sessionManager.getSessionId(); - const expectedModel = this.model; - const expectedContextGeneration = this.#reasoningControlContextGeneration; - try { - await this.settings.commitAtomicBatch([ - { path: "hideThinkingBlock", op: "set", value: visibility === "hidden" }, - ]); - } catch { + this.#assertDurableSettingsWritable(); + const mutationRevision = ++this.#thinkingVisibilityMutationRevision; + const expectedLiveMutationRevision = this.#thinkingVisibilityLiveMutationRevision; + const expectedSessionId = identity.sessionId; + const expectedModel = this.model; + const expectedContextGeneration = this.#reasoningControlContextGeneration; + lease.release(); + try { + await this.settings.commitAtomicBatch([ + { path: "hideThinkingBlock", op: "set", value: visibility === "hidden" }, + ]); + } catch { + const recoveryIdentity = this.#captureSessionSelectionIdentity(); + if (recoveryIdentity.sessionId === expectedSessionId) { + await this.#withSelectionAdmission(recoveryIdentity, async () => { + if ( + mutationRevision === this.#thinkingVisibilityMutationRevision && + this.#reasoningControlContextGeneration === expectedContextGeneration && + this.#isSessionSelectionIdentityCurrent(identity) && + !this.#terminalPersistenceRecovery && + this.model === expectedModel + ) { + const pending = this.#pendingThinkingVisibilityControlSuccess; + this.#pendingThinkingVisibilityControlSuccess = undefined; + if ( + pending && + pending.contextGeneration === expectedContextGeneration && + this.sessionManager.getSessionId() === pending.sessionId && + this.model === pending.model + ) { + this.#assertSelectionMutationReady(recoveryIdentity); + this.setThinkingVisibility(pending.visibility); + } else { + this.#pendingThinkingVisibilityControlFailure = { + mutationRevision, + liveMutationRevision: expectedLiveMutationRevision, + sessionId: expectedSessionId, + model: expectedModel, + contextGeneration: expectedContextGeneration, + }; + } + } + }); + } + throw new Error("Unable to persist reasoning settings."); + } if ( - mutationRevision === this.#thinkingVisibilityMutationRevision && - this.#reasoningControlContextGeneration === expectedContextGeneration && - this.sessionManager.getSessionId() === expectedSessionId && - this.model === expectedModel - ) { - const pending = this.#pendingThinkingVisibilityControlSuccess; - this.#pendingThinkingVisibilityControlSuccess = undefined; + !this.#isSessionSelectionIdentityCurrent(identity) || + this.#reasoningControlContextGeneration !== expectedContextGeneration + ) + return; + await this.#withSelectionAdmission(identity, async () => { if ( - pending && - pending.contextGeneration === expectedContextGeneration && - this.sessionManager.getSessionId() === pending.sessionId && - this.model === pending.model + !this.#isSessionSelectionIdentityCurrent(identity) || + this.#reasoningControlContextGeneration !== expectedContextGeneration || + this.model !== expectedModel + ) + return; + if ( + mutationRevision === this.#thinkingVisibilityMutationRevision && + this.#thinkingVisibilityLiveMutationRevision === expectedLiveMutationRevision ) { - this.setThinkingVisibility(pending.visibility); - } else { - this.#pendingThinkingVisibilityControlFailure = { - mutationRevision, - liveMutationRevision: expectedLiveMutationRevision, - sessionId: expectedSessionId, - model: expectedModel, - contextGeneration: expectedContextGeneration, - }; + this.#assertSelectionMutationReady(identity); + this.setThinkingVisibility(visibility); + return; } - } - throw new Error("Unable to persist reasoning settings."); - } - if ( - mutationRevision === this.#thinkingVisibilityMutationRevision && - this.#thinkingVisibilityLiveMutationRevision === expectedLiveMutationRevision - ) { - this.setThinkingVisibility(visibility); - return; - } - if ( - mutationRevision !== this.#thinkingVisibilityMutationRevision || - this.#reasoningControlContextGeneration !== expectedContextGeneration || - this.sessionManager.getSessionId() !== expectedSessionId || - this.model !== expectedModel - ) { - if ( - this.#thinkingVisibilityLiveMutationRevision === expectedLiveMutationRevision && - this.#reasoningControlContextGeneration === expectedContextGeneration && - this.sessionManager.getSessionId() === expectedSessionId && - this.model === expectedModel && - (this.#pendingThinkingVisibilityControlSuccess?.mutationRevision ?? -1) < mutationRevision - ) { - this.#pendingThinkingVisibilityControlSuccess = { - visibility, - mutationRevision, - sessionId: expectedSessionId, - model: expectedModel, - contextGeneration: expectedContextGeneration, - }; - const failure = this.#pendingThinkingVisibilityControlFailure; if ( - failure && - failure.mutationRevision === this.#thinkingVisibilityMutationRevision && - failure.liveMutationRevision === expectedLiveMutationRevision && - failure.sessionId === expectedSessionId && - failure.model === expectedModel && - failure.contextGeneration === expectedContextGeneration + mutationRevision !== this.#thinkingVisibilityMutationRevision || + this.#reasoningControlContextGeneration !== expectedContextGeneration || + !this.#isSessionSelectionIdentityCurrent(identity) || + this.model !== expectedModel ) { - this.#pendingThinkingVisibilityControlFailure = undefined; - this.setThinkingVisibility(visibility); + if ( + this.#thinkingVisibilityLiveMutationRevision === expectedLiveMutationRevision && + this.#reasoningControlContextGeneration === expectedContextGeneration && + this.#isSessionSelectionIdentityCurrent(identity) && + !this.#terminalPersistenceRecovery && + this.model === expectedModel && + (this.#pendingThinkingVisibilityControlSuccess?.mutationRevision ?? -1) < mutationRevision + ) { + this.#pendingThinkingVisibilityControlSuccess = { + visibility, + mutationRevision, + sessionId: expectedSessionId, + model: expectedModel, + contextGeneration: expectedContextGeneration, + }; + const failure = this.#pendingThinkingVisibilityControlFailure; + if ( + failure && + failure.mutationRevision === this.#thinkingVisibilityMutationRevision && + failure.liveMutationRevision === expectedLiveMutationRevision && + failure.sessionId === expectedSessionId && + failure.model === expectedModel && + failure.contextGeneration === expectedContextGeneration + ) { + this.#assertSelectionMutationReady(identity); + this.#pendingThinkingVisibilityControlFailure = undefined; + this.setThinkingVisibility(visibility); + } + } + return; } - } - return; - } - this.setThinkingVisibility(visibility); + this.#assertSelectionMutationReady(identity); + this.setThinkingVisibility(visibility); + }); + }); } /** @@ -19710,6 +21204,16 @@ export class AgentSession { */ cycleThinkingLevel(): ThinkingLevel | undefined { if (!this.model?.reasoning) return undefined; + const identity = this.#captureSessionSelectionIdentity(); + const owner = this.#sessionAdmissionContext.getStore(); + const active = this.#activeSessionAdmission; + if ( + (active !== undefined && (active !== owner || active.kind !== "selection")) || + this.#sessionAdmissionQueue.some(entry => entry !== active) + ) { + throw this.#sessionAdmissionBusyError(); + } + this.#assertSelectionMutationReady(identity); const levels = [ThinkingLevel.Off, ...this.getAvailableThinkingLevels()]; const currentLevel = this.thinkingLevel === ThinkingLevel.Inherit ? ThinkingLevel.Off : this.thinkingLevel; @@ -19718,6 +21222,7 @@ export class AgentSession { const nextLevel = levels[nextIndex]; if (!nextLevel) return undefined; + this.#assertSelectionMutationReady(identity); this.#applyThinkingLevel(nextLevel, false, true); return nextLevel; } @@ -19827,6 +21332,7 @@ export class AgentSession { } setServiceTier(serviceTier: ServiceTier | undefined): void { + this.#assertTerminalPersistenceSettledForHistoryMutation(); // Re-arming a priority-granting tier always clears the per-session // auto-fallback sticky disable AND the auto-disable markers so the next // request carries `speed: "fast"` again — even when the tier is unchanged @@ -19919,6 +21425,7 @@ export class AgentSession { overThreshold = false, options?: { commitGate?: (actual: { prunedCount: number; tokensSaved: number }) => boolean }, ): Promise { + this.#assertTerminalPersistenceSettledForHistoryMutation(); const branchEntries = this.sessionManager.getBranch(); const artifactManager = await this.sessionManager.ensureArtifactManager(); // Over-threshold callers have already proven tool-output savings before entering @@ -20094,6 +21601,7 @@ export class AgentSession { eviction: handle, }); } + this.#assertTerminalPersistenceSettledForHistoryMutation(); const commitOutcomes = commitToolOutputPrune(branchEntries, committedPlan, { replacements: replacementOverrides, }); @@ -20154,6 +21662,7 @@ export class AgentSession { entry.type === "message" && committedIds.has(entry.id), ); const combined = [...committedToolEntries, ...argumentResult.prunedEntries, ...fileMentionResult.changed]; + this.#assertTerminalPersistenceSettledForHistoryMutation(); this.sessionManager.applyEntryMessageUpdates(combined); this.sessionManager.applyCustomMessageEntryUpdates([ ...volatileContextResult.changed, @@ -20294,6 +21803,7 @@ export class AgentSession { * @param options Optional callbacks for completion/error handling */ async compact(customInstructions?: string, options?: CompactOptions): Promise { + this.#assertTerminalPersistenceSettledForHistoryMutation(); this.#assertSessionAdmissionOpen(); // Automatic context maintenance owns the transition lease while its // post-append hooks run. A manual request must join behind that owner so @@ -20307,6 +21817,7 @@ export class AgentSession { // (bidirectional mutual exclusion with handoff/new/switch/branch/clear/fork/ // navigateTree). Released in the outer finally below. this.#beginSessionTransition("compact"); + this.#externalIngressSealed = true; const completion = Promise.withResolvers(); this.#compactionCompletion = completion.promise; try { @@ -20378,6 +21889,7 @@ export class AgentSession { throw error; } await this.#waitForAutoCompactionCompletions(); + if (this.#turnEndPersistenceFailure) await this.#reconcileTurnEndPersistenceFailure(); try { if (compactionAbortController.signal.aborted) { throw new CompactionCancelledError(); @@ -20497,6 +22009,7 @@ export class AgentSession { if (compactionAbortController.signal.aborted) { throw new CompactionCancelledError(); } + this.#assertTerminalPersistenceSettledForHistoryMutation(); const compactionEntryId = this.sessionManager.appendCompaction( summary, shortSummary, @@ -20677,6 +22190,7 @@ export class AgentSession { // maintenance orchestrator does not hold this lease, so acquiring it here does // not self-deadlock. Released in the outer finally below. this.#beginSessionTransition("handoff"); + this.#externalIngressSealed = true; this.#skipPostTurnMaintenanceAssistantTimestamp = undefined; // Fence background async-job delivery for the whole transition (generation @@ -20813,7 +22327,7 @@ export class AgentSession { // manager under it (review thread P1). this.#rekeyJobManagerForSessionIdentity(rollbackSessionState.sessionId, rollbackSessionState.sessionFile); committed = true; - await this.#runToolSessionTransitionCleanups(); + await this.#runCommittedSessionTransitionCleanups(); this.#terminalizeQueuedSdkWorkForSessionTransition([ ...rollbackAgentSteeringQueue, ...rollbackAgentFollowUpQueue, @@ -20869,12 +22383,14 @@ export class AgentSession { // errors are isolated by ExtensionRunner and must not roll back the // already-committed switch. if (this.#extensionRunner) { - await this.#runCommittedSuccessorHook(() => - this.#extensionRunner!.emit({ - type: "session_switch", - reason: "new", - previousSessionFile, - }), + await this.#withPostCommitTransitionIngress(() => + this.#runCommittedSuccessorHook(() => + this.#extensionRunner!.emit({ + type: "session_switch", + reason: "new", + previousSessionFile, + }), + ), ); } @@ -20976,7 +22492,12 @@ export class AgentSession { resourceRunId?: string, ownershipSignal?: AbortSignal, sdkOwnership?: SdkContinuationOwnership, + producerIdentity?: SessionSelectionIdentity, ): Promise { + const continuationIdentity = producerIdentity ?? this.#captureSessionSelectionIdentity(); + const continuationIdentityIsCurrent = (): boolean => + this.#sessionTransitionKind === undefined && this.#isSessionSelectionIdentityCurrent(continuationIdentity); + if (!continuationIdentityIsCurrent()) return false; // Safety stops are terminal and must not trigger context maintenance. if ( assistantMessage.errorKind === "provider_safety_stop" || @@ -21028,6 +22549,7 @@ export class AgentSession { !errorIsFromBeforeCompaction && classifyContextOverflow(assistantMessage, assistantMessage.transportFailure, contextWindow) ) { + if (!continuationIdentityIsCurrent()) return false; this.#overflowMaintenanceAttempts += 1; if (this.#overflowMaintenanceAttempts > 1) return false; // Remove the error message from agent state (it IS saved to session for history, @@ -21035,6 +22557,7 @@ export class AgentSession { const messages = this.agent.state.messages; let removedOverflowAssistant = false; if (messages.length > 0 && messages[messages.length - 1].role === "assistant") { + if (!continuationIdentityIsCurrent()) return false; this.agent.replaceMessages(messages.slice(0, -1), { historyRewrite: { reason: "overflow-retry", preserveSeededPrefix: true }, }); @@ -21043,7 +22566,7 @@ export class AgentSession { // Try context promotion first - switch to a larger model and retry without compacting const promoted = await this.#tryContextPromotion(assistantMessage, ownershipSignal); - if (ownershipSignal?.aborted) return false; + if (ownershipSignal?.aborted || !continuationIdentityIsCurrent()) return false; if (promoted) { // Retry on the promoted (larger) model without compacting this.#scheduleAgentContinue({ @@ -21051,7 +22574,9 @@ export class AgentSession { generation, suppressPredecessorAgentEnd: true, resourceRunId, - sdkOwnership, + sdkOwnership, + scheduledSessionId: continuationIdentity.sessionId, + scheduledSessionIdentityEpoch: continuationIdentity.sessionIdentityEpoch, }); return true; @@ -21062,8 +22587,17 @@ export class AgentSession { if (compactionSettings.enabled && compactionSettings.strategy !== "off") { const status = await this.#runAutoCompaction("overflow", true, false, { beforeTerminalOverflowNoop: () => { + if (!this.#isSessionSelectionIdentityCurrent(continuationIdentity)) return; if (onTerminalOverflowNoop) { onTerminalOverflowNoop(); + return; + } + const logicalRunId = this.agent.currentManagedLogicalRunId; + if (logicalRunId !== undefined) { + this.agent.requestRunTerminal(logicalRunId, { + stopReason: "error", + messages: [assistantMessage], + }); } else if (removedOverflowAssistant) { this.agent.appendMessage(assistantMessage); } @@ -21072,9 +22606,15 @@ export class AgentSession { signal: ownershipSignal, sdkOwnership, }); + if (!continuationIdentityIsCurrent()) return false; return "continuationScheduled" in status && status.continuationScheduled === true; } - return await this.#scheduleOverflowRetryContinuation(generation, resourceRunId, sdkOwnership); + return await this.#scheduleOverflowRetryContinuation( + generation, + resourceRunId, + sdkOwnership, + continuationIdentity, + ); } const compactionSettings = this.settings.getGroup("compaction"); if (!compactionSettings.enabled || compactionSettings.strategy === "off") return false; @@ -21111,15 +22651,16 @@ export class AgentSession { ) ) { const pruneResult = await this.#pruneToolOutputs(ownershipSignal, true); - if (ownershipSignal?.aborted) return false; + if (ownershipSignal?.aborted || !continuationIdentityIsCurrent()) return false; if (pruneResult) contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved); } const prunedCompactionSettings = this.#compactionSettingsWithAdaptiveState(compactionSettings); if (shouldCompact(contextTokens, contextWindow, prunedCompactionSettings, autoCompactionOutputReserveTokens)) { // Try promotion first — if a larger model is available, switch instead of compacting const promoted = await this.#tryContextPromotion(assistantMessage, ownershipSignal); - if (ownershipSignal?.aborted) return false; + if (ownershipSignal?.aborted || !continuationIdentityIsCurrent()) return false; if (!promoted) { + if (!continuationIdentityIsCurrent()) return false; await this.#runAutoCompaction("threshold", false, false, { resourceRunId, signal: ownershipSignal, @@ -21512,10 +23053,12 @@ export class AgentSession { return lastToolCall?.name === "yield" && lastToolCall.id === toolCallId; } - #enforceRewindBeforeYield(): boolean { + #enforceRewindBeforeYield(producerIdentity?: SessionSelectionIdentity): boolean { if (!this.#checkpointState || this.#pendingRewindReport) { return false; } + const identity = producerIdentity ?? this.#captureSessionSelectionIdentity(); + if (this.#sessionTransitionKind !== undefined || !this.#isSessionSelectionIdentityCurrent(identity)) return false; const reminder = [ "", "You are in an active checkpoint. You MUST call rewind with your investigation findings before yielding. Do NOT yield without completing the checkpoint.", @@ -21527,11 +23070,17 @@ export class AgentSession { attribution: "agent", timestamp: Date.now(), }); - this.#scheduleAgentContinue({ generation: this.#promptGeneration }); + if (this.#sessionTransitionKind !== undefined || !this.#isSessionSelectionIdentityCurrent(identity)) return false; + this.#scheduleAgentContinue({ + generation: this.#promptGeneration, + scheduledSessionId: identity.sessionId, + scheduledSessionIdentityEpoch: identity.sessionIdentityEpoch, + }); return true; } async #applyRewind(report: string): Promise { + this.#assertTerminalPersistenceSettledForHistoryMutation(); const checkpointState = this.#checkpointState; if (!checkpointState) { return; @@ -21551,6 +23100,7 @@ export class AgentSession { }); this.sessionManager.branchWithSummary(null, report, { startedAt: checkpointState.startedAt }); } + this.#reloadTtsrStateFromSessionManager(); const details = { startedAt: checkpointState.startedAt, rewoundAt: new Date().toISOString() }; this.agent.appendMessage({ role: "custom", @@ -21664,9 +23214,17 @@ export class AgentSession { }; } - async #checkGoalCompletion(assistantMessage: AssistantMessage): Promise { + async #checkGoalCompletion( + assistantMessage: AssistantMessage, + producerIdentity?: SessionSelectionIdentity, + ): Promise { + const identity = producerIdentity ?? this.#captureSessionSelectionIdentity(); + const identityIsCurrent = (): boolean => + this.#sessionTransitionKind === undefined && this.#isSessionSelectionIdentityCurrent(identity); + if (!identityIsCurrent()) return false; const state = this.getGoalModeState(); if (!state?.enabled || state.goal.status !== "active") { + if (!identityIsCurrent()) return false; this.#lastGoalReminderAssistantTimestamp = undefined; this.#suppressNextGoalReminderAfterAbortGoalId = undefined; return false; @@ -21691,15 +23249,22 @@ export class AgentSession { this.#suppressNextGoalReminderAfterAbortGoalId = undefined; if (suppressReminder) return false; } + if (!identityIsCurrent()) return false; logger.debug("Goal completion: sending active-goal reminder", { goalId: state.goal.id }); + if (!identityIsCurrent()) return false; this.agent.appendMessage({ role: "developer", content: [{ type: "text", text: reminder }], attribution: "agent", timestamp: Date.now(), }); - this.#scheduleAgentContinue({ generation: this.#promptGeneration }); + if (!identityIsCurrent()) return false; + this.#scheduleAgentContinue({ + generation: this.#promptGeneration, + scheduledSessionId: identity.sessionId, + scheduledSessionIdentityEpoch: identity.sessionIdentityEpoch, + }); return true; } #claimDeepInterviewUserIntent(): number { @@ -21719,7 +23284,11 @@ export class AgentSession { assistantMessage: AssistantMessage, agentEndGeneration: number | undefined, ownerEpoch: number | undefined, + producerIdentity: SessionSelectionIdentity, ): Promise<"not_applicable" | "continued" | "superseded" | "already_handled"> { + const identityIsCurrent = (): boolean => + this.#sessionTransitionKind === undefined && this.#isSessionSelectionIdentityCurrent(producerIdentity); + if (!identityIsCurrent()) return "superseded"; const identity = this.#deepInterviewAssistantIdentity(assistantMessage); if (this.#handledDeepInterviewAssistantIds.has(identity)) return "already_handled"; @@ -21733,7 +23302,8 @@ export class AgentSession { ownerEpoch === undefined || this.#isDisposed || agentEndGeneration !== this.#promptGeneration || - ownerEpoch !== this.#deepInterviewUserIntentEpoch + ownerEpoch !== this.#deepInterviewUserIntentEpoch || + !identityIsCurrent() ) { this.#handledDeepInterviewAssistantIds.add(identity); return "superseded"; @@ -21757,7 +23327,8 @@ export class AgentSession { if ( this.#isDisposed || agentEndGeneration !== this.#promptGeneration || - ownerEpoch !== this.#deepInterviewUserIntentEpoch + ownerEpoch !== this.#deepInterviewUserIntentEpoch || + !identityIsCurrent() ) { return "superseded"; } @@ -21778,12 +23349,17 @@ export class AgentSession { attribution: "agent", timestamp: Date.now(), }; + if (!identityIsCurrent()) return "superseded"; this.agent.appendMessage(reminderMessage); + if (!identityIsCurrent()) return "superseded"; this.sessionManager.appendMessage(reminderMessage); + if (!identityIsCurrent()) return "superseded"; this.#scheduleAgentContinue({ generation: agentEndGeneration, skipCompactionCheck: true, - shouldContinue: () => ownerEpoch === this.#deepInterviewUserIntentEpoch, + scheduledSessionId: producerIdentity.sessionId, + scheduledSessionIdentityEpoch: producerIdentity.sessionIdentityEpoch, + shouldContinue: () => ownerEpoch === this.#deepInterviewUserIntentEpoch && identityIsCurrent(), }); return "continued"; } finally { @@ -21793,7 +23369,11 @@ export class AgentSession { /** * Check if agent stopped with incomplete todos and prompt to continue. */ - async #checkTodoCompletion(): Promise { + async #checkTodoCompletion(producerIdentity?: SessionSelectionIdentity): Promise { + const identity = producerIdentity ?? this.#captureSessionSelectionIdentity(); + const identityIsCurrent = (): boolean => + this.#sessionTransitionKind === undefined && this.#isSessionSelectionIdentityCurrent(identity); + if (!identityIsCurrent()) return; // Skip todo reminders when the most recent turn was driven by an explicit user force — // the user wanted exactly that tool, not a follow-up nag about incomplete todos. const lastServedLabel = this.#toolChoiceQueue.consumeLastServedLabel(); @@ -21837,8 +23417,9 @@ export class AgentSession { return; } - // Build reminder message - this.#todoReminderCount++; + // Build reminder message. Commit the count only after the advisory event has + // returned and the producer identity is still current. + const reminderAttempt = this.#todoReminderCount + 1; const todoList = incompleteByPhase .map(phase => `- ${phase.name}\n${phase.tasks.map(task => ` - ${task.content}`).join("\n")}`) .join("\n"); @@ -21846,16 +23427,18 @@ export class AgentSession { `\n` + `You stopped with ${incomplete.length} incomplete todo item(s):\n${todoList}\n\n` + `Please continue working on these tasks or mark them complete if finished.\n` + - `(Reminder ${this.#todoReminderCount}/${remindersMax})\n` + + `(Reminder ${reminderAttempt}/${remindersMax})\n` + ``; // Emit event for UI to render notification await this.#emitSessionEvent({ type: "todo_reminder", todos: incomplete, - attempt: this.#todoReminderCount, + attempt: reminderAttempt, maxAttempts: remindersMax, }); + if (!identityIsCurrent()) return; + this.#todoReminderCount = reminderAttempt; // Consumers that cannot represent a server-initiated turn (ACP v1 clients, SDK // hosts) keep reporting the prompt as running until the terminal `agent_end` is @@ -21867,17 +23450,18 @@ export class AgentSession { if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) { logger.debug("Todo completion: advisory reminder only", { incomplete: incomplete.length, - attempt: this.#todoReminderCount, + attempt: reminderAttempt, }); return; } logger.debug("Todo completion: sending reminder", { incomplete: incomplete.length, - attempt: this.#todoReminderCount, + attempt: reminderAttempt, }); // Inject reminder and continue conversation + if (!identityIsCurrent()) return; this.agent.appendMessage({ role: "developer", content: [{ type: "text", text: reminder }], @@ -21887,9 +23471,16 @@ export class AgentSession { // The reminder continues the current prompt, so the predecessor `agent_end` // must stay held until the continuation turn produces the real terminal. // Publishing it here would settle the caller's prompt mid-reminder. + if (!identityIsCurrent()) return; // Disable managed fallback to prevent indefinite retries for this server-initiated turn. this.#todoReminderContinuationGeneration = this.#promptGeneration; - this.#scheduleAgentContinue({ skipCompactionCheck: true, suppressPredecessorAgentEnd: true, disableManagedFallback: true }); + this.#scheduleAgentContinue({ + skipCompactionCheck: true, + suppressPredecessorAgentEnd: true, + disableManagedFallback: true, + scheduledSessionId: identity.sessionId, + scheduledSessionIdentityEpoch: identity.sessionIdentityEpoch, + }); } /** @@ -21914,6 +23505,7 @@ export class AgentSession { cause: "temporary-operation", reason: "context-promotion", signal, + allowPromptContinuationReentry: true, }); if (signal?.aborted) { if (scope) await this.restoreTemporaryProviderSessionScope(scope); @@ -22536,6 +24128,7 @@ export class AgentSession { preparation: CompactionPreparation, hookCompaction: CompactionResult | undefined, stateSnapshot: CompactionStateSnapshot, + identityIsCurrent?: () => boolean, ): Promise< | { kind: "fromHook"; @@ -22556,14 +24149,24 @@ export class AgentSession { let hookContext: string[] | undefined; let hookPrompt: string | undefined; let preserveData: Record | undefined; + const assertCurrent = (): void => { + if (identityIsCurrent?.() !== false) return; + throw Object.assign(new Error("Compaction session identity changed."), { + code: "compaction_identity_changed", + }); + }; + assertCurrent(); if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) { const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages); - const result = (await this.#extensionRunner.emit({ - type: "session.compacting", - sessionId: this.sessionId, - messages: compactMessages, - })) as { context?: string[]; prompt?: string; preserveData?: Record } | undefined; + const result = (await this.#withActiveCompactionHook(() => + this.#extensionRunner!.emit({ + type: "session.compacting", + sessionId: this.sessionId, + messages: compactMessages, + }), + )) as { context?: string[]; prompt?: string; preserveData?: Record } | undefined; + assertCurrent(); hookContext = result?.context; hookPrompt = result?.prompt; @@ -22571,6 +24174,7 @@ export class AgentSession { } const memoryBackendContext = await this.#collectMemoryBackendContext(preparation); + assertCurrent(); if (memoryBackendContext) { hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext]; } @@ -22614,6 +24218,8 @@ export class AgentSession { sdkOwnership?: SdkContinuationOwnership; }, ): Promise { + if (this.#terminalPersistenceRecovery) return Promise.resolve({ kind: "skipped" }); + if (this.#sessionTransitionKind !== undefined) return Promise.resolve({ kind: "skipped" }); if (this.#isDisposed || this.#sessionAdmissionClosing) return Promise.resolve({ kind: "skipped" }); const completion = this.#runAutoCompactionImpl(reason, willRetry, deferred, options); this.#autoCompactionCompletions.add(completion); @@ -22635,6 +24241,17 @@ export class AgentSession { sdkOwnership?: SdkContinuationOwnership; }, ): Promise { + const compactionSessionId = this.sessionId; + const compactionSessionIdentityEpoch = this.#sessionIdentityEpoch; + let ownsAutoCompactionTransition = false; + const compactionIdentityIsCurrent = (): boolean => + (this.#sessionTransitionKind === undefined || + (ownsAutoCompactionTransition && this.#sessionTransitionKind === "auto-compaction")) && + this.sessionId === compactionSessionId && + this.#sessionIdentityEpoch === compactionSessionIdentityEpoch; + if (!compactionIdentityIsCurrent()) return { kind: "skipped" }; + if (this.#turnEndPersistenceFailure) await this.#reconcileTurnEndPersistenceFailure(); + if (!compactionIdentityIsCurrent()) return { kind: "skipped" }; const compactionSettings = this.settings.getGroup("compaction"); // `force` is the non-disableable emergency floor (F6): it bypasses the user's // disabled/off settings so a resource-floor breach still compacts before OOM. @@ -22683,7 +24300,12 @@ export class AgentSession { if (signal.aborted) return; await this.#runAutoCompaction(reason, willRetry, true, options); }, - { generation, resourceRunId: options?.resourceRunId }, + { + generation, + resourceRunId: options?.resourceRunId, + scheduledSessionId: compactionSessionId, + scheduledSessionIdentityEpoch: compactionSessionIdentityEpoch, + }, ); return { kind: "skipped" }; } @@ -22691,7 +24313,6 @@ export class AgentSession { let action: "context-full" | "handoff" = compactionSettings.strategy === "handoff" && reason !== "overflow" ? "handoff" : "context-full"; const continueAfterMaintenance = options?.continueAfterMaintenance !== false; - let ownsAutoCompactionTransition = false; const acquireAutoCompactionTransition = (): boolean => { if (ownsAutoCompactionTransition) return true; if (this.#sessionTransitionKind !== undefined || this.#cancelAndSubmitInProgress) return false; @@ -22721,18 +24342,22 @@ export class AgentSession { }; try { - if (autoCompactionSignal.aborted) return { kind: "aborted", source: "signal" }; + if (autoCompactionSignal.aborted || !compactionIdentityIsCurrent()) + return { kind: "aborted", source: "signal" }; await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action }); if (autoCompactionSignal.aborted) return await emitAborted(); - // Start the workflow projection in parallel with the synchronous compaction - // preparation. Overflow recovery often has no eligible history after the - // failed assistant is removed; waiting for this filesystem projection before - // discovering that no-op would delay the terminal auto_compaction_end event. - // The promise is still awaited below so zero-progress tracking remains a - // completed side effect for every compaction observation. const compactionStateSnapshotPromise = this.#compactionStateSnapshot({ trackWorkflowRecoveryProgress: true, }); + if ( + autoCompactionSignal.aborted || + !compactionIdentityIsCurrent() || + this.#isDisposed || + this.#promptGeneration !== generation + ) { + await compactionStateSnapshotPromise; + return await emitAborted(); + } if (compactionSettings.strategy === "handoff" && reason !== "overflow") { await compactionStateSnapshotPromise; @@ -22761,11 +24386,12 @@ export class AgentSession { reason, }); action = "context-full"; - if (!acquireAutoCompactionTransition()) return { kind: "skipped" }; + if (!compactionIdentityIsCurrent() || !acquireAutoCompactionTransition()) return { kind: "skipped" }; } if (autoCompactionSignal.aborted) return await emitAborted(); if (handoffResult) { + const handoffSuccessorIdentity = this.#captureSessionSelectionIdentity(); await this.#emitSessionEvent({ type: "auto_compaction_end", action, @@ -22774,6 +24400,11 @@ export class AgentSession { willRetry: false, }); if (autoCompactionSignal.aborted) return { kind: "aborted", source: "signal" }; + if ( + this.#sessionTransitionKind !== undefined || + !this.#isSessionSelectionIdentityCurrent(handoffSuccessorIdentity) + ) + return { kind: "compacted" }; if (continueAfterMaintenance && reason !== "idle" && compactionSettings.autoContinue !== false) { this.#scheduleAutoContinuePrompt( generation, @@ -22782,6 +24413,7 @@ export class AgentSession { undefined, undefined, options?.sdkOwnership, + handoffSuccessorIdentity, ); } @@ -22832,17 +24464,22 @@ export class AgentSession { if (continuationSkipReason) { this.#logCompactionContinuationSkipped("overflow_retry", continuationSkipReason); } - if (overflowNoopWouldReplay) { - options?.beforeTerminalOverflowNoop?.(); - } const overflowContinuationScheduled = !overflowNoopWouldReplay && willRetry && !continuationSkipReason ? await this.#scheduleOverflowRetryContinuation( - generation, - options?.resourceRunId, - options?.sdkOwnership, - ) + generation, + options?.resourceRunId, + options?.sdkOwnership, + { sessionId: compactionSessionId, sessionIdentityEpoch: compactionSessionIdentityEpoch }, + ownsAutoCompactionTransition, + ) : false; + if (willRetry && !overflowContinuationScheduled) { + // No retry owns this logical run now. Terminalize it before the + // observable maintenance boundary so clients cannot retain a live + // managed owner after a no-op overflow recovery. + options?.beforeTerminalOverflowNoop?.(); + } await this.#emitSessionEvent({ type: "auto_compaction_end", action, @@ -22867,6 +24504,8 @@ export class AgentSession { onSkip: skipReason => this.#logCompactionContinuationSkipped("queued_continue", skipReason), onError: error => this.#logCompactionContinuationError("queued_continue", error), resourceRunId: options?.resourceRunId, + scheduledSessionId: compactionSessionId, + scheduledSessionIdentityEpoch: compactionSessionIdentityEpoch, }); return { kind: "skipped", continuationScheduled: true }; } @@ -22877,6 +24516,7 @@ export class AgentSession { ? { kind: "skipped", continuationScheduled: true } : { kind: "skipped" }; } + if (!compactionIdentityIsCurrent()) return await emitAborted(); if (continueAfterMaintenance && reason !== "idle" && this.agent.hasQueuedMessages()) { this.#scheduleAgentContinue({ delayMs: 100, @@ -22889,6 +24529,8 @@ export class AgentSession { onSkip: skipReason => this.#logCompactionContinuationSkipped("queued_continue", skipReason), onError: error => this.#logCompactionContinuationError("queued_continue", error), resourceRunId: options?.resourceRunId, + scheduledSessionId: compactionSessionId, + scheduledSessionIdentityEpoch: compactionSessionIdentityEpoch, }); } else if (continueAfterMaintenance && reason !== "idle" && compactionSettings.autoContinue !== false) { this.#scheduleAutoContinuePrompt( @@ -22898,6 +24540,7 @@ export class AgentSession { undefined, undefined, options?.sdkOwnership, + { sessionId: compactionSessionId, sessionIdentityEpoch: compactionSessionIdentityEpoch }, ); } return { kind: "skipped" }; @@ -22913,14 +24556,16 @@ export class AgentSession { let preserveData: Record | undefined; if (this.#extensionRunner?.hasHandlers("session_before_compact")) { - const hookResult = (await this.#extensionRunner.emit({ - type: "session_before_compact", - preparation, - branchEntries: pathEntries, - customInstructions: undefined, - signal: autoCompactionSignal, - })) as SessionBeforeCompactResult | undefined; - if (autoCompactionSignal.aborted) return await emitAborted(); + const hookResult = (await this.#withActiveCompactionHook(() => + this.#extensionRunner!.emit({ + type: "session_before_compact", + preparation, + branchEntries: pathEntries, + customInstructions: undefined, + signal: autoCompactionSignal, + }), + )) as SessionBeforeCompactResult | undefined; + if (autoCompactionSignal.aborted || !compactionIdentityIsCurrent()) return await emitAborted(); if (hookResult?.cancel) { await this.#emitSessionEvent({ @@ -22943,8 +24588,9 @@ export class AgentSession { preparation, hookCompaction, compactionStateSnapshot, + compactionIdentityIsCurrent, ); - if (autoCompactionSignal.aborted) return await emitAborted(); + if (autoCompactionSignal.aborted || !compactionIdentityIsCurrent()) return await emitAborted(); let summary: string; let shortSummary: string | undefined; @@ -23015,6 +24661,7 @@ export class AgentSession { previousCandidate, previousFailure, ); + if (!compactionIdentityIsCurrent()) return await emitAborted(); if (apiKey === undefined) { lastError = authError; previousCandidate = candidate; @@ -23029,7 +24676,7 @@ export class AgentSession { let attempt = 0; while (true) { try { - if (autoCompactionSignal.aborted) return await emitAborted(); + if (autoCompactionSignal.aborted || !compactionIdentityIsCurrent()) return await emitAborted(); compactResult = await compact(preparation, candidate, apiKey, undefined, autoCompactionSignal, { ...this.#maintenanceProviderTransport(), @@ -23143,7 +24790,7 @@ export class AgentSession { preserveData = { ...(compactionPrep.preserveData ?? {}), ...(compactResult.preserveData ?? {}) }; } - if (autoCompactionSignal.aborted) { + if (autoCompactionSignal.aborted || !compactionIdentityIsCurrent()) { await this.#emitSessionEvent({ type: "auto_compaction_end", action, @@ -23154,6 +24801,8 @@ export class AgentSession { return { kind: "aborted", source: "signal" }; } + this.#assertTerminalPersistenceSettledForHistoryMutation(); + if (!compactionIdentityIsCurrent()) return await emitAborted(); const compactionEntryId = this.sessionManager.appendCompaction( summary, shortSummary, @@ -23164,8 +24813,13 @@ export class AgentSession { preserveData, ); this.#recordAdaptiveCompactionReset(tokensBefore); - await this.#applyCompactionPostAppend(compactionEntryId, firstKeptEntryId, fromExtension); - if (autoCompactionSignal.aborted) return await emitAborted(); + await this.#applyCompactionPostAppend( + compactionEntryId, + firstKeptEntryId, + fromExtension, + compactionIdentityIsCurrent, + ); + if (autoCompactionSignal.aborted || !compactionIdentityIsCurrent()) return await emitAborted(); const result: CompactionResult = { summary, @@ -23177,6 +24831,7 @@ export class AgentSession { }; this.#lastOversizedAutoMaintenanceAttemptSignature = undefined; + if (!compactionIdentityIsCurrent()) return await emitAborted(); const continuationSkipReason = willRetry ? this.#detectOverflowRetryContinuationSkip() : undefined; if (continuationSkipReason) { this.#logCompactionContinuationSkipped("overflow_retry", continuationSkipReason); @@ -23184,10 +24839,12 @@ export class AgentSession { const overflowContinuationScheduled = willRetry && !continuationSkipReason ? await this.#scheduleOverflowRetryContinuation( - generation, - options?.resourceRunId, - options?.sdkOwnership, - ) + generation, + options?.resourceRunId, + options?.sdkOwnership, + { sessionId: compactionSessionId, sessionIdentityEpoch: compactionSessionIdentityEpoch }, + ownsAutoCompactionTransition, + ) : false; await this.#emitSessionEvent({ @@ -23219,6 +24876,8 @@ export class AgentSession { onSkip: reason => this.#logCompactionContinuationSkipped("queued_continue", reason), onError: error => this.#logCompactionContinuationError("queued_continue", error), resourceRunId: options?.resourceRunId, + scheduledSessionId: compactionSessionId, + scheduledSessionIdentityEpoch: compactionSessionIdentityEpoch, }); } else if (continueAfterMaintenance && reason !== "idle" && compactionSettings.autoContinue !== false) { this.#scheduleAutoContinuePrompt( @@ -23228,11 +24887,16 @@ export class AgentSession { undefined, undefined, options?.sdkOwnership, + { sessionId: compactionSessionId, sessionIdentityEpoch: compactionSessionIdentityEpoch }, ); } return { kind: "compacted" }; } catch (error) { - if (autoCompactionSignal.aborted) { + if ( + autoCompactionSignal.aborted || + !compactionIdentityIsCurrent() || + (error instanceof Error && (error as Error & { code?: string }).code === "compaction_identity_changed") + ) { await this.#emitSessionEvent({ type: "auto_compaction_end", action, @@ -23731,7 +25395,6 @@ export class AgentSession { contextOverflowManaged: true, nextFallbackAttempt: model => { controller.onAttemptStarted(); - this.#managedFallbackProviderAttemptCount++; return beginAttempt(formatModelString(model), String(++this.#fallbackInvocationId)); }, onManagedAttemptAccepted: () => { @@ -23843,8 +25506,6 @@ export class AgentSession { return existing; } this.#defaultFallbackController = new FallbackChainController(chain, this.settings.get("fallback.maxAttempts")); - this.#providerRetryMaxAttempts = undefined; - this.#managedFallbackProviderAttemptCount = 0; return this.#defaultFallbackController; } @@ -23983,6 +25644,15 @@ export class AgentSession { await restoreOwnedTransition(); return; } + const transitionEpoch = this.#sessionIdentityEpoch; + if ( + this.#sessionTransitionSettlement && + !(await this.#awaitSessionTransitionDisposition(transitionEpoch)) + ) { + await restoreOwnedTransition(); + return; + } + this.#assertNoSessionTransition(); const continuation = this.agent.continue({ ...this.#managedFallbackPromptOptions(), transientRecoveryMessage: this.#escapedNonAsciiRecoveryMessage(), @@ -24031,6 +25701,14 @@ export class AgentSession { type: "retry", continuation: async ownership => { if (attemptCancelled() || !ownership.isCurrent() || ownership.lease.signal.aborted) return; + const transitionEpoch = this.#sessionIdentityEpoch; + if ( + this.#sessionTransitionSettlement && + !(await this.#awaitSessionTransitionDisposition(transitionEpoch)) + ) + return; + if (attemptCancelled() || !ownership.isCurrent() || ownership.lease.signal.aborted) return; + this.#assertNoSessionTransition(); const continuationSdkOwnership = sdkOwnership ?? this.#captureSdkContinuationOwnership(ownership.handle.scope); await this.agent.continue({ @@ -24335,6 +26013,7 @@ export class AgentSession { continue; } const resolvedModel = resolved.model; + const canonicalModelKey = this.#managedFallbackCanonicalModelKey(resolvedModel); if (this.#escapedNonAsciiExhaustedModelKeys.has(`${resolved.model.provider}\u0000${resolved.model.id}`)) { controller.onResolutionSkip("escaped_non_ascii_model_exhausted"); continue; @@ -24344,10 +26023,26 @@ export class AgentSession { controller.onResolutionSkip(managedCursorUnavailable); continue; } - this.#ensureManagedFallbackCredentialTrackingGeneration(); - const canonicalModelKey = this.#managedFallbackCanonicalModelKey(resolvedModel); - if (this.#managedFallbackQuotaCeilingModels.has(canonicalModelKey)) { - controller.onResolutionSkip("same_model_provider_retry_ceiling"); + const keyResult = await (async () => { + try { + return await awaitWithCancellation( + this.#modelRegistry.getApiKey(resolvedModel, this.credentialSessionId), + ); + } catch (error) { + await rollbackCancelled(); + throw error; + } + })(); + if (keyResult.kind === "aborted") return await rollbackCancelled(); + const key = keyResult.value; + if ( + cancellationSignal.aborted || + (abortEpoch !== undefined && this.#abortAdmissionEpoch !== abortEpoch) || + !transitionStillOwned() + ) + return await rollbackCancelled(); + if (!isAuthenticated(key) && key !== kNoAuth) { + controller.onResolutionSkip("unauthenticated"); continue; } const failedCredentialKinds = this.#managedFallbackQuotaFailedModelCredentialKinds.get(canonicalModelKey); @@ -24605,7 +26300,6 @@ export class AgentSession { authStorage.hasConfigApiKey(provider, this.#modelRegistry.getAuthStorageOwner()) ); } - /** * Marks the credential that just failed and reports whether the session * actually moved to a DIFFERENT stored credential. @@ -24622,9 +26316,9 @@ export class AgentSession { * would hide an authorization defect and cycle through healthy rows. * 3. Auth failures retain their existing key-change proof. Quota, rate-limit * and OAuth account-model rejections mark before resolving and require two known, - * different stored row IDs before reporting rotation. If a same-kind peer remains - * but the pre-mark row identity is unknown, return `alternate` so managed fallback - * can retry the model without claiming a proven row rotation. + * different stored row IDs before reporting rotation. Managed same-turn fallback + * also retains the dispatched row identity when a concurrent insertion resets + * the session assignment. */ async #markFailedCredential(trigger: { class: FallbackTriggerClass; @@ -24648,7 +26342,12 @@ export class AgentSession { const authStorage = this.#modelRegistry.authStorage; const provider = model.provider; // (1) Pin guard, before any mutation and for every branch. - if (this.#hasManagedFallbackCredentialPin(provider)) { + if ( + authStorage.hasRuntimeApiKey(provider) || + authStorage.hasRuntimeCredentialSelector(provider) || + authStorage.hasSessionCredentialSelector(provider, this.credentialSessionId) || + authStorage.hasConfigApiKey(provider, this.#modelRegistry.getAuthStorageOwner()) + ) { return "unchanged"; } @@ -24913,25 +26612,12 @@ export class AgentSession { : false; } const attemptsUsed = managedFallback ? controller.attemptsUsed || 1 : this.#retryAttempt + 1; - const providerAttemptsUsed = managedFallback - ? Math.max(this.#managedFallbackProviderAttemptCount, controller.attemptsUsed || 1) - : attemptsUsed; const providerRetryCeilingReached = - providerRetryMaxAttempts !== undefined && providerAttemptsUsed >= providerRetryMaxAttempts; - if ( - managedFallback && - providerRetryCeilingReached && - (trigger.class === "quota" || trigger.class === "rate_limit") && - this.model - ) { - this.#ensureManagedFallbackCredentialTrackingGeneration(); - this.#managedFallbackQuotaCeilingModels.add(this.#managedFallbackCanonicalModelKey(this.model)); - } + providerRetryMaxAttempts !== undefined && attemptsUsed >= providerRetryMaxAttempts; // Credential rotation: a content-free quota/rate-limit failure has no // observable state to corrupt, so it is replay-safe regardless of - // extension lifecycle participation. Mark it before managed fallback - // policy decides whether to retry or advance, unless an explicit provider - // retry ceiling forbids another attempt. + // extension lifecycle participation. Mark the failed credential and + // retry with the next stored credential of the same provider. let credentialRotated = false; let quotaCredentialMark: "rotated" | "alternate" | "exhausted" | "unchanged" | undefined; if (canRotateCodexCredential && trigger.class === "credential" && !providerRetryCeilingReached) { @@ -25020,17 +26706,7 @@ export class AgentSession { const failedSelector = managedFallback ? controller.currentSelector() : undefined; let outcome: "retry" | "advance" | "exhausted"; - if ( - managedFallback && - (quotaCredentialMark === "rotated" || quotaCredentialMark === "alternate") && - !providerRetryCeilingReached - ) { - // A credential retry is a separate dimension from model fallback - // attempts. Leave the controller on the current entry and refund its - // provisional request charge so every same-kind account is tried first. - controller.discardStartedAttempt(); - outcome = "retry"; - } else if (managedFallback) { + if (managedFallback) { outcome = controller.onAttemptFailure(trigger.class, message.errorMessage || "Unknown error"); if (providerRetryCeilingReached && outcome === "retry") { outcome = controller.advance() ? "advance" : "exhausted"; @@ -25084,17 +26760,27 @@ export class AgentSession { ) { this.#modelRegistry.suppressSelector(failedSelector, Date.now() + trigger.retryAfterMs); } - // A fresh credential normally has its own retry dimension, but never - // override an explicit provider retry ceiling with a restored same-model - // entry. - if (credentialRotated && !providerRetryCeilingReached) { - // Do not rewind when the controller already chose retry: it still points - // at the current model. Rewind only after an actual model advance. - if ( - !managedFallback || - outcome === "retry" || - (outcome === "advance" && controller.restorePreviousEntryForRetry()) - ) { + // Credential rotation is unbounded: a fresh credential is a different + // retry dimension from transient-error backoff, so it overrides maxRetries + // exhaustion and forces an immediate same-model retry. + if ( + managedFallback && + !providerRetryCeilingReached && + outcome === "advance" && + (trigger.class === "quota" || trigger.class === "rate_limit") + ) { + const mark = await this.#markFailedCredential(trigger); + credentialRotated = mark === "rotated"; + if (mark === "exhausted") this.#stampQuotaRetryableAt(message); + } + if (credentialRotated) { + // A rotation only becomes a same-model retry if the controller can + // actually be rewound. `restorePreviousEntryForRetry()` refuses once an + // entry's restore budget is consumed or attempts are exhausted; ignoring + // that would force `outcome = "retry"` while `activeIndex` stays on the + // next entry and `this.model` stays on the previous one — splitting + // attempt attribution, exhaustion, and sticky selection across two models. + if (!managedFallback || controller.restorePreviousEntryForRetry()) { outcome = "retry"; } } @@ -25114,20 +26800,13 @@ export class AgentSession { } if (outcome === "advance") { this.#providerRetryMaxAttempts = undefined; - this.#managedFallbackProviderAttemptCount = 0; } if (outcome === "exhausted") { if (managedFallback) { let errorMessage = this.#fallbackExhaustionError(controller); if (!providerRetryCeilingReached && (trigger.class === "quota" || trigger.class === "rate_limit")) { if (!assistantMessageHasVisibleOrToolContent(message)) { - const mark = - quotaCredentialMark ?? - (await this.#markFailedCredential({ - ...trigger, - trackSameTurnRows: - managedFallback && (trigger.class === "quota" || trigger.class === "rate_limit"), - })); + const mark = await this.#markFailedCredential(trigger); if (mark === "exhausted") { this.#stampQuotaRetryableAt(message); errorMessage = this.#annotateQuotaRetryableAt(errorMessage); @@ -25172,18 +26851,14 @@ export class AgentSession { (activePromptHandle ? this.#runCancellationDomains.lookup(activePromptHandle)?.signal : undefined); if (ownership && (!ownership.isCurrent() || cancellationSignal?.aborted)) return; if (retryCancelled()) return; - let quotaPoolExhausted = quotaCredentialMark === "exhausted"; + let quotaPoolExhausted = false; if ( managedFallback && !credentialRotated && - quotaCredentialMark === undefined && !providerRetryCeilingReached && !(this.#isCodexCredentialModelUnavailable(message) && this.#codexCredentialModelUnavailableRetried) ) { - const mark = await this.#markFailedCredential({ - ...trigger, - trackSameTurnRows: managedFallback && (trigger.class === "quota" || trigger.class === "rate_limit"), - }); + const mark = await this.#markFailedCredential(trigger); if (mark === "rotated") credentialRotated = true; quotaPoolExhausted = mark === "exhausted"; } @@ -25377,6 +27052,14 @@ export class AgentSession { this.#resolveRetry(); return; } + const transitionEpoch = this.#sessionIdentityEpoch; + if ( + this.#sessionTransitionSettlement && + !(await this.#awaitSessionTransitionDisposition(transitionEpoch)) + ) + return; + if (retryCancelled() || ownershipCancelled()) return; + this.#assertNoSessionTransition(); await this.agent.continue({ ...this.#managedFallbackPromptOptions(), onRunAccepted: (handle: AttemptRunHandle) => { @@ -25480,6 +27163,7 @@ export class AgentSession { signal?: AbortSignal; resourceRunId?: string; onPreflightAccepted?: () => void | Promise; + onPreflightCommitted?: () => void; }, ): Promise { const deadline = Date.now() + 30_000; @@ -25501,10 +27185,14 @@ export class AgentSession { return; } if (!preflightAccepted) { + this.#assertNoSessionTransition(); await seam?.onPreflightAccepted?.(); if (seam?.signal?.aborted) throw promptPreflightCancelledError(); + this.#assertNoSessionTransition(); + seam?.onPreflightCommitted?.(); preflightAccepted = true; } + this.#assertNoSessionTransition(); await this.agent.prompt(messages, options); this.#releaseDeferredAgentEndLease(predecessorAgentEnd); return; @@ -25609,7 +27297,6 @@ export class AgentSession { if (!this.#isInterruptedRetryTail(lastMsg)) return false; this.#retryAttempt = 0; this.#providerRetryMaxAttempts = undefined; - this.#managedFallbackProviderAttemptCount = 0; this.#scheduleAgentContinue({ delayMs: 1 }); return true; } @@ -25629,7 +27316,6 @@ export class AgentSession { // Reset retry budget for a fresh attempt this.#retryAttempt = 0; this.#providerRetryMaxAttempts = undefined; - this.#managedFallbackProviderAttemptCount = 0; // Re-attempt the turn this.#scheduleAgentContinue({ delayMs: 1 }); @@ -25641,6 +27327,37 @@ export class AgentSession { // Bash Execution // ========================================================================= + async #saveBashOriginalArtifact( + originalText: string, + identity: SessionSelectionIdentity = this.#captureSessionSelectionIdentity(), + ): Promise { + if (!this.#isSessionSelectionIdentityCurrent(identity) || this.#sessionTransitionKind !== undefined) { + return { status: "unavailable" }; + } + try { + return await this.#withSelectionAdmission(identity, async () => { + if (!this.#isSessionSelectionIdentityCurrent(identity)) return { status: "unavailable" }; + const publication = this.sessionManager.captureArtifactPublication(); + const saved = await saveAgentBashOriginalArtifact(publication, originalText); + return this.#isSessionSelectionIdentityCurrent(identity) ? saved : { status: "unavailable" }; + }); + } catch (error) { + if (!this.#isSessionSelectionIdentityCurrent(identity) || this.#sessionTransitionKind !== undefined) { + return { status: "unavailable" }; + } + throw error; + } + } + + async #activatePendingGjcGoalModeRequestForExecution(identity: SessionSelectionIdentity): Promise { + if (!this.#isSessionSelectionIdentityCurrent(identity)) return; + if (this.#terminalPersistenceRecovery) await this.#reconcileTerminalPersistenceFailure(); + if (!this.#isSessionSelectionIdentityCurrent(identity)) return; + await this.#withSelectionAdmission(identity, async () => { + await this.#activatePendingGjcGoalModeRequest(); + this.#assertSessionSelectionIdentityCurrent(identity); + }); + } /** * Execute a bash command. * Adds result to agent context and session. @@ -25648,14 +27365,15 @@ export class AgentSession { * @param onChunk Optional streaming callback for output * @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix) * @param options.onPersisted Called once the execution's message is in session state - * (immediately when idle, at the post-turn flush while streaming) + * (immediately when idle, at the post-turn or recovery flush otherwise) */ async executeBash( command: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean; onPersisted?: () => void }, ): Promise { - const publishArtifact = this.sessionManager.captureArtifactPublication(); + if (this.#terminalPersistenceRecovery) await this.#reconcileTerminalPersistenceFailure(); + const executionIdentity = this.#captureSessionSelectionIdentity(); const excludeFromContext = options?.excludeFromContext === true; this.#markRetryReplayUnsafe(); @@ -25669,9 +27387,14 @@ export class AgentSession { cwd, }); if (hookResult?.result) { - this.recordBashResult(command, hookResult.result, options); - if (hookResult.result.exitCode === 0 && !hookResult.result.cancelled) { - await this.#activatePendingGjcGoalModeRequest(); + this.recordBashResult(command, hookResult.result, options, executionIdentity); + if ( + hookResult.result.exitCode === 0 && + !hookResult.result.cancelled && + this.#isSessionSelectionIdentityCurrent(executionIdentity) && + this.#sessionTransitionKind === undefined + ) { + await this.#activatePendingGjcGoalModeRequestForExecution(executionIdentity); } return hookResult.result; } @@ -25685,25 +27408,31 @@ export class AgentSession { onChunk, settings: this.settings, signal: abortController.signal, - sessionKey: this.sessionId, + sessionKey: executionIdentity.sessionId, cwd, timeout: clampTimeout("bash") * 1000, env: buildGjcRuntimeSessionEnv({ sessionFile: null, - sessionId: this.sessionId, + sessionId: executionIdentity.sessionId, cwd, }), - onMinimizedSave: originalText => saveAgentBashOriginalArtifact(publishArtifact, originalText), + onMinimizedSave: originalText => this.#saveBashOriginalArtifact(originalText, executionIdentity), }); - this.recordBashResult(command, result, options); - if (result.exitCode === 0 && !result.cancelled) { - await this.#activatePendingGjcGoalModeRequest(); + this.recordBashResult(command, result, options, executionIdentity); + if ( + result.exitCode === 0 && + !result.cancelled && + this.#isSessionSelectionIdentityCurrent(executionIdentity) && + this.#sessionTransitionKind === undefined + ) { + await this.#activatePendingGjcGoalModeRequestForExecution(executionIdentity); } return result; } finally { this.#bashAbortControllers.delete(abortController); - this.#scheduleQueuedFollowUpContinuation(); + if (this.#isSessionSelectionIdentityCurrent(executionIdentity) && this.#sessionTransitionKind === undefined) + this.#scheduleQueuedFollowUpContinuation(); } } @@ -25715,7 +27444,10 @@ export class AgentSession { command: string, result: BashResult, options?: { excludeFromContext?: boolean; onPersisted?: () => void }, + executionIdentity = this.#captureSessionSelectionIdentity(), ): void { + if (!this.#isSessionSelectionIdentityCurrent(executionIdentity) || this.#sessionTransitionKind !== undefined) + return; const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get(); const bashMessage: BashExecutionMessage = { role: "bashExecution", @@ -25730,13 +27462,16 @@ export class AgentSession { excludeFromContext: options?.excludeFromContext, }; - // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering - if (this.isStreaming) { - // Queue for later - will be flushed on agent_end + // If agent is streaming or terminal persistence recovery is pending, defer adding + // to preserve canonical transcript ordering. + if (this.isStreaming || this.#terminalPersistenceRecovery) { + // Queue for later - will be flushed on agent_end or after recovery. this.#pendingBashMessages.push({ message: bashMessage, onPersisted: options?.onPersisted, appendedToAgent: false, + sessionId: executionIdentity.sessionId, + sessionIdentityEpoch: executionIdentity.sessionIdentityEpoch, }); } else { // Add to agent state immediately @@ -25769,7 +27504,7 @@ export class AgentSession { /** * Flush pending bash messages to agent state and session. - * Called after agent turn completes to maintain proper message ordering. + * Called after agent turn completes or terminal persistence recovery succeeds. */ #flushPendingBashMessages(): void { this.#flushPendingExecutionMessages(this.#pendingBashMessages, "bash"); @@ -25786,13 +27521,15 @@ export class AgentSession { * @param onChunk Optional streaming callback for output * @param options.excludeFromContext If true, execution won't be sent to LLM ($$ prefix) * @param options.onPersisted Called once the execution's message is in session state - * (immediately when idle, at the post-turn flush while streaming) + * (immediately when idle, at the post-turn or recovery flush otherwise) */ async executePython( code: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean; onPersisted?: () => void }, ): Promise { + if (this.#terminalPersistenceRecovery) await this.#reconcileTerminalPersistenceFailure(); + const executionIdentity = this.#captureSessionSelectionIdentity(); const excludeFromContext = options?.excludeFromContext === true; this.#markRetryReplayUnsafe(); const cwd = this.sessionManager.getCwd(); @@ -25821,7 +27558,7 @@ export class AgentSession { } this.assertEvalExecutionAllowed(); if (hookResult?.result) { - this.recordPythonResult(code, hookResult.result, options); + this.recordPythonResult(code, hookResult.result, options, executionIdentity); return hookResult.result; } } @@ -25838,10 +27575,10 @@ export class AgentSession { // A retained kernel can finish after strict session close has fenced // persistence. The caller still receives its completed result, but it // must not reopen a closing session to append history. - if (!this.#evalExecutionDisposing) this.recordPythonResult(code, result, options); + if (!this.#evalExecutionDisposing) this.recordPythonResult(code, result, options, executionIdentity); return result; }); - return await this.trackEvalExecution(execution, abortController); + return await this.trackEvalExecution(execution, abortController, executionIdentity); } assertEvalExecutionAllowed(): void { @@ -25853,19 +27590,25 @@ export class AgentSession { /** * Track Python work started outside AgentSession.executePython so dispose can await and abort it too. */ - trackEvalExecution(execution: Promise, abortController: AbortController): Promise { + trackEvalExecution( + execution: Promise, + abortController: AbortController, + identity: SessionSelectionIdentity = this.#captureSessionSelectionIdentity(), + ): Promise { this.#evalAbortControllers.add(abortController); this.#activeEvalExecutions.add(execution); void execution.then( () => { this.#evalAbortControllers.delete(abortController); this.#activeEvalExecutions.delete(execution); - this.#scheduleQueuedFollowUpContinuation(); + if (this.#isSessionSelectionIdentityCurrent(identity) && this.#sessionTransitionKind === undefined) + this.#scheduleQueuedFollowUpContinuation(); }, () => { this.#evalAbortControllers.delete(abortController); this.#activeEvalExecutions.delete(execution); - this.#scheduleQueuedFollowUpContinuation(); + if (this.#isSessionSelectionIdentityCurrent(identity) && this.#sessionTransitionKind === undefined) + this.#scheduleQueuedFollowUpContinuation(); }, ); return execution; @@ -25878,7 +27621,10 @@ export class AgentSession { code: string, result: PythonResult, options?: { excludeFromContext?: boolean; onPersisted?: () => void }, + executionIdentity = this.#captureSessionSelectionIdentity(), ): void { + if (!this.#isSessionSelectionIdentityCurrent(executionIdentity) || this.#sessionTransitionKind !== undefined) + return; const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get(); const pythonMessage: PythonExecutionMessage = { role: "pythonExecution", @@ -25892,12 +27638,15 @@ export class AgentSession { excludeFromContext: options?.excludeFromContext, }; - // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering - if (this.isStreaming) { + // If agent is streaming or terminal persistence recovery is pending, defer adding + // to preserve canonical transcript ordering. + if (this.isStreaming || this.#terminalPersistenceRecovery) { this.#pendingPythonMessages.push({ message: pythonMessage, onPersisted: options?.onPersisted, appendedToAgent: false, + sessionId: executionIdentity.sessionId, + sessionIdentityEpoch: executionIdentity.sessionIdentityEpoch, }); } else { this.agent.appendMessage(pythonMessage); @@ -25966,16 +27715,25 @@ export class AgentSession { /** * Flush pending Python messages to agent state and session. + * Called after agent turn completes or terminal persistence recovery succeeds. */ #flushPendingPythonMessages(): void { this.#flushPendingExecutionMessages(this.#pendingPythonMessages, "python"); } #flushPendingExecutionMessages( - pendingMessages: Array<{ message: T; onPersisted?: () => void; appendedToAgent: boolean }>, + pendingMessages: Array<{ + message: T; + onPersisted?: () => void; + appendedToAgent: boolean; + sessionId: string; + sessionIdentityEpoch: number; + }>, kind: "bash" | "python", ): void { if (pendingMessages.length === 0) return; + if (this.#sessionTransitionKind !== undefined) return; + if (this.#terminalPersistenceRecovery) return; const total = pendingMessages.length; const remaining: typeof pendingMessages = []; @@ -25985,6 +27743,7 @@ export class AgentSession { let persistenceBlocked = false; let agentAppendBlocked = false; for (const pending of pendingMessages) { + if (!this.#isSessionSelectionIdentityCurrent(pending)) continue; if (!pending.appendedToAgent) { if (agentAppendBlocked) { remaining.push(pending); @@ -26010,6 +27769,17 @@ export class AgentSession { try { this.sessionManager.appendMessage(pending.message); } catch (error) { + if (error instanceof SessionNearLimitAppendError && error.entryRetained) { + persisted.push("command" in pending.message ? pending.message.command : pending.message.code); + try { + pending.onPersisted?.(); + } catch (callbackError) { + callbackFailureCount++; + errors.push(callbackError); + } + errors.push(error); + continue; + } // Session entries form a leaf-linked transcript. Once one append fails, // later entries must remain queued behind it or a retry would append the // failed entry after messages that originally followed it. @@ -26069,6 +27839,7 @@ export class AgentSession { awaitReply?: boolean; signal?: AbortSignal; }): Promise<{ replyText: string | null }> { + const exchangeIdentity = { sessionId: this.sessionId, sessionIdentityEpoch: this.#sessionIdentityEpoch }; const awaitReply = args.awaitReply !== false; const incomingTimestamp = Date.now(); const incomingObservationId = crypto.randomUUID(); @@ -26097,7 +27868,7 @@ export class AgentSession { args.signal?.throwIfAborted(); // Volatile session acceptance happens before any recipient or main-UI // observation, and before this delivery reports success to its sender. - this.#queueBackgroundExchangeInjection([incomingRecord]); + this.#queueBackgroundExchangeInjection([incomingRecord], { identity: exchangeIdentity }); announceIncoming(); return { replyText: null }; } @@ -26125,6 +27896,11 @@ export class AgentSession { : undefined, ircRosterClaim: rosterClaim, }); + if ( + exchangeIdentity.sessionId !== this.sessionId || + exchangeIdentity.sessionIdentityEpoch !== this.#sessionIdentityEpoch + ) + return { replyText: null }; const replyText = dedupeIrcReply(generatedReplyText); const replyObservationId = crypto.randomUUID(); const replyRecord: CustomMessage = { @@ -26139,7 +27915,10 @@ export class AgentSession { // Accept the ordered pair as one volatile batch before committing its // roster claim, notifying either UI, or resolving the sender delivery. args.signal?.throwIfAborted(); - this.#queueBackgroundExchangeInjection([incomingRecord, replyRecord], { deferFlush: true }); + this.#queueBackgroundExchangeInjection([incomingRecord, replyRecord], { + deferFlush: true, + identity: exchangeIdentity, + }); if (rosterClaim) this.#commitIrcRosterClaim(rosterClaim.token, rosterClaim.epoch); this.#flushOrSchedulePendingBackgroundExchanges(); announceIncoming(); @@ -26636,13 +28415,32 @@ export class AgentSession { }; } - #queueBackgroundExchangeInjection(messages: CustomMessage[], options?: { deferFlush?: boolean }): void { - this.#pendingBackgroundExchanges.push(messages); + #queueBackgroundExchangeInjection( + messages: CustomMessage[], + options?: { + deferFlush?: boolean; + identity?: { sessionId: string; sessionIdentityEpoch: number }; + }, + ): void { + const identity = options?.identity ?? { + sessionId: this.sessionId, + sessionIdentityEpoch: this.#sessionIdentityEpoch, + }; + this.#pendingBackgroundExchanges.push({ + messages, + sessionId: identity.sessionId, + sessionIdentityEpoch: identity.sessionIdentityEpoch, + }); if (!options?.deferFlush) this.#flushOrSchedulePendingBackgroundExchanges(); } #flushOrSchedulePendingBackgroundExchanges(): void { - if (!this.isStreaming) { + if ( + !this.isStreaming && + !this.#externalIngressSealed && + this.#sessionTransitionKind === undefined && + !this.#terminalPersistenceRecovery + ) { this.#flushPendingBackgroundExchanges(); return; } @@ -26658,7 +28456,12 @@ export class AgentSession { this.#scheduledBackgroundExchangeFlush = false; return; } - if (this.isStreaming) { + if ( + this.isStreaming || + this.#externalIngressSealed || + this.#sessionTransitionKind !== undefined || + this.#terminalPersistenceRecovery + ) { // Re-poll while streaming, but do not let this housekeeping timer // keep the event loop alive on its own (CPU-7). const pollTimer = setTimeout(attempt, 50); @@ -26674,15 +28477,24 @@ export class AgentSession { #flushPendingBackgroundExchanges(): void { if (this.#pendingBackgroundExchanges.length === 0) return; + if ( + this.#externalIngressSealed || + this.#sessionTransitionKind !== undefined || + this.#terminalPersistenceRecovery + ) { + this.#scheduleBackgroundExchangeFlush(); + return; + } const batches = this.#pendingBackgroundExchanges; this.#pendingBackgroundExchanges = []; for (const batch of batches) { - for (const msg of batch) { + if (batch.sessionId !== this.sessionId || batch.sessionIdentityEpoch !== this.#sessionIdentityEpoch) continue; + for (const msg of batch.messages) { // emitExternalEvent on message_end appends to agent state and dispatches // to all session listeners, which in turn handle TUI rendering and // sessionManager persistence via #handleAgentEvent. - this.agent.emitExternalEvent({ type: "message_start", message: msg }); - this.agent.emitExternalEvent({ type: "message_end", message: msg }); + this.#emitSessionOwnedExternalEvent({ type: "message_start", message: msg }); + this.#emitSessionOwnedExternalEvent({ type: "message_end", message: msg }); } } } @@ -26758,7 +28570,9 @@ export class AgentSession { ownerShutdownManager = asyncManager; ownerShutdownLease = lease; } + this.#externalIngressSealed = true; await this.abort(); + if (this.#turnEndPersistenceFailure) await this.#reconcileTurnEndPersistenceFailure(); if (this.isCompacting) { this.abortCompaction(); while (this.isCompacting) await Bun.sleep(10); @@ -26800,6 +28614,29 @@ export class AgentSession { const previousActiveSdkRunToken = this.#activeSdkRunToken; const previousActiveAttemptScope = this.#activeAttemptScope; const previousActiveLogicalRunId = this.#activeLogicalRunId; + const previousSessionIdentityEpoch = this.#sessionIdentityEpoch; + const previousCurrentAttemptScopeKeys = [...this.#currentSessionIdentityAttemptScopeKeys]; + const previousRetiredAttemptScopeKeys = [...this.#retiredSessionIdentityAttemptScopeKeys]; + const previousTtsrRecords = this.#ttsrManager?.getInjectedRecords() ?? []; + const previousTtsrMessageCount = this.#ttsrManager?.getMessageCount() ?? 0; + const previousPendingTtsrInjections = [...this.#pendingTtsrInjections]; + const previousPerToolTtsrInjections = new Map(this.#perToolTtsrInjections); + const previousCheckpointState = this.#checkpointState ? { ...this.#checkpointState } : undefined; + const previousPendingRewindReport = this.#pendingRewindReport; + const previousActiveSkillState = this.#activeSkillState; + const previousRestoredWorkflowSkillState = this.#restoredWorkflowSkillState; + const previousTurnEndPersistenceFailure = this.#turnEndPersistenceFailure; + const previousCanonicalMessageAdmissionTail = this.#canonicalMessageAdmissionTail; + const switchJobManager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance(); + const previousJobEndpointId = this.#asyncJobEndpointId( + previousSessionState.sessionId, + previousSessionState.sessionFile, + ); + const switchJobManagerOwnedPreviousEndpoint = + switchJobManager !== undefined && AsyncJobManager.forEndpoint(previousJobEndpointId) === switchJobManager; + const previousJobEndpointOwnedByForeign = + AsyncJobManager.forEndpoint(previousJobEndpointId) !== undefined && !switchJobManagerOwnedPreviousEndpoint; + let jobManagerRekeyed = false; this.#steeringMessages = []; this.#followUpMessages = []; @@ -26826,6 +28663,10 @@ export class AgentSession { this.#rekeyJobManagerForSessionIdentity(previousSessionState.sessionId, previousSessionState.sessionFile, { retirePredecessorRegistrations: false, }); + jobManagerRekeyed = + switchJobManagerOwnedPreviousEndpoint && + previousJobEndpointId !== + this.#asyncJobEndpointId(this.sessionManager.getSessionId(), this.sessionManager.getSessionFile()); if (switchingToDifferentSession) this.sessionManager.stageAdoptedArtifactManagerForTransition(); // The successor identity is already rotated in the manager but not yet // published; gate its local:// root before publication so the agent, @@ -27097,6 +28938,7 @@ export class AgentSession { ownerShutdownLease, ownerId, predecessorEndpointId, + !previousJobEndpointOwnedByForeign, ); this.#suppressOwnAsyncJobDeliveries(); this.emitNotice( @@ -27106,6 +28948,19 @@ export class AgentSession { ); } } + // Successor validation has committed. Retire predecessor completion + // authority even when job cleanup must finish in the background: copied + // transcripts can preserve the same endpoint id, so endpoint equality + // alone cannot distinguish a late predecessor result from successor work. + // Deferred cleanup retains the manager/job metadata it needs; the owned + // registration is only resume authority and must not cross identities. + const predecessorOwnerAtCommit = AsyncJobManager.forEndpoint(predecessorEndpointId); + if ( + !previousJobEndpointOwnedByForeign && + (predecessorOwnerAtCommit === undefined || predecessorOwnerAtCommit === switchJobManager) + ) + retireOwnedRegistrationsForEndpoint(predecessorEndpointId); + transitionCleanupCommitted = true; // The successor identity is committed. Settle consumed tracked inputs // before reconnecting the successor or running session-switch hooks, // either of which can outlive the predecessor event bridge. @@ -27124,9 +28979,8 @@ export class AgentSession { // Every predecessor job/delivery has settled. Only now can its // tuple evidence be retired; doing this immediately after rekey // made rollback restore live jobs without their owned tuples. - retireOwnedRegistrationsForEndpoint(predecessorEndpointId); this.sessionManager.retireEphemeralArtifactsAfterTransition(); - await this.#runToolSessionTransitionCleanups(); + await this.#runCommittedSessionTransitionCleanups(); } } this.#reconnectToAgent(); @@ -27140,13 +28994,15 @@ export class AgentSession { // messages, model state, MCP selections, the agent subscription, and // session-scoped tool cleanup are complete. if (this.#extensionRunner) { - await this.#runCommittedSuccessorHook(() => - this.#extensionRunner!.emit({ - type: "session_switch", - reason: "resume", - previousSessionFile, - ...(options?.transition ? { transition: options.transition } : {}), - }), + await this.#withPostCommitTransitionIngress(() => + this.#runCommittedSuccessorHook(() => + this.#extensionRunner!.emit({ + type: "session_switch", + reason: "resume", + previousSessionFile, + ...(options?.transition ? { transition: options.transition } : {}), + }), + ), ); } this.#terminalizeQueuedSdkWorkForSessionTransition( @@ -27167,6 +29023,13 @@ export class AgentSession { return true; } catch (error) { if (transitionCleanupCommitted) throw error; + this.#sessionIdentityEpoch = previousSessionIdentityEpoch; + this.#currentSessionIdentityAttemptScopeKeys.clear(); + for (const key of previousCurrentAttemptScopeKeys) this.#currentSessionIdentityAttemptScopeKeys.add(key); + this.#retiredSessionIdentityAttemptScopeKeys.clear(); + for (const key of previousRetiredAttemptScopeKeys) this.#retiredSessionIdentityAttemptScopeKeys.add(key); + this.agent.restoreStreamMessageForSessionRollback(null); + this.#provisionalAssistantMessage = undefined; let exactRetirementAbortError: unknown; try { await exactRetirement?.abort(); @@ -27184,11 +29047,12 @@ export class AgentSession { previousSessionState.sessionId, previousSessionState.sessionFile, ); - const rekeyed = AsyncJobManager.rekeyForEndpoint( - successorEndpointId, - predecessorEndpointId, - AsyncJobManager.forEndpoint(successorEndpointId), - ); + const successorOwner = AsyncJobManager.forEndpoint(successorEndpointId); + const rekeyed = + !jobManagerRekeyed || + (switchJobManager !== undefined && + successorOwner === switchJobManager && + AsyncJobManager.rekeyForEndpoint(successorEndpointId, predecessorEndpointId, switchJobManager)); if (!rekeyed) { // Another top-level session claimed the freed predecessor endpoint // while the switch's cleanup was in flight: restoring the old @@ -27266,6 +29130,18 @@ export class AgentSession { this.#thinkingLevel = previousThinkingLevel; this.agent.setThinkingLevel(toReasoningEffort(previousThinkingLevel)); this.agent.serviceTier = previousServiceTier; + this.#ttsrManager?.replacePersistedState(previousTtsrRecords, previousTtsrMessageCount); + this.#pendingTtsrInjections = previousPendingTtsrInjections; + this.#perToolTtsrInjections.clear(); + for (const [toolCallId, rules] of previousPerToolTtsrInjections) { + this.#perToolTtsrInjections.set(toolCallId, rules); + } + this.#checkpointState = previousCheckpointState; + this.#pendingRewindReport = previousPendingRewindReport; + this.#activeSkillState = previousActiveSkillState; + this.#restoredWorkflowSkillState = previousRestoredWorkflowSkillState; + this.#turnEndPersistenceFailure = previousTurnEndPersistenceFailure; + this.#canonicalMessageAdmissionTail = previousCanonicalMessageAdmissionTail; this.#syncTodoPhasesFromBranch(); this.#reconnectToAgent(); const rollbackErrors = [exactRetirementAbortError, sessionRestoreError, restoreMcpError].filter( @@ -27344,6 +29220,14 @@ export class AgentSession { } skipConversationRestore = result?.skipConversationRestore ?? false; } + if (this.isStreaming) await this.abort(); + await this.awaitSessionSettlement(); + if (this.#turnEndPersistenceFailure) await this.#reconcileTurnEndPersistenceFailure(); + if (this.isCompacting) { + this.abortCompaction(); + while (this.isCompacting) await Bun.sleep(10); + } + this.#externalIngressSealed = true; this.#settleTrackedQueuedInputsBeforeAgentDisconnect(); this.#disconnectFromAgent(); @@ -27363,6 +29247,7 @@ export class AgentSession { if (exactRetirement && !exactRetirement.canCommit()) throw new Error("Exact MCP control retirement changed before session adoption"); this.sessionManager.commitPreparedNewSession(prepared); + await exactRetirement?.commit(); } catch (error) { await exactRetirement?.abort(); @@ -27406,22 +29291,25 @@ export class AgentSession { // before fallible post-commit integrations. A cleanup or MCP failure must // not leave the next turn running with the parent's messages/session id. this.#rekeyJobManagerForSessionIdentity(previousSessionIdentity, previousSessionFile); - await this.#runToolSessionTransitionCleanups(); + await this.#runCommittedSessionTransitionCleanups(); await this.#restoreMCPSelectionsForSessionContext(sessionContext); // session_branch is the post-commit identity signal. Publish it only after // the successor's messages and MCP selections are restored. if (this.#extensionRunner) { - await this.#runCommittedSuccessorHook(() => - this.#extensionRunner!.emit({ - type: "session_branch", - previousSessionFile, - }), + await this.#withPostCommitTransitionIngress(() => + this.#runCommittedSuccessorHook(() => + this.#extensionRunner!.emit({ + type: "session_branch", + previousSessionFile, + }), + ), ); } return { selectedText, cancelled: false }; } finally { this.#reconnectToAgent(); + this.#externalIngressSealed = false; this.#endSessionTransition(); } } @@ -27503,6 +29391,14 @@ export class AgentSession { fromExtension = true; } } + this.#externalIngressSealed = true; + if (this.isStreaming) await this.abort(); + await this.awaitSessionSettlement(); + if (this.#turnEndPersistenceFailure) await this.#reconcileTurnEndPersistenceFailure(); + if (this.isCompacting) { + this.abortCompaction(); + while (this.isCompacting) await Bun.sleep(10); + } this.#settleTrackedQueuedInputsBeforeAgentDisconnect(); this.#disconnectFromAgent(); @@ -27569,6 +29465,10 @@ export class AgentSession { newLeafId = targetId; } + // Skill activation and background completion ownership must settle before the selected tree boundary. + await Promise.allSettled([...this.#skillStateSynchronizations]); + await this.#settleOwnAsyncJobsBeforeArtifactRetirement(); + // Switch leaf (with or without summary) // Summary is attached at the navigation target position (newLeafId), not the old branch let summaryEntry: BranchSummaryEntry | undefined; @@ -27588,11 +29488,15 @@ export class AgentSession { // No summary, navigating to non-root this.sessionManager.branch(newLeafId); } + this.#commitSessionIdentityTransition(); // The history rewrite is now committed. Drop predecessor-owned queued SDK // work at this boundary; cancelled or failed preparation above preserves it. const queuedSdkWork = this.#queuedMessagesForSessionTransition(); this.#terminalizeQueuedSdkWorkForSessionTransition(queuedSdkWork); + this.#settleDeliveredOwnedRegistrations(this.#pendingNextTurnMessages.map(entry => entry.message)); + this.#pendingNextTurnMessages = []; + this.#scheduledHiddenNextTurnGeneration = undefined; this.#deferredSdkFollowUps = []; this.#pendingNextTurnMessages = []; this.#scheduledHiddenNextTurnGeneration = undefined; @@ -27618,14 +29522,16 @@ export class AgentSession { // the emit and the context rebuild when no handlers are registered (mirrors // the session_before_tree guard above). if (this.#extensionRunner?.hasHandlers("session_tree")) { - await this.#runCommittedSuccessorHook(() => - this.#extensionRunner!.emit({ - type: "session_tree", - newLeafId: this.sessionManager.getLeafId(), - oldLeafId, - summaryEntry, - fromExtension: summaryText ? fromExtension : undefined, - }), + await this.#withPostCommitTransitionIngress(() => + this.#runCommittedSuccessorHook(() => + this.#extensionRunner!.emit({ + type: "session_tree", + newLeafId: this.sessionManager.getLeafId(), + oldLeafId, + summaryEntry, + fromExtension: summaryText ? fromExtension : undefined, + }), + ), ); const refreshedContext = this.buildDisplaySessionContext(); return { editorText, cancelled: false, summaryEntry, sessionContext: refreshedContext }; diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index f1537b96206..9033bf8b116 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -19065,14 +19065,21 @@ export class SessionManager { timestamp: new Date().toISOString(), message, }; - associateSessionMessageEntryId(message, entry.id); // Defense-in-depth (#4443): detect directly adjacent thinking/redacted_thinking // blocks in a persisted assistant transcript message and warn once per session. // This is a read-only observation — storage is NEVER mutated. The diagnostic is // bounded to development/test to avoid production noise, and names only the // envelope shape (block count), never raw thinking text, signatures, or payloads. this.#warnAdjacentPrivateThinking(message); - this.#appendEntry(entry); + try { + this.#appendEntry(entry); + } catch (error) { + if (error instanceof SessionNearLimitAppendError && error.entryRetained) { + associateSessionMessageEntryId(message, entry.id); + } + throw error; + } + associateSessionMessageEntryId(message, entry.id); const residentEntry = this.#byId.get(entry.id); if (residentEntry?.type === "message") transferSessionMessageIdentity([message], [residentEntry.message]); return entry.id; @@ -19503,6 +19510,34 @@ export class SessionManager { return Array.from(ruleNames); } + /** Read repeat-state authority without materializing the full session context. */ + getTtsrPersistenceState(): { records: TtsrInjectionRecord[]; messageCount: number } { + const records = new Map(); + let messageCount: number | undefined; + const visited = new Set(); + let current = this.#leafId ? this.#resolveEntry(this.#leafId) : undefined; + while (current && !visited.has(current.id)) { + visited.add(current.id); + if (current.type === "ttsr_injection") { + if ( + messageCount === undefined && + typeof current.ttsrMessageCount === "number" && + Number.isFinite(current.ttsrMessageCount) + ) { + messageCount = current.ttsrMessageCount; + } + for (const record of current.injectedRuleRecords ?? []) { + if (!records.has(record.name)) records.set(record.name, { ...record }); + } + for (const name of current.injectedRules) { + if (!records.has(name)) records.set(name, { name, lastInjectedAt: 0 }); + } + } + current = current.parentId ? this.#resolveEntry(current.parentId) : undefined; + } + return { records: Array.from(records.values()), messageCount: messageCount ?? 0 }; + } + // ========================================================================= // Tree Traversal // ========================================================================= diff --git a/packages/coding-agent/src/session/terminal-abort.ts b/packages/coding-agent/src/session/terminal-abort.ts index 11cc08a2a1a..6fb14540fc1 100644 --- a/packages/coding-agent/src/session/terminal-abort.ts +++ b/packages/coding-agent/src/session/terminal-abort.ts @@ -711,7 +711,7 @@ const toolCallLineageKey = (endpointId: string | undefined, toolCallId: string) * never be mutated from a session-current fallback; missing/mismatched * context fails closed (resolve returns undefined). */ -export function bindToolLineage(toolCallId: string, binding: LineageBinding): void { +export function bindToolLineage(toolCallId: string, binding: LineageBinding): LineageBinding { if (lineageByToolCall.size >= MAX_LINEAGE_BINDINGS) { const oldest = lineageByToolCall.keys().next().value; if (oldest !== undefined) { @@ -764,6 +764,7 @@ export function bindToolLineage(toolCallId: string, binding: LineageBinding): vo // is in flight until its own afterToolCall settles. settledToolCallLineages.delete(toolCallLineageKey(binding.endpointId, toolCallId)); lineageByToolCall.set(toolCallLineageKey(binding.endpointId, toolCallId), binding); + return binding; } export function resolveToolLineage(toolCallId: string | undefined, endpointId?: string): LineageBinding | undefined { @@ -780,16 +781,24 @@ export function resolveToolLineage(toolCallId: string | undefined, endpointId?: } /** Close an evicted tool's registration window after its execution settles. */ -export function settleToolLineageRegistrationWindow(toolCallId: string, endpointId?: string): void { +export function settleToolLineageRegistrationWindow( + toolCallId: string, + endpointId?: string, + expectedBinding?: LineageBinding, +): void { const key = toolCallLineageKey(endpointId, toolCallId); + const current = lineageByToolCall.get(key); + const window = incompleteToolCallWindows.get(key); + if (expectedBinding !== undefined && current !== expectedBinding && window?.binding !== expectedBinding) return; // Mark the execution settled ONLY while its binding remains in the lineage // map: a later FIFO eviction of that binding consults this set and removes // the key. An already-evicted binding lives only in the window closed // below — retaining the marker there would leak the key forever because // no eviction can ever execute the delete (review thread P2). - if (lineageByToolCall.has(key)) settledToolCallLineages.add(key); - const window = incompleteToolCallWindows.get(key); - if (window !== undefined) { + if (current !== undefined && (expectedBinding === undefined || current === expectedBinding)) { + settledToolCallLineages.add(key); + } + if (window !== undefined && (expectedBinding === undefined || window.binding === expectedBinding)) { incompleteToolCallWindows.delete(key); const remaining = (incompleteAttemptWindowCounts.get(window.incompleteKey) ?? 1) - 1; if (remaining <= 0) { diff --git a/packages/coding-agent/src/session/yield-queue.ts b/packages/coding-agent/src/session/yield-queue.ts index 229508aa665..7b8bf695582 100644 --- a/packages/coding-agent/src/session/yield-queue.ts +++ b/packages/coding-agent/src/session/yield-queue.ts @@ -2,7 +2,7 @@ import type { AgentMessage } from "@gajae-code/agent-core"; import { logger } from "@gajae-code/utils"; export interface YieldDispatcher

{ - /** Drop entries already delivered through another path. Called per-entry at flush time. */ + /** Drop entries already delivered through another path or an explicit identity cleanup. */ isStale?(entry: P): boolean; /** * Optional ownership-origin key: when provided, the flush builds ONE @@ -11,16 +11,29 @@ export interface YieldDispatcher

{ * another origin (review thread P2). */ groupKey?(entry: P): string; + onDrop?(entry: P): void; + /** Called per-entry after the built message is accepted by the injector. */ + onDelivered?(entry: P): void; + preserveAcrossIdentity?: boolean; /** Produce one batched AgentMessage from non-stale entries. Return null to skip. */ build(survivors: P[]): AgentMessage | null; } +/** Outcome reported by an idle injector after it reaches its admission boundary. */ +export type YieldDeliveryResult = "delivered" | "retry" | "dropped"; + export interface YieldQueueOptions { isStreaming: () => boolean; injectStreaming(msg: AgentMessage): void; - injectIdle(messages: AgentMessage[], signal?: AbortSignal): Promise; - scheduleIdleFlush(run: (signal?: AbortSignal) => Promise, onSkip: () => void): void; + injectIdle( + messages: AgentMessage[], + signal?: AbortSignal, + identityIsCurrent?: () => boolean, + ): Promise; + scheduleIdleFlush(run: (signal?: AbortSignal) => Promise, onSkip: () => void, delayMs?: number): void; getIdleFlushSignal?(): AbortSignal | undefined; + captureIdentity?(): unknown; + isIdentityCurrent?(identity: unknown): boolean; } type YieldFlushMode = "streaming" | "idle"; @@ -28,19 +41,45 @@ type YieldFlushMode = "streaming" | "idle"; interface StoredDispatcher { isStale?: (entry: unknown) => boolean; groupKey?: (entry: unknown) => string; + onDrop?: (entry: unknown) => void; + onDelivered?: (entry: unknown) => void; + preserveAcrossIdentity: boolean; build: (survivors: unknown[]) => AgentMessage | null; } +interface StoredEntry { + value: unknown; + identity: unknown; +} + +interface BuiltMessage { + message: AgentMessage; + entries: StoredEntry[]; +} + +interface FlushBatch { + kind: string; + dispatcher: StoredDispatcher; + built: BuiltMessage; + generation: number; +} + function formatError(error: unknown): string { return error instanceof Error ? error.message : String(error); } +const DISPATCH_FAILURE_RETRY_DELAY_MS = 1_000; + export class YieldQueue { readonly #options: YieldQueueOptions; readonly #dispatchers = new Map(); - readonly #entries = new Map(); + readonly #entries = new Map(); + readonly #kindClearGenerations = new Map(); + #clearGeneration = 0; #idleFlushPending = false; #idleFlushPendingOwner: symbol | undefined; + #idleFlushPendingDelayMs = 0; + #idleFlushRetryDelayMs = 0; constructor(options: YieldQueueOptions) { this.#options = options; @@ -50,6 +89,9 @@ export class YieldQueue { const stored: StoredDispatcher = { ...(dispatcher.isStale ? { isStale: entry => dispatcher.isStale?.(entry as P) ?? false } : {}), ...(dispatcher.groupKey ? { groupKey: entry => dispatcher.groupKey?.(entry as P) ?? "default" } : {}), + ...(dispatcher.onDrop ? { onDrop: entry => dispatcher.onDrop?.(entry as P) } : {}), + ...(dispatcher.onDelivered ? { onDelivered: entry => dispatcher.onDelivered?.(entry as P) } : {}), + preserveAcrossIdentity: dispatcher.preserveAcrossIdentity === true, build: survivors => dispatcher.build(survivors as P[]), }; this.#dispatchers.set(kind, stored); @@ -57,6 +99,7 @@ export class YieldQueue { if (this.#dispatchers.get(kind) !== stored) return; this.#dispatchers.delete(kind); this.#entries.delete(kind); + this.#clearIdleFlushIfEmpty(); }; } @@ -70,7 +113,7 @@ export class YieldQueue { entries = []; this.#entries.set(kind, entries); } - entries.push(entry); + entries.push({ value: entry, identity: this.#options.captureIdentity?.() }); if (!this.#options.isStreaming()) { this.#scheduleIdleFlush(); } @@ -88,45 +131,117 @@ export class YieldQueue { if (mode === "idle") { this.#idleFlushPending = false; this.#idleFlushPendingOwner = undefined; + this.#idleFlushPendingDelayMs = 0; + this.#idleFlushRetryDelayMs = 0; } const idleMessages: AgentMessage[] = []; + const idleBatches: FlushBatch[] = []; + const preservedIdleMessages: AgentMessage[] = []; + const preservedIdleBatches: FlushBatch[] = []; for (const [kind, dispatcher] of this.#dispatchers) { - const entries = this.#drain(kind); - if (entries.length === 0) continue; - const messages = this.#build(kind, dispatcher, entries) ?? []; - for (const message of messages) { + const drained = this.#drain(kind); + const admitted = drained.filter(entry => { + const current = + dispatcher.preserveAcrossIdentity || (this.#options.isIdentityCurrent?.(entry.identity) ?? true); + if (!current) dispatcher.onDrop?.(entry.value); + return current; + }); + if (admitted.length === 0) continue; + const builtMessages = this.#build(kind, dispatcher, admitted) ?? []; + for (const built of builtMessages) { if (mode === "streaming") { try { - this.#options.injectStreaming(message); + this.#options.injectStreaming(built.message); } catch (error) { + this.#requeue(kind, built.entries); + this.rearmIdle(); logger.warn("Yield queue streaming dispatch failed", { kind, error: formatError(error) }); + continue; } + this.#notifyDelivered(dispatcher, built.entries); } else { - idleMessages.push(message); + if (dispatcher.preserveAcrossIdentity) { + preservedIdleMessages.push(built.message); + preservedIdleBatches.push({ kind, dispatcher, built, generation: this.#generationFor(kind) }); + } else { + idleMessages.push(built.message); + idleBatches.push({ kind, dispatcher, built, generation: this.#generationFor(kind) }); + } } } } if (mode === "idle" && idleMessages.length > 0) { try { - await this.#options.injectIdle(idleMessages, signal ?? this.#options.getIdleFlushSignal?.()); + const result = await this.#options.injectIdle( + idleMessages, + signal ?? this.#options.getIdleFlushSignal?.(), + () => this.#identitiesAreCurrent(idleBatches), + ); + if (!this.#batchesAreCurrent(idleBatches)) { + this.#notifyDroppedBatches(idleBatches); + } else if (result === "retry") { + if (this.#identitiesAreCurrent(idleBatches)) this.#requeueBatches(idleBatches); + else this.#notifyDroppedBatches(idleBatches); + } else if (result === "dropped") this.#notifyDroppedBatches(idleBatches); + else this.#notifyDeliveredBatches(idleBatches); } catch (error) { + if (this.#identitiesAreCurrent(idleBatches)) this.#requeueBatches(idleBatches); + else this.#notifyDroppedBatches(idleBatches); logger.warn("Yield queue idle dispatch failed", { error: formatError(error) }); } } + if (mode === "idle" && preservedIdleMessages.length > 0) { + try { + const result = await this.#options.injectIdle( + preservedIdleMessages, + signal ?? this.#options.getIdleFlushSignal?.(), + ); + if (!this.#batchesAreCurrent(preservedIdleBatches)) this.#notifyDroppedBatches(preservedIdleBatches); + else if (result === "retry") this.#requeueBatches(preservedIdleBatches); + else if (result === "dropped") this.#notifyDroppedBatches(preservedIdleBatches); + else this.#notifyDeliveredBatches(preservedIdleBatches); + } catch (error) { + this.#requeueBatches(preservedIdleBatches); + logger.warn("Yield queue preserved idle dispatch failed", { error: formatError(error) }); + } + } + this.#clearIdleFlushIfEmpty(); } clear(onDrop?: (kind: string, entries: readonly unknown[]) => void): void { - if (onDrop) { - for (const [kind, entries] of this.#entries) onDrop(kind, entries); + this.#clearGeneration += 1; + for (const [kind, entries] of this.#entries) { + if (onDrop) { + onDrop( + kind, + entries.map(entry => entry.value), + ); + } else { + this.#notifyDropped(this.#dispatchers.get(kind), entries); + } } this.#entries.clear(); this.#idleFlushPending = false; this.#idleFlushPendingOwner = undefined; + this.#idleFlushPendingDelayMs = 0; + this.#idleFlushRetryDelayMs = 0; } - /** Drop only the queued entries of a single kind, leaving other kinds intact. */ + /** Drop only the queued entries of a single kind, releasing their claims. */ clearKind(kind: string): void { + this.#kindClearGenerations.set(kind, this.#generationFor(kind) + 1); + const entries = this.#entries.get(kind); + if (entries) this.#notifyDropped(this.#dispatchers.get(kind), entries); this.#entries.delete(kind); + this.#clearIdleFlushIfEmpty(); + } + + #clearIdleFlushIfEmpty(): void { + if (this.has()) return; + this.#idleFlushPending = false; + this.#idleFlushPendingOwner = undefined; + this.#idleFlushPendingDelayMs = 0; + this.#idleFlushRetryDelayMs = 0; } /** @@ -134,7 +249,15 @@ export class YieldQueue { * a transition (e.g. handoff) releases a delivery fence so entries queued while * fenced are not stranded until an unrelated enqueue or agent yield. */ - rearmIdle(): void { + rearmIdle(delayMs?: number): void { + if (delayMs !== undefined) { + this.#idleFlushRetryDelayMs = Math.max(this.#idleFlushRetryDelayMs, delayMs); + if (this.#idleFlushPending && this.#idleFlushPendingDelayMs < this.#idleFlushRetryDelayMs) { + this.#idleFlushPendingOwner = undefined; + this.#idleFlushPending = false; + this.#idleFlushPendingDelayMs = 0; + } + } if (this.#options.isStreaming()) return; for (const entries of this.#entries.values()) { if (entries.length > 0) { @@ -142,38 +265,59 @@ export class YieldQueue { return; } } + this.#idleFlushRetryDelayMs = 0; } #scheduleIdleFlush(): void { if (this.#idleFlushPending) return; this.#idleFlushPending = true; + const delayMs = this.#idleFlushRetryDelayMs; + this.#idleFlushRetryDelayMs = 0; + this.#idleFlushPendingDelayMs = delayMs; const owner = Symbol("idle-flush"); this.#idleFlushPendingOwner = owner; const releaseOwner = () => { if (this.#idleFlushPendingOwner !== owner) return; this.#idleFlushPendingOwner = undefined; this.#idleFlushPending = false; + this.#idleFlushPendingDelayMs = 0; }; try { - this.#options.scheduleIdleFlush(async signal => { - releaseOwner(); - if (this.#options.isStreaming()) return; - await this.flush("idle", signal); - }, releaseOwner); + this.#options.scheduleIdleFlush( + async signal => { + if (this.#idleFlushPendingOwner !== owner) return; + releaseOwner(); + if (this.#options.isStreaming()) { + if (this.has()) this.#idleFlushRetryDelayMs = Math.max(this.#idleFlushRetryDelayMs, delayMs); + return; + } + await this.flush("idle", signal); + }, + () => { + const ownsPendingFlush = this.#idleFlushPendingOwner === owner; + releaseOwner(); + if (ownsPendingFlush && this.has()) + this.#idleFlushRetryDelayMs = Math.max(this.#idleFlushRetryDelayMs, delayMs); + }, + delayMs > 0 ? delayMs : undefined, + ); } catch (error) { + const ownsPendingFlush = this.#idleFlushPendingOwner === owner; releaseOwner(); + if (ownsPendingFlush && this.has()) + this.#idleFlushRetryDelayMs = Math.max(this.#idleFlushRetryDelayMs, delayMs); logger.warn("Yield queue idle flush scheduling failed", { error: formatError(error) }); } } - #drain(kind: string): unknown[] { + #drain(kind: string): StoredEntry[] { const entries = this.#entries.get(kind); if (!entries || entries.length === 0) return []; this.#entries.delete(kind); return entries; } - #build(kind: string, dispatcher: StoredDispatcher, entries: unknown[]): AgentMessage[] | null { + #build(kind: string, dispatcher: StoredDispatcher, entries: StoredEntry[]): BuiltMessage[] | null { // Corrected turn semantics (terminal abort): turn-scope abort blocks only // deliveries whose origin is a continuation of the aborted turn. // Owned-completion deliveries from work deliberately left running are @@ -183,15 +327,18 @@ export class YieldQueue { // stale merely because it is closed; stale filtering below applies only // to ordinary manager state (e.g. isDeliverySuppressed) or explicit // blocked-continuation/owned-cleanup entries. - const survivors: unknown[] = []; - for (const entry of entries) { + const survivors: StoredEntry[] = []; + for (let entryIndex = 0; entryIndex < entries.length; entryIndex++) { + const entry = entries[entryIndex]!; if (dispatcher.isStale) { let stale: boolean; try { - stale = dispatcher.isStale(entry); + stale = dispatcher.isStale(entry.value); } catch (error) { logger.warn("Yield queue stale check failed", { kind, error: formatError(error) }); - continue; + this.#requeue(kind, entries.slice(entryIndex)); + this.rearmIdle(DISPATCH_FAILURE_RETRY_DELAY_MS); + break; } if (stale) continue; } @@ -205,10 +352,10 @@ export class YieldQueue { // entries A1, B1, A2, a map grouping every A together would deliver A2 // before the earlier B1, changing the observable order of async results // (review thread P2). - const groups: unknown[][] = []; + const groups: StoredEntry[][] = []; let currentGroupKey: string | undefined; for (const entry of survivors) { - const key = dispatcher.groupKey ? dispatcher.groupKey(entry) : "default"; + const key = dispatcher.groupKey ? dispatcher.groupKey(entry.value) : "default"; const last = groups[groups.length - 1]; if (last !== undefined && currentGroupKey === key) { last.push(entry); @@ -217,15 +364,86 @@ export class YieldQueue { currentGroupKey = key; } } - const messages: AgentMessage[] = []; - for (const group of groups.values()) { + const messages: BuiltMessage[] = []; + for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) { + const group = groups[groupIndex]!; try { - const message = dispatcher.build(group); - if (message) messages.push(message); + const message = dispatcher.build(group.map(entry => entry.value)); + if (message) messages.push({ message, entries: group }); + else this.#notifyDropped(dispatcher, group); } catch (error) { logger.warn("Yield queue build failed", { kind, error: formatError(error) }); + // Preserve FIFO by retaining the failed group and every group that + // has not been built yet. Requeueing each group in reverse order + // works with #requeue's prepend semantics without combining origins. + for (let pendingIndex = groups.length - 1; pendingIndex >= groupIndex; pendingIndex--) { + this.#requeue(kind, groups[pendingIndex]!); + } + this.rearmIdle(DISPATCH_FAILURE_RETRY_DELAY_MS); + break; } } return messages.length > 0 ? messages : null; } + + #identitiesAreCurrent(batches: FlushBatch[]): boolean { + return batches.every(batch => + batch.built.entries.every( + entry => entry.identity === undefined || (this.#options.isIdentityCurrent?.(entry.identity) ?? true), + ), + ); + } + + #batchesAreCurrent(batches: FlushBatch[]): boolean { + return batches.every(batch => this.#isGenerationCurrent(batch)); + } + + #requeueBatches(batches: FlushBatch[]): void { + const retryable = batches.filter(batch => this.#isGenerationCurrent(batch)); + for (const batch of batches) { + if (!this.#isGenerationCurrent(batch)) this.#notifyDropped(batch.dispatcher, batch.built.entries); + } + const entriesByKind = new Map(); + for (const { kind, built } of retryable) { + const entries = entriesByKind.get(kind); + if (entries) entries.push(...built.entries); + else entriesByKind.set(kind, [...built.entries]); + } + for (const [kind, entries] of entriesByKind) this.#requeue(kind, entries); + this.rearmIdle(); + } + + #generationFor(kind: string): number { + return this.#clearGeneration + (this.#kindClearGenerations.get(kind) ?? 0); + } + + #isGenerationCurrent(batch: FlushBatch): boolean { + return batch.generation === this.#generationFor(batch.kind); + } + + #requeue(kind: string, entries: StoredEntry[]): void { + if (entries.length === 0) return; + // Put the drained entries back in front of anything enqueued while the + // injector was waiting so a retry cannot reorder or duplicate delivery. + const existing = this.#entries.get(kind); + this.#entries.set(kind, existing ? [...entries, ...existing] : [...entries]); + } + + #notifyDelivered(dispatcher: StoredDispatcher | undefined, entries: StoredEntry[]): void { + if (!dispatcher?.onDelivered) return; + for (const entry of entries) dispatcher.onDelivered(entry.value); + } + + #notifyDropped(dispatcher: StoredDispatcher | undefined, entries: StoredEntry[]): void { + if (!dispatcher?.onDrop) return; + for (const entry of entries) dispatcher.onDrop(entry.value); + } + + #notifyDeliveredBatches(batches: Array<{ dispatcher: StoredDispatcher; built: BuiltMessage }>): void { + for (const { dispatcher, built } of batches) this.#notifyDelivered(dispatcher, built.entries); + } + + #notifyDroppedBatches(batches: Array<{ dispatcher: StoredDispatcher; built: BuiltMessage }>): void { + for (const { dispatcher, built } of batches) this.#notifyDropped(dispatcher, built.entries); + } } diff --git a/packages/coding-agent/src/skill-state/active-state.ts b/packages/coding-agent/src/skill-state/active-state.ts index b98a601dc3d..09458b63fee 100644 --- a/packages/coding-agent/src/skill-state/active-state.ts +++ b/packages/coding-agent/src/skill-state/active-state.ts @@ -17,6 +17,7 @@ import { rebuildActiveSnapshot, removeActiveEntry, setActiveStateCacheInvalidator, + withActiveStateScopeLock, workflowEnvelopeChecksumStatus, writeActiveEntry, } from "../gjc-runtime/state-writer"; @@ -648,6 +649,7 @@ const PLANNING_PIPELINE_RANK = new Map([ ["ralplan", 1], ["ultragoal", 2], ]); +const ACTIVE_ENTRY_MIGRATION_MARKER = ".active-entry-migration.pending"; function planningPipelineRank(skill: string): number | undefined { return PLANNING_PIPELINE_RANK.get(skill); @@ -683,22 +685,105 @@ async function mergeVisibleEntries( sessionState: SkillActiveState | null, sessionId: string, perSkillEntries?: readonly SkillActiveEntry[], + activeStateScopeLockHeld = false, ): Promise { // Use the raw (active + inactive) rows so a handoff demotion stays visible // long enough to supersede a stale same-skill row before the active filter. // Per-skill files in active/.json are authoritative and are merged // after the derived snapshot cache, so a stale skill-active-state.json row // cannot override the latest entry file. - const entries = [ - ...rawActiveEntries(sessionState), - ...(perSkillEntries ?? (await readActiveEntries(cwd, { sessionId }))), - ]; - const merged = new Map(entries.map(entry => [entryKey(entry), entry])); - const canonicalRalplanPhase = await readModeStatePhase(cwd, sessionId, "ralplan"); - const visibleEntries = dedupeVisibleBySkill([...merged.values()], sessionId) - .filter(entry => entry.active !== false) - .map(entry => withCanonicalRalplanPhase(entry, canonicalRalplanPhase)); - return collapsePlanningPipeline(visibleEntries).toSorted(comparePipelineEntry); + const read = async () => { + const activeEntries = perSkillEntries ?? (await readActiveEntries(cwd, { sessionId })); + const hasAuthoritativeEntryDirectory = await hasAuthoritativeActiveEntryDirectory(cwd, sessionId); + const authoritativeSkills = new Set(activeEntries.map(entry => entry.skill)); + const snapshotFallbackEntries = rawActiveEntries(sessionState).filter( + entry => !hasAuthoritativeEntryDirectory || authoritativeSkills.has(entry.skill), + ); + const entries = [...snapshotFallbackEntries, ...activeEntries]; + const merged = new Map(entries.map(entry => [entryKey(entry), entry])); + const canonicalRalplanPhase = await readModeStatePhase(cwd, sessionId, "ralplan"); + const visibleEntries = dedupeVisibleBySkill([...merged.values()], sessionId) + .filter(entry => entry.active !== false) + .map(entry => withCanonicalRalplanPhase(entry, canonicalRalplanPhase)); + return collapsePlanningPipeline(visibleEntries).toSorted(comparePipelineEntry); + }; + return activeStateScopeLockHeld ? await read() : await withActiveStateScopeLock(cwd, { sessionId }, read); +} + +function activeEntryMigrationMarkerPath(cwd: string, sessionId: string): string { + return path.join(path.dirname(activeStateDir(cwd, sessionId)), ACTIVE_ENTRY_MIGRATION_MARKER); +} + +async function hasActiveEntryDirectory(cwd: string, sessionId: string): Promise { + try { + return (await fs.stat(activeStateDir(cwd, sessionId))).isDirectory(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + return false; + } +} + +async function hasPendingActiveEntryMigration(cwd: string, sessionId: string): Promise { + try { + await fs.stat(activeEntryMigrationMarkerPath(cwd, sessionId)); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + return false; + } +} + +async function hasAuthoritativeActiveEntryDirectory(cwd: string, sessionId: string): Promise { + return (await hasActiveEntryDirectory(cwd, sessionId)) && !(await hasPendingActiveEntryMigration(cwd, sessionId)); +} + +/** + * Migrate unrepresented legacy or indeterminate snapshot rows before treating the + * per-skill directory as complete. Keep a marker until all entries are durable so + * reads and retries continue to include the snapshot if migration is interrupted. + */ +async function migrateSnapshotEntriesToActiveDirectory(cwd: string, sessionId: string): Promise { + const sessionScope = { sessionId }; + const migrationMarkerPath = activeEntryMigrationMarkerPath(cwd, sessionId); + const migrationPending = await hasPendingActiveEntryMigration(cwd, sessionId); + const activeDirectoryExists = await hasActiveEntryDirectory(cwd, sessionId); + + const { sessionPath } = getSkillActiveStatePaths(cwd, sessionId); + const snapshot = migrationPending + ? await readSessionSnapshotStrict(sessionPath) + : await readRawActiveStateForHandoff(sessionPath, false); + const snapshotEntries = snapshot ? rawActiveEntries(snapshot) : []; + const existingEntries = await readActiveEntries(cwd, sessionScope); + let entriesToMigrate = snapshotEntries; + if (!migrationPending) { + if (snapshot && classifySnapshotAuthority(snapshot) === "derived") return; + if (activeDirectoryExists) { + const representedSkills = new Set(existingEntries.map(entry => entry.skill)); + entriesToMigrate = snapshotEntries.filter(entry => !representedSkills.has(entry.skill)); + } + if (entriesToMigrate.length === 0) return; + } + + const entries = dedupeVisibleBySkill([...existingEntries, ...entriesToMigrate], sessionId); + if (entries.length === 0) { + throw new Error( + `Cannot complete skill active-entry migration for session ${sessionId}: no source entries remain`, + ); + } + if (!migrationPending) await Bun.write(migrationMarkerPath, "pending\n"); + + for (const entry of entries) { + await writeActiveEntry(cwd, sessionScope, entry.skill, entry, { + cwd, + audit: activeStateWriterAudit("migrate-active-entry", sessionScope), + sourceRevision: + typeof entry.source_state_revision === "number" && Number.isFinite(entry.source_state_revision) + ? entry.source_state_revision + : persistedStateRevision(entry), + activeStateScopeLockHeld: true, + }); + } + await fs.rm(migrationMarkerPath, { force: true }); } export type VisibleSkillActiveStateCacheTier = "security" | "hud"; @@ -718,6 +803,7 @@ interface ActiveStateSignature { sessionPath: ActiveStateStatSignature | null; activeDir: ActiveStateStatSignature | null; activeEntries: ActiveStateStatSignature[]; + migrationMarker: ActiveStateStatSignature | null; ralplanModeState: ActiveStateStatSignature | null; } @@ -751,6 +837,7 @@ function signaturesEqual(a: ActiveStateSignature, b: ActiveStateSignature): bool left?.size === right?.size); if (!statEqual(a.sessionPath, b.sessionPath)) return false; if (!statEqual(a.activeDir, b.activeDir)) return false; + if (!statEqual(a.migrationMarker, b.migrationMarker)) return false; if (!statEqual(a.ralplanModeState, b.ralplanModeState)) return false; if (a.activeEntries.length !== b.activeEntries.length) return false; return a.activeEntries.every((entry, index) => statEqual(entry, b.activeEntries[index] ?? null)); @@ -779,6 +866,7 @@ export async function computeActiveStateSignature(cwd: string, sessionId: string sessionPath: await statSignature(sessionPath), activeDir, activeEntries, + migrationMarker: await statSignature(activeEntryMigrationMarkerPath(resolvedCwd, sessionId)), ralplanModeState: await statSignature(modeStatePath(resolvedCwd, sessionId, "ralplan")), }; } @@ -819,12 +907,13 @@ async function buildVisibleSkillActiveState( const activeSkills = await mergeVisibleEntries(cwd, sessionState, resolvedSessionId, perSkillEntries); if (activeSkills.length === 0) return null; const primary = activeSkills[0]; + const authoritativeEntries = await hasAuthoritativeActiveEntryDirectory(cwd, resolvedSessionId); return { ...(sessionState ?? {}), version: 1, active: true, - skill: sessionState?.skill ?? primary?.skill ?? "", - phase: sessionState?.phase ?? primary?.phase ?? "", + skill: authoritativeEntries ? (primary?.skill ?? "") : (sessionState?.skill ?? primary?.skill ?? ""), + phase: authoritativeEntries ? (primary?.phase ?? "") : (sessionState?.phase ?? primary?.phase ?? ""), session_id: resolvedSessionId, active_skills: activeSkills, active_subskills: activeSkills.flatMap(entry => entry.active_subskills ?? []), @@ -993,18 +1082,21 @@ async function persistActiveEntry( cwd: string, sessionScope: ActiveSessionScope | undefined, entry: SkillActiveEntry, + activeStateScopeLockHeld = false, ): Promise { if (entry.active === false) { await removeActiveEntry(cwd, sessionScope, entry.skill, { cwd, audit: activeStateWriterAudit("remove-active-entry", sessionScope), sourceRevision: entry.source_state_revision, + activeStateScopeLockHeld, }); return undefined; } return await writeActiveEntry(cwd, sessionScope, entry.skill, entry, { cwd, audit: activeStateWriterAudit("write-active-entry", sessionScope), + activeStateScopeLockHeld, }); } @@ -1016,6 +1108,7 @@ async function writeHandoffEntry( await writeActiveEntry(cwd, sessionScope, entry.skill, entry, { cwd, audit: activeStateWriterAudit("write-active-entry", sessionScope), + activeStateScopeLockHeld: true, }); } @@ -1030,12 +1123,14 @@ async function removeSupersededPlanningPipelineEntries( cwd: string, sessionScope: ActiveSessionScope | undefined, entry: SkillActiveEntry, + activeStateScopeLockHeld = false, ): Promise { if (entry.active === false) return; for (const skill of upstreamPlanningPipelineSkills(entry.skill)) { await removeActiveEntry(cwd, sessionScope, skill, { cwd, audit: activeStateWriterAudit("remove-superseded-pipeline-entry", sessionScope), + activeStateScopeLockHeld, }); } } @@ -1044,13 +1139,14 @@ async function activeSubskillsForExistingEntry( cwd: string, sessionId: string | undefined, skill: string, + activeStateScopeLockHeld = false, ): Promise { const resolvedSessionId = await resolveBoundarySessionId(cwd, sessionId); const { sessionPath } = getSkillActiveStatePaths(cwd, resolvedSessionId); const sessionState = await readRawActiveStateForHandoff(sessionPath, false); - const existing = (await mergeVisibleEntries(cwd, sessionState, resolvedSessionId)).find( - entry => entry.skill === skill, - ); + const existing = ( + await mergeVisibleEntries(cwd, sessionState, resolvedSessionId, undefined, activeStateScopeLockHeld) + ).find(entry => entry.skill === skill); return existing?.active_subskills; } @@ -1058,13 +1154,9 @@ export async function syncSkillActiveState( options: SyncSkillActiveStateOptions, ): Promise { if (!options.sessionId) return undefined; - const preservedActiveSubskills = - options.active_subskills === undefined - ? await activeSubskillsForExistingEntry(options.cwd, options.sessionId, options.skill) - : undefined; const nowIso = options.nowIso ?? new Date().toISOString(); const hud = normalizeWorkflowHudSummary(options.hud); - const entry: SkillActiveEntry = { + const entryBase: SkillActiveEntry = { skill: options.skill, phase: options.phase, active: options.active, @@ -1078,22 +1170,32 @@ export async function syncSkillActiveState( ...(options.handoff_at ? { handoff_at: options.handoff_at } : {}), ...(hud ? { hud } : {}), ...(options.receipt ? { receipt: options.receipt } : {}), - ...(options.active_subskills !== undefined - ? { active_subskills: options.active_subskills } - : preservedActiveSubskills - ? { active_subskills: preservedActiveSubskills } - : {}), ...(typeof options.sourceRevision === "number" ? { source_state_revision: options.sourceRevision } : {}), }; const sessionScope = { sessionId: options.sessionId }; - await removeSupersededPlanningPipelineEntries(options.cwd, sessionScope, entry); - const entryWrite = await persistActiveEntry(options.cwd, sessionScope, entry); - try { - await rebuildActiveState(options.cwd, sessionScope); - } catch (error) { - if (!options.bestEffortSnapshot) throw error; - } - return entryWrite; + return withActiveStateScopeLock(options.cwd, sessionScope, async () => { + await migrateSnapshotEntriesToActiveDirectory(options.cwd, sessionScope.sessionId); + const preservedActiveSubskills = + options.active_subskills === undefined + ? await activeSubskillsForExistingEntry(options.cwd, options.sessionId, options.skill, true) + : undefined; + const entry: SkillActiveEntry = { + ...entryBase, + ...(options.active_subskills !== undefined + ? { active_subskills: options.active_subskills } + : preservedActiveSubskills + ? { active_subskills: preservedActiveSubskills } + : {}), + }; + await removeSupersededPlanningPipelineEntries(options.cwd, sessionScope, entry, true); + const entryWrite = await persistActiveEntry(options.cwd, sessionScope, entry, true); + try { + await rebuildActiveState(options.cwd, sessionScope); + } catch (error) { + if (!options.bestEffortSnapshot) throw error; + } + return entryWrite; + }); } export interface ApplyHandoffOptions { @@ -1146,11 +1248,20 @@ export async function applyHandoffToActiveState(options: ApplyHandoffOptions): P return [...kept, mergedCaller, mergedCallee]; }; const writeEntries = async (sessionScope: ActiveSessionScope, prior: SkillActiveState | null): Promise => { - const nextEntries = applyEntries(rawActiveEntries(prior)); - for (const entry of nextEntries) { - await writeHandoffEntry(options.cwd, sessionScope, entry); - } - await rebuildActiveState(options.cwd, sessionScope); + await withActiveStateScopeLock(options.cwd, sessionScope, async () => { + if (await hasPendingActiveEntryMigration(options.cwd, sessionId)) { + await migrateSnapshotEntriesToActiveDirectory(options.cwd, sessionId); + } + const authoritativeEntries = await hasAuthoritativeActiveEntryDirectory(options.cwd, sessionId); + const priorEntries = authoritativeEntries + ? await readActiveEntries(options.cwd, sessionScope) + : rawActiveEntries(prior); + const nextEntries = applyEntries(priorEntries); + for (const entry of nextEntries) { + await writeHandoffEntry(options.cwd, sessionScope, entry); + } + await rebuildActiveState(options.cwd, sessionScope); + }); }; const prior = await readState(sessionPath); diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 8ac22b7959c..7ec741abfe9 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -134,7 +134,9 @@ const providerStreamingUpdateTypes = new Set([ "toolcall_end", ]); -const isAgentEvent = (event: AgentSessionEvent): event is AgentEvent => +type AgentSessionProgressEvent = Extract; + +const isAgentEvent = (event: AgentSessionEvent): event is AgentSessionProgressEvent => agentEventTypes.has(event.type as AgentEvent["type"]); function normalizeModelPatterns(value: string | string[] | undefined): string[] { diff --git a/packages/coding-agent/src/tools/python.ts b/packages/coding-agent/src/tools/python.ts index 669cd187a13..28a2693c4e3 100644 --- a/packages/coding-agent/src/tools/python.ts +++ b/packages/coding-agent/src/tools/python.ts @@ -31,7 +31,7 @@ export interface SessionPythonToolInput { /** Resolve the GJC session id used for the kernel owner and transcript paths. */ getSessionId: () => string | null; /** Register cleanup with the current logical session lifecycle. */ - registerSessionCleanup: (cleanup: () => Promise | void) => (() => void) | void; + registerSessionCleanup: (cleanup: () => Promise | void) => () => void; /** Reject execution after the owning session has begun disposal. */ assertEvalExecutionAllowed?: () => void; /** Track this whole invocation through its transcript append. */ diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts index 7de8d2bb9ff..a2990751a93 100644 --- a/packages/coding-agent/test/agent-session-branching.test.ts +++ b/packages/coding-agent/test/agent-session-branching.test.ts @@ -303,6 +303,24 @@ describe("AgentSession tree navigation local identity", () => { timestamp: Date.now() - 1, }); created.sessionManager.appendMessage({ role: "user", content: "tree leaf", timestamp: Date.now() }); + session.setCheckpointState({ + checkpointEntryId: rootEntryId, + checkpointMessageCount: 1, + startedAt: new Date().toISOString(), + }); + session.queueDeferredMessageForTests( + { + role: "custom", + customType: "test-hidden-next-turn", + content: "stale predecessor context", + display: false, + details: {}, + attribution: "agent", + timestamp: Date.now(), + }, + false, + ); + expect(session.queuedMessageCount).toBe(1); const localOptions = { getArtifactsDir: () => created.sessionManager.getArtifactsDir(), getSessionId: () => created.sessionManager.getSessionId(), @@ -327,6 +345,13 @@ describe("AgentSession tree navigation local identity", () => { expect(path.dirname(markerPath)).toBe(before.root); expect(fs.readFileSync(markerPath, "utf8")).toBe(before.marker); expect(fs.existsSync(before.root)).toBe(true); + expect(session.queuedMessageCount).toBe(0); + expect(session.getCheckpointState()).toBeUndefined(); + expect( + session.messages.some( + message => message.role === "custom" && message.content === "stale predecessor context", + ), + ).toBe(false); } finally { await session?.dispose(); authStorage?.close(); diff --git a/packages/coding-agent/test/agent-session-concurrent.test.ts b/packages/coding-agent/test/agent-session-concurrent.test.ts index 0eead1e7806..7686092d967 100644 --- a/packages/coding-agent/test/agent-session-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -19,14 +19,20 @@ import { TtsrManager } from "@gajae-code/coding-agent/export/ttsr"; import type { ExtensionRunner } from "@gajae-code/coding-agent/extensibility/extensions/runner"; import { submitInteractiveInput } from "@gajae-code/coding-agent/main"; import type { SubmittedUserInput } from "@gajae-code/coding-agent/modes/types"; -import type { QueuedInputSubmission } from "@gajae-code/coding-agent/sdk"; -import { AgentSession } from "@gajae-code/coding-agent/session/agent-session"; +import { AgentSession, type AgentSessionEvent } from "@gajae-code/coding-agent/session/agent-session"; import { AuthStorage } from "@gajae-code/coding-agent/session/auth-storage"; -import { convertToLlm } from "@gajae-code/coding-agent/session/messages"; +import { type CustomMessage, convertToLlm, SILENT_ABORT_MARKER } from "@gajae-code/coding-agent/session/messages"; import { SessionManager } from "@gajae-code/coding-agent/session/session-manager"; +import { + type OwnedCompletionEnvelope, + registerOwnedRegistration, + registerTerminalTurnScope, + type TurnRegistrationKey, + unregisterOwnedRegistration, + unregisterTerminalScope, +} from "@gajae-code/coding-agent/session/terminal-abort"; import { Snowflake } from "@gajae-code/utils"; import * as z from "zod/v4"; -import { createSdkRunCapability } from "../src/sdk/host/sdk-run-capability"; import { createAssistantMessage } from "./helpers/agent-session-setup"; // Mock stream that mimics AssistantMessageEventStream @@ -973,7 +979,7 @@ describe("AgentSession concurrent prompt guard", () => { expect(session.queuedMessageCount).toBe(1); }); - it("keeps session_switch hook-queued steering deliverable after clearing pre-switch queues", async () => { + it("rejects untracked session_switch steering and clears pre-switch queues", async () => { const model = getBundledModel("anthropic", "claude-sonnet-4-5")!; const agent = new Agent({ getApiKey: () => "test-key", @@ -993,15 +999,16 @@ describe("AgentSession concurrent prompt guard", () => { authStorages.push(authStorage); authStorage.setRuntimeApiKey("anthropic", "test-key"); const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models-switch-hook.yml")); - let switchSubmission: QueuedInputSubmission | undefined; + const switchHookErrors: unknown[] = []; const extensionRunner = { hasHandlers: vi.fn(() => false), emit: vi.fn(async (event: { type: string }) => { if (event.type === "session_switch") { - switchSubmission = await session.submitUserMessage("queued by switch hook", { - deliverAs: "steer", - trackSubmission: true, - }); + try { + await session.sendUserMessage("queued by switch hook", { deliverAs: "steer" }); + } catch (error) { + switchHookErrors.push(error); + } } }), } as unknown as ExtensionRunner; @@ -1026,59 +1033,9 @@ describe("AgentSession concurrent prompt guard", () => { expect(await session.switchSession(targetSessionFile)).toBe(true); expect(appendOnly?.log.length).toBe(0); - expect(session.getQueuedMessages().followUp).toEqual(["queued by switch hook"]); - expect(agent.snapshotFollowUp()).toHaveLength(1); - expect(switchSubmission).toBeDefined(); - const submission = switchSubmission!; - expect(await Promise.race([submission.terminal.then(() => "settled"), Bun.sleep(20).then(() => "pending")])).toBe( - "pending", - ); - session.clearQueue(); - await expect(submission.terminal).resolves.toMatchObject({ disposition: "removed", reason: "removed" }); - }); - - it("admits tracked work from a committed new-session hook", async () => { - const model = getBundledModel("anthropic", "claude-sonnet-4-5")!; - const currentSessionManager = SessionManager.create(tempDir, tempDir); - const settings = Settings.isolated(); - const authStorage = await AuthStorage.create(path.join(tempDir, "testauth-new-hook.db")); - authStorages.push(authStorage); - authStorage.setRuntimeApiKey("anthropic", "test-key"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models-new-hook.yml")); - let newSessionSubmission: QueuedInputSubmission | undefined; - const extensionRunner = { - hasHandlers: vi.fn(() => false), - emit: vi.fn(async (event: { type: string; reason?: string }) => { - if (event.type === "session_switch" && event.reason === "new") { - newSessionSubmission = await session.submitUserMessage("queued by new hook", { - deliverAs: "followUp", - trackSubmission: true, - }); - } - }), - } as unknown as ExtensionRunner; - - session = new AgentSession({ - agent: new Agent({ - getApiKey: () => "test-key", - initialState: { model, systemPrompt: ["Test"], tools: [] }, - appendOnlyContext: createAppendOnlyContextManager(model.provider), - }), - sessionManager: currentSessionManager, - settings, - modelRegistry, - extensionRunner, - }); - - expect(await session.newSession()).toBe(true); - expect(newSessionSubmission).toBeDefined(); - expect(session.getQueuedMessages().followUp).toEqual(["queued by new hook"]); - const submission = newSessionSubmission!; - expect(await Promise.race([submission.terminal.then(() => "settled"), Bun.sleep(20).then(() => "pending")])).toBe( - "pending", - ); - session.clearQueue(); - await expect(submission.terminal).resolves.toMatchObject({ disposition: "removed", reason: "removed" }); + expect(switchHookErrors).toEqual([expect.objectContaining({ code: "busy" })]); + expect(session.getQueuedMessages().followUp).toEqual([]); + expect(agent.snapshotFollowUp()).toHaveLength(0); }); // Regression: a subscriber that fires the next prompt synchronously from the @@ -1502,7 +1459,8 @@ describe("AgentSession TTSR resume gate", () => { modelRegistry, ttsrManager, }); - + const terminalEvents: AgentSessionEvent[] = []; + session.subscribe(event => terminalEvents.push(event)); // prompt() must block until the TTSR continuation completes await session.prompt("Write some Rust code"); @@ -1510,6 +1468,58 @@ describe("AgentSession TTSR resume gate", () => { expect(continuationCompleted).toBe(true); expect(streamCallCount).toBeGreaterThanOrEqual(2); expect(session.isStreaming).toBe(false); + expect( + terminalEvents.some( + event => + event.type === "message_end" && + event.message.role === "assistant" && + event.ttsrAbort === true && + event.message.errorMessage === SILENT_ABORT_MARKER, + ), + ).toBe(true); + }); + + it("releases an interrupted TTSR continuation when repeat-state persistence fails", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5")!; + let streamCallCount = 0; + const ttsrManager = new TtsrManager({ + enabled: true, + contextMode: "discard", + interruptMode: "always", + repeatMode: "once", + repeatGap: 10, + }); + ttsrManager.addRule(testRule); + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { model, systemPrompt: ["Test"], tools: [] }, + streamFn: (_model, _context, options) => { + streamCallCount++; + const stream = new AssistantMessageEventStream(); + if (streamCallCount === 1) pushAbortableTtsrStream(stream, options?.signal); + else pushContinuationStream(stream, () => {}); + return stream; + }, + }); + const sessionManager = SessionManager.inMemory(tempDir); + const appendTtsrInjection = sessionManager.appendTtsrInjection.bind(sessionManager); + vi.spyOn(sessionManager, "appendTtsrInjection").mockImplementation((ruleNames, records, messageCount) => { + if (ruleNames.length > 0) throw new Error("injected interrupt persistence failure"); + return appendTtsrInjection(ruleNames, records, messageCount); + }); + const settings = Settings.isolated(); + const authStorage = await AuthStorage.create(path.join(tempDir, "testauth-int-failure.db")); + authStorages.push(authStorage); + const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + session = new AgentSession({ agent, sessionManager, settings, modelRegistry, ttsrManager }); + + await session.prompt("Write some Rust code"); + + expect(streamCallCount).toBe(1); + expect(session.isStreaming).toBe(false); + expect(session.isTtsrAbortPending).toBe(false); + expect(ttsrManager.getInjectedRuleNames()).toEqual([]); }); it("prompt() blocks until TTSR deferred continuation completes", async () => { @@ -1577,7 +1587,6 @@ describe("AgentSession TTSR resume gate", () => { modelRegistry, ttsrManager, }); - // prompt() must block until the deferred TTSR continuation completes await session.prompt("Write some Rust code"); @@ -1648,7 +1657,6 @@ describe("AgentSession TTSR resume gate", () => { modelRegistry, ttsrManager, }); - // Start prompt (will trigger TTSR and create resume gate) const promptPromise = session.prompt("Write some Rust code"); await waitFor(() => session.agent.state.isStreaming); @@ -1658,94 +1666,7 @@ describe("AgentSession TTSR resume gate", () => { await promptPromise; expect(session.isStreaming).toBe(false); - }); - - it("purges deferred TTSR follow-ups during SDK terminal abort", async () => { - const model = getBundledModel("anthropic", "claude-sonnet-4-5")!; - let streamCallCount = 0; - let staleTtsrStarted = false; - const ttsrManager = new TtsrManager({ - enabled: true, - contextMode: "discard", - interruptMode: "never", - repeatMode: "once", - repeatGap: 10, - }); - ttsrManager.addRule(testRule); - - const agent = new Agent({ - getApiKey: () => "test-key", - initialState: { model, systemPrompt: ["Test"], tools: [] }, - streamFn: (_model, context, options) => { - streamCallCount += 1; - const stream = new AssistantMessageEventStream(); - if (streamCallCount === 1) { - queueMicrotask(() => { - stream.push({ type: "start", partial: makeMsg("") }); - stream.push({ - type: "text_delta", - contentIndex: 0, - delta: "result.unwrap(", - partial: makeMsg("result.unwrap("), - }); - stream.push({ - type: "done", - reason: "stop", - message: makeMsg("result.unwrap()"), - }); - }); - } else if (streamCallCount === 2) { - queueMicrotask(() => { - stream.push({ type: "start", partial: makeMsg("") }); - options?.signal?.addEventListener( - "abort", - () => { - stream.push({ - type: "error", - reason: "aborted", - error: makeMsg("aborted", "aborted"), - }); - }, - { once: true }, - ); - }); - } else { - staleTtsrStarted = context.messages.some(message => JSON.stringify(message).includes(testRule.content)); - queueMicrotask(() => { - stream.push({ type: "start", partial: makeMsg("") }); - stream.push({ type: "done", reason: "stop", message: makeMsg("stale TTSR") }); - }); - } - return stream; - }, - }); - const sessionManager = SessionManager.inMemory(tempDir); - const settings = Settings.isolated(); - const authStorage = await AuthStorage.create(path.join(tempDir, "testauth-terminal-ttsr.db")); - authStorages.push(authStorage); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); - authStorage.setRuntimeApiKey("anthropic", "test-key"); - session = new AgentSession({ agent, sessionManager, settings, modelRegistry, ttsrManager }); - - const promptPromise = session.prompt("first turn").catch(() => {}); - await waitFor(() => streamCallCount >= 1, 5_000); - await session.submitUserMessage("older SDK follow-up", { - deliverAs: "followUp", - trackSubmission: true, - sdkRunCapability: createSdkRunCapability("terminal-ttsr-deferred"), - } as never); - await waitFor(() => streamCallCount >= 2, 5_000); - await Bun.sleep(10); - const handle = session.agent.activeResourceRunId; - const abortPromise = session.abortPromptAndWait(handle ?? "run", { - graceMs: 1_000, - terminal: { scope: "turn" }, - }); - if (!handle) session.agent.abort(); - await abortPromise; - await promptPromise; - await Bun.sleep(100); - expect(staleTtsrStarted).toBe(false); + expect(session.isTtsrAbortPending).toBe(false); }); it("prompt() waits for TTSR continuation with tool calls to finish", async () => { @@ -1981,6 +1902,350 @@ describe("AgentSession TTSR resume gate", () => { expect(text.indexOf(" { + const model = getBundledModel("anthropic", "claude-sonnet-4-5")!; + let streamCallCount = 0; + const ttsrManager = new TtsrManager({ + enabled: true, + contextMode: "discard", + interruptMode: "never", + repeatMode: "once", + repeatGap: 10, + }); + ttsrManager.addRule(testRule); + const mockTool: AgentTool = { + name: "mock_edit", + label: "Mock Edit", + description: "A mock edit tool", + parameters: z.object({ snippet: z.string() }), + execute: async () => ({ content: [{ type: "text" as const, text: "edit applied" }] }), + }; + const toolCall = (snippet: unknown): ToolCall => ({ + type: "toolCall", + id: "call_reused_after_validation", + name: "mock_edit", + arguments: { snippet }, + }); + const toolMessage = (call: ToolCall): AssistantMessage => ({ + role: "assistant", + content: [call], + api: "anthropic-messages", + provider: "anthropic", + model: "mock", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: Date.now(), + }); + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { model, systemPrompt: ["Test"], tools: [mockTool] }, + streamFn: () => { + streamCallCount++; + const stream = new AssistantMessageEventStream(); + queueMicrotask(() => { + if (streamCallCount === 1 || streamCallCount === 3) { + const call = toolCall(streamCallCount === 1 ? 7 : "value.unwrap()"); + const partial = toolMessage(call); + stream.push({ type: "start", partial }); + stream.push({ type: "toolcall_start", contentIndex: 0, partial }); + stream.push({ type: "toolcall_delta", contentIndex: 0, delta: "value.unwrap()", partial }); + stream.push({ type: "toolcall_end", contentIndex: 0, toolCall: call, partial }); + stream.push({ type: "done", reason: "toolUse", message: partial }); + } else { + const done = makeMsg("done"); + stream.push({ type: "start", partial: done }); + stream.push({ type: "done", reason: "stop", message: done }); + } + }); + return stream; + }, + }); + const sessionManager = SessionManager.inMemory(tempDir); + const settings = Settings.isolated(); + const authStorage = await AuthStorage.create(path.join(tempDir, "testauth-validation-ttsr.db")); + authStorages.push(authStorage); + const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + session = new AgentSession({ agent, sessionManager, settings, modelRegistry, ttsrManager }); + + await session.prompt("invalid call"); + await session.prompt("valid retry"); + + const results = agent.state.messages.filter( + (message): message is Extract => + message.role === "toolResult" && message.toolCallId === "call_reused_after_validation", + ); + const finalResult = results.at(-1); + const resultText = Array.isArray(finalResult?.content) + ? finalResult.content + .filter((content): content is { type: "text"; text: string } => content.type === "text") + .map(content => content.text) + .join("\n") + : ""; + expect(results).toHaveLength(2); + expect(resultText).toContain('rule="no-unwrap"'); + expect(resultText).toContain("edit applied"); + }); + + it("restores the repeat gate when per-tool TTSR persistence fails", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5")!; + let streamCallCount = 0; + const ttsrManager = new TtsrManager({ + enabled: true, + contextMode: "discard", + interruptMode: "never", + repeatMode: "once", + repeatGap: 10, + }); + ttsrManager.addRule(testRule); + const toolCall: ToolCall = { + type: "toolCall", + id: "call_failed_ttsr_persistence", + name: "mock_edit", + arguments: { snippet: "value.unwrap()" }, + }; + const mockTool: AgentTool = { + name: "mock_edit", + label: "Mock Edit", + description: "A mock edit tool", + parameters: z.object({ snippet: z.string().optional() }), + execute: async () => ({ content: [{ type: "text" as const, text: "edit applied" }] }), + }; + const toolMessage = (): AssistantMessage => ({ + role: "assistant", + content: [toolCall], + api: "anthropic-messages", + provider: "anthropic", + model: "mock", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: Date.now(), + }); + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { model, systemPrompt: ["Test"], tools: [mockTool] }, + streamFn: () => { + streamCallCount++; + const stream = new AssistantMessageEventStream(); + queueMicrotask(() => { + if (streamCallCount === 1) { + const partial = toolMessage(); + stream.push({ type: "start", partial }); + stream.push({ type: "toolcall_start", contentIndex: 0, partial }); + stream.push({ + type: "toolcall_delta", + contentIndex: 0, + delta: "value.unwrap()", + partial, + }); + stream.push({ type: "toolcall_end", contentIndex: 0, toolCall, partial }); + stream.push({ type: "done", reason: "toolUse", message: partial }); + } else { + const done = makeMsg("done"); + stream.push({ type: "start", partial: done }); + stream.push({ type: "done", reason: "stop", message: done }); + } + }); + return stream; + }, + }); + const sessionManager = SessionManager.inMemory(tempDir); + const append = vi.spyOn(sessionManager, "appendTtsrInjection").mockImplementationOnce(() => { + throw new Error("injected TTSR persistence failure"); + }); + const settings = Settings.isolated(); + const authStorage = await AuthStorage.create(path.join(tempDir, "testauth-failed-ttsr.db")); + authStorages.push(authStorage); + const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + session = new AgentSession({ agent, sessionManager, settings, modelRegistry, ttsrManager }); + + await session.prompt("Write some Rust code"); + + expect(append).toHaveBeenCalled(); + expect(ttsrManager.getInjectedRuleNames()).toEqual([]); + }); + + it("reconciles a failed turn-end repeat checkpoint before the next provider call", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5")!; + const order: string[] = []; + let streamCallCount = 0; + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { model, systemPrompt: ["Test"] }, + streamFn: () => { + order.push(`provider-${++streamCallCount}`); + const stream = new AssistantMessageEventStream(); + queueMicrotask(() => { + const done = makeMsg("done"); + stream.push({ type: "start", partial: done }); + stream.push({ type: "done", reason: "stop", message: done }); + }); + return stream; + }, + }); + const sessionManager = SessionManager.inMemory(tempDir); + const appendTtsrInjection = sessionManager.appendTtsrInjection.bind(sessionManager); + let rejectNextTurnCheckpoint = true; + vi.spyOn(sessionManager, "appendTtsrInjection").mockImplementation((ruleNames, records, messageCount) => { + if (ruleNames.length === 0 && rejectNextTurnCheckpoint) { + rejectNextTurnCheckpoint = false; + order.push("persist-failed"); + throw new Error("injected turn-end persistence failure"); + } + order.push("persisted"); + return appendTtsrInjection(ruleNames, records, messageCount); + }); + const settings = Settings.isolated(); + const authStorage = await AuthStorage.create(path.join(tempDir, "testauth-turn-end-failure.db")); + authStorages.push(authStorage); + const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + const ttsrManager = new TtsrManager({ enabled: true }); + session = new AgentSession({ agent, sessionManager, settings, modelRegistry, ttsrManager }); + + await expect(session.prompt("first turn")).rejects.toThrow("injected turn-end persistence failure"); + expect(ttsrManager.getMessageCount()).toBe(0); + expect(() => session.newSession()).toThrow("Reconcile repeat-state persistence before changing session history."); + expect(order).toEqual(["provider-1", "persist-failed"]); + + await session.prompt("second turn"); + + expect(order.slice(0, 4)).toEqual(["provider-1", "persist-failed", "persisted", "provider-2"]); + expect(ttsrManager.getMessageCount()).toBe(2); + }); + + it("does not promote an owned idle completion after a transition starts during reconciliation", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5")!; + let streamCallCount = 0; + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { model, systemPrompt: ["Test"] }, + streamFn: () => { + streamCallCount++; + const stream = new AssistantMessageEventStream(); + queueMicrotask(() => { + const done = makeMsg("done"); + stream.push({ type: "start", partial: done }); + stream.push({ type: "done", reason: "stop", message: done }); + }); + return stream; + }, + }); + const sessionManager = SessionManager.inMemory(tempDir); + const appendTtsrInjection = sessionManager.appendTtsrInjection.bind(sessionManager); + let rejectInitialCheckpoint = true; + let startTransitionDuringReconciliation = false; + const transitionStarted = Promise.withResolvers(); + const releaseTransitionAbort = Promise.withResolvers(); + let transition: Promise | undefined; + let transitionStartError: unknown; + vi.spyOn(sessionManager, "appendTtsrInjection").mockImplementation((ruleNames, records, messageCount) => { + if (ruleNames.length === 0 && rejectInitialCheckpoint) { + rejectInitialCheckpoint = false; + throw new Error("injected turn-end persistence failure"); + } + if (ruleNames.length === 0 && startTransitionDuringReconciliation) { + startTransitionDuringReconciliation = false; + queueMicrotask(() => { + try { + transition = session.newSession(); + } catch (error) { + transitionStartError = error; + transitionStarted.resolve(); + } + }); + } + return appendTtsrInjection(ruleNames, records, messageCount); + }); + const settings = Settings.isolated(); + const authStorage = await AuthStorage.create(path.join(tempDir, "testauth-idle-reconcile-race.db")); + authStorages.push(authStorage); + const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + const ttsrManager = new TtsrManager({ enabled: true }); + session = new AgentSession({ agent, sessionManager, settings, modelRegistry, ttsrManager }); + await expect(session.prompt("first turn")).rejects.toThrow("injected turn-end persistence failure"); + const originalEpoch = session.getTerminalTurnEpoch(); + if (originalEpoch === undefined) throw new Error("Expected a prompt lineage before idle delivery"); + + const registration: TurnRegistrationKey = { + endpointId: `idle-reconcile-${Snowflake.next()}`, + endpointGeneration: 0, + lineageIdHash: `idle-reconcile-lineage-${Snowflake.next()}`, + promptAttemptEpoch: 17, + jobId: `idle-reconcile-job-${Snowflake.next()}`, + jobGeneration: "job:1", + }; + const terminalScope = registerTerminalTurnScope({ + lineageIdHash: registration.lineageIdHash, + promptAttemptEpoch: registration.promptAttemptEpoch, + ownedCompletionPolicy: "enabled", + }); + if (!terminalScope) throw new Error("Expected terminal scope registration to succeed"); + registerOwnedRegistration(registration, { isJobTerminal: () => true }); + const ownedEnvelope: OwnedCompletionEnvelope = { + lineageIdHash: registration.lineageIdHash, + promptAttemptEpoch: registration.promptAttemptEpoch, + registration, + }; + const ownedMessage: CustomMessage<{ ownedCompletions: OwnedCompletionEnvelope[] }> = { + role: "custom", + customType: "async-result", + content: "owned completion", + display: true, + attribution: "agent", + details: { ownedCompletions: [ownedEnvelope] }, + timestamp: Date.now(), + }; + const unregisterDispatcher = session.yieldQueue.register("idle-reconcile-race", { + build: () => ownedMessage, + }); + const abortSpy = vi.spyOn(session, "abort").mockImplementation(async () => { + transitionStarted.resolve(); + await releaseTransitionAbort.promise; + }); + + try { + startTransitionDuringReconciliation = true; + session.yieldQueue.enqueue("idle-reconcile-race", "completion"); + await session.yieldQueue.flush("idle"); + await transitionStarted.promise; + if (transitionStartError !== undefined) throw transitionStartError; + if (!transition) throw new Error("Expected the new-session transition to start"); + + expect(session.getTerminalTurnEpoch()).toBe(originalEpoch); + expect(session.yieldQueue.has("idle-reconcile-race")).toBe(true); + expect(streamCallCount).toBe(1); + + releaseTransitionAbort.resolve(); + await transition; + await session.yieldQueue.flush("idle"); + expect(session.yieldQueue.has("idle-reconcile-race")).toBe(false); + } finally { + releaseTransitionAbort.resolve(); + await transition?.catch(() => {}); + abortSpy.mockRestore(); + unregisterDispatcher(); + unregisterOwnedRegistration(registration); + unregisterTerminalScope(terminalScope.scopeId); + } + }); + it("interruptMode never deduplicates the reminder across sibling tool calls in one batch", async () => { const model = getBundledModel("anthropic", "claude-sonnet-4-5")!; let streamCallCount = 0; diff --git a/packages/coding-agent/test/agent-session-fallback-upstream-count.e2e.test.ts b/packages/coding-agent/test/agent-session-fallback-upstream-count.e2e.test.ts index 7cb8b79a06b..be2956e5e5c 100644 --- a/packages/coding-agent/test/agent-session-fallback-upstream-count.e2e.test.ts +++ b/packages/coding-agent/test/agent-session-fallback-upstream-count.e2e.test.ts @@ -181,7 +181,7 @@ describe("AgentSession fallback upstream request counts", () => { maxAttempts: number, streamFn: AgentOptions["streamFn"], modelsOrSettings: { primary: Model; fallback: Model } | Record = {}, - ): { primary: Model; fallback: Model } { + ): { primary: Model; fallback: Model; sessionManager: SessionManager } { const models = "primary" in modelsOrSettings && "fallback" in modelsOrSettings ? (modelsOrSettings as { primary: Model; fallback: Model }) @@ -202,9 +202,10 @@ describe("AgentSession fallback upstream request counts", () => { ...settingsOverrides, }); settings.setModelRole("default", selector(primary)); - session = new AgentSession({ agent, sessionManager: SessionManager.inMemory(), settings, modelRegistry }); + const sessionManager = SessionManager.inMemory(); + session = new AgentSession({ agent, sessionManager, settings, modelRegistry }); session!.setConfiguredModelChain("default", [selector(primary), selector(fallback)], "test"); - return { primary, fallback }; + return { primary, fallback, sessionManager }; } it("does not replay exported Alibaba lazy-stream timeouts for direct or managed-fallback requests", async () => { @@ -474,7 +475,7 @@ describe("AgentSession fallback upstream request counts", () => { const fallbackSwitches: Array> = []; const events: AgentSessionEvent[] = []; let primaryCalls = 0; - const { primary, fallback } = createSession(1, (model, context, options) => { + const { primary, fallback, sessionManager } = createSession(1, (model, context, options) => { calls.push({ selector: selector(model), fallbackManaged: options?.fallbackManaged, @@ -526,6 +527,9 @@ describe("AgentSession fallback upstream request counts", () => { ]); expect(session!.messages.filter(message => message.role === "user")).toHaveLength(1); expect(session!.messages.filter(message => message.role === "assistant")).toHaveLength(1); + expect( + sessionManager.getBranch().filter(entry => entry.type === "message" && entry.message.role === "assistant"), + ).toHaveLength(1); }); it("advances typed 429 with hostile overflow prose without running maintenance", async () => { diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index 3db28cfc719..561b321a76e 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -526,49 +526,32 @@ describe("AgentSession handoff", () => { expect(JSON.stringify(promptSpy.mock.calls)).toContain("STALLED:"); }); - it("uses handoff strategy for threshold-triggered auto maintenance", async () => { + it("uses handoff strategy for idle auto maintenance", async () => { session.settings.set("compaction.strategy", "handoff"); - session.settings.set("compaction.thresholdPercent", 1); session.settings.set("contextPromotion.enabled", false); - const model = session.model; - if (!model) { - throw new Error("Expected model to be set"); - } - - const assistantMessage: AssistantMessage = { - role: "assistant", - content: [{ type: "text", text: "maintenance trigger" }], - api: model.api, - provider: model.provider, - model: model.id, - stopReason: "stop", - usage: { - input: 10_000, - output: 1_000, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 11_000, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - timestamp: Date.now(), - }; - const handoffSpy = vi.spyOn(session, "handoff").mockResolvedValue({ document: "handoff document" }); - session.agent.emitExternalEvent({ type: "message_end", message: assistantMessage }); - session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMessage] }); - await Bun.sleep(20); + await session.runIdleCompaction(); expect(handoffSpy).toHaveBeenCalledTimes(1); - expect(handoffSpy).toHaveBeenCalledWith(expect.stringContaining("Threshold-triggered maintenance"), { + expect(handoffSpy).toHaveBeenCalledWith(expect.any(String), { autoTriggered: true, signal: expect.anything(), }); expect(events.filter(event => event.type === "auto_compaction_start")).toHaveLength(1); + expect(events.find(event => event.type === "auto_compaction_start")).toMatchObject({ + reason: "idle", + action: "handoff", + }); const endEvents = events.filter(event => event.type === "auto_compaction_end"); expect(endEvents).toHaveLength(1); - expect(endEvents[0]).toMatchObject({ type: "auto_compaction_end", aborted: false, willRetry: false }); + expect(endEvents[0]).toMatchObject({ + type: "auto_compaction_end", + action: "handoff", + aborted: false, + willRetry: false, + }); }); it("completes threshold-triggered auto-handoff while the original prompt is still unwinding", async () => { @@ -648,12 +631,18 @@ describe("AgentSession handoff", () => { events.push(event); }); + const handoffSpy = vi.spyOn(session, "handoff"); const generateHandoffSpy = vi .spyOn(compactionModule, "generateHandoff") .mockResolvedValue("## Goal\nContinue from here"); await session.prompt("Trigger threshold handoff"); expect(mock.calls).toHaveLength(1); + expect(handoffSpy).toHaveBeenCalledTimes(1); + expect(handoffSpy).toHaveBeenCalledWith(expect.stringContaining("Threshold-triggered maintenance"), { + autoTriggered: true, + signal: expect.anything(), + }); expect(generateHandoffSpy).toHaveBeenCalledTimes(1); const endEvents = events.filter(event => event.type === "auto_compaction_end"); expect(endEvents).toHaveLength(1); @@ -1092,11 +1081,9 @@ describe("AgentSession handoff", () => { await Bun.sleep(5); // The bypass turn-start paths (steer/follow-up/sendUserMessage) are fenced too. - await expect(session.steer("steer during handoff")).rejects.toThrow(/handoff is in progress/i); - await expect(session.followUp("follow-up during handoff")).rejects.toThrow(/handoff is in progress/i); - await expect(session.sendUserMessage("msg", { deliverAs: "followUp" })).rejects.toThrow( - /handoff is in progress/i, - ); + await expect(session.steer("steer during handoff")).rejects.toMatchObject({ code: "busy" }); + await expect(session.followUp("follow-up during handoff")).rejects.toMatchObject({ code: "busy" }); + await expect(session.sendUserMessage("msg", { deliverAs: "followUp" })).rejects.toMatchObject({ code: "busy" }); gate.resolve(); await handoffPromise; diff --git a/packages/coding-agent/test/agent-session-issue-2261-esc-subagent-cancel.test.ts b/packages/coding-agent/test/agent-session-issue-2261-esc-subagent-cancel.test.ts index 49812c33565..abbdf6f090a 100644 --- a/packages/coding-agent/test/agent-session-issue-2261-esc-subagent-cancel.test.ts +++ b/packages/coding-agent/test/agent-session-issue-2261-esc-subagent-cancel.test.ts @@ -6,12 +6,17 @@ import { getBundledModel } from "@gajae-code/ai"; import { AsyncJobManager } from "@gajae-code/coding-agent/async/job-manager"; import { ModelRegistry } from "@gajae-code/coding-agent/config/model-registry"; import { Settings } from "@gajae-code/coding-agent/config/settings"; +import { TtsrManager } from "@gajae-code/coding-agent/export/ttsr"; import * as internalUrls from "@gajae-code/coding-agent/internal-urls"; import { AgentSession } from "@gajae-code/coding-agent/session/agent-session"; import { ArtifactManager } from "@gajae-code/coding-agent/session/artifacts"; import { AuthStorage } from "@gajae-code/coding-agent/session/auth-storage"; import { SessionManager } from "@gajae-code/coding-agent/session/session-manager"; -import { lookupOwnedRegistration, registerOwnedRegistration } from "@gajae-code/coding-agent/session/terminal-abort"; +import { + lookupOwnedRegistration, + registerOwnedRegistration, + unregisterOwnedRegistration, +} from "@gajae-code/coding-agent/session/terminal-abort"; import { TempDir } from "@gajae-code/utils"; const CLEANUP_NOTICE = @@ -46,6 +51,7 @@ describe("AgentSession Issue #2261 /new owner-subagent cancellation", () => { let sessionManager: SessionManager; let session: AgentSession; let manager: AsyncJobManager | undefined; + let ttsrManager: TtsrManager; beforeEach(async () => { tempDir = TempDir.createSync("@gjc-issue-2261-"); @@ -53,12 +59,14 @@ describe("AgentSession Issue #2261 /new owner-subagent cancellation", () => { const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected bundled test model"); sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); + ttsrManager = new TtsrManager({ enabled: true }); session = new AgentSession({ agent: new Agent({ initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] } }), sessionManager, settings: Settings.isolated(), modelRegistry: new ModelRegistry(authStorage), agentId: "owner", + ttsrManager, }); }); @@ -326,6 +334,16 @@ describe("AgentSession Issue #2261 /new owner-subagent cancellation", () => { sessionFile: "/tmp/copied-transcript-child.jsonl", resumable: true, }); + const predecessorJob = manager.getJob(predecessorJobId); + if (!predecessorJob) throw new Error("Expected copied-transcript predecessor job"); + registerOwnedRegistration({ + endpointId: previousSessionId, + endpointGeneration: 0, + lineageIdHash: "copied-transcript-predecessor", + promptAttemptEpoch: 1, + jobId: predecessorJobId, + jobGeneration: predecessorJob.generation, + }); const finishShutdown = vi.spyOn(manager, "finishOwnerSubagentShutdown"); await expect(session.switchSession(copiedFile)).resolves.toBe(true); @@ -336,6 +354,7 @@ describe("AgentSession Issue #2261 /new owner-subagent cancellation", () => { expect(manager.getJob(predecessorJobId)?.status).toBe("cancelled"); expect(manager.getDeliveryState({ ownerId: "owner" }).queued).toBe(0); expect(manager.getSubagentRecord("copied-transcript-child")).toBeUndefined(); + expect(lookupOwnedRegistration(predecessorJobId, predecessorJob.generation, previousSessionId)).toBeUndefined(); expect(completions).toEqual([]); }); @@ -360,6 +379,12 @@ describe("AgentSession Issue #2261 /new owner-subagent cancellation", () => { { ownerId: "owner" }, ); const predecessorEndpointId = sessionManager.getSessionId(); + const checkpointState = { + checkpointEntryId: sessionManager.getLeafId(), + checkpointMessageCount: session.agent.state.messages.length, + startedAt: new Date().toISOString(), + }; + session.setCheckpointState(checkpointState); const ownerJob = ownerManager.getJob(ownerJobId); if (!ownerJob) throw new Error("Expected owner job"); registerOwnedRegistration( @@ -380,6 +405,7 @@ describe("AgentSession Issue #2261 /new owner-subagent cancellation", () => { await expect(session.switchSession(copiedFile)).rejects.toThrow("injected successor validation failure"); expect(session.sessionFile).toBe(previousFile); + expect(session.getCheckpointState()).toEqual(checkpointState); expect(ownerManager.getJob(ownerJobId)?.status).toBe("running"); // Rekey moved the manager mapping, but rollback restored the // predecessor while the job remains live. Its predecessor tuple must @@ -398,6 +424,88 @@ describe("AgentSession Issue #2261 /new owner-subagent cancellation", () => { expect(finishShutdown).toHaveBeenLastCalledWith(expect.any(Object), "commit"); }); + it("restores predecessor TTSR state when successor validation rolls back", async () => { + const previousFile = session.sessionFile; + if (!previousFile) throw new Error("Expected a persisted predecessor session"); + await sessionManager.ensureOnDisk(); + const copiedFile = path.join(tempDir.path(), "fallible-ttsr-successor.jsonl"); + await Bun.write(copiedFile, Bun.file(previousFile)); + ttsrManager.replacePersistedState(["predecessor-rule"], 7); + vi.spyOn(internalUrls, "initializeLocalRoot").mockRejectedValueOnce( + new Error("injected successor validation failure"), + ); + + await expect(session.switchSession(copiedFile)).rejects.toThrow("injected successor validation failure"); + + expect(ttsrManager.getInjectedRuleNames()).toEqual(["predecessor-rule"]); + expect(ttsrManager.getMessageCount()).toBe(7); + }); + + it("does not move a foreign endpoint manager when switch admission fails", async () => { + const ownerManager = installOwnerManager(); + const predecessorEndpoint = sessionManager.getSessionId(); + expect(AsyncJobManager.registerForEndpoint(predecessorEndpoint, ownerManager)).toBe(true); + await sessionManager.ensureOnDisk(); + const previousFile = session.sessionFile; + if (!previousFile) throw new Error("Expected a persisted predecessor session"); + + const targetManager = SessionManager.create(tempDir.path(), tempDir.path()); + await targetManager.ensureOnDisk(); + const targetFile = targetManager.getSessionFile(); + if (!targetFile) throw new Error("Expected a persisted target session"); + const targetEndpoint = targetManager.getSessionId(); + const foreignManager = new AsyncJobManager({ onJobComplete: async () => {} }); + expect(AsyncJobManager.registerForEndpoint(targetEndpoint, foreignManager)).toBe(true); + + try { + await expect(session.switchSession(targetFile)).rejects.toThrow("owned by another live session's job manager"); + expect(session.sessionFile).toBe(previousFile); + expect(AsyncJobManager.forEndpoint(predecessorEndpoint)).toBe(ownerManager); + expect(AsyncJobManager.forEndpoint(targetEndpoint)).toBe(foreignManager); + } finally { + await targetManager.close(); + AsyncJobManager.unregisterManager(foreignManager); + await foreignManager.dispose({ timeoutMs: 100 }); + } + }); + + it("does not retire registrations owned by a foreign predecessor endpoint", async () => { + installOwnerManager(); + await sessionManager.ensureOnDisk(); + const predecessorEndpoint = sessionManager.getSessionId(); + const foreignManager = new AsyncJobManager({ onJobComplete: async () => {} }); + expect(AsyncJobManager.registerForEndpoint(predecessorEndpoint, foreignManager)).toBe(true); + const foreignJobId = foreignManager.register("task", "foreign predecessor", async () => "done"); + const foreignJob = foreignManager.getJob(foreignJobId); + if (!foreignJob) throw new Error("Expected foreign predecessor job"); + const registration = { + endpointId: predecessorEndpoint, + endpointGeneration: 0, + lineageIdHash: "foreign-predecessor", + promptAttemptEpoch: 1, + jobId: foreignJobId, + jobGeneration: foreignJob.generation, + }; + registerOwnedRegistration(registration, { isJobTerminal: () => false }); + const targetManager = SessionManager.create(tempDir.path(), tempDir.path()); + await targetManager.ensureOnDisk(); + const targetFile = targetManager.getSessionFile(); + if (!targetFile) throw new Error("Expected a persisted target session"); + + try { + await expect(session.switchSession(targetFile)).resolves.toBe(true); + expect(AsyncJobManager.forEndpoint(predecessorEndpoint)).toBe(foreignManager); + expect(lookupOwnedRegistration(foreignJobId, foreignJob.generation, predecessorEndpoint)).toEqual( + registration, + ); + } finally { + unregisterOwnedRegistration(registration); + await targetManager.close(); + AsyncJobManager.unregisterManager(foreignManager); + await foreignManager.dispose({ timeoutMs: 100 }); + } + }); + it.each([ "proof", "settlement", @@ -461,7 +569,6 @@ describe("AgentSession Issue #2261 /new owner-subagent cancellation", () => { }); } const finishShutdown = vi.spyOn(ownerManager, "finishOwnerSubagentShutdown"); - const appendMessage = vi.spyOn(sessionManager, "appendMessage"); const notices: string[] = []; session.subscribe(event => { if (event.type === "notice") notices.push(event.message); @@ -474,13 +581,8 @@ describe("AgentSession Issue #2261 /new owner-subagent cancellation", () => { expect(ownerManager.beginOwnerSubagentShutdown("owner")).toBeUndefined(); expect(await pathExists(fallbackRoot)).toBe(true); expect(notices.some(message => message.includes("Successor session is active"))).toBe(true); - session.agent.emitExternalEvent({ - type: "message_end", - message: { role: "user", content: "successor remains connected", timestamp: Date.now() }, - }); - expect(appendMessage).toHaveBeenCalledWith( - expect.objectContaining({ content: "successor remains connected" }), - ); + await session.sendUserMessage("successor remains connected", { deliverAs: "followUp" }); + expect(session.pendingMessageCounts.followUp).toBe(1); } finally { retryAllowed.resolve(); } diff --git a/packages/coding-agent/test/agent-session-promotion-identity.test.ts b/packages/coding-agent/test/agent-session-promotion-identity.test.ts index 9948e0afdee..bcb52b5fc07 100644 --- a/packages/coding-agent/test/agent-session-promotion-identity.test.ts +++ b/packages/coding-agent/test/agent-session-promotion-identity.test.ts @@ -56,6 +56,7 @@ describe("queued promotion run identity (#4668)", () => { settings = Settings.isolated({ "compaction.enabled": false }), sessionManager = SessionManager.inMemory(), extensionRunner?: unknown, + afterTurnEndPublished?: () => void | Promise, ): AgentSession { const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected bundled Anthropic test model to exist"); @@ -64,6 +65,7 @@ describe("queued promotion run identity (#4668)", () => { getApiKey: provider => `${provider}-test-key`, initialState: { model, systemPrompt: ["Test"], tools: [tool], messages: [] }, streamFn: mock.stream, + ...(afterTurnEndPublished ? { afterTurnEndPublished } : {}), }); settings.setModelRole("default", `${model.provider}/${model.id}`); return new AgentSession({ @@ -75,6 +77,33 @@ describe("queued promotion run identity (#4668)", () => { }); } + it("preserves configured turn-end observers after canonical session persistence", async () => { + const sessionManager = SessionManager.inMemory(); + let observerCalls = 0; + let assistantWasCanonical = false; + const tool: AgentTool = { + name: "echo", + label: "Echo", + description: "Echo tool", + parameters: echoSchema, + execute: async (_toolCallId, params) => ({ + content: [{ type: "text", text: params.value }], + }), + }; + session = buildSession([{ content: ["turn answer"] }], tool, undefined, sessionManager, undefined, async () => { + observerCalls++; + assistantWasCanonical = sessionManager + .getBranch() + .some(entry => entry.type === "message" && entry.message.role === "assistant"); + }); + + await session.prompt("first task"); + await session.waitForIdle(); + + expect(observerCalls).toBe(1); + expect(assistantWasCanonical).toBe(true); + }); + function buildAbortableTrackedTransitionFixture( sessionManager = SessionManager.inMemory(), extensionRunner?: unknown, @@ -1563,6 +1592,7 @@ describe("queued promotion run identity (#4668)", () => { disposition: "completed", }); if (terminal.disposition !== "completed") throw new Error("Expected completed terminal receipt"); + expect(terminal.attemptScope).toEqual(execution.attemptScope); expect(observedScopes).toContain("turn_start:2"); unsubscribe(); await promptDone; diff --git a/packages/coding-agent/test/agent-session-silent-abort.test.ts b/packages/coding-agent/test/agent-session-silent-abort.test.ts index 7f21f9d32e3..34678e41f56 100644 --- a/packages/coding-agent/test/agent-session-silent-abort.test.ts +++ b/packages/coding-agent/test/agent-session-silent-abort.test.ts @@ -14,7 +14,7 @@ */ import { afterEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; -import { Agent } from "@gajae-code/agent-core"; +import { Agent, ThinkingLevel } from "@gajae-code/agent-core"; import type { AssistantMessage, TextContent } from "@gajae-code/ai"; import { getBundledModel } from "@gajae-code/ai/models"; import { ModelRegistry } from "@gajae-code/coding-agent/config/model-registry"; @@ -23,7 +23,7 @@ import { SecretObfuscator } from "@gajae-code/coding-agent/secrets/obfuscator"; import { AgentSession, type AgentSessionEvent } from "@gajae-code/coding-agent/session/agent-session"; import { AuthStorage } from "@gajae-code/coding-agent/session/auth-storage"; import { SILENT_ABORT_MARKER } from "@gajae-code/coding-agent/session/messages"; -import { SessionManager } from "@gajae-code/coding-agent/session/session-manager"; +import { getSessionMessageEntryId, SessionManager } from "@gajae-code/coding-agent/session/session-manager"; import { TempDir } from "@gajae-code/utils"; function makeAbortedAssistantMessage(text = "partial draft"): AssistantMessage { @@ -155,6 +155,273 @@ describe("AgentSession silent-abort marker stamping", () => { await Promise.all([silentAbort, realAbort]); }); + it("canonically commits and classifies an orphan before publishing the same agent_end object", async () => { + fixture = await createSessionWithObfuscator(); + const { session } = fixture; + const { scope, dispose: disposeScope } = session.agent.mintSideAttemptScope(); + const seen: AgentSessionEvent[] = []; + session.subscribe(event => seen.push(event)); + + const provisional = makeStoppedAssistantMessage("retained orphan partial"); + session.agent.emitExternalEvent({ type: "message_start", message: provisional, scope }); + session.agent.emitExternalEvent({ + type: "message_update", + message: provisional, + assistantMessageEvent: { + type: "text_delta", + contentIndex: 0, + delta: "retained orphan partial", + partial: provisional, + }, + scope, + }); + const provisionalText = provisional.content[0]; + if (provisionalText?.type === "text") provisionalText.text = "mutated after captured update"; + session.markPlanCompactAbortPending(); + expect(session.isPlanCompactAbortPending).toBe(true); + const rawAgentEnd: Extract = { + type: "agent_end", + messages: [], + stopReason: "cancelled", + scope, + }; + session.agent.emitExternalEvent(rawAgentEnd); + let agentEnd: Extract | undefined; + for (let attempt = 0; attempt < 50 && !agentEnd; attempt++) { + await Bun.sleep(1); + agentEnd = seen.find( + (event): event is Extract => + event.type === "agent_end" && event.silentAbort === true, + ); + } + expect(agentEnd).toBe(rawAgentEnd); + expect(agentEnd?.silentAbort).toBe(true); + const recovered = agentEnd?.messages.find((message): message is AssistantMessage => message.role === "assistant"); + expect(recovered?.stopReason).toBe("aborted"); + expect(recovered?.errorMessage).toBe(SILENT_ABORT_MARKER); + expect(recovered && getSessionMessageEntryId(recovered)).toBeDefined(); + expect(session.agent.state.messages.filter(message => message === recovered)).toHaveLength(1); + expect( + session + .buildDisplaySessionContext() + .messages.some( + message => + message.role === "assistant" && + message.content.some( + content => content.type === "text" && content.text === "retained orphan partial", + ), + ), + ).toBe(true); + expect(session.isPlanCompactAbortPending).toBe(false); + disposeScope(); + }); + + it("matches forced recovery by attempt scope after abort advances prompt generation", async () => { + fixture = await createSessionWithObfuscator(); + const { session } = fixture; + const { scope, dispose: disposeScope } = session.agent.mintSideAttemptScope(); + const partial = makeStoppedAssistantMessage("forced partial"); + session.agent.emitExternalEvent({ type: "message_start", message: partial, scope }); + await session.awaitSessionSettlement(); + const beforeAbortGeneration = session.transcriptPromptGeneration; + await session.abort(); + expect(session.transcriptPromptGeneration).toBeGreaterThan(beforeAbortGeneration); + const terminal: Extract = { + type: "agent_end", + messages: [], + stopReason: "cancelled", + scope, + }; + session.agent.emitExternalEvent(terminal); + await session.awaitSessionSettlement(); + + const recovered = terminal.messages.find((message): message is AssistantMessage => message.role === "assistant"); + expect(recovered?.stopReason).toBe("aborted"); + expect(recovered?.content).toEqual([{ type: "text", text: "forced partial" }]); + expect(recovered && getSessionMessageEntryId(recovered)).toBeDefined(); + disposeScope(); + }); + + it("canonically admits an authoritative external terminal without message_end", async () => { + fixture = await createSessionWithObfuscator(); + const { session } = fixture; + const { scope, dispose: disposeScope } = session.agent.mintSideAttemptScope(); + const presentationMessage = makeStoppedAssistantMessage("external partial"); + const finalMessage = makeStoppedAssistantMessage("external final"); + session.agent.emitExternalEvent({ type: "message_start", message: presentationMessage, scope }); + const terminal: Extract = { + type: "agent_end", + messages: [finalMessage], + stopReason: "completed", + scope, + }; + session.agent.emitExternalEvent(terminal); + await session.awaitSessionSettlement(); + + expect(getSessionMessageEntryId(finalMessage)).toBeDefined(); + expect(getSessionMessageEntryId(presentationMessage)).toBe(getSessionMessageEntryId(finalMessage)); + expect(session.agent.state.messages.filter(message => message === finalMessage)).toHaveLength(1); + expect( + session + .buildDisplaySessionContext() + .messages.some( + message => + message === finalMessage || + getSessionMessageEntryId(message) === getSessionMessageEntryId(finalMessage), + ), + ).toBe(true); + disposeScope(); + }); + + it("persists silent classification on a cancelled authoritative external terminal", async () => { + fixture = await createSessionWithObfuscator(); + const { session } = fixture; + const finalMessage: AssistantMessage = { + ...makeStoppedAssistantMessage("silent external final"), + stopReason: "aborted", + }; + session.markPlanCompactAbortPending(); + session.agent.emitExternalEvent({ + type: "agent_end", + messages: [finalMessage], + stopReason: "cancelled", + }); + await session.awaitSessionSettlement(); + + expect(finalMessage.errorMessage).toBe(SILENT_ABORT_MARKER); + expect(getSessionMessageEntryId(finalMessage)).toBeDefined(); + }); + + it("does not recover a predecessor partial after a branch rotates session identity", async () => { + fixture = await createSessionWithObfuscator(); + const { session } = fixture; + const userMessage = { + role: "user" as const, + content: [{ type: "text" as const, text: "branch root" }], + timestamp: Date.now(), + }; + const entryId = session.sessionManager.appendMessage(userMessage); + session.agent.appendMessage(userMessage); + const predecessorScope = { attemptId: "predecessor", generation: 7, lineage: "main" as const }; + const partial = makeStoppedAssistantMessage("predecessor partial"); + session.agent.emitExternalEvent({ type: "message_start", message: partial, scope: predecessorScope }); + await Promise.resolve(); + + const result = await session.branch(entryId); + expect(result.cancelled).toBe(false); + const clonedScope = { ...predecessorScope }; + const lateFinal = makeStoppedAssistantMessage("late cloned-scope final"); + const lateTerminal: Extract = { + type: "agent_end", + messages: [lateFinal], + stopReason: "completed", + scope: clonedScope, + disownedSteering: [ + { + role: "user", + content: [{ type: "text", text: "must not rearm in successor" }], + timestamp: Date.now(), + }, + ], + }; + session.agent.emitExternalEvent(lateTerminal); + const unscopedLate = makeStoppedAssistantMessage("late unscoped final"); + session.agent.emitExternalEvent({ type: "message_start", message: unscopedLate }); + session.agent.emitExternalEvent({ + type: "agent_end", + messages: [unscopedLate], + stopReason: "completed", + }); + await session.awaitSessionSettlement(); + + expect(getSessionMessageEntryId(lateFinal)).toBeUndefined(); + expect(getSessionMessageEntryId(unscopedLate)).toBeUndefined(); + expect(session.agent.state.messages).not.toContain(lateFinal); + expect(session.agent.state.messages).not.toContain(unscopedLate); + expect(session.agent.state.streamMessage).toBeNull(); + expect(session.agent.snapshotSteering()).toHaveLength(0); + expect(session.agent.snapshotFollowUp()).toHaveLength(0); + expect( + session + .buildDisplaySessionContext() + .messages.some( + message => + message.role === "assistant" && + message.content.some(content => content.type === "text" && content.text === "predecessor partial"), + ), + ).toBe(false); + }); + + it("emits a visible notice when canonical orphan persistence fails", async () => { + fixture = await createSessionWithObfuscator(); + const { session } = fixture; + const { scope, dispose: disposeScope } = session.agent.mintSideAttemptScope(); + const partial = makeStoppedAssistantMessage("unpersisted partial"); + const events: AgentSessionEvent[] = []; + session.subscribe(event => events.push(event)); + session.agent.emitExternalEvent({ type: "message_start", message: partial, scope }); + vi.spyOn(session.sessionManager, "appendMessage").mockImplementationOnce(() => { + throw new Error("synthetic persistence failure"); + }); + const terminal: Extract = { + type: "agent_end", + messages: [], + stopReason: "cancelled", + scope, + }; + session.agent.emitExternalEvent(terminal); + await session.awaitSessionSettlement(); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "notice", + level: "error", + source: "session-persistence", + }), + ); + expect(events).toContain(terminal); + expect((terminal as Extract).terminalPersistenceFailed).toBe(true); + expect(terminal.messages).toHaveLength(0); + expect(session.agent.state.messages.some(message => message === partial)).toBe(false); + expect(session.agent.state.streamMessage).toBeNull(); + disposeScope(); + expect(() => session.newSession()).toThrow(expect.objectContaining({ code: "session_persistence_blocked" })); + const thinkingLevelBeforeBlockedMutation = session.thinkingLevel; + expect(() => session.setThinkingLevel(ThinkingLevel.High, false)).toThrow( + expect.objectContaining({ code: "session_persistence_blocked" }), + ); + expect(session.thinkingLevel).toBe(thinkingLevelBeforeBlockedMutation); + const recovery = vi + .spyOn(session.sessionManager, "recoverPersistenceFailure") + .mockRejectedValueOnce(new Error("selection still unreconciled")) + .mockRejectedValueOnce(new Error("prompt still unreconciled")); + const selectionMutation = vi.fn(async () => {}); + await expect(session.withSdkControlMutation(selectionMutation)).rejects.toMatchObject({ + code: "session_persistence_blocked", + }); + expect(selectionMutation).not.toHaveBeenCalled(); + await expect(session.prompt("must remain fenced")).rejects.toMatchObject({ + code: "session_persistence_blocked", + }); + await Promise.all([ + session.runWithPromptAdmissionForTests(async () => {}), + session.runWithPromptAdmissionForTests(async () => {}), + ]); + expect(recovery).toHaveBeenCalledTimes(3); + expect( + events.filter(event => event.type === "notice" && event.source === "terminal-persistence-recovered"), + ).toHaveLength(1); + expect( + session + .buildDisplaySessionContext() + .messages.filter( + message => + message.role === "assistant" && + message.content.some(content => content.type === "text" && content.text === "unpersisted partial"), + ), + ).toHaveLength(1); + }); + it("A3: flag set + non-aborted message_end does NOT consume the flag", async () => { fixture = await createSessionWithObfuscator(); const { session } = fixture; @@ -238,6 +505,8 @@ describe("AgentSession silent-abort marker stamping", () => { throw new Error("expected emitted message_end to be an assistant message"); } expect(emittedMessage.errorMessage).toBe(SILENT_ABORT_MARKER); + expect(emitted.silentAbort).toBe(true); + expect(Object.getOwnPropertyDescriptor(emitted, "silentAbort")?.writable).toBe(false); // Prove the obfuscator branch actually ran by asserting the emitted message // is a distinct object (post-spread) AND its content was deobfuscated back to diff --git a/packages/coding-agent/test/agent-session-terminal-abort-chain.test.ts b/packages/coding-agent/test/agent-session-terminal-abort-chain.test.ts index e081299fc5a..8f08f4b46ad 100644 --- a/packages/coding-agent/test/agent-session-terminal-abort-chain.test.ts +++ b/packages/coding-agent/test/agent-session-terminal-abort-chain.test.ts @@ -61,7 +61,7 @@ function stopReply(text: string): MockResponse { }; } -async function waitFor(predicate: () => boolean, label: string, timeoutMs = 10_000): Promise { +async function waitFor(predicate: () => boolean, label: string, timeoutMs = 20_000): Promise { const deadline = Date.now() + timeoutMs; while (!predicate()) { if (Date.now() > deadline) throw new Error(`Timed out waiting for ${label}`); @@ -86,6 +86,9 @@ describe("terminal abort registers a turn scope so left-running owned work class let settingsRef: Settings; let modelRegistryRef: ModelRegistry; let extraManagers: Set; + const setScriptedResponses = (responses: MockResponse[]): void => { + scriptedResponses.splice(0, scriptedResponses.length, ...responses); + }; const trackExtraManager = (candidate: AsyncJobManager): AsyncJobManager => { extraManagers.add(candidate); @@ -174,9 +177,10 @@ describe("terminal abort registers a turn scope so left-running owned work class toolSession = ts; scriptedResponses = []; + const mockResponses = scriptedResponses; const mock = createMockModel({ - handler: () => scriptedResponses.shift() ?? stopReply("done"), + handler: () => mockResponses.shift() ?? stopReply("done"), }); mockModelRef = mock; @@ -221,15 +225,13 @@ describe("terminal abort registers a turn scope so left-running owned work class session.agent.abort(); await manager.dispose({ timeoutMs: 1_000 }); await chainSessionManager.close(); + await session.dispose(); } else { - // Abort active runs and cancel producers before stopping the manager. A - // terminal abort can rearm a preserved steer after the body returns, so - // wait for the session's idle signal and every canceled job promise first. session.agent.abort(); manager.cancelAll(); - // Join any rearmed continuation before disposing its manager. The - // coordinator persistence seam waits for this continuation to settle; - // disposing the manager first can strand it and make the seam hang. + // Join any rearmed continuation before disposing its manager. A terminal + // abort can rearm a preserved steer after the body returns, so wait for + // the session's idle signal and every canceled job promise first. const idleSettled = await Promise.race([ session.waitForIdle().then( () => true, @@ -240,6 +242,7 @@ describe("terminal abort registers a turn scope so left-running owned work class if (!idleSettled) { await Promise.race([manager.dispose({ timeoutMs: 3_000 }), Bun.sleep(4_000)]); await Promise.race([chainSessionManager.close(), Bun.sleep(3_000)]); + await session.dispose(); return; } const jobsSettled = await Promise.race([ @@ -252,8 +255,14 @@ describe("terminal abort registers a turn scope so left-running owned work class if (!jobsSettled) { await Promise.race([manager.dispose({ timeoutMs: 3_000 }), Bun.sleep(4_000)]); await Promise.race([chainSessionManager.close(), Bun.sleep(3_000)]); + await session.dispose(); return; } + // Stop the block-owned manager before joining coordinator persistence. + // A completion callback can enqueue persistence work, so waiting for + // that queue first would leave teardown waiting on the manager that + // teardown itself is responsible for settling. + await Promise.race([manager.dispose({ timeoutMs: 3_000 }), Bun.sleep(4_000)]); const persistenceSettled = await Promise.race([ session.awaitCoordinatorRuntimeStatePersistenceForTests().then( () => true, @@ -261,9 +270,12 @@ describe("terminal abort registers a turn scope so left-running owned work class ), Bun.sleep(5_000).then(() => false), ]); - await Promise.race([manager.dispose({ timeoutMs: 3_000 }), Bun.sleep(4_000)]); - if (persistenceSettled) await session.dispose(); - else void session.dispose().catch(() => {}); + if (persistenceSettled) { + // Await sidecar work only after manager disposal above, the order + // required to avoid deadlocking on a callback owned by this manager. + await session.awaitSessionSettlement(); + await session.dispose(); + } else void session.dispose().catch(() => {}); } } finally { const managers = [...extraManagers]; @@ -285,7 +297,7 @@ describe("terminal abort registers a turn scope so left-running owned work class it("terminal abort registers the scope so the left-running owned job classifies as owned-completion", async () => { const callId = "call_terminal_owned"; - scriptedResponses = [bashCall("sleep 30", callId, true), stopReply("ok")]; + setScriptedResponses([bashCall("sleep 30", callId, true), stopReply("ok")]); const promptPromise = session.prompt("run owned work").catch(() => {}); await waitFor(() => manager.getAllJobs().length > 0, "bash job registered"); @@ -341,7 +353,7 @@ describe("terminal abort registers a turn scope so left-running owned work class const foreign = trackExtraManager(new AsyncJobManager({ maxRunningJobs: 2, onJobComplete: () => {} })); try { AsyncJobManager.setInstance(foreign); - scriptedResponses = [bashCall("sleep 30", "call_endpoint_manager", true), stopReply("ok")]; + setScriptedResponses([bashCall("sleep 30", "call_endpoint_manager", true), stopReply("ok")]); const promptPromise = session.prompt("run owned work").catch(() => {}); await waitFor(() => manager.getAllJobs().length > 0, "job registered in the endpoint-owned manager"); expect(manager.getAllJobs().length).toBeGreaterThan(0); @@ -469,7 +481,7 @@ describe("terminal abort registers a turn scope so left-running owned work class // resolver before rejecting Bash. try { AsyncJobManager.setInstance(undefined); - scriptedResponses = [bashCall("sleep 30", "call_endpoint_after_global_dispose", true), stopReply("ok")]; + setScriptedResponses([bashCall("sleep 30", "call_endpoint_after_global_dispose", true), stopReply("ok")]); const promptPromise = session.prompt("run endpoint-owned async work").catch(() => {}); await waitFor(() => manager.getAllJobs().length > 0, "job registered after global manager was cleared"); expect(manager.getAllJobs().length).toBeGreaterThan(0); @@ -481,7 +493,7 @@ describe("terminal abort registers a turn scope so left-running owned work class it("owned scope registers a scope with owned-completion delivery disabled", async () => { const callId = "call_terminal_owned_disabled"; - scriptedResponses = [bashCall("sleep 30", callId, true), stopReply("ok")]; + setScriptedResponses([bashCall("sleep 30", callId, true), stopReply("ok")]); const promptPromise = session.prompt("run capturable work").catch(() => {}); await waitFor(() => manager.getAllJobs().length > 0, "bash job registered"); @@ -506,7 +518,7 @@ describe("terminal abort registers a turn scope so left-running owned work class it("terminal abort advances the epoch so a later turn's work never binds the aborted scope", async () => { // Turn A spawns a job; terminal abort fences turn A's lineage+epoch. - scriptedResponses = [bashCall("sleep 30", "call-a", true), stopReply("ok")]; + setScriptedResponses([bashCall("sleep 30", "call-a", true), stopReply("ok")]); const firstPrompt = session.prompt("first turn").catch(() => {}); await waitFor(() => manager.getAllJobs().length > 0, "first job registered"); await firstPrompt; @@ -521,7 +533,7 @@ describe("terminal abort registers a turn scope so left-running owned work class // advanced, so its lineage is distinct and the aborted scope must NOT // claim it (AC 27/28 — the fence bounds only the aborted turn). const jobCountBefore = manager.getAllJobs().length; - scriptedResponses = [bashCall("sleep 30", "call-b", true), stopReply("ok")]; + setScriptedResponses([bashCall("sleep 30", "call-b", true), stopReply("ok")]); const secondPrompt = session.prompt("second turn").catch(() => {}); await waitFor(() => manager.getAllJobs().length > jobCountBefore, "second job registered"); await secondPrompt; @@ -531,7 +543,7 @@ describe("terminal abort registers a turn scope so left-running owned work class it("consecutive normal turns get distinct lineage epochs; owned abort of turn B never captures turn A's job", async () => { // Turn A completes normally (no abort), leaving a registered job. - scriptedResponses = [bashCall("sleep 30", "call-distinct-a", true), stopReply("ok")]; + setScriptedResponses([bashCall("sleep 30", "call-distinct-a", true), stopReply("ok")]); await session.prompt("first turn"); await waitFor(() => manager.getAllJobs().length >= 1, "first job registered"); const jobA = manager.getAllJobs()[0]!; @@ -539,7 +551,7 @@ describe("terminal abort registers a turn scope so left-running owned work class // Turn B also completes normally; the lineage epoch must NOT be reused, // otherwise both turns share (lineageIdHash, epoch) and turn A's job // would look owned by turn B (review thread P1). - scriptedResponses = [bashCall("sleep 30", "call-distinct-b", true), stopReply("ok")]; + setScriptedResponses([bashCall("sleep 30", "call-distinct-b", true), stopReply("ok")]); await session.prompt("second turn"); await waitFor(() => manager.getAllJobs().length >= 2, "second job registered"); const jobB = manager.getAllJobs().find(job => job.id !== jobA.id)!; @@ -602,7 +614,7 @@ describe("terminal abort registers a turn scope so left-running owned work class }, 20_000); it("terminal abort + new prompt discards hidden next-turn successors before injection", async () => { - scriptedResponses = [stopReply("ok")]; + setScriptedResponses([stopReply("ok")]); await session.prompt("first turn"); // Queue WITHOUT scheduling a drain: the first turn has already ended, so // a scheduled drain would fire before the abort lands (the session is @@ -628,7 +640,7 @@ describe("terminal abort registers a turn scope so left-running owned work class // A NEW prompt advances the generation: the explicit-prompt admission // must discard the aborted turn's hidden successors before injection // instead of adding them to this new turn (review thread P2). - scriptedResponses = [stopReply("ok")]; + setScriptedResponses([stopReply("ok")]); await session.prompt("new user turn"); expect(session.getPendingNextTurnMessagesForTests()).toHaveLength(0); }, 20_000); @@ -638,7 +650,7 @@ describe("terminal abort registers a turn scope so left-running owned work class // to mean anything: steering a finished turn is refused at admission (R1), // which would make the empty-queue assertion below pass for the wrong // reason. Park a tool so the turn is live, admit, then abort terminally. - scriptedResponses = [bashCall("sleep 2", "call_hold_turn"), stopReply("must not run")]; + setScriptedResponses([bashCall("sleep 2", "call_hold_turn"), stopReply("must not run")]); const promptPromise = session.prompt("hold the turn").catch(() => {}); await waitFor(() => session.agent.activeResourceRunId !== undefined, "active run handle"); const callsBeforeAbort = recordedProviderContexts().length; @@ -676,7 +688,7 @@ describe("terminal abort registers a turn scope so left-running owned work class // whose acceptance was already acknowledged, so the abort must preserve // it instead of purging every steering message — clearing it would // leave its reconciliation record accepted indefinitely. - scriptedResponses = [bashCall("sleep 2", "call_hold_turn"), stopReply("steer answered")]; + setScriptedResponses([bashCall("sleep 2", "call_hold_turn"), stopReply("steer answered")]); const promptPromise = session.prompt("hold the turn").catch(() => {}); await waitFor(() => session.agent.activeResourceRunId !== undefined, "active run handle"); const abortPromise = session.abortPromptAndWait(session.agent.activeResourceRunId ?? "run", { @@ -700,7 +712,7 @@ describe("terminal abort registers a turn scope so left-running owned work class // ADMISSION (before its durable marker transaction) — a steer admitted // while the abort is in flight classifies as post-snapshot even though // the later abortPromptAndWait purge has not run yet. - scriptedResponses = [bashCall("sleep 2", "call_hold_turn"), stopReply("steer answered")]; + setScriptedResponses([bashCall("sleep 2", "call_hold_turn"), stopReply("steer answered")]); const promptPromise = session.prompt("hold the turn").catch(() => {}); await waitFor(() => session.agent.activeResourceRunId !== undefined, "active run handle"); session.captureTerminalAbortSteeringSnapshot(); @@ -721,7 +733,7 @@ describe("terminal abort registers a turn scope so left-running owned work class // aborted attempt, which the terminal-abort contract requires to be // blocked — the abort exits the loop and only rearms post-snapshot // steers, so a stale steer would otherwise alter the next prompt. - scriptedResponses = [bashCall("sleep 2", "call_hold_turn"), stopReply("unused")]; + setScriptedResponses([bashCall("sleep 2", "call_hold_turn"), stopReply("unused")]); const promptPromise = session.prompt("hold the turn").catch(() => {}); await waitFor(() => session.agent.activeResourceRunId !== undefined, "active run handle"); await session.sendUserMessage("pre-abort steer", { deliverAs: "steer" }); @@ -740,7 +752,7 @@ describe("terminal abort registers a turn scope so left-running owned work class // to steer, so the session routes the request as a follow-up owned by // the next turn — never as steering the terminal-abort purge has to // reason about. - scriptedResponses = [stopReply("ok"), stopReply("delivered")]; + setScriptedResponses([stopReply("ok"), stopReply("delivered")]); await session.prompt("first turn"); await session.sendUserMessage("post-turn steer", { deliverAs: "steer" }); expect(session.agent.hasQueuedSteering()).toBe(false); @@ -754,7 +766,7 @@ describe("terminal abort registers a turn scope so left-running owned work class // with their OWN admission's snapshot — a later admission capturing a // higher sequence must not overwrite the earlier admission's snapshot // and purge an already-accepted steer. - scriptedResponses = [bashCall("sleep 2", "call_hold_turn"), stopReply("steer answered")]; + setScriptedResponses([bashCall("sleep 2", "call_hold_turn"), stopReply("steer answered")]); const promptPromise = session.prompt("hold the turn").catch(() => {}); await waitFor(() => session.agent.activeResourceRunId !== undefined, "active run handle"); const handle = session.agent.activeResourceRunId; @@ -792,7 +804,7 @@ describe("terminal abort registers a turn scope so left-running owned work class // consumed at that run's terminal. Settling the OLDER (looser) admission // last must not widen the newer one: only steering admitted after the // highest snapshot survives. - scriptedResponses = [bashCall("sleep 2", "call_hold_turn"), stopReply("late steer answered")]; + setScriptedResponses([bashCall("sleep 2", "call_hold_turn"), stopReply("late steer answered")]); const promptPromise = session.prompt("hold the turn").catch(() => {}); await waitFor(() => session.agent.activeResourceRunId !== undefined, "active run handle"); const handle = session.agent.activeResourceRunId ?? "run"; @@ -835,7 +847,7 @@ describe("terminal abort registers a turn scope so left-running owned work class // arriving afterwards must be classified by WHERE IT CAME FROM: genuine // user input survives as its own root request, while a continuation the // aborted turn's own machinery produced is dropped with its display chip. - scriptedResponses = [bashCall("sleep 2", "call_hold_turn"), stopReply("user steer answered")]; + setScriptedResponses([bashCall("sleep 2", "call_hold_turn"), stopReply("user steer answered")]); const promptPromise = session.prompt("hold the turn").catch(() => {}); await waitFor(() => session.agent.activeResourceRunId !== undefined, "active run handle"); const handle = session.agent.activeResourceRunId ?? "run"; @@ -911,7 +923,7 @@ describe("terminal abort registers a turn scope so left-running owned work class // root request. Removing that request must remove its requirement too: a // later turn-owned continuation must not inherit a fresh identity and // escape the terminal fence. - scriptedResponses = [bashCall("sleep 2", "call_hold_turn"), stopReply("should not run")]; + setScriptedResponses([bashCall("sleep 2", "call_hold_turn"), stopReply("should not run")]); const promptPromise = session.prompt("hold the turn").catch(() => {}); await waitFor(() => session.agent.activeResourceRunId !== undefined, "active run handle"); const handle = session.agent.activeResourceRunId ?? "run"; @@ -956,7 +968,7 @@ describe("terminal abort registers a turn scope so left-running owned work class // preserved post-snapshot external steer is re-routed (queue AND display) // as a follow-up of the fresh turn, so the positional editing APIs never // address a stale entry. - scriptedResponses = [bashCall("sleep 2", "call_hold_turn"), stopReply("steer answered")]; + setScriptedResponses([bashCall("sleep 2", "call_hold_turn"), stopReply("steer answered")]); const promptPromise = session.prompt("hold the turn").catch(() => {}); await waitFor(() => session.agent.activeResourceRunId !== undefined, "active run handle"); const handle = session.agent.activeResourceRunId; @@ -990,9 +1002,9 @@ describe("terminal abort registers a turn scope so left-running owned work class // lineage — otherwise the scheduled continue runs under the aborted // lineage+epoch, the terminal fence skips it as terminal_turn, and the // preserved user follow-up stays stranded until unrelated activity. - scriptedResponses = [bashCall("sleep 2", "call_hold_turn"), stopReply("follow-up answered")]; + setScriptedResponses([bashCall("sleep 2", "call_hold_turn"), stopReply("follow-up answered")]); const promptPromise = session.prompt("hold the turn").catch(() => {}); - await waitFor(() => session.agent.activeResourceRunId !== undefined, "active run handle"); + await waitFor(() => session.agent.activeResourceRunId !== undefined, "active run handle", 20_000); // Queue the external follow-up while the turn is active (the idle // auto-continue is suppressed while streaming, so it stays queued). await session.followUp("external follow-up"); @@ -1015,9 +1027,9 @@ describe("terminal abort registers a turn scope so left-running owned work class // becomes active, the settlement must purge with the ORIGINAL admission // sequence — the session rebinds the captured token to the current turn // instead of rejecting it as stale. - scriptedResponses = [bashCall("sleep 30", "call_hold_turn"), stopReply("ok")]; + setScriptedResponses([bashCall("sleep 30", "call_hold_turn"), stopReply("ok")]); const promptPromise = session.prompt("hold the turn").catch(() => {}); - await waitFor(() => session.agent.activeResourceRunId !== undefined, "active run handle"); + await waitFor(() => session.agent.activeResourceRunId !== undefined, "active run handle", 20_000); const handle = session.agent.activeResourceRunId; // Admission capture: recorded under the CURRENT turn key. const token = session.captureTerminalAbortSteeringSnapshot(); @@ -1048,7 +1060,7 @@ describe("terminal abort registers a turn scope so left-running owned work class // never settles — its captured snapshot must be discarded, or a later // real abort would consume the stale entry and treat steering admitted // since the replay as post-abort. - scriptedResponses = [bashCall("sleep 2", "call_hold_turn"), stopReply("steer answered")]; + setScriptedResponses([bashCall("sleep 2", "call_hold_turn"), stopReply("steer answered")]); const promptPromise = session.prompt("hold the turn").catch(() => {}); await waitFor(() => session.agent.activeResourceRunId !== undefined, "active run handle"); await session.abortPromptAndWait(session.agent.activeResourceRunId ?? "run", { @@ -1080,9 +1092,19 @@ describe("terminal abort registers a turn scope so left-running owned work class // The hidden-next-turn fence purge must not delete it solely by origin — // it is a promised resume of the root worker (classified fresh), and // dropping it also bypasses the registration-settlement path. - scriptedResponses = [bashCall("sleep 2", "call_hold_turn")]; - const promptPromise = session.prompt("hold the turn").catch(() => {}); - await waitFor(() => session.agent.activeResourceRunId !== undefined, "active run handle"); + setScriptedResponses([bashCall("sleep 2", "call_hold_turn")]); + let promptError: unknown; + const promptPromise = session.prompt("hold the turn").catch(error => { + promptError = error; + }); + // Allow extra startup slack under the full suite; prompt failures still + // short-circuit this wait and are rethrown below. + await waitFor( + () => session.agent.activeResourceRunId !== undefined || promptError !== undefined, + "active run handle", + 30_000, + ); + if (promptError !== undefined) throw promptError; // Terminal abort closes the current turn's continuation fence. await session.abortPromptAndWait(session.agent.activeResourceRunId ?? "run", { graceMs: TEST_ABORT_GRACE_MS, @@ -1142,7 +1164,7 @@ describe("terminal abort registers a turn scope so left-running owned work class // A new explicit prompt drains the preserved completion: it is // classified fresh, delivered (the mock answers), and its registration // settles instead of bypassing the settlement path. - scriptedResponses = [stopReply("completion answered")]; + setScriptedResponses([stopReply("completion answered")]); await session.prompt("new user turn"); expect(session.getPendingNextTurnMessagesForTests()).toHaveLength(0); await waitFor( @@ -1150,7 +1172,51 @@ describe("terminal abort registers a turn scope so left-running owned work class "owned completion registration settled after delivery", ); await promptPromise; - }, 30_000); + }, 60_000); + + it("rejects an owned completion replayed from another live endpoint", async () => { + const foreignManager = new AsyncJobManager({ maxRunningJobs: 1, onJobComplete: () => {} }); + const foreignEndpoint = `${chainSessionManager.getSessionId()}-foreign`; + AsyncJobManager.registerForEndpoint(foreignEndpoint, foreignManager); + const registration: TurnRegistrationKey = { + endpointId: foreignEndpoint, + endpointGeneration: 0, + lineageIdHash: "foreign-replay-lineage", + promptAttemptEpoch: 91, + jobId: "foreign-live-job", + jobGeneration: "job:foreign:1", + }; + registerTerminalTurnScope({ + lineageIdHash: registration.lineageIdHash, + promptAttemptEpoch: registration.promptAttemptEpoch, + }); + registerOwnedRegistration(registration, { isJobTerminal: () => false }); + const envelope: OwnedCompletionEnvelope = { + lineageIdHash: registration.lineageIdHash, + promptAttemptEpoch: registration.promptAttemptEpoch, + registration, + }; + try { + await expect( + session.sendCustomMessage( + { + customType: "async-result", + content: "foreign owned completion", + display: false, + details: { ownedCompletions: [envelope] }, + attribution: "agent", + }, + { triggerTurn: true }, + ), + ).rejects.toThrow("different session endpoint"); + expect( + lookupOwnedRegistration(registration.jobId, registration.jobGeneration, registration.endpointId), + ).toBeDefined(); + } finally { + AsyncJobManager.unregisterManager(foreignManager); + await foreignManager.dispose({ timeoutMs: 1_000 }); + } + }); it("removed steers never fire their ownership hook into a later rearm", async () => { // Review thread P1: promotion hooks must bind to the messages a run @@ -1159,7 +1225,7 @@ describe("terminal abort registers a turn scope so left-running owned work class // callback for a run that did not consume it. const promoted: string[] = []; const removalDispositions: boolean[] = []; - scriptedResponses = [bashCall("sleep 2", "hold-removal"), stopReply("accepted")]; + setScriptedResponses([bashCall("sleep 2", "hold-removal"), stopReply("accepted")]); const promptPromise = session.prompt("hold removal window").catch(() => {}); await waitFor(() => session.agent.activeResourceRunId !== undefined, "active removal window"); await session.sendUserMessage("removed steer", { @@ -1204,7 +1270,7 @@ describe("terminal abort registers a turn scope so left-running owned work class // getSteeringMessages, the ownership hook never fires, and the // connection's later terminal abort is rejected as an owner mismatch. let promoted = 0; - scriptedResponses = [ + setScriptedResponses([ stopReply("turn one"), // The second turn's model response never emits content: the abort // interrupts the delay (delayMs honors the run's AbortSignal), and @@ -1214,7 +1280,7 @@ describe("terminal abort registers a turn scope so left-running owned work class // queue again. { delayMs: 1_000, throw: "abort probe", content: [] }, stopReply("steer answered"), - ]; + ]); await session.prompt("first turn"); // A completed in-flight tool's result is the history tail a terminal // abort leaves in production when the grace window lets the tool @@ -1268,8 +1334,7 @@ describe("terminal abort registers a turn scope so left-running owned work class // must stay associated with the requesting connection or a later // terminal abort from that client is rejected as non-owner. let promoted = 0; - const steerPromoted = Promise.withResolvers(); - scriptedResponses = [stopReply("ok"), stopReply("steer answered")]; + setScriptedResponses([stopReply("ok"), stopReply("steer answered")]); await session.prompt("first turn"); await session.waitForIdle(); // Queue the client steer while idle: the auto-continue promotes it into @@ -1278,81 +1343,36 @@ describe("terminal abort registers a turn scope so left-running owned work class deliverAs: "steer", onQueuedPromoted: () => { promoted += 1; - steerPromoted.resolve(); }, }); - // Promotion is fired from the queued-message run-acceptance callback, - // after the Agent has consumed the steer; join that boundary directly - // instead of polling wall-clock time for the scheduler to run. - await steerPromoted.promise; - expect(session.agent.hasQueuedSteering()).toBe(false); + await waitFor(() => !session.agent.hasQueuedSteering(), "steer consumed by its own run"); + await waitFor(() => promoted === 1, "steer ownership hook fired"); expect(promoted).toBe(1); await session.waitForIdle(); + await session.awaitCoordinatorRuntimeStatePersistenceForTests(); }, 60_000); it("rejects a steering snapshot token captured for an earlier turn", async () => { - scriptedResponses = [stopReply("first turn done")]; + setScriptedResponses([stopReply("first turn done"), bashCall("sleep 2", "call_second_turn")]); await session.prompt("first turn"); await session.waitForIdle(); const staleToken = session.captureTerminalAbortSteeringSnapshot(); expect(staleToken).toBeDefined(); - const bashStarted = Promise.withResolvers(); - const bashStopped = Promise.withResolvers(); - const executeBashSpy = vi.spyOn(bashExecutor, "executeBash").mockImplementation(async (_command, options) => { - const signal = options?.signal; - if (!signal) throw new Error("expected the second turn's bash command to have an abort signal"); - bashStarted.resolve(options); - await bashStopped.promise; - return { - output: "", - exitCode: 0, - cancelled: false, - truncated: false, - totalLines: 0, - totalBytes: 0, - outputLines: 0, - outputBytes: 0, - }; - }); - try { - scriptedResponses = [bashCall("controlled second-turn command", "call_second_turn")]; - const secondPrompt = session.prompt("second turn").catch(() => {}); - // A prompt that ends before dispatching Bash must also settle this gate; - // otherwise a failed/short-circuited run leaves bashStarted unresolved - // until the test's 60-second timeout. - const bashExecution = await Promise.race([ - bashStarted.promise, - secondPrompt.then(() => { - throw new Error("second turn settled before the controlled Bash executor started"); - }), - ]); - expect(bashExecution.signal?.aborted).toBe(false); - const handle = session.agent.activeResourceRunId ?? "run"; - await expect( - session.abortPromptAndWait(handle, { - graceMs: TEST_ABORT_GRACE_MS, - terminal: { scope: "turn", steeringSnapshotToken: staleToken }, - }), - ).resolves.toMatchObject({ status: "unfenced", reason: "unknown_run" }); - // The stale token is retired and cannot affect cleanup of the live turn. - session.discardTerminalAbortSteeringSnapshot(staleToken ?? 0); - const abortPromise = session.abortPromptAndWait(handle, { + const secondPrompt = session.prompt("second turn").catch(() => {}); + await waitFor(() => session.agent.activeResourceRunId !== undefined, "second run handle"); + const handle = session.agent.activeResourceRunId ?? "run"; + await expect( + session.abortPromptAndWait(handle, { graceMs: TEST_ABORT_GRACE_MS, - terminal: { scope: "turn" }, - }); - // `abortPromptAndWait` admits the terminal fence synchronously, but a - // foreground BashTool does not necessarily cancel its shell when the - // Agent turn is aborted. Release the controlled command after that - // admission so the test does not depend on process timing. - bashStopped.resolve(); - await abortPromise; - await secondPrompt; - await session.waitForIdle(); - await session.awaitCoordinatorRuntimeStatePersistenceForTests(); - } finally { - bashStopped.resolve(); - executeBashSpy.mockRestore(); - } + terminal: { scope: "turn", steeringSnapshotToken: staleToken }, + }), + ).resolves.toMatchObject({ status: "unfenced", reason: "unknown_run" }); + // The stale token is retired and cannot affect cleanup of the live turn. + session.discardTerminalAbortSteeringSnapshot(staleToken ?? 0); + await session.abortPromptAndWait(handle, { graceMs: TEST_ABORT_GRACE_MS, terminal: { scope: "turn" } }); + await secondPrompt; + await session.waitForIdle(); + await session.awaitCoordinatorRuntimeStatePersistenceForTests(); }, 60_000); it("terminal abort preserves a queued external follow-up through the purge and rearms it", async () => { @@ -1362,7 +1382,7 @@ describe("terminal abort registers a turn scope so left-running owned work class // independent next-root-turn request and must survive the abort // (alongside authorized owned-completion envelopes), then be rearmed // under a fresh lineage. - scriptedResponses = [bashCall("sleep 2", "call_hold_turn"), stopReply("follow-up answered")]; + setScriptedResponses([bashCall("sleep 2", "call_hold_turn"), stopReply("follow-up answered")]); const promptPromise = session.prompt("hold the turn").catch(() => {}); await waitFor(() => session.agent.activeResourceRunId !== undefined, "active run handle"); await session.followUp("external follow-up"); @@ -1523,7 +1543,7 @@ describe("terminal abort registers a turn scope so left-running owned work class try { AsyncJobManager.setInstance(foreign); const bindEndpoint = chainSessionManager.getSessionId() ?? "local"; - scriptedResponses = [bashCall("echo foreground-ok", "call_fg_endpoint", false), stopReply("done")]; + setScriptedResponses([bashCall("echo foreground-ok", "call_fg_endpoint", false), stopReply("done")]); const promptPromise = session.prompt("run foreground work").catch(() => {}); await promptPromise; // The foreground job landed in the endpoint-owned manager and its @@ -1549,22 +1569,57 @@ describe("terminal abort registers a turn scope so left-running owned work class // Reproduction of the review-thread P1 scenario: JobTool.execute and // #snapshotJobs must resolve the session's endpoint manager — a // non-global session A otherwise inspects session B's manager and - // cannot manage the job A just launched. + // cannot manage a job owned by session A. Keep manager selection and + // registration real, but gate the child execution instead of spawning a + // real shell process under shard load. const foreign = trackExtraManager(new AsyncJobManager({ maxRunningJobs: 2, onJobComplete: () => {} })); + const stopBash = Promise.withResolvers(); + let bashExecutionStarted = false; + let promptError: unknown; + const executeBashSpy = vi.spyOn(bashExecutor, "executeBash").mockImplementation(async (_command, options) => { + if (!options?.signal) throw new Error("expected endpoint-owned bash cancellation signal"); + options.signal.addEventListener("abort", () => stopBash.resolve(), { once: true }); + bashExecutionStarted = true; + await stopBash.promise; + return { + output: "job-output\n", + exitCode: undefined, + cancelled: options.signal.aborted, + truncated: false, + totalLines: 1, + totalBytes: 12, + outputLines: 1, + outputBytes: 12, + }; + }); try { AsyncJobManager.setInstance(foreign); - scriptedResponses = [bashCall("sleep 30", "call_jobtool", true), stopReply("ok")]; - const promptPromise = session.prompt("spawn job").catch(() => {}); - await waitFor(() => manager.getAllJobs().length > 0, "job registered"); + const endpointId = toolSession.getSessionId?.() ?? undefined; + expect(endpointId).toBeDefined(); + expect(AsyncJobManager.forEndpoint(endpointId)).toBe(manager); + setScriptedResponses([bashCall("sleep 30", "call_jobtool", true), stopReply("job started")]); + const promptPromise = session.prompt("spawn job").catch(error => { + promptError = error; + }); + await waitFor( + () => manager.getAllJobs().length > 0 || foreign.getAllJobs().length > 0 || promptError !== undefined, + "endpoint background job registered", + ); + if (promptError !== undefined) throw promptError; + await waitFor(() => bashExecutionStarted, "BashTool reached background execution"); await promptPromise; + const jobId = manager.getAllJobs()[0]?.id; + if (!jobId) throw new Error("expected the endpoint-owned manager to contain the Bash job"); + expect(foreign.getAllJobs()).toHaveLength(0); const jobTool = new JobTool(toolSession); - const job = manager.getAllJobs()[0]!; const listResult = await jobTool.execute("job-call", { list: true }); - expect(listResult.details?.jobs.some(snapshot => snapshot.id === job.id)).toBe(true); - const cancelResult = await jobTool.execute("job-call", { cancel: [job.id] }); + expect(listResult.details?.jobs.some(snapshot => snapshot.id === jobId)).toBe(true); + const cancelResult = await jobTool.execute("job-call", { cancel: [jobId] }); expect(cancelResult.details?.cancelled?.[0]?.status).toBe("cancelled"); - await waitFor(() => manager.getJob(job.id)?.status !== "running", "endpoint job cancelled", 5_000); + await waitFor(() => manager.getJob(jobId)?.status !== "running", "endpoint job cancelled", 5_000); } finally { + stopBash.resolve(); + executeBashSpy.mockRestore(); AsyncJobManager.setInstance(manager); } }, 20_000); diff --git a/packages/coding-agent/test/async-yield-queue.test.ts b/packages/coding-agent/test/async-yield-queue.test.ts index ab8d7818d33..505a0486d76 100644 --- a/packages/coding-agent/test/async-yield-queue.test.ts +++ b/packages/coding-agent/test/async-yield-queue.test.ts @@ -1,10 +1,22 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test, vi } from "bun:test"; +import * as path from "node:path"; import type { AgentMessage } from "@gajae-code/agent-core"; +import { getBundledModel } from "@gajae-code/ai"; import { type AsyncJob, AsyncJobManager } from "@gajae-code/coding-agent/async"; +import { Settings } from "@gajae-code/coding-agent/config/settings"; +import { type CreateAgentSessionResult, createAgentSession } from "@gajae-code/coding-agent/sdk"; +import { AuthStorage } from "@gajae-code/coding-agent/session/auth-storage"; import type { CustomMessage } from "@gajae-code/coding-agent/session/messages"; +import { SessionManager } from "@gajae-code/coding-agent/session/session-manager"; +import { + lookupOwnedRegistration, + registerOwnedRegistration, + type TurnRegistrationKey, +} from "@gajae-code/coding-agent/session/terminal-abort"; import { YieldQueue } from "@gajae-code/coding-agent/session/yield-queue"; import type { ToolSession } from "@gajae-code/coding-agent/tools"; import { JobTool } from "@gajae-code/coding-agent/tools/job"; +import { TempDir } from "@gajae-code/utils"; type AsyncEntry = { jobId: string; @@ -172,6 +184,106 @@ describe("async result yield queue delivery", () => { expect(harness.prompts[0]).toHaveLength(1); expect(asyncDetails(harness.prompts[0]![0]!).jobs.map(job => job.jobId)).toEqual([jobId]); }); + + test("acknowledgement during formatting settles only the stale owned registration", async () => { + const tempDir = TempDir.createSync("@gjc-async-yield-race-"); + const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + let created: CreateAgentSessionResult | undefined; + let staleRegistration: TurnRegistrationKey | undefined; + let liveRegistration: TurnRegistrationKey | undefined; + const formattingStarted = Promise.withResolvers(); + const releaseFormatting = Promise.withResolvers<{ id?: string; path?: string }>(); + try { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected bundled test model to exist"); + created = await createAgentSession({ + cwd: tempDir.path(), + agentDir: tempDir.path(), + sessionManager: SessionManager.inMemory(tempDir.path()), + authStorage, + settings: Settings.isolated({ "async.enabled": true, "compaction.enabled": false }), + model, + disableExtensionDiscovery: true, + extensions: [], + skills: [], + rules: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + enableMCP: false, + enableLsp: false, + notificationHostModeSupported: false, + sdkHostModeSupported: false, + }); + const manager = AsyncJobManager.instance(); + if (!manager) throw new Error("Expected the SDK session to own an async job manager"); + const endpointId = AsyncJobManager.endpointIdOf(manager); + if (!endpointId) throw new Error("Expected the async job manager endpoint to be registered"); + + vi.spyOn(created.session.sessionManager, "allocateArtifactPath").mockImplementation(async () => { + formattingStarted.resolve(); + return await releaseFormatting.promise; + }); + const staleResult = Promise.withResolvers(); + const staleJobId = manager.register("bash", "formatting race", async () => staleResult.promise); + const staleJob = manager.getJob(staleJobId); + if (!staleJob) throw new Error("Expected the formatting-race job to be registered"); + staleRegistration = { + endpointId, + endpointGeneration: 0, + lineageIdHash: "formatting-race-lineage", + promptAttemptEpoch: 1, + jobId: staleJob.id, + jobGeneration: staleJob.generation, + }; + registerOwnedRegistration(staleRegistration); + + staleResult.resolve("x".repeat(12_001)); + await formattingStarted.promise; + // The callback has passed its initial suppression check and is now + // awaiting artifact formatting. Acknowledgement suppresses the job + // before the callback can enqueue its async-result entry. + manager.acknowledgeDeliveries([staleJobId]); + + const liveResult = Promise.withResolvers(); + const liveJobId = manager.register("task", "live job", async () => liveResult.promise); + const liveJob = manager.getJob(liveJobId); + if (!liveJob) throw new Error("Expected the live job to be registered"); + liveRegistration = { + endpointId, + endpointGeneration: 0, + lineageIdHash: "live-lineage", + promptAttemptEpoch: 2, + jobId: liveJob.id, + jobGeneration: liveJob.generation, + }; + registerOwnedRegistration(liveRegistration); + + releaseFormatting.resolve({}); + await waitUntil( + () => + created!.session.yieldQueue.has("async-result") && + manager.getDeliveryState().pendingJobIds.includes(staleJobId), + "stale completion to enqueue with its retained claim", + ); + await created.session.yieldQueue.flush("streaming"); + + expect(manager.getDeliveryState().pendingJobIds).not.toContain(staleJobId); + expect(lookupOwnedRegistration(staleJob.id, staleJob.generation, endpointId)).toBeUndefined(); + expect(lookupOwnedRegistration(liveJob.id, liveJob.generation, endpointId)).toEqual(liveRegistration); + expect(manager.getJob(liveJob.id)?.status).toBe("running"); + liveResult.resolve("settle live job"); + } finally { + releaseFormatting.resolve({}); + if (staleRegistration) { + const manager = AsyncJobManager.forEndpoint(staleRegistration.endpointId); + if (manager) manager.acknowledgeDeliveries([staleRegistration.jobId]); + } + if (created) await created.session.dispose(); + authStorage.close(); + tempDir.removeSync(); + } + }); }); test("flush builds one message per groupKey origin so owned drops cannot suppress other origins", async () => { @@ -236,6 +348,371 @@ test("flush preserves the queued FIFO chronology across contiguous origin runs", expect(grouped.map(m => m.details?.jobs)).toEqual([["j-1"], ["j-2"], ["j-3"]]); }); +test("build failure requeues the failed and unbuilt groups in FIFO order", async () => { + const { queue, followUps } = createHarness(false); + const buildCalls: string[] = []; + let failB = true; + queue.register("test-build-failure", { + groupKey: value => value, + build: values => { + const [value] = values; + if (value === undefined) return null; + buildCalls.push(value); + if (value === "b" && failB) { + failB = false; + throw new Error("temporary build failure"); + } + return { + role: "custom", + customType: "async-result", + content: value, + display: true, + attribution: "agent", + details: {}, + timestamp: 1, + }; + }, + }); + queue.enqueue("test-build-failure", "a"); + queue.enqueue("test-build-failure", "b"); + queue.enqueue("test-build-failure", "c"); + + await queue.flush("streaming"); + + expect(followUps.map(message => (message as CustomMessage).content)).toEqual(["a"]); + expect(buildCalls).toEqual(["a", "b"]); + expect(queue.has("test-build-failure")).toBe(true); + + await queue.flush("streaming"); + + expect(followUps.map(message => (message as CustomMessage).content)).toEqual(["a", "b", "c"]); + expect(buildCalls).toEqual(["a", "b", "b", "c"]); +}); + +test("dispatcher build failures schedule a delayed idle retry", async () => { + const scheduled: Array<{ run: (signal?: AbortSignal) => Promise; delayMs: number | undefined }> = []; + const queue = new YieldQueue({ + isStreaming: () => false, + injectStreaming: () => {}, + injectIdle: async () => "delivered", + scheduleIdleFlush: (run, _onSkip, delayMs) => scheduled.push({ run, delayMs }), + }); + queue.register("persistent-build-failure", { + build: () => { + throw new Error("persistent build failure"); + }, + }); + queue.enqueue("persistent-build-failure", "entry"); + + expect(scheduled).toHaveLength(1); + await scheduled[0]!.run(); + + expect(scheduled).toHaveLength(2); + expect(scheduled[1]!.delayMs).toBe(1_000); + expect(queue.has("persistent-build-failure")).toBe(true); +}); + +test("stale-check failures retain the failed FIFO suffix for a delayed retry", async () => { + const scheduled: Array<{ run: (signal?: AbortSignal) => Promise; delayMs: number | undefined }> = []; + const delivered: string[][] = []; + const staleChecks: string[] = []; + let failFirstCheck = true; + const queue = new YieldQueue({ + isStreaming: () => false, + injectStreaming: () => {}, + injectIdle: async () => "delivered", + scheduleIdleFlush: (run, _onSkip, delayMs) => scheduled.push({ run, delayMs }), + }); + queue.register("stale-check-failure", { + isStale: entry => { + staleChecks.push(entry); + if (entry === "A" && failFirstCheck) throw new Error("temporary stale-check failure"); + return false; + }, + build: entries => { + delivered.push([...entries]); + return { + role: "custom", + customType: "async-result", + content: entries.join(","), + display: true, + attribution: "agent", + details: {}, + timestamp: 1, + }; + }, + }); + queue.enqueue("stale-check-failure", "A"); + queue.enqueue("stale-check-failure", "B"); + + await queue.flush("idle"); + + expect(staleChecks).toEqual(["A"]); + expect(delivered).toEqual([]); + expect(scheduled[1]?.delayMs).toBe(1_000); + expect(queue.has("stale-check-failure")).toBe(true); + await scheduled[0]!.run(); + expect(staleChecks).toEqual(["A"]); + + failFirstCheck = false; + await scheduled[1]!.run(); + + expect(staleChecks).toEqual(["A", "A", "B"]); + expect(delivered).toEqual([["A", "B"]]); + expect(queue.has("stale-check-failure")).toBe(false); +}); + +test("stale-check retry delay survives a streaming turn and preserves prefix delivery", async () => { + const scheduled: Array<{ run: (signal?: AbortSignal) => Promise; delayMs: number | undefined }> = []; + const staleChecks: string[] = []; + const deliveries: string[] = []; + let streaming = true; + let failA = true; + const queue = new YieldQueue({ + isStreaming: () => streaming, + injectStreaming: message => { + if (message.role === "custom") deliveries.push(`stream:${message.content}`); + }, + injectIdle: async messages => { + for (const message of messages) { + if (message.role === "custom") deliveries.push(`idle:${message.content}`); + } + return "delivered"; + }, + scheduleIdleFlush: (run, _onSkip, delayMs) => scheduled.push({ run, delayMs }), + }); + queue.register("streaming-stale-check-failure", { + isStale: entry => { + staleChecks.push(entry); + if (entry === "A" && failA) throw new Error("temporary stale-check failure"); + return false; + }, + build: entries => ({ + role: "custom", + customType: "async-result", + content: entries.join(","), + display: true, + attribution: "agent", + details: {}, + timestamp: 1, + }), + }); + queue.enqueue("streaming-stale-check-failure", "prefix"); + queue.enqueue("streaming-stale-check-failure", "A"); + queue.enqueue("streaming-stale-check-failure", "B"); + + await queue.flush("streaming"); + + expect(staleChecks).toEqual(["prefix", "A"]); + expect(deliveries).toEqual(["stream:prefix"]); + expect(queue.has("streaming-stale-check-failure")).toBe(true); + expect(scheduled).toHaveLength(0); + + streaming = false; + queue.rearmIdle(); + expect(scheduled[0]?.delayMs).toBe(1_000); + failA = false; + await scheduled[0]!.run(); + + expect(staleChecks).toEqual(["prefix", "A", "A", "B"]); + expect(deliveries).toEqual(["stream:prefix", "idle:A,B"]); + expect(queue.has("streaming-stale-check-failure")).toBe(false); +}); + +test("retry delay survives a scheduled wake that arrives during streaming", async () => { + const scheduled: Array<{ run: (signal?: AbortSignal) => Promise; delayMs: number | undefined }> = []; + const delivered: string[] = []; + let streaming = false; + let failBuild = true; + const queue = new YieldQueue({ + isStreaming: () => streaming, + injectStreaming: () => {}, + injectIdle: async messages => { + for (const message of messages) if (message.role === "custom") delivered.push(message.content as string); + return "delivered"; + }, + scheduleIdleFlush: (run, _onSkip, delayMs) => scheduled.push({ run, delayMs }), + }); + queue.register("scheduled-during-streaming", { + build: entries => { + if (failBuild) throw new Error("temporary build failure"); + return { + role: "custom", + customType: "async-result", + content: entries.join(","), + display: true, + attribution: "agent", + details: {}, + timestamp: 1, + }; + }, + }); + queue.enqueue("scheduled-during-streaming", "retry me"); + await queue.flush("idle"); + expect(scheduled[1]!.delayMs).toBe(1_000); + + streaming = true; + await scheduled[1]!.run(); + expect(queue.has("scheduled-during-streaming")).toBe(true); + + streaming = false; + queue.rearmIdle(); + expect(scheduled[2]!.delayMs).toBe(1_000); + failBuild = false; + await queue.flush("idle"); + await scheduled[2]!.run(); + expect(delivered).toEqual(["retry me"]); + + queue.enqueue("scheduled-during-streaming", "fresh entry"); + expect(scheduled[3]!.delayMs).toBeUndefined(); +}); + +test("successful direct retry does not delay new work enqueued during injection", async () => { + const scheduled: Array<{ run: (signal?: AbortSignal) => Promise; delayMs: number | undefined }> = []; + const injectionStarted = Promise.withResolvers(); + const releaseInjection = Promise.withResolvers(); + const delivered: string[] = []; + let streaming = false; + let failBuild = true; + const queue = new YieldQueue({ + isStreaming: () => streaming, + injectStreaming: () => {}, + injectIdle: async messages => { + for (const message of messages) if (message.role === "custom") delivered.push(message.content as string); + injectionStarted.resolve(); + await releaseInjection.promise; + return "delivered"; + }, + scheduleIdleFlush: (run, _onSkip, delayMs) => scheduled.push({ run, delayMs }), + }); + queue.register("direct-retry-success", { + build: entries => { + if (failBuild) throw new Error("temporary build failure"); + return { + role: "custom", + customType: "async-result", + content: entries.join(","), + display: true, + attribution: "agent", + details: {}, + timestamp: 1, + }; + }, + }); + queue.enqueue("direct-retry-success", "retried entry"); + await queue.flush("idle"); + expect(scheduled[1]!.delayMs).toBe(1_000); + + streaming = true; + await scheduled[1]!.run(); + streaming = false; + failBuild = false; + const directFlush = queue.flush("idle"); + await injectionStarted.promise; + queue.enqueue("direct-retry-success", "fresh entry"); + expect(scheduled[2]!.delayMs).toBeUndefined(); + releaseInjection.resolve(); + await directFlush; + await scheduled[2]!.run(); + + expect(delivered).toEqual(["retried entry", "fresh entry"]); +}); + +test("an invalidated retry onSkip cannot delay a new queue owner", async () => { + const scheduled: Array<{ + run: (signal?: AbortSignal) => Promise; + onSkip: () => void; + delayMs: number | undefined; + }> = []; + const delivered: string[] = []; + let failBuild = true; + const queue = new YieldQueue({ + isStreaming: () => false, + injectStreaming: () => {}, + injectIdle: async messages => { + for (const message of messages) if (message.role === "custom") delivered.push(message.content as string); + return "delivered"; + }, + scheduleIdleFlush: (run, onSkip, delayMs) => scheduled.push({ run, onSkip, delayMs }), + }); + queue.register("retry-owner", { + build: entries => { + if (failBuild) throw new Error("temporary build failure"); + return { + role: "custom", + customType: "async-result", + content: entries.join(","), + display: true, + attribution: "agent", + details: {}, + timestamp: 1, + }; + }, + }); + queue.enqueue("retry-owner", "old entry"); + await queue.flush("idle"); + expect(scheduled[1]!.delayMs).toBe(1_000); + + queue.clearKind("retry-owner"); + failBuild = false; + queue.enqueue("retry-owner", "fresh entry"); + expect(scheduled[2]!.delayMs).toBeUndefined(); + scheduled[1]!.onSkip(); + await scheduled[2]!.run(); + expect(delivered).toEqual(["fresh entry"]); + + queue.enqueue("retry-owner", "next entry"); + expect(scheduled[3]!.delayMs).toBeUndefined(); +}); + +test("idle injection rechecks queued identity after a transition clears the kind", async () => { + const injectionStarted = Promise.withResolvers(); + const releaseInjection = Promise.withResolvers(); + let currentIdentity = "predecessor"; + let identityCurrentAtRelease: boolean | undefined; + const delivered: string[] = []; + const dropped: string[] = []; + const queue = new YieldQueue({ + isStreaming: () => false, + captureIdentity: () => currentIdentity, + isIdentityCurrent: identity => identity === currentIdentity, + injectStreaming: () => {}, + injectIdle: async (_messages, _signal, identityIsCurrent) => { + injectionStarted.resolve(); + await releaseInjection.promise; + identityCurrentAtRelease = identityIsCurrent?.() ?? false; + return identityCurrentAtRelease ? "delivered" : "dropped"; + }, + scheduleIdleFlush: () => {}, + }); + queue.register("identity-fence", { + build: values => ({ + role: "custom", + customType: "async-result", + content: values.join("+"), + display: true, + attribution: "agent", + details: {}, + timestamp: 1, + }), + onDelivered: value => delivered.push(value), + onDrop: value => dropped.push(value), + }); + queue.enqueue("identity-fence", "predecessor result"); + + const flush = queue.flush("idle"); + await injectionStarted.promise; + currentIdentity = "successor"; + queue.clearKind("identity-fence"); + releaseInjection.resolve(); + await flush; + + expect(identityCurrentAtRelease).toBe(false); + expect(delivered).toEqual([]); + expect(dropped).toEqual(["predecessor result"]); + expect(queue.has("identity-fence")).toBe(false); +}); + test("flush without a groupKey keeps the single-batch behavior", async () => { const { queue, followUps } = createHarness(false); queue.register("test-plain", { diff --git a/packages/coding-agent/test/event-controller-abort-render.test.ts b/packages/coding-agent/test/event-controller-abort-render.test.ts index bfac7f680d8..b45381bc69b 100644 --- a/packages/coding-agent/test/event-controller-abort-render.test.ts +++ b/packages/coding-agent/test/event-controller-abort-render.test.ts @@ -10,7 +10,7 @@ * C2 errorMessage = undefined + aborted + no TTSR flag * → `streamingMessage.errorMessage` is set to "Operation aborted"; * `updateContent` receives the original message ref. - * C3 isTtsrAbortPending = true + aborted + * C3 ttsrAbort event snapshot + aborted * → `updateContent` receives a message with `stopReason: "stop"`; * `errorMessage` is NOT set (TTSR existing behavior unchanged). */ @@ -55,11 +55,7 @@ function makeAssistantMessage(overrides: Partial = {}): Assist }; } -function createFixture(opts: { - streamingMessage: AssistantMessage; - isTtsrAbortPending?: boolean; - retryAttempt?: number; -}) { +function createFixture(opts: { streamingMessage: AssistantMessage; retryAttempt?: number }) { const updateContent = vi.fn(); const setUsageInfo = vi.fn(); const streamingComponent = { updateContent, setUsageInfo }; @@ -87,9 +83,10 @@ function createFixture(opts: { streamingMessage: opts.streamingMessage, pendingTools: new Map(), session: { - isTtsrAbortPending: opts.isTtsrAbortPending ?? false, + agent: { appendMessage: vi.fn() }, retryAttempt: opts.retryAttempt ?? 0, }, + sessionManager: { appendMessage: vi.fn() }, } as unknown as InteractiveModeContext; const controller = new EventController(ctx); @@ -97,10 +94,24 @@ function createFixture(opts: { } describe("EventController #handleMessageEnd abort labeling", () => { + it("rebuilds the transcript after terminal persistence recovery", async () => { + const f = createFixture({ streamingMessage: makeAssistantMessage() }); + f.ctx.rebuildChatFromMessages = vi.fn(); + + await f.controller.handleEvent({ + type: "notice", + level: "info", + message: "Recovered interrupted assistant output into canonical session history.", + source: "terminal-persistence-recovered", + }); + + expect(f.ctx.rebuildChatFromMessages).toHaveBeenCalledWith("reconcile-same-transcript"); + }); + for (const ending of ["success", "error", "visible", "silent", "ttsr"] as const) { it(`queued text cannot supersede authoritative ${ending} finalization`, async () => { const initial = makeAssistantMessage({ stopReason: "stop", content: [] }); - const f = createFixture({ streamingMessage: initial, isTtsrAbortPending: ending === "ttsr" }); + const f = createFixture({ streamingMessage: initial }); await f.controller.handleEvent({ type: "message_start", message: initial }); const component = f.ctx.streamingComponent!; const projection = vi.spyOn(component, "updateContent"); @@ -126,7 +137,11 @@ describe("EventController #handleMessageEnd abort labeling", () => { errorMessage: ending === "silent" ? SILENT_ABORT_MARKER : ending === "error" ? "provider failed" : undefined, }); - await f.controller.handleEvent({ type: "message_end", message: final }); + await f.controller.handleEvent({ + type: "message_end", + message: final, + ...(ending === "ttsr" ? { ttsrAbort: true } : {}), + }); expect(f.preparations.size).toBe(0); expect(usage).toHaveBeenCalledTimes(1); const finalProjectionCount = projection.mock.calls.length; @@ -188,7 +203,6 @@ describe("EventController #handleMessageEnd abort labeling", () => { const message = makeAssistantMessage({ stopReason: "aborted", errorMessage: undefined }); const { controller, streamingComponent } = createFixture({ streamingMessage: message, - isTtsrAbortPending: false, }); await controller.handleEvent({ type: "message_end", message }); @@ -204,14 +218,11 @@ describe("EventController #handleMessageEnd abort labeling", () => { expect(arg.errorMessage).toBe("Operation aborted"); }); - it("C3: isTtsrAbortPending=true + aborted -> updateContent stopReason='stop', errorMessage NOT set", async () => { + it("C3: ttsrAbort event snapshot + aborted -> updateContent stopReason='stop', errorMessage NOT set", async () => { const message = makeAssistantMessage({ stopReason: "aborted", errorMessage: undefined }); - const { controller, streamingComponent } = createFixture({ - streamingMessage: message, - isTtsrAbortPending: true, - }); + const { controller, streamingComponent } = createFixture({ streamingMessage: message }); - await controller.handleEvent({ type: "message_end", message }); + await controller.handleEvent({ type: "message_end", message, ttsrAbort: true }); // TTSR keeps its existing flag-only render path — `errorMessage` stays undefined, // and the display copy gets `stopReason: "stop"`. @@ -228,7 +239,6 @@ describe("EventController #handleMessageEnd abort labeling", () => { }); const { controller, streamingComponent } = createFixture({ streamingMessage: message, - isTtsrAbortPending: false, retryAttempt: 1, }); @@ -253,7 +263,6 @@ describe("EventController #handleMessageEnd abort labeling", () => { }); const { controller, streamingComponent } = createFixture({ streamingMessage: message, - isTtsrAbortPending: false, retryAttempt: 0, }); @@ -265,4 +274,165 @@ describe("EventController #handleMessageEnd abort labeling", () => { expect(arg).toBe(message); expect(arg.errorMessage).toBe(formatted); }); + + it("orphan agent_end commits the latest partial assistant before terminal cleanup", async () => { + const initial = makeAssistantMessage({ stopReason: "stop", content: [] }); + const f = createFixture({ streamingMessage: initial }); + f.ctx.setWorkingMessage = vi.fn(); + const gate = Promise.withResolvers(); + const entered = Promise.withResolvers(); + f.ctx.planModeController = { + flushPendingModelSwitch: () => { + entered.resolve(); + return gate.promise; + }, + } as never; + let stopped = false; + f.ctx.isStopped = () => stopped; + + await f.controller.handleEvent({ type: "message_start", message: initial }); + const component = f.ctx.streamingComponent!; + const partial = makeAssistantMessage({ + stopReason: "aborted", + errorMessage: undefined, + content: [{ type: "text", text: "partial" }], + }); + await f.controller.handleEvent({ + type: "message_update", + message: partial, + assistantMessageEvent: { type: "text_delta", contentIndex: 0 }, + } as never); + + const pending = f.controller.handleEvent({ type: "agent_end", messages: [partial] } as never); + await entered.promise; + expect(f.ctx.chatContainer.hasLiveChild(component)).toBe(true); + expect(Bun.stripANSI(component.render(80).join("\n"))).toContain("partial"); + expect(partial.errorMessage).toBe("Operation aborted"); + expect(f.ctx.streamingComponent).toBeUndefined(); + expect(f.ctx.streamingMessage).toBeUndefined(); + f.captured[0]!(); + stopped = true; + gate.resolve(); + await pending; + }); + + it("force-cancelled agent_end marks an orphan provisional partial as aborted", async () => { + const initial = makeAssistantMessage({ stopReason: "stop", content: [] }); + const f = createFixture({ streamingMessage: initial }); + f.ctx.setWorkingMessage = vi.fn(); + const gate = Promise.withResolvers(); + const entered = Promise.withResolvers(); + f.ctx.planModeController = { + flushPendingModelSwitch: () => { + entered.resolve(); + return gate.promise; + }, + } as never; + let stopped = false; + f.ctx.isStopped = () => stopped; + + await f.controller.handleEvent({ type: "message_start", message: initial }); + const component = f.ctx.streamingComponent!; + const partial = makeAssistantMessage({ + stopReason: "stop", + content: [{ type: "text", text: "partial before forced cancellation" }], + }); + await f.controller.handleEvent({ + type: "message_update", + message: partial, + assistantMessageEvent: { type: "text_delta", contentIndex: 0 }, + } as never); + + const pending = f.controller.handleEvent({ type: "agent_end", messages: [], stopReason: "cancelled" } as never); + await entered.promise; + + expect(f.ctx.chatContainer.hasLiveChild(component)).toBe(true); + const rendered = Bun.stripANSI(component.render(80).join("\n")); + expect(rendered).toContain("partial before forced cancellation"); + expect(rendered).toContain("Operation aborted"); + expect(partial.stopReason).toBe("stop"); + expect(partial.errorMessage).toBeUndefined(); + expect(f.ctx.streamingComponent).toBeUndefined(); + expect(f.ctx.streamingMessage).toBeUndefined(); + stopped = true; + gate.resolve(); + await pending; + }); + + it("force-cancelled agent_end preserves pending silent-abort suppression", async () => { + const initial = makeAssistantMessage({ stopReason: "stop", content: [] }); + const f = createFixture({ streamingMessage: initial }); + f.ctx.setWorkingMessage = vi.fn(); + const gate = Promise.withResolvers(); + const entered = Promise.withResolvers(); + f.ctx.planModeController = { + flushPendingModelSwitch: () => { + entered.resolve(); + return gate.promise; + }, + } as never; + let stopped = false; + f.ctx.isStopped = () => stopped; + + await f.controller.handleEvent({ type: "message_start", message: initial }); + const component = f.ctx.streamingComponent!; + const partial = makeAssistantMessage({ + stopReason: "stop", + content: [{ type: "text", text: "silent partial before forced recovery" }], + }); + await f.controller.handleEvent({ + type: "message_update", + message: partial, + assistantMessageEvent: { type: "text_delta", contentIndex: 0 }, + } as never); + + const pending = f.controller.handleEvent({ + type: "agent_end", + messages: [], + stopReason: "cancelled", + silentAbort: true, + } as never); + await entered.promise; + + const rendered = Bun.stripANSI(component.render(80).join("\n")); + expect(rendered).toContain("silent partial before forced recovery"); + expect(rendered).not.toContain("Operation aborted"); + expect(rendered).not.toContain(SILENT_ABORT_MARKER); + expect(partial.stopReason).toBe("stop"); + expect(partial.errorMessage).toBeUndefined(); + stopped = true; + gate.resolve(); + await pending; + }); + + it("terminal persistence failure removes the uncommitted live projection", async () => { + const initial = makeAssistantMessage({ + stopReason: "stop", + content: [{ type: "text", text: "must not survive failed persistence" }], + }); + const f = createFixture({ streamingMessage: initial }); + f.ctx.setWorkingMessage = vi.fn(); + f.ctx.planModeController = { flushPendingModelSwitch: vi.fn() } as never; + f.ctx.isStopped = () => false; + f.ctx.updateEditorBorderColor = vi.fn(); + const recordVisibleTranscriptMutation = vi.fn(); + f.ctx.recordVisibleTranscriptMutation = recordVisibleTranscriptMutation; + (f.ctx.session as unknown as { isCompacting: boolean }).isCompacting = true; + f.ctx.session.getLastAssistantMessage = () => makeAssistantMessage({ stopReason: "aborted" }); + await f.controller.handleEvent({ type: "message_start", message: initial }); + const component = f.ctx.streamingComponent!; + recordVisibleTranscriptMutation.mockClear(); + + await f.controller.handleEvent({ + type: "agent_end", + messages: [], + stopReason: "cancelled", + terminalPersistenceFailed: true, + } as never); + + expect(f.ctx.chatContainer.hasLiveChild(component)).toBe(false); + expect(f.ctx.streamingComponent).toBeUndefined(); + expect(f.ctx.streamingMessage).toBeUndefined(); + expect(recordVisibleTranscriptMutation).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/coding-agent/test/gjc-plugin-tool-refresh.test.ts b/packages/coding-agent/test/gjc-plugin-tool-refresh.test.ts index ade8363141b..a727c760a2d 100644 --- a/packages/coding-agent/test/gjc-plugin-tool-refresh.test.ts +++ b/packages/coding-agent/test/gjc-plugin-tool-refresh.test.ts @@ -34,7 +34,7 @@ async function writeCustomTool(fileName: string, toolName: string): Promise { }); describe("AgentSession GJC plugin sub-skill tool refresh", () => { - test("adds and removes sub-skill tools as the active phase changes", async () => { + test("does not publish tools from a refresh superseded by same-session deactivation", async () => { + const toolPath = await writeCustomTool("stale-domain-note.ts", "stale_domain_note"); + const started = Promise.withResolvers(); + const release = Promise.withResolvers(); + const gateKey = "__gjcSubskillRefreshGate"; + Object.assign(globalThis, { [gateKey]: { started: started.resolve, promise: release.promise } }); + await Bun.write( + toolPath, + `import type { CustomToolFactory } from "@gajae-code/coding-agent/extensibility/custom-tools/types"; +const gate = (globalThis as unknown as { __gjcSubskillRefreshGate: { started(): void; promise: Promise } }).__gjcSubskillRefreshGate; +gate.started(); +await gate.promise; +const factory: CustomToolFactory = pi => ({ + name: "stale_domain_note", + label: "stale_domain_note", + description: "stale refresh fixture tool", + parameters: pi.zod.object({}), + async execute() { return { content: [{ type: "text", text: "stale" }] }; }, +}); +export default factory; +`, + ); + try { + await activateSubskill([toolPath], "planner"); + const staleRefresh = session.refreshGjcSubskillTools(); + await started.promise; + await syncSkillActiveState({ + cwd: tempDir.path(), + skill: "ralplan", + active: false, + phase: "planner", + sessionId: sessionManager.getSessionId(), + active_subskills: [], + }); + await session.refreshGjcSubskillTools(); + release.resolve(); + await staleRefresh; + + expect(session.getAllToolNames()).not.toContain("stale_domain_note"); + expect(session.getActiveToolNames()).toEqual(["read", "bash"]); + } finally { + delete (globalThis as { __gjcSubskillRefreshGate?: unknown }).__gjcSubskillRefreshGate; + release.resolve(); + } + }); + + test("adds, removes, and identically reactivates sub-skill tools", async () => { const toolPath = await writeCustomTool("domain-note.ts", "domain_note"); await activateSubskill([toolPath], "planner"); @@ -140,7 +186,7 @@ describe("AgentSession GJC plugin sub-skill tool refresh", () => { await syncSkillActiveState({ cwd: tempDir.path(), skill: "ralplan", - active: true, + active: false, phase: "critic", sessionId: sessionManager.getSessionId(), active_subskills: [], @@ -150,6 +196,11 @@ describe("AgentSession GJC plugin sub-skill tool refresh", () => { expect(session.getAllToolNames()).not.toContain("domain_note"); expect(session.getActiveToolNames()).not.toContain("domain_note"); expect(session.getActiveToolNames()).toEqual(["read", "bash"]); + + await activateSubskill([toolPath], "planner"); + await session.refreshGjcSubskillTools(); + expect(session.getAllToolNames()).toContain("domain_note"); + expect(session.getActiveToolNames()).toContain("domain_note"); }); test("rejects sub-skill tools whose names conflict with existing tools", async () => { diff --git a/packages/coding-agent/test/gjc-runtime/state-writer-cas.test.ts b/packages/coding-agent/test/gjc-runtime/state-writer-cas.test.ts index 95216d4c0bd..a3d34295404 100644 --- a/packages/coding-agent/test/gjc-runtime/state-writer-cas.test.ts +++ b/packages/coding-agent/test/gjc-runtime/state-writer-cas.test.ts @@ -246,6 +246,31 @@ describe("state-writer concurrency (issue #646)", () => { // flight, so the observed peak concurrency stays at 1. expect(maxActive).toBe(1); }); + it("does not recreate a missing parent when a read-side workflow lock requires existing directories", async () => { + const root = await tempDir(); + const gjcRoot = path.join(root, ".gjc"); + await fs.mkdir(gjcRoot, { mode: 0o700 }); + + const missingDirectory = path.join(gjcRoot, "read-only-missing"); + const target = path.relative(root, path.join(missingDirectory, "state.json")); + await expect( + withWorkflowStateLock(target, async () => {}, { cwd: root, createMissingParents: false }), + ).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fs.lstat(missingDirectory)).rejects.toMatchObject({ code: "ENOENT" }); + + if (process.platform === "linux") { + const privateDirectory = path.join(gjcRoot, "private-read-only-missing"); + const privateTarget = path.relative(root, path.join(privateDirectory, "state.json")); + await expect( + withWorkflowStateLock(privateTarget, async () => {}, { + cwd: root, + createMissingParents: false, + privateDurable: { directory: path.dirname(privateTarget) }, + }), + ).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fs.lstat(privateDirectory)).rejects.toMatchObject({ code: "ENOENT" }); + } + }); it("returns the lock-owned stamped workflow envelope without rereading the file", async () => { const root = await tempDir(); const target = path.relative(root, path.join(sessionStateDir(root, "test-session"), "stamped-probe.json")); diff --git a/packages/coding-agent/test/gjc-skill-state-hooks.test.ts b/packages/coding-agent/test/gjc-skill-state-hooks.test.ts index d9944157d5a..ec39c1d8b81 100644 --- a/packages/coding-agent/test/gjc-skill-state-hooks.test.ts +++ b/packages/coding-agent/test/gjc-skill-state-hooks.test.ts @@ -9,6 +9,7 @@ import { runNativeRalplanCommand } from "../src/gjc-runtime/ralplan-runtime"; import { activeEntryPath, activeSnapshotPath, + activeStateDir, modeStatePath, sessionSpecsDir, sessionStateDir, @@ -17,7 +18,9 @@ import { reconcileWorkflowSkillState } from "../src/gjc-runtime/state-runtime"; import { RequiredOnWriteEnvelopeSchema } from "../src/gjc-runtime/state-schema"; import { detectWorkflowEnvelopeIntegrityMismatch, + mergeActiveEntrySubskills, readActiveEntries, + removeActiveEntry, writeActiveEntry, writeGuardedJsonAtomic, writeGuardedWorkflowEnvelopeAtomic, @@ -478,6 +481,55 @@ describe("GJC native skill-state hooks", () => { await expect(readVisibleSkillActiveState(root, "test-session")).resolves.toMatchObject(state); }); + it("does not resurrect a stale snapshot after authoritative entries are cleared", async () => { + const root = await cwd(); + const sessionId = "test-cleared-authoritative-entries"; + const stateDir = sessionStateDir(root, sessionId); + await fs.mkdir(activeStateDir(root, sessionId), { recursive: true }); + await Bun.write( + path.join(stateDir, "skill-active-state.json"), + JSON.stringify({ + version: 1, + active: true, + skill: "ultragoal", + active_skills: [{ skill: "ultragoal", active: true, phase: "executing", session_id: sessionId }], + }), + ); + + await expect( + activeStateModule.readVisibleSkillActiveState(root, sessionId, { bypassCache: true }), + ).resolves.toBeNull(); + + await writeActiveEntry( + root, + { sessionId }, + "deep-interview", + { + skill: "deep-interview", + active: true, + phase: "interviewing", + session_id: sessionId, + }, + { cwd: root }, + ); + await expect( + activeStateModule.readVisibleSkillActiveState(root, sessionId, { bypassCache: true }), + ).resolves.toMatchObject({ + skill: "deep-interview", + phase: "interviewing", + active_skills: [expect.objectContaining({ skill: "deep-interview" })], + }); + + await activeStateModule.applyHandoffToActiveState({ + cwd: root, + caller: { cwd: root, skill: "deep-interview", active: false, phase: "handoff", sessionId }, + callee: { cwd: root, skill: "ralplan", active: true, phase: "planner", sessionId }, + }); + const entries = await readActiveEntries(root, { sessionId }); + expect(entries.map(entry => entry.skill).sort()).toEqual(["deep-interview", "ralplan"]); + expect(entries.some(entry => entry.skill === "ultragoal")).toBe(false); + }); + it("fails open and logs when custom skill-active state is corrupt", async () => { const root = await cwd(); const stateDir = sessionStateDir(root, "test-session"); @@ -1960,6 +2012,43 @@ disabledExtensions: expect(entry?.handoff_from).toBe("deep-interview"); }); + it("persists an explicit empty active-subskill list", async () => { + const root = await cwd(); + const sessionId = "session-clear-subskills"; + const activeSubskills = [ + { + plugin: "gjc", + subskillName: "ralplan", + parent: "deep-interview", + bindsTo: "session", + phase: "planner", + activationArg: "", + }, + ]; + + await ensureWorkflowSkillActivationSeed({ + cwd: root, + skill: "deep-interview", + sessionId, + activeSubskills, + }); + const cleared = await ensureWorkflowSkillActivationSeed({ + cwd: root, + skill: "deep-interview", + sessionId, + activeSubskills: [], + }); + + expect(cleared.seeded).toBe(true); + expect(cleared.state?.active_skills?.find(entry => entry.skill === "deep-interview")?.active_subskills).toEqual( + [], + ); + expect( + (await readActiveEntries(root, { sessionId })).find(entry => entry.skill === "deep-interview") + ?.active_subskills, + ).toEqual([]); + }); + it("activation rollback preserves successor mode, entry, and rebuilt snapshot state", async () => { const root = await cwd(); const sessionId = "session-seed-owned"; @@ -2015,6 +2104,37 @@ disabledExtensions: expect(rebuiltSnapshot.phase).toBe("requirements"); }); + it("does not recreate an active entry removed before a subskill merge", async () => { + const root = await cwd(); + const sessionId = "session-subskill-removal-race"; + await ensureWorkflowSkillActivationSeed({ cwd: root, skill: "deep-interview", sessionId }); + const [expected] = await readActiveEntries(root, { sessionId }); + if (!expected) throw new Error("Expected seeded active entry"); + await removeActiveEntry(root, { sessionId }, "deep-interview"); + + const merged = await mergeActiveEntrySubskills( + root, + { sessionId }, + "deep-interview", + expected, + [ + { + plugin: "gjc", + subskillName: "ralplan", + parent: "deep-interview", + bindsTo: "session", + phase: "planner", + activationArg: "", + }, + ], + "2099-01-01T00:00:00.000Z", + ); + + expect(merged.result).toMatchObject({ written: false, reason: "stale-skip" }); + expect(merged.predecessor).toBeUndefined(); + expect(await readActiveEntries(root, { sessionId })).toEqual([]); + }); + it("seeds ralplan repository binding for the first explicit-target role write", async () => { const root = await cwd(); await initGitRepo(root); diff --git a/packages/coding-agent/test/input-controller-keybindings.test.ts b/packages/coding-agent/test/input-controller-keybindings.test.ts index 9ed2eea3597..8240e38253d 100644 --- a/packages/coding-agent/test/input-controller-keybindings.test.ts +++ b/packages/coding-agent/test/input-controller-keybindings.test.ts @@ -512,6 +512,9 @@ describe("InputController keybinding setup", () => { expect(set).toHaveBeenCalledWith("hideThinkingBlock", true); expect(setThinkingVisibility).toHaveBeenCalledWith("hidden"); expect(set.mock.invocationCallOrder[0]).toBeLessThan(setThinkingVisibility.mock.invocationCallOrder[0]); + expect(ctx.rebuildChatFromMessages).toHaveBeenCalledWith("reconcile-same-transcript"); + expect(ctx.chatContainer.detachChild).not.toHaveBeenCalled(); + expect(ctx.chatContainer.addChild).not.toHaveBeenCalled(); } finally { set.mockRestore(); resetSettingsForTest(); diff --git a/packages/coding-agent/test/interactive-mode-background-activity.test.ts b/packages/coding-agent/test/interactive-mode-background-activity.test.ts index 59cabef1ff7..eadd89ee4db 100644 --- a/packages/coding-agent/test/interactive-mode-background-activity.test.ts +++ b/packages/coding-agent/test/interactive-mode-background-activity.test.ts @@ -24,7 +24,7 @@ import { import { AuthStorage } from "@gajae-code/coding-agent/session/auth-storage"; import { SessionManager } from "@gajae-code/coding-agent/session/session-manager"; import { EventBus } from "@gajae-code/coding-agent/utils/event-bus"; -import { Container, Loader } from "@gajae-code/tui"; +import { Container, Loader, Text } from "@gajae-code/tui"; import { logger, postmortem, TempDir } from "@gajae-code/utils"; import * as z from "zod/v4"; import { VirtualTerminal } from "../../tui/test/virtual-terminal"; @@ -76,6 +76,7 @@ describe("interactive background activity indicator", () => { manager = new AsyncJobManager({ onJobComplete: () => {}, retentionMs: 60_000 }); AsyncJobManager.setInstance(manager); mode = new InteractiveMode(session, "test"); + mode.ui.terminal = new VirtualTerminal(100, 30); await mode.init(); }); @@ -438,6 +439,56 @@ describe("interactive background activity indicator", () => { expect(quit).toHaveBeenCalledWith(0); }); + it("commits the final forced frame before terminal restoration when raster ingress settles", async () => { + const terminal = mode.ui.terminal as VirtualTerminal; + const lease = await mode.ui.acquireRasterLease({ + ownerId: "shutdown-held-raster", + rect: { column: 0, row: 0, width: 2, height: 1 }, + erase: { type: "raster-erase", bytes: new TextEncoder().encode("SHUTDOWN_ERASE") }, + }); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + const ingressGate = Promise.withResolvers(); + const ingressStarted = Promise.withResolvers(); + const held = mode.ui.submitTerminalOutput({ + token: lease.token, + operation: { + type: "raster-multipart-batch", + prefix: new TextEncoder().encode("SHUTDOWN_PREFIX"), + afterPrefix: async () => { + ingressStarted.resolve(); + await ingressGate.promise; + return true; + }, + records: [new TextEncoder().encode("SHUTDOWN_RASTER")], + abortSuffix: new TextEncoder().encode("SHUTDOWN_ABORT"), + }, + }); + const finalMarker = "FINAL_SHUTDOWN_MARKER"; + mode.statusContainer.addChild(new Text(finalMarker, 0, 0)); + const forcedRender = vi.spyOn(mode.ui, "requestRenderWithGeneration"); + const quit = vi.spyOn(postmortem, "quit").mockResolvedValue(undefined); + try { + await ingressStarted.promise; + terminal.clearWriteLog(); + const shutdown = mode.shutdown(); + await waitFor(() => forcedRender.mock.calls.some(([, source]) => source === "shutdown")); + ingressGate.resolve(); + expect((await held).status).toBe("written"); + await shutdown; + const writes = terminal.getWriteLog(); + const markerIndex = writes.findIndex(write => write.includes(finalMarker)); + const restorationIndex = writes.findIndex(write => write.includes("\x1b[?2004l")); + expect(markerIndex).toBeGreaterThanOrEqual(0); + expect(restorationIndex).toBeGreaterThan(markerIndex); + expect(quit).toHaveBeenCalledWith(0); + } finally { + ingressGate.resolve(); + await held; + forcedRender.mockRestore(); + quit.mockRestore(); + } + }); + it.each([ "compaction", "retry", diff --git a/packages/coding-agent/test/interactive-mode-editor-component.test.ts b/packages/coding-agent/test/interactive-mode-editor-component.test.ts index 4f98445bd2e..184f8014093 100644 --- a/packages/coding-agent/test/interactive-mode-editor-component.test.ts +++ b/packages/coding-agent/test/interactive-mode-editor-component.test.ts @@ -18,6 +18,7 @@ import type { ExtensionContextActions, ExtensionUIContext, } from "../src/extensibility/extensions"; +import { AssistantMessageComponent } from "../src/modes/components/assistant-message"; import { CustomEditor } from "../src/modes/components/custom-editor"; import { computeIrcWorkLaneWidths, IrcSplitViewComponent } from "../src/modes/components/irc-sidebar"; import { resolveWelcomeIntroTickMs, WelcomeComponent } from "../src/modes/components/welcome"; @@ -26,7 +27,12 @@ import { SelectorController } from "../src/modes/controllers/selector-controller import { InteractiveMode } from "../src/modes/interactive-mode"; import { AgentSession } from "../src/session/agent-session"; import { AuthStorage } from "../src/session/auth-storage"; -import { associateSessionMessageEntryId, type SessionContext, SessionManager } from "../src/session/session-manager"; +import { + associateSessionMessageEntryId, + type SessionContext, + SessionManager, + transferSessionMessageIdentity, +} from "../src/session/session-manager"; class TestModalEditor extends CustomEditor {} function stripRenderControls(line: string): string { @@ -122,6 +128,122 @@ describe("InteractiveMode.setEditorComponent", () => { expect(reconcile).toHaveBeenCalledTimes(1); }); + it("preserves a live assistant through the tree-navigation initial rebuild path", () => { + const message: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "live before tree navigation" }], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5", + stopReason: "stop", + timestamp: Date.now(), + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }; + const component = new AssistantMessageComponent(message); + const dispose = vi.spyOn(component, "dispose"); + mode.streamingMessage = message; + mode.streamingComponent = component; + mode.chatContainer.addChild(component); + + mode.rebuildInitialMessages("reconcile-same-transcript", { + messages: [], + thinkingLevel: "off", + serviceTier: undefined, + models: {}, + configuredModelChains: {}, + injectedTtsrRules: [], + selectedMCPToolNames: [], + hasPersistedMCPToolSelection: false, + mode: "none", + }); + + expect(dispose).not.toHaveBeenCalled(); + expect(mode.chatContainer.hasLiveChild(component)).toBe(true); + expect(mode.streamingComponent).toBe(component); + expect(mode.streamingMessage).toBe(message); + component.updateContent( + { ...message, content: [{ type: "text", text: "live after tree navigation" }] }, + { streaming: true }, + ); + expect(Bun.stripANSI(component.render(80).join("\n"))).toContain("live after tree navigation"); + }); + + it("rebuilds a committed orphan assistant from the session transcript", () => { + mode.renderInitialMessages(); + const message: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "orphan survives later reconcile" }], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5", + stopReason: "aborted", + errorMessage: "Operation aborted", + timestamp: Date.now(), + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }; + session.sessionManager.appendMessage(message); + session.agent.appendMessage(message); + + mode.rebuildChatFromMessages("reconcile-same-transcript"); + + const rendered = Bun.stripANSI(mode.chatContainer.render(100).join("\n")); + expect(rendered).toContain("orphan survives later reconcile"); + expect(rendered).toContain("Operation aborted"); + }); + + it("does not restore a live assistant after canonical orphan persistence", () => { + mode.renderInitialMessages(); + const committed: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "canonical orphan exactly once" }], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5", + stopReason: "aborted", + errorMessage: "Operation aborted", + timestamp: Date.now(), + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }; + const liveProjection = { ...committed, stopReason: "stop" as const }; + session.sessionManager.appendMessage(committed); + session.agent.appendMessage(committed); + transferSessionMessageIdentity([committed], [liveProjection]); + const component = new AssistantMessageComponent(liveProjection); + const dispose = vi.spyOn(component, "dispose"); + mode.streamingMessage = liveProjection; + mode.streamingComponent = component; + mode.chatContainer.addChild(component); + + mode.rebuildChatFromMessages("reconcile-same-transcript"); + + const rendered = Bun.stripANSI(mode.chatContainer.render(100).join("\n")); + expect(rendered.match(/canonical orphan exactly once/g)).toHaveLength(1); + expect(dispose).toHaveBeenCalledTimes(1); + expect(mode.streamingComponent).toBeUndefined(); + expect(mode.streamingMessage).toBeUndefined(); + }); + it("does not spend the iTerm pet warning deadline during pre-start initialization", async () => { const originalProtocol = TERMINAL.imageProtocol; const envKeys = [ diff --git a/packages/coding-agent/test/modes/components/iterm-pet-transport.test.ts b/packages/coding-agent/test/modes/components/iterm-pet-transport.test.ts index d16c87bdb09..5d7276cffae 100644 --- a/packages/coding-agent/test/modes/components/iterm-pet-transport.test.ts +++ b/packages/coding-agent/test/modes/components/iterm-pet-transport.test.ts @@ -5,6 +5,8 @@ import { ItermPetTransport, isItermCandidate, type NativePetUi, + type PetTmuxResult, + type PetTmuxRunner, type PetTransportClock, } from "@gajae-code/coding-agent/modes/components/iterm-pet-transport"; @@ -58,6 +60,53 @@ async function waitFor(predicate: () => boolean): Promise { throw new Error("condition did not become true within 200 microtasks"); } +function makeRefreshFixture(tmux: PetTmuxRunner, transportClock = clock) { + const input = new Input(); + const transport = new ItermPetTransport({ + mode: "managed", + clock: transportClock, + input, + output: { write: async () => ({ status: "written" as const }) }, + tmux, + paneId: "%1", + expectedClientId: "@1", + topology: async () => ({ clients: 1, paneId: "%1", ownedPaneId: "%1", clientId: "@1" }), + }); + return { + input, + transport, + async ready() { + const probe = transport.inspectManagedTopology(); + await waitFor(() => input.listeners.size === 1); + input.send("\x1b]1337;Capabilities=F\x07"); + await probe; + }, + }; +} + +function pendingCursorCommand(started: { resolve: () => void }, aborted: { resolve: () => void }) { + let commandSignal: AbortSignal | undefined; + const tmux: PetTmuxRunner = async (argv, signal) => { + if (argv[0] === "show-options" && argv.includes("-q")) return { status: 0, stdout: "" }; + if (argv[0] === "show-options" && argv.includes("-A")) return { status: 0, stdout: "on" }; + if (argv[0] === "set-option") return { status: 0, stdout: "" }; + if (argv[0] === "display-message") { + commandSignal = signal; + started.resolve(); + const result = Promise.withResolvers(); + const onAbort = () => { + aborted.resolve(); + result.resolve({ status: 1, stdout: "" }); + }; + if (signal?.aborted) onAbort(); + else signal?.addEventListener("abort", onAbort, { once: true }); + return result.promise; + } + return { status: 0, stdout: "" }; + }; + return { tmux, getCommandSignal: () => commandSignal }; +} + const nativeUi = { drainInput: async () => {}, addInputListener: () => () => {}, @@ -156,6 +205,58 @@ describe("managed iTerm Pet topology revocation", () => { }); }); +describe("managed iTerm Pet cursor refresh cancellation", () => { + it("aborts a pending cursor command when its TUI lifecycle ends", async () => { + const commandStarted = Promise.withResolvers(); + const commandAborted = Promise.withResolvers(); + const command = pendingCursorCommand(commandStarted, commandAborted); + const fixture = makeRefreshFixture(command.tmux); + await fixture.ready(); + + const lifecycle = new AbortController(); + const refresh = fixture.transport.refreshManagedClient(1, 2, lifecycle.signal); + await commandStarted.promise; + lifecycle.abort(); + + expect(await refresh).toBe(false); + await commandAborted.promise; + expect(command.getCommandSignal()?.aborted).toBe(true); + await fixture.transport.dispose(); + }); + + it("aborts a pending cursor command when the refresh deadline expires", async () => { + const timers = new Map void; ms: number }>(); + let nextTimerId = 0; + const timeoutClock: PetTransportClock = { + now: () => 0, + setTimeout: (callback, ms) => { + const id = ++nextTimerId; + timers.set(id, { callback, ms }); + return id; + }, + clearTimeout: handle => { + if (typeof handle === "number") timers.delete(handle); + }, + }; + const commandStarted = Promise.withResolvers(); + const commandAborted = Promise.withResolvers(); + const command = pendingCursorCommand(commandStarted, commandAborted); + const fixture = makeRefreshFixture(command.tmux, timeoutClock); + await fixture.ready(); + + const refresh = fixture.transport.refreshManagedClient(1, 2); + await commandStarted.promise; + const timeout = [...timers.values()].find(timer => timer.ms === 250); + if (!timeout) throw new Error("managed cursor refresh timeout was not scheduled"); + timeout.callback(); + + expect(await refresh).toBe(false); + await commandAborted.promise; + expect(command.getCommandSignal()?.aborted).toBe(true); + await fixture.transport.dispose(); + }); +}); + describe("capability input fragmentation", () => { it.each([ ["immediately after the ESC byte", "abcde\x1b", "]1337;Capabilities=F\x07", "abcde"], diff --git a/packages/coding-agent/test/modes/controllers/event-controller-text-coalescing.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-text-coalescing.test.ts index 5d3c384fe05..e0fa10cf54d 100644 --- a/packages/coding-agent/test/modes/controllers/event-controller-text-coalescing.test.ts +++ b/packages/coding-agent/test/modes/controllers/event-controller-text-coalescing.test.ts @@ -91,7 +91,8 @@ function fixture(real = false) { chatContainer, pendingTools: new Map(), settings: { get: () => true }, - session: {}, + session: { agent: { appendMessage: vi.fn() } }, + sessionManager: { appendMessage: vi.fn() }, recordVisibleTranscriptMutation: vi.fn(), } as unknown as InteractiveModeContext; const controller = new EventController(ctx); @@ -444,7 +445,7 @@ describe("assistant text frame preparation", () => { expect(f.queued.size).toBe(0); }); - it("flushes valid orphan text before agent_end removes the live component", async () => { + it("commits valid orphan text before agent_end retains the live component", async () => { const f = fixture(); await f.controller.handleEvent({ type: "message_start", message: message("") }); const component = f.ctx.streamingComponent!; @@ -474,11 +475,13 @@ describe("assistant text frame preparation", () => { await f.update(message("orphan latest")); const pending = f.controller.handleEvent({ type: "agent_end", messages: [] } as never); await entered.promise; - expect(order).toEqual(["project", "remove"]); + expect(order).toEqual(["project", "project"]); + expect(f.ctx.chatContainer.hasLiveChild(component)).toBe(true); expect(f.ctx.streamingMessage).toBeUndefined(); expect(f.ctx.streamingComponent).toBeUndefined(); f.captured[0]!(); - expect(order).toEqual(["project", "remove"]); + expect(order).toEqual(["project", "project"]); + expect(Bun.stripANSI(component.render(80).join("\n"))).toContain("orphan latest"); stopped = true; gate.resolve(); await pending; @@ -536,6 +539,34 @@ describe("assistant text frame preparation", () => { expect(projection).toHaveBeenLastCalledWith(latest, { streaming: true }); }); + it("rebinds the live assistant across a reconcile rebuild while text is pending", async () => { + const f = fixture(); + await f.controller.handleEvent({ type: "message_start", message: message("") }); + const component = f.ctx.streamingComponent!; + const projection = vi.spyOn(component, "updateContent"); + await f.update(message("before reconcile")); + const stale = f.captured[0]!; + + // InteractiveMode.rebuildChatFromMessages("reconcile-same-transcript") + // detaches this live owner before clearing the historical children, then + // reattaches it and rebinds the controller epoch. + f.ctx.chatContainer.detachChild(component); + f.controller.resetAssistantTextPresentation(); + f.ctx.chatContainer.clear(); + f.ctx.chatContainer.addChild(component); + f.controller.rebindAssistantTextPresentation(); + const latest = message("after reconcile"); + await f.update(latest); + stale(); + + expect(f.ctx.chatContainer.hasLiveChild(component)).toBe(true); + expect(f.queued.size).toBe(1); + f.drain(); + expect(projection).toHaveBeenCalledTimes(1); + expect(projection).toHaveBeenLastCalledWith(latest, { streaming: true }); + expect(f.ctx.streamingComponent).toBe(component); + }); + for (const invalidation of ["reset", "dispose", "session", "remove"] as const) { it(`rejects stale captured work after ${invalidation}`, async () => { const f = fixture(); @@ -561,9 +592,27 @@ describe("assistant text frame preparation", () => { }); } - for (const duringStop of ["none", "delta", "final", "reset", "dispose"] as const) { + for (const duringStop of ["none", "delta", "final", "orphan-end", "reset", "dispose"] as const) { it(`real TUI restart reconstructs current text: ${duringStop}`, async () => { const f = fixture(true); + if (duringStop === "orphan-end") { + f.ctx.setWorkingMessage = vi.fn(); + f.ctx.updateEditorBorderColor = vi.fn(); + f.ctx.editor = { getText: () => "" } as never; + f.ctx.session = { + isCompacting: false, + isStreaming: false, + getLastAssistantMessage: vi.fn(() => undefined), + agent: { appendMessage: vi.fn(), state: { messages: [] } }, + } as never; + f.ctx.sessionManager = { + appendMessage: vi.fn(), + getSessionName: vi.fn(() => ""), + getCwd: vi.fn(() => process.cwd()), + } as never; + f.ctx.planModeController = { flushPendingModelSwitch: vi.fn(async () => {}) } as never; + vi.spyOn(f.controller, "sendCompletionNotification").mockImplementation(() => {}); + } try { f.tui.start(); await f.terminal.waitForRender(); @@ -575,6 +624,12 @@ describe("assistant text frame preparation", () => { if (duringStop === "delta") await f.update(message("latest during stop")); if (duringStop === "final") await f.controller.handleEvent({ type: "message_end", message: message("authoritative final") }); + if (duringStop === "orphan-end") { + const revision = vi.spyOn(f.ctx, "recordVisibleTranscriptMutation"); + revision.mockClear(); + await f.controller.handleEvent({ type: "agent_end", messages: [] } as never); + expect(revision).toHaveBeenCalledTimes(1); + } if (duringStop === "reset") f.controller.resetAssistantTextPresentation(); if (duringStop === "dispose") f.controller.dispose(); projection.mockClear(); @@ -585,6 +640,9 @@ describe("assistant text frame preparation", () => { expect(f.terminal.getWriteLog().join("")).toContain( duringStop === "none" ? "queued before stop" : "latest during stop", ); + } else if (duringStop === "orphan-end") { + expect(projection).not.toHaveBeenCalled(); + expect(f.terminal.getWriteLog().join("")).toContain("queued before stop"); } else expect(projection).not.toHaveBeenCalled(); } finally { f.tui.setRenderPreparationLifecycleCallbacks(undefined); diff --git a/packages/coding-agent/test/modes/controllers/event-controller-viewport-output-revision.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-viewport-output-revision.test.ts index 12400896f4b..cef230e89a6 100644 --- a/packages/coding-agent/test/modes/controllers/event-controller-viewport-output-revision.test.ts +++ b/packages/coding-agent/test/modes/controllers/event-controller-viewport-output-revision.test.ts @@ -43,8 +43,8 @@ function createContext(handle: ToolExecutionHandle): { settings: { get: () => true }, toolOutputExpanded: false, chatContainer: new Container(), - session: { getToolByName: vi.fn() }, - sessionManager: { getCwd: vi.fn(() => process.cwd()) }, + session: { getToolByName: vi.fn(), agent: { appendMessage: vi.fn() } }, + sessionManager: { getCwd: vi.fn(() => process.cwd()), appendMessage: vi.fn() }, } as unknown as InteractiveModeContext; return { ctx, @@ -401,6 +401,93 @@ describe("EventController viewport output revision", () => { expect(handle.consumeVisibleTranscriptChange).toHaveBeenCalledTimes(1); }); + it("retains orphan assistant output and publishes its committed viewport revision", async () => { + await Settings.init({ inMemory: true }); + const handle: ToolExecutionHandle = { + updateArgs: vi.fn(), + updateResult: vi.fn(), + setArgsComplete: vi.fn(), + setExpanded: vi.fn(), + }; + const { ctx, recordVisibleTranscriptMutation } = createContext(handle); + ctx.pendingTools.clear(); + ctx.setWorkingMessage = vi.fn(); + ctx.updateEditorBorderColor = vi.fn(); + ctx.editor = { getText: () => "" } as never; + ctx.session = { + isCompacting: false, + isStreaming: false, + getLastAssistantMessage: vi.fn(() => undefined), + agent: { appendMessage: vi.fn(), state: { messages: [] } }, + } as never; + ctx.sessionManager = { + appendMessage: vi.fn(), + getSessionName: vi.fn(() => ""), + getCwd: vi.fn(() => process.cwd()), + } as never; + ctx.planModeController = { flushPendingModelSwitch: vi.fn(async () => {}) } as never; + const controller = new EventController(ctx); + vi.spyOn(controller, "sendCompletionNotification").mockImplementation(() => {}); + + await controller.handleEvent({ type: "message_start", message: assistantMessage("") }); + const component = ctx.streamingComponent!; + const partial = assistantMessage("orphan partial output"); + await controller.handleEvent({ + type: "message_update", + message: partial, + assistantMessageEvent: { type: "text_delta", contentIndex: 0 }, + } as never); + expect(recordVisibleTranscriptMutation).not.toHaveBeenCalled(); + + await controller.handleEvent({ type: "agent_end", messages: [] } as never); + + expect(ctx.chatContainer.hasLiveChild(component)).toBe(true); + expect(Bun.stripANSI(component.render(80).join("\n"))).toContain("orphan partial output"); + expect(ctx.streamingComponent).toBeUndefined(); + expect(recordVisibleTranscriptMutation).toHaveBeenCalledTimes(1); + }); + + it("reconciles a pending assistant delta onto the reattached owner and advances one revision", async () => { + await Settings.init({ inMemory: true }); + const handle: ToolExecutionHandle = { + updateArgs: vi.fn(), + updateResult: vi.fn(), + setArgsComplete: vi.fn(), + setExpanded: vi.fn(), + }; + const { ctx, drain, recordVisibleTranscriptMutation } = createContext(handle); + ctx.pendingTools.clear(); + const controller = new EventController(ctx); + await controller.handleEvent({ type: "message_start", message: assistantMessage("") }); + const component = ctx.streamingComponent!; + const projection = vi.spyOn(component, "updateContent"); + await controller.handleEvent({ + type: "message_update", + message: assistantMessage("before reconcile"), + assistantMessageEvent: { type: "text_delta", contentIndex: 0 }, + } as never); + recordVisibleTranscriptMutation.mockClear(); + ctx.chatContainer.detachChild(component); + controller.resetAssistantTextPresentation(); + ctx.chatContainer.clear(); + ctx.chatContainer.addChild(component); + controller.rebindAssistantTextPresentation(); + const latest = assistantMessage("after reconcile"); + await controller.handleEvent({ + type: "message_update", + message: latest, + assistantMessageEvent: { type: "text_delta", contentIndex: 0 }, + } as never); + + drain(); + + expect(ctx.chatContainer.hasLiveChild(component)).toBe(true); + expect(projection).toHaveBeenCalledTimes(1); + expect(projection).toHaveBeenLastCalledWith(latest, { streaming: true }); + expect(Bun.stripANSI(component.render(80).join("\n"))).toContain("after reconcile"); + expect(recordVisibleTranscriptMutation).toHaveBeenCalledTimes(1); + }); + it("does not revise for a synchronous no-op projection", async () => { const handle: ToolExecutionHandle = { updateArgs: vi.fn(), diff --git a/packages/coding-agent/test/sdk-managed-task-dag.test.ts b/packages/coding-agent/test/sdk-managed-task-dag.test.ts index 9e28d18d0c2..7a31e4adc08 100644 --- a/packages/coding-agent/test/sdk-managed-task-dag.test.ts +++ b/packages/coding-agent/test/sdk-managed-task-dag.test.ts @@ -11,6 +11,7 @@ import { captureManagedManifest, createManagedDomainBinding, defineManagedTaskGraph, + loadManagedEnrollmentRecord, type ManagedResource, type ManagedTaskDefinition, type ManagedTaskDomain, @@ -59,6 +60,22 @@ function resource(p: string, mode: "read" | "write", recursive = false): Managed return { kind: "path", path: p, mode, recursive, namespace: false }; } +describe("managed enrollment index reads", () => { + it("reads an absent index without Linux-only private publication", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-enrollment-empty-")); + roots.push(agentDir); + + await expect(loadManagedEnrollmentRecord(agentDir)).resolves.toEqual({ + controlRoots: [], + establishedRoots: [], + publishingRoots: [], + nativeIdentities: [], + byRoot: {}, + }); + await expect(fs.access(path.join(agentDir, ".gjc"))).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); + describe("managed DAG policy (no native effects or verification authority)", () => { it("rejects duplicate, missing, cyclic, extra-key, and empty-validation graphs", async () => { const { node } = await fixture(); diff --git a/packages/coding-agent/test/sdk-mcp-discovery.test.ts b/packages/coding-agent/test/sdk-mcp-discovery.test.ts index 9c90673c12b..38f2db4d37e 100644 --- a/packages/coding-agent/test/sdk-mcp-discovery.test.ts +++ b/packages/coding-agent/test/sdk-mcp-discovery.test.ts @@ -964,6 +964,8 @@ describe("createAgentSession MCP discovery prompt gating", () => { } } + // Each session captures one initial snapshot after subscribing; a raced + // update is reconciled from the snapshot publication, not by rereading. expect(getTools).toHaveBeenCalledTimes(3); expect(disconnectAll).not.toHaveBeenCalled(); }); diff --git a/packages/coding-agent/test/session/terminal-abort.test.ts b/packages/coding-agent/test/session/terminal-abort.test.ts index bb9e2608eb5..a03be582f7e 100644 --- a/packages/coding-agent/test/session/terminal-abort.test.ts +++ b/packages/coding-agent/test/session/terminal-abort.test.ts @@ -14,6 +14,7 @@ import { findOwnedRegistrationsForTurn, isOwnedAttemptRegistrationIncomplete, isOwnedCompletionEnvelopeAllowed, + type LineageBinding, lookupOwnedRegistration, lookupTerminalScope, mintTurnLineageIdHash, @@ -1469,6 +1470,43 @@ test("incomplete attempt evidence remains until every evicted tool window settle expect(isOwnedAttemptRegistrationIncomplete("shared-attempt-lineage", 77)).toBe(false); }); +test("exact settlement closes an evicted binding after its map entry is gone", () => { + let evictedBinding: LineageBinding | undefined; + for (let index = 0; index < 8193; index++) { + const binding = bindToolLineage(`exact-evicted-${index}`, { + lineageIdHash: "exact-evicted-lineage", + promptAttemptEpoch: 91, + endpointGeneration: 2, + }); + if (index === 0) evictedBinding = binding; + } + expect(isOwnedAttemptRegistrationIncomplete("exact-evicted-lineage", 91)).toBe(true); + settleToolLineageRegistrationWindow("exact-evicted-0", undefined, evictedBinding); + expect(isOwnedAttemptRegistrationIncomplete("exact-evicted-lineage", 91)).toBe(false); +}); + +test("successor settlement cannot close a predecessor retained window", () => { + let predecessor: LineageBinding | undefined; + for (let index = 0; index < 8193; index++) { + const binding = bindToolLineage(`rebound-window-${index}`, { + lineageIdHash: "rebound-predecessor-lineage", + promptAttemptEpoch: 92, + endpointGeneration: 3, + }); + if (index === 0) predecessor = binding; + } + unbindToolLineage("rebound-window-1"); + const successor = bindToolLineage("rebound-window-0", { + lineageIdHash: "rebound-successor-lineage", + promptAttemptEpoch: 93, + endpointGeneration: 4, + }); + settleToolLineageRegistrationWindow("rebound-window-0", undefined, successor); + expect(isOwnedAttemptRegistrationIncomplete("rebound-predecessor-lineage", 92)).toBe(true); + settleToolLineageRegistrationWindow("rebound-window-0", undefined, predecessor); + expect(isOwnedAttemptRegistrationIncomplete("rebound-predecessor-lineage", 92)).toBe(false); +}); + test("registerOwnedIfLineaged registers an evicted tool call's job with the retained lineage", () => { // Evict a binding (8192 cap) while its tool call is still in flight, then // have that tool launch a background job: the job must register under the diff --git a/packages/coding-agent/test/session/yield-queue.test.ts b/packages/coding-agent/test/session/yield-queue.test.ts index 96e93c1e9cb..6d001b2ed3c 100644 --- a/packages/coding-agent/test/session/yield-queue.test.ts +++ b/packages/coding-agent/test/session/yield-queue.test.ts @@ -170,6 +170,91 @@ describe("YieldQueue", () => { expect(harness.streamingMessages.map(messageText)).toEqual(["good"]); }); + test("requeues an idle delivery rejected by admission and releases its claim once after retry", async () => { + let attempts = 0; + let delivered = 0; + const streaming = false; + const scheduledFlushes: Array<{ run: () => Promise; onSkip: () => void }> = []; + const queue = new YieldQueue({ + isStreaming: () => streaming, + injectStreaming: () => {}, + injectIdle: async () => { + attempts += 1; + if (attempts === 1) throw Object.assign(new Error("transition busy"), { code: "busy" }); + return "delivered" as const; + }, + scheduleIdleFlush: (run, onSkip) => scheduledFlushes.push({ run, onSkip }), + }); + queue.register("items", { + build: entries => userMessage(entries.map(entry => entry.id).join(",")), + onDelivered: () => { + delivered += 1; + }, + }); + + queue.enqueue("items", { id: "race" }); + await scheduledFlushes[0]!.run(); + + expect(queue.has("items")).toBe(true); + expect(delivered).toBe(0); + expect(scheduledFlushes).toHaveLength(2); + + await scheduledFlushes[1]!.run(); + expect(queue.has("items")).toBe(false); + expect(attempts).toBe(2); + expect(delivered).toBe(1); + }); + + test("clearKind drops queued identity-bound entries through the dispatcher cleanup", () => { + const harness = createHarness(false); + let dropped = 0; + harness.queue.register("items", { + build: entries => userMessage(entries.map(entry => entry.id).join(",")), + onDrop: () => { + dropped += 1; + }, + }); + + harness.queue.enqueue("items", { id: "predecessor" }); + harness.queue.clearKind("items"); + + expect(harness.queue.has("items")).toBe(false); + expect(dropped).toBe(1); + }); + + test("clear invalidates a drained idle batch instead of resurrecting it after a failed injection", async () => { + const injectionStarted = Promise.withResolvers(); + const releaseInjection = Promise.withResolvers(); + let dropped = 0; + const scheduledFlushes: Array<{ run: () => Promise; onSkip: () => void }> = []; + const queue = new YieldQueue({ + isStreaming: () => false, + injectStreaming: () => {}, + injectIdle: async () => { + injectionStarted.resolve(); + await releaseInjection.promise; + throw Object.assign(new Error("transition busy"), { code: "busy" }); + }, + scheduleIdleFlush: (run, onSkip) => scheduledFlushes.push({ run, onSkip }), + }); + queue.register("items", { + build: entries => userMessage(entries.map(entry => entry.id).join(",")), + onDrop: () => { + dropped += 1; + }, + }); + + queue.enqueue("items", { id: "cleared" }); + const runningFlush = scheduledFlushes[0]!.run(); + await injectionStarted.promise; + queue.clear(); + releaseInjection.resolve(); + await runningFlush; + + expect(queue.has("items")).toBe(false); + expect(dropped).toBe(1); + }); + test("flush preserves registration order across kinds", async () => { const harness = createHarness(true); harness.queue.register("second", { diff --git a/packages/coding-agent/test/skill-active-state.test.ts b/packages/coding-agent/test/skill-active-state.test.ts index d71500a4f14..3f47fa0882a 100644 --- a/packages/coding-agent/test/skill-active-state.test.ts +++ b/packages/coding-agent/test/skill-active-state.test.ts @@ -414,6 +414,161 @@ describe("GJC skill-active state", () => { }); }); + it("migrates every legacy snapshot workflow before making per-skill entries authoritative", async () => { + await withTempCwd(async cwd => { + const sessionId = "sess-legacy-migrate"; + const { sessionPath } = getSkillActiveStatePaths(cwd, sessionId); + await fs.mkdir(path.dirname(sessionPath), { recursive: true }); + await Bun.write( + sessionPath, + JSON.stringify({ + version: 1, + active: true, + skill: "autoresearch", + active_skills: [ + { + skill: "autoresearch", + phase: "research", + active: true, + session_id: sessionId, + updated_at: "2026-10-08T18:00:00.000Z", + }, + { + skill: "ralplan", + phase: "pending-approval", + active: true, + session_id: sessionId, + updated_at: "2026-10-08T18:01:00.000Z", + source_state_revision: "invalid", + }, + ], + }), + ); + + await syncSkillActiveState({ + cwd, + skill: "autoresearch", + phase: "running", + active: true, + sessionId, + nowIso: "2026-10-08T19:00:00.000Z", + }); + + const visible = await readVisibleSkillActiveState(cwd, sessionId); + expect(visible?.active_skills?.map(entry => entry.skill).sort()).toEqual(["autoresearch", "ralplan"]); + expect(visible?.active_skills?.find(entry => entry.skill === "ralplan")?.phase).toBe("pending-approval"); + const migratedRalplan = JSON.parse( + await Bun.file(path.join(activeStateDir(cwd, sessionId), "ralplan.json")).text(), + ); + expect(migratedRalplan.source_state_revision).toBe(0); + }); + }); + + it("migrates unrepresented legacy rows from an existing active directory", async () => { + await withTempCwd(async cwd => { + const sessionId = "sess-legacy-partial"; + const { sessionPath } = getSkillActiveStatePaths(cwd, sessionId); + await fs.mkdir(path.dirname(sessionPath), { recursive: true }); + const snapshotEntries = [ + { + skill: "autoresearch", + phase: "research", + active: true, + session_id: sessionId, + updated_at: "2026-10-08T18:00:00.000Z", + }, + { + skill: "ralplan", + phase: "pending-approval", + active: true, + session_id: sessionId, + updated_at: "2026-10-08T18:01:00.000Z", + }, + ]; + await Bun.write( + sessionPath, + JSON.stringify({ version: 1, active: true, skill: "autoresearch", active_skills: snapshotEntries }), + ); + + const activeDir = activeStateDir(cwd, sessionId); + await fs.mkdir(activeDir, { recursive: true }); + await Bun.write(path.join(activeDir, "autoresearch.json"), JSON.stringify(snapshotEntries[0])); + + await syncSkillActiveState({ + cwd, + skill: "autoresearch", + phase: "running", + active: true, + sessionId, + nowIso: "2026-10-08T19:00:00.000Z", + }); + + const visible = await readVisibleSkillActiveState(cwd, sessionId); + expect(visible?.active_skills?.map(entry => entry.skill).sort()).toEqual(["autoresearch", "ralplan"]); + expect(visible?.active_skills?.find(entry => entry.skill === "ralplan")?.phase).toBe("pending-approval"); + }); + }); + + it("resumes an interrupted legacy-entry migration without dropping snapshot rows", async () => { + await withTempCwd(async cwd => { + const sessionId = "sess-legacy-retry"; + const { sessionPath } = getSkillActiveStatePaths(cwd, sessionId); + await fs.mkdir(path.dirname(sessionPath), { recursive: true }); + const snapshotEntries = [ + { + skill: "autoresearch", + phase: "research", + active: true, + session_id: sessionId, + updated_at: "2026-10-08T18:00:00.000Z", + }, + { + skill: "ralplan", + phase: "pending-approval", + active: true, + session_id: sessionId, + updated_at: "2026-10-08T18:01:00.000Z", + }, + ]; + await Bun.write( + sessionPath, + JSON.stringify({ version: 1, active: true, skill: "autoresearch", active_skills: snapshotEntries }), + ); + + const activeDir = activeStateDir(cwd, sessionId); + await fs.mkdir(activeDir, { recursive: true }); + await Bun.write(path.join(activeDir, "autoresearch.json"), JSON.stringify(snapshotEntries[0])); + const beforeMigration = await readVisibleSkillActiveState(cwd, sessionId); + expect(beforeMigration?.active_skills?.map(entry => entry.skill)).toEqual(["autoresearch"]); + + const migrationMarker = path.join(path.dirname(activeDir), ".active-entry-migration.pending"); + await Bun.write(migrationMarker, "pending\n"); + const duringMigration = await readVisibleSkillActiveState(cwd, sessionId); + expect(duringMigration?.active_skills?.map(entry => entry.skill).sort()).toEqual(["autoresearch", "ralplan"]); + + await syncSkillActiveState({ + cwd, + skill: "autoresearch", + phase: "running", + active: true, + sessionId, + nowIso: "2026-10-08T19:00:00.000Z", + }); + + const visible = await readVisibleSkillActiveState(cwd, sessionId); + expect(visible?.active_skills?.map(entry => entry.skill).sort()).toEqual(["autoresearch", "ralplan"]); + expect(visible?.active_skills?.find(entry => entry.skill === "ralplan")?.phase).toBe("pending-approval"); + const markerExists = await fs.stat(migrationMarker).then( + () => true, + error => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + }, + ); + expect(markerExists).toBe(false); + }); + }); + it("chooses the most advanced active pipeline stage as snapshot primary regardless of file order", async () => { await withTempCwd(async cwd => { const activeDir = path.join(cwd, ".gjc", "_session-sess1", "state", "active"); diff --git a/packages/coding-agent/test/task-cache-key.test.ts b/packages/coding-agent/test/task-cache-key.test.ts index 811ac6fb1eb..5200e6b3f49 100644 --- a/packages/coding-agent/test/task-cache-key.test.ts +++ b/packages/coding-agent/test/task-cache-key.test.ts @@ -93,6 +93,19 @@ async function withLifecycleIdentity(sessionId: string, run: () => Promise } } +async function withoutLifecycleIdentity(run: () => Promise): Promise { + const previousRequestId = process.env.GJC_LIFECYCLE_REQUEST_ID; + const previousSessionId = process.env.GJC_SESSION_ID; + try { + delete process.env.GJC_LIFECYCLE_REQUEST_ID; + delete process.env.GJC_SESSION_ID; + return await run(); + } finally { + if (previousRequestId !== undefined) process.env.GJC_LIFECYCLE_REQUEST_ID = previousRequestId; + if (previousSessionId !== undefined) process.env.GJC_SESSION_ID = previousSessionId; + } +} + describe("async job endpoint id derivation", () => { const tempDirs: string[] = []; @@ -201,29 +214,28 @@ describe("task fork-context provider identity", () => { return paths; } async function removeTempTree(dir: string): Promise { - for (const entry of await fsPromises.readdir(dir, { withFileTypes: true })) { - const entryPath = path.join(dir, entry.name); - if (entry.isDirectory() && !entry.isSymbolicLink()) { - await removeTempTree(entryPath); - } else { - try { - await fsPromises.rm(entryPath, { force: true }); - } catch (error) { - throw new Error(`Failed to remove entry ${entryPath}`, { cause: error }); + // Retry recursive removal with backoff in case lock directories are still being cleaned up + let lastError: unknown; + for (let attempts = 0; attempts < 10; attempts++) { + try { + await fsPromises.rm(dir, { recursive: true, force: true }); + return; + } catch (error) { + lastError = error; + if (attempts < 9) { + await Bun.sleep(10 * (attempts + 1)); } } } - try { - await fsPromises.rmdir(dir); - } catch (error) { - throw new Error(`Failed to remove directory ${dir}`, { cause: error }); - } + throw new Error(`Failed to remove directory ${dir}`, { cause: lastError }); } afterEach(async () => { + // Ensure all sessions are properly disposed and no writes are pending after disposal while (sessions.length > 0) await sessions.pop()?.dispose(); while (authStorages.length > 0) authStorages.pop()?.close(); while (artifactStores.length > 0) artifactStores.pop()?.close(); + while (tempDirs.length > 0) { const tempDir = tempDirs.pop(); if (!tempDir) continue; @@ -423,117 +435,125 @@ describe("task fork-context provider identity", () => { }); it("keeps top-level async ownership isolated when provider affinity is shared", async () => { - const firstDir = await fsPromises.mkdtemp( - path.join(os.tmpdir(), `pi-task-shared-provider-a-${Snowflake.next()}-`), - ); - const secondDir = await fsPromises.mkdtemp( - path.join(os.tmpdir(), `pi-task-shared-provider-b-${Snowflake.next()}-`), - ); - tempDirs.push(firstDir, secondDir); - const [{ session: first, authStorage: firstAuth }, { session: second, authStorage: secondAuth }] = - await Promise.all([ - createSession(firstDir, { providerSessionId: "shared-provider-affinity" }), - createSession(secondDir, { providerSessionId: "shared-provider-affinity" }), - ]); - sessions.push(first, second); - authStorages.push(firstAuth, secondAuth); - - expect(first.agent.providerSessionId).toBe("shared-provider-affinity"); - expect(second.agent.providerSessionId).toBe("shared-provider-affinity"); - expect(first.sessionManager.getSessionId()).not.toBe(second.sessionManager.getSessionId()); + await withoutLifecycleIdentity(async () => { + const firstDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), `pi-task-shared-provider-a-${Snowflake.next()}-`), + ); + const secondDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), `pi-task-shared-provider-b-${Snowflake.next()}-`), + ); + tempDirs.push(firstDir, secondDir); + const [{ session: first, authStorage: firstAuth }, { session: second, authStorage: secondAuth }] = + await Promise.all([ + createSession(firstDir, { providerSessionId: "shared-provider-affinity" }), + createSession(secondDir, { providerSessionId: "shared-provider-affinity" }), + ]); + sessions.push(first, second); + authStorages.push(firstAuth, secondAuth); + + expect(first.agent.providerSessionId).toBe("shared-provider-affinity"); + expect(second.agent.providerSessionId).toBe("shared-provider-affinity"); + expect(first.sessionManager.getSessionId()).not.toBe(second.sessionManager.getSessionId()); + }); }, 15_000); it("rekeys explicit provider ownership to the successor transcript and frees the predecessor", async () => { - const tempDir = await fsPromises.mkdtemp( - path.join(os.tmpdir(), `pi-task-provider-transition-${Snowflake.next()}-`), - ); - tempDirs.push(tempDir); - const providerSessionId = "shared-provider-affinity"; - const { session, authStorage } = await createSession(tempDir, { providerSessionId }); - sessions.push(session); - authStorages.push(authStorage); - - const previousSessionId = session.sessionManager.getSessionId(); - const previousSessionFile = session.sessionManager.getSessionFile(); - expect(previousSessionFile).toBeDefined(); - expect(fs.existsSync(previousSessionFile!)).toBe(false); - const previousEndpoint = JSON.stringify([ - "async-job-endpoint", - providerSessionId, - stablePathKey(path.resolve(previousSessionFile!)), - ]); - const manager = AsyncJobManager.forEndpoint(previousEndpoint); - expect(manager).toBeDefined(); - session.sessionManager.appendMessage({ - role: "user", - content: "persist endpoint identity", - timestamp: Date.now(), - }); - await session.sessionManager.ensureOnDisk(); - await session.sessionManager.flush(); - expect(fs.existsSync(previousSessionFile!)).toBe(true); - expect(asyncJobEndpointId(providerSessionId, previousSessionId, previousSessionFile)).toBe(previousEndpoint); - expect(AsyncJobManager.forEndpoint(previousEndpoint)).toBe(manager); - - await session.sessionManager.rewriteEntries(); - expect(asyncJobEndpointId(providerSessionId, previousSessionId, previousSessionFile)).toBe(previousEndpoint); - expect(AsyncJobManager.forEndpoint(previousEndpoint)).toBe(manager); - - expect(await session.newSession()).toBe(true); - const successorSessionFile = session.sessionManager.getSessionFile(); - expect(successorSessionFile).toBeDefined(); - expect(session.sessionManager.getSessionId()).not.toBe(previousSessionId); - const successorEndpoint = JSON.stringify([ - "async-job-endpoint", - providerSessionId, - stablePathKey(path.resolve(successorSessionFile!)), - ]); - expect(AsyncJobManager.forEndpoint(previousEndpoint)).toBeUndefined(); - expect(AsyncJobManager.forEndpoint(successorEndpoint)).toBe(manager); + await withoutLifecycleIdentity(async () => { + const tempDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), `pi-task-provider-transition-${Snowflake.next()}-`), + ); + tempDirs.push(tempDir); + const providerSessionId = "shared-provider-affinity"; + const { session, authStorage } = await createSession(tempDir, { providerSessionId }); + sessions.push(session); + authStorages.push(authStorage); + + const previousSessionId = session.sessionManager.getSessionId(); + const previousSessionFile = session.sessionManager.getSessionFile(); + expect(previousSessionFile).toBeDefined(); + expect(fs.existsSync(previousSessionFile!)).toBe(false); + const previousEndpoint = JSON.stringify([ + "async-job-endpoint", + providerSessionId, + stablePathKey(path.resolve(previousSessionFile!)), + ]); + const manager = AsyncJobManager.forEndpoint(previousEndpoint); + expect(manager).toBeDefined(); + session.sessionManager.appendMessage({ + role: "user", + content: "persist endpoint identity", + timestamp: Date.now(), + }); + await session.sessionManager.ensureOnDisk(); + await session.sessionManager.flush(); + expect(fs.existsSync(previousSessionFile!)).toBe(true); + expect(asyncJobEndpointId(providerSessionId, previousSessionId, previousSessionFile)).toBe(previousEndpoint); + expect(AsyncJobManager.forEndpoint(previousEndpoint)).toBe(manager); + + await session.sessionManager.rewriteEntries(); + expect(asyncJobEndpointId(providerSessionId, previousSessionId, previousSessionFile)).toBe(previousEndpoint); + expect(AsyncJobManager.forEndpoint(previousEndpoint)).toBe(manager); + + expect(await session.newSession()).toBe(true); + const successorSessionFile = session.sessionManager.getSessionFile(); + expect(successorSessionFile).toBeDefined(); + expect(session.sessionManager.getSessionId()).not.toBe(previousSessionId); + const successorEndpoint = JSON.stringify([ + "async-job-endpoint", + providerSessionId, + stablePathKey(path.resolve(successorSessionFile!)), + ]); + expect(AsyncJobManager.forEndpoint(previousEndpoint)).toBeUndefined(); + expect(AsyncJobManager.forEndpoint(successorEndpoint)).toBe(manager); - expect(await session.switchSession(previousSessionFile!)).toBe(true); - expect(AsyncJobManager.forEndpoint(successorEndpoint)).toBeUndefined(); - expect(AsyncJobManager.forEndpoint(previousEndpoint)).toBe(manager); + expect(await session.switchSession(previousSessionFile!)).toBe(true); + expect(AsyncJobManager.forEndpoint(successorEndpoint)).toBeUndefined(); + expect(AsyncJobManager.forEndpoint(previousEndpoint)).toBe(manager); - const { session: reopened, authStorage: reopenedAuth } = await createSession(tempDir, { - providerSessionId, - sessionManager: await SessionManager.open(successorSessionFile!), + const { session: reopened, authStorage: reopenedAuth } = await createSession(tempDir, { + providerSessionId, + sessionManager: await SessionManager.open(successorSessionFile!), + }); + sessions.push(reopened); + authStorages.push(reopenedAuth); + expect(AsyncJobManager.forEndpoint(successorEndpoint)).toBeDefined(); }); - sessions.push(reopened); - authStorages.push(reopenedAuth); - expect(AsyncJobManager.forEndpoint(successorEndpoint)).toBeDefined(); }, 15_000); it("registers construction-time ownership under the shared canonical endpoint key", async () => { - const tempDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), `pi-task-provider-alias-${Snowflake.next()}-`)); - tempDirs.push(tempDir); - const providerSessionId = "aliased-provider-affinity"; - const { session, authStorage } = await createSession(tempDir, { providerSessionId }); - sessions.push(session); - authStorages.push(authStorage); - - // The constructor must register under exactly the key the transition path - // recomputes; any divergence strands ownership on the first transition. - const predecessorFile = session.sessionManager.getSessionFile(); - expect(predecessorFile).toBeDefined(); - const predecessorEndpoint = asyncJobEndpointId( - providerSessionId, - session.sessionManager.getSessionId(), - predecessorFile, - ); - const manager = AsyncJobManager.forEndpoint(predecessorEndpoint); - expect(manager).toBeDefined(); - expect(AsyncJobManager.endpointIdOf(manager!)).toBe(predecessorEndpoint); - - expect(await session.newSession()).toBe(true); - const successorEndpoint = asyncJobEndpointId( - providerSessionId, - session.sessionManager.getSessionId(), - session.sessionManager.getSessionFile(), - ); - expect(successorEndpoint).not.toBe(predecessorEndpoint); - expect(AsyncJobManager.forEndpoint(predecessorEndpoint)).toBeUndefined(); - expect(AsyncJobManager.forEndpoint(successorEndpoint)).toBe(manager); + await withoutLifecycleIdentity(async () => { + const tempDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), `pi-task-provider-alias-${Snowflake.next()}-`), + ); + tempDirs.push(tempDir); + const providerSessionId = "aliased-provider-affinity"; + const { session, authStorage } = await createSession(tempDir, { providerSessionId }); + sessions.push(session); + authStorages.push(authStorage); + + // The constructor must register under exactly the key the transition path + // recomputes; any divergence strands ownership on the first transition. + const predecessorFile = session.sessionManager.getSessionFile(); + expect(predecessorFile).toBeDefined(); + const predecessorEndpoint = asyncJobEndpointId( + providerSessionId, + session.sessionManager.getSessionId(), + predecessorFile, + ); + const manager = AsyncJobManager.forEndpoint(predecessorEndpoint); + expect(manager).toBeDefined(); + expect(AsyncJobManager.endpointIdOf(manager!)).toBe(predecessorEndpoint); + + expect(await session.newSession()).toBe(true); + const successorEndpoint = asyncJobEndpointId( + providerSessionId, + session.sessionManager.getSessionId(), + session.sessionManager.getSessionFile(), + ); + expect(successorEndpoint).not.toBe(predecessorEndpoint); + expect(AsyncJobManager.forEndpoint(predecessorEndpoint)).toBeUndefined(); + expect(AsyncJobManager.forEndpoint(successorEndpoint)).toBe(manager); + }); }, 15_000); it("does not share mutable provider state unless explicitly supplied", async () => { diff --git a/packages/coding-agent/test/tools/python-tool-builtin.test.ts b/packages/coding-agent/test/tools/python-tool-builtin.test.ts index af0c0ffd932..b3a2529702c 100644 --- a/packages/coding-agent/test/tools/python-tool-builtin.test.ts +++ b/packages/coding-agent/test/tools/python-tool-builtin.test.ts @@ -74,7 +74,7 @@ function makeToolSession(options: { getSessionFile?: () => string | null; getSessionId?: () => string | null; settings?: Settings; - registerSessionCleanup?: (cleanup: () => Promise | void) => (() => void) | void; + registerSessionCleanup?: (cleanup: () => Promise | void) => () => void; assertEvalExecutionAllowed?: () => void; trackEvalExecution?: ToolSession["trackEvalExecution"]; }): ToolSession { @@ -107,7 +107,7 @@ async function loadPythonTool(options: { getSessionFile?: () => string | null; getSessionId?: () => string | null; settings?: Settings; - registerSessionCleanup?: (cleanup: () => Promise | void) => (() => void) | void; + registerSessionCleanup?: (cleanup: () => Promise | void) => () => void; assertEvalExecutionAllowed?: () => void; trackEvalExecution?: ToolSession["trackEvalExecution"]; }): Promise { @@ -472,6 +472,7 @@ describe("builtin session Python tool", () => { cwd, registerSessionCleanup: cleanup => { registeredCleanup = cleanup; + return () => {}; }, trackEvalExecution: () => { if (!registeredCleanup) throw new Error("Expected the Python generation cleanup to be registered"); @@ -920,7 +921,7 @@ describe("builtin session Python tool", () => { const tool = await loadPythonTool({ cwd, getSessionId: () => sessionManager?.getSessionId() ?? null, - registerSessionCleanup: cleanup => liveSession?.registerToolSessionTransitionCleanup(cleanup), + registerSessionCleanup: cleanup => liveSession?.registerToolSessionTransitionCleanup(cleanup) ?? (() => {}), }); const fixture = await createAgentSessionFixture({ cwd, toolRegistry: new Map([[PYTHON_TOOL_NAME, tool]]) }); liveSession = fixture.session; diff --git a/packages/coding-agent/test/tools/python-tool.test.ts b/packages/coding-agent/test/tools/python-tool.test.ts index 29f71622196..0023fc370b4 100644 --- a/packages/coding-agent/test/tools/python-tool.test.ts +++ b/packages/coding-agent/test/tools/python-tool.test.ts @@ -30,7 +30,7 @@ function createTool(cwd: string, sessionId: string): AgentTool { cwd, settings: Settings.isolated(), getSessionId: () => sessionId, - registerSessionCleanup: () => {}, + registerSessionCleanup: () => () => {}, }); } diff --git a/packages/natives/native/diagnostic-artifact.json b/packages/natives/native/diagnostic-artifact.json index b9c537e2d44..7bec6e6633d 100644 --- a/packages/natives/native/diagnostic-artifact.json +++ b/packages/natives/native/diagnostic-artifact.json @@ -3,6 +3,6 @@ "version": "0.18.8", "artifacts": { "pi_natives.darwin-arm64.node": "18cbb004b1fbda2d42eb8cb654d517985faa0ab305113ab4447f851c8b64820b", - "pi_natives.linux-x64-modern.node": "eb8d594762d104a31d103b1bdc0587adb0ebe1f01be80e0c2e2f473b10c73dde" + "pi_natives.linux-x64-modern.node": "8dff731b31ca2b0e7fd55f3636de3ba477abef29080e0d099b2de6f119b0f5d5" } } diff --git a/packages/tui/changelog.d/5321-frame-coalescing-lifecycle.md b/packages/tui/changelog.d/5321-frame-coalescing-lifecycle.md new file mode 100644 index 00000000000..40e9502cbab --- /dev/null +++ b/packages/tui/changelog.d/5321-frame-coalescing-lifecycle.md @@ -0,0 +1,5 @@ +### Fixed +- Multipart raster output now releases its prefix-barrier ownership when the final terminal write is rejected, terminal availability is lost, or either asynchronous prefix boundary resumes into a stale lifecycle, preventing stale synchronization ownership from surviving terminal loss. +- Coalesced forced redraw generations now remain pending until their shared terminal write commits instead of being failed by a later next-tick callback after frame preparation. +- Disposal and terminal-loss recovery now fence work already queued behind raster ingress, and disposal rejects new raster leases and ordinary output submissions, preventing stale terminal bytes or a falsely successful render generation after the owning TUI lifecycle ends. +- Multipart prefix callbacks and terminal-flush waits now race lifecycle cancellation, so non-cooperative work cannot hold raster ingress after stop, disposal, or terminal loss; restart output proceeds under a fresh lifecycle signal. diff --git a/packages/tui/changelog.d/5321-performance-lifecycle.md b/packages/tui/changelog.d/5321-performance-lifecycle.md new file mode 100644 index 00000000000..bbfadde4032 --- /dev/null +++ b/packages/tui/changelog.d/5321-performance-lifecycle.md @@ -0,0 +1,8 @@ +### Performance + +- Skip full-transcript resize-width scans on unchanged-width render frames while preserving the existing resize and forced-redraw checks. +- Avoid copying unchanged terminal-control spans, reuse retained row widths, consume only the first grapheme for cursor operations, stop select-list width scans at their bounds, and avoid redundant background-row padding. + +### Added + +- Added cancellable, one-shot `enqueueBeforeRender` preparation on the existing frame scheduler and a single-owner `setRenderPreparationLifecycleCallbacks` seam for invalidation and restart preparation. Stop, terminal loss, and disposal cancel stale work; restart preparation runs before the first forced frame without adding another streaming timer. diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 6a3c53c34ee..a39045407dc 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -68,7 +68,7 @@ export type TerminalOutputOperation = type: "raster-multipart-batch"; records: readonly Uint8Array[]; prefix?: Uint8Array; - afterPrefix?: () => Promise; + afterPrefix?: (signal: AbortSignal) => Promise; /** Synchronous freshness gate evaluated immediately before terminal output. */ shouldWrite?: () => boolean; replayPrefix?: Uint8Array; @@ -1211,12 +1211,14 @@ export class TUI extends Container { #pendingDependentGenericBytes: Array<{ bytes: Uint8Array; rect: CellRect; blockedBy: string[] }> = []; #terminalGeneration = 0; /** - * Raster lifecycle epoch. `stop()` increments it before lease cleanup so a - * queue body captured under a prior running epoch can never write to the - * terminal after restoration; `start()` does not reset it, so work enqueued - * in the new lifecycle carries the new epoch. + * Raster lifecycle epoch. Lifecycle-invalidating transitions increment it + * before lease cleanup so a queue body captured under a prior running epoch + * can never write to the terminal after restoration; `start()` does not reset + * it, so work enqueued in the new lifecycle carries the new epoch. */ #rasterLifecycle = 0; + #rasterLifecycleAbortController = new AbortController(); + #inFlightMultipartAbort?: () => void; #unsubscribeTabWidthChange?: () => void; static #renderCounters: TuiRenderCounterSnapshot = { @@ -1375,6 +1377,12 @@ export class TUI extends Container { this.#widthSettleTimer = undefined; } this.#invalidatePreparations(); + // Invalidate every raster-queue body captured before disposal. Disposal does + // not stop the terminal itself, so the ingress epoch is the only fence that + // prevents a held body or queued render from writing after teardown. + this.#closeInFlightMultipartPrefix(); + this.#rasterLifecycleAbortController.abort(); + this.#rasterLifecycle++; this.#preparationLifecycle = undefined; this.#settleRenderCommitWaiters(false); this.#unsubscribeTabWidthChange?.(); @@ -2033,7 +2041,8 @@ export class TUI extends Container { acquireRasterLease(request: RasterLeaseRequest): Promise { return this.#enqueueRaster(isCurrentLifecycle => { - if (!isCurrentLifecycle()) return { status: "rejected", reason: "terminal-unavailable" } as const; + if (this.#preparationDisposed || !isCurrentLifecycle()) + return { status: "rejected", reason: "terminal-unavailable" } as const; if ( !request || typeof request.ownerId !== "string" || @@ -2071,7 +2080,7 @@ export class TUI extends Container { submitTerminalOutput( request: Readonly<{ operation: TerminalOutputOperation; token?: RasterLeaseToken }>, ): Promise { - return this.#enqueueRaster(async isCurrentLifecycle => { + return this.#enqueueRaster(async (isCurrentLifecycle, lifecycleSignal) => { const id = ++this.#rasterQueueId; const rawOperation0 = request && typeof request === "object" ? (request as { operation?: unknown }).operation : undefined; @@ -2081,7 +2090,7 @@ export class TUI extends Container { typeof (rawOperation0 as { type?: unknown }).type === "string" ? (rawOperation0 as { type: string }).type : "queued-output"; - if (!isCurrentLifecycle()) + if (!isCurrentLifecycle() || this.#preparationDisposed) return { queueId: id, operation: operation0 as TerminalOutputOperation["type"], status: "failed" as const }; const rawOperation = request && typeof request === "object" ? (request as { operation?: unknown }).operation : undefined; @@ -2151,42 +2160,68 @@ export class TUI extends Container { } if (!shouldWrite) return { queueId: id, operation: op.type, status: "stale-token" }; } + let multipartAbortBarrier: (() => void) | undefined; if (op.type === "raster-multipart-batch" && op.prefix !== undefined && op.afterPrefix !== undefined) { const prefixWritten = this.#guardTerminalOperation(() => this.#emit(new TextDecoder().decode(op.prefix))); if (!prefixWritten) return failed(); const abortBarrier = () => { + if (this.#inFlightMultipartAbort === abortBarrier) this.#inFlightMultipartAbort = undefined; // Abort/cursor-restoration bytes are terminal writes: never emit // them once the running epoch ended (e.g. a user predicate that // itself stops the terminal before throwing or returning false). - if (!isCurrentLifecycle()) return; + if (!isCurrentLifecycle() || !this.terminalAvailable) return; const abortSuffix = op.abortSuffix === undefined ? "" : new TextDecoder().decode(op.abortSuffix); const cursorVisibility = op.restoreCursorVisibility ? this.#cursorVisibilitySequence() : ""; if (abortSuffix || cursorVisibility) this.#guardTerminalOperation(() => this.#emit(abortSuffix + cursorVisibility)); }; - const flushed = await (this.terminal as Terminal & { flush?: () => Promise }).flush?.(); + multipartAbortBarrier = abortBarrier; + this.#inFlightMultipartAbort = abortBarrier; + let flushed: boolean | undefined; + try { + const flush = (this.terminal as Terminal & { flush?: () => Promise }).flush?.(); + flushed = + flush === undefined ? undefined : await this.#raceRasterLifecycle(flush, lifecycleSignal, false); + } catch { + abortBarrier(); + return failed(); + } // Async boundary: the terminal may have stopped while we awaited. - if (!isCurrentLifecycle()) return failed(); + if (!isCurrentLifecycle()) { + abortBarrier(); + return failed(); + } if (flushed === false) { - if (isCurrentLifecycle()) abortBarrier(); + abortBarrier(); return failed(); } let afterPrefixSucceeded: boolean; + if (lifecycleSignal.aborted) { + abortBarrier(); + return failed(); + } try { - afterPrefixSucceeded = await op.afterPrefix(); + afterPrefixSucceeded = await this.#raceRasterLifecycle( + op.afterPrefix(lifecycleSignal), + lifecycleSignal, + false, + ); } catch { - if (isCurrentLifecycle()) abortBarrier(); + abortBarrier(); return failed(); } if (afterPrefixSucceeded !== true) { - if (isCurrentLifecycle()) abortBarrier(); + abortBarrier(); return failed(); } // Async boundary: afterPrefix awaited external work; re-check epoch. - if (!isCurrentLifecycle()) return failed(); + if (!isCurrentLifecycle()) { + abortBarrier(); + return failed(); + } const currentLease = this.#rasterLeases.get(request.token?.ownerId ?? ""); if (!currentLease || currentLease.revoked || currentLease.token !== request.token) { - if (isCurrentLifecycle()) abortBarrier(); + abortBarrier(); return { queueId: id, operation: op.type, status: currentLease?.revoked ? "revoked" : "stale-token" }; } if (op.shouldWrite !== undefined) { @@ -2194,17 +2229,20 @@ export class TUI extends Container { try { shouldWrite = op.shouldWrite(); } catch { - if (isCurrentLifecycle()) abortBarrier(); + abortBarrier(); return failed(); } if (!shouldWrite) { - if (isCurrentLifecycle()) abortBarrier(); + abortBarrier(); return { queueId: id, operation: op.type, status: "stale-token" }; } } } // Final pre-write gate: an async body may have resumed after stop(). - if (!isCurrentLifecycle()) return failed(); + if (!isCurrentLifecycle()) { + multipartAbortBarrier?.(); + return failed(); + } const bytes = op.type === "raster-multipart-batch" ? op.records.map((b: Uint8Array) => new TextDecoder().decode(b)).join("") @@ -2217,6 +2255,11 @@ export class TUI extends Container { const ok = dependent ? this.#writeProtectedRenderIngress(finalBytes) : this.#guardTerminalOperation(() => this.#emit(finalBytes)); + if (ok && this.#inFlightMultipartAbort === multipartAbortBarrier) { + this.#inFlightMultipartAbort = undefined; + } else if (!ok && multipartAbortBarrier) { + multipartAbortBarrier(); + } if (!ok && dependent) { const rect = (op as { rect: CellRect }).rect; const blockedBy = [...this.#rasterCleanup.entries()] @@ -2335,15 +2378,18 @@ export class TUI extends Container { }; }); } - #enqueueRaster(fn: (isCurrentLifecycle: () => boolean) => T | Promise): Promise { + #enqueueRaster( + fn: (isCurrentLifecycle: () => boolean, lifecycleSignal: AbortSignal) => T | Promise, + ): Promise { const lifecycle = this.#rasterLifecycle; + const lifecycleSignal = this.#rasterLifecycleAbortController.signal; this.#rasterPending++; // A queue body captured under a prior running epoch must not write to the // terminal after stop() restored it — captured-epoch equality only, // evaluated lazily at entry AND after every await inside async bodies. // Synchronous stop cleanup writes directly, never through a stale body. const isCurrentLifecycle = () => lifecycle === this.#rasterLifecycle; - const next: Promise = this.#rasterIngress.then(() => fn(isCurrentLifecycle)) as Promise; + const next: Promise = this.#rasterIngress.then(() => fn(isCurrentLifecycle, lifecycleSignal)) as Promise; this.#rasterIngress = next.catch(() => undefined); void next.then( () => { @@ -2355,6 +2401,21 @@ export class TUI extends Container { ); return next; } + + #closeInFlightMultipartPrefix(): void { + const abort = this.#inFlightMultipartAbort; + this.#inFlightMultipartAbort = undefined; + abort?.(); + } + #raceRasterLifecycle(operation: Promise, lifecycleSignal: AbortSignal, cancelled: T): Promise { + if (lifecycleSignal.aborted) return Promise.resolve(cancelled); + const abortResult = Promise.withResolvers(); + const onAbort = () => abortResult.resolve(cancelled); + lifecycleSignal.addEventListener("abort", onAbort, { once: true }); + return Promise.race([operation, abortResult.promise]).finally(() => { + lifecycleSignal.removeEventListener("abort", onAbort); + }); + } #validRect(r: CellRect): boolean { return ( Object.values(r).every(Number.isSafeInteger) && @@ -2467,6 +2528,9 @@ export class TUI extends Container { } start(): void { if (this.#preparationDisposed) return; + if (this.#rasterLifecycleAbortController.signal.aborted) { + this.#rasterLifecycleAbortController = new AbortController(); + } this.#stopped = false; this.#terminalUnavailable = false; this.#clearMouseSelection(); @@ -2629,10 +2693,16 @@ export class TUI extends Container { } #markTerminalUnavailable(settleRenderWaiters = true): void { this.#invalidatePreparations(); + // A terminal-loss transition invalidates bodies parked behind the raster + // ingress just like stop(). Restarted output must never resume that stale + // epoch after availability returns. + this.#terminalUnavailable = true; + this.#closeInFlightMultipartPrefix(); + this.#rasterLifecycleAbortController.abort(); + this.#rasterLifecycle++; this.#terminalGeneration++; for (const record of this.#rasterCleanup.values()) record.terminalGeneration = this.#terminalGeneration; this.#revokeRasterLeases("terminal-loss"); - this.#terminalUnavailable = true; this.#stopped = true; this.#renderRequested = false; if (settleRenderWaiters) this.#settleRenderCommitWaiters(false); @@ -2882,7 +2952,9 @@ export class TUI extends Container { this.#invalidatePreparations(); // Invalidate every raster-queue body captured under the running epoch // before any teardown: nothing queued before stop may write after - // restoration. Synchronous stop cleanup below writes directly. + // restoration. Synchronous stop cleanup writes directly. + this.#closeInFlightMultipartPrefix(); + this.#rasterLifecycleAbortController.abort(); this.#rasterLifecycle++; this.#flushRasterLeasesBeforeStop("terminal-loss"); const placementCleanup = this.#kittyPlacementDeletePlan(this.#kittyPlacementSpans, [], [], true).output; @@ -3319,10 +3391,15 @@ export class TUI extends Container { } this.#renderRequested = true; process.nextTick(() => { - if (this.#stopped || !this.#renderRequested) { + if (this.#stopped) { this.#settleRenderCommitWaiters(false, generation); return; } + // Another next-tick callback may already have coalesced this forced + // generation into the active frame. Its queued terminal write owns the + // commit result; do not fail every waiter merely because renderRequested + // was cleared when that frame was prepared. + if (!this.#renderRequested) return; this.#renderPreparedFrame(); }); return; diff --git a/packages/tui/test/raster-lease.test.ts b/packages/tui/test/raster-lease.test.ts index a955617364a..c65a1579df1 100644 --- a/packages/tui/test/raster-lease.test.ts +++ b/packages/tui/test/raster-lease.test.ts @@ -71,6 +71,22 @@ describe("TUI raster lease public boundary", () => { expect(terminal.getWriteLog()).toEqual([]); }); + it("rejects new raster and generic output after TUI disposal", async () => { + const { tui, terminal } = await setup(); + tui.dispose(); + + const lease = await tui.acquireRasterLease(request("after-dispose")); + expect(lease).toEqual({ status: "rejected", reason: "terminal-unavailable" }); + + const rasterOutput = await tui.submitTerminalOutput({ + operation: { type: "raster-probe", bytes: bytes("AFTER_DISPOSE_RASTER") }, + }); + const genericOutput = await tui.queueTerminalOutput("AFTER_DISPOSE_GENERIC"); + expect(rasterOutput.status).toBe("failed"); + expect(genericOutput.status).toBe("failed"); + expect(terminal.getWriteLog()).toEqual([]); + }); + it("writes multipart records as one terminal write", async () => { const { tui, terminal } = await setup(); const lease = await tui.acquireRasterLease(request("multipart")); @@ -345,6 +361,170 @@ describe("TUI raster lease public boundary", () => { expect(ack.status).toBe("failed"); expect(terminal.getWriteLog()).toEqual(["SAVE+PLACE", "RESTORE"]); }); + it("closes the multipart barrier when the final write is rejected", async () => { + const { tui, terminal } = await setup(); + const lease = await tui.acquireRasterLease(request("barrier-final-write")); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + terminal.clearWriteLog(); + const ack = await tui.submitTerminalOutput({ + operation: { + type: "raster-multipart-batch", + prefix: bytes("SAVE+PLACE"), + afterPrefix: async () => { + failNextWrites(terminal); + return true; + }, + records: [bytes("IMAGE")], + abortSuffix: bytes("RESTORE"), + }, + token: lease.token, + }); + expect(ack.status).toBe("failed"); + expect(terminal.getWriteLog()).toEqual(["SAVE+PLACE"]); + tui.stop(); + expect(terminal.getWriteLog().join("")).not.toContain("RESTORE"); + }); + it("closes multipart ownership when terminal loss occurs during prefix flush", async () => { + const { tui, terminal } = await setup(); + const lease = await tui.acquireRasterLease(request("terminal-loss-flush")); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + const flushStarted = Promise.withResolvers(); + const releaseFlush = Promise.withResolvers(); + terminal.flush = async () => { + flushStarted.resolve(); + await releaseFlush.promise; + }; + let available = true; + Object.defineProperty(terminal, "available", { configurable: true, get: () => available }); + terminal.clearWriteLog(); + const pending = tui.submitTerminalOutput({ + token: lease.token, + operation: { + type: "raster-multipart-batch", + prefix: bytes("FLUSH_PREFIX"), + afterPrefix: async () => true, + records: [bytes("FLUSH_BODY")], + abortSuffix: bytes("FLUSH_ABORT"), + }, + }); + await flushStarted.promise; + available = false; + tui.requestRender(true, "terminal-loss-during-flush"); + releaseFlush.resolve(); + + expect((await pending).status).toBe("failed"); + expect(terminal.getWriteLog().join("")).toBe("FLUSH_PREFIX"); + }); + it("unblocks raster ingress when terminal flush ignores lifecycle abort", async () => { + const { tui, terminal } = await setup(); + const lease = await tui.acquireRasterLease(request("flush-never-settles")); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + const flushStarted = Promise.withResolvers(); + const releaseFlush = Promise.withResolvers(); + terminal.flush = async () => { + flushStarted.resolve(); + await releaseFlush.promise; + }; + let callbackCalled = false; + terminal.clearWriteLog(); + const pending = tui.submitTerminalOutput({ + token: lease.token, + operation: { + type: "raster-multipart-batch", + prefix: bytes("FLUSH_SAVE"), + afterPrefix: async () => { + callbackCalled = true; + return true; + }, + records: [bytes("STALE_FLUSH_BODY")], + abortSuffix: bytes("FLUSH_RESTORE"), + }, + }); + await flushStarted.promise; + + tui.stop(); + + const settled = await Promise.race([pending.then(() => true), Bun.sleep(100).then(() => false)]); + expect(settled).toBe(true); + expect((await pending).status).toBe("failed"); + expect(callbackCalled).toBe(false); + expect(terminal.getWriteLog().join("")).not.toContain("STALE_FLUSH_BODY"); + + tui.start(); + expect((await tui.queueTerminalOutput("AFTER_FLUSH_RESTART")).status).toBe("written"); + expect(terminal.getWriteLog().join("")).toContain("AFTER_FLUSH_RESTART"); + releaseFlush.resolve(); + tui.stop(); + }); + it("closes multipart ownership when terminal loss occurs during afterPrefix", async () => { + const { tui, terminal } = await setup(); + const lease = await tui.acquireRasterLease(request("terminal-loss-after-prefix")); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + const callbackStarted = Promise.withResolvers(); + const releaseCallback = Promise.withResolvers(); + let available = true; + Object.defineProperty(terminal, "available", { configurable: true, get: () => available }); + terminal.clearWriteLog(); + const pending = tui.submitTerminalOutput({ + token: lease.token, + operation: { + type: "raster-multipart-batch", + prefix: bytes("CALLBACK_PREFIX"), + afterPrefix: async () => { + callbackStarted.resolve(); + await releaseCallback.promise; + return true; + }, + records: [bytes("CALLBACK_BODY")], + abortSuffix: bytes("CALLBACK_ABORT"), + }, + }); + await callbackStarted.promise; + available = false; + tui.requestRender(true, "terminal-loss-during-after-prefix"); + releaseCallback.resolve(); + + expect((await pending).status).toBe("failed"); + expect(terminal.getWriteLog().join("")).toBe("CALLBACK_PREFIX"); + }); + it("unblocks raster ingress when afterPrefix ignores lifecycle abort", async () => { + const { tui, terminal } = await setup(); + const lease = await tui.acquireRasterLease(request("after-prefix-never-settles")); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + const callbackStarted = Promise.withResolvers(); + const releaseCallback = Promise.withResolvers(); + let callbackSignal: AbortSignal | undefined; + terminal.clearWriteLog(); + const pending = tui.submitTerminalOutput({ + token: lease.token, + operation: { + type: "raster-multipart-batch", + prefix: bytes("SAVE_CURSOR"), + afterPrefix: async signal => { + callbackSignal = signal; + callbackStarted.resolve(); + return releaseCallback.promise; + }, + records: [bytes("STALE_BODY")], + abortSuffix: bytes("RESTORE_CURSOR"), + }, + }); + await callbackStarted.promise; + + tui.stop(); + + expect(callbackSignal?.aborted).toBe(true); + const settled = await Promise.race([pending.then(() => true), Bun.sleep(100).then(() => false)]); + expect(settled).toBe(true); + expect((await pending).status).toBe("failed"); + expect(terminal.getWriteLog().join("")).not.toContain("STALE_BODY"); + + tui.start(); + expect((await tui.queueTerminalOutput("AFTER_RESTART")).status).toBe("written"); + expect(terminal.getWriteLog().join("")).toContain("AFTER_RESTART"); + releaseCallback.resolve(true); + tui.stop(); + }); it("does not write records when the prefix callback returns false or throws", async () => { const { tui, terminal } = await setup(); const lease = await tui.acquireRasterLease(request("prefix-failure")); @@ -838,8 +1018,8 @@ describe("TUI raster lease public boundary", () => { // The retained payload is delivered by the first start() after stop. expect(terminal.getWriteLog().join("")).toContain("PET_ERASE"); - // (b) Started body paused at an await when stop occurs: no suffix/abort/GIF - // bytes and no cursor restoration may follow stop. + // (b) Started body paused at an await when stop occurs: stop synchronously + // closes the prefix barrier, and no body bytes may follow afterward. const lease = await tui.acquireRasterLease(request("paused")); if (lease.status !== "acquired") throw new Error("lease not acquired"); terminal.clearWriteLog(); @@ -865,16 +1045,18 @@ describe("TUI raster lease public boundary", () => { await Bun.sleep(5); expect(terminal.getWriteLog().join("")).toContain("PREFIX"); tui.stop(); + const multipartAtStop = terminal.getWriteLog().join(""); + expect(multipartAtStop).toContain("ABORT_BYTES"); releaseAfterPrefix(); const ack = await submit; expect(ack.status).toBe("failed"); const after = terminal.getWriteLog().join(""); + expect(after).toBe(multipartAtStop); expect(after).not.toContain("SUFFIX"); - expect(after).not.toContain("ABORT_BYTES"); expect(after).not.toContain("GIF"); - // (c) A shouldWrite predicate that stops the terminal mid-operation must - // not emit abort or cursor-restoration bytes either. + // (c) A shouldWrite predicate that stops the terminal before prefix output + // emits neither abort nor body bytes. tui.start(); const lease2 = await tui.acquireRasterLease(request("predicate-stop")); if (lease2.status !== "acquired") throw new Error("lease not acquired"); @@ -896,6 +1078,7 @@ describe("TUI raster lease public boundary", () => { }); expect(ack2.status).toBe("failed"); expect(terminal.getWriteLog().join("")).not.toContain("ABORT2"); + expect(terminal.getWriteLog().join("")).not.toContain("R2"); // (d) New-lifecycle work after restart writes normally. tui.start(); diff --git a/packages/tui/test/render-commit.test.ts b/packages/tui/test/render-commit.test.ts index 669eb540366..1f152cc481b 100644 --- a/packages/tui/test/render-commit.test.ts +++ b/packages/tui/test/render-commit.test.ts @@ -426,6 +426,181 @@ describe("generation-scoped render commits", () => { tui.stop(); }); + it("commits coalesced forced generations behind held raster ingress", async () => { + const terminal = new VirtualTerminal(40, 8); + const tui = new TUI(terminal); + const text = new Text("initial", 0, 0); + tui.addChild(text); + tui.start(); + await terminal.waitForRender(); + const lease = await tui.acquireRasterLease({ + ownerId: "coalesced-force-held-raster", + rect: { column: 0, row: 0, width: 2, height: 1 }, + erase: { type: "raster-erase", bytes: new TextEncoder().encode("COALESCED_ERASE") }, + }); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + const flushGate = Promise.withResolvers(); + const flushStarted = Promise.withResolvers(); + terminal.flush = async () => { + flushStarted.resolve(); + await flushGate.promise; + }; + const held = tui.submitTerminalOutput({ + token: lease.token, + operation: { + type: "raster-multipart-batch", + prefix: new TextEncoder().encode("COALESCED_PREFIX"), + afterPrefix: async () => true, + records: [new TextEncoder().encode("COALESCED_RASTER")], + abortSuffix: new TextEncoder().encode("COALESCED_ABORT"), + }, + }); + try { + await flushStarted.promise; + text.setText("COALESCED_FINAL_FRAME"); + const first = tui.requestRenderWithGeneration(true, "test.coalesced-force.first"); + const second = tui.requestRenderWithGeneration(true, "test.coalesced-force.second"); + const firstCommit = tui.waitForRenderCommit(first); + const secondCommit = tui.waitForRenderCommit(second); + const renderQueued = Promise.withResolvers(); + process.nextTick(renderQueued.resolve); + await renderQueued.promise; + flushGate.resolve(); + expect((await held).status).toBe("written"); + expect(await Promise.all([firstCommit, secondCommit])).toEqual([true, true]); + expect(terminal.getWriteLog().join("")).toContain("FINAL_FRAME"); + } finally { + flushGate.resolve(); + await held; + tui.stop(); + } + }); + + it("fences a render queued behind held raster ingress when disposed", async () => { + const terminal = new VirtualTerminal(40, 8); + const tui = new TUI(terminal); + const text = new Text("initial", 0, 0); + tui.addChild(text); + tui.start(); + await terminal.waitForRender(); + const lease = await tui.acquireRasterLease({ + ownerId: "dispose-held-raster", + rect: { column: 0, row: 0, width: 2, height: 1 }, + erase: { type: "raster-erase", bytes: new TextEncoder().encode("DISPOSE_ERASE") }, + }); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + const ingressGate = Promise.withResolvers(); + const ingressStarted = Promise.withResolvers(); + let disposed = false; + const held = tui.submitTerminalOutput({ + token: lease.token, + operation: { + type: "raster-multipart-batch", + prefix: new TextEncoder().encode("DISPOSE_PREFIX"), + afterPrefix: async () => { + ingressStarted.resolve(); + await ingressGate.promise; + return true; + }, + records: [new TextEncoder().encode("DISPOSE_RASTER")], + abortSuffix: new TextEncoder().encode("DISPOSE_STALE_ABORT"), + }, + }); + try { + await ingressStarted.promise; + terminal.clearWriteLog(); + text.setText("DISPOSE_STALE_RENDER"); + const generation = tui.requestRenderWithGeneration(true, "test.dispose-held-raster"); + const committed = tui.waitForRenderCommit(generation); + // Let the forced frame capture its write closure behind the held ingress. + const renderQueued = Promise.withResolvers(); + process.nextTick(renderQueued.resolve); + await renderQueued.promise; + tui.dispose(); + disposed = true; + expect(await committed).toBe(false); + ingressGate.resolve(); + expect((await held).status).toBe("failed"); + await terminal.waitForRender(); + const output = terminal.getWriteLog().join(""); + expect(output).not.toContain("DISPOSE_STALE_RENDER"); + expect(output).toContain("DISPOSE_STALE_ABORT"); + expect(output).not.toContain("DISPOSE_RASTER"); + } finally { + ingressGate.resolve(); + await held; + if (!disposed) tui.stop(); + } + }); + + it("fences a render queued behind held raster ingress across terminal loss and restart", async () => { + class AvailabilityTerminal extends VirtualTerminal { + live = true; + override get available(): boolean { + return this.live; + } + } + const terminal = new AvailabilityTerminal(40, 8); + const tui = new TUI(terminal); + const text = new Text("initial", 0, 0); + tui.addChild(text); + tui.start(); + await terminal.waitForRender(); + const lease = await tui.acquireRasterLease({ + ownerId: "loss-held-raster", + rect: { column: 0, row: 0, width: 2, height: 1 }, + erase: { type: "raster-erase", bytes: new TextEncoder().encode("LOSS_ERASE") }, + }); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + const ingressGate = Promise.withResolvers(); + const ingressStarted = Promise.withResolvers(); + const held = tui.submitTerminalOutput({ + token: lease.token, + operation: { + type: "raster-multipart-batch", + prefix: new TextEncoder().encode("LOSS_PREFIX"), + afterPrefix: async () => { + ingressStarted.resolve(); + await ingressGate.promise; + return true; + }, + records: [new TextEncoder().encode("LOSS_RASTER")], + abortSuffix: new TextEncoder().encode("LOSS_STALE_ABORT"), + }, + }); + try { + await ingressStarted.promise; + terminal.clearWriteLog(); + text.setText("LOSS_STALE_RENDER"); + const generation = tui.requestRenderWithGeneration(true, "test.loss-held-raster"); + const committed = tui.waitForRenderCommit(generation); + // Let the forced frame capture its write closure behind the held ingress. + const renderQueued = Promise.withResolvers(); + process.nextTick(renderQueued.resolve); + await renderQueued.promise; + terminal.live = false; + const invalidatedGeneration = tui.requestRenderWithGeneration(true, "test.loss-held-raster.invalidate"); + expect(await committed).toBe(false); + expect(await tui.waitForRenderCommit(invalidatedGeneration)).toBe(false); + text.setText("LOSS_FRESH_RENDER"); + terminal.live = true; + tui.start(); + terminal.clearWriteLog(); + ingressGate.resolve(); + expect((await held).status).toBe("failed"); + await terminal.waitForRender(); + const output = terminal.getWriteLog().join(""); + expect(output).not.toContain("LOSS_STALE_RENDER"); + expect(output).not.toContain("LOSS_STALE_ABORT"); + expect(output).not.toContain("LOSS_RASTER"); + expect(output).toContain("LOSS_FRESH_RENDER"); + } finally { + ingressGate.resolve(); + await held; + tui.stop(); + } + }); + it("does not call beforeStart when terminal setup fails", () => { class FailedStartTerminal extends VirtualTerminal { override start(): void {