@gajae-code/coding-agent 0.17.1 → 0.17.2

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 (53) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/types/async/job-manager.d.ts +7 -6
  3. package/dist/types/config/autorouting-tier-map.d.ts +6 -0
  4. package/dist/types/config/file-lock.d.ts +1 -0
  5. package/dist/types/config/model-profile-activation.d.ts +18 -3
  6. package/dist/types/config/model-registry.d.ts +9 -0
  7. package/dist/types/coordinator-mcp/server.d.ts +3 -0
  8. package/dist/types/modes/acp/acp-agent.d.ts +40 -1
  9. package/dist/types/modes/components/thinking-selector.d.ts +2 -1
  10. package/dist/types/sdk/acp/adapter.d.ts +27 -0
  11. package/dist/types/sdk/acp/final-text.d.ts +8 -0
  12. package/dist/types/sdk/broker/ensure.d.ts +46 -1
  13. package/dist/types/sdk/prompt-deadline-manager.d.ts +1 -1
  14. package/dist/types/sdk/prompt-failure.d.ts +30 -1
  15. package/dist/types/sdk/prompt-status.d.ts +8 -0
  16. package/dist/types/sdk/router/replay-retention-gap-cache.d.ts +13 -0
  17. package/dist/types/sdk/transport/serve-cli.d.ts +27 -0
  18. package/dist/types/thinking.d.ts +3 -1
  19. package/dist/types/utils/mupdf-wasm-embedded.d.ts +2 -0
  20. package/package.json +7 -7
  21. package/src/async/job-manager.ts +38 -8
  22. package/src/commands/sdk.ts +78 -4
  23. package/src/config/autorouting-tier-map.ts +2 -0
  24. package/src/config/file-lock.ts +52 -17
  25. package/src/config/model-profile-activation.ts +72 -10
  26. package/src/config/model-registry.ts +21 -0
  27. package/src/coordinator-mcp/server.ts +21 -6
  28. package/src/debug/report-bundle.ts +114 -24
  29. package/src/gjc-runtime/managed-owner-admission.ts +67 -50
  30. package/src/internal-urls/docs-index.generated.ts +7 -6
  31. package/src/modes/acp/acp-agent.ts +187 -14
  32. package/src/modes/acp/acp-event-mapper.ts +9 -0
  33. package/src/modes/components/model-selector.ts +31 -15
  34. package/src/modes/components/status-line/segments.ts +6 -1
  35. package/src/modes/components/thinking-selector.ts +7 -3
  36. package/src/modes/controllers/selector-controller.ts +16 -3
  37. package/src/sdk/acp/adapter.ts +63 -1
  38. package/src/sdk/acp/final-text.ts +19 -2
  39. package/src/sdk/broker/ensure.ts +97 -19
  40. package/src/sdk/bus/index.ts +31 -4
  41. package/src/sdk/host/session-runtime.test.ts +498 -7
  42. package/src/sdk/host/session-runtime.ts +188 -21
  43. package/src/sdk/prompt-deadline-manager.ts +3 -3
  44. package/src/sdk/prompt-failure.ts +43 -1
  45. package/src/sdk/prompt-status.ts +8 -0
  46. package/src/sdk/router/replay-retention-gap-cache.ts +27 -0
  47. package/src/sdk/router/session-router.ts +22 -3
  48. package/src/sdk/session.ts +12 -9
  49. package/src/sdk/transport/serve-cli.ts +105 -15
  50. package/src/session-import/codex.ts +12 -3
  51. package/src/thinking.ts +21 -2
  52. package/src/utils/mupdf-wasm-embedded.ts +3 -0
  53. package/src/utils/mupdf-wasm.ts +24 -2
@@ -4,13 +4,14 @@ import * as crypto from "node:crypto";
4
4
  import * as fs from "node:fs/promises";
5
5
  import * as path from "node:path";
6
6
  import { promisify } from "node:util";
7
- import { isContinuingMidRunMaintenanceOutcome, ThinkingLevel } from "@gajae-code/agent-core";
7
+ import { isContinuingMidRunMaintenanceOutcome, isNonDispatchedToolEvent, ThinkingLevel } from "@gajae-code/agent-core";
8
8
  import type { Api, ImageContent, Model } from "@gajae-code/ai/core";
