@gajae-code/coding-agent 0.16.0 → 0.16.3
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 +52 -1
- package/dist/types/async/job-manager.d.ts +32 -1
- package/dist/types/browser-backend.d.ts +12 -0
- package/dist/types/cli/update-cli.d.ts +1 -0
- package/dist/types/commands/sdk.d.ts +2 -0
- package/dist/types/config/billing-path.d.ts +25 -0
- package/dist/types/config/config-schema-version.d.ts +13 -1
- package/dist/types/config/model-registry.d.ts +16 -0
- package/dist/types/config/settings-schema.d.ts +17 -7
- package/dist/types/extensibility/extensions/types.d.ts +8 -3
- package/dist/types/gjc-runtime/launch-worktree.d.ts +25 -1
- package/dist/types/gjc-runtime/session-state-lock.d.ts +22 -0
- package/dist/types/main.d.ts +1 -0
- package/dist/types/modes/acp/acp-agent.d.ts +21 -21
- package/dist/types/modes/components/model-selector.d.ts +4 -0
- package/dist/types/modes/components/session-selector.d.ts +1 -1
- package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
- package/dist/types/modes/interactive-mode.d.ts +16 -2
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/sdk/broker/broker.d.ts +2 -1
- package/dist/types/sdk/broker/ensure.d.ts +3 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +6 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +14 -0
- package/dist/types/sdk/broker/startup-budget.d.ts +52 -0
- package/dist/types/sdk/broker/state-version.d.ts +17 -0
- package/dist/types/sdk/bus/discord-daemon.d.ts +1 -1
- package/dist/types/sdk/bus/index.d.ts +3 -1
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +1 -1
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +1 -1
- package/dist/types/sdk/bus/slack-conversation.d.ts +1 -1
- package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/cli/rows.d.ts +15 -2
- package/dist/types/sdk/cli/session-cli.d.ts +2 -1
- package/dist/types/sdk/host/events.d.ts +2 -0
- package/dist/types/sdk/host/host.d.ts +2 -0
- package/dist/types/sdk/host/query/cursor.d.ts +4 -2
- package/dist/types/sdk/host/query/handlers.d.ts +1 -0
- package/dist/types/sdk/host/query/revision-store.d.ts +1 -0
- package/dist/types/sdk/host/session-runtime.d.ts +18 -17
- package/dist/types/sdk/lifecycle-session.d.ts +1 -1
- package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -5
- package/dist/types/sdk/receipt-state.d.ts +2 -0
- package/dist/types/sdk/router/session-router.d.ts +4 -0
- package/dist/types/sdk/turn-result.d.ts +6 -0
- package/dist/types/session/agent-session.d.ts +47 -7
- package/dist/types/session/fold-coordinator.d.ts +29 -2
- package/dist/types/session/session-manager.d.ts +14 -2
- package/dist/types/tools/ask.d.ts +1 -0
- package/dist/types/tools/bash.d.ts +12 -0
- package/dist/types/tools/browser/launch.d.ts +10 -0
- package/dist/types/tools/browser/registry.d.ts +8 -0
- package/dist/types/tools/index.d.ts +4 -10
- package/dist/types/tools/job.d.ts +3 -1
- package/dist/types/utils/external-editor.d.ts +12 -0
- package/examples/extensions/README.md +1 -1
- package/examples/extensions/pirate.ts +7 -4
- package/package.json +7 -7
- package/scripts/generate-sdk-operation-inventory.ts +7 -1
- package/src/async/job-manager.ts +107 -9
- package/src/browser-backend.ts +29 -0
- package/src/cli/plugin-cli.ts +29 -0
- package/src/cli/update-cli.ts +63 -12
- package/src/commands/sdk.ts +22 -7
- package/src/config/billing-path.ts +42 -0
- package/src/config/config-schema-version.ts +18 -1
- package/src/config/model-preset-registry.ts +177 -18
- package/src/config/model-registry.ts +268 -42
- package/src/config/settings-schema.ts +19 -7
- package/src/config/settings.ts +46 -15
- package/src/coordinator-mcp/projection-scan.ts +6 -6
- package/src/coordinator-mcp/server.ts +21 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
- package/src/extensibility/extensions/runner.ts +27 -0
- package/src/extensibility/extensions/types.ts +8 -3
- package/src/extensibility/gjc-plugins/installer.ts +51 -11
- package/src/extensibility/runtime-skill-discovery.ts +110 -3
- package/src/extensibility/skills.ts +22 -12
- package/src/gjc-runtime/launch-worktree.ts +285 -4
- package/src/gjc-runtime/session-state-lock.ts +156 -32
- package/src/gjc-runtime/session-state-sidecar.ts +10 -17
- package/src/gjc-runtime/tmux-owner-isolation.ts +16 -1
- package/src/internal-urls/docs-index.generated.ts +11 -11
- package/src/main.ts +4 -1
- package/src/modes/acp/acp-agent.ts +34 -19
- package/src/modes/components/model-selector.ts +76 -8
- package/src/modes/components/session-selector.ts +23 -15
- package/src/modes/components/sessions-dashboard.ts +8 -5
- package/src/modes/controllers/command-controller.ts +1 -1
- package/src/modes/controllers/event-controller.ts +30 -4
- package/src/modes/controllers/extension-ui-controller.ts +13 -13
- package/src/modes/controllers/input-controller.ts +2 -2
- package/src/modes/controllers/selector-controller.ts +10 -3
- package/src/modes/interactive-mode.ts +36 -12
- package/src/modes/prompt-action-autocomplete.ts +30 -4
- package/src/modes/runtime-init.ts +12 -9
- package/src/modes/theme/theme.ts +5 -0
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/ui-helpers.ts +22 -3
- package/src/prompts/agent-fragments/browser-aside.md +22 -0
- package/src/prompts/tools/ask.md +1 -1
- package/src/prompts/tools/bash.md +1 -0
- package/src/prompts/tools/job.md +1 -1
- package/src/sdk/acp/adapter.ts +6 -1
- package/src/sdk/broker/broker.ts +10 -1
- package/src/sdk/broker/ensure.ts +76 -3
- package/src/sdk/broker/lifecycle-ledger.ts +6 -0
- package/src/sdk/broker/lifecycle.ts +254 -34
- package/src/sdk/broker/spawn-substrate.ts +45 -2
- package/src/sdk/broker/startup-budget.ts +144 -2
- package/src/sdk/broker/state-version.ts +17 -0
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +118 -24
- package/src/sdk/bus/discord-daemon.ts +50 -10
- package/src/sdk/bus/index.ts +33 -3
- package/src/sdk/bus/kind-aware-reconciliation.ts +296 -48
- package/src/sdk/bus/lifecycle-commands.ts +4 -0
- package/src/sdk/bus/notification-service.ts +22 -5
- package/src/sdk/bus/prompt-reconciliation.ts +7 -2
- package/src/sdk/bus/reconciliation-store.ts +55 -31
- package/src/sdk/bus/slack-conversation.ts +1 -1
- package/src/sdk/bus/slack-daemon.ts +72 -38
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/cli/rows.ts +75 -7
- package/src/sdk/cli/session-cli.ts +68 -35
- package/src/sdk/host/control/dispatch.ts +2 -2
- package/src/sdk/host/events.ts +26 -2
- package/src/sdk/host/host.ts +6 -1
- package/src/sdk/host/query/cursor.ts +52 -7
- package/src/sdk/host/query/handlers.ts +33 -12
- package/src/sdk/host/query/revision-store.ts +12 -0
- package/src/sdk/host/session-runtime.test.ts +497 -31
- package/src/sdk/host/session-runtime.ts +387 -54
- package/src/sdk/host/surface-policy.ts +3 -3
- package/src/sdk/lifecycle-session.ts +7 -2
- package/src/sdk/prompt-deadline-manager.ts +2 -1
- package/src/sdk/protocol/operation-inventory.generated.json +54 -3
- package/src/sdk/protocol/operation-registry.ts +1 -1
- package/src/sdk/receipt-state.ts +10 -0
- package/src/sdk/router/session-router.ts +210 -94
- package/src/sdk/session.ts +59 -41
- package/src/sdk/turn-result.ts +22 -0
- package/src/session/agent-session.ts +1033 -260
- package/src/session/fold-coordinator.ts +40 -3
- package/src/session/session-manager.ts +517 -77
- package/src/slash-commands/builtin-registry.ts +41 -0
- package/src/task/executor.ts +9 -4
- package/src/tools/ask-contract.test.ts +64 -0
- package/src/tools/ask-contract.ts +36 -1
- package/src/tools/ask.ts +10 -8
- package/src/tools/bash-interactive.ts +11 -2
- package/src/tools/bash.ts +158 -33
- package/src/tools/browser/launch.ts +37 -0
- package/src/tools/browser/registry.ts +99 -9
- package/src/tools/cron.ts +6 -4
- package/src/tools/descriptors.test.ts +15 -0
- package/src/tools/descriptors.ts +5 -1
- package/src/tools/index.ts +4 -6
- package/src/tools/job.ts +4 -1
- package/src/tools/monitor.ts +12 -9
- package/src/tools/resolve.ts +30 -10
- package/src/tools/tool-catalog.generated.ts +2 -2
- package/src/utils/external-editor.ts +86 -1
|
@@ -192,11 +192,13 @@ export interface SessionRouterClient {
|
|
|
192
192
|
send(frame: Record<string, unknown>): void;
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
/** One frame after the caller's envelope/payload identity correlation. */
|
|
195
196
|
/** One frame after the caller's envelope/payload identity correlation. */
|
|
196
197
|
export interface SessionRouterFrame {
|
|
197
198
|
readonly body: Record<string, unknown>;
|
|
198
199
|
readonly name: string | undefined;
|
|
199
200
|
readonly sessionId: string | undefined;
|
|
201
|
+
readonly revision?: number;
|
|
200
202
|
readonly generation: number | undefined;
|
|
201
203
|
readonly commandId?: string;
|
|
202
204
|
readonly turnId?: string;
|
|
@@ -222,6 +224,8 @@ export interface SessionRouterDeps {
|
|
|
222
224
|
onAttachment?: (attachment: SessionAttachment) => Promise<void> | void;
|
|
223
225
|
/** Called only after the opaque capability becomes externally current. */
|
|
224
226
|
onAttachmentReady?: (attachment: SessionAttachment) => Promise<void> | void;
|
|
227
|
+
/** Called before deferred replay may perform provider-facing mutations. */
|
|
228
|
+
onReplayReady?: () => Promise<void> | void;
|
|
225
229
|
/** Called when the Broker index no longer reports an attached session as live. */
|
|
226
230
|
onSessionRemoved?: (
|
|
227
231
|
attachment: SessionAttachment,
|
|
@@ -331,6 +335,11 @@ type AttachedSession = {
|
|
|
331
335
|
readonly notificationCursor: { generation: number; seq: number };
|
|
332
336
|
published: boolean;
|
|
333
337
|
initializingPublication: boolean;
|
|
338
|
+
replayPending: boolean;
|
|
339
|
+
replayPendingSince?: number;
|
|
340
|
+
replayPendingToken?: object;
|
|
341
|
+
drainingHeldSeq?: number;
|
|
342
|
+
drainingHeldBatch?: HeldFrame[];
|
|
334
343
|
readyTail: Promise<void>;
|
|
335
344
|
readonly publication: { promise: Promise<void>; resolve: () => void; reject: (reason?: unknown) => void };
|
|
336
345
|
dispose: () => void;
|
|
@@ -350,6 +359,7 @@ function isUnansweredAfterDispatch(error: unknown): boolean {
|
|
|
350
359
|
}
|
|
351
360
|
|
|
352
361
|
const REPLAY_BARRIER_LIMIT = 1_024;
|
|
362
|
+
const REPLAY_PENDING_STALL_MS = 10_000;
|
|
353
363
|
const REPLAY_RETRY_ATTEMPTS = 3;
|
|
354
364
|
const REPLAY_RETRY_BACKOFF_MS = 100;
|
|
355
365
|
const DELIVERY_ATTEMPT_LIMIT = 3;
|
|
@@ -429,11 +439,15 @@ function readSequence(value: unknown): number | undefined {
|
|
|
429
439
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 ? value : undefined;
|
|
430
440
|
}
|
|
431
441
|
|
|
442
|
+
function readNonnegativeSequence(value: unknown): number | undefined {
|
|
443
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
444
|
+
}
|
|
445
|
+
|
|
432
446
|
async function lstatEndpoint(file: string): Promise<SessionEndpointIdentity | undefined> {
|
|
433
447
|
const stat = await fs.lstat(file).catch(() => undefined);
|
|
434
|
-
if (!stat
|
|
448
|
+
if (!stat?.isFile()) return undefined;
|
|
435
449
|
const identity = await fs.lstat(file, { bigint: true }).catch(() => undefined);
|
|
436
|
-
if (!identity
|
|
450
|
+
if (!identity?.isFile()) return undefined;
|
|
437
451
|
if (
|
|
438
452
|
stat.size !== Number(identity.size) ||
|
|
439
453
|
stat.ino !== Number(identity.ino) ||
|
|
@@ -469,18 +483,9 @@ function readFrameSequenceClaim(frame: Record<string, unknown>): {
|
|
|
469
483
|
valid: boolean;
|
|
470
484
|
seq?: number;
|
|
471
485
|
} {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
: undefined;
|
|
476
|
-
const claims: unknown[] = [];
|
|
477
|
-
if (Object.hasOwn(frame, "seq")) claims.push(frame.seq);
|
|
478
|
-
if (payload && Object.hasOwn(payload, "seq")) claims.push(payload.seq);
|
|
479
|
-
if (claims.length === 0) return { claimed: false, valid: true };
|
|
480
|
-
const sequences = claims.map(readSequence);
|
|
481
|
-
if (sequences.some(sequence => sequence === undefined)) return { claimed: true, valid: false };
|
|
482
|
-
if (new Set(sequences).size !== 1) return { claimed: true, valid: false };
|
|
483
|
-
return { claimed: true, valid: true, seq: sequences[0] };
|
|
486
|
+
if (!Object.hasOwn(frame, "seq")) return { claimed: false, valid: true };
|
|
487
|
+
const seq = readSequence(frame.seq);
|
|
488
|
+
return seq === undefined ? { claimed: true, valid: false } : { claimed: true, valid: true, seq };
|
|
484
489
|
}
|
|
485
490
|
|
|
486
491
|
function fallbackCorrelation(frame: Record<string, unknown>): SessionRouterFrame | undefined {
|
|
@@ -496,12 +501,9 @@ function fallbackCorrelation(frame: Record<string, unknown>): SessionRouterFrame
|
|
|
496
501
|
const outerSession = frame.sessionId;
|
|
497
502
|
const innerSession = payload?.sessionId;
|
|
498
503
|
const outerGeneration = frame.generation;
|
|
499
|
-
const innerGeneration = payload?.generation;
|
|
500
504
|
if (outerSession !== undefined && innerSession !== undefined && outerSession !== innerSession) return undefined;
|
|
501
|
-
if (outerGeneration !== undefined && innerGeneration !== undefined && outerGeneration !== innerGeneration)
|
|
502
|
-
return undefined;
|
|
503
505
|
const sessionClaim = outerSession !== undefined ? outerSession : innerSession;
|
|
504
|
-
const generationClaim = outerGeneration
|
|
506
|
+
const generationClaim = outerGeneration;
|
|
505
507
|
const sessionId = readSession(sessionClaim);
|
|
506
508
|
const generation = readGeneration(generationClaim);
|
|
507
509
|
if (sessionClaim !== undefined && sessionId === undefined) return undefined;
|
|
@@ -522,10 +524,24 @@ function fallbackCorrelation(frame: Record<string, unknown>): SessionRouterFrame
|
|
|
522
524
|
body,
|
|
523
525
|
name: readName(frame.name) ?? readName(frame.kind) ?? readName(body.type),
|
|
524
526
|
sessionId,
|
|
527
|
+
revision: typeof frame.revision === "number" ? frame.revision : undefined,
|
|
525
528
|
generation,
|
|
526
529
|
commandId,
|
|
527
530
|
turnId,
|
|
528
|
-
seq: readSequence(frame.seq)
|
|
531
|
+
seq: readSequence(frame.seq),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function coordinateFreeRouterFrame(correlated: SessionRouterFrame): SessionRouterFrame {
|
|
536
|
+
return {
|
|
537
|
+
body: correlated.body,
|
|
538
|
+
name: correlated.name,
|
|
539
|
+
sessionId: correlated.sessionId,
|
|
540
|
+
...(correlated.revision === undefined ? {} : { revision: correlated.revision }),
|
|
541
|
+
generation: undefined,
|
|
542
|
+
seq: undefined,
|
|
543
|
+
...(correlated.commandId === undefined ? {} : { commandId: correlated.commandId }),
|
|
544
|
+
...(correlated.turnId === undefined ? {} : { turnId: correlated.turnId }),
|
|
529
545
|
};
|
|
530
546
|
}
|
|
531
547
|
|
|
@@ -664,7 +680,7 @@ export class SessionRouter {
|
|
|
664
680
|
// STARTUP_ATTACH_BUDGET_MS: those attachments are published with their
|
|
665
681
|
// initial replay delivered. Past that budget the pass continues in the
|
|
666
682
|
// background instead of owning the caller's deadline.
|
|
667
|
-
const initialPass = this.#serialReconcile(runEpoch,
|
|
683
|
+
const initialPass = this.#serialReconcile(runEpoch, true, true);
|
|
668
684
|
// Cancelled as soon as the race settles. Promise.race leaves the loser pending,
|
|
669
685
|
// and a pending Bun.sleep keeps the event loop alive, so a startup that settled
|
|
670
686
|
// in milliseconds still owed the rest of the budget before the process could exit.
|
|
@@ -751,6 +767,7 @@ export class SessionRouter {
|
|
|
751
767
|
const endpointMtimeMs = readEndpointMtime(result.endpointMtimeMs);
|
|
752
768
|
if (
|
|
753
769
|
sessionId !== fallback.sessionId ||
|
|
770
|
+
(this.#sessionIds !== undefined && !this.#sessionIds.has(sessionId ?? "")) ||
|
|
754
771
|
endpointGeneration === undefined ||
|
|
755
772
|
pid === undefined ||
|
|
756
773
|
endpointMtimeMs === undefined ||
|
|
@@ -825,8 +842,11 @@ export class SessionRouter {
|
|
|
825
842
|
attached.dispose();
|
|
826
843
|
this.#detachNotification(attached, "removed");
|
|
827
844
|
shutdownTasks.push((async () => await attached.client.close())());
|
|
828
|
-
|
|
829
|
-
|
|
845
|
+
shutdownTasks.push(
|
|
846
|
+
Promise.resolve(this.#deps.onSessionRemoved?.(attached.capability)).catch(error => {
|
|
847
|
+
logger.warn(`SDK provider cleanup failed during router stop: ${String(error)}`);
|
|
848
|
+
throw error;
|
|
849
|
+
}),
|
|
830
850
|
);
|
|
831
851
|
}
|
|
832
852
|
this.#adopted.clear();
|
|
@@ -891,15 +911,19 @@ export class SessionRouter {
|
|
|
891
911
|
onDispatch?: SdkDispatchHandler;
|
|
892
912
|
},
|
|
893
913
|
): Promise<Record<string, unknown>> {
|
|
914
|
+
const matchesExpectedAuthority = (attachment: SessionAttachment): boolean =>
|
|
915
|
+
expectedAttachment === undefined ||
|
|
916
|
+
attachment === expectedAttachment ||
|
|
917
|
+
(expectedAttachment.authorityId !== undefined && attachment.authorityId === expectedAttachment.authorityId);
|
|
894
918
|
const publishing = this.#sessions.get(sessionId);
|
|
895
|
-
if (!
|
|
919
|
+
if (!publishing || !matchesExpectedAuthority(publishing.capability) || !publishing.initializingPublication)
|
|
896
920
|
await this.#serialReconcile(this.#runEpoch, true, true);
|
|
897
921
|
const attached = this.#sessions.get(sessionId);
|
|
898
922
|
if (!attached || !this.#attachmentPublished(attached))
|
|
899
923
|
throw new SessionRouterError("pre_send", "SDK session attachment is unavailable: session not published.");
|
|
900
924
|
if (expectedGeneration !== undefined && expectedGeneration !== attached.generation)
|
|
901
925
|
throw new SessionRouterError("pre_send", "SDK session endpoint changed before command dispatch.");
|
|
902
|
-
if (
|
|
926
|
+
if (!matchesExpectedAuthority(attached.capability))
|
|
903
927
|
throw new SessionRouterError("pre_send", "SDK session attachment changed before command dispatch.");
|
|
904
928
|
if (attached.initializingPublication) {
|
|
905
929
|
const proven = await this.#readProvenEndpoint(attached.indexed);
|
|
@@ -947,10 +971,12 @@ export class SessionRouter {
|
|
|
947
971
|
}
|
|
948
972
|
: {}),
|
|
949
973
|
});
|
|
974
|
+
const settled = this.#sessions.get(sessionId);
|
|
950
975
|
if (
|
|
951
|
-
!
|
|
952
|
-
(
|
|
953
|
-
(
|
|
976
|
+
!settled ||
|
|
977
|
+
!this.#attachmentPublished(settled) ||
|
|
978
|
+
(expectedGeneration !== undefined && settled.generation !== expectedGeneration) ||
|
|
979
|
+
!matchesExpectedAuthority(settled.capability)
|
|
954
980
|
)
|
|
955
981
|
throw new SessionRouterError("ambiguous", "SDK session attachment changed while awaiting command response.");
|
|
956
982
|
return response;
|
|
@@ -1537,7 +1563,12 @@ export class SessionRouter {
|
|
|
1537
1563
|
existing.endpointMtimeMs === indexed.endpointMtimeMs &&
|
|
1538
1564
|
endpointIdentity !== undefined &&
|
|
1539
1565
|
sameEndpointIdentity(existing.endpointIdentity, endpointIdentity);
|
|
1540
|
-
if (
|
|
1566
|
+
if (
|
|
1567
|
+
existing &&
|
|
1568
|
+
resumable &&
|
|
1569
|
+
!existing.barrier.failed &&
|
|
1570
|
+
(!existing.replayPending || Date.now() - (existing.replayPendingSince ?? Date.now()) < REPLAY_PENDING_STALL_MS)
|
|
1571
|
+
) {
|
|
1541
1572
|
this.#reviveTransport(existing);
|
|
1542
1573
|
return true;
|
|
1543
1574
|
}
|
|
@@ -1699,6 +1730,7 @@ export class SessionRouter {
|
|
|
1699
1730
|
});
|
|
1700
1731
|
attached = {
|
|
1701
1732
|
initializingPublication: false,
|
|
1733
|
+
replayPending: false,
|
|
1702
1734
|
id: crypto.randomUUID(),
|
|
1703
1735
|
sessionId: indexed.sessionId,
|
|
1704
1736
|
endpoint,
|
|
@@ -1836,56 +1868,94 @@ export class SessionRouter {
|
|
|
1836
1868
|
// generation fences are unchanged; replay just runs on the attachment's
|
|
1837
1869
|
// ready tail like the reconnect path (#4527).
|
|
1838
1870
|
if (deferReplay) {
|
|
1871
|
+
const replayToken = {};
|
|
1872
|
+
attached.replayPending = true;
|
|
1873
|
+
attached.replayPendingSince = Date.now();
|
|
1874
|
+
attached.replayPendingToken = replayToken;
|
|
1839
1875
|
attached.readyTail = attached.readyTail
|
|
1840
1876
|
.catch(() => undefined)
|
|
1841
1877
|
.then(async () => {
|
|
1878
|
+
if (!this.#attachmentLive(attached)) return;
|
|
1879
|
+
await this.#deps.onReplayReady?.();
|
|
1842
1880
|
if (!this.#attachmentLive(attached)) return;
|
|
1843
1881
|
if (!(await this.#deliverRecoveredFrames(attached))) return;
|
|
1844
1882
|
await this.#replayAttachment(attached, attached.cursor.seq);
|
|
1883
|
+
})
|
|
1884
|
+
.catch(error => {
|
|
1885
|
+
attached.barrier.failed = true;
|
|
1886
|
+
logger.warn(`SDK deferred replay failed for ${attached.sessionId}: ${String(error)}`);
|
|
1887
|
+
})
|
|
1888
|
+
.finally(() => {
|
|
1889
|
+
if (attached.replayPendingToken === replayToken) {
|
|
1890
|
+
attached.replayPending = false;
|
|
1891
|
+
attached.replayPendingSince = undefined;
|
|
1892
|
+
attached.replayPendingToken = undefined;
|
|
1893
|
+
}
|
|
1845
1894
|
});
|
|
1846
1895
|
return true;
|
|
1847
1896
|
}
|
|
1897
|
+
await this.#deps.onReplayReady?.();
|
|
1848
1898
|
if (!(await this.#deliverRecoveredFrames(attached))) return false;
|
|
1849
1899
|
await this.#replayAttachment(attached, attached.cursor.seq);
|
|
1850
1900
|
return true;
|
|
1851
1901
|
}
|
|
1852
1902
|
|
|
1853
1903
|
async #reinitializeAttachment(attached: AttachedSession): Promise<void> {
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1904
|
+
// A prior ready tail may be waiting for publication work carried by the dead
|
|
1905
|
+
// socket. Chaining the reconnect behind it prevents the replacement from even
|
|
1906
|
+
// issuing replay. Replace its hold-array identity to fence that tail, but carry
|
|
1907
|
+
// forward frames it had not started draining; the active draining sequence is
|
|
1908
|
+
// already represented by the independent frame tail.
|
|
1909
|
+
if (attached.barrier.held) {
|
|
1910
|
+
const carried = [...(attached.drainingHeldBatch ?? []), ...attached.barrier.held];
|
|
1911
|
+
attached.drainingHeldBatch = undefined;
|
|
1912
|
+
attached.barrier.held = carried
|
|
1913
|
+
.filter(entry => entry.seq !== attached.drainingHeldSeq)
|
|
1914
|
+
.filter((entry, index, entries) => entries.findIndex(candidate => candidate.seq === entry.seq) === index);
|
|
1915
|
+
}
|
|
1916
|
+
const replayToken = {};
|
|
1917
|
+
attached.replayPending = true;
|
|
1918
|
+
attached.replayPendingSince = Date.now();
|
|
1919
|
+
attached.replayPendingToken = replayToken;
|
|
1920
|
+
const current = Promise.resolve().then(async () => {
|
|
1921
|
+
if (!this.#attachmentLive(attached)) return;
|
|
1922
|
+
const proven = await this.#readProvenEndpoint(attached.indexed);
|
|
1923
|
+
if (
|
|
1924
|
+
!proven ||
|
|
1925
|
+
proven.endpoint.url !== attached.endpoint.url ||
|
|
1926
|
+
proven.endpoint.token !== attached.endpoint.token ||
|
|
1927
|
+
proven.endpoint.pid !== attached.pid ||
|
|
1928
|
+
!sameEndpointIdentity(attached.endpointIdentity, proven.identity)
|
|
1929
|
+
) {
|
|
1930
|
+
await this.#retireAttachment(attached, proven ? "replaced_same_generation" : undefined);
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
attached.initializingPublication = true;
|
|
1934
|
+
try {
|
|
1935
|
+
void Promise.resolve()
|
|
1936
|
+
.then(() => this.#deps.onNotificationSubscriptionReady?.(attached.notificationSubscription))
|
|
1937
|
+
.catch(error => {
|
|
1938
|
+
this.#detachNotification(attached, "cancelled");
|
|
1939
|
+
logger.warn(`SDK notification subscription reconnect hook failed locally: ${String(error)}`);
|
|
1940
|
+
});
|
|
1941
|
+
await this.#deps.onAttachmentReady?.(attached.capability);
|
|
1942
|
+
await this.#deps.onReplayReady?.();
|
|
1943
|
+
} catch {
|
|
1944
|
+
await this.#retireAttachment(attached);
|
|
1945
|
+
return;
|
|
1946
|
+
} finally {
|
|
1947
|
+
attached.initializingPublication = false;
|
|
1948
|
+
}
|
|
1949
|
+
if (this.#attachmentLive(attached)) await this.#replayAttachment(attached, attached.cursor.seq);
|
|
1950
|
+
});
|
|
1951
|
+
attached.readyTail = current.finally(() => {
|
|
1952
|
+
if (attached.replayPendingToken === replayToken) {
|
|
1953
|
+
attached.replayPending = false;
|
|
1954
|
+
attached.replayPendingSince = undefined;
|
|
1955
|
+
attached.replayPendingToken = undefined;
|
|
1956
|
+
}
|
|
1957
|
+
});
|
|
1958
|
+
await attached.readyTail;
|
|
1889
1959
|
}
|
|
1890
1960
|
#reviveTransport(attached: AttachedSession): void {
|
|
1891
1961
|
const connect = attached.client.connect?.bind(attached.client);
|
|
@@ -1907,7 +1977,14 @@ export class SessionRouter {
|
|
|
1907
1977
|
*/
|
|
1908
1978
|
#hasUnhealthyAttachment(): boolean {
|
|
1909
1979
|
for (const attached of this.#sessions.values()) {
|
|
1910
|
-
if (
|
|
1980
|
+
if (
|
|
1981
|
+
attached.barrier.failed ||
|
|
1982
|
+
attached.barrier.detached ||
|
|
1983
|
+
!attached.published ||
|
|
1984
|
+
(attached.replayPending &&
|
|
1985
|
+
Date.now() - (attached.replayPendingSince ?? Date.now()) >= REPLAY_PENDING_STALL_MS)
|
|
1986
|
+
)
|
|
1987
|
+
return true;
|
|
1911
1988
|
}
|
|
1912
1989
|
return false;
|
|
1913
1990
|
}
|
|
@@ -2139,9 +2216,9 @@ export class SessionRouter {
|
|
|
2139
2216
|
const correlated = this.#correlateFrame(frame);
|
|
2140
2217
|
if (!correlated) return;
|
|
2141
2218
|
if (correlated.sessionId !== undefined && correlated.sessionId !== attached.sessionId) return;
|
|
2142
|
-
|
|
2143
|
-
this.#dispatchNotificationFrame(attached,
|
|
2144
|
-
void Promise.resolve(this.#deps.onFrame?.(attached.capability,
|
|
2219
|
+
const notificationFrame = coordinateFreeRouterFrame(correlated);
|
|
2220
|
+
this.#dispatchNotificationFrame(attached, notificationFrame);
|
|
2221
|
+
void Promise.resolve(this.#deps.onFrame?.(attached.capability, notificationFrame)).catch(error =>
|
|
2145
2222
|
logger.warn(`SDK provider frame hook failed: ${String(error)}`),
|
|
2146
2223
|
);
|
|
2147
2224
|
}
|
|
@@ -2158,26 +2235,18 @@ export class SessionRouter {
|
|
|
2158
2235
|
const correlated = this.#correlateFrame(frame);
|
|
2159
2236
|
if (!correlated) return;
|
|
2160
2237
|
const sequenceClaim = readFrameSequenceClaim(frame);
|
|
2161
|
-
|
|
2238
|
+
const ringFrame = frame.type === "event";
|
|
2239
|
+
const generationClaim = Object.hasOwn(frame, "generation");
|
|
2240
|
+
const outerGeneration = generationClaim ? readGeneration(frame.generation) : undefined;
|
|
2241
|
+
const partialCoordinate = ringFrame && sequenceClaim.claimed !== generationClaim;
|
|
2242
|
+
if (!sequenceClaim.valid || partialCoordinate || (generationClaim && outerGeneration === undefined)) {
|
|
2162
2243
|
this.#failBarrier(attached, "protocol error: malformed sequence coordinate");
|
|
2163
2244
|
return;
|
|
2164
2245
|
}
|
|
2165
|
-
const
|
|
2166
|
-
if (correlated.seq !== undefined && correlatedSeq === undefined) {
|
|
2167
|
-
this.#failBarrier(attached, "protocol error: malformed correlated sequence coordinate");
|
|
2168
|
-
return;
|
|
2169
|
-
}
|
|
2170
|
-
if (sequenceClaim.claimed && correlatedSeq !== undefined && correlatedSeq !== sequenceClaim.seq) {
|
|
2171
|
-
this.#failBarrier(attached, "protocol error: inconsistent sequence coordinate");
|
|
2172
|
-
return;
|
|
2173
|
-
}
|
|
2174
|
-
const seq = sequenceClaim.seq ?? correlatedSeq;
|
|
2246
|
+
const seq = ringFrame ? sequenceClaim.seq : undefined;
|
|
2175
2247
|
if (correlated.sessionId !== undefined && correlated.sessionId !== attached.sessionId) return;
|
|
2176
|
-
if (
|
|
2177
|
-
|
|
2178
|
-
const ownsSequence =
|
|
2179
|
-
correlated.generation === attached.generation &&
|
|
2180
|
-
(correlated.sessionId === undefined || correlated.sessionId === attached.sessionId);
|
|
2248
|
+
if (outerGeneration !== undefined && outerGeneration !== attached.generation) return;
|
|
2249
|
+
const ownsSequence = seq !== undefined && outerGeneration === attached.generation;
|
|
2181
2250
|
if (seq !== undefined && ownsSequence) {
|
|
2182
2251
|
if (seq <= attached.cursor.seq) return;
|
|
2183
2252
|
const held = attached.barrier.held;
|
|
@@ -2192,16 +2261,19 @@ export class SessionRouter {
|
|
|
2192
2261
|
}
|
|
2193
2262
|
const publicationId =
|
|
2194
2263
|
seq !== undefined && ownsSequence ? `${attached.sessionId}:${attached.generation}:${seq}` : undefined;
|
|
2195
|
-
|
|
2264
|
+
const coordinateFree = coordinateFreeRouterFrame(correlated);
|
|
2265
|
+
const notificationFrame: SessionRouterFrame =
|
|
2266
|
+
publicationId === undefined
|
|
2267
|
+
? coordinateFree
|
|
2268
|
+
: { ...coordinateFree, generation: outerGeneration, publicationId, seq };
|
|
2196
2269
|
try {
|
|
2197
|
-
notificationFrame = publicationId === undefined ? correlated : { ...correlated, publicationId };
|
|
2198
2270
|
this.#dispatchNotificationFrame(attached, notificationFrame);
|
|
2199
2271
|
await this.#deps.onFrame?.(attached.capability, notificationFrame);
|
|
2200
2272
|
} catch (error) {
|
|
2201
2273
|
if (!this.#attachmentLive(attached)) return;
|
|
2202
2274
|
if (seq === undefined || !ownsSequence) throw error;
|
|
2203
2275
|
this.#failDelivery(attached, seq, error);
|
|
2204
|
-
this.#deps.onFrameSettled?.(attached.capability,
|
|
2276
|
+
this.#deps.onFrameSettled?.(attached.capability, notificationFrame);
|
|
2205
2277
|
return;
|
|
2206
2278
|
}
|
|
2207
2279
|
if (!this.#attachmentLive(attached)) return;
|
|
@@ -2233,7 +2305,19 @@ export class SessionRouter {
|
|
|
2233
2305
|
return;
|
|
2234
2306
|
}
|
|
2235
2307
|
const batch = held.splice(0, held.length).sort((left, right) => left.seq - right.seq);
|
|
2236
|
-
|
|
2308
|
+
attached.drainingHeldBatch = batch;
|
|
2309
|
+
for (;;) {
|
|
2310
|
+
const entry = batch.shift();
|
|
2311
|
+
if (!entry) break;
|
|
2312
|
+
attached.drainingHeldSeq = entry.seq;
|
|
2313
|
+
try {
|
|
2314
|
+
await this.#enqueueFrame(attached, entry.frame, "ordered");
|
|
2315
|
+
} finally {
|
|
2316
|
+
if (attached.drainingHeldSeq === entry.seq) attached.drainingHeldSeq = undefined;
|
|
2317
|
+
}
|
|
2318
|
+
if (attached.barrier.held !== held) return;
|
|
2319
|
+
}
|
|
2320
|
+
if (attached.drainingHeldBatch === batch) attached.drainingHeldBatch = undefined;
|
|
2237
2321
|
}
|
|
2238
2322
|
}
|
|
2239
2323
|
|
|
@@ -2293,12 +2377,40 @@ export class SessionRouter {
|
|
|
2293
2377
|
if (attached.barrier.held !== held || !this.#attachmentLive(attached)) return;
|
|
2294
2378
|
await this.#frameTails.get(attached.id)?.catch(() => undefined);
|
|
2295
2379
|
if (attached.barrier.held !== held || !this.#attachmentLive(attached)) return;
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2380
|
+
if (!Array.isArray(replay.events)) {
|
|
2381
|
+
this.#failBarrier(attached, "replay response omitted an events array");
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
if (replay.ok !== true || readGeneration(replay.generation) !== attached.generation) {
|
|
2385
|
+
this.#failBarrier(attached, "replay response failed or crossed generations");
|
|
2386
|
+
return;
|
|
2387
|
+
}
|
|
2388
|
+
const lastSeq = readNonnegativeSequence(replay.lastSeq);
|
|
2389
|
+
if (lastSeq === undefined || lastSeq < sinceSeq) {
|
|
2390
|
+
this.#failBarrier(attached, "replay response omitted a coherent high-watermark");
|
|
2391
|
+
return;
|
|
2392
|
+
}
|
|
2393
|
+
const events = replay.events.filter(
|
|
2394
|
+
(event): event is Record<string, unknown> => !!event && typeof event === "object" && !Array.isArray(event),
|
|
2395
|
+
);
|
|
2396
|
+
if (events.length !== replay.events.length) {
|
|
2397
|
+
this.#failBarrier(attached, "replay response contained a malformed event");
|
|
2398
|
+
return;
|
|
2399
|
+
}
|
|
2400
|
+
let previousSeq = -1;
|
|
2401
|
+
for (const event of events) {
|
|
2402
|
+
if (event.type !== "event") {
|
|
2403
|
+
this.#failBarrier(attached, "replay response contained a non-event frame");
|
|
2404
|
+
return;
|
|
2405
|
+
}
|
|
2406
|
+
const generation = readGeneration(event.generation);
|
|
2407
|
+
const seq = readSequence(event.seq);
|
|
2408
|
+
if (generation !== attached.generation || seq === undefined || seq <= previousSeq || seq > lastSeq) {
|
|
2409
|
+
this.#failBarrier(attached, "replay response contained invalid or unordered coordinates");
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
2412
|
+
previousSeq = seq;
|
|
2413
|
+
}
|
|
2302
2414
|
if (replay.gap !== undefined) {
|
|
2303
2415
|
const gap = readReplayGap(replay.gap);
|
|
2304
2416
|
if (!gap) {
|
|
@@ -2316,9 +2428,13 @@ export class SessionRouter {
|
|
|
2316
2428
|
);
|
|
2317
2429
|
return;
|
|
2318
2430
|
}
|
|
2431
|
+
if (gap.toSeq > lastSeq) {
|
|
2432
|
+
this.#failBarrier(attached, "replay gap exceeded the reported high-watermark");
|
|
2433
|
+
return;
|
|
2434
|
+
}
|
|
2319
2435
|
const retained = events
|
|
2320
2436
|
.map(event => readSequence(event.seq))
|
|
2321
|
-
.find(seq => seq !== undefined && seq <= gap.toSeq);
|
|
2437
|
+
.find(seq => seq !== undefined && seq >= gap.fromSeq && seq <= gap.toSeq);
|
|
2322
2438
|
if (retained !== undefined) {
|
|
2323
2439
|
this.#failBarrier(
|
|
2324
2440
|
attached,
|