@gajae-code/coding-agent 0.15.4 → 0.15.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 +46 -2
- package/dist/types/async/job-manager.d.ts +83 -0
- package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
- package/dist/types/cli/model-presets-cli.d.ts +9 -0
- package/dist/types/cli/update-cli.d.ts +27 -5
- package/dist/types/commands/auth-gateway.d.ts +3 -0
- package/dist/types/commands/gc.d.ts +13 -0
- package/dist/types/commands/model-presets.d.ts +21 -0
- package/dist/types/config/autorouting-tier-map.d.ts +99 -0
- package/dist/types/config/file-lock.d.ts +49 -2
- package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
- package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
- package/dist/types/config/keybindings.d.ts +3 -2
- package/dist/types/config/model-preset-registry.d.ts +368 -0
- package/dist/types/config/model-profile-activation.d.ts +39 -0
- package/dist/types/config/model-profiles.d.ts +5 -3
- package/dist/types/config/model-registry.d.ts +11 -5
- package/dist/types/config/settings-schema.d.ts +9 -0
- package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
- package/dist/types/coordinator-mcp/projection-scan.d.ts +46 -0
- package/dist/types/extensibility/extensions/types.d.ts +4 -4
- package/dist/types/extensibility/shared-events.d.ts +2 -0
- package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +6 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/main.d.ts +3 -1
- package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
- package/dist/types/modes/jobs-observer.d.ts +36 -0
- package/dist/types/sdk/broker/broker.d.ts +3 -0
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
- package/dist/types/sdk/cli/session-cli.d.ts +42 -1
- package/dist/types/sdk/client/discovery.d.ts +1 -0
- package/dist/types/sdk/host/host.d.ts +2 -0
- package/dist/types/sdk/host/model-pin.d.ts +7 -2
- package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
- package/dist/types/sdk/host/session-runtime.d.ts +19 -1
- package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
- package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
- package/dist/types/sdk/router/session-router.d.ts +9 -0
- package/dist/types/session/account-inventory.d.ts +1 -0
- package/dist/types/session/agent-session.d.ts +19 -5
- package/dist/types/session/fold-coordinator.d.ts +201 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +4 -3
- package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
- package/dist/types/session/sdk-run-capability.d.ts +2 -0
- package/dist/types/telemetry/control.d.ts +11 -0
- package/dist/types/telemetry/events.d.ts +20 -0
- package/dist/types/telemetry/index.d.ts +3 -0
- package/dist/types/telemetry/transport.d.ts +22 -0
- package/dist/types/tools/bash-interactive.d.ts +23 -0
- package/dist/types/tools/index.d.ts +6 -5
- package/dist/types/tools/path-utils.d.ts +5 -6
- package/dist/types/tools/tmux-self-injection-guard.d.ts +15 -0
- package/dist/types/web/search/providers/base.d.ts +9 -4
- package/dist/types/web/search/providers/xai.d.ts +2 -1
- package/dist/types/web/search/types.d.ts +14 -0
- package/package.json +10 -7
- package/scripts/generate-hotkeys-docs.ts +4 -0
- package/scripts/generate-sdk-operation-inventory.ts +3 -3
- package/src/async/job-manager.ts +671 -39
- package/src/cli/auth-broker-cli.ts +40 -12
- package/src/cli/auth-gateway-cli.ts +273 -97
- package/src/cli/config-cli.ts +2 -2
- package/src/cli/fast-help.ts +3 -0
- package/src/cli/model-presets-cli.ts +100 -0
- package/src/cli/update-cli.ts +142 -6
- package/src/cli.ts +6 -2
- package/src/commands/auth-gateway.ts +12 -8
- package/src/commands/gc.ts +19 -0
- package/src/commands/model-presets.ts +24 -0
- package/src/commands/sdk.ts +5 -0
- package/src/config/autorouting-tier-map.ts +33 -0
- package/src/config/file-lock-gc.ts +9 -4
- package/src/config/file-lock.ts +775 -59
- package/src/config/internal/model-preset-registry-test-state.ts +28 -0
- package/src/config/internal/model-preset-registry-test-support.ts +11 -0
- package/src/config/keybindings.ts +6 -2
- package/src/config/model-preset-registry.ts +2799 -0
- package/src/config/model-profile-activation.ts +115 -28
- package/src/config/model-profile-contract.ts +4 -1
- package/src/config/model-profiles.ts +56 -14
- package/src/config/model-registry.ts +1028 -123
- package/src/config/settings-schema.ts +11 -0
- package/src/config/settings.ts +6 -1
- package/src/coordinator-mcp/model-preset.ts +32 -17
- package/src/coordinator-mcp/policy.ts +1 -1
- package/src/coordinator-mcp/projection-scan.ts +571 -0
- package/src/coordinator-mcp/server.ts +19 -24
- package/src/coordinator-mcp/session-reaper.ts +9 -3
- package/src/extensibility/extensions/loader.ts +0 -1
- package/src/extensibility/extensions/types.ts +4 -2
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
- package/src/extensibility/shared-events.ts +2 -0
- package/src/gjc-runtime/empty-delete-gc.ts +301 -0
- package/src/gjc-runtime/gc-render.ts +29 -0
- package/src/gjc-runtime/gc-runtime.ts +174 -11
- package/src/gjc-runtime/launch-worktree.ts +44 -7
- package/src/gjc-runtime/ralplan-runtime.ts +14 -1
- package/src/gjc-runtime/session-state-lock.ts +1242 -141
- package/src/gjc-runtime/session-state-sidecar.ts +76 -3
- package/src/gjc-runtime/tmux-sessions.ts +145 -48
- package/src/index.ts +1 -0
- package/src/internal-urls/docs-index.generated.ts +14 -13
- package/src/lsp/config.ts +4 -3
- package/src/lsp/path-trust.ts +12 -6
- package/src/main.ts +9 -2
- package/src/modes/acp/acp-agent.ts +11 -5
- package/src/modes/components/jobs-overlay-model.ts +99 -16
- package/src/modes/components/model-selector.ts +158 -18
- package/src/modes/components/status-line/segments.ts +6 -1
- package/src/modes/controllers/extension-ui-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +23 -6
- package/src/modes/interactive-mode.ts +14 -1
- package/src/modes/jobs-observer.ts +149 -16
- package/src/modes/prompt-action-autocomplete.ts +26 -47
- package/src/modes/runtime-init.ts +1 -1
- package/src/modes/utils/injected-user-submission.ts +15 -1
- package/src/prompts/tools/bash.md +2 -2
- package/src/prompts/tools/fold-receipt.md +3 -0
- package/src/prompts/tools/job.md +1 -1
- package/src/sdk/acp/adapter.ts +17 -2
- package/src/sdk/broker/broker.ts +48 -21
- package/src/sdk/broker/lifecycle.ts +32 -6
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +11 -1
- package/src/sdk/bus/kind-aware-reconciliation.ts +46 -8
- package/src/sdk/bus/prompt-reconciliation.ts +33 -6
- package/src/sdk/bus/slack-conversation.ts +5 -1
- package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
- package/src/sdk/cli/session-cli.ts +161 -5
- package/src/sdk/client/discovery.ts +3 -1
- package/src/sdk/host/control/dispatch.ts +3 -0
- package/src/sdk/host/host.ts +7 -0
- package/src/sdk/host/model-pin.ts +185 -25
- package/src/sdk/host/sdk-run-capability.ts +1 -0
- package/src/sdk/host/session-runtime.test.ts +1240 -107
- package/src/sdk/host/session-runtime.ts +1029 -115
- package/src/sdk/prompt-deadline-manager.ts +16 -1
- package/src/sdk/protocol/adapter-validation.ts +27 -0
- package/src/sdk/protocol/operation-inventory.generated.json +2 -2
- package/src/sdk/protocol/operation-registry.ts +4 -1
- package/src/sdk/router/session-router.ts +297 -111
- package/src/sdk/session.ts +83 -12
- package/src/session/account-inventory.ts +27 -11
- package/src/session/agent-session.ts +887 -95
- package/src/session/fold-coordinator.ts +433 -0
- package/src/session/internal/managed-session-storage.ts +64 -26
- package/src/session/sdk-run-capability-internal.ts +15 -0
- package/src/session/sdk-run-capability.ts +2 -0
- package/src/session/session-manager.ts +29 -10
- package/src/setup/provider-presets.json +1 -1
- package/src/slash-commands/helpers/credential-switch.ts +6 -1
- package/src/task/executor.ts +19 -3
- package/src/telemetry/control.ts +26 -0
- package/src/telemetry/events.ts +142 -0
- package/src/telemetry/index.ts +3 -0
- package/src/telemetry/transport.ts +87 -0
- package/src/tools/bash-interactive.ts +182 -85
- package/src/tools/bash.ts +786 -67
- package/src/tools/index.ts +7 -5
- package/src/tools/path-utils.ts +77 -29
- package/src/tools/tmux-self-injection-guard.ts +359 -0
- package/src/tools/tool-catalog.generated.ts +1 -1
- package/src/web/search/provider.ts +120 -11
- package/src/web/search/providers/anthropic.ts +89 -17
- package/src/web/search/providers/base.ts +9 -4
- package/src/web/search/providers/gemini.ts +87 -15
- package/src/web/search/providers/openai-compatible.ts +23 -8
- package/src/web/search/providers/xai.ts +98 -12
- package/src/web/search/types.ts +16 -0
|
@@ -8,9 +8,14 @@ import {
|
|
|
8
8
|
isSessionAuthorityEligible,
|
|
9
9
|
type SessionIndex,
|
|
10
10
|
} from "../broker/session-index";
|
|
11
|
-
import { lifecycleRequestTimeoutMs } from "../broker/startup-budget";
|
|
11
|
+
import { cancellableSleep, lifecycleRequestTimeoutMs } from "../broker/startup-budget";
|
|
12
12
|
import { SdkClient, SdkClientError, type SdkDispatchContext, type SdkDispatchHandler } from "../client/client";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
endpointDirectory,
|
|
15
|
+
readSdkBrokerDiscovery,
|
|
16
|
+
readSdkSessionEndpoint,
|
|
17
|
+
type SdkSessionEndpoint,
|
|
18
|
+
} from "../client/discovery";
|
|
14
19
|
import {
|
|
15
20
|
type ActivatedPreparedSession,
|
|
16
21
|
type PreparedSessionActivationClient,
|
|
@@ -19,6 +24,14 @@ import {
|
|
|
19
24
|
} from "../session-activation";
|
|
20
25
|
import { ACP_SESSION_RECONNECT, SESSION_REQUEST_TIMEOUT_MS } from "../session-reconnect";
|
|
21
26
|
|
|
27
|
+
export interface SessionEndpointIdentity {
|
|
28
|
+
readonly mtimeMs: number;
|
|
29
|
+
readonly mtimeNs: bigint;
|
|
30
|
+
readonly ctimeNs: bigint;
|
|
31
|
+
readonly size: bigint;
|
|
32
|
+
readonly ino: bigint;
|
|
33
|
+
}
|
|
34
|
+
|
|
22
35
|
/**
|
|
23
36
|
* Exact identity of one attached SDK session endpoint. Providers persist it next to
|
|
24
37
|
* their conversation state and re-prove it before every resume, so it must be derived
|
|
@@ -32,11 +45,22 @@ export function sessionAttachmentAuthorityId(input: {
|
|
|
32
45
|
endpointMtimeMs: number | undefined;
|
|
33
46
|
url: string;
|
|
34
47
|
token: string;
|
|
48
|
+
/** Proven no-follow endpoint identity, when the caller has one. */
|
|
49
|
+
endpointIdentity?: SessionEndpointIdentity;
|
|
35
50
|
}): string {
|
|
36
51
|
const endpointAuthorityDigest = crypto
|
|
37
52
|
.createHash("sha256")
|
|
38
53
|
.update(JSON.stringify({ url: input.url, token: input.token }))
|
|
39
54
|
.digest("hex");
|
|
55
|
+
const endpointIdentity = input.endpointIdentity
|
|
56
|
+
? {
|
|
57
|
+
mtimeMs: input.endpointIdentity.mtimeMs,
|
|
58
|
+
mtimeNs: input.endpointIdentity.mtimeNs.toString(),
|
|
59
|
+
ctimeNs: input.endpointIdentity.ctimeNs.toString(),
|
|
60
|
+
size: input.endpointIdentity.size.toString(),
|
|
61
|
+
ino: input.endpointIdentity.ino.toString(),
|
|
62
|
+
}
|
|
63
|
+
: undefined;
|
|
40
64
|
return crypto
|
|
41
65
|
.createHash("sha256")
|
|
42
66
|
.update(
|
|
@@ -46,6 +70,7 @@ export function sessionAttachmentAuthorityId(input: {
|
|
|
46
70
|
pid: input.pid,
|
|
47
71
|
endpointMtimeMs: input.endpointMtimeMs,
|
|
48
72
|
endpointAuthorityDigest,
|
|
73
|
+
endpointIdentity,
|
|
49
74
|
}),
|
|
50
75
|
)
|
|
51
76
|
.digest("hex");
|
|
@@ -287,6 +312,8 @@ type AttachedSession = {
|
|
|
287
312
|
readonly generation: number;
|
|
288
313
|
readonly pid: number;
|
|
289
314
|
readonly endpointMtimeMs: number;
|
|
315
|
+
/** No-follow endpoint identity proven before this attachment became current. */
|
|
316
|
+
readonly endpointIdentity: SessionEndpointIdentity;
|
|
290
317
|
readonly runEpoch: number;
|
|
291
318
|
readonly client: SessionRouterClient;
|
|
292
319
|
readonly indexed: IndexedSession;
|
|
@@ -295,6 +322,8 @@ type AttachedSession = {
|
|
|
295
322
|
readonly capability: SessionAttachment;
|
|
296
323
|
readonly notificationSubscription: NotificationSubscription;
|
|
297
324
|
notificationCancelled: boolean;
|
|
325
|
+
/** Consecutive refused notification publications; reset by the next delivered frame. */
|
|
326
|
+
notificationFailures: number;
|
|
298
327
|
readonly notificationCursor: { generation: number; seq: number };
|
|
299
328
|
published: boolean;
|
|
300
329
|
initializingPublication: boolean;
|
|
@@ -342,6 +371,9 @@ const STARTUP_ATTACH_BUDGET_MS = 5_000;
|
|
|
342
371
|
const STARTUP_STRAGGLER_LOG_LIMIT = 8;
|
|
343
372
|
const ATTACH_CONNECT_TIMEOUT_MS = 10_000;
|
|
344
373
|
const NOTIFICATION_WORK_TIMEOUT_MS = 5_000;
|
|
374
|
+
// Core shutdown waits only for Router reconciliation and client close; provider
|
|
375
|
+
// notification work is bounded separately by NOTIFICATION_WORK_TIMEOUT_MS.
|
|
376
|
+
const ROUTER_SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
345
377
|
/**
|
|
346
378
|
* Idle liveness-sweep cadence (#4689). When the session index is unchanged and
|
|
347
379
|
* no adoption is pending, the 2s reconcile tick is only a change-stamp check;
|
|
@@ -352,6 +384,17 @@ const NOTIFICATION_WORK_TIMEOUT_MS = 5_000;
|
|
|
352
384
|
*/
|
|
353
385
|
const SESSION_ROUTER_IDLE_SWEEP_MS = 30_000;
|
|
354
386
|
const NOTIFICATION_WORK_TIMEOUT = Symbol("notification_work_timeout");
|
|
387
|
+
/**
|
|
388
|
+
* Consecutive local notification-dispatch failures tolerated before the
|
|
389
|
+
* subscription is conceded. A provider that refuses ONE publication is not a
|
|
390
|
+
* dead subscription: it has already settled that publication as rejected on its
|
|
391
|
+
* own side, and the next frame normally succeeds. Cancelling on the first
|
|
392
|
+
* rejection latched `notificationCancelled` for the life of the
|
|
393
|
+
* `AttachedSession`, which only a new attachment clears -- a still-running
|
|
394
|
+
* session never gets one, so its mirroring stayed dead until the session was
|
|
395
|
+
* restarted. Mirrors {@link DELIVERY_ATTEMPT_LIMIT} on the ordered path.
|
|
396
|
+
*/
|
|
397
|
+
const NOTIFICATION_LOCAL_FAILURE_LIMIT = 5;
|
|
355
398
|
/**
|
|
356
399
|
* Client-message types the native session server authorizes with the
|
|
357
400
|
* per-session endpoint token (`tokens_match` in crates/gjc-sdk server.rs).
|
|
@@ -382,6 +425,56 @@ function readSequence(value: unknown): number | undefined {
|
|
|
382
425
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 ? value : undefined;
|
|
383
426
|
}
|
|
384
427
|
|
|
428
|
+
async function lstatEndpoint(file: string): Promise<SessionEndpointIdentity | undefined> {
|
|
429
|
+
const stat = await fs.lstat(file).catch(() => undefined);
|
|
430
|
+
if (!stat || !stat.isFile()) return undefined;
|
|
431
|
+
const identity = await fs.lstat(file, { bigint: true }).catch(() => undefined);
|
|
432
|
+
if (!identity || !identity.isFile()) return undefined;
|
|
433
|
+
if (
|
|
434
|
+
stat.size !== Number(identity.size) ||
|
|
435
|
+
stat.ino !== Number(identity.ino) ||
|
|
436
|
+
Math.abs(stat.mtimeMs - Number(identity.mtimeNs) / 1_000_000) > 0.0005 ||
|
|
437
|
+
Math.abs(stat.ctimeMs - Number(identity.ctimeNs) / 1_000_000) > 0.0005
|
|
438
|
+
)
|
|
439
|
+
return undefined;
|
|
440
|
+
return {
|
|
441
|
+
mtimeMs: stat.mtimeMs,
|
|
442
|
+
mtimeNs: identity.mtimeNs,
|
|
443
|
+
ctimeNs: identity.ctimeNs,
|
|
444
|
+
size: identity.size,
|
|
445
|
+
ino: identity.ino,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function sameEndpointIdentity(expected: SessionEndpointIdentity, current: SessionEndpointIdentity): boolean {
|
|
450
|
+
return (
|
|
451
|
+
expected.mtimeMs === current.mtimeMs &&
|
|
452
|
+
expected.mtimeNs === current.mtimeNs &&
|
|
453
|
+
expected.ctimeNs === current.ctimeNs &&
|
|
454
|
+
expected.size === current.size &&
|
|
455
|
+
expected.ino === current.ino
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function readFrameSequenceClaim(frame: Record<string, unknown>): {
|
|
460
|
+
claimed: boolean;
|
|
461
|
+
valid: boolean;
|
|
462
|
+
seq?: number;
|
|
463
|
+
} {
|
|
464
|
+
const payload =
|
|
465
|
+
frame.type === "event" && frame.payload && typeof frame.payload === "object" && !Array.isArray(frame.payload)
|
|
466
|
+
? (frame.payload as Record<string, unknown>)
|
|
467
|
+
: undefined;
|
|
468
|
+
const claims: unknown[] = [];
|
|
469
|
+
if (Object.hasOwn(frame, "seq")) claims.push(frame.seq);
|
|
470
|
+
if (payload && Object.hasOwn(payload, "seq")) claims.push(payload.seq);
|
|
471
|
+
if (claims.length === 0) return { claimed: false, valid: true };
|
|
472
|
+
const sequences = claims.map(readSequence);
|
|
473
|
+
if (sequences.some(sequence => sequence === undefined)) return { claimed: true, valid: false };
|
|
474
|
+
if (new Set(sequences).size !== 1) return { claimed: true, valid: false };
|
|
475
|
+
return { claimed: true, valid: true, seq: sequences[0] };
|
|
476
|
+
}
|
|
477
|
+
|
|
385
478
|
function fallbackCorrelation(frame: Record<string, unknown>): SessionRouterFrame | undefined {
|
|
386
479
|
const payload =
|
|
387
480
|
frame.type === "event" && frame.payload && typeof frame.payload === "object" && !Array.isArray(frame.payload)
|
|
@@ -487,16 +580,11 @@ export class SessionRouter {
|
|
|
487
580
|
{ generation: number; frames: Array<{ seq: number; frame: Record<string, unknown> }> }
|
|
488
581
|
>();
|
|
489
582
|
readonly #reviving = new Set<string>();
|
|
490
|
-
/**
|
|
491
|
-
* Endpoint-file inode captured when each attachment was published (#4730
|
|
492
|
-
* review). Maintenance renewal compares against this so a rename-replace that
|
|
493
|
-
* preserves size, mtime and body cannot keep a superseded endpoint authorized.
|
|
494
|
-
*/
|
|
495
|
-
readonly #endpointInodes = new Map<string, bigint>();
|
|
496
583
|
readonly #notificationReceipts = new Map<string, NotificationCleanupReceipt>();
|
|
497
584
|
#stopTimer: (() => void) | undefined;
|
|
498
585
|
#reconcileTail: Promise<void> = Promise.resolve();
|
|
499
586
|
#reconcilePending: { readonly runEpoch: number; force: boolean } | undefined;
|
|
587
|
+
#attachmentTails = new Map<string, Promise<boolean>>();
|
|
500
588
|
/**
|
|
501
589
|
* Set when a live indexed session failed to attach; keeps the idle gate
|
|
502
590
|
* from parking retries until the 30s sweep (#4689 review).
|
|
@@ -508,6 +596,8 @@ export class SessionRouter {
|
|
|
508
596
|
#lastReconciledIndexSeq = -1;
|
|
509
597
|
#ready = false;
|
|
510
598
|
#started = false;
|
|
599
|
+
#startPromise: Promise<void> | undefined;
|
|
600
|
+
#stopPromise: Promise<void> | undefined;
|
|
511
601
|
#stopController = new AbortController();
|
|
512
602
|
#runEpoch = 0;
|
|
513
603
|
|
|
@@ -529,14 +619,27 @@ export class SessionRouter {
|
|
|
529
619
|
|
|
530
620
|
/** Starts reconciliation and the index watcher. */
|
|
531
621
|
async start(): Promise<void> {
|
|
622
|
+
if (this.#stopPromise) await this.#stopPromise;
|
|
623
|
+
if (this.#startPromise) return await this.#startPromise;
|
|
532
624
|
if (this.#started) return;
|
|
533
625
|
this.#started = true;
|
|
626
|
+
const startup = this.#startImpl();
|
|
627
|
+
this.#startPromise = startup;
|
|
628
|
+
try {
|
|
629
|
+
await startup;
|
|
630
|
+
} finally {
|
|
631
|
+
if (this.#startPromise === startup) this.#startPromise = undefined;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async #startImpl(): Promise<void> {
|
|
534
636
|
const runEpoch = ++this.#runEpoch;
|
|
535
637
|
if (this.#stopController.signal.aborted) {
|
|
536
638
|
this.#stopController = new AbortController();
|
|
537
639
|
this.#reconcileTail = Promise.resolve();
|
|
538
640
|
this.#reconcilePending = undefined;
|
|
539
641
|
this.#frameTails.clear();
|
|
642
|
+
this.#attachmentTails.clear();
|
|
540
643
|
// A restart must re-run the full body on the first tick: reset the
|
|
541
644
|
// idle-gate markers so stale state cannot carry across run epochs.
|
|
542
645
|
this.#lastReconcileSweepAt = 0;
|
|
@@ -552,10 +655,16 @@ export class SessionRouter {
|
|
|
552
655
|
// initial replay delivered. Past that budget the pass continues in the
|
|
553
656
|
// background instead of owning the caller's deadline.
|
|
554
657
|
const initialPass = this.#serialReconcile(runEpoch, false, true);
|
|
658
|
+
// Cancelled as soon as the race settles. Promise.race leaves the loser pending,
|
|
659
|
+
// and a pending Bun.sleep keeps the event loop alive, so a startup that settled
|
|
660
|
+
// in milliseconds still owed the rest of the budget before the process could exit.
|
|
661
|
+
const startupCutoff = new AbortController();
|
|
555
662
|
const settled = await Promise.race([
|
|
556
663
|
initialPass.then(() => true),
|
|
557
|
-
|
|
558
|
-
|
|
664
|
+
cancellableSleep(this.#deps.startupAttachBudgetMs ?? STARTUP_ATTACH_BUDGET_MS, startupCutoff.signal).then(
|
|
665
|
+
() => false,
|
|
666
|
+
),
|
|
667
|
+
]).finally(() => startupCutoff.abort());
|
|
559
668
|
if (!settled) {
|
|
560
669
|
// Name the sessions the budget was spent on. Without this the lapse is
|
|
561
670
|
// silent and the only symptom is a slow startup somewhere else, which
|
|
@@ -586,6 +695,10 @@ export class SessionRouter {
|
|
|
586
695
|
() => this.#schedule(this.#serialReconcile(runEpoch, true)),
|
|
587
696
|
2_000,
|
|
588
697
|
);
|
|
698
|
+
// Reconciliation is background upkeep and must not be the reason a host process
|
|
699
|
+
// stays alive. stop() still clears it on the normal path; this only keeps a
|
|
700
|
+
// caller that never stops the Router from hanging at exit.
|
|
701
|
+
timer.unref?.();
|
|
589
702
|
this.#stopTimer = () => (this.#deps.clearInterval ?? clearInterval)(timer);
|
|
590
703
|
} catch (error) {
|
|
591
704
|
if (this.#running(runEpoch)) await this.stop();
|
|
@@ -675,6 +788,20 @@ export class SessionRouter {
|
|
|
675
788
|
}
|
|
676
789
|
|
|
677
790
|
async stop(): Promise<void> {
|
|
791
|
+
if (this.#stopPromise) return await this.#stopPromise;
|
|
792
|
+
const stopping = this.#stopImpl();
|
|
793
|
+
this.#stopPromise = stopping;
|
|
794
|
+
try {
|
|
795
|
+
await stopping;
|
|
796
|
+
} finally {
|
|
797
|
+
if (this.#stopPromise === stopping) this.#stopPromise = undefined;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async #stopImpl(): Promise<void> {
|
|
802
|
+
// Detach the in-flight startup promise before invalidating its epoch. A
|
|
803
|
+
// later start must not return a promise owned by the stopped epoch.
|
|
804
|
+
this.#startPromise = undefined;
|
|
678
805
|
if (this.#stopTimer) this.#stopTimer();
|
|
679
806
|
this.#stopTimer = undefined;
|
|
680
807
|
this.#started = false;
|
|
@@ -694,14 +821,19 @@ export class SessionRouter {
|
|
|
694
821
|
this.#adopted.clear();
|
|
695
822
|
// Provider notification work is detached and bounded independently; core
|
|
696
823
|
// shutdown waits only for Router reconciliation and client close.
|
|
697
|
-
const pending = Promise.allSettled([this.#reconcileTail, ...shutdownTasks]);
|
|
824
|
+
const pending = Promise.allSettled([this.#reconcileTail, ...this.#attachmentTails.values(), ...shutdownTasks]);
|
|
825
|
+
// Cancelled once the race settles, so a completed shutdown does not hold the
|
|
826
|
+
// event loop open for the remainder of the timeout.
|
|
827
|
+
const shutdownCutoff = new AbortController();
|
|
698
828
|
const outcome = await Promise.race([
|
|
699
829
|
pending.then(results => ({ kind: "settled" as const, results })),
|
|
700
|
-
|
|
701
|
-
|
|
830
|
+
cancellableSleep(ROUTER_SHUTDOWN_TIMEOUT_MS, shutdownCutoff.signal).then(() => ({
|
|
831
|
+
kind: "timeout" as const,
|
|
832
|
+
})),
|
|
833
|
+
]).finally(() => shutdownCutoff.abort());
|
|
702
834
|
if (outcome.kind === "timeout") {
|
|
703
835
|
logger.warn(
|
|
704
|
-
|
|
836
|
+
`SessionRouter shutdown exceeded ${ROUTER_SHUTDOWN_TIMEOUT_MS}ms; authority is revoked and cleanup continues in background.`,
|
|
705
837
|
);
|
|
706
838
|
return;
|
|
707
839
|
}
|
|
@@ -759,14 +891,15 @@ export class SessionRouter {
|
|
|
759
891
|
if (expectedAttachment !== undefined && attached.capability !== expectedAttachment)
|
|
760
892
|
throw new SessionRouterError("pre_send", "SDK session attachment changed before command dispatch.");
|
|
761
893
|
if (attached.initializingPublication) {
|
|
762
|
-
const
|
|
894
|
+
const proven = await this.#readProvenEndpoint(attached.indexed);
|
|
763
895
|
if (
|
|
764
|
-
!
|
|
765
|
-
endpoint.url !== attached.endpoint.url ||
|
|
766
|
-
endpoint.token !== attached.endpoint.token ||
|
|
767
|
-
endpoint.pid !== attached.pid
|
|
896
|
+
!proven ||
|
|
897
|
+
proven.endpoint.url !== attached.endpoint.url ||
|
|
898
|
+
proven.endpoint.token !== attached.endpoint.token ||
|
|
899
|
+
proven.endpoint.pid !== attached.pid ||
|
|
900
|
+
!sameEndpointIdentity(attached.endpointIdentity, proven.identity)
|
|
768
901
|
) {
|
|
769
|
-
await this.#retireAttachment(attached,
|
|
902
|
+
await this.#retireAttachment(attached, proven ? "replaced_same_generation" : undefined);
|
|
770
903
|
throw new SessionRouterError("pre_send", "SDK session attachment changed during publication.");
|
|
771
904
|
}
|
|
772
905
|
}
|
|
@@ -834,8 +967,15 @@ export class SessionRouter {
|
|
|
834
967
|
)
|
|
835
968
|
return undefined;
|
|
836
969
|
if (!Number.isSafeInteger(indexed.pid) || indexed.pid <= 0) return undefined;
|
|
837
|
-
const
|
|
838
|
-
if (
|
|
970
|
+
const proven = await this.#readProvenEndpoint(indexed).catch(() => null);
|
|
971
|
+
if (
|
|
972
|
+
!proven ||
|
|
973
|
+
proven.endpoint.stale === true ||
|
|
974
|
+
proven.endpoint.pid !== indexed.pid ||
|
|
975
|
+
!proven.endpoint.token ||
|
|
976
|
+
!sameEndpointIdentity(attached.endpointIdentity, proven.identity)
|
|
977
|
+
)
|
|
978
|
+
return undefined;
|
|
839
979
|
if (this.#sessions.get(sessionId) !== attached || !this.#attachmentPublished(attached)) return undefined;
|
|
840
980
|
return { sessionId, endpointGeneration: attached.generation };
|
|
841
981
|
}
|
|
@@ -914,8 +1054,16 @@ export class SessionRouter {
|
|
|
914
1054
|
"session_not_live",
|
|
915
1055
|
"Session activation requires an exact live session endpoint.",
|
|
916
1056
|
);
|
|
917
|
-
const
|
|
918
|
-
|
|
1057
|
+
const provenEndpoint = await this.#readProvenEndpoint(indexed).catch(() => null);
|
|
1058
|
+
const endpoint = provenEndpoint?.endpoint;
|
|
1059
|
+
if (
|
|
1060
|
+
!provenEndpoint ||
|
|
1061
|
+
!endpoint ||
|
|
1062
|
+
endpoint.stale === true ||
|
|
1063
|
+
!endpoint.url ||
|
|
1064
|
+
!endpoint.token ||
|
|
1065
|
+
endpoint.pid !== indexed.pid
|
|
1066
|
+
)
|
|
919
1067
|
throw new SessionActivationError(
|
|
920
1068
|
"session_not_live",
|
|
921
1069
|
"Session activation requires a readable session discovery endpoint.",
|
|
@@ -935,12 +1083,15 @@ export class SessionRouter {
|
|
|
935
1083
|
throw new SessionActivationError("activation_unavailable", "The session endpoint could not be reached.");
|
|
936
1084
|
}
|
|
937
1085
|
try {
|
|
938
|
-
const
|
|
1086
|
+
const currentProvenEndpoint = await this.#readProvenEndpoint(indexed).catch(() => null);
|
|
1087
|
+
const currentEndpoint = currentProvenEndpoint?.endpoint;
|
|
939
1088
|
if (
|
|
1089
|
+
!currentProvenEndpoint ||
|
|
940
1090
|
!currentEndpoint ||
|
|
941
1091
|
currentEndpoint.url !== endpoint.url ||
|
|
942
1092
|
currentEndpoint.token !== endpoint.token ||
|
|
943
|
-
currentEndpoint.pid !== endpoint.pid
|
|
1093
|
+
currentEndpoint.pid !== endpoint.pid ||
|
|
1094
|
+
!sameEndpointIdentity(provenEndpoint.identity, currentProvenEndpoint.identity)
|
|
944
1095
|
)
|
|
945
1096
|
throw new SessionActivationError(
|
|
946
1097
|
"session_not_live",
|
|
@@ -1169,15 +1320,9 @@ export class SessionRouter {
|
|
|
1169
1320
|
// attachment was PUBLISHED against is therefore the identity, and the
|
|
1170
1321
|
// generation is compared so a re-registration reusing the same url/token/pid
|
|
1171
1322
|
// cannot keep the old attachment authorized.
|
|
1172
|
-
const
|
|
1173
|
-
if (
|
|
1174
|
-
|
|
1175
|
-
const inoBefore = await fs
|
|
1176
|
-
.stat(attached.endpoint.path, { bigint: true })
|
|
1177
|
-
.then(value => value.ino)
|
|
1178
|
-
.catch(() => undefined);
|
|
1179
|
-
if (!statBefore || inoBefore === undefined || inoBefore !== publishedIno) return false;
|
|
1180
|
-
if (statBefore.mtimeMs !== indexed.endpointMtimeMs) return false;
|
|
1323
|
+
const identityBefore = await lstatEndpoint(attached.endpoint.path);
|
|
1324
|
+
if (!identityBefore || !sameEndpointIdentity(attached.endpointIdentity, identityBefore)) return false;
|
|
1325
|
+
if (identityBefore.mtimeMs !== indexed.endpointMtimeMs) return false;
|
|
1181
1326
|
let raw: Record<string, unknown>;
|
|
1182
1327
|
try {
|
|
1183
1328
|
const parsed = JSON.parse(await Bun.file(attached.endpoint.path).text());
|
|
@@ -1198,26 +1343,23 @@ export class SessionRouter {
|
|
|
1198
1343
|
// comes from the indexed row this attachment was published against.
|
|
1199
1344
|
if (indexed.endpointGeneration !== attached.generation) return false;
|
|
1200
1345
|
if (raw.endpointGeneration !== undefined && raw.endpointGeneration !== attached.generation) return false;
|
|
1201
|
-
const
|
|
1202
|
-
const inoAfter = await fs
|
|
1203
|
-
.stat(attached.endpoint.path, { bigint: true })
|
|
1204
|
-
.then(value => value.ino)
|
|
1205
|
-
.catch(() => undefined);
|
|
1346
|
+
const identityAfter = await lstatEndpoint(attached.endpoint.path);
|
|
1206
1347
|
return (
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
inoAfter === publishedIno
|
|
1348
|
+
identityAfter !== undefined &&
|
|
1349
|
+
identityAfter.mtimeMs === indexed.endpointMtimeMs &&
|
|
1350
|
+
sameEndpointIdentity(attached.endpointIdentity, identityAfter)
|
|
1211
1351
|
);
|
|
1212
1352
|
}
|
|
1213
1353
|
|
|
1214
1354
|
/**
|
|
1215
|
-
* Returns the endpoint together with the
|
|
1355
|
+
* Returns the endpoint together with the full identity this read PROVED (#4730
|
|
1216
1356
|
* review). The identity travels with the value instead of living in a side
|
|
1217
1357
|
* map, so every consumer decision compares the same proof and the two cannot
|
|
1218
1358
|
* drift apart.
|
|
1219
1359
|
*/
|
|
1220
|
-
async #readProvenEndpoint(
|
|
1360
|
+
async #readProvenEndpoint(
|
|
1361
|
+
indexed: IndexedSession,
|
|
1362
|
+
): Promise<{ endpoint: SdkSessionEndpoint; identity: SessionEndpointIdentity } | null> {
|
|
1221
1363
|
if (!isSessionAuthorityEligible(indexed)) return null;
|
|
1222
1364
|
const repo = path.resolve(indexed.locator.repo);
|
|
1223
1365
|
const defaultStateRoot = path.join(repo, ".gjc", "state");
|
|
@@ -1236,18 +1378,15 @@ export class SessionRouter {
|
|
|
1236
1378
|
? "chat"
|
|
1237
1379
|
: undefined;
|
|
1238
1380
|
if (!scope || indexed.endpointMtimeMs === undefined || !Number.isFinite(indexed.endpointMtimeMs)) return null;
|
|
1381
|
+
const endpointPath = path.join(endpointDirectory(repo, scope), `${indexed.sessionId}.json`);
|
|
1382
|
+
const endpointIdentity = await lstatEndpoint(endpointPath);
|
|
1239
1383
|
const endpoint = await readSdkSessionEndpoint(repo, indexed.sessionId, scope);
|
|
1240
1384
|
if (!endpoint || endpoint.stale || endpoint.pid !== indexed.pid) return null;
|
|
1241
|
-
|
|
1242
|
-
const endpointIno = await fs
|
|
1243
|
-
.stat(endpoint.path, { bigint: true })
|
|
1244
|
-
.then(value => value.ino)
|
|
1245
|
-
.catch(() => undefined);
|
|
1246
|
-
if (!endpointStat || endpointIno === undefined || endpointStat.mtimeMs !== indexed.endpointMtimeMs) return null;
|
|
1385
|
+
if (!endpointIdentity || endpointIdentity.mtimeMs !== indexed.endpointMtimeMs) return null;
|
|
1247
1386
|
// Identity is proven INSIDE this authority read (#4730 review): sampling it
|
|
1248
1387
|
// afterwards would let an identical rename between the read and the sample
|
|
1249
1388
|
// install the replacement's inode as the trusted baseline.
|
|
1250
|
-
const
|
|
1389
|
+
const provenIdentity = endpointIdentity;
|
|
1251
1390
|
let raw: Record<string, unknown>;
|
|
1252
1391
|
try {
|
|
1253
1392
|
const parsed = JSON.parse(await Bun.file(endpoint.path).text());
|
|
@@ -1265,16 +1404,11 @@ export class SessionRouter {
|
|
|
1265
1404
|
)
|
|
1266
1405
|
return null;
|
|
1267
1406
|
// Fail CLOSED on stat error and on any identity change across the body read.
|
|
1268
|
-
const
|
|
1269
|
-
const inoAfterRead = await fs
|
|
1270
|
-
.stat(endpoint.path, { bigint: true })
|
|
1271
|
-
.then(value => value.ino)
|
|
1272
|
-
.catch(() => undefined);
|
|
1407
|
+
const endpointIdentityAfterRead = await lstatEndpoint(endpoint.path);
|
|
1273
1408
|
if (
|
|
1274
|
-
!
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
inoAfterRead !== provenIno
|
|
1409
|
+
!endpointIdentityAfterRead ||
|
|
1410
|
+
endpointIdentityAfterRead.mtimeMs !== indexed.endpointMtimeMs ||
|
|
1411
|
+
!sameEndpointIdentity(endpointIdentity, endpointIdentityAfterRead)
|
|
1278
1412
|
)
|
|
1279
1413
|
return null;
|
|
1280
1414
|
await this.#index.refresh();
|
|
@@ -1282,7 +1416,7 @@ export class SessionRouter {
|
|
|
1282
1416
|
if (listing.warnings.length > 0) return null;
|
|
1283
1417
|
const current = listing.sessions.find(session => session.sessionId === indexed.sessionId);
|
|
1284
1418
|
if (!current || !sameIndexedAuthority(indexed, current)) return null;
|
|
1285
|
-
return { endpoint,
|
|
1419
|
+
return { endpoint, identity: provenIdentity };
|
|
1286
1420
|
}
|
|
1287
1421
|
|
|
1288
1422
|
/** Endpoint-only view for callers that do not make an authority decision. */
|
|
@@ -1344,6 +1478,28 @@ export class SessionRouter {
|
|
|
1344
1478
|
skipReplay = false,
|
|
1345
1479
|
deferPublication = false,
|
|
1346
1480
|
deferReplay = false,
|
|
1481
|
+
): Promise<boolean> {
|
|
1482
|
+
const previous = this.#attachmentTails.get(indexed.sessionId) ?? Promise.resolve(true);
|
|
1483
|
+
const task = previous
|
|
1484
|
+
.catch(() => false)
|
|
1485
|
+
.then(() =>
|
|
1486
|
+
this.#attachUnserialized(indexed, runEpoch, resolvedEndpoint, skipReplay, deferPublication, deferReplay),
|
|
1487
|
+
);
|
|
1488
|
+
this.#attachmentTails.set(indexed.sessionId, task);
|
|
1489
|
+
try {
|
|
1490
|
+
return await task;
|
|
1491
|
+
} finally {
|
|
1492
|
+
if (this.#attachmentTails.get(indexed.sessionId) === task) this.#attachmentTails.delete(indexed.sessionId);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
async #attachUnserialized(
|
|
1497
|
+
indexed: IndexedSession,
|
|
1498
|
+
runEpoch: number,
|
|
1499
|
+
resolvedEndpoint?: SdkSessionEndpoint,
|
|
1500
|
+
skipReplay = false,
|
|
1501
|
+
deferPublication = false,
|
|
1502
|
+
deferReplay = false,
|
|
1347
1503
|
): Promise<boolean> {
|
|
1348
1504
|
const retirementVersion = this.#retirementVersions.get(indexed.sessionId) ?? 0;
|
|
1349
1505
|
const retirement = this.#retirements.get(indexed.sessionId);
|
|
@@ -1355,18 +1511,14 @@ export class SessionRouter {
|
|
|
1355
1511
|
// authority read of its own, so it samples once here.
|
|
1356
1512
|
const proven = resolvedEndpoint === undefined ? await this.#readProvenEndpoint(indexed) : null;
|
|
1357
1513
|
const endpoint = resolvedEndpoint ?? proven?.endpoint ?? null;
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
provenIno = await fs
|
|
1361
|
-
.stat(resolvedEndpoint.path, { bigint: true })
|
|
1362
|
-
.then(value => value.ino)
|
|
1363
|
-
.catch(() => undefined);
|
|
1514
|
+
const endpointIdentity =
|
|
1515
|
+
resolvedEndpoint === undefined ? proven?.identity : await lstatEndpoint(resolvedEndpoint.path);
|
|
1364
1516
|
const retirementAfterValidation = this.#retirements.get(indexed.sessionId);
|
|
1365
1517
|
if (retirementAfterValidation) await retirementAfterValidation;
|
|
1366
1518
|
if ((this.#retirementVersions.get(indexed.sessionId) ?? 0) !== retirementVersion)
|
|
1367
|
-
return await this.#
|
|
1519
|
+
return await this.#attachUnserialized(indexed, runEpoch, undefined, skipReplay, deferPublication, deferReplay);
|
|
1368
1520
|
if (!this.#running(runEpoch)) return false;
|
|
1369
|
-
if (!endpoint) return false;
|
|
1521
|
+
if (!endpoint || !endpointIdentity) return false;
|
|
1370
1522
|
const existing = this.#sessions.get(indexed.sessionId);
|
|
1371
1523
|
const resumable =
|
|
1372
1524
|
existing !== undefined &&
|
|
@@ -1374,7 +1526,9 @@ export class SessionRouter {
|
|
|
1374
1526
|
existing.endpoint.token === endpoint.token &&
|
|
1375
1527
|
existing.generation === indexed.endpointGeneration &&
|
|
1376
1528
|
existing.pid === indexed.pid &&
|
|
1377
|
-
existing.endpointMtimeMs === indexed.endpointMtimeMs
|
|
1529
|
+
existing.endpointMtimeMs === indexed.endpointMtimeMs &&
|
|
1530
|
+
endpointIdentity !== undefined &&
|
|
1531
|
+
sameEndpointIdentity(existing.endpointIdentity, endpointIdentity);
|
|
1378
1532
|
if (existing && resumable && !existing.barrier.failed) {
|
|
1379
1533
|
this.#reviveTransport(existing);
|
|
1380
1534
|
return true;
|
|
@@ -1403,7 +1557,9 @@ export class SessionRouter {
|
|
|
1403
1557
|
(existing.endpoint.url !== endpoint.url ||
|
|
1404
1558
|
existing.endpoint.token !== endpoint.token ||
|
|
1405
1559
|
existing.pid !== indexed.pid ||
|
|
1406
|
-
existing.endpointMtimeMs !== indexed.endpointMtimeMs
|
|
1560
|
+
existing.endpointMtimeMs !== indexed.endpointMtimeMs ||
|
|
1561
|
+
endpointIdentity === undefined ||
|
|
1562
|
+
!sameEndpointIdentity(existing.endpointIdentity, endpointIdentity))
|
|
1407
1563
|
? "replaced_same_generation"
|
|
1408
1564
|
: "replaced",
|
|
1409
1565
|
),
|
|
@@ -1424,7 +1580,7 @@ export class SessionRouter {
|
|
|
1424
1580
|
await client.close().catch(() => undefined);
|
|
1425
1581
|
const currentRetirement = this.#retirements.get(indexed.sessionId);
|
|
1426
1582
|
if (currentRetirement) await currentRetirement;
|
|
1427
|
-
return await this.#
|
|
1583
|
+
return await this.#attachUnserialized(indexed, runEpoch, undefined, skipReplay, deferPublication, deferReplay);
|
|
1428
1584
|
}
|
|
1429
1585
|
let attached: AttachedSession | undefined;
|
|
1430
1586
|
const barrier: ReplayBarrier = { held: undefined, detached: false, failed: false };
|
|
@@ -1438,6 +1594,7 @@ export class SessionRouter {
|
|
|
1438
1594
|
endpointMtimeMs: indexed.endpointMtimeMs,
|
|
1439
1595
|
url: endpoint.url,
|
|
1440
1596
|
token: endpoint.token,
|
|
1597
|
+
endpointIdentity,
|
|
1441
1598
|
}),
|
|
1442
1599
|
sessionId: indexed.sessionId,
|
|
1443
1600
|
generation: indexed.endpointGeneration,
|
|
@@ -1449,14 +1606,15 @@ export class SessionRouter {
|
|
|
1449
1606
|
if (!attached || !this.#attachmentPublished(attached))
|
|
1450
1607
|
throw new SessionRouterError("pre_send", "SDK session attachment is stale.");
|
|
1451
1608
|
if (attached.initializingPublication) {
|
|
1452
|
-
const
|
|
1609
|
+
const proven = await this.#readProvenEndpoint(attached.indexed);
|
|
1453
1610
|
if (
|
|
1454
|
-
!
|
|
1455
|
-
endpoint.url !== attached.endpoint.url ||
|
|
1456
|
-
endpoint.token !== attached.endpoint.token ||
|
|
1457
|
-
endpoint.pid !== attached.pid
|
|
1611
|
+
!proven ||
|
|
1612
|
+
proven.endpoint.url !== attached.endpoint.url ||
|
|
1613
|
+
proven.endpoint.token !== attached.endpoint.token ||
|
|
1614
|
+
proven.endpoint.pid !== attached.pid ||
|
|
1615
|
+
!sameEndpointIdentity(attached.endpointIdentity, proven.identity)
|
|
1458
1616
|
) {
|
|
1459
|
-
await this.#retireAttachment(attached,
|
|
1617
|
+
await this.#retireAttachment(attached, proven ? "replaced_same_generation" : undefined);
|
|
1460
1618
|
throw new SessionRouterError("pre_send", "SDK session attachment changed during publication.");
|
|
1461
1619
|
}
|
|
1462
1620
|
} else await this.#serialReconcile(runEpoch, true, true);
|
|
@@ -1538,6 +1696,7 @@ export class SessionRouter {
|
|
|
1538
1696
|
endpoint,
|
|
1539
1697
|
pid: indexed.pid,
|
|
1540
1698
|
endpointMtimeMs: indexed.endpointMtimeMs,
|
|
1699
|
+
endpointIdentity,
|
|
1541
1700
|
generation: indexed.endpointGeneration,
|
|
1542
1701
|
runEpoch,
|
|
1543
1702
|
client,
|
|
@@ -1548,6 +1707,7 @@ export class SessionRouter {
|
|
|
1548
1707
|
capability,
|
|
1549
1708
|
notificationSubscription,
|
|
1550
1709
|
notificationCancelled: false,
|
|
1710
|
+
notificationFailures: 0,
|
|
1551
1711
|
notificationCursor,
|
|
1552
1712
|
published: false,
|
|
1553
1713
|
publication,
|
|
@@ -1560,10 +1720,6 @@ export class SessionRouter {
|
|
|
1560
1720
|
new SessionRouterError("pre_send", "SDK session publication was detached before completion."),
|
|
1561
1721
|
);
|
|
1562
1722
|
barrier.held = undefined;
|
|
1563
|
-
// Every teardown path (retirement, replacement, stop, failed
|
|
1564
|
-
// publication) routes through dispose(), so the published-inode entry
|
|
1565
|
-
// is dropped here rather than at one call site (#4730 review).
|
|
1566
|
-
if (attached) this.#endpointInodes.delete(attached.id);
|
|
1567
1723
|
},
|
|
1568
1724
|
};
|
|
1569
1725
|
// Re-check at publication (#4730 review): the endpoint must STILL be the
|
|
@@ -1577,14 +1733,9 @@ export class SessionRouter {
|
|
|
1577
1733
|
await client.close().catch(() => undefined);
|
|
1578
1734
|
return false;
|
|
1579
1735
|
};
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
.stat(endpoint.path, { bigint: true })
|
|
1583
|
-
.then(value => value.ino)
|
|
1584
|
-
.catch(() => undefined);
|
|
1585
|
-
if (publishIno === undefined || publishIno !== provenIno) return await rollback();
|
|
1736
|
+
const publishIdentity = await lstatEndpoint(endpoint.path);
|
|
1737
|
+
if (!publishIdentity || !sameEndpointIdentity(endpointIdentity, publishIdentity)) return await rollback();
|
|
1586
1738
|
this.#sessions.set(indexed.sessionId, attached);
|
|
1587
|
-
this.#endpointInodes.set(attached.id, provenIno);
|
|
1588
1739
|
if (deferPublication)
|
|
1589
1740
|
this.#adopted.set(indexed.sessionId, {
|
|
1590
1741
|
generation: indexed.endpointGeneration,
|
|
@@ -1624,19 +1775,17 @@ export class SessionRouter {
|
|
|
1624
1775
|
if (!this.#attachmentLive(attached)) return false;
|
|
1625
1776
|
const proven = await this.#readProvenEndpoint(attached.indexed).catch(() => null);
|
|
1626
1777
|
if (!this.#attachmentLive(attached)) return false;
|
|
1627
|
-
// The commit point compares the PROVEN
|
|
1778
|
+
// The commit point compares the PROVEN endpoint identity alongside url/token/pid
|
|
1628
1779
|
// (#4730 review): an identical-byte rename during the pre-publication
|
|
1629
1780
|
// hooks matches every other field, so identity is what rejects it. The
|
|
1630
1781
|
// value compared here is the one the authority read proved, not a later
|
|
1631
1782
|
// observation, so the proof and its consumer cannot drift apart.
|
|
1632
|
-
const publishedIno = this.#endpointInodes.get(attached.id);
|
|
1633
1783
|
if (
|
|
1634
1784
|
!proven ||
|
|
1635
1785
|
proven.endpoint.url !== attached.endpoint.url ||
|
|
1636
1786
|
proven.endpoint.token !== attached.endpoint.token ||
|
|
1637
1787
|
proven.endpoint.pid !== attached.pid ||
|
|
1638
|
-
|
|
1639
|
-
proven.ino !== publishedIno
|
|
1788
|
+
!sameEndpointIdentity(attached.endpointIdentity, proven.identity)
|
|
1640
1789
|
) {
|
|
1641
1790
|
await this.#retireAttachment(attached, proven ? "replaced_same_generation" : undefined);
|
|
1642
1791
|
return false;
|
|
@@ -1699,14 +1848,15 @@ export class SessionRouter {
|
|
|
1699
1848
|
.catch(() => undefined)
|
|
1700
1849
|
.then(async () => {
|
|
1701
1850
|
if (!this.#attachmentLive(attached)) return;
|
|
1702
|
-
const
|
|
1851
|
+
const proven = await this.#readProvenEndpoint(attached.indexed);
|
|
1703
1852
|
if (
|
|
1704
|
-
!
|
|
1705
|
-
endpoint.url !== attached.endpoint.url ||
|
|
1706
|
-
endpoint.token !== attached.endpoint.token ||
|
|
1707
|
-
endpoint.pid !== attached.pid
|
|
1853
|
+
!proven ||
|
|
1854
|
+
proven.endpoint.url !== attached.endpoint.url ||
|
|
1855
|
+
proven.endpoint.token !== attached.endpoint.token ||
|
|
1856
|
+
proven.endpoint.pid !== attached.pid ||
|
|
1857
|
+
!sameEndpointIdentity(attached.endpointIdentity, proven.identity)
|
|
1708
1858
|
) {
|
|
1709
|
-
await this.#retireAttachment(attached,
|
|
1859
|
+
await this.#retireAttachment(attached, proven ? "replaced_same_generation" : undefined);
|
|
1710
1860
|
return;
|
|
1711
1861
|
}
|
|
1712
1862
|
attached.initializingPublication = true;
|
|
@@ -1780,10 +1930,20 @@ export class SessionRouter {
|
|
|
1780
1930
|
}
|
|
1781
1931
|
|
|
1782
1932
|
async #boundedNotificationWork<T>(work: () => Promise<T> | T): Promise<T | typeof NOTIFICATION_WORK_TIMEOUT> {
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1933
|
+
// The dominant cost of a one-shot `gjc sdk` command: notification work that
|
|
1934
|
+
// finished in milliseconds left this timeout pending, and a pending Bun.sleep
|
|
1935
|
+
// keeps the event loop alive, so the process wrote its response and then idled
|
|
1936
|
+
// for the full budget before exiting.
|
|
1937
|
+
const cutoff = new AbortController();
|
|
1938
|
+
const timeout: Promise<typeof NOTIFICATION_WORK_TIMEOUT> = cancellableSleep(
|
|
1939
|
+
NOTIFICATION_WORK_TIMEOUT_MS,
|
|
1940
|
+
cutoff.signal,
|
|
1941
|
+
).then(() => NOTIFICATION_WORK_TIMEOUT);
|
|
1942
|
+
try {
|
|
1943
|
+
return await Promise.race([Promise.resolve().then(work), timeout]);
|
|
1944
|
+
} finally {
|
|
1945
|
+
cutoff.abort();
|
|
1946
|
+
}
|
|
1787
1947
|
}
|
|
1788
1948
|
|
|
1789
1949
|
#detachNotification(
|
|
@@ -1827,15 +1987,25 @@ export class SessionRouter {
|
|
|
1827
1987
|
this.#detachNotification(attached, "cancelled");
|
|
1828
1988
|
return;
|
|
1829
1989
|
}
|
|
1990
|
+
// A delivered frame ends the consecutive-failure run.
|
|
1991
|
+
attached.notificationFailures = 0;
|
|
1830
1992
|
if (frame.seq !== undefined)
|
|
1831
1993
|
attached.notificationSubscription.advanceCursor(frame.generation ?? attached.generation, frame.seq);
|
|
1832
1994
|
},
|
|
1833
1995
|
(error: unknown) => {
|
|
1834
|
-
|
|
1996
|
+
// Concede only after a bounded consecutive run: a single refused
|
|
1997
|
+
// publication drops that frame, it does not end the subscription.
|
|
1998
|
+
attached.notificationFailures += 1;
|
|
1999
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
2000
|
+
if (attached.notificationFailures >= NOTIFICATION_LOCAL_FAILURE_LIMIT) {
|
|
2001
|
+
this.#detachNotification(attached, "cancelled");
|
|
2002
|
+
logger.warn(
|
|
2003
|
+
`SDK notification subscription ${attached.notificationSubscription.subscriptionId} failed locally ${attached.notificationFailures} times in a row; cancelling: ${detail}`,
|
|
2004
|
+
);
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
1835
2007
|
logger.warn(
|
|
1836
|
-
`SDK notification subscription ${attached.notificationSubscription.subscriptionId}
|
|
1837
|
-
error instanceof Error ? error.message : String(error)
|
|
1838
|
-
}`,
|
|
2008
|
+
`SDK notification subscription ${attached.notificationSubscription.subscriptionId} refused one frame (${attached.notificationFailures}/${NOTIFICATION_LOCAL_FAILURE_LIMIT}); subscription retained: ${detail}`,
|
|
1839
2009
|
);
|
|
1840
2010
|
},
|
|
1841
2011
|
);
|
|
@@ -1979,7 +2149,21 @@ export class SessionRouter {
|
|
|
1979
2149
|
}
|
|
1980
2150
|
const correlated = this.#correlateFrame(frame);
|
|
1981
2151
|
if (!correlated) return;
|
|
1982
|
-
const
|
|
2152
|
+
const sequenceClaim = readFrameSequenceClaim(frame);
|
|
2153
|
+
if (!sequenceClaim.valid) {
|
|
2154
|
+
this.#failBarrier(attached, "protocol error: malformed sequence coordinate");
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2157
|
+
const correlatedSeq = correlated.seq === undefined ? undefined : readSequence(correlated.seq);
|
|
2158
|
+
if (correlated.seq !== undefined && correlatedSeq === undefined) {
|
|
2159
|
+
this.#failBarrier(attached, "protocol error: malformed correlated sequence coordinate");
|
|
2160
|
+
return;
|
|
2161
|
+
}
|
|
2162
|
+
if (sequenceClaim.claimed && correlatedSeq !== undefined && correlatedSeq !== sequenceClaim.seq) {
|
|
2163
|
+
this.#failBarrier(attached, "protocol error: inconsistent sequence coordinate");
|
|
2164
|
+
return;
|
|
2165
|
+
}
|
|
2166
|
+
const seq = sequenceClaim.seq ?? correlatedSeq;
|
|
1983
2167
|
if (correlated.sessionId !== undefined && correlated.sessionId !== attached.sessionId) return;
|
|
1984
2168
|
if (correlated.generation !== undefined && correlated.generation !== attached.generation) return;
|
|
1985
2169
|
if (seq !== undefined && correlated.generation === undefined) return;
|
|
@@ -2092,7 +2276,9 @@ export class SessionRouter {
|
|
|
2092
2276
|
this.#failBarrier(attached, "replay went unanswered");
|
|
2093
2277
|
return;
|
|
2094
2278
|
}
|
|
2095
|
-
|
|
2279
|
+
// Awaited backoff rather than a raced timeout, but it must still not sleep
|
|
2280
|
+
// through a shutdown: cancelling on stop lets the process exit promptly.
|
|
2281
|
+
await cancellableSleep(REPLAY_RETRY_BACKOFF_MS * 2 ** attempt, this.#stopController.signal);
|
|
2096
2282
|
if (attached.barrier.held !== held || !this.#attachmentLive(attached)) return;
|
|
2097
2283
|
}
|
|
2098
2284
|
}
|