9
9
  import { logger } from "@gajae-code/utils";
10
10
  import { AsyncJobManager } from "../../async";
11
11
  import {
12
12
  getProxyRoutableProviders,
13
13
  inspectProxyProviderId,
14
+ isModelProfileProxyConfigured,
14
15
  requiresQualifiedModelProfileRoleResolution,
15
16
  resolveProxyMode,
16
17
  rewriteSelectorForProxy,
@@ -1645,18 +1646,18 @@ function createQuerySurface(
1645
1646
  };
1646
1647
  try {
1647
1648
  const proxyMode = profile.source === "user" ? "fallback" : resolveProxyMode(profileSettings);
1648
- if (profile.source !== "user") {
1649
- const configuredProviders = ctx.modelRegistry.getConfiguredProviderIds?.() ?? [];
1650
- if (proxyProvider !== undefined && !configuredProviders.includes(proxyProvider))
1649
+ if (proxyProvider !== undefined) {
1650
+ const configuredProviders = ctx.modelRegistry.getConfiguredProviderIds?.();
1651
+ const credentialless =
1652
+ proxyProvider === "opencodex" &&
1653
+ !configuredProviders?.includes(proxyProvider) &&
1654
+ (await ctx.modelRegistry.getApiKeyForProvider(proxyProvider, getProfileCredentialSessionId())) ===
1655
+ kNoAuth;
1656
+ if (!isModelProfileProxyConfigured(proxyProvider, configuredProviders, credentialless))
1651
1657
  return { available: false };
1652
1658
  }
1653
1659
  if (profile.source !== "user" && proxyMode === "always") {
1654
- if (
1655
- proxyProvider === undefined ||
1656
- !proxyAuthenticated ||
1657
- !(ctx.modelRegistry.getConfiguredProviderIds?.() ?? []).includes(proxyProvider)
1658
- )
1659
- return { available: false };
1660
+ if (proxyProvider === undefined || !proxyAuthenticated) return { available: false };
1660
1661
  }
1661
1662
  const bindings = resolveProfileBindings(profile);
1662
1663
  const assignments: Array<{ value: ModelSelectorValue; isDefault: boolean }> = [];
@@ -4378,6 +4379,78 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4378
4379
  const activePromptOwnerHolder: { connectionIds?: Set<string>; lifecycleEpoch?: number } = {};
4379
4380
  let nextLifecycleEpoch = 0;
4380
4381
  const skillTerminalRecoveryKeys = new Set<string>();
4382
+ /**
4383
+ * Exactly one correlated terminal BOUNDARY (agent_end) may reach the wire per
4384
+ * correlation. The provider agent_end handler and the prompt-deadline expiry can both
4385
+ * reach their emission after the other has already published: the handler captures its
4386
+ * transitions synchronously and then awaits durable writes, while the deadline callback
4387
+ * fires only after its own claim/finalize awaits have settled. Neither can be stopped by
4388
+ * removing lifecycle references, and noteTransition is a no-op once the record is
4389
+ * terminal, so durable state would record one outcome while clients received two. This
4390
+ * claim is the shared arbiter, taken synchronously -- no await between the check and the
4391
+ * set -- by whoever is about to publish the boundary.
4392
+ *
4393
+ * The key is never removed at cleanup: dropping it when the correlation retires would
4394
+ * re-open the window, and would stop a late real boundary from being idempotent for an
4395
+ * already-retired correlation. Growth is bounded by FIFO eviction instead (a Set
4396
+ * preserves insertion order). A correlation displaced by 1024 later boundaries is long
4397
+ * retired, and its durable record stays authoritative for anything a consumer missed.
4398
+ *
4399
+ * Eviction must never reach a correlation that can still be published (review P1). A
4400
+ * handler captures its transitions, awaits durable writes, and claims only immediately
4401
+ * before its emit; parked on those awaits, 1024 later boundaries would otherwise evict
4402
+ * its key and let its delayed agent_end publish a SECOND boundary for a correlation the
4403
+ * deadline already terminalized. Every would-be publisher therefore RETAINS its
4404
+ * correlations for as long as it can still reach a claim, and a retained key is skipped
4405
+ * as an eviction victim.
4406
+ */
4407
+ const MAX_PUBLISHED_TERMINAL_BOUNDARIES = 1024;
4408
+ const publishedTerminalBoundaries = new Set<string>();
4409
+ /** Correlations with at least one publisher still able to reach a claim. */
4410
+ const retainedTerminalBoundaries = new Map<string, number>();
4411
+ /**
4412
+ * Protect every correlation this publisher may still claim, and return its release.
4413
+ * Callers MUST release on every exit path -- published, claim lost, or threw -- so a
4414
+ * failure cannot leak a permanent protection.
4415
+ */
4416
+ const retainTerminalBoundaries = (
4417
+ invocations: ReadonlyArray<{ correlation: InvocationCorrelation }>,
4418
+ ): (() => void) => {
4419
+ const keys = invocations.map(({ correlation }) => lifecycleCorrelationKey(correlation));
4420
+ for (const key of keys) retainedTerminalBoundaries.set(key, (retainedTerminalBoundaries.get(key) ?? 0) + 1);
4421
+ let released = false;
4422
+ return () => {
4423
+ if (released) return;
4424
+ released = true;
4425
+ for (const key of keys) {
4426
+ const retained = retainedTerminalBoundaries.get(key);
4427
+ if (retained === undefined) continue;
4428
+ if (retained > 1) retainedTerminalBoundaries.set(key, retained - 1);
4429
+ else retainedTerminalBoundaries.delete(key);
4430
+ }
4431
+ };
4432
+ };
4433
+ const claimTerminalBoundary = (correlation: InvocationCorrelation): boolean => {
4434
+ const key = lifecycleCorrelationKey(correlation);
4435
+ if (publishedTerminalBoundaries.has(key)) return false;
4436
+ publishedTerminalBoundaries.add(key);
4437
+ while (publishedTerminalBoundaries.size > MAX_PUBLISHED_TERMINAL_BOUNDARIES) {
4438
+ let victim: string | undefined;
4439
+ for (const candidate of publishedTerminalBoundaries) {
4440
+ if (retainedTerminalBoundaries.has(candidate)) continue;
4441
+ victim = candidate;
4442
+ break;
4443
+ }
4444
+ // Every key is still publishable: exceed the bound rather than evict a live
4445
+ // one. Correctness beats the bound, and the excess is bounded by the live
4446
+ // lifecycles the session already bounds.
4447
+ if (victim === undefined) break;
4448
+ publishedTerminalBoundaries.delete(victim);
4449
+ }
4450
+ return true;
4451
+ };
4452
+ const hasClaimedTerminalBoundary = (correlation: InvocationCorrelation): boolean =>
4453
+ publishedTerminalBoundaries.has(lifecycleCorrelationKey(correlation));
4381
4454
  const trackLifecycle = (handler: () => Promise<void>, owner: RuntimeState | undefined): Promise<void> => {
4382
4455
  if (!owner) return Promise.resolve();
4383
4456
  let task: Promise<void>;
@@ -4728,6 +4801,11 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4728
4801
  // recorded as observed=false, never rethrown into the api handler.
4729
4802
  let observed = true;
4730
4803
  const failedTransitions: Array<{ kind: InvocationKind; correlation: InvocationCorrelation }> = [];
4804
+ // Retained BEFORE the first durable await: from here this publisher can still
4805
+ // reach the claim below, so no later boundary may evict its correlations while
4806
+ // it is parked. A start retains too -- its keys are not published yet, so the
4807
+ // protection is inert, and one unconditional release path cannot leak.
4808
+ const releaseTerminalRetention = retainTerminalBoundaries(transitions);
4731
4809
  try {
4732
4810
  for (const invocation of transitions) {
4733
4811
  try {
@@ -4809,6 +4887,15 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4809
4887
  failureCause ?? Object.assign(new Error("agent run failed"), { code: "agent_failed" }),
4810
4888
  );
4811
4889
  for (const invocation of transitions) {
4890
+ // A correlation whose terminal boundary is already published was
4891
+ // terminalized by the deadline, which emitted its own diagnostic together
4892
+ // with the pair; a second correlated agent_failed would duplicate it. The
4893
+ // check is READ-ONLY: the claim is taken by would-be agent_end publishers
4894
+ // alone, because the real path emits its agent_failed and its agent_end
4895
+ // from two SEPARATE emitLifecycle invocations, so claiming here would make
4896
+ // the publisher block its own boundary. In the ordinary flow this changes
4897
+ // nothing -- agent_failed precedes agent_end and the claim is untaken.
4898
+ if (hasClaimedTerminalBoundary(invocation.correlation)) continue;
4812
4899
  try {
4813
4900
  current.runtime.emitEvent({
4814
4901
  type,
@@ -4856,6 +4943,15 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4856
4943
  // above with the one uncorrelated frame, so it cannot settle a live prompt.
4857
4944
  const outcome = terminalOutcome ?? terminalStoppedOutcome(stopReason, maintenanceOutcome);
4858
4945
  for (const invocation of transitions) {
4946
+ // Claim synchronously, immediately before the emit: the durable awaits
4947
+ // above give the deadline expiry room to publish this correlation's
4948
+ // boundary first. On a lost claim skip ONLY this frame -- every durable
4949
+ // write, diagnostic cleanup, batch retirement and waiter resolution below
4950
+ // still runs. `observed` deliberately stays true: it means "the correlated
4951
+ // publication reached the wire", and it did, the deadline put it there.
4952
+ // Flipping it would make a terminal abort's durable row refuse to claim
4953
+ // terminalPublished for a boundary that IS on the wire.
4954
+ if (!claimTerminalBoundary(invocation.correlation)) continue;
4859
4955
  try {
4860
4956
  current.runtime.emitEvent({ type, sessionId, ...invocation.correlation, outcome });
4861
4957
  } catch {
@@ -4872,6 +4968,10 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
4872
4968
  }
4873
4969
  } catch {
4874
4970
  observed = false;
4971
+ } finally {
4972
+ // Every exit path -- published, claim lost, or threw -- releases: this
4973
+ // publisher can no longer reach a claim.
4974
+ releaseTerminalRetention();
4875
4975
  }
4876
4976
  if (type === "agent_end" && isContinuingMidRunMaintenanceOutcome(maintenanceOutcome)) return;
4877
4977
  if (type === "agent_end") {
@@ -5055,7 +5155,16 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
5055
5155
  // the active run — the root invocation plus any in-run consumed follow-ups
5056
5156
  // sharing it — not only the head, or an attached correlation would
5057
5157
  // false-fire prompt_deadline_exceeded during a long shared run.
5058
- const renewAttributableProgress = (eventType: string, ctx: ExtensionContext): void => {
5158
+ const renewAttributableProgress = (
5159
+ eventType: "tool_execution_start" | "tool_execution_update" | "tool_execution_end",
5160
+ event: AgentSessionEvent,
5161
+ ctx: ExtensionContext,
5162
+ ): void => {
5163
+ // Pairing-only tool boundaries synthesized while an abort unwinds never
5164
+ // entered a tool. Treating them as progress lets a deadline renew its own
5165
+ // lease after claiming the terminal outcome, leaving that claim pending
5166
+ // instead of durably finalizing it.
5167
+ if (isNonDispatchedToolEvent(event)) return;
5059
5168
  // Tool events do not carry an SDK run token. Prefer the lifecycle-active
5060
5169
  // runtime for the current session so a retained predecessor cannot make a
5061
5170
  // live replacement look ambiguous and suppress its lease renewal.
@@ -5166,14 +5275,14 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
5166
5275
  }
5167
5276
  publishContentFrames(current, event as AgentSessionEvent, invocations);
5168
5277
  };
5169
- api.on("tool_execution_start", async (_event, ctx) => {
5170
- renewAttributableProgress("tool_execution_start", ctx);
5278
+ api.on("tool_execution_start", async (event, ctx) => {
5279
+ renewAttributableProgress("tool_execution_start", event as AgentSessionEvent, ctx);
5171
5280
  });
5172
- api.on("tool_execution_update", async (_event, ctx) => {
5173
- renewAttributableProgress("tool_execution_update", ctx);
5281
+ api.on("tool_execution_update", async (event, ctx) => {
5282
+ renewAttributableProgress("tool_execution_update", event as AgentSessionEvent, ctx);
5174
5283
  });
5175
- api.on("tool_execution_end", async (_event, ctx) => {
5176
- renewAttributableProgress("tool_execution_end", ctx);
5284
+ api.on("tool_execution_end", async (event, ctx) => {
5285
+ renewAttributableProgress("tool_execution_end", event as AgentSessionEvent, ctx);
5177
5286
  });
5178
5287
  const errorCode = (error: unknown): string | undefined =>
5179
5288
  typeof error === "object" &&
@@ -5214,11 +5323,69 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
5214
5323
  const v = options.settings?.get("sdk.promptMaxRuntimeMs" as never) as number | undefined;
5215
5324
  return typeof v === "number" && Number.isFinite(v) ? v : 21_600_000;
5216
5325
  },
5217
- onExpired: correlation => {
5326
+ onExpired: (correlation, deadlineOutcome) => {
5218
5327
  const owner = lifecycleOwnerHolder.state;
5219
- if (!owner) return;
5220
- removeLifecycleReferences(owner, correlation);
5221
- maybeRetireLifecycleOwner(owner);
5328
+ if (deadlineOutcome === undefined) {
5329
+ if (!owner) return;
5330
+ removeLifecycleReferences(owner, correlation);
5331
+ maybeRetireLifecycleOwner(owner);
5332
+ return;
5333
+ }
5334
+ const failure = sanitizePromptFailure(
5335
+ Object.assign(new Error("Prompt deadline exceeded."), { code: deadlineOutcome.code }),
5336
+ );
5337
+ // The deadline publishes a failed/end PAIR and owns it atomically, so the
5338
+ // boundary claim is taken once, here, covering both frames. Losing it means
5339
+ // a real agent_end already reached the wire for this correlation: emit
5340
+ // NEITHER frame, but still run the cleanup below, and still let #expire
5341
+ // finish its durable reconciliation. Losing the boundary must not skip
5342
+ // cleanup.
5343
+ //
5344
+ // Retained for the whole publication: this path owns the correlation from
5345
+ // here, so a concurrent boundary must not evict its key mid-flight.
5346
+ const releaseTerminalRetention = retainTerminalBoundaries([{ correlation }]);
5347
+ try {
5348
+ if (claimTerminalBoundary(correlation)) {
5349
+ // Each required frame is emitted INDEPENDENTLY (review P2): a throwing
5350
+ // diagnostic must never suppress the boundary, which is the frame this
5351
+ // path exists to deliver. Both failures are reported, never rethrown --
5352
+ // the cleanup below still has to run.
5353
+ try {
5354
+ runtime.emitEvent({
5355
+ type: "agent_failed",
5356
+ sessionId,
5357
+ ...correlation,
5358
+ error: failure,
5359
+ });
5360
+ } catch (error) {
5361
+ logger.warn("sdk: deadline correlated diagnostic publication failed", {
5362
+ commandId: correlation.commandId,
5363
+ turnId: correlation.turnId,
5364
+ error: sanitizePromptFailure(error),
5365
+ });
5366
+ }
5367
+ try {
5368
+ runtime.emitEvent({
5369
+ type: "agent_end",
5370
+ sessionId,
5371
+ ...correlation,
5372
+ outcome: canonicalFailedOutcome(failure, "deadline"),
5373
+ });
5374
+ } catch (error) {
5375
+ logger.error("sdk: deadline correlated boundary publication failed", {
5376
+ commandId: correlation.commandId,
5377
+ turnId: correlation.turnId,
5378
+ error: sanitizePromptFailure(error),
5379
+ });
5380
+ }
5381
+ }
5382
+ } finally {
5383
+ releaseTerminalRetention();
5384
+ }
5385
+ if (owner) {
5386
+ removeLifecycleReferences(owner, correlation);
5387
+ maybeRetireLifecycleOwner(owner);
5388
+ }
5222
5389
  },
5223
5390
  });
5224
5391
  const pending: Array<{
@@ -48,14 +48,14 @@ export class PromptDeadlineManager {
48
48
  readonly #getLeaseMs: () => number;
49
49
  readonly #getMaxMs: () => number;
50
50
  readonly #now: () => number;
51
- readonly #onExpired?: (correlation: InvocationCorrelation) => void;
51
+ readonly #onExpired?: (correlation: InvocationCorrelation, outcome?: PromptDeadlineOutcome) => void;
52
52
 
53
53
  constructor(options: {
54
54
  reconciliation: DeadlineReconciliation;
55
55
  getLeaseMs: () => number;
56
56
  getMaxMs: () => number;
57
57
  now?: () => number;
58
- onExpired?: (correlation: InvocationCorrelation) => void;
58
+ onExpired?: (correlation: InvocationCorrelation, outcome?: PromptDeadlineOutcome) => void;
59
59
  }) {
60
60
  this.#reconciliation = options.reconciliation;
61
61
  this.#getLeaseMs = options.getLeaseMs;
@@ -197,7 +197,7 @@ export class PromptDeadlineManager {
197
197
  // accepted/in-flight invocation without an owner, retry, or deadline
198
198
  // recovery path.
199
199
  try {
200
- this.#onExpired?.(correlation);
200
+ this.#onExpired?.(correlation, outcome);
201
201
  } catch {}
202
202
  this.#expiryRetries.delete(key);
203
203
  this.clear(correlation);
@@ -3,7 +3,12 @@
3
3
  * Provider error text is retained only in the local diagnostic log; wire and
4
4
  * persisted reconciliation details expose a fixed redacted message.
5
5
  */
6
- import type { SdkPromptFailureCategory, SdkPromptFailurePhase, SdkPromptTerminalOutcome } from "./prompt-status";
6
+ import type {
7
+ SdkPromptFailureCategory,
8
+ SdkPromptFailurePhase,
9
+ SdkPromptFailureRetryability,
10
+ SdkPromptTerminalOutcome,
11
+ } from "./prompt-status";
7
12
 
8
13
  export const PROMPT_FAILURE_CODE_MAX = 64;
9
14
  const LOCAL_FAILURE_LOG_MAX = 16_384;
@@ -79,6 +84,43 @@ export function promptFailureCategory(
79
84
  return "unknown";
80
85
  }
81
86
 
87
+ /**
88
+ * Whether the bounded category says an identical re-submission could still
89
+ * succeed. Read off the category alone, so it cannot drift from the sets above.
90
+ *
91
+ * `provider_transport` is the only transient class: every classifier in
92
+ * `PROVIDER_TRANSPORT_CODES` (and the 5xx range) describes the link to the
93
+ * provider failing, never a judgement about the request, so the same bytes may
94
+ * well succeed on a second attempt. That is exactly the class the shipped
95
+ * first-turn gate (`isStartupReadinessFailure`, issue #5574) already treats as
96
+ * re-submittable, so the wire signal and the internal gate stay in agreement.
97
+ *
98
+ * Everything else is terminal for this turn: `provider_rejected` is the provider
99
+ * judging this request (quota, refusal, 4xx) and re-sending it reproduces the
100
+ * rejection; `deadline` means the turn's budget is spent, which a re-submit does
101
+ * not restore; `agent_runtime` is a local fault that re-running the same prompt
102
+ * re-enters. `unknown` stays `unknown` — the module's standing contract is to
103
+ * preserve uncertain attribution rather than guess a recovery for it.
104
+ *
105
+ * This is advisory classification for the caller, not a retry gate: nothing in
106
+ * this repo's retry paths consults it.
107
+ */
108
+ export function promptFailureRetryability(category: SdkPromptFailureCategory): SdkPromptFailureRetryability {
109
+ if (category === "provider_transport") return "transient";
110
+ if (category === "unknown") return "unknown";
111
+ return "terminal";
112
+ }
113
+
114
+ /**
115
+ * Whether a value is a safe bounded classifier token — the same rule
116
+ * `sanitizePromptFailure` and `assistantFailureCode` apply before retaining one.
117
+ * Anything else is provider text rather than a classifier and must not be
118
+ * forwarded to a caller.
119
+ */
120
+ export function isSafePromptFailureCode(value: unknown): value is string {
121
+ return typeof value === "string" && value.length <= PROMPT_FAILURE_CODE_MAX && /^[A-Za-z0-9._-]+$/.test(value);
122
+ }
123
+
82
124
  /**
83
125
  * Phase- and category-aware safe wording. A post-start failure is never
84
126
  * described as a submission rejection; the wording says only what the bounded
@@ -75,6 +75,14 @@ export type SdkPromptFailureCategory =
75
75
  | "agent_runtime"
76
76
  | "deadline"
77
77
  | "unknown";
78
+ /**
79
+ * Whether the failure class says an identical re-submission could still succeed.
80
+ * Derived from `SdkPromptFailureCategory` alone (see `promptFailureRetryability`)
81
+ * so a consumer never has to re-implement the category matrix or parse a message.
82
+ * `unknown` is preserved rather than collapsed into `terminal`: an uncertain
83
+ * attribution is not evidence that the turn is unrecoverable.
84
+ */
85
+ export type SdkPromptFailureRetryability = "transient" | "terminal" | "unknown";
78
86
  export type SdkPromptTerminalOutcome =
79
87
  | { kind: "stopped"; reason: SdkPromptStopReason; provenance: "agent" | "client_cancel" }
80
88
  | {
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Retention-gap concessions are durable stream facts, but a Router can observe
3
+ * many streams over its lifetime. Keep the warning memo bounded while retaining
4
+ * enough recent coordinates to suppress reconnect/poll duplicates.
5
+ */
6
+ export const REPLAY_CONCESSION_CACHE_LIMIT = 1_024;
7
+
8
+ /**
9
+ * Records one stream/gap coordinate in an LRU memo.
10
+ *
11
+ * `undefined` means this is the first observation; a number is the duplicate
12
+ * count after the coordinate was already observed.
13
+ */
14
+ export function rememberReplayRetentionGap(cache: Map<string, number>, key: string): number | undefined {
15
+ const previous = cache.get(key);
16
+ if (previous !== undefined) {
17
+ cache.delete(key);
18
+ cache.set(key, previous + 1);
19
+ return previous + 1;
20
+ }
21
+ cache.set(key, 0);
22
+ if (cache.size > REPLAY_CONCESSION_CACHE_LIMIT) {
23
+ const oldest = cache.keys().next().value;
24
+ if (oldest !== undefined) cache.delete(oldest);
25
+ }
26
+ return undefined;
27
+ }
@@ -33,6 +33,7 @@ import {
33
33
  } from "../session-activation";
34
34
  import type { SessionBindingAuthority } from "../session-authority";
35
35
  import { ACP_SESSION_RECONNECT, SESSION_REQUEST_TIMEOUT_MS } from "../session-reconnect";
36
+ import { rememberReplayRetentionGap } from "./replay-retention-gap-cache";
36
37
 
37
38
  export type { SessionBindingAuthority, SessionEndpointAuthority } from "../session-authority";
38
39
 
@@ -627,6 +628,8 @@ export class SessionRouter {
627
628
  >();
628
629
  readonly #reviving = new Set<string>();
629
630
  readonly #notificationReceipts = new Map<string, NotificationCleanupReceipt>();
631
+ /** Recent retention-gap coordinates already reported at warning level. */
632
+ readonly #concededReplayGaps = new Map<string, number>();
630
633
  #stopTimer: (() => void) | undefined;
631
634
  #reconcileTail: Promise<void> = Promise.resolve();
632
635
  #reconcilePending: { readonly runEpoch: number; force: boolean } | undefined;
@@ -2203,6 +2206,24 @@ export class SessionRouter {
2203
2206
  `chat daemon replay barrier failed (${reason}); rebuilding session ${attached.sessionId} at generation ${attached.generation} from seq ${attached.cursor.seq}.`,
2204
2207
  );
2205
2208
  }
2209
+
2210
+ #logReplayRetentionGap(
2211
+ attached: AttachedSession,
2212
+ gap: Readonly<{ fromSeq: number; toSeq: number }>,
2213
+ recoveredNote: string,
2214
+ ): void {
2215
+ const key = JSON.stringify([attached.sessionId, attached.generation, gap.fromSeq, gap.toSeq]);
2216
+ const repeat = rememberReplayRetentionGap(this.#concededReplayGaps, key);
2217
+ if (repeat !== undefined) {
2218
+ logger.debug(
2219
+ `chat daemon replay retention gap repeated (sequences ${gap.fromSeq}-${gap.toSeq}); suppressed warning ${repeat} for session ${attached.sessionId} generation ${attached.generation}.`,
2220
+ );
2221
+ return;
2222
+ }
2223
+ logger.warn(
2224
+ `chat daemon replay conceded a retention gap (sequences ${gap.fromSeq}-${gap.toSeq} are gone from the host${recoveredNote}); session ${attached.sessionId} generation ${attached.generation} resumes at seq ${gap.toSeq + 1}.`,
2225
+ );
2226
+ }
2206
2227
  #failDelivery(attached: AttachedSession, seq: number, error: unknown): void {
2207
2228
  const previous = this.#undelivered.get(attached.sessionId);
2208
2229
  const attempts = previous?.generation === attached.generation && previous.seq === seq ? previous.attempts + 1 : 1;
@@ -2503,9 +2524,7 @@ export class SessionRouter {
2503
2524
  held.splice(0, held.length, ...carried);
2504
2525
  const recoveredNote =
2505
2526
  recovered.length > 0 ? `, ${recovered.length} of them recovered from live delivery` : "";
2506
- logger.warn(
2507
- `chat daemon replay conceded a retention gap (sequences ${gap.fromSeq}-${gap.toSeq} are gone from the host${recoveredNote}); session ${attached.sessionId} generation ${attached.generation} resumes at seq ${gap.toSeq + 1}.`,
2508
- );
2527
+ this.#logReplayRetentionGap(attached, gap, recoveredNote);
2509
2528
  for (const entry of recovered) this.#rememberRecoveredFrame(attached, entry.seq, entry.frame);
2510
2529
  if (!(await this.#deliverRecoveredFrames(attached))) return;
2511
2530
  if (gap.toSeq > attached.cursor.seq) attached.cursor.seq = gap.toSeq;
@@ -1516,6 +1516,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1516
1516
  let processCwdClaimed = false;
1517
1517
  let hasRegistered = false;
1518
1518
  let asyncJobManager: AsyncJobManager | undefined;
1519
+ let asyncJobManagerOwned = false;
1519
1520
  let asyncJobManagerAdmitted = false;
1520
1521
  let priorAsyncJobManager: AsyncJobManager | undefined;
1521
1522
  let cleanupOwnedMcpManager: (() => Promise<void>) | undefined;
@@ -2296,6 +2297,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2296
2297
  },
2297
2298
  })
2298
2299
  : options.inheritedAsyncJobManager;
2300
+ asyncJobManagerOwned = backgroundJobsEnabled === true && !options.parentTaskPrefix;
2299
2301
 
2300
2302
  let promptMetadataModel: Model | undefined;
2301
2303
  const getActiveModelString = (): string | undefined => {
@@ -5196,15 +5198,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
5196
5198
  } else {
5197
5199
  if (hasRegistered) agentRegistry.unregister(resolvedAgentId);
5198
5200
  // Admission happens before session construction. Any later startup
5199
- // failure must remove THIS manager's endpoint mapping and restore
5200
- // the prior global only when this manager is still global: otherwise
5201
- // a retry under the same endpoint is falsely rejected and an orphan
5202
- // redirects global-manager consumers away from the live session
5203
- // (review thread P1).
5204
- if (asyncJobManagerAdmitted && asyncJobManager) {
5205
- AsyncJobManager.unregisterManager(asyncJobManager);
5206
- if (AsyncJobManager.instance() === asyncJobManager) {
5207
- AsyncJobManager.setInstance(priorAsyncJobManager);
5201
+ // failure must eventually release THIS manager's endpoint mapping
5202
+ // through disposal and restore the prior global only when this manager
5203
+ // is still global: otherwise a retry under the same endpoint is falsely
5204
+ // rejected and an orphan redirects global-manager consumers away from
5205
+ // the live session (review thread P1).
5206
+ if (asyncJobManagerOwned && asyncJobManager) {
5207
+ if (asyncJobManagerAdmitted) {
5208
+ if (AsyncJobManager.instance() === asyncJobManager) {
5209
+ AsyncJobManager.setInstance(priorAsyncJobManager);
5210
+ }
5208
5211
  }
5209
5212
  await asyncJobManager.dispose({ timeoutMs: 100 });
5210
5213
  }