@gajae-code/coding-agent 0.15.4 → 0.15.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/dist/types/cli/update-cli.d.ts +25 -5
  3. package/dist/types/config/autorouting-tier-map.d.ts +3 -0
  4. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  5. package/dist/types/extensibility/shared-events.d.ts +2 -0
  6. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
  7. package/dist/types/sdk/cli/session-cli.d.ts +42 -1
  8. package/dist/types/sdk/host/host.d.ts +2 -0
  9. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  10. package/dist/types/session/agent-session.d.ts +4 -2
  11. package/dist/types/session/internal/managed-session-storage.d.ts +4 -3
  12. package/dist/types/tools/path-utils.d.ts +5 -6
  13. package/dist/types/tools/tmux-self-injection-guard.d.ts +15 -0
  14. package/package.json +7 -7
  15. package/src/cli/update-cli.ts +102 -1
  16. package/src/commands/sdk.ts +5 -0
  17. package/src/config/autorouting-tier-map.ts +1 -0
  18. package/src/config/model-profiles.ts +5 -5
  19. package/src/config/model-registry.ts +10 -4
  20. package/src/coordinator-mcp/policy.ts +1 -1
  21. package/src/extensibility/extensions/types.ts +3 -0
  22. package/src/extensibility/shared-events.ts +2 -0
  23. package/src/gjc-runtime/launch-worktree.ts +44 -7
  24. package/src/gjc-runtime/ralplan-runtime.ts +14 -1
  25. package/src/gjc-runtime/tmux-sessions.ts +145 -48
  26. package/src/internal-urls/docs-index.generated.ts +7 -7
  27. package/src/modes/prompt-action-autocomplete.ts +26 -47
  28. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  29. package/src/sdk/bus/kind-aware-reconciliation.ts +10 -2
  30. package/src/sdk/bus/prompt-reconciliation.ts +5 -2
  31. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  32. package/src/sdk/cli/session-cli.ts +149 -4
  33. package/src/sdk/host/host.ts +7 -0
  34. package/src/sdk/host/session-runtime.test.ts +821 -22
  35. package/src/sdk/host/session-runtime.ts +669 -67
  36. package/src/sdk/router/session-router.ts +69 -15
  37. package/src/session/agent-session.ts +232 -15
  38. package/src/session/internal/managed-session-storage.ts +64 -26
  39. package/src/session/session-manager.ts +26 -8
  40. package/src/tools/bash.ts +14 -0
  41. package/src/tools/path-utils.ts +77 -29
  42. package/src/tools/tmux-self-injection-guard.ts +359 -0
@@ -8,7 +8,7 @@ import {
8
8
  isSessionAuthorityEligible,
9
9
  type SessionIndex,
10
10
  } from "../broker/session-index";
11
- import { lifecycleRequestTimeoutMs } from "../broker/startup-budget";
11
+ import { cancellableSleep, lifecycleRequestTimeoutMs } from "../broker/startup-budget";
12
12
  import { SdkClient, SdkClientError, type SdkDispatchContext, type SdkDispatchHandler } from "../client/client";
13
13
  import { readSdkBrokerDiscovery, readSdkSessionEndpoint, type SdkSessionEndpoint } from "../client/discovery";
14
14
  import {
@@ -295,6 +295,8 @@ type AttachedSession = {
295
295
  readonly capability: SessionAttachment;
296
296
  readonly notificationSubscription: NotificationSubscription;
297
297
  notificationCancelled: boolean;
298
+ /** Consecutive refused notification publications; reset by the next delivered frame. */
299
+ notificationFailures: number;
298
300
  readonly notificationCursor: { generation: number; seq: number };
299
301
  published: boolean;
300
302
  initializingPublication: boolean;
@@ -342,6 +344,9 @@ const STARTUP_ATTACH_BUDGET_MS = 5_000;
342
344
  const STARTUP_STRAGGLER_LOG_LIMIT = 8;
343
345
  const ATTACH_CONNECT_TIMEOUT_MS = 10_000;
344
346
  const NOTIFICATION_WORK_TIMEOUT_MS = 5_000;
347
+ // Core shutdown waits only for Router reconciliation and client close; provider
348
+ // notification work is bounded separately by NOTIFICATION_WORK_TIMEOUT_MS.
349
+ const ROUTER_SHUTDOWN_TIMEOUT_MS = 5_000;
345
350
  /**
346
351
  * Idle liveness-sweep cadence (#4689). When the session index is unchanged and
347
352
  * no adoption is pending, the 2s reconcile tick is only a change-stamp check;
@@ -352,6 +357,17 @@ const NOTIFICATION_WORK_TIMEOUT_MS = 5_000;
352
357
  */
353
358
  const SESSION_ROUTER_IDLE_SWEEP_MS = 30_000;
354
359
  const NOTIFICATION_WORK_TIMEOUT = Symbol("notification_work_timeout");
360
+ /**
361
+ * Consecutive local notification-dispatch failures tolerated before the
362
+ * subscription is conceded. A provider that refuses ONE publication is not a
363
+ * dead subscription: it has already settled that publication as rejected on its
364
+ * own side, and the next frame normally succeeds. Cancelling on the first
365
+ * rejection latched `notificationCancelled` for the life of the
366
+ * `AttachedSession`, which only a new attachment clears -- a still-running
367
+ * session never gets one, so its mirroring stayed dead until the session was
368
+ * restarted. Mirrors {@link DELIVERY_ATTEMPT_LIMIT} on the ordered path.
369
+ */
370
+ const NOTIFICATION_LOCAL_FAILURE_LIMIT = 5;
355
371
  /**
356
372
  * Client-message types the native session server authorizes with the
357
373
  * per-session endpoint token (`tokens_match` in crates/gjc-sdk server.rs).
@@ -552,10 +568,16 @@ export class SessionRouter {
552
568
  // initial replay delivered. Past that budget the pass continues in the
553
569
  // background instead of owning the caller's deadline.
554
570
  const initialPass = this.#serialReconcile(runEpoch, false, true);
571
+ // Cancelled as soon as the race settles. Promise.race leaves the loser pending,
572
+ // and a pending Bun.sleep keeps the event loop alive, so a startup that settled
573
+ // in milliseconds still owed the rest of the budget before the process could exit.
574
+ const startupCutoff = new AbortController();
555
575
  const settled = await Promise.race([
556
576
  initialPass.then(() => true),
557
- Bun.sleep(this.#deps.startupAttachBudgetMs ?? STARTUP_ATTACH_BUDGET_MS).then(() => false),
558
- ]);
577
+ cancellableSleep(this.#deps.startupAttachBudgetMs ?? STARTUP_ATTACH_BUDGET_MS, startupCutoff.signal).then(
578
+ () => false,
579
+ ),
580
+ ]).finally(() => startupCutoff.abort());
559
581
  if (!settled) {
560
582
  // Name the sessions the budget was spent on. Without this the lapse is
561
583
  // silent and the only symptom is a slow startup somewhere else, which
@@ -586,6 +608,10 @@ export class SessionRouter {
586
608
  () => this.#schedule(this.#serialReconcile(runEpoch, true)),
587
609
  2_000,
588
610
  );
611
+ // Reconciliation is background upkeep and must not be the reason a host process
612
+ // stays alive. stop() still clears it on the normal path; this only keeps a
613
+ // caller that never stops the Router from hanging at exit.
614
+ timer.unref?.();
589
615
  this.#stopTimer = () => (this.#deps.clearInterval ?? clearInterval)(timer);
590
616
  } catch (error) {
591
617
  if (this.#running(runEpoch)) await this.stop();
@@ -695,13 +721,18 @@ export class SessionRouter {
695
721
  // Provider notification work is detached and bounded independently; core
696
722
  // shutdown waits only for Router reconciliation and client close.
697
723
  const pending = Promise.allSettled([this.#reconcileTail, ...shutdownTasks]);
724
+ // Cancelled once the race settles, so a completed shutdown does not hold the
725
+ // event loop open for the remainder of the timeout.
726
+ const shutdownCutoff = new AbortController();
698
727
  const outcome = await Promise.race([
699
728
  pending.then(results => ({ kind: "settled" as const, results })),
700
- Bun.sleep(5_000).then(() => ({ kind: "timeout" as const })),
701
- ]);
729
+ cancellableSleep(ROUTER_SHUTDOWN_TIMEOUT_MS, shutdownCutoff.signal).then(() => ({
730
+ kind: "timeout" as const,
731
+ })),
732
+ ]).finally(() => shutdownCutoff.abort());
702
733
  if (outcome.kind === "timeout") {
703
734
  logger.warn(
704
- "SessionRouter shutdown exceeded 5000ms; authority is revoked and cleanup continues in background.",
735
+ `SessionRouter shutdown exceeded ${ROUTER_SHUTDOWN_TIMEOUT_MS}ms; authority is revoked and cleanup continues in background.`,
705
736
  );
706
737
  return;
707
738
  }
@@ -1548,6 +1579,7 @@ export class SessionRouter {
1548
1579
  capability,
1549
1580
  notificationSubscription,
1550
1581
  notificationCancelled: false,
1582
+ notificationFailures: 0,
1551
1583
  notificationCursor,
1552
1584
  published: false,
1553
1585
  publication,
@@ -1780,10 +1812,20 @@ export class SessionRouter {
1780
1812
  }
1781
1813
 
1782
1814
  async #boundedNotificationWork<T>(work: () => Promise<T> | T): Promise<T | typeof NOTIFICATION_WORK_TIMEOUT> {
1783
- const timeout: Promise<typeof NOTIFICATION_WORK_TIMEOUT> = Bun.sleep(NOTIFICATION_WORK_TIMEOUT_MS).then(
1784
- () => NOTIFICATION_WORK_TIMEOUT,
1785
- );
1786
- return await Promise.race([Promise.resolve().then(work), timeout]);
1815
+ // The dominant cost of a one-shot `gjc sdk` command: notification work that
1816
+ // finished in milliseconds left this timeout pending, and a pending Bun.sleep
1817
+ // keeps the event loop alive, so the process wrote its response and then idled
1818
+ // for the full budget before exiting.
1819
+ const cutoff = new AbortController();
1820
+ const timeout: Promise<typeof NOTIFICATION_WORK_TIMEOUT> = cancellableSleep(
1821
+ NOTIFICATION_WORK_TIMEOUT_MS,
1822
+ cutoff.signal,
1823
+ ).then(() => NOTIFICATION_WORK_TIMEOUT);
1824
+ try {
1825
+ return await Promise.race([Promise.resolve().then(work), timeout]);
1826
+ } finally {
1827
+ cutoff.abort();
1828
+ }
1787
1829
  }
1788
1830
 
1789
1831
  #detachNotification(
@@ -1827,15 +1869,25 @@ export class SessionRouter {
1827
1869
  this.#detachNotification(attached, "cancelled");
1828
1870
  return;
1829
1871
  }
1872
+ // A delivered frame ends the consecutive-failure run.
1873
+ attached.notificationFailures = 0;
1830
1874
  if (frame.seq !== undefined)
1831
1875
  attached.notificationSubscription.advanceCursor(frame.generation ?? attached.generation, frame.seq);
1832
1876
  },
1833
1877
  (error: unknown) => {
1834
- this.#detachNotification(attached, "cancelled");
1878
+ // Concede only after a bounded consecutive run: a single refused
1879
+ // publication drops that frame, it does not end the subscription.
1880
+ attached.notificationFailures += 1;
1881
+ const detail = error instanceof Error ? error.message : String(error);
1882
+ if (attached.notificationFailures >= NOTIFICATION_LOCAL_FAILURE_LIMIT) {
1883
+ this.#detachNotification(attached, "cancelled");
1884
+ logger.warn(
1885
+ `SDK notification subscription ${attached.notificationSubscription.subscriptionId} failed locally ${attached.notificationFailures} times in a row; cancelling: ${detail}`,
1886
+ );
1887
+ return;
1888
+ }
1835
1889
  logger.warn(
1836
- `SDK notification subscription ${attached.notificationSubscription.subscriptionId} failed locally: ${
1837
- error instanceof Error ? error.message : String(error)
1838
- }`,
1890
+ `SDK notification subscription ${attached.notificationSubscription.subscriptionId} refused one frame (${attached.notificationFailures}/${NOTIFICATION_LOCAL_FAILURE_LIMIT}); subscription retained: ${detail}`,
1839
1891
  );
1840
1892
  },
1841
1893
  );
@@ -2092,7 +2144,9 @@ export class SessionRouter {
2092
2144
  this.#failBarrier(attached, "replay went unanswered");
2093
2145
  return;
2094
2146
  }
2095
- await Bun.sleep(REPLAY_RETRY_BACKOFF_MS * 2 ** attempt);
2147
+ // Awaited backoff rather than a raced timeout, but it must still not sleep
2148
+ // through a shutdown: cancelling on stop lets the process exit promptly.
2149
+ await cancellableSleep(REPLAY_RETRY_BACKOFF_MS * 2 ** attempt, this.#stopController.signal);
2096
2150
  if (attached.barrier.held !== held || !this.#attachmentLive(attached)) return;
2097
2151
  }
2098
2152
  }
@@ -124,6 +124,7 @@ import {
124
124
  EMPTY_RESPONSE_PROVIDER_CODE,
125
125
  type FallbackAttemptToken,
126
126
  type FallbackTriggerClass,
127
+ SERVER_OVERLOADED_PROVIDER_CODE,
127
128
  STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE,
128
129
  } from "@gajae-code/ai/utils/fallback-transport";
129
130
  import { AttemptRecordStore } from "./attempt-record-store";
@@ -1047,6 +1048,8 @@ export interface PromptOptions {
1047
1048
  onSkillPrepared?: (meta: { name: string; path: string; lineCount?: number; cleanedArgs?: string }) => void;
1048
1049
  /** Optional invocation-scoped cancellation fence used before an accepted skill starts execution. */
1049
1050
  preflightSignal?: AbortSignal;
1051
+ /** Internal SDK lifecycle owner token. */
1052
+ sdkRunToken?: string;
1050
1053
  }
1051
1054
 
1052
1055
  function promptPreflightCancelledError(): Error {
@@ -1297,11 +1300,74 @@ function isBareDefaultMessageOnlyFirstEventTimeout(message: AssistantMessage): b
1297
1300
  BARE_DEFAULT_WATCHDOG_ERROR.test(message.errorMessage ?? ""))
1298
1301
  );
