@gajae-code/coding-agent 0.18.1 → 0.18.6
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 +161 -0
- package/dist/types/cli/stats-cli.d.ts +0 -5
- package/dist/types/cli-commands.d.ts +10 -0
- package/dist/types/cli-ordinary.d.ts +13 -0
- package/dist/types/cli.d.ts +12 -3
- package/dist/types/commands/contribution-prep.d.ts +0 -3
- package/dist/types/commands/sdk.d.ts +3 -0
- package/dist/types/commands/update.d.ts +1 -0
- package/dist/types/config/autorouting-tier-map.d.ts +29 -2
- package/dist/types/config/model-profile-activation.d.ts +19 -0
- package/dist/types/config/model-registry.d.ts +16 -1
- package/dist/types/coordinator-mcp/session-reaper.d.ts +5 -0
- package/dist/types/edit/modes/replace.d.ts +3 -3
- package/dist/types/edit/streaming.d.ts +2 -2
- package/dist/types/exa/mcp-client.d.ts +1 -5
- package/dist/types/exec/bash-shell-guardian.d.ts +26 -0
- package/dist/types/exec/bash-shell-supervisor.d.ts +12 -0
- package/dist/types/hashline/parser.d.ts +28 -5
- package/dist/types/modes/acp/acp-agent.d.ts +6 -1
- package/dist/types/sdk/broker/discovery.d.ts +8 -0
- package/dist/types/sdk/broker/ensure.d.ts +1 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +4 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +6 -1
- package/dist/types/sdk/broker/recovery-backoff.d.ts +62 -0
- package/dist/types/sdk/broker/runtime.d.ts +21 -1
- package/dist/types/sdk/broker/startup-budget.d.ts +5 -1
- package/dist/types/sdk/bus/index.d.ts +1 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +1 -0
- package/dist/types/sdk/cli/diagnostics-cli.d.ts +18 -0
- package/dist/types/sdk/diagnostics/observe-broker.d.ts +185 -0
- package/dist/types/sdk/host/control/operations.d.ts +1 -1
- package/dist/types/sdk/host/session-runtime.d.ts +7 -0
- package/dist/types/sdk/index.d.ts +1 -0
- package/dist/types/sdk/prompt-watchdog.d.ts +1 -1
- package/dist/types/sdk/router/session-router.d.ts +2 -0
- package/dist/types/sdk/session.d.ts +13 -0
- package/dist/types/session/agent-session.d.ts +5 -1
- package/dist/types/tools/ask-contract.d.ts +75 -254
- package/dist/types/tools/bash.d.ts +1 -0
- package/package.json +7 -7
- package/scripts/build-sdk-package-smoke.ts +5 -0
- package/src/cli/public-command-entry.ts +5 -0
- package/src/cli/ssh-cli.ts +6 -3
- package/src/cli/stats-cli.ts +4 -36
- package/src/cli/web-search-cli.ts +9 -3
- package/src/cli-commands.ts +14 -0
- package/src/cli-main.ts +4 -8
- package/src/cli-ordinary.ts +197 -0
- package/src/cli.ts +25 -186
- package/src/commands/commit.ts +2 -2
- package/src/commands/contribution-prep.ts +1 -3
- package/src/commands/sdk.ts +60 -2
- package/src/commands/update.ts +15 -1
- package/src/config/autorouting-tier-map.ts +20 -2
- package/src/config/file-lock.ts +91 -2
- package/src/config/model-preset-registry.ts +17 -2
- package/src/config/model-profile-activation.ts +73 -5
- package/src/config/model-profile-contract.ts +4 -1
- package/src/config/model-profiles.ts +29 -29
- package/src/config/model-registry.ts +45 -17
- package/src/config/model-resolver.ts +9 -1
- package/src/coordinator-mcp/event-webhook.ts +19 -14
- package/src/coordinator-mcp/policy.ts +14 -4
- package/src/coordinator-mcp/server.ts +142 -7
- package/src/coordinator-mcp/session-reaper.ts +9 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
- package/src/edit/modes/replace.ts +6 -6
- package/src/edit/streaming.ts +15 -15
- package/src/exa/mcp-client.ts +41 -8
- package/src/exec/bash-shell-guardian.ts +184 -34
- package/src/exec/bash-shell-supervisor.ts +60 -3
- package/src/hashline/execute.ts +51 -3
- package/src/hashline/parser.ts +63 -16
- package/src/internal-urls/docs-index.generated.ts +29 -28
- package/src/memories/index.ts +8 -2
- package/src/modes/acp/acp-agent.ts +688 -371
- package/src/modes/components/tool-execution.ts +1 -1
- package/src/modes/controllers/ssh-command-controller.ts +3 -1
- package/src/prompts/tools/ask.md +2 -0
- package/src/sdk/broker/broker.ts +46 -0
- package/src/sdk/broker/daemon-entry.ts +67 -6
- package/src/sdk/broker/discovery.ts +8 -0
- package/src/sdk/broker/ensure.ts +156 -20
- package/src/sdk/broker/lifecycle-ledger.ts +110 -12
- package/src/sdk/broker/lifecycle.ts +108 -34
- package/src/sdk/broker/recovery-backoff.ts +135 -0
- package/src/sdk/broker/runtime.ts +88 -1
- package/src/sdk/broker/startup-budget.ts +6 -1
- package/src/sdk/broker/transport.ts +5 -1
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/index.ts +305 -19
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/bus/telegram-daemon-control.ts +15 -12
- package/src/sdk/bus/telegram-daemon.ts +34 -1
- package/src/sdk/cli/diagnostics-cli.ts +247 -0
- package/src/sdk/diagnostics/observe-broker.ts +654 -0
- package/src/sdk/host/control/dispatch.ts +52 -4
- package/src/sdk/host/control/operations.ts +1 -1
- package/src/sdk/host/session-runtime.test.ts +40 -2
- package/src/sdk/host/session-runtime.ts +112 -8
- package/src/sdk/index.ts +1 -0
- package/src/sdk/lifecycle-session.ts +1 -0
- package/src/sdk/prompt-watchdog.ts +17 -4
- package/src/sdk/protocol/operation-registry.ts +1 -1
- package/src/sdk/router/session-router.ts +7 -1
- package/src/sdk/session.ts +37 -8
- package/src/session/agent-session.ts +128 -41
- package/src/session/blob-store.ts +78 -33
- package/src/session/internal/managed-session-storage.ts +119 -3
- package/src/setup/hermes/templates/operator-instructions.v1.md +19 -1
- package/src/setup/hermes-setup.ts +4 -1
- package/src/slash-commands/builtin-registry.ts +8 -5
- package/src/tools/ask-contract.test.ts +76 -1
- package/src/tools/ask-contract.ts +57 -6
- package/src/tools/ask.ts +16 -7
- package/src/tools/bash.ts +77 -29
- package/src/tools/descriptors.test.ts +29 -0
- package/src/tools/tool-catalog.generated.ts +7 -4
- package/src/web/search/index.ts +2 -2
- package/dist/types/cli/grep-cli.d.ts +0 -17
- package/dist/types/cli/shell-cli.d.ts +0 -8
- package/dist/types/commands/grep.d.ts +0 -42
- package/dist/types/commands/shell.d.ts +0 -21
- package/src/cli/grep-cli.ts +0 -160
- package/src/cli/shell-cli.ts +0 -176
- package/src/commands/grep.ts +0 -48
- package/src/commands/shell.ts +0 -29
|
@@ -93,6 +93,7 @@ const IDEMPOTENCY_TTL_MS = 15 * 60 * 1_000;
|
|
|
93
93
|
const MAX_IDEMPOTENCY_ENTRIES = 256;
|
|
94
94
|
|
|
95
95
|
const sessionChains = new WeakMap<ControlSurface, Promise<void>>();
|
|
96
|
+
const pendingReplacementBarriers = new WeakMap<ControlSurface, Promise<void>>();
|
|
96
97
|
interface IdempotencyEntry {
|
|
97
98
|
hash: string;
|
|
98
99
|
expiresAt: number;
|
|
@@ -189,6 +190,19 @@ function invalidInput(message: string): never {
|
|
|
189
190
|
throw new TypedControlError("invalid_input", message);
|
|
190
191
|
}
|
|
191
192
|
|
|
193
|
+
const EXPECTED_SDK_RUN_TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}:[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
|
|
194
|
+
|
|
195
|
+
function enqueueReplacementBarrier(surface: ControlSurface): () => void {
|
|
196
|
+
const previous = pendingReplacementBarriers.get(surface) ?? Promise.resolve();
|
|
197
|
+
const { promise: current, resolve: resolveCurrent } = Promise.withResolvers<void>();
|
|
198
|
+
const barrier = previous.then(() => current);
|
|
199
|
+
pendingReplacementBarriers.set(surface, barrier);
|
|
200
|
+
return () => {
|
|
201
|
+
resolveCurrent();
|
|
202
|
+
if (pendingReplacementBarriers.get(surface) === barrier) pendingReplacementBarriers.delete(surface);
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
192
206
|
/**
|
|
193
207
|
* C04 `turn.abort` dispatch.
|
|
194
208
|
*
|
|
@@ -244,7 +258,11 @@ function invoke(
|
|
|
244
258
|
case "turn.prompt":
|
|
245
259
|
return surface.prompt(text(input), input.images, input.clientRef as string | undefined);
|
|
246
260
|
case "turn.steer":
|
|
247
|
-
return surface.steer(
|
|
261
|
+
return surface.steer(
|
|
262
|
+
text(input),
|
|
263
|
+
typeof input.clientRef === "string" ? input.clientRef : undefined,
|
|
264
|
+
Object.hasOwn(input, "expectedSdkRunToken") ? (input.expectedSdkRunToken as string) : undefined,
|
|
265
|
+
);
|
|
248
266
|
case "turn.follow_up":
|
|
249
267
|
return surface.followUp(text(input));
|
|
250
268
|
case "turn.abort":
|
|
@@ -486,6 +504,15 @@ export function dispatchControl(
|
|
|
486
504
|
);
|
|
487
505
|
if (!isInput(request.input))
|
|
488
506
|
return Promise.resolve(failure(request.id, "invalid_input", "Control input must be an object."));
|
|
507
|
+
if (
|
|
508
|
+
row.sdkId === "turn.steer" &&
|
|
509
|
+
Object.hasOwn(request.input, "expectedSdkRunToken") &&
|
|
510
|
+
(typeof request.input.expectedSdkRunToken !== "string" ||
|
|
511
|
+
!EXPECTED_SDK_RUN_TOKEN_PATTERN.test(request.input.expectedSdkRunToken))
|
|
512
|
+
)
|
|
513
|
+
return Promise.resolve(
|
|
514
|
+
failure(request.id, "invalid_input", "expectedSdkRunToken must be a commandId:turnId pair."),
|
|
515
|
+
);
|
|
489
516
|
if (
|
|
490
517
|
row.sdkId === "turn.abort" &&
|
|
491
518
|
((brokerAbortFieldPresent && !brokerAbortAuthorized) ||
|
|
@@ -538,10 +565,31 @@ export function dispatchControl(
|
|
|
538
565
|
const work = () => execute(surface, row, dispatchRequest);
|
|
539
566
|
if (row.sdkId === "turn.abort_and_prompt") {
|
|
540
567
|
const cancellable = surface as PreflightCancellableSurface;
|
|
541
|
-
|
|
542
|
-
|
|
568
|
+
const releaseBarrier = enqueueReplacementBarrier(surface);
|
|
569
|
+
try {
|
|
570
|
+
if (Object.hasOwn(cancellable, "cancelPendingPreflights")) cancellable.cancelPendingPreflights?.();
|
|
571
|
+
return serialize(surface, async () => {
|
|
572
|
+
try {
|
|
573
|
+
return await work();
|
|
574
|
+
} finally {
|
|
575
|
+
releaseBarrier();
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
} catch (error) {
|
|
579
|
+
releaseBarrier();
|
|
580
|
+
throw error;
|
|
581
|
+
}
|
|
543
582
|
}
|
|
544
583
|
if (row.idempotency === "idempotent" && dispatchRequest.idempotencyKey)
|
|
545
584
|
return idempotent(surface, row, dispatchRequest, work);
|
|
546
|
-
|
|
585
|
+
if (row.sdkId === "turn.steer") {
|
|
586
|
+
const replacementBarrier = pendingReplacementBarriers.get(surface);
|
|
587
|
+
return replacementBarrier ? replacementBarrier.then(work) : work();
|
|
588
|
+
}
|
|
589
|
+
// Active feedback must reach the worker even while an earlier ordered control
|
|
590
|
+
// waits for that worker. Correlated steer admission remains deduplicated by
|
|
591
|
+
// the runtime reconciliation store, independently of this dispatch chain.
|
|
592
|
+
return row.idempotency === "ordered" && row.sdkId !== "retry.now" && row.sdkId !== "turn.steer"
|
|
593
|
+
? serialize(surface, work)
|
|
594
|
+
: work();
|
|
547
595
|
}
|
|
@@ -22,7 +22,7 @@ export type ControlInput = Record<string, unknown>;
|
|
|
22
22
|
*/
|
|
23
23
|
export interface ControlSurface {
|
|
24
24
|
prompt(text: string, images?: ControlValue, clientRef?: string): Promise<ControlValue> | ControlValue;
|
|
25
|
-
steer(text: string, clientRef?: string): Promise<ControlValue> | ControlValue;
|
|
25
|
+
steer(text: string, clientRef?: string, expectedSdkRunToken?: string): Promise<ControlValue> | ControlValue;
|
|
26
26
|
followUp(text: string): Promise<ControlValue> | ControlValue;
|
|
27
27
|
abort(): Promise<ControlValue> | ControlValue;
|
|
28
28
|
/** Terminal abort: stop the current root turn (and optionally exact owned work). */
|
|
@@ -290,7 +290,7 @@ test("session.last_assistant returns the latest projected readable text past non
|
|
|
290
290
|
});
|
|
291
291
|
});
|
|
292
292
|
|
|
293
|
-
test("session.last_assistant returns
|
|
293
|
+
test("session.last_assistant returns null when the projected transcript has no readable assistant text", async () => {
|
|
294
294
|
const sessionId = "last-assistant-empty";
|
|
295
295
|
const ctx = extensionContext(sessionId, "/tmp", {
|
|
296
296
|
transcript: [
|
|
@@ -311,7 +311,7 @@ test("session.last_assistant returns resource_gone when the projected transcript
|
|
|
311
311
|
],
|
|
312
312
|
});
|
|
313
313
|
|
|
314
|
-
expect(await queryLastAssistant(ctx, sessionId)).toMatchObject({ ok:
|
|
314
|
+
expect(await queryLastAssistant(ctx, sessionId)).toMatchObject({ ok: true, page: { items: [null] } });
|
|
315
315
|
});
|
|
316
316
|
|
|
317
317
|
test("native prompt reconciliation fails closed for an explicitly empty assistant result", () => {
|
|
@@ -4142,6 +4142,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
4142
4142
|
interface PreflightHooks {
|
|
4143
4143
|
onPreflightAccepted?: () => void;
|
|
4144
4144
|
onPreflightAcceptCommit?: () => void | Promise<void>;
|
|
4145
|
+
expectedSdkRunToken?: string;
|
|
4145
4146
|
}
|
|
4146
4147
|
|
|
4147
4148
|
interface ResponseFrame {
|
|
@@ -4482,6 +4483,43 @@ function neverSettlingPromise(): Promise<void> {
|
|
|
4482
4483
|
return Promise.withResolvers<void>().promise;
|
|
4483
4484
|
}
|
|
4484
4485
|
|
|
4486
|
+
test("SDK turn.steer preserves its expected run token and propagates a stale-run rejection", async () => {
|
|
4487
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-turn-steer-run-token-"));
|
|
4488
|
+
const staleToken = "ended-command:ended-turn";
|
|
4489
|
+
const clientRef = "stale-steer-ref";
|
|
4490
|
+
const calls: Array<{ content: unknown; deliverAs?: string; expectedSdkRunToken?: string }> = [];
|
|
4491
|
+
let harness: InvocationHarness | undefined;
|
|
4492
|
+
try {
|
|
4493
|
+
harness = await invocationHarness("steer-run-token", cwd, {
|
|
4494
|
+
sendUserMessage: async (content, options) => {
|
|
4495
|
+
calls.push({
|
|
4496
|
+
content,
|
|
4497
|
+
deliverAs: options?.deliverAs,
|
|
4498
|
+
expectedSdkRunToken: options?.expectedSdkRunToken,
|
|
4499
|
+
});
|
|
4500
|
+
if (options?.expectedSdkRunToken === staleToken)
|
|
4501
|
+
throw Object.assign(new Error("The expected SDK run is not active."), { code: "turn_not_active" });
|
|
4502
|
+
return "completed";
|
|
4503
|
+
},
|
|
4504
|
+
});
|
|
4505
|
+
|
|
4506
|
+
const rejected = await harness.control("turn.steer", {
|
|
4507
|
+
text: "stale steer",
|
|
4508
|
+
expectedSdkRunToken: staleToken,
|
|
4509
|
+
clientRef,
|
|
4510
|
+
});
|
|
4511
|
+
expect(rejected).toMatchObject({ ok: false, error: { code: "turn_not_active" } });
|
|
4512
|
+
expect(calls).toEqual([{ content: "stale steer", deliverAs: "steer", expectedSdkRunToken: staleToken }]);
|
|
4513
|
+
expect(await harness.query("turn.steer_status", { clientRef })).toMatchObject({
|
|
4514
|
+
ok: true,
|
|
4515
|
+
result: { status: "rejected" },
|
|
4516
|
+
});
|
|
4517
|
+
} finally {
|
|
4518
|
+
if (harness) await harness.stop();
|
|
4519
|
+
await rm(cwd, { recursive: true, force: true });
|
|
4520
|
+
}
|
|
4521
|
+
});
|
|
4522
|
+
|
|
4485
4523
|
describe("post-acceptance invocation terminalization", () => {
|
|
4486
4524
|
test.each([
|
|
4487
4525
|
{ status: 402, code: "provider_http_402" },
|
|
@@ -50,6 +50,12 @@ import { parseThinkingLevel } from "../../thinking";
|
|
|
50
50
|
import { readEndpointFile } from "../broker/endpoint-authority";
|
|
51
51
|
import { ensureBroker } from "../broker/ensure";
|
|
52
52
|
import { processIncarnation } from "../broker/process-incarnation";
|
|
53
|
+
import { RecoveryBackoffTracker } from "../broker/recovery-backoff";
|
|
54
|
+
import {
|
|
55
|
+
captureRuntimeImageIdentity,
|
|
56
|
+
isSdkInternalRuntimeImageReplaced,
|
|
57
|
+
sdkInternalRuntimeImage,
|
|
58
|
+
} from "../broker/runtime";
|
|
53
59
|
import {
|
|
54
60
|
type MasterRoleAttestationV2,
|
|
55
61
|
resolveSessionLocator,
|
|
@@ -716,6 +722,15 @@ export interface CreateSdkSessionRuntimeOptions {
|
|
|
716
722
|
onFailureDiagnosticKeyCountForTests?: (count: number) => void;
|
|
717
723
|
/** Test-only observation after a resolved invocation completion is reconciled. */
|
|
718
724
|
onInvocationCompletionReconciledForTests?: (kind: InvocationKind, correlation: InvocationCorrelation) => void;
|
|
725
|
+
/** Test hook: provides a function to inject a controlled clock for broker recovery backoff assertions. */
|
|
726
|
+
setRecoveryBackoffClockForTest?: (inject: (clock: { now(): number }) => void) => void;
|
|
727
|
+
/** Test hook: inject mock runtime image identity and replacement detection for broker recovery tests. */
|
|
728
|
+
setRuntimeImageIdentityForTest?: (
|
|
729
|
+
register: (
|
|
730
|
+
captureFn: () => Awaited<ReturnType<typeof captureRuntimeImageIdentity>> | undefined,
|
|
731
|
+
isReplacedFn: () => Promise<boolean>,
|
|
732
|
+
) => void,
|
|
733
|
+
) => void;
|
|
719
734
|
}
|
|
720
735
|
|
|
721
736
|
function unavailable(operation: string): () => never {
|
|
@@ -2044,7 +2059,7 @@ function createQuerySurface(
|
|
|
2044
2059
|
: entry.content?.flatMap(block => (block.type === "text" ? [block.text] : [])).join("\n");
|
|
2045
2060
|
if (text !== undefined && text.trim().length > 0) return text;
|
|
2046
2061
|
}
|
|
2047
|
-
return
|
|
2062
|
+
return null;
|
|
2048
2063
|
};
|
|
2049
2064
|
const getProfileCredentialSessionId = () => ctx.credentialSessionId ?? id;
|
|
2050
2065
|
const profileSettings = (options.settings ?? ctx.settings) as Pick<Settings, "get"> | undefined;
|
|
@@ -2899,6 +2914,7 @@ function createControlSurface(
|
|
|
2899
2914
|
};
|
|
2900
2915
|
type InternalSendOptions = NonNullable<Parameters<ExtensionAPI["sendUserMessage"]>[1]> & {
|
|
2901
2916
|
sdkRunCapability?: SdkRunCapability;
|
|
2917
|
+
expectedSdkRunToken?: string;
|
|
2902
2918
|
};
|
|
2903
2919
|
type InternalSdkApi = Omit<ExtensionAPI, "sendUserMessage"> & {
|
|
2904
2920
|
sendUserMessage: (
|
|
@@ -4228,23 +4244,30 @@ function createControlSurface(
|
|
|
4228
4244
|
),
|
|
4229
4245
|
);
|
|
4230
4246
|
},
|
|
4231
|
-
steer: async (text, clientRef) => {
|
|
4247
|
+
steer: async (text, clientRef, expectedSdkRunToken) => {
|
|
4232
4248
|
const invalid = validateRequiredPromptText("turn.steer", { text });
|
|
4233
4249
|
if (invalid) throw Object.assign(new Error(invalid.message), { code: invalid.code });
|
|
4234
4250
|
const retainedClientRef = normalizeClientRef(clientRef);
|
|
4251
|
+
const sendSteer = () =>
|
|
4252
|
+
sendSdkUserMessage(text, {
|
|
4253
|
+
deliverAs: "steer",
|
|
4254
|
+
...(expectedSdkRunToken === undefined ? {} : { expectedSdkRunToken }),
|
|
4255
|
+
});
|
|
4235
4256
|
if (retainedClientRef === undefined) {
|
|
4236
4257
|
const correlation = newCorrelation();
|
|
4237
|
-
await
|
|
4258
|
+
await sendSteer();
|
|
4238
4259
|
return { accepted: true, ...correlation };
|
|
4239
4260
|
}
|
|
4240
4261
|
const durable = steerReconciliation;
|
|
4241
4262
|
const reservation = await durable.reserveSteer(retainedClientRef, text);
|
|
4242
4263
|
if (reservation.replay) return { accepted: reservation.result.status === "accepted", ...reservation.result };
|
|
4243
4264
|
try {
|
|
4244
|
-
await
|
|
4265
|
+
await sendSteer();
|
|
4245
4266
|
return { accepted: true, ...(await durable.settleSteer(retainedClientRef, "accepted")) };
|
|
4246
4267
|
} catch (error) {
|
|
4247
|
-
|
|
4268
|
+
const settled = await durable.settleSteer(retainedClientRef, "rejected", error);
|
|
4269
|
+
if (expectedSdkRunToken !== undefined) throw error;
|
|
4270
|
+
return { accepted: false, ...settled };
|
|
4248
4271
|
}
|
|
4249
4272
|
},
|
|
4250
4273
|
followUp: async text => {
|
|
@@ -7013,6 +7036,43 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
|
|
|
7013
7036
|
let brokerRegistered = false;
|
|
7014
7037
|
let brokerRegistrationInFlight: Promise<void> | undefined;
|
|
7015
7038
|
let brokerRecoveryStopped = false;
|
|
7039
|
+
let brokerRecoveryRestartRequired = false;
|
|
7040
|
+
const brokerRecoveryBackoff = new RecoveryBackoffTracker();
|
|
7041
|
+
if (options.setRecoveryBackoffClockForTest) {
|
|
7042
|
+
options.setRecoveryBackoffClockForTest((clock: { now(): number }) =>
|
|
7043
|
+
brokerRecoveryBackoff.setClockForTest(clock),
|
|
7044
|
+
);
|
|
7045
|
+
}
|
|
7046
|
+
// Capture runtime image identity at startup to detect replacements
|
|
7047
|
+
let startupRuntimeImageIdentity: Awaited<ReturnType<typeof captureRuntimeImageIdentity>> | undefined;
|
|
7048
|
+
let captureStartupRuntimeImageForTest:
|
|
7049
|
+
| (() => Awaited<ReturnType<typeof captureRuntimeImageIdentity>> | undefined)
|
|
7050
|
+
| undefined;
|
|
7051
|
+
let isRuntimeImageReplacedForTest: (() => Promise<boolean>) | undefined;
|
|
7052
|
+
if (options.setRuntimeImageIdentityForTest) {
|
|
7053
|
+
options.setRuntimeImageIdentityForTest(
|
|
7054
|
+
(
|
|
7055
|
+
captureFn: () => Awaited<ReturnType<typeof captureRuntimeImageIdentity>> | undefined,
|
|
7056
|
+
isReplacedFn: () => Promise<boolean>,
|
|
7057
|
+
) => {
|
|
7058
|
+
captureStartupRuntimeImageForTest = captureFn;
|
|
7059
|
+
isRuntimeImageReplacedForTest = isReplacedFn;
|
|
7060
|
+
},
|
|
7061
|
+
);
|
|
7062
|
+
}
|
|
7063
|
+
const captureStartupRuntimeImage = async (): Promise<void> => {
|
|
7064
|
+
if (options.setRuntimeImageIdentityForTest) {
|
|
7065
|
+
// Test mode: use injected identity
|
|
7066
|
+
startupRuntimeImageIdentity = captureStartupRuntimeImageForTest?.();
|
|
7067
|
+
} else {
|
|
7068
|
+
// Production mode: capture real runtime image
|
|
7069
|
+
const runtimeImage = sdkInternalRuntimeImage();
|
|
7070
|
+
if (runtimeImage) {
|
|
7071
|
+
startupRuntimeImageIdentity = await captureRuntimeImageIdentity(runtimeImage);
|
|
7072
|
+
}
|
|
7073
|
+
}
|
|
7074
|
+
};
|
|
7075
|
+
const startupImageCapture = captureStartupRuntimeImage();
|
|
7016
7076
|
const registerBroker = async (): Promise<void> => {
|
|
7017
7077
|
if (brokerRegistered) return;
|
|
7018
7078
|
if (brokerRegistrationInFlight !== undefined) {
|
|
@@ -7143,17 +7203,58 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
|
|
|
7143
7203
|
let brokerRecoveryTimer: NodeJS.Timeout | undefined;
|
|
7144
7204
|
let brokerRecoveryInFlight: Promise<void> | undefined;
|
|
7145
7205
|
const runBrokerRecovery = async (): Promise<void> => {
|
|
7146
|
-
if (brokerRecoveryStopped || brokerRecoveryInFlight !== undefined) return;
|
|
7206
|
+
if (brokerRecoveryStopped || brokerRecoveryInFlight !== undefined || brokerRecoveryRestartRequired) return;
|
|
7207
|
+
// Checked synchronously so an interval tick cannot start an attempt the backoff forbids.
|
|
7208
|
+
if (!brokerRecoveryBackoff.canAttemptRecovery(options.agentDir)) return;
|
|
7209
|
+
|
|
7210
|
+
// The single-flight slot is claimed before the first await: the interval does not wait
|
|
7211
|
+
// for prior callbacks, so a slow runtime-image probe must not let a second tick through
|
|
7212
|
+
// to ensure the broker and record the same failure twice.
|
|
7147
7213
|
const recovery = (async (): Promise<void> => {
|
|
7214
|
+
// A replaced runtime image cannot launch a broker; restarting the session is the only fix.
|
|
7215
|
+
const imageReplaced = await (isRuntimeImageReplacedForTest
|
|
7216
|
+
? isRuntimeImageReplacedForTest()
|
|
7217
|
+
: isSdkInternalRuntimeImageReplaced(startupRuntimeImageIdentity).catch(() => false));
|
|
7218
|
+
if (imageReplaced) {
|
|
7219
|
+
brokerRecoveryRestartRequired = true;
|
|
7220
|
+
stopBrokerRecovery();
|
|
7221
|
+
logger.warn("sdk broker recovery requires session restart", {
|
|
7222
|
+
reason: "runtime_image_replaced",
|
|
7223
|
+
});
|
|
7224
|
+
return;
|
|
7225
|
+
}
|
|
7148
7226
|
if (brokerRecoveryStopped) return;
|
|
7149
7227
|
if (brokerRegistered) await (options.ensureBrokerImpl ?? ensureBroker)({ agentDir: options.agentDir });
|
|
7150
|
-
else
|
|
7228
|
+
else {
|
|
7229
|
+
// Optional registration logs and resolves on failure; recovery must still count it
|
|
7230
|
+
// as a failed attempt so the backoff holds instead of resetting every tick.
|
|
7231
|
+
await registerBroker();
|
|
7232
|
+
if (!brokerRegistered && !brokerRecoveryStopped)
|
|
7233
|
+
throw Object.assign(new Error("SDK broker registration did not complete."), {
|
|
7234
|
+
code: "registration_incomplete",
|
|
7235
|
+
});
|
|
7236
|
+
}
|
|
7151
7237
|
})();
|
|
7152
7238
|
brokerRecoveryInFlight = recovery;
|
|
7153
7239
|
try {
|
|
7154
7240
|
await recovery;
|
|
7241
|
+
if (brokerRecoveryRestartRequired || brokerRecoveryStopped) return;
|
|
7242
|
+
brokerRecoveryBackoff.recordSuccess(options.agentDir);
|
|
7155
7243
|
} catch (error) {
|
|
7156
|
-
|
|
7244
|
+
const shouldContinue = brokerRecoveryBackoff.recordFailure(options.agentDir);
|
|
7245
|
+
if (!shouldContinue) {
|
|
7246
|
+
// Max recovery attempts reached; mark that restart is required.
|
|
7247
|
+
brokerRecoveryRestartRequired = true;
|
|
7248
|
+
stopBrokerRecovery();
|
|
7249
|
+
logger.warn("sdk broker recovery max attempts exceeded", {
|
|
7250
|
+
code: brokerDiagnosticCode(error),
|
|
7251
|
+
});
|
|
7252
|
+
} else {
|
|
7253
|
+
logger.warn("sdk broker recovery unavailable", {
|
|
7254
|
+
code: brokerDiagnosticCode(error),
|
|
7255
|
+
backoffWaitMs: brokerRecoveryBackoff.getBackoffWaitMs(options.agentDir),
|
|
7256
|
+
});
|
|
7257
|
+
}
|
|
7157
7258
|
} finally {
|
|
7158
7259
|
if (brokerRecoveryInFlight === recovery) brokerRecoveryInFlight = undefined;
|
|
7159
7260
|
}
|
|
@@ -7210,6 +7311,9 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
|
|
|
7210
7311
|
try {
|
|
7211
7312
|
publishedEndpointUrl = (await runtime.start()).url;
|
|
7212
7313
|
await registerBroker();
|
|
7314
|
+
// Await the startup runtime image capture before arming recovery.
|
|
7315
|
+
// This ensures replacement detection works correctly on the first recovery check.
|
|
7316
|
+
await startupImageCapture;
|
|
7213
7317
|
if (!brokerRecoveryStopped) startBrokerRecovery();
|
|
7214
7318
|
} catch (error) {
|
|
7215
7319
|
active = undefined;
|
package/src/sdk/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ export type {
|
|
|
21
21
|
TrackedSendUserMessageOptions,
|
|
22
22
|
} from "../session/agent-session";
|
|
23
23
|
export * as bus from "./bus";
|
|
24
|
+
export * from "./diagnostics/observe-broker";
|
|
24
25
|
export * as host from "./host";
|
|
25
26
|
export * as lifecycle from "./lifecycle";
|
|
26
27
|
export * as mcp from "./mcp";
|
|
@@ -123,6 +123,7 @@ export async function createLifecycleAgentSession(
|
|
|
123
123
|
// inside the broker's readiness window is what kills the child at the
|
|
124
124
|
// cutoff; the host resumes it once readiness is published.
|
|
125
125
|
deferMemoryBackendStartup: true,
|
|
126
|
+
deferOptionalModelRefresh: true,
|
|
126
127
|
[lifecycleStartupCapabilityOption]: capability,
|
|
127
128
|
...(mcpStartupTimeoutMs !== undefined ? { [lifecycleMcpStartupTimeoutOption]: mcpStartupTimeoutMs } : {}),
|
|
128
129
|
} as CreateAgentSessionOptions & {
|
|
@@ -97,8 +97,13 @@ export class PromptActivity {
|
|
|
97
97
|
#awaitingModel = false;
|
|
98
98
|
|
|
99
99
|
/** Folds one inbound frame's lifecycle event into the in-flight tool and model state. */
|
|
100
|
-
observe(
|
|
101
|
-
|
|
100
|
+
observe(
|
|
101
|
+
eventType: string | undefined,
|
|
102
|
+
toolCallId: string | undefined,
|
|
103
|
+
messageRole: string | undefined,
|
|
104
|
+
messageHasToolCall = false,
|
|
105
|
+
): void {
|
|
106
|
+
this.#observeInference(eventType, messageRole, messageHasToolCall);
|
|
102
107
|
if (toolCallId === undefined) return;
|
|
103
108
|
if (eventType === "tool_execution_start" || eventType === "tool_execution_update") this.#running.add(toolCallId);
|
|
104
109
|
else if (eventType === "tool_execution_end") this.#running.delete(toolCallId);
|
|
@@ -110,7 +115,11 @@ export class PromptActivity {
|
|
|
110
115
|
* re-invocation that carries a tool result back — until the first frame of that
|
|
111
116
|
* response proves the model answered.
|
|
112
117
|
*/
|
|
113
|
-
#observeInference(
|
|
118
|
+
#observeInference(
|
|
119
|
+
eventType: string | undefined,
|
|
120
|
+
messageRole: string | undefined,
|
|
121
|
+
messageHasToolCall: boolean,
|
|
122
|
+
): void {
|
|
114
123
|
if (eventType === "agent_start" || eventType === "tool_execution_end") {
|
|
115
124
|
this.#awaitingModel = true;
|
|
116
125
|
return;
|
|
@@ -132,7 +141,11 @@ export class PromptActivity {
|
|
|
132
141
|
// Providers that do not stream emit no `message_update`, so the assistant message
|
|
133
142
|
// itself is the first proof. `message_start`/`message_end` also carry the user and
|
|
134
143
|
// tool-result messages the host echoes back, which prove nothing about the model.
|
|
135
|
-
if (
|
|
144
|
+
if (
|
|
145
|
+
(eventType === "message_start" || eventType === "message_end") &&
|
|
146
|
+
messageRole === "assistant" &&
|
|
147
|
+
!messageHasToolCall
|
|
148
|
+
)
|
|
136
149
|
this.#awaitingModel = false;
|
|
137
150
|
}
|
|
138
151
|
|
|
@@ -267,7 +267,7 @@ function controlErrors(id: string): string[] {
|
|
|
267
267
|
// refuses a session with no selected model before admission. The code is
|
|
268
268
|
// appended: ACP machine-only mapping reads errorCodes[0].
|
|
269
269
|
C01: ["client_ref_conflict", "reconciliation_capacity", "reconciliation_persist_failed", "model_not_selected"],
|
|
270
|
-
C02: ["client_ref_conflict", "reconciliation_capacity", "reconciliation_persist_failed"],
|
|
270
|
+
C02: ["turn_not_active", "client_ref_conflict", "reconciliation_capacity", "reconciliation_persist_failed"],
|
|
271
271
|
C09: ["client_ref_conflict", "reconciliation_capacity", "reconciliation_persist_failed"],
|
|
272
272
|
C06: ["action_claimed"],
|
|
273
273
|
C07: ["action_claimed", "terminal_uncertain"],
|
|
@@ -287,6 +287,8 @@ export interface SessionRouterOptions {
|
|
|
287
287
|
agentDir: string;
|
|
288
288
|
/** Limits attachment to exact ids selected by a Broker-scoped operation. */
|
|
289
289
|
sessionIds?: readonly string[];
|
|
290
|
+
/** Dynamically limits attachment to sessions currently owned by the caller. */
|
|
291
|
+
attachFilter?: (sessionId: string) => boolean;
|
|
290
292
|
/** Marks default SDK clients as notification-only observers; demanding by default. */
|
|
291
293
|
observer?: boolean;
|
|
292
294
|
deps?: SessionRouterDeps;
|
|
@@ -615,6 +617,7 @@ type AdoptedSession = {
|
|
|
615
617
|
export class SessionRouter {
|
|
616
618
|
readonly #agentDir: string;
|
|
617
619
|
readonly #sessionIds: ReadonlySet<string> | undefined;
|
|
620
|
+
readonly #attachFilter: ((sessionId: string) => boolean) | undefined;
|
|
618
621
|
readonly #observer: boolean;
|
|
619
622
|
readonly #deps: SessionRouterDeps;
|
|
620
623
|
readonly #correlateFrame: SessionRouterFrameCorrelator;
|
|
@@ -657,6 +660,7 @@ export class SessionRouter {
|
|
|
657
660
|
constructor(options: SessionRouterOptions) {
|
|
658
661
|
this.#agentDir = options.agentDir;
|
|
659
662
|
this.#sessionIds = options.sessionIds === undefined ? undefined : new Set(options.sessionIds);
|
|
663
|
+
this.#attachFilter = options.attachFilter;
|
|
660
664
|
this.#observer = options.observer === true;
|
|
661
665
|
this.#deps = options.deps ?? {};
|
|
662
666
|
this.#correlateFrame = options.correlateFrame ?? fallbackCorrelation;
|
|
@@ -797,6 +801,7 @@ export class SessionRouter {
|
|
|
797
801
|
if (
|
|
798
802
|
sessionId !== fallback.sessionId ||
|
|
799
803
|
(this.#sessionIds !== undefined && !this.#sessionIds.has(sessionId ?? "")) ||
|
|
804
|
+
(this.#attachFilter !== undefined && !this.#attachFilter(sessionId ?? "")) ||
|
|
800
805
|
endpointGeneration === undefined ||
|
|
801
806
|
pid === undefined ||
|
|
802
807
|
endpointMtimeMs === undefined ||
|
|
@@ -1294,7 +1299,8 @@ export class SessionRouter {
|
|
|
1294
1299
|
session.live &&
|
|
1295
1300
|
isSessionAuthorityEligible(session) &&
|
|
1296
1301
|
!session.terminalUncertain &&
|
|
1297
|
-
(this.#sessionIds === undefined || this.#sessionIds.has(session.sessionId))
|
|
1302
|
+
(this.#sessionIds === undefined || this.#sessionIds.has(session.sessionId)) &&
|
|
1303
|
+
(this.#attachFilter === undefined || this.#attachFilter(session.sessionId)),
|
|
1298
1304
|
);
|
|
1299
1305
|
const liveIds = new Set(live.map(session => session.sessionId));
|
|
1300
1306
|
const attachedIds = new Set<string>();
|
package/src/sdk/session.ts
CHANGED
|
@@ -59,7 +59,11 @@ import { loadCapability, reset as resetCapabilities } from "../capability";
|
|
|
59
59
|
import { type Rule, ruleCapability, setActiveRules } from "../capability/rule";
|
|
60
60
|
import type { SourceMeta } from "../capability/types";
|
|
61
61
|
import { AUTOROUTING_INACTIVE_WARNING } from "../config/autorouting-contract";
|
|
62
|
-
import {
|
|
62
|
+
import {
|
|
63
|
+
isSessionCredentialPinBlocking,
|
|
64
|
+
ModelProfileCredentialError,
|
|
65
|
+
resolveMissingSessionModelRecovery,
|
|
66
|
+
} from "../config/model-profile-activation";
|
|
63
67
|
import { resolveModelProfileName } from "../config/model-profile-contract";
|
|
64
68
|
import { resolveProfileBindings } from "../config/model-profiles";
|
|
65
69
|
import { kNoAuth, ModelRegistry } from "../config/model-registry";
|
|
@@ -577,6 +581,12 @@ export interface CreateAgentSessionOptions {
|
|
|
577
581
|
* @internal CLI-only ordering guard; SDK callers retain immediate startup by default.
|
|
578
582
|
*/
|
|
579
583
|
deferMemoryBackendStartup?: boolean;
|
|
584
|
+
/**
|
|
585
|
+
* Skip optional model-catalog discovery during lifecycle startup. ACP hosts must
|
|
586
|
+
* publish their endpoint before unrelated provider credential refreshes begin.
|
|
587
|
+
* @internal lifecycle-only startup guard.
|
|
588
|
+
*/
|
|
589
|
+
deferOptionalModelRefresh?: boolean;
|
|
580
590
|
|
|
581
591
|
/** Enable LSP integration (tool, formatting, diagnostics, warmup). Default: true */
|
|
582
592
|
enableLsp?: boolean;
|
|
@@ -695,6 +705,22 @@ export interface CreateAgentSessionResult {
|
|
|
695
705
|
gjcRuntimeSnapshot?: GjcRuntimeSnapshotProvider;
|
|
696
706
|
}
|
|
697
707
|
|
|
708
|
+
/**
|
|
709
|
+
* Start optional provider/model discovery only when it is safe to add work to
|
|
710
|
+
* the caller's startup graph. Lifecycle hosts deliberately skip this during
|
|
711
|
+
* construction: discovery preflights every configured provider and may refresh
|
|
712
|
+
* an unrelated OAuth credential while several hosts are opening the same DB.
|
|
713
|
+
*/
|
|
714
|
+
export function startOptionalModelRefresh(
|
|
715
|
+
modelRegistry: Pick<ModelRegistry, "refreshInBackground">,
|
|
716
|
+
credentialSessionId: string | undefined,
|
|
717
|
+
deferred: boolean,
|
|
718
|
+
): boolean {
|
|
719
|
+
if (deferred) return false;
|
|
720
|
+
modelRegistry.refreshInBackground("online-if-uncached", credentialSessionId);
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
723
|
+
|
|
698
724
|
export interface DeferredMcpConfigStartupResult {
|
|
699
725
|
loadedToolCount: number;
|
|
700
726
|
hasErrors: boolean;
|
|
@@ -1970,11 +1996,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1970
1996
|
options.preferredCredentialSelector.selector,
|
|
1971
1997
|
);
|
|
1972
1998
|
}
|
|
1999
|
+
// A runtime key or this registry's literal models.yml apiKey outranks an
|
|
2000
|
+
// unavailable pin; without one the provider stays blocked (never retargeted).
|
|
2001
|
+
const isCredentialPinBlocking = (provider: string): boolean =>
|
|
2002
|
+
isSessionCredentialPinBlocking(modelRegistry, provider, credentialSessionId);
|
|
1973
2003
|
const modelApiKeyAvailability = new Map<string, boolean>();
|
|
1974
2004
|
const getModelAvailabilityKey = (candidate: Model): string =>
|
|
1975
2005
|
`${candidate.provider}\u0000${candidate.baseUrl ?? ""}`;
|
|
1976
2006
|
const hasModelApiKey = async (candidate: Model): Promise<boolean> => {
|
|
1977
|
-
if (
|
|
2007
|
+
if (isCredentialPinBlocking(candidate.provider)) return false;
|
|
1978
2008
|
const availabilityKey = getModelAvailabilityKey(candidate);
|
|
1979
2009
|
const cached = modelApiKeyAvailability.get(availabilityKey);
|
|
1980
2010
|
if (cached !== undefined) {
|
|
@@ -2000,6 +2030,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
2000
2030
|
const key = await modelRegistry
|
|
2001
2031
|
.getApiKey(candidate, credentialSessionId, { credentialSelector })
|
|
2002
2032
|
.catch(error => {
|
|
2033
|
+
if (isCredentialPinBlocking(candidate.provider)) return undefined;
|
|
2003
2034
|
if (credentialSelector) {
|
|
2004
2035
|
logger.debug("Credential selector did not match model availability candidate", {
|
|
2005
2036
|
provider: candidate.provider,
|
|
@@ -2148,7 +2179,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
2148
2179
|
settings,
|
|
2149
2180
|
);
|
|
2150
2181
|
await refreshMissingQualifiedModelProviders(startupModelSelectors, modelRegistry, credentialSessionId);
|
|
2151
|
-
modelRegistry
|
|
2182
|
+
startOptionalModelRefresh(modelRegistry, credentialSessionId, options.deferOptionalModelRefresh === true);
|
|
2152
2183
|
}
|
|
2153
2184
|
|
|
2154
2185
|
const hasExplicitModel = options.model !== undefined || options.modelPattern !== undefined;
|
|
@@ -2241,8 +2272,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
2241
2272
|
{
|
|
2242
2273
|
managedFallback: defaultModelEntries.length > 1,
|
|
2243
2274
|
canonicalSessionId: providerSessionId,
|
|
2244
|
-
isCredentialUnavailable:
|
|
2245
|
-
authStorage.hasSessionCredentialUnavailable(provider, credentialSessionId),
|
|
2275
|
+
isCredentialUnavailable: isCredentialPinBlocking,
|
|
2246
2276
|
...(persistedProfileOwnsDefault ? { aliasIntent: "preset-equivalent" as const } : {}),
|
|
2247
2277
|
},
|
|
2248
2278
|
);
|
|
@@ -2297,7 +2327,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
2297
2327
|
!hasExplicitModel &&
|
|
2298
2328
|
!model &&
|
|
2299
2329
|
defaultRoleSpec.model &&
|
|
2300
|
-
!
|
|
2330
|
+
!isCredentialPinBlocking(defaultRoleSpec.model.provider) &&
|
|
2301
2331
|
(!preferredCredentialProvider || defaultRoleSpec.model.provider === preferredCredentialProvider)
|
|
2302
2332
|
) {
|
|
2303
2333
|
const settingsDefaultModel = defaultRoleSpec.model;
|
|
@@ -4236,8 +4266,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
4236
4266
|
{
|
|
4237
4267
|
managedFallback: defaultModelEntries.length > 1,
|
|
4238
4268
|
canonicalSessionId: providerSessionId,
|
|
4239
|
-
isCredentialUnavailable:
|
|
4240
|
-
authStorage.hasSessionCredentialUnavailable(provider, credentialSessionId),
|
|
4269
|
+
isCredentialUnavailable: isCredentialPinBlocking,
|
|
4241
4270
|
...(persistedProfileOwnsDefault ? { aliasIntent: "preset-equivalent" as const } : {}),
|
|
4242
4271
|
},
|
|
4243
4272
|
);
|