@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.
- package/CHANGELOG.md +72 -0
- package/dist/types/async/job-manager.d.ts +7 -6
- package/dist/types/config/autorouting-tier-map.d.ts +6 -0
- package/dist/types/config/file-lock.d.ts +1 -0
- package/dist/types/config/model-profile-activation.d.ts +18 -3
- package/dist/types/config/model-registry.d.ts +9 -0
- package/dist/types/coordinator-mcp/server.d.ts +3 -0
- package/dist/types/modes/acp/acp-agent.d.ts +40 -1
- package/dist/types/modes/components/thinking-selector.d.ts +2 -1
- package/dist/types/sdk/acp/adapter.d.ts +27 -0
- package/dist/types/sdk/acp/final-text.d.ts +8 -0
- package/dist/types/sdk/broker/ensure.d.ts +46 -1
- package/dist/types/sdk/prompt-deadline-manager.d.ts +1 -1
- package/dist/types/sdk/prompt-failure.d.ts +30 -1
- package/dist/types/sdk/prompt-status.d.ts +8 -0
- package/dist/types/sdk/router/replay-retention-gap-cache.d.ts +13 -0
- package/dist/types/sdk/transport/serve-cli.d.ts +27 -0
- package/dist/types/thinking.d.ts +3 -1
- package/dist/types/utils/mupdf-wasm-embedded.d.ts +2 -0
- package/package.json +7 -7
- package/src/async/job-manager.ts +38 -8
- package/src/commands/sdk.ts +78 -4
- package/src/config/autorouting-tier-map.ts +2 -0
- package/src/config/file-lock.ts +52 -17
- package/src/config/model-profile-activation.ts +72 -10
- package/src/config/model-registry.ts +21 -0
- package/src/coordinator-mcp/server.ts +21 -6
- package/src/debug/report-bundle.ts +114 -24
- package/src/gjc-runtime/managed-owner-admission.ts +67 -50
- package/src/internal-urls/docs-index.generated.ts +7 -6
- package/src/modes/acp/acp-agent.ts +187 -14
- package/src/modes/acp/acp-event-mapper.ts +9 -0
- package/src/modes/components/model-selector.ts +31 -15
- package/src/modes/components/status-line/segments.ts +6 -1
- package/src/modes/components/thinking-selector.ts +7 -3
- package/src/modes/controllers/selector-controller.ts +16 -3
- package/src/sdk/acp/adapter.ts +63 -1
- package/src/sdk/acp/final-text.ts +19 -2
- package/src/sdk/broker/ensure.ts +97 -19
- package/src/sdk/bus/index.ts +31 -4
- package/src/sdk/host/session-runtime.test.ts +498 -7
- package/src/sdk/host/session-runtime.ts +188 -21
- package/src/sdk/prompt-deadline-manager.ts +3 -3
- package/src/sdk/prompt-failure.ts +43 -1
- package/src/sdk/prompt-status.ts +8 -0
- package/src/sdk/router/replay-retention-gap-cache.ts +27 -0
- package/src/sdk/router/session-router.ts +22 -3
- package/src/sdk/session.ts +12 -9
- package/src/sdk/transport/serve-cli.ts +105 -15
- package/src/session-import/codex.ts +12 -3
- package/src/thinking.ts +21 -2
- package/src/utils/mupdf-wasm-embedded.ts +3 -0
- 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 (
|
|
1649
|
-
const configuredProviders = ctx.modelRegistry.getConfiguredProviderIds?.()
|
|
1650
|
-
|
|
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 = (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
5220
|
-
|
|
5221
|
-
|
|
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 {
|
|
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
|
package/src/sdk/prompt-status.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/sdk/session.ts
CHANGED
|
@@ -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
|
|
5200
|
-
// the prior global only when this manager
|
|
5201
|
-
// a retry under the same endpoint is falsely
|
|
5202
|
-
// redirects global-manager consumers away from
|
|
5203
|
-
// (review thread P1).
|
|
5204
|
-
if (
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
|
|
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
|
}
|