1299
1302
  }
1303
+ /**
1304
+ * True when the attempt's structured evidence is exactly the provider's typed
1305
+ * capacity-overload code and nothing else: no status, no retained retry header,
1306
+ * no second typed code, no error kind, and none of the transport's other
1307
+ * observations (request size, first-event timing, endpoint class, provider retry
1308
+ * ceiling). The code arrives statuslessly (a Codex error event, an HTTP 200
1309
+ * terminal Responses envelope), so these facts ARE the overload rather than
1310
+ * evidence that the attempt did observable work; any additional fact means the
1311
+ * attempt is no longer provably that bare capacity rejection. The code is
1312
+ * compared case-sensitively, matching the parser and the transport gate.
1313
+ */
1314
+ function isExactTypedOverloadFacts(message: AssistantMessage): boolean {
1315
+ if (message.errorKind !== undefined || message.errorStatus !== undefined) return false;
1316
+ const facts = message.transportFailure;
1317
+ if (!facts) return false;
1318
+ return (
1319
+ facts.providerCode === SERVER_OVERLOADED_PROVIDER_CODE &&
1320
+ facts.status === undefined &&
1321
+ facts.anthropicErrorType === undefined &&
1322
+ (facts.openaiErrorCode === undefined || facts.openaiErrorCode === SERVER_OVERLOADED_PROVIDER_CODE) &&
1323
+ (facts.headers === undefined || Object.keys(facts.headers).length === 0) &&
1324
+ facts.requestBytes === undefined &&
1325
+ facts.firstEventElapsedMs === undefined &&
1326
+ facts.firstEventTimeoutMs === undefined &&
1327
+ facts.endpointClass === undefined &&
1328
+ facts.retryMaxAttempts === undefined
1329
+ );
1330
+ }
1331
+ /**
1332
+ * True when transport facts are exactly the provider's statusless typed
1333
+ * capacity-overload code (the provider code verbatim and, when present, the
1334
+ * OpenAI code verbatim, with no status). The agent loop must not treat the new
1335
+ * facts as managed transaction authority, and managed session fallback must
1336
+ * not gain retry/advance authority from them, so this predicate keeps both
1337
+ * guards reading only typed facts, never error prose.
1338
+ */
1339
+ function isStatuslessTypedOverloadFacts(facts: TransportFailureFacts | undefined): boolean {
1340
+ if (!facts) return false;
1341
+ return (
1342
+ facts.status === undefined &&
1343
+ facts.providerCode === SERVER_OVERLOADED_PROVIDER_CODE &&
1344
+ (facts.openaiErrorCode === undefined || facts.openaiErrorCode === SERVER_OVERLOADED_PROVIDER_CODE)
1345
+ );
1346
+ }
1347
+
1300
1348
  function isBareDefaultCodexOverload(message: AssistantMessage): boolean {
1301
1349
  return (
1302
1350
  message.api === "openai-codex-responses" &&
1303
1351
  BARE_DEFAULT_CODEX_OVERLOAD_ERROR.test(message.errorMessage ?? "") &&
1304
- !hasBareDefaultRetryDisqualifyingFacts(message) &&
1352
+ (isExactTypedOverloadFacts(message) || !hasBareDefaultRetryDisqualifyingFacts(message)) &&
1353
+ !assistantMessageHasVisibleOrToolContent(message)
1354
+ );
1355
+ }
1356
+
1357
+ /**
1358
+ * True when a generic OpenAI Responses turn failed with exactly OpenAI's typed
1359
+ * capacity-overload code. Unlike the Codex and Anthropic admissions this reads
1360
+ * no prose at all: the terminal Responses envelope carries the code in typed
1361
+ * transport facts, so the code plus a content-free attempt is the whole
1362
+ * admission. Both `openaiErrorCode` and the derived `providerCode` must be the
1363
+ * exact code, so a different transport's overload facts cannot enter through
1364
+ * this path.
1365
+ */
1366
+ function isBareDefaultOpenAIResponsesOverload(message: AssistantMessage): boolean {
1367
+ return (
1368
+ message.api === "openai-responses" &&
1369
+ message.transportFailure?.openaiErrorCode === SERVER_OVERLOADED_PROVIDER_CODE &&
1370
+ isExactTypedOverloadFacts(message) &&
1305
1371
  !assistantMessageHasVisibleOrToolContent(message)
1306
1372
  );
1307
1373
  }
