@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
@@ -87,6 +87,8 @@ const SDK_ONLY_TERMINAL_PUBLICATION_WAIT_MS = 1_000;
87
87
  * after this bound are abandoned — their durable broker state is the recovery
88
88
  * authority, and outcomes are inherently uncertain. */
89
89
  const GATE_RESOLUTION_QUIESCENCE_MS = 5_000;
90
+ /** Maximum time a replaced runtime may retain a lifecycle persistence task. */
91
+ const LIFECYCLE_QUIESCENCE_MS = 1_000;
90
92
 
91
93
  class DiffQueryError extends Error {
92
94
  constructor(
@@ -397,6 +399,10 @@ export interface CreateSdkSessionRuntimeOptions {
397
399
  terminalAbortSeams?: SdkOnlyTerminalAbortSeams;
398
400
  /** Callback when a frame is admitted to the runtime (test harness). */
399
401
  onFrameAdmitted?: () => void;
402
+ /** Test-only observation of a genuinely timed-out lifecycle drain. */
403
+ onLifecycleDrainTimeoutForTests?: () => void;
404
+ /** Test-only observation of bounded failure-diagnostic deduplication state. */
405
+ onFailureDiagnosticKeyCountForTests?: (count: number) => void;
400
406
  }
401
407
 
402
408
  function unavailable(operation: string): () => never {
@@ -746,7 +752,13 @@ export function createInvocationReconciliation(
746
752
  // failure reason may arrive on a different delivery path than the one that
747
753
  // claimed the terminal. Enrich the settled record instead of dropping it;
748
754
  // never resurrect (status/terminalAt untouched), and first reason wins.
749
- if (frame.type === "agent_failed" && record.error === undefined) {
755
+ if (frame.type === "agent_failed") {
756
+ const failure = sanitizePromptFailure(frame.error);
757
+ if (
758
+ record.error !== undefined &&
759
+ !(record.error.code === "agent_failed" && failure.code !== "agent_failed")
760
+ )
761
+ return;
750
762
  const next = { ...record, revision: ++mutationRevision };
751
763
  logger.error("SDK invocation failed (late)", {
752
764
  kind,
@@ -754,7 +766,7 @@ export function createInvocationReconciliation(
754
766
  turnId: correlation.turnId,
755
767
  error: formatPromptFailureForLocalLog(frame.error),
756
768
  });
757
- next.error = sanitizePromptFailure(frame.error);
769
+ next.error = failure;
758
770
  records.set(recordKey, next);
759
771
  try {
760
772
  await persist();
@@ -779,7 +791,9 @@ export function createInvocationReconciliation(
779
791
  turnId: correlation.turnId,
780
792
  error: formatPromptFailureForLocalLog(frame.error),
781
793
  });
782
- next.error ??= sanitizePromptFailure(frame.error);
794
+ const failure = sanitizePromptFailure(frame.error);
795
+ if (next.error === undefined || (next.error.code === "agent_failed" && failure.code !== "agent_failed"))
796
+ next.error = failure;
783
797
  } else {
784
798
  next.status = next.error === undefined ? "terminal_ok" : "failed";
785
799
  next.terminalAt = Date.now();
@@ -1507,6 +1521,85 @@ function rejectionRecoveryIntent(error: unknown): { code: string; message: strin
1507
1521
  return sanitizePromptFailure(error);
1508
1522
  }
1509
1523
 
1524
+ /**
1525
+ * A provider can finish a stream with an assistant error message rather than
1526
+ * rejecting the prompt promise. The SDK extension receives the final agent_end
1527
+ * after retry/fallback policy has settled, so this is the safe boundary at which
1528
+ * to publish the additive failure diagnostic before terminal reconciliation.
1529
+ */
1530
+ function providerFailureFromAgentEnd(event: unknown): { code: string; message: string } | undefined {
1531
+ try {
1532
+ if (!event || typeof event !== "object") return undefined;
1533
+ const messages = (event as { messages?: unknown }).messages;
1534
+ if (!Array.isArray(messages)) return undefined;
1535
+ const lastAssistant = [...messages]
1536
+ .reverse()
1537
+ .find(
1538
+ message => message && typeof message === "object" && (message as { role?: unknown }).role === "assistant",
1539
+ );
1540
+ if (!lastAssistant || typeof lastAssistant !== "object") return undefined;
1541
+ const assistant = lastAssistant as {
1542
+ stopReason?: unknown;
1543
+ errorMessage?: unknown;
1544
+ errorKind?: unknown;
1545
+ errorStatus?: unknown;
1546
+ transportFailure?: { status?: unknown };
1547
+ };
1548
+ let stopReason: unknown;
1549
+ try {
1550
+ stopReason = assistant.stopReason;
1551
+ } catch {
1552
+ return undefined;
1553
+ }
1554
+ if (stopReason !== "error") return undefined;
1555
+ let errorKind: unknown;
1556
+ try {
1557
+ errorKind = assistant.errorKind;
1558
+ } catch {
1559
+ return undefined;
1560
+ }
1561
+ if (errorKind === "local_snapshot_failure" || errorKind === "local_buffer_overflow") return undefined;
1562
+ let errorMessage: unknown;
1563
+ try {
1564
+ errorMessage = assistant.errorMessage;
1565
+ } catch {
1566
+ return undefined;
1567
+ }
1568
+ // Agent-local malformed-tool and composer policy circuit breakers also use
1569
+ // stopReason=error, but they are not provider failures and must not be
1570
+ // reported as provider_rejected to SDK callers.
1571
+ if (
1572
+ typeof errorMessage === "string" &&
1573
+ (errorMessage.includes("Composer bash policy blocked repository file I/O again") ||
1574
+ errorMessage.includes("consecutive turns of malformed tool calls"))
1575
+ )
1576
+ return undefined;
1577
+ let status: number | undefined;
1578
+ try {
1579
+ const errorStatus = assistant.errorStatus;
1580
+ if (typeof errorStatus === "number" && Number.isSafeInteger(errorStatus)) status = errorStatus;
1581
+ } catch {
1582
+ // A malformed errorStatus is equivalent to a statusless provider error.
1583
+ }
1584
+ if (status === undefined) {
1585
+ try {
1586
+ const transportStatus = assistant.transportFailure?.status;
1587
+ if (typeof transportStatus === "number" && Number.isSafeInteger(transportStatus)) status = transportStatus;
1588
+ } catch {
1589
+ // A throwing transport metadata accessor is also statusless.
1590
+ }
1591
+ }
1592
+ if (status === 402 || status === 429)
1593
+ return { code: `provider_http_${status}`, message: "Prompt submission failed." };
1594
+ if (status !== undefined) return { code: "provider_rejected", message: "Prompt submission failed." };
1595
+ return { code: "provider_rejected", message: "Prompt submission failed." };
1596
+ } catch {
1597
+ // Provider metadata is untrusted: an SDK/provider adapter may expose a
1598
+ // throwing getter while the lifecycle boundary still needs to terminalize.
1599
+ return undefined;
1600
+ }
1601
+ }
1602
+
1510
1603
  function createControlSurface(
1511
1604
  ctx: ExtensionContext,
1512
1605
  api: ExtensionAPI,
@@ -1516,12 +1609,14 @@ function createControlSurface(
1516
1609
  correlation: InvocationCorrelation,
1517
1610
  connectionId: string | undefined,
1518
1611
  startsOwnTurn: boolean,
1612
+ sdkRunToken: string,
1519
1613
  ) => void,
1520
1614
  steerReconciliation: KindAwareReconciliation,
1521
1615
  onPromotedTurn?: (
1522
1616
  kind: InvocationKind,
1523
1617
  correlation: InvocationCorrelation,
1524
1618
  connectionId: string | undefined,
1619
+ sdkRunToken: string,
1525
1620
  promotion?: { startsOwnRun?: boolean; removed?: boolean },
1526
1621
  ) => void,
1527
1622
  policy?: SdkSurfacePolicy,
@@ -1627,6 +1722,7 @@ function createControlSurface(
1627
1722
  kind: InvocationKind,
1628
1723
  clientRef: string | undefined,
1629
1724
  run: (options: {
1725
+ sdkRunToken: string;
1630
1726
  onPreflightAccepted: () => void;
1631
1727
  onPreflightAcceptCommit: () => Promise<void>;
1632
1728
  /** Internal disposition before a queued submission is actually consumed. */
@@ -1646,6 +1742,7 @@ function createControlSurface(
1646
1742
  const retainedClientRef = normalizeClientRef(clientRef);
1647
1743
  reconciliation.admit(kind, retainedClientRef);
1648
1744
  const correlation = newCorrelation();
1745
+ const sdkRunToken = `${correlation.commandId}:${correlation.turnId}`;
1649
1746
  const preflight = Promise.withResolvers<void>();
1650
1747
  let accepted = false;
1651
1748
  let settled = false;
@@ -1672,7 +1769,7 @@ function createControlSurface(
1672
1769
  // The accepted submission does NOT own the active turn until its run
1673
1770
  // actually STARTS: the connection is carried on the pending entry and
1674
1771
  // associated at agent_start instead (review thread P1).
1675
- onAccepted(kind, correlation, requesterConnectionId, startsOwnTurn);
1772
+ onAccepted(kind, correlation, requesterConnectionId, startsOwnTurn, sdkRunToken);
1676
1773
  preflight.resolve();
1677
1774
  } catch (error) {
1678
1775
  settled = true;
@@ -1704,6 +1801,7 @@ function createControlSurface(
1704
1801
  const submission = Promise.resolve(
1705
1802
  run({
1706
1803
  onPreflightAccepted: () => void accept().catch(() => undefined),
1804
+ sdkRunToken,
1707
1805
  onPreflightAcceptCommit: accept,
1708
1806
  onDispatchDisposition: promotion => {
1709
1807
  promotionStartsOwnRun = promotion.startsOwnRun;
@@ -1724,7 +1822,7 @@ function createControlSurface(
1724
1822
  // terminal-abort that turn (review threads P1/P2).
1725
1823
  onQueuedPromoted: (promotion?: { startsOwnRun?: boolean; removed?: boolean }) => {
1726
1824
  promotionStartsOwnRun = promotion?.startsOwnRun;
1727
- onPromotedTurn?.(kind, correlation, requesterConnectionId, promotion);
1825
+ onPromotedTurn?.(kind, correlation, requesterConnectionId, sdkRunToken, promotion);
1728
1826
  },
1729
1827
  queuedAtDispatch,
1730
1828
  }),
@@ -1768,7 +1866,7 @@ function createControlSurface(
1768
1866
  });
1769
1867
  retirePendingOwner?.(kind, correlation, "always");
1770
1868
  } catch (transitionError) {
1771
- if (kind === "prompt") {
1869
+ if (kind === "prompt" || kind === "skill") {
1772
1870
  retirePendingOwner?.(kind, correlation, "recover-terminal");
1773
1871
  return;
1774
1872
  }
@@ -1830,7 +1928,7 @@ function createControlSurface(
1830
1928
  try {
1831
1929
  await reconciliation.noteTransition(kind, correlation, { type: "agent_failed", error });
1832
1930
  } catch {
1833
- if (kind === "prompt") {
1931
+ if (kind === "prompt" || kind === "skill") {
1834
1932
  retirePendingOwner?.(
1835
1933
  kind,
1836
1934
  correlation,
@@ -1863,7 +1961,7 @@ function createControlSurface(
1863
1961
  try {
1864
1962
  await reconciliation.noteTransition(kind, correlation, { type: "agent_end" });
1865
1963
  } catch (transitionError) {
1866
- if (kind === "prompt") {
1964
+ if (kind === "prompt" || kind === "skill") {
1867
1965
  retirePendingOwner?.(
1868
1966
  kind,
1869
1967
  correlation,
@@ -2784,10 +2882,14 @@ function createControlSurface(
2784
2882
  };
2785
2883
  return {
2786
2884
  prompt: async (text, images, clientRef) =>
2787
- submit("prompt", clientRef, ({ queuedAtDispatch, ...options }) =>
2885
+ submit("prompt", clientRef, ({ queuedAtDispatch, sdkRunToken, ...options }) =>
2788
2886
  api.sendUserMessage(
2789
2887
  typeof images === "undefined" ? text : ([{ type: "text", text }, ...(images as never[])] as never),
2790
- queuedAtDispatch ? { ...options, queuedAtDispatch: true } : options,
2888
+ {
2889
+ ...options,
2890
+ sdkRunToken,
2891
+ ...(queuedAtDispatch ? { queuedAtDispatch: true } : {}),
2892
+ },
2791
2893
  ),
2792
2894
  ),
2793
2895
  steer: async (text, clientRef) => {
@@ -3033,10 +3135,28 @@ export function registerSdkOnlyNotificationCommand(api: ExtensionAPI): void {
3033
3135
  });
3034
3136
  }
3035
3137
 
3138
+ function quiescingFrame(frame: Record<string, unknown>): Record<string, unknown> | undefined {
3139
+ const error = { code: "session_quiescing", message: "The session endpoint is being replaced." };
3140
+ const id = typeof frame.id === "string" ? frame.id : "";
3141
+ if (frame.type === "control_request") return { type: "control_response", id, ok: false, error };
3142
+ if (frame.type === "query_request") return { type: "query_response", id, ok: false, error };
3143
+ if (frame.type === "event_replay") return { type: "event_replay_result", id, ok: false, error };
3144
+ if (frame.type === "session_activate")
3145
+ return { type: "session_activate_result", id, ok: false, status: "authority_unavailable", error };
3146
+ if (frame.type === "register_provider") return { type: "register_provider_result", id, ok: false, error };
3147
+ if (frame.type === "reverse_response")
3148
+ return { type: "transport_error", id, code: error.code, message: error.message };
3149
+ if (frame.type === "provider_heartbeat" || frame.type === "lease_release")
3150
+ return { type: "transport_error", code: "session_quiescing", message: error.message };
3151
+ return undefined;
3152
+ }
3153
+
3036
3154
  /** Install a complete SDK host for a session when notifications are inactive. */
3037
3155
  export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: CreateSdkSessionRuntimeOptions): void {
3038
3156
  let active:
3039
3157
  | {
3158
+ sessionId: string;
3159
+ sessionIdentity: string;
3040
3160
  runtime: SessionSdkSessionRuntime;
3041
3161
  revisions: RevisionStore;
3042
3162
  cursors: CursorRegistry;
@@ -3047,8 +3167,10 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3047
3167
  kind: InvocationKind;
3048
3168
  correlation: InvocationCorrelation;
3049
3169
  connectionId: string | undefined;
3170
+ sdkRunToken: string;
3050
3171
  }>;
3051
3172
  registerBroker: () => Promise<void>;
3173
+ quiesceInput: () => void;
3052
3174
  fenceGateResolutions: () => void;
3053
3175
  waitForGateResolutionQuiescence: () => Promise<void>;
3054
3176
  activeInvocation?: { kind: InvocationKind; correlation: InvocationCorrelation };
@@ -3060,18 +3182,163 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3060
3182
  kind: InvocationKind;
3061
3183
  correlation: InvocationCorrelation;
3062
3184
  connectionId: string | undefined;
3185
+ sdkRunToken: string;
3063
3186
  }>;
3064
3187
  attachedInvocations: Array<{ kind: InvocationKind; correlation: InvocationCorrelation }>;
3065
3188
  }>;
3066
3189
  disposeGate?: () => void;
3067
3190
  lifecycleActive: boolean;
3068
3191
  lifecycleEpoch: number;
3192
+ failureDiagnosticKeys: Set<string>;
3193
+ failureDiagnosticCodes: Map<string, string>;
3194
+ lifecycleTasks: Set<Promise<void>>;
3069
3195
  /** Failure reasons whose durable agent_failed write failed; the
3070
3196
  * subsequent agent_end must re-record them before terminalizing or
3071
3197
  * the record classifies terminal_ok (exact-head review P1). */
3072
3198
  unrecordedFailureReasons?: Map<string, { code: string; message: string }>;
3073
3199
  }
3074
3200
  | undefined;
3201
+ type RuntimeState = NonNullable<typeof active>;
3202
+ type LifecycleBatch = RuntimeState["openLifecycleBatches"][number];
3203
+ type LifecycleOwner = { state: RuntimeState; sessionId: string; batch?: LifecycleBatch };
3204
+ const retiredLifecycleOwners = new Map<string, RuntimeState[]>();
3205
+ const retiredLifecycleOwnerTimers = new Map<RuntimeState, ReturnType<typeof setTimeout>>();
3206
+ const skillRecoveryControllers = new Map<string, AbortController>();
3207
+ const skillTerminalRecoveryControllers = new Map<string, AbortController>();
3208
+ const ambiguousLifecycleIdentities = new Set<string>();
3209
+ const lifecycleRunOwners = new Map<
3210
+ string,
3211
+ { state: RuntimeState; batch?: LifecycleBatch; correlationKey?: string }
3212
+ >();
3213
+ const lifecycleCorrelationKey = (correlation: InvocationCorrelation): string =>
3214
+ `${correlation.commandId}:${correlation.turnId}`;
3215
+ const sessionIdentityForContext = (ctx: ExtensionContext): string | undefined => {
3216
+ const sessionId = ctx.sessionManager.getSessionId();
3217
+ const sessionFile = ctx.sessionManager.getSessionFile?.();
3218
+ if (sessionFile) return `${sessionId}\u0000${sessionFile}`;
3219
+ const stateRoot = path.join(ctx.cwd, ".gjc", "state");
3220
+ return `${sessionId}\u0000${resolveReconciliationSessionFile(undefined, stateRoot, sessionId)}`;
3221
+ };
3222
+ const lifecycleStateForContext = (
3223
+ ctx: ExtensionContext,
3224
+ type: "agent_start" | "agent_end" | "agent_failed" = "agent_start",
3225
+ ): RuntimeState | undefined => {
3226
+ const sessionId = ctx.sessionManager.getSessionId();
3227
+ const sessionIdentity = sessionIdentityForContext(ctx);
3228
+ if (!sessionIdentity) return undefined;
3229
+ const retired = (retiredLifecycleOwners.get(sessionId) ?? []).filter(
3230
+ owner => owner.sessionIdentity === sessionIdentity,
3231
+ );
3232
+ if (type !== "agent_start") {
3233
+ if (ambiguousLifecycleIdentities.has(sessionIdentity)) return undefined;
3234
+ if (retired.length > 1) {
3235
+ ambiguousLifecycleIdentities.add(sessionIdentity);
3236
+ return undefined;
3237
+ }
3238
+ const delayedOwner = retired.find(owner => owner.openLifecycleBatches.length > 0);
3239
+ if (delayedOwner) {
3240
+ if (active?.sessionIdentity === sessionIdentity && active.lifecycleActive) {
3241
+ ambiguousLifecycleIdentities.add(sessionIdentity);
3242
+ return undefined;
3243
+ }
3244
+ return delayedOwner;
3245
+ }
3246
+ }
3247
+ if (active?.sessionIdentity === sessionIdentity) return active;
3248
+ return retired[0];
3249
+ };
3250
+ const lifecycleStateForEvent = (
3251
+ ctx: ExtensionContext,
3252
+ type: "agent_start" | "agent_end" | "agent_failed",
3253
+ sdkRunToken: unknown,
3254
+ ): RuntimeState | undefined =>
3255
+ typeof sdkRunToken === "string" && sdkRunToken.length > 0
3256
+ ? lifecycleRunOwners.get(sdkRunToken)?.state
3257
+ : lifecycleStateForContext(ctx, type);
3258
+ const removeRetiredLifecycleOwner = (owner: RuntimeState): void => {
3259
+ const timer = retiredLifecycleOwnerTimers.get(owner);
3260
+ if (timer !== undefined) {
3261
+ clearTimeout(timer);
3262
+ retiredLifecycleOwnerTimers.delete(owner);
3263
+ }
3264
+ const owners = retiredLifecycleOwners.get(owner.sessionId);
3265
+ if (!owners) {
3266
+ ambiguousLifecycleIdentities.delete(owner.sessionIdentity);
3267
+ return;
3268
+ }
3269
+ const remaining = owners.filter(candidate => candidate !== owner);
3270
+ if (remaining.length === 0) retiredLifecycleOwners.delete(owner.sessionId);
3271
+ else retiredLifecycleOwners.set(owner.sessionId, remaining);
3272
+ const remainingSameIdentity = remaining.filter(candidate => candidate.sessionIdentity === owner.sessionIdentity);
3273
+ if (remainingSameIdentity.length < 2) ambiguousLifecycleIdentities.delete(owner.sessionIdentity);
3274
+ // Expiry is the final lifecycle-reference boundary. Do not retain
3275
+ // diagnostic suppression or failed-write recovery state after the owner is
3276
+ // no longer eligible to receive a delayed event.
3277
+ owner.failureDiagnosticKeys.clear();
3278
+ owner.failureDiagnosticCodes.clear();
3279
+ owner.unrecordedFailureReasons?.clear();
3280
+ for (const [token, binding] of lifecycleRunOwners) if (binding.state === owner) lifecycleRunOwners.delete(token);
3281
+ };
3282
+ const maybeRetireLifecycleOwner = (owner: RuntimeState): void => {
3283
+ if (
3284
+ owner.pending.length > 0 ||
3285
+ owner.openLifecycleBatches.length > 0 ||
3286
+ (owner.attachedInvocations?.length ?? 0) > 0 ||
3287
+ (owner.drainedInvocations?.length ?? 0) > 0 ||
3288
+ owner.lifecycleTasks.size > 0
3289
+ )
3290
+ return;
3291
+ removeRetiredLifecycleOwner(owner);
3292
+ };
3293
+ const removeLifecycleTokenAliases = (owner: RuntimeState, correlation: InvocationCorrelation): void => {
3294
+ const target = lifecycleCorrelationKey(correlation);
3295
+ for (const [token, binding] of lifecycleRunOwners) {
3296
+ if (binding.state === owner && binding.correlationKey === target) lifecycleRunOwners.delete(token);
3297
+ }
3298
+ };
3299
+ const removeLifecycleReferences = (owner: RuntimeState, correlation: InvocationCorrelation): void => {
3300
+ const target = lifecycleCorrelationKey(correlation);
3301
+ const retainedPending = owner.pending.filter(entry => lifecycleCorrelationKey(entry.correlation) !== target);
3302
+ owner.pending.splice(0, owner.pending.length, ...retainedPending);
3303
+ for (const batch of owner.openLifecycleBatches) {
3304
+ batch.invocations = batch.invocations.filter(entry => lifecycleCorrelationKey(entry.correlation) !== target);
3305
+ batch.attachedInvocations = batch.attachedInvocations.filter(
3306
+ entry => lifecycleCorrelationKey(entry.correlation) !== target,
3307
+ );
3308
+ }
3309
+ owner.openLifecycleBatches = owner.openLifecycleBatches.filter(
3310
+ batch => batch.invocations.length > 0 || batch.attachedInvocations.length > 0,
3311
+ );
3312
+ owner.drainedInvocations = owner.drainedInvocations?.filter(
3313
+ entry => lifecycleCorrelationKey(entry.correlation) !== target,
3314
+ );
3315
+ owner.attachedInvocations = owner.attachedInvocations?.filter(
3316
+ entry => lifecycleCorrelationKey(entry.correlation) !== target,
3317
+ );
3318
+ if (owner.activeInvocation && lifecycleCorrelationKey(owner.activeInvocation.correlation) === target) {
3319
+ const replacement =
3320
+ owner.openLifecycleBatches.flatMap(batch => batch.invocations)[0] ?? owner.attachedInvocations?.[0];
3321
+ owner.activeInvocation = replacement
3322
+ ? { kind: replacement.kind, correlation: replacement.correlation }
3323
+ : undefined;
3324
+ }
3325
+ removeLifecycleTokenAliases(owner, correlation);
3326
+ if (owner === active) {
3327
+ const owners = new Set<string>();
3328
+ const activeBatch = owner.activeInvocation
3329
+ ? owner.openLifecycleBatches.find(batch =>
3330
+ batch.invocations.some(
3331
+ entry =>
3332
+ lifecycleCorrelationKey(entry.correlation) ===
3333
+ lifecycleCorrelationKey(owner.activeInvocation!.correlation),
3334
+ ),
3335
+ )
3336
+ : undefined;
3337
+ for (const entry of activeBatch?.invocations ?? [])
3338
+ if (entry.connectionId !== undefined) owners.add(entry.connectionId);
3339
+ activePromptOwnerHolder.connectionIds = owners.size > 0 ? owners : undefined;
3340
+ }
3341
+ };
3075
3342
  // Shared with the control surface's terminal abort: the correlated
3076
3343
  // agent_end publication capture (AC 19). terminalAbort installs a fresh
3077
3344
  // resolver before settling the abort; emitLifecycle resolves it with the
@@ -3089,15 +3356,28 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3089
3356
  // turn it did not submit, and never set for agent-initiated turns (review
3090
3357
  // thread P1).
3091
3358
  const activePromptOwnerHolder: { connectionIds?: Set<string>; lifecycleEpoch?: number } = {};
3359
+ let nextLifecycleEpoch = 0;
3092
3360
  const skillTerminalRecoveryKeys = new Set<string>();
3361
+ const trackLifecycle = (handler: () => Promise<void>, owner: RuntimeState | undefined): Promise<void> => {
3362
+ if (!owner) return Promise.resolve();
3363
+ let task: Promise<void>;
3364
+ task = handler().finally(() => {
3365
+ owner.lifecycleTasks.delete(task);
3366
+ maybeRetireLifecycleOwner(owner);
3367
+ });
3368
+ owner.lifecycleTasks.add(task);
3369
+ return task;
3370
+ };
3093
3371
  const emitLifecycle = async (
3094
3372
  type: "agent_start" | "agent_end" | "agent_failed",
3095
3373
  ctx: ExtensionContext,
3096
3374
  failureCause?: unknown,
3097
3375
  maintenanceOutcome?: string,
3376
+ lifecycleOwner?: LifecycleOwner,
3098
3377
  ): Promise<void> => {
3099
- const current = active;
3378
+ const current = lifecycleOwner?.state ?? active;
3100
3379
  if (!current) return;
3380
+ const failureBatch = lifecycleOwner?.batch;
3101
3381
  const adoptLifecycleBatch = (
3102
3382
  batch:
3103
3383
  | Array<{
@@ -3111,7 +3391,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3111
3391
  current.activeInvocation = undefined;
3112
3392
  current.drainedInvocations = undefined;
3113
3393
  current.attachedInvocations = undefined;
3114
- activePromptOwnerHolder.connectionIds = undefined;
3394
+ if (current === active) activePromptOwnerHolder.connectionIds = undefined;
3115
3395
  return;
3116
3396
  }
3117
3397
  current.activeInvocation = batch[0];
@@ -3119,19 +3399,20 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3119
3399
  current.attachedInvocations = undefined;
3120
3400
  const owners = new Set<string>();
3121
3401
  for (const entry of batch) if (entry.connectionId !== undefined) owners.add(entry.connectionId);
3122
- activePromptOwnerHolder.connectionIds = owners;
3402
+ if (current === active) activePromptOwnerHolder.connectionIds = owners;
3123
3403
  };
3124
3404
  let transitions: Array<{ kind: InvocationKind; correlation: InvocationCorrelation }> = [];
3125
3405
  if (type === "agent_failed") {
3126
- const activeInvocation = current.activeInvocation;
3406
+ const activeInvocation = failureBatch?.invocations[0] ?? current.activeInvocation;
3127
3407
  const activeBatch = activeInvocation
3128
- ? current.openLifecycleBatches.find(batch =>
3408
+ ? (failureBatch ??
3409
+ current.openLifecycleBatches.find(batch =>
3129
3410
  batch.invocations.some(
3130
3411
  entry =>
3131
3412
  entry.correlation.commandId === activeInvocation.correlation.commandId &&
3132
3413
  entry.correlation.turnId === activeInvocation.correlation.turnId,
3133
3414
  ),
3134
- )
3415
+ ))
3135
3416
  : undefined;
3136
3417
  const fallback =
3137
3418
  activeBatch?.invocations ??
@@ -3152,8 +3433,8 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3152
3433
  );
3153
3434
  transitions = [...fallback, ...attached].map(({ kind, correlation }) => ({ kind, correlation }));
3154
3435
  } else if (type === "agent_start") {
3155
- current.lifecycleEpoch += 1;
3156
- activePromptOwnerHolder.lifecycleEpoch = current.lifecycleEpoch;
3436
+ current.lifecycleEpoch = ++nextLifecycleEpoch;
3437
+ if (current === active) activePromptOwnerHolder.lifecycleEpoch = current.lifecycleEpoch;
3157
3438
  // Mark lifecycle active even when the drain is empty: a monitor/cron
3158
3439
  // run started by the session has no SDK pending entry but is still a
3159
3440
  // real active run that later in-run promotions must attach to instead
@@ -3211,8 +3492,8 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3211
3492
  // may already have advanced to. Publication proof and race retirement must
3212
3493
  // use the same batch identity as reconciliation.
3213
3494
  const eventLifecycleEpoch =
3214
- type === "agent_end"
3215
- ? (current.openLifecycleBatches[0]?.epoch ?? current.lifecycleEpoch)
3495
+ (type === "agent_failed" || type === "agent_end") && failureBatch
3496
+ ? failureBatch.epoch
3216
3497
  : current.lifecycleEpoch;
3217
3498
  const resolveTerminalPublicationWaiters = (observed: boolean): void => {
3218
3499
  const waiters = terminalPublicationCapture.waiters;
@@ -3223,17 +3504,19 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3223
3504
  for (const waiter of matched) waiter.resolve(observed);
3224
3505
  };
3225
3506
  const retireEndedLifecycleBatch = (): void => {
3226
- const ended = current.openLifecycleBatches[0];
3507
+ const ended = failureBatch ?? current.openLifecycleBatches[0];
3227
3508
  if (!ended) return;
3228
3509
  const transitionKeys = new Set(
3229
3510
  transitions.map(({ correlation }) => `${correlation.commandId}:${correlation.turnId}`),
3230
3511
  );
3231
3512
  if (
3232
- ended.invocations.some(({ correlation }) =>
3513
+ !ended.invocations.some(({ correlation }) =>
3233
3514
  transitionKeys.has(`${correlation.commandId}:${correlation.turnId}`),
3234
3515
  )
3235
3516
  )
3236
- current.openLifecycleBatches.shift();
3517
+ return;
3518
+ const index = current.openLifecycleBatches.indexOf(ended);
3519
+ if (index >= 0) current.openLifecycleBatches.splice(index, 1);
3237
3520
  };
3238
3521
  const scheduleSkillTerminalRecovery = (invocation: {
3239
3522
  kind: InvocationKind;
@@ -3243,6 +3526,8 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3243
3526
  const recoveryKey = `${invocation.correlation.commandId}:${invocation.correlation.turnId}`;
3244
3527
  if (skillTerminalRecoveryKeys.has(recoveryKey)) return;
3245
3528
  skillTerminalRecoveryKeys.add(recoveryKey);
3529
+ const controller = new AbortController();
3530
+ skillTerminalRecoveryControllers.set(recoveryKey, controller);
3246
3531
  const attempt = async (): Promise<void> => {
3247
3532
  try {
3248
3533
  const unrecorded = current.unrecordedFailureReasons?.get(recoveryKey);
@@ -3255,6 +3540,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3255
3540
  }
3256
3541
  await current.reconciliation.noteTransition("skill", invocation.correlation, { type: "agent_end" });
3257
3542
  skillTerminalRecoveryKeys.delete(recoveryKey);
3543
+ current.failureDiagnosticKeys.delete(recoveryKey);
3258
3544
  for (const batch of current.openLifecycleBatches) {
3259
3545
  batch.invocations = batch.invocations.filter(
3260
3546
  entry =>
@@ -3277,16 +3563,33 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3277
3563
  adoptLifecycleBatch(current.openLifecycleBatches[0]?.invocations);
3278
3564
  if (current.openLifecycleBatches.length === 0) current.lifecycleActive = false;
3279
3565
  } catch (error) {
3566
+ if (controller.signal.aborted) {
3567
+ skillTerminalRecoveryKeys.delete(recoveryKey);
3568
+ return;
3569
+ }
3280
3570
  logger.error("SDK skill lifecycle terminal recovery retrying", {
3281
3571
  commandId: invocation.correlation.commandId,
3282
3572
  turnId: invocation.correlation.turnId,
3283
3573
  error: sanitizePromptFailure(error),
3284
3574
  });
3285
- await Bun.sleep(1_000);
3575
+ const wait = Promise.withResolvers<void>();
3576
+ const timer = setTimeout(wait.resolve, 1_000);
3577
+ timer.unref();
3578
+ const onAbort = () => {
3579
+ clearTimeout(timer);
3580
+ wait.resolve();
3581
+ };
3582
+ controller.signal.addEventListener("abort", onAbort, { once: true });
3583
+ await wait.promise;
3584
+ controller.signal.removeEventListener("abort", onAbort);
3585
+ if (controller.signal.aborted) {
3586
+ skillTerminalRecoveryKeys.delete(recoveryKey);
3587
+ return;
3588
+ }
3286
3589
  return attempt();
3287
3590
  }
3288
3591
  };
3289
- void attempt();
3592
+ void attempt().finally(() => skillTerminalRecoveryControllers.delete(recoveryKey));
3290
3593
  };
3291
3594
  if (type === "agent_end" && maintenanceOutcome !== undefined && maintenanceOutcome !== "aborted") {
3292
3595
  try {
@@ -3296,6 +3599,20 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3296
3599
  }
3297
3600
  return;
3298
3601
  }
3602
+ const correlationKey = (correlation: InvocationCorrelation): string =>
3603
+ `${correlation.commandId}:${correlation.turnId}`;
3604
+ if (type === "agent_failed") {
3605
+ transitions = transitions.filter(
3606
+ invocation => !current.failureDiagnosticKeys.has(correlationKey(invocation.correlation)),
3607
+ );
3608
+ const diagnosticCode = sanitizePromptFailure(
3609
+ failureCause ?? Object.assign(new Error("agent run failed"), { code: "agent_failed" }),
3610
+ ).code;
3611
+ for (const invocation of transitions)
3612
+ current.failureDiagnosticKeys.add(correlationKey(invocation.correlation));
3613
+ for (const invocation of transitions)
3614
+ current.failureDiagnosticCodes.set(correlationKey(invocation.correlation), diagnosticCode);
3615
+ }
3299
3616
  // Observe whether the lifecycle publication actually landed: a terminal
3300
3617
  // abort awaits this result so its durable row only claims
3301
3618
  // terminalPublished when the correlated agent_end event reached the
@@ -3356,7 +3673,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3356
3673
  }
3357
3674
  }
3358
3675
  }
3359
- const sessionId = ctx.sessionManager.getSessionId();
3676
+ const sessionId = lifecycleOwner?.sessionId ?? ctx.sessionManager.getSessionId();
3360
3677
  if (type === "agent_failed") {
3361
3678
  // Failure is a correlated diagnostic, not the terminal lifecycle boundary.
3362
3679
  // Publish one safe frame per invocation so clients can attribute a shared
@@ -3395,6 +3712,16 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3395
3712
  // and the next agent_end is paired with stale ownership.
3396
3713
  for (const invocation of failedTransitions) scheduleSkillTerminalRecovery(invocation);
3397
3714
  retireEndedLifecycleBatch();
3715
+ const failedKeys = new Set(failedTransitions.map(({ correlation }) => correlationKey(correlation)));
3716
+ for (const invocation of transitions) {
3717
+ if (!failedKeys.has(correlationKey(invocation.correlation)))
3718
+ removeLifecycleReferences(current, invocation.correlation);
3719
+ if (!failedKeys.has(correlationKey(invocation.correlation)))
3720
+ current.failureDiagnosticKeys.delete(correlationKey(invocation.correlation));
3721
+ if (!failedKeys.has(correlationKey(invocation.correlation)))
3722
+ current.failureDiagnosticCodes.delete(correlationKey(invocation.correlation));
3723
+ }
3724
+ options.onFailureDiagnosticKeyCountForTests?.(current.failureDiagnosticKeys.size);
3398
3725
  resolveTerminalPublicationWaiters(observed);
3399
3726
  return;
3400
3727
  }
@@ -3410,7 +3737,17 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3410
3737
  resolveTerminalPublicationWaiters(observed);
3411
3738
  return;
3412
3739
  }
3413
- if (current.openLifecycleBatches.length > 0) current.openLifecycleBatches.shift();
3740
+ const ended = failureBatch ?? current.openLifecycleBatches[0];
3741
+ if (ended) {
3742
+ const index = current.openLifecycleBatches.indexOf(ended);
3743
+ if (index >= 0) current.openLifecycleBatches.splice(index, 1);
3744
+ }
3745
+ for (const invocation of transitions) removeLifecycleReferences(current, invocation.correlation);
3746
+ for (const invocation of transitions)
3747
+ current.failureDiagnosticKeys.delete(correlationKey(invocation.correlation));
3748
+ for (const invocation of transitions)
3749
+ current.failureDiagnosticCodes.delete(correlationKey(invocation.correlation));
3750
+ options.onFailureDiagnosticKeyCountForTests?.(current.failureDiagnosticKeys.size);
3414
3751
  for (const invocation of transitions)
3415
3752
  if (invocation.kind === "prompt") current.deadlineManager.clear(invocation.correlation);
3416
3753
  adoptLifecycleBatch(current.openLifecycleBatches[0]?.invocations);
@@ -3422,33 +3759,122 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3422
3759
  resolveTerminalPublicationWaiters(observed);
3423
3760
  }
3424
3761
  };
3425
- api.on("agent_start", async (_event, ctx) => await emitLifecycle("agent_start", ctx));
3426
- api.on(
3427
- "agent_end",
3428
- async (event, ctx) =>
3762
+ api.on("agent_start", (event, ctx) => {
3763
+ const owner = lifecycleStateForEvent(ctx, "agent_start", event.sdkRunToken);
3764
+ return trackLifecycle(async () => {
3765
+ await emitLifecycle(
3766
+ "agent_start",
3767
+ ctx,
3768
+ undefined,
3769
+ undefined,
3770
+ owner ? { state: owner, sessionId: owner.sessionId } : undefined,
3771
+ );
3772
+ if (owner && typeof event.sdkRunToken === "string" && event.sdkRunToken.length > 0) {
3773
+ const batch = owner.openLifecycleBatches.at(-1);
3774
+ const entry = batch?.invocations.find(item => item.sdkRunToken === event.sdkRunToken);
3775
+ const previous = lifecycleRunOwners.get(event.sdkRunToken);
3776
+ lifecycleRunOwners.set(event.sdkRunToken, {
3777
+ state: owner,
3778
+ batch,
3779
+ ...(previous?.correlationKey !== undefined
3780
+ ? { correlationKey: previous.correlationKey }
3781
+ : entry
3782
+ ? { correlationKey: lifecycleCorrelationKey(entry.correlation) }
3783
+ : {}),
3784
+ });
3785
+ }
3786
+ }, owner);
3787
+ });
3788
+ api.on("agent_end", (event, ctx) => {
3789
+ const tokenBinding =
3790
+ typeof event.sdkRunToken === "string" ? lifecycleRunOwners.get(event.sdkRunToken) : undefined;
3791
+ const owner = tokenBinding?.state ?? lifecycleStateForEvent(ctx, "agent_end", event.sdkRunToken);
3792
+ // Capture the oldest unmatched batch synchronously. A successor may start
3793
+ // while the failed diagnostic persists; that must not retarget the
3794
+ // predecessor's reason or terminal boundary to the successor invocation.
3795
+ const endedBatch = tokenBinding?.batch ?? owner?.openLifecycleBatches[0];
3796
+ const lifecycleOwner = owner
3797
+ ? {
3798
+ state: owner,
3799
+ sessionId: owner.sessionId,
3800
+ ...(endedBatch ? { batch: endedBatch } : {}),
3801
+ }
3802
+ : undefined;
3803
+ const currentInvocation = endedBatch?.invocations[0] ?? owner?.activeInvocation;
3804
+ const failure = providerFailureFromAgentEnd(event);
3805
+ const failureCandidates = endedBatch
3806
+ ? [...endedBatch.invocations, ...endedBatch.attachedInvocations]
3807
+ : currentInvocation
3808
+ ? [currentInvocation]
3809
+ : [];
3810
+ const genericFailureKeys = failureCandidates
3811
+ .map(({ correlation }) => lifecycleCorrelationKey(correlation))
3812
+ .filter(key => owner?.failureDiagnosticCodes.get(key) === "agent_failed");
3813
+ const hasExistingFailure = failureCandidates.some(({ correlation }) =>
3814
+ owner?.failureDiagnosticKeys.has(lifecycleCorrelationKey(correlation)),
3815
+ );
3816
+ const failureAlreadyPublished = failure === undefined || (hasExistingFailure && genericFailureKeys.length === 0);
3817
+ return trackLifecycle(async () => {
3818
+ if (failure && !failureAlreadyPublished) {
3819
+ for (const key of genericFailureKeys) owner?.failureDiagnosticKeys.delete(key);
3820
+ await emitLifecycle("agent_failed", ctx, failure, undefined, lifecycleOwner);
3821
+ }
3429
3822
  await emitLifecycle(
3430
3823
  "agent_end",
3431
3824
  ctx,
3432
3825
  undefined,
3433
3826
  event.stopReason === "maintenance" ? event.maintenanceOutcome : undefined,
3434
- ),
3435
- );
3436
- api.on("agent_failed", async (event, ctx) => emitLifecycle("agent_failed", ctx, event.error));
3827
+ lifecycleOwner,
3828
+ );
3829
+ }, owner).finally(() => {
3830
+ if (typeof event.sdkRunToken === "string" && lifecycleRunOwners.get(event.sdkRunToken)?.state === owner)
3831
+ lifecycleRunOwners.delete(event.sdkRunToken);
3832
+ });
3833
+ });
3834
+ api.on("agent_failed", (event, ctx) => {
3835
+ const tokenBinding =
3836
+ typeof event.sdkRunToken === "string" ? lifecycleRunOwners.get(event.sdkRunToken) : undefined;
3837
+ const owner = tokenBinding?.state ?? lifecycleStateForEvent(ctx, "agent_failed", event.sdkRunToken);
3838
+ const failedBatch = tokenBinding?.batch ?? owner?.openLifecycleBatches[0];
3839
+ return trackLifecycle(
3840
+ async () =>
3841
+ emitLifecycle(
3842
+ "agent_failed",
3843
+ ctx,
3844
+ event.error,
3845
+ undefined,
3846
+ owner
3847
+ ? {
3848
+ state: owner,
3849
+ sessionId: owner.sessionId,
3850
+ ...(failedBatch ? { batch: failedBatch } : {}),
3851
+ }
3852
+ : undefined,
3853
+ ),
3854
+ owner,
3855
+ );
3856
+ });
3437
3857
  api.on("turn_start", async (_event, ctx) => {
3438
- const current = active;
3858
+ const current = lifecycleStateForContext(ctx, "agent_start");
3439
3859
  if (!current) return;
3440
3860
  await current.registerBroker();
3441
3861
  current.runtime.emitEvent({ type: "turn_start", sessionId: ctx.sessionManager.getSessionId() });
3442
3862
  });
3443
3863
  api.on("turn_end", (_event, ctx) =>
3444
- active?.runtime.emitEvent({ type: "turn_end", sessionId: ctx.sessionManager.getSessionId() }),
3864
+ lifecycleStateForContext(ctx, "agent_end")?.runtime.emitEvent({
3865
+ type: "turn_end",
3866
+ sessionId: ctx.sessionManager.getSessionId(),
3867
+ }),
3445
3868
  );
3446
3869
  // Tool activity renews the deadline of EVERY prompt correlation attached to
3447
3870
  // the active run — the root invocation plus any in-run consumed follow-ups
3448
3871
  // sharing it — not only the head, or an attached correlation would
3449
- // false-fire prompt_deadline_exceeded during a long shared run (#4668).
3450
- const renewAttributableProgress = (eventType: string): void => {
3451
- const current = active;
3872
+ // false-fire prompt_deadline_exceeded during a long shared run.
3873
+ const renewAttributableProgress = (eventType: string, ctx: ExtensionContext): void => {
3874
+ // Tool events do not carry an SDK run token. Prefer the lifecycle-active
3875
+ // runtime for the current session so a retained predecessor cannot make a
3876
+ // live replacement look ambiguous and suppress its lease renewal.
3877
+ const current = lifecycleStateForContext(ctx, "agent_start");
3452
3878
  if (!current) return;
3453
3879
  // Renew only invocations adopted by the CURRENT run: the live lifecycle
3454
3880
  // batch plus in-run attachments. drainedInvocations may still hold
@@ -3488,12 +3914,10 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3488
3914
  }
3489
3915
  };
3490
3916
  api.on("tool_execution_start", async (_event, ctx) => {
3491
- renewAttributableProgress("tool_execution_start");
3492
- void ctx;
3917
+ renewAttributableProgress("tool_execution_start", ctx);
3493
3918
  });
3494
3919
  api.on("tool_execution_end", async (_event, ctx) => {
3495
- renewAttributableProgress("tool_execution_end");
3496
- void ctx;
3920
+ renewAttributableProgress("tool_execution_end", ctx);
3497
3921
  });
3498
3922
  const errorCode = (error: unknown): string | undefined =>
3499
3923
  typeof error === "object" &&
@@ -3513,6 +3937,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3513
3937
  const sessionFile =
3514
3938
  (typeof ctx.sessionManager.getSessionFile === "function" ? ctx.sessionManager.getSessionFile() : undefined) ??
3515
3939
  resolveReconciliationSessionFile(undefined, stateRoot, sessionId);
3940
+ const sessionIdentity = `${sessionId}\u0000${sessionFile}`;
3516
3941
  const reconciliationStore =
3517
3942
  options.terminalAbortSeams?.getReconciliationStore?.() ??
3518
3943
  createReconciliationStore({ sessionFile, sessionId });
@@ -3534,18 +3959,17 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3534
3959
  return typeof v === "number" && Number.isFinite(v) ? v : 21_600_000;
3535
3960
  },
3536
3961
  onExpired: correlation => {
3537
- const idx = pending.findIndex(
3538
- entry =>
3539
- entry.correlation.commandId === correlation.commandId &&
3540
- entry.correlation.turnId === correlation.turnId,
3541
- );
3542
- if (idx >= 0) pending.splice(idx, 1);
3962
+ const owner = lifecycleOwnerHolder.state;
3963
+ if (!owner) return;
3964
+ removeLifecycleReferences(owner, correlation);
3965
+ maybeRetireLifecycleOwner(owner);
3543
3966
  },
3544
3967
  });
3545
3968
  const pending: Array<{
3546
3969
  kind: InvocationKind;
3547
3970
  correlation: InvocationCorrelation;
3548
3971
  connectionId: string | undefined;
3972
+ sdkRunToken: string;
3549
3973
  }> = [];
3550
3974
  for (const { correlation, acceptedAt, deadlineMaxAt } of reconciliation.listDeadlineRecoveryPendingPrompts())
3551
3975
  deadlineManager.recoverPending(correlation, acceptedAt, deadlineMaxAt);
@@ -3555,6 +3979,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3555
3979
  kind: InvocationKind;
3556
3980
  correlation: InvocationCorrelation;
3557
3981
  connectionId: string | undefined;
3982
+ sdkRunToken: string;
3558
3983
  }>;
3559
3984
  attachedInvocations: Array<{ kind: InvocationKind; correlation: InvocationCorrelation }>;
3560
3985
  }> = [];
@@ -3584,6 +4009,60 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3584
4009
  settings: options.settings,
3585
4010
  });
3586
4011
  const queryHandlers = new QueryHandlers(surfaceFactory.query, sessionId, revisions, cursors);
4012
+ const inputGate = { quiescing: false };
4013
+ const lifecycleOwnerHolder: { state?: RuntimeState; quiescing: boolean } = { quiescing: false };
4014
+ const skillRecoveryTasks = new Map<string, Promise<void>>();
4015
+ const scheduleSkillRecovery = (
4016
+ correlation: InvocationCorrelation,
4017
+ failureIntent?: { code: string; message: string },
4018
+ ): void => {
4019
+ const key = lifecycleCorrelationKey(correlation);
4020
+ if (skillRecoveryTasks.has(key)) return;
4021
+ const controller = new AbortController();
4022
+ skillRecoveryControllers.set(key, controller);
4023
+ const intent = failureIntent;
4024
+ const task = (async (): Promise<void> => {
4025
+ let failureRecorded = intent === undefined;
4026
+ for (;;) {
4027
+ if (controller.signal.aborted) return;
4028
+ try {
4029
+ if (!failureRecorded) {
4030
+ await reconciliation.noteTransition("skill", correlation, {
4031
+ type: "agent_failed",
4032
+ error: Object.assign(new Error(intent?.message ?? "skill invocation failed"), {
4033
+ code: intent?.code ?? "skill_failed",
4034
+ }),
4035
+ } as never);
4036
+ failureRecorded = true;
4037
+ }
4038
+ await reconciliation.noteTransition("skill", correlation, { type: "agent_end" });
4039
+ return;
4040
+ } catch (error) {
4041
+ if (controller.signal.aborted) return;
4042
+ logger.error("SDK skill lifecycle recovery retrying", {
4043
+ commandId: correlation.commandId,
4044
+ turnId: correlation.turnId,
4045
+ error: sanitizePromptFailure(error),
4046
+ });
4047
+ const wait = Promise.withResolvers<void>();
4048
+ const timer = setTimeout(wait.resolve, 1_000);
4049
+ timer.unref();
4050
+ const onAbort = () => {
4051
+ clearTimeout(timer);
4052
+ wait.resolve();
4053
+ };
4054
+ controller.signal.addEventListener("abort", onAbort, { once: true });
4055
+ await wait.promise;
4056
+ controller.signal.removeEventListener("abort", onAbort);
4057
+ }
4058
+ }
4059
+ })();
4060
+ skillRecoveryTasks.set(key, task);
4061
+ void task.finally(() => {
4062
+ skillRecoveryTasks.delete(key);
4063
+ skillRecoveryControllers.delete(key);
4064
+ });
4065
+ };
3587
4066
  let runtime: SessionSdkSessionRuntime;
3588
4067
  // Durable-first bounded terminalization for accepted submissions that leave
3589
4068
  // their queue or race a run WITHOUT consumption (exact-head review: clearing
@@ -3597,6 +4076,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3597
4076
  correlation: InvocationCorrelation,
3598
4077
  error: { code: string; message: string },
3599
4078
  ): void => {
4079
+ if (lifecycleOwnerHolder.state) removeLifecycleTokenAliases(lifecycleOwnerHolder.state, correlation);
3600
4080
  const attempt = async (remaining: number): Promise<void> => {
3601
4081
  try {
3602
4082
  // 1. Record the failure reason durably FIRST. If this write fails,
@@ -3608,10 +4088,12 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3608
4088
  } as never);
3609
4089
  } catch (reasonError) {
3610
4090
  if (remaining <= 1) {
3611
- // Hand replay to the deadline manager: the lease stays armed and
3612
- // its bounded expiry retries the durable terminal transition
3613
- // instead of leaving an accepted row with no recovery owner.
3614
- deadlineManager.noteTerminalTransition(correlation, { code: error.code, message: error.message });
4091
+ // Prompts use the deadline lease; skills have no prompt lease and
4092
+ // must use their kind-aware durable retry owner instead.
4093
+ if (kind === "skill")
4094
+ scheduleSkillRecovery(correlation, { code: error.code, message: error.message });
4095
+ else
4096
+ deadlineManager.noteTerminalTransition(correlation, { code: error.code, message: error.message });
3615
4097
  logger.error("SDK abandoned submission failed to record its failure reason", {
3616
4098
  kind,
3617
4099
  commandId: correlation.commandId,
@@ -3630,8 +4112,9 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3630
4112
  // 3. Release recovery ownership ONLY after durable terminalization.
3631
4113
  deadlineManager.clear(correlation);
3632
4114
  } catch (transitionError) {
3633
- // Keep the lease armed; the manager replays the real agent_end.
3634
- deadlineManager.noteTerminalTransition(correlation, { code: error.code, message: error.message });
4115
+ // Keep prompt recovery leased; skill recovery has no prompt lease.
4116
+ if (kind === "skill") scheduleSkillRecovery(correlation);
4117
+ else deadlineManager.noteTerminalTransition(correlation, { code: error.code, message: error.message });
3635
4118
  logger.error("SDK abandoned submission failed to terminalize", {
3636
4119
  kind,
3637
4120
  commandId: correlation.commandId,
@@ -3647,13 +4130,26 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3647
4130
  ctx,
3648
4131
  api,
3649
4132
  reconciliation,
3650
- (kind, correlation, connectionId, startsOwnTurn) => {
4133
+ (kind, correlation, connectionId, startsOwnTurn, sdkRunToken) => {
4134
+ const owner = lifecycleOwnerHolder.state;
4135
+ if (lifecycleOwnerHolder.quiescing) {
4136
+ terminalizeAbandonedSubmission(kind, correlation, {
4137
+ code: "session_quiescing",
4138
+ message: "Session endpoint was replaced before the invocation started.",
4139
+ });
4140
+ return;
4141
+ }
4142
+ if (sdkRunToken && owner)
4143
+ lifecycleRunOwners.set(sdkRunToken, {
4144
+ state: owner,
4145
+ correlationKey: lifecycleCorrelationKey(correlation),
4146
+ });
3651
4147
  // Only submissions that start their OWN turn get a pending entry: a
3652
4148
  // steering-queued submission consumed inside the current run never
3653
4149
  // emits the agent_start that would consume the entry, so leaving it
3654
4150
  // queued would assign its stale connection as owner of a later
3655
4151
  // agent-initiated turn (review thread P1).
3656
- if (startsOwnTurn) pending.push({ kind, correlation, connectionId });
4152
+ if (startsOwnTurn) pending.push({ kind, correlation, connectionId, sdkRunToken });
3657
4153
  // Anchor the zero-progress deadline at durable acceptance, not at
3658
4154
  // agent_start (#4668): an accepted own-turn prompt that wedges
3659
4155
  // between acceptance and run start (provider/credential/compaction
@@ -3666,7 +4162,23 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3666
4162
  if (startsOwnTurn && kind === "prompt") deadlineManager.onAccepted(correlation);
3667
4163
  },
3668
4164
  steerReconciliation,
3669
- (kind, correlation, connectionId, promotion) => {
4165
+ (kind, correlation, connectionId, sdkRunToken, promotion) => {
4166
+ const bindPromotedToken = (batch?: LifecycleBatch): void => {
4167
+ const owner = lifecycleOwnerHolder.state;
4168
+ if (sdkRunToken && owner)
4169
+ lifecycleRunOwners.set(sdkRunToken, {
4170
+ state: owner,
4171
+ batch,
4172
+ correlationKey: lifecycleCorrelationKey(correlation),
4173
+ });
4174
+ };
4175
+ if (lifecycleOwnerHolder.quiescing) {
4176
+ terminalizeAbandonedSubmission(kind, correlation, {
4177
+ code: "session_quiescing",
4178
+ message: "Session endpoint was replaced before the invocation started.",
4179
+ });
4180
+ return;
4181
+ }
3670
4182
  // Lease at the ACTUAL promotion boundary (#4668 review): a promoted
3671
4183
  // submission that wedges before its run's agent_start must still
3672
4184
  // terminalize boundedly instead of remaining accepted forever.
@@ -3696,7 +4208,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3696
4208
  // starts with an empty pending queue at its agent_start; create the
3697
4209
  // entry at promotion so the submitting connection owns that turn
3698
4210
  // (review thread P2).
3699
- pending.push({ kind, correlation, connectionId });
4211
+ pending.push({ kind, correlation, connectionId, sdkRunToken });
3700
4212
  return;
3701
4213
  }
3702
4214
  // Consumed INSIDE the currently running turn: no new agent_start
@@ -3759,6 +4271,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3759
4271
  )
3760
4272
  )
3761
4273
  activeBatch.attachedInvocations.push({ kind, correlation });
4274
+ bindPromotedToken(activeBatch);
3762
4275
  return;
3763
4276
  }
3764
4277
  // No in-flight run visible and this is an in-run consumption
@@ -3794,8 +4307,14 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3794
4307
  // the matching correlation so a later agent-initiated turn never
3795
4308
  // inherits the failed submission's connection as owner (review
3796
4309
  // thread P1).
3797
- const index = pending.findIndex(entry => entry.correlation === correlation);
3798
- if (index >= 0) pending.splice(index, 1);
4310
+ if (!lifecycleOwnerHolder.quiescing) {
4311
+ const index = pending.findIndex(entry => entry.correlation === correlation);
4312
+ if (index >= 0) {
4313
+ const [removed] = pending.splice(index, 1);
4314
+ if (removed && lifecycleRunOwners.get(removed.sdkRunToken)?.state === lifecycleOwnerHolder.state)
4315
+ lifecycleRunOwners.delete(removed.sdkRunToken);
4316
+ }
4317
+ }
3799
4318
  // Release the acceptance-anchored lease ONLY when the durable row is
3800
4319
  // already terminal (exact-head review #4): clearing it before the
3801
4320
  // settlement writes land strands the row accepted with no recovery
@@ -3808,6 +4327,10 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3808
4327
  return;
3809
4328
  }
3810
4329
  if (leaseRelease === "recover-failure" && failureIntent !== undefined) {
4330
+ if (kind === "skill") {
4331
+ scheduleSkillRecovery(correlation, failureIntent);
4332
+ return;
4333
+ }
3811
4334
  // Compound failure-plus-terminal recovery: the settlement's durable
3812
4335
  // writes failed, so the lease stays armed as the recovery owner and its
3813
4336
  // replay re-records the failure reason before agent_end (exact-head
@@ -3816,6 +4339,10 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3816
4339
  return;
3817
4340
  }
3818
4341
  if (leaseRelease === "recover-terminal") {
4342
+ if (kind === "skill") {
4343
+ scheduleSkillRecovery(correlation);
4344
+ return;
4345
+ }
3819
4346
  deadlineManager.noteTerminalTransition(correlation);
3820
4347
  return;
3821
4348
  }
@@ -3892,6 +4419,8 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
3892
4419
  };
3893
4420
  runtime = new SessionSdkSessionRuntime({
3894
4421
  transport,
4422
+ onFrameAdmission: (_connectionId, frame) =>
4423
+ inputGate.quiescing ? quiescingFrame(frame as Record<string, unknown>) : undefined,
3895
4424
  control: async (connectionId, frame) => {
3896
4425
  options.onFrameAdmitted?.();
3897
4426
  const request = controlRequestFromFrame(frame as Record<string, unknown>);
@@ -4028,7 +4557,9 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4028
4557
  logger.warn(`sdk broker registration unavailable: ${String(error)}`);
4029
4558
  }
4030
4559
  };
4031
- active = {
4560
+ const runtimeOwner: RuntimeState = {
4561
+ sessionId,
4562
+ sessionIdentity,
4032
4563
  runtime,
4033
4564
  revisions,
4034
4565
  cursors,
@@ -4038,6 +4569,10 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4038
4569
  pending,
4039
4570
  openLifecycleBatches,
4040
4571
  registerBroker,
4572
+ quiesceInput: () => {
4573
+ inputGate.quiescing = true;
4574
+ lifecycleOwnerHolder.quiescing = true;
4575
+ },
4041
4576
  fenceGateResolutions: () => {
4042
4577
  acceptingGateResolutions = false;
4043
4578
  },
@@ -4045,7 +4580,12 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4045
4580
  disposeGate,
4046
4581
  lifecycleActive: false,
4047
4582
  lifecycleEpoch: 0,
4583
+ failureDiagnosticKeys: new Set(),
4584
+ failureDiagnosticCodes: new Map(),
4585
+ lifecycleTasks: new Set(),
4048
4586
  };
4587
+ lifecycleOwnerHolder.state = runtimeOwner;
4588
+ active = runtimeOwner;
4049
4589
  try {
4050
4590
  await runtime.start();
4051
4591
  await registerBroker();
@@ -4059,7 +4599,9 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4059
4599
  code: errorCode(cleanupError),
4060
4600
  error: String(cleanupError),
4061
4601
  });
4062
- active = {
4602
+ const failedRuntimeOwner: RuntimeState = {
4603
+ sessionId,
4604
+ sessionIdentity,
4063
4605
  runtime,
4064
4606
  revisions,
4065
4607
  cursors,
@@ -4069,6 +4611,10 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4069
4611
  pending,
4070
4612
  openLifecycleBatches,
4071
4613
  registerBroker,
4614
+ quiesceInput: () => {
4615
+ inputGate.quiescing = true;
4616
+ lifecycleOwnerHolder.quiescing = true;
4617
+ },
4072
4618
  fenceGateResolutions: () => {
4073
4619
  acceptingGateResolutions = false;
4074
4620
  },
@@ -4076,7 +4622,12 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4076
4622
  disposeGate,
4077
4623
  lifecycleActive: false,
4078
4624
  lifecycleEpoch: 0,
4625
+ failureDiagnosticKeys: new Set(),
4626
+ failureDiagnosticCodes: new Map(),
4627
+ lifecycleTasks: new Set(),
4079
4628
  };
4629
+ lifecycleOwnerHolder.state = failedRuntimeOwner;
4630
+ active = failedRuntimeOwner;
4080
4631
  throw new AggregateError([error, cleanupError], "SDK runtime startup failed and cleanup failed.");
4081
4632
  }
4082
4633
  cursors.close();
@@ -4084,19 +4635,70 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4084
4635
  throw error;
4085
4636
  }
4086
4637
  };
4087
- const stopActive = async (): Promise<void> => {
4638
+ const stopActive = async (cancelSkillRecovery = false): Promise<void> => {
4088
4639
  const current = active;
4089
4640
  if (!current) return;
4090
- current.deadlineManager.clearAll();
4641
+ if (cancelSkillRecovery) {
4642
+ for (const controller of skillRecoveryControllers.values()) controller.abort();
4643
+ for (const controller of skillTerminalRecoveryControllers.values()) controller.abort();
4644
+ }
4645
+ activePromptOwnerHolder.connectionIds = undefined;
4646
+ activePromptOwnerHolder.lifecycleEpoch = undefined;
4647
+ current.quiesceInput();
4091
4648
  current.fenceGateResolutions();
4092
4649
  try {
4093
4650
  await current.waitForGateResolutionQuiescence();
4651
+ if (current.lifecycleTasks.size > 0) {
4652
+ const lifecycleDrain = Promise.all([...current.lifecycleTasks]);
4653
+ const timeout = Promise.withResolvers<void>();
4654
+ const timer = setTimeout(timeout.resolve, LIFECYCLE_QUIESCENCE_MS);
4655
+ const drained = Promise.race([lifecycleDrain, timeout.promise]);
4656
+ await drained;
4657
+ clearTimeout(timer);
4658
+ if (current.lifecycleTasks.size > 0) {
4659
+ logger.warn("SDK runtime lifecycle drain timed out; durable lifecycle recovery remains authoritative.");
4660
+ options.onLifecycleDrainTimeoutForTests?.();
4661
+ }
4662
+ }
4094
4663
  active = undefined;
4664
+ const retainsLifecycleWork =
4665
+ current.pending.length > 0 ||
4666
+ current.openLifecycleBatches.length > 0 ||
4667
+ (current.attachedInvocations?.length ?? 0) > 0 ||
4668
+ (current.drainedInvocations?.length ?? 0) > 0 ||
4669
+ current.lifecycleTasks.size > 0;
4670
+ if (retainsLifecycleWork) {
4671
+ const owners = retiredLifecycleOwners.get(current.sessionId) ?? [];
4672
+ if (!owners.includes(current)) owners.push(current);
4673
+ retiredLifecycleOwners.set(current.sessionId, owners);
4674
+ const retryCleanup = (): void => {
4675
+ retiredLifecycleOwnerTimers.delete(current);
4676
+ if (
4677
+ current.pending.length > 0 ||
4678
+ current.openLifecycleBatches.length > 0 ||
4679
+ (current.attachedInvocations?.length ?? 0) > 0 ||
4680
+ (current.drainedInvocations?.length ?? 0) > 0 ||
4681
+ current.lifecycleTasks.size > 0
4682
+ ) {
4683
+ const retry = setTimeout(retryCleanup, LIFECYCLE_QUIESCENCE_MS);
4684
+ retry.unref();
4685
+ retiredLifecycleOwnerTimers.set(current, retry);
4686
+ return;
4687
+ }
4688
+ removeRetiredLifecycleOwner(current);
4689
+ };
4690
+ const timer = setTimeout(retryCleanup, LIFECYCLE_QUIESCENCE_MS);
4691
+ timer.unref();
4692
+ retiredLifecycleOwnerTimers.set(current, timer);
4693
+ } else current.deadlineManager.clearAll();
4095
4694
  current.disposeGate?.();
4096
4695
  await current.runtime.stop();
4097
4696
  } catch (error) {
4697
+ // Keep the immutable owner available for a retry when transport teardown
4698
+ // fails after quiescing. Clearing `active` before stop prevents a second
4699
+ // shutdown from retrying the failed endpoint removal.
4700
+ if (active === undefined) active = current;
4098
4701
  logger.error("sdk runtime stop failed", { code: errorCode(error), error: String(error) });
4099
- active = current;
4100
4702
  throw error;
4101
4703
  }
4102
4704
  current.cursors.close();
@@ -4114,6 +4716,6 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4114
4716
  await startRuntime(ctx);
4115
4717
  });
4116
4718
  api.on("session_shutdown", async () => {
4117
- await stopActive();
4719
+ await stopActive(true);
4118
4720
  });
4119
4721
  }