@@ -2365,6 +2431,8 @@ export class AgentSession {
2365
2431
  this.#followUpPromotionHooks.delete(message);
2366
2432
  followUpHook({ startsOwnRun: true, removed: true });
2367
2433
  }
2434
+ this.#sdkRunTokensByQueuedMessage.delete(message);
2435
+ this.#deepInterviewGenuineUserMessageEpochs.delete(message);
2368
2436
  }
2369
2437
  }
2370
2438
  #fireQueuedPromotionHooks(messages: readonly AgentMessage[], promotion?: { startsOwnRun?: boolean }): void {
@@ -2384,6 +2452,29 @@ export class AgentSession {
2384
2452
  }
2385
2453
  }
2386
2454
  }
2455
+ #queuedMessagesForSessionTransition(): AgentMessage[] {
2456
+ return [
2457
+ ...new Set([
2458
+ ...this.agent.snapshotSteering(),
2459
+ ...this.agent.snapshotFollowUp(),
2460
+ ...this.#deferredSdkFollowUps,
2461
+ ]),
2462
+ ];
2463
+ }
2464
+ /** Drop queued SDK work when the session identity is replaced. The old
2465
+ * promotion hooks belong to the predecessor runtime; retaining the message
2466
+ * would let it execute later under the successor without an owner. */
2467
+ #terminalizeQueuedSdkWorkForSessionTransition(messages: readonly AgentMessage[]): void {
2468
+ if (messages.length === 0) return;
2469
+ this.#fireQueuedRemovalHooks(messages);
2470
+ for (const message of messages) this.#sdkRunTokensByQueuedMessage.delete(message);
2471
+ this.#deferredSdkFollowUps = this.#deferredSdkFollowUps.filter(message => !messages.includes(message));
2472
+ }
2473
+ #resetActiveSdkRunOwnership(): void {
2474
+ this.#activeSdkRunToken = undefined;
2475
+ this.#activeAttemptScope = undefined;
2476
+ this.#activeLogicalRunId = undefined;
2477
+ }
2387
2478
  /** Messages queued to be included with the next user prompt as context ("asides"). */
2388
2479
  #pendingNextTurnMessages: Array<{ message: CustomMessage; origin: "turn" | "external" }> = [];
2389
2480
  #scheduledHiddenNextTurnGeneration: number | undefined = undefined;
@@ -2568,6 +2659,8 @@ export class AgentSession {
2568
2659
  /** SDK follow-up ownership by queued message and the attempt that dequeues it. */
2569
2660
  #sdkRunTokensByQueuedMessage = new WeakMap<AgentMessage, string>();
2570
2661
  #sdkRunTokensByAttemptScope = new WeakMap<AttemptScope, string>();
2662
+ #activeSdkRunToken: string | undefined;
2663
+ #activeAttemptScope: AttemptScope | undefined;
2571
2664
  #attemptAuthority!: AttemptScopeAuthority;
2572
2665
  #attemptRecordStore!: AttemptRecordStore;
2573
2666
  #activeLogicalRunId: AttemptRunHandle["logicalRunId"] | undefined;
@@ -2576,6 +2669,7 @@ export class AgentSession {
2576
2669
  // previously recorded run id rather than throwing inside the callback.
2577
2670
  if (!handle) return;
2578
2671
  this.#activeLogicalRunId = handle.logicalRunId;
2672
+ this.#activeAttemptScope = handle.scope;
2579
2673
  }
2580
2674
 
2581
2675
  #turnIndex = 0;
@@ -3975,7 +4069,20 @@ export class AgentSession {
3975
4069
  // when the batch is drained by a continuation the message did not
3976
4070
  // schedule (a skipped continuation must never discard the correlation
3977
4071
  // of work that is still consumed; review thread P2).
4072
+ const consumedSdkRunToken =
4073
+ promotion.startsOwnRun === true
4074
+ ? [...messages, ...dropped]
4075
+ .map(message => this.#sdkRunTokensByQueuedMessage.get(message))
4076
+ .find((token): token is string => token !== undefined)
4077
+ : undefined;
3978
4078
  this.#fireQueuedPromotionHooks(messages, promotion);
4079
+ if (consumedSdkRunToken !== undefined) this.#activeSdkRunToken = consumedSdkRunToken;
4080
+ // Dropped or in-run follow-ups have no later own-run acceptance callback;
4081
+ // delete those tokens now. Own-run messages stay mapped until
4082
+ // #scheduleAgentContinue.onRunAccepted binds the new attempt scope.
4083
+ for (const message of dropped) this.#sdkRunTokensByQueuedMessage.delete(message);
4084
+ if (promotion.startsOwnRun !== true)
4085
+ for (const message of messages) this.#sdkRunTokensByQueuedMessage.delete(message);
3979
4086
  };
3980
4087
  // Steering consumed mid-run never starts its own run: fire the stored
3981
4088
  // promotion hook at the REAL dequeue boundary so the SDK attaches the
@@ -6368,18 +6475,23 @@ export class AgentSession {
6368
6475
  // agent_start (review P1); their queued messages are in-run
6369
6476
  // consumptions, not own-run promotions.
6370
6477
  const startsOwn = options?.maintenanceContinuation !== true;
6478
+ const consumedSdkRunToken = startsOwn
6479
+ ? acceptance.consumedQueuedMessages
6480
+ .map(message => this.#sdkRunTokensByQueuedMessage.get(message))
6481
+ .find((token): token is string => token !== undefined)
6482
+ : undefined;
6371
6483
  this.#fireQueuedPromotionHooks(acceptance.consumedQueuedMessages, {
6372
6484
  startsOwnRun: startsOwn,
6373
6485
  });
6374
- for (const message of acceptance.consumedQueuedMessages) {
6375
- const sdkRunToken = this.#sdkRunTokensByQueuedMessage.get(message);
6376
- if (sdkRunToken) {
6377
- this.#sdkRunTokensByAttemptScope.set(handle.scope, sdkRunToken);
6378
- break;
6379
- }
6380
- }
6486
+ if (consumedSdkRunToken !== undefined)
6487
+ this.#sdkRunTokensByAttemptScope.set(handle.scope, consumedSdkRunToken);
6488
+ if (startsOwn) this.#activeSdkRunToken = consumedSdkRunToken;
6381
6489
  this.#acceptRunHandle(handle);
6382
6490
  options?.onRunAccepted?.(handle);
6491
+ // Keep the queued token available through the acceptance callback;
6492
+ // SDK follow-up owners bind it to the new attempt scope there.
6493
+ for (const message of acceptance.consumedQueuedMessages)
6494
+ this.#sdkRunTokensByQueuedMessage.delete(message);
6383
6495
  settleLease();
6384
6496
  releasePredecessor();
6385
6497
  if (startsQueuedSuccessor) {
@@ -7441,12 +7553,30 @@ export class AgentSession {
7441
7553
  await this.#flushWorkerIntegrationForAgentEnd();
7442
7554
  }
7443
7555
  const deliveryScope = scope ?? (event as AgentSessionEvent & { scope?: AttemptScopeRef }).scope;
7556
+ const eventToken = (event as AgentSessionEvent & { sdkRunToken?: unknown }).sdkRunToken;
7557
+ const sdkRunToken =
7558
+ typeof eventToken === "string"
7559
+ ? eventToken
7560
+ : deliveryScope
7561
+ ? this.#sdkRunTokensByAttemptScope.get(deliveryScope as AttemptScope)
7562
+ : this.#activeSdkRunToken;
7444
7563
  const isTerminalAgentEnd =
7445
7564
  event.type === "agent_end" && !(event.stopReason === "maintenance" && event.maintenanceOutcome !== "aborted");
7446
7565
  const finishAttempt = () => {
7447
7566
  if (!isTerminalAgentEnd) return;
7448
- if (deliveryScope) this.#attemptRecordStore.retire(deliveryScope as AttemptScope);
7449
- this.#activeLogicalRunId = undefined;
7567
+ const isActiveAttempt =
7568
+ deliveryScope === undefined ||
7569
+ this.#activeAttemptScope === undefined ||
7570
+ deliveryScope === this.#activeAttemptScope;
7571
+ if (deliveryScope) {
7572
+ this.#attemptRecordStore.retire(deliveryScope as AttemptScope);
7573
+ this.#sdkRunTokensByAttemptScope.delete(deliveryScope as AttemptScope);
7574
+ }
7575
+ if (isActiveAttempt && sdkRunToken === this.#activeSdkRunToken) this.#activeSdkRunToken = undefined;
7576
+ if (isActiveAttempt) {
7577
+ this.#activeAttemptScope = undefined;
7578
+ this.#activeLogicalRunId = undefined;
7579
+ }
7450
7580
  };
7451
7581
  if (!this.#extensionRunner) {
7452
7582
  finishAttempt();
@@ -7459,9 +7589,7 @@ export class AgentSession {
7459
7589
  await this.#extensionRunner.emit(
7460
7590
  {
7461
7591
  type: "agent_start",
7462
- ...(deliveryScope
7463
- ? { sdkRunToken: this.#sdkRunTokensByAttemptScope.get(deliveryScope as AttemptScope) }
7464
- : {}),
7592
+ ...(sdkRunToken ? { sdkRunToken } : {}),
7465
7593
  },
7466
7594
  undefined,
7467
7595
  deliveryScope,
@@ -7472,6 +7600,7 @@ export class AgentSession {
7472
7600
  type: "agent_failed",
7473
7601
  error: sanitizePromptFailure(event.error),
7474
7602
  scope: event.scope,
7603
+ ...(sdkRunToken ? { sdkRunToken } : {}),
7475
7604
  },
7476
7605
  undefined,
7477
7606
  deliveryScope,
@@ -7483,6 +7612,7 @@ export class AgentSession {
7483
7612
  messages: event.messages,
7484
7613
  stopReason: event.stopReason,
7485
7614
  maintenanceOutcome: event.maintenanceOutcome,
7615
+ ...(sdkRunToken ? { sdkRunToken } : {}),
7486
7616
  },
7487
7617
  undefined,
7488
7618
  deliveryScope,
@@ -9571,7 +9701,7 @@ export class AgentSession {
9571
9701
  args = "",
9572
9702
  options?: Pick<
9573
9703
  PromptOptions,
9574
- "onPreflightAccepted" | "onPreflightAcceptCommit" | "onSkillPrepared" | "preflightSignal"
9704
+ "onPreflightAccepted" | "onPreflightAcceptCommit" | "onSkillPrepared" | "preflightSignal" | "sdkRunToken"
9575
9705
  >,
9576
9706
  ): Promise<{ name: string; path: string; args?: string; lineCount?: number }> {
9577
9707
  if (options?.preflightSignal?.aborted) throw promptPreflightCancelledError();
@@ -10330,6 +10460,7 @@ export class AgentSession {
10330
10460
  ? { onPreflightAcceptCommit: options.onPreflightAcceptCommit }
10331
10461
  : {}),
10332
10462
  ...(options.preflightSignal ? { preflightSignal: options.preflightSignal } : {}),
10463
+ ...(options.sdkRunToken ? { sdkRunToken: options.sdkRunToken } : {}),
10333
10464
  }
10334
10465
  : undefined,
10335
10466
  );
@@ -10583,6 +10714,7 @@ export class AgentSession {
10583
10714
  | "onPreflightAccepted"
10584
10715
  | "onPreflightAcceptCommit"
10585
10716
  | "preflightSignal"
10717
+ | "sdkRunToken"
10586
10718
  >,
10587
10719
  ): Promise<void> {
10588
10720
  if (options?.preflightSignal?.aborted) throw promptPreflightCancelledError();
@@ -10684,6 +10816,7 @@ export class AgentSession {
10684
10816
  | "onPreflightAccepted"
10685
10817
  | "onPreflightAcceptCommit"
10686
10818
  | "preflightSignal"
10819
+ | "sdkRunToken"
10687
10820
  > & {
10688
10821
  prependMessages?: AgentMessage[];
10689
10822
  skipPostPromptRecoveryWait?: boolean;
@@ -11017,8 +11150,13 @@ export class AgentSession {
11017
11150
  const agentPromptOptions = {
11018
11151
  ...(options?.toolChoice ? { toolChoice: options.toolChoice } : undefined),
11019
11152
  ...this.#managedFallbackPromptOptions(),
11153
+ ...(options?.sdkRunToken ? { sdkRunToken: options.sdkRunToken } : {}),
11020
11154
  onRunAccepted: (handle: AttemptRunHandle) => {
11021
11155
  this.#acceptRunHandle(handle);
11156
+ if (options?.sdkRunToken) {
11157
+ this.#sdkRunTokensByAttemptScope.set(handle.scope, options.sdkRunToken);
11158
+ this.#activeSdkRunToken = options.sdkRunToken;
11159
+ }
11022
11160
  options?.onRunAccepted?.(handle);
11023
11161
  options?.admissionLease?.release();
11024
11162
  // R3.3: the accepted-run wrapper is the exact acceptance boundary —
@@ -12110,6 +12248,7 @@ export class AgentSession {
12110
12248
  await this.prompt(text, {
12111
12249
  expandPromptTemplates: false,
12112
12250
  images,
12251
+ sdkRunToken: options?.sdkRunToken,
12113
12252
  onPreflightAccepted: () => {
12114
12253
  options?.onPreflightAccepted?.();
12115
12254
  fireQueuedPromotion();
@@ -13461,6 +13600,8 @@ export class AgentSession {
13461
13600
  this.#cancelOwnAsyncJobs();
13462
13601
  this.#closeAllProviderSessions("new session");
13463
13602
  this.#rebindProviderSessionState(new Map());
13603
+ this.#terminalizeQueuedSdkWorkForSessionTransition(this.#queuedMessagesForSessionTransition());
13604
+ this.#resetActiveSdkRunOwnership();
13464
13605
  this.agent.reset();
13465
13606
  if (!options?.drop) await this.sessionManager.flush();
13466
13607
  const noLeasePreviousSessionIdentity = this.sessionManager.getSessionId();
@@ -13558,6 +13699,8 @@ export class AgentSession {
13558
13699
  this.#disconnectFromAgent();
13559
13700
  this.#closeAllProviderSessions("new session");
13560
13701
  this.#rebindProviderSessionState(new Map());
13702
+ this.#terminalizeQueuedSdkWorkForSessionTransition(this.#queuedMessagesForSessionTransition());
13703
+ this.#resetActiveSdkRunOwnership();
13561
13704
  this.agent.reset();
13562
13705
  this.setTodoPhases([]);
13563
13706
  this.#syncAgentSessionId();
@@ -13680,6 +13823,8 @@ export class AgentSession {
13680
13823
  this.yieldQueue.clear();
13681
13824
  this.#pendingBackgroundExchanges = [];
13682
13825
  this.#closeAllProviderSessions("context clear");
13826
+ this.#terminalizeQueuedSdkWorkForSessionTransition(this.#queuedMessagesForSessionTransition());
13827
+ this.#resetActiveSdkRunOwnership();
13683
13828
  this.agent.reset();
13684
13829
  await this.sessionManager.flush();
13685
13830
  this.sessionManager.appendContextClearEntry({ sessionId });
@@ -16104,6 +16249,7 @@ export class AgentSession {
16104
16249
  const rollbackPendingNextTurnMessages = [...this.#pendingNextTurnMessages];
16105
16250
  const rollbackScheduledHiddenNextTurnGeneration = this.#scheduledHiddenNextTurnGeneration;
16106
16251
  const rollbackTodoReminderCount = this.#todoReminderCount;
16252
+ const rollbackDeferredSdkFollowUps = [...this.#deferredSdkFollowUps];
16107
16253
  // Snapshot the agent's executable queues so a rollback restores queued
16108
16254
  // user work that agent.reset() would otherwise clear.
16109
16255
  const rollbackAgentSteeringQueue = this.agent.snapshotSteering();
@@ -16144,6 +16290,12 @@ export class AgentSession {
16144
16290
  this.#rekeyJobManagerForSessionIdentity(rollbackSessionState.sessionId, rollbackSessionState.sessionFile);
16145
16291
  committed = true;
16146
16292
  await this.#runToolSessionTransitionCleanups();
16293
+ this.#terminalizeQueuedSdkWorkForSessionTransition([
16294
+ ...rollbackAgentSteeringQueue,
16295
+ ...rollbackAgentFollowUpQueue,
16296
+ ...rollbackDeferredSdkFollowUps,
16297
+ ]);
16298
+ this.#resetActiveSdkRunOwnership();
16147
16299
  this.agent.reset();
16148
16300
  this.#syncAgentSessionId();
16149
16301
  this.#rekeyHindsightMemoryForCurrentSessionId();
@@ -16227,6 +16379,7 @@ export class AgentSession {
16227
16379
  this.#pendingNextTurnMessages = rollbackPendingNextTurnMessages;
16228
16380
  this.#scheduledHiddenNextTurnGeneration = rollbackScheduledHiddenNextTurnGeneration;
16229
16381
  this.#todoReminderCount = rollbackTodoReminderCount;
16382
+ this.#deferredSdkFollowUps = rollbackDeferredSdkFollowUps;
16230
16383
  this.#syncTodoPhasesFromBranch();
16231
16384
  // Exact-discard only the staged successor; predecessor state was never adopted.
16232
16385
  const rollbackError = prepared
@@ -18995,6 +19148,18 @@ export class AgentSession {
18995
19148
  },
18996
19149
  };
18997
19150
  }
19151
+ // Issue #5018 preserves managed behavior for the typed statusless
19152
+ // Responses overload: before the code survived transport, this failure
19153
+ // reached the session as an ordinary committed error, so the chain never
19154
+ // discarded or advanced on it. Route it to the exhaustion decision
19155
+ // directly, before the retryable path can classify its new facts.
19156
+ if (isStatuslessTypedOverloadFacts(outcome.failure.transportFailure)) {
19157
+ this.#defaultFallbackChain().resetAttemptBudget();
19158
+ return this.#managedFallbackExhaustionDecision(
19159
+ outcome.failure.message,
19160
+ outcome.failure.message.errorMessage || "Model fallback attempt failed",
19161
+ );
19162
+ }
18998
19163
  return this.#handleRetryableError(
18999
19164
  outcome.failure.message,
19000
19165
  true,
@@ -19453,7 +19618,10 @@ export class AgentSession {
19453
19618
  }
19454
19619
  const firstEventTimeout = classification === "first_event_timeout";
19455
19620
  const emptyResponse = classification === "empty_response";
19456
- const canReplayProviderOverload = isBareDefaultCodexOverload(message) || isBareDefaultAnthropicOverload(message);
19621
+ const canReplayProviderOverload =
19622
+ isBareDefaultCodexOverload(message) ||
19623
+ isBareDefaultAnthropicOverload(message) ||
19624
+ isBareDefaultOpenAIResponsesOverload(message);
19457
19625
  const reportedRetryMaxAttempts = transportFailure?.retryMaxAttempts;
19458
19626
  if (reportedRetryMaxAttempts !== undefined) {
19459
19627
  this.#providerRetryMaxAttempts = Math.min(
@@ -19496,6 +19664,17 @@ export class AgentSession {
19496
19664
  const trigger:
19497
19665
  | { class: FallbackTriggerClass; retryAfterMs?: number; authDisposition?: AuthDisposition }
19498
19666
  | undefined = this.#fallbackTriggerFor(message, !managedFallback, transportFailure);
19667
+ // OpenAI's typed statusless capacity-overload code (issue #5018) must not
19668
+ // gain managed-chain retry/advance authority from its new facts. Before
19669
+ // the code survived transport, this failure reached the session as an
19670
+ // ordinary committed error and surfaced immediately, so mirror that
19671
+ // behavior with the existing exhaustion decision.
19672
+ if (managedFallback && isStatuslessTypedOverloadFacts(transportFailure)) {
19673
+ return this.#managedFallbackExhaustionDecision(
19674
+ message,
19675
+ message.errorMessage || "Model fallback attempt failed",
19676
+ );
19677
+ }
19499
19678
  if (!trigger) {
19500
19679
  return managedOutcome
19501
19680
  ? this.#managedFallbackExhaustionDecision(message, message.errorMessage || "Model fallback attempt failed")
@@ -19934,6 +20113,7 @@ export class AgentSession {
19934
20113
  preSubmit: PreSubmitBuilder,
19935
20114
  options?: {
19936
20115
  toolChoice?: ToolChoice;
20116
+ sdkRunToken?: string;
19937
20117
  fallbackManaged?: boolean;
19938
20118
  onRunAccepted?: (handle: AttemptRunHandle) => void;
19939
20119
  },
@@ -21233,6 +21413,10 @@ export class AgentSession {
21233
21413
  const previousSystemPrompt = this.agent.state.systemPrompt;
21234
21414
  const previousAgentSteeringQueue = this.agent.snapshotSteering();
21235
21415
  const previousAgentFollowUpQueue = this.agent.snapshotFollowUp();
21416
+ const previousDeferredSdkFollowUps = [...this.#deferredSdkFollowUps];
21417
+ const previousActiveSdkRunToken = this.#activeSdkRunToken;
21418
+ const previousActiveAttemptScope = this.#activeAttemptScope;
21419
+ const previousActiveLogicalRunId = this.#activeLogicalRunId;
21236
21420
 
21237
21421
  this.#steeringMessages = [];
21238
21422
  this.#followUpMessages = [];
@@ -21280,6 +21464,7 @@ export class AgentSession {
21280
21464
  // The target session is loaded and MCP selections are restored: discard
21281
21465
  // pre-switch delivery queues before completing the restored agent state.
21282
21466
  this.agent.clearAllQueues();
21467
+ this.#resetActiveSdkRunOwnership();
21283
21468
 
21284
21469
  if (historyRewriteReason) {
21285
21470
  this.agent.replaceMessages(sessionContext.messages, {
@@ -21464,6 +21649,12 @@ export class AgentSession {
21464
21649
  ...(options?.transition ? { transition: options.transition } : {}),
21465
21650
  });
21466
21651
  }
21652
+ this.#terminalizeQueuedSdkWorkForSessionTransition([
21653
+ ...previousAgentSteeringQueue,
21654
+ ...previousAgentFollowUpQueue,
21655
+ ...previousDeferredSdkFollowUps,
21656
+ ]);
21657
+ this.#deferredSdkFollowUps = [];
21467
21658
  return true;
21468
21659
  } catch (error) {
21469
21660
  if (transitionCleanupCommitted) throw error;
@@ -21529,6 +21720,10 @@ export class AgentSession {
21529
21720
  this.#followUpMessages = previousFollowUpMessages;
21530
21721
  this.#pendingNextTurnMessages = previousPendingNextTurnMessages;
21531
21722
  this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration;
21723
+ this.#deferredSdkFollowUps = previousDeferredSdkFollowUps;
21724
+ this.#activeSdkRunToken = previousActiveSdkRunToken;
21725
+ this.#activeAttemptScope = previousActiveAttemptScope;
21726
+ this.#activeLogicalRunId = previousActiveLogicalRunId;
21532
21727
  this.agent.clearAllQueues();
21533
21728
  this.agent.restoreSteering(previousAgentSteeringQueue);
21534
21729
  this.agent.restoreFollowUp(previousAgentFollowUpQueue);
@@ -21595,6 +21790,9 @@ export class AgentSession {
21595
21790
  }
21596
21791
 
21597
21792
  const selectedText = this.#extractUserMessageText(selectedEntry.message.content);
21793
+ const previousAgentSteeringQueue = this.agent.snapshotSteering();
21794
+ const previousAgentFollowUpQueue = this.agent.snapshotFollowUp();
21795
+ const previousDeferredSdkFollowUps = [...this.#deferredSdkFollowUps];
21598
21796
 
21599
21797
  let skipConversationRestore = false;
21600
21798
 
@@ -21630,6 +21828,16 @@ export class AgentSession {
21630
21828
  }
21631
21829
  this.#pendingNextTurnMessages = [];
21632
21830
  this.#scheduledHiddenNextTurnGeneration = undefined;
21831
+ this.#terminalizeQueuedSdkWorkForSessionTransition([
21832
+ ...previousAgentSteeringQueue,
21833
+ ...previousAgentFollowUpQueue,
21834
+ ...previousDeferredSdkFollowUps,
21835
+ ]);
21836
+ this.#deferredSdkFollowUps = [];
21837
+ this.#resetActiveSdkRunOwnership();
21838
+ this.agent.clearAllQueues();
21839
+ this.#steeringMessages = [];
21840
+ this.#followUpMessages = [];
21633
21841
 
21634
21842
  this.#syncTodoPhasesFromBranch();
21635
21843
  this.#syncAgentSessionId();
@@ -21835,6 +22043,15 @@ export class AgentSession {
21835
22043
  this.sessionManager.branch(newLeafId);
21836
22044
  }
21837
22045
 
22046
+ // The history rewrite is now committed. Drop predecessor-owned queued SDK
22047
+ // work at this boundary; cancelled or failed preparation above preserves it.
22048
+ const queuedSdkWork = this.#queuedMessagesForSessionTransition();
22049
+ this.#terminalizeQueuedSdkWorkForSessionTransition(queuedSdkWork);
22050
+ this.#deferredSdkFollowUps = [];
22051
+ this.agent.clearAllQueues();
22052
+ this.#steeringMessages = [];
22053
+ this.#followUpMessages = [];
22054
+
21838
22055
  // Update agent state through the canonical filtered display context so legacy
21839
22056
  // request-scoped entries cannot re-enter live history after tree navigation.
21840
22057
  const displayContext = this.buildDisplaySessionContext();