@gajae-code/coding-agent 0.14.2 → 0.15.0
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 +120 -1
- package/dist/types/async/job-manager.d.ts +4 -1
- package/dist/types/autoresearch/dashboard.d.ts +29 -4
- package/dist/types/autoresearch/data-context.d.ts +25 -0
- package/dist/types/autoresearch/git.d.ts +65 -7
- package/dist/types/autoresearch/harness.d.ts +58 -0
- package/dist/types/autoresearch/prompts.d.ts +51 -0
- package/dist/types/autoresearch/runs.d.ts +173 -0
- package/dist/types/autoresearch/types.d.ts +9 -139
- package/dist/types/capability/types.d.ts +20 -0
- package/dist/types/cli/accounts-cli.d.ts +1 -0
- package/dist/types/commands/{rlm.d.ts → autoresearch.d.ts} +2 -4
- package/dist/types/config/autorouting-contract.d.ts +146 -0
- package/dist/types/config/autorouting-generator.d.ts +40 -0
- package/dist/types/config/autorouting-tier-map.d.ts +16844 -0
- package/dist/types/config/autorouting.d.ts +35 -0
- package/dist/types/config/model-discovery-manager.d.ts +8 -1
- package/dist/types/config/model-registry.d.ts +30 -1
- package/dist/types/config/provider-selection-policy.d.ts +23 -0
- package/dist/types/config/settings-schema.d.ts +61 -3
- package/dist/types/config/settings.d.ts +3 -0
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/coordinator-mcp/durability.d.ts +29 -0
- package/dist/types/coordinator-mcp/event-webhook.d.ts +50 -0
- package/dist/types/coordinator-mcp/question-state.d.ts +1 -1
- package/dist/types/coordinator-mcp/server.d.ts +14 -0
- package/dist/types/crash/index-store.d.ts +3 -0
- package/dist/types/crash/record-loader.d.ts +2 -0
- package/dist/types/crash/upstream/relay.d.ts +5 -3
- package/dist/types/cursor.d.ts +12 -0
- package/dist/types/defaults/gjc-defaults.d.ts +35 -1
- package/dist/types/defaults/gjc-skills.generated.d.ts +1 -1
- package/dist/types/discovery/helpers.d.ts +1 -1
- package/dist/types/edit/index.d.ts +2 -1
- package/dist/types/eval/py/executor.d.ts +2 -0
- package/dist/types/extensibility/extensions/loader.d.ts +32 -0
- package/dist/types/extensibility/gjc-plugins/types.d.ts +1 -1
- package/dist/types/extensibility/skills.d.ts +2 -0
- package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +194 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
- package/dist/types/gjc-runtime/psmux-detect.d.ts +1 -1
- package/dist/types/gjc-runtime/python-transcript.d.ts +19 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +2 -8
- package/dist/types/gjc-runtime/session-layout.d.ts +6 -3
- package/dist/types/gjc-runtime/state-renderer.d.ts +0 -22
- package/dist/types/gjc-runtime/state-schema.d.ts +7 -7
- package/dist/types/gjc-runtime/tmux-common.d.ts +2 -2
- package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +38 -1
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +2 -24
- package/dist/types/gjc-runtime/ultragoal-validation-policy.d.ts +73 -0
- package/dist/types/gjc-runtime/workflow-recovery-projection.d.ts +100 -0
- package/dist/types/hooks/skill-keywords.d.ts +1 -1
- package/dist/types/main.d.ts +0 -10
- package/dist/types/modes/acp/acp-agent.d.ts +13 -6
- package/dist/types/modes/components/model-selector.d.ts +14 -0
- package/dist/types/modes/components/skill-hud/render.d.ts +6 -1
- package/dist/types/modes/components/smart-routing-panel.d.ts +82 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +14 -0
- package/dist/types/modes/interactive-mode.d.ts +3 -0
- package/dist/types/modes/types.d.ts +9 -0
- package/dist/types/rlm/artifacts.d.ts +4 -4
- package/dist/types/runtime/optional-runtime-services.d.ts +2 -1
- package/dist/types/runtime/workspace-tree-service.d.ts +5 -1
- package/dist/types/runtime-mcp/config.d.ts +6 -0
- package/dist/types/runtime-mcp/manager.d.ts +10 -0
- package/dist/types/sdk/acp/adapter.d.ts +0 -4
- package/dist/types/sdk/broker/broker.d.ts +1 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +57 -2
- package/dist/types/sdk/broker/session-index.d.ts +15 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +6 -0
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -0
- package/dist/types/sdk/bus/index.d.ts +8 -0
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +2 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -2
- package/dist/types/sdk/client/client.d.ts +39 -4
- package/dist/types/sdk/host/host.d.ts +1 -0
- package/dist/types/sdk/host/internal-autorouting-state.d.ts +3 -0
- package/dist/types/sdk/host/model-pin.d.ts +29 -0
- package/dist/types/sdk/host/session-runtime.d.ts +2 -1
- package/dist/types/sdk/lifecycle-session.d.ts +13 -0
- package/dist/types/sdk/router/session-router.d.ts +27 -1
- package/dist/types/sdk/session.d.ts +11 -1
- package/dist/types/session/agent-session.d.ts +36 -6
- package/dist/types/session/artifacts.d.ts +16 -1
- package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
- package/dist/types/session/session-manager.d.ts +84 -2
- package/dist/types/session/session-staging-paths.d.ts +3 -0
- package/dist/types/skill-state/canonical-skills.d.ts +1 -1
- package/dist/types/skill-state/workflow-hud.d.ts +12 -21
- package/dist/types/skill-state/workflow-mutation-guard.d.ts +1 -0
- package/dist/types/slash-commands/helpers/autorouting-status.d.ts +8 -0
- package/dist/types/system-prompt.d.ts +23 -0
- package/dist/types/task/executor.d.ts +35 -4
- package/dist/types/task/index.d.ts +17 -3
- package/dist/types/task/receipt.d.ts +3 -1
- package/dist/types/task/types.d.ts +144 -0
- package/dist/types/tools/atomic-file-write.d.ts +72 -0
- package/dist/types/tools/descriptors.d.ts +1 -0
- package/dist/types/tools/index.d.ts +36 -1
- package/dist/types/tools/move-session.d.ts +40 -0
- package/dist/types/tools/python.d.ts +12 -0
- package/dist/types/tools/resource-gc.d.ts +0 -3
- package/dist/types/tools/search.d.ts +8 -1
- package/dist/types/tools/skill.d.ts +9 -4
- package/dist/types/tools/subagent.d.ts +33 -3
- package/dist/types/utils/xml-escape.d.ts +2 -0
- package/dist/types/workflow/workflow-intent-diff.d.ts +2 -2
- package/dist/types/workflow/workflow-intent-report-consumer.d.ts +1 -1
- package/package.json +11 -12
- package/scripts/build-sdk-package-smoke.ts +7 -5
- package/scripts/check-autorouting-tier-map.ts +183 -0
- package/scripts/generate-sdk-operation-inventory.ts +12 -0
- package/scripts/generate-tool-catalog.ts +40 -5
- package/src/async/job-manager.ts +5 -2
- package/src/autoresearch/dashboard.ts +134 -314
- package/src/autoresearch/data-context.ts +35 -0
- package/src/autoresearch/git.ts +186 -26
- package/src/autoresearch/{helpers.ts → harness.ts} +65 -40
- package/src/autoresearch/{prompt-setup.md → prompts/prompt-setup.md} +7 -7
- package/src/autoresearch/prompts/prompt.md +103 -0
- package/src/autoresearch/prompts.ts +126 -0
- package/src/autoresearch/runs.ts +708 -0
- package/src/autoresearch/types.ts +9 -150
- package/src/capability/index.ts +4 -4
- package/src/capability/types.ts +20 -0
- package/src/cli/accounts-cli.ts +21 -5
- package/src/cli/config-cli.ts +25 -1
- package/src/cli/fast-help.ts +1 -2
- package/src/cli/list-models.ts +5 -8
- package/src/cli/setup-cli.ts +8 -0
- package/src/cli.ts +2 -21
- package/src/commands/autoresearch.ts +33 -0
- package/src/commands/coordinator.ts +15 -0
- package/src/commands/launch.ts +2 -4
- package/src/commands/quick-lane.ts +1 -1
- package/src/commands/sdk.ts +24 -0
- package/src/commands/state.ts +1 -1
- package/src/config/autorouting-contract.ts +474 -0
- package/src/config/autorouting-generator.ts +238 -0
- package/src/config/autorouting-tier-map.ts +6108 -0
- package/src/config/autorouting.ts +120 -0
- package/src/config/model-discovery-manager.ts +42 -11
- package/src/config/model-registry.ts +348 -68
- package/src/config/provider-selection-policy.ts +60 -9
- package/src/config/settings-schema.ts +195 -6
- package/src/config/settings.ts +62 -1
- package/src/config.ts +2 -3
- package/src/coordinator/contract.ts +0 -1
- package/src/coordinator-mcp/codex-handoff.ts +68 -42
- package/src/coordinator-mcp/durability.ts +200 -0
- package/src/coordinator-mcp/event-webhook.ts +278 -0
- package/src/coordinator-mcp/question-state.ts +109 -46
- package/src/coordinator-mcp/server.ts +932 -504
- package/src/crash/index-store.ts +126 -22
- package/src/crash/record-loader.ts +7 -0
- package/src/crash/upstream/relay.ts +82 -34
- package/src/cursor.ts +84 -1
- package/src/debug/index.ts +26 -0
- package/src/defaults/gjc/skills/autoresearch/SKILL.md +116 -0
- package/src/defaults/gjc/skills/autoresearch/auto-critic.md +46 -0
- package/src/defaults/gjc/skills/autoresearch/auto-iterate.md +42 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +17 -37
- package/src/defaults/gjc/skills/ralplan/SKILL.md +21 -18
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +6 -17
- package/src/defaults/gjc/skills/ultragoal/ai-slop-cleaner.md +2 -2
- package/src/defaults/gjc-defaults.ts +89 -2
- package/src/defaults/gjc-skills.generated.ts +24 -17
- package/src/discovery/builtin.ts +18 -5
- package/src/discovery/helpers.ts +6 -6
- package/src/edit/index.ts +5 -2
- package/src/eval/py/executor.ts +8 -0
- package/src/extensibility/extensions/loader.ts +135 -13
- package/src/extensibility/extensions/wrapper.ts +1 -2
- package/src/extensibility/gjc-plugins/schema.ts +1 -1
- package/src/extensibility/runtime-skill-discovery.ts +2 -2
- package/src/extensibility/skill-management.ts +2 -3
- package/src/extensibility/skills.ts +16 -8
- package/src/gjc-runtime/autoresearch-runtime.ts +1509 -0
- package/src/gjc-runtime/gc-render.ts +0 -1
- package/src/gjc-runtime/gc-runtime.ts +1 -11
- package/src/gjc-runtime/launch-tmux.ts +3 -3
- package/src/gjc-runtime/psmux-detect.ts +2 -2
- package/src/gjc-runtime/python-transcript.ts +58 -0
- package/src/gjc-runtime/ralplan-runtime.ts +6 -18
- package/src/gjc-runtime/session-layout.ts +18 -7
- package/src/gjc-runtime/session-state-sidecar.ts +58 -9
- package/src/gjc-runtime/state-renderer.ts +0 -38
- package/src/gjc-runtime/state-runtime.ts +43 -22
- package/src/gjc-runtime/state-schema.ts +1 -1
- package/src/gjc-runtime/tmux-common.ts +7 -7
- package/src/gjc-runtime/ultragoal-change-set.ts +232 -11
- package/src/gjc-runtime/ultragoal-runtime.ts +309 -114
- package/src/gjc-runtime/ultragoal-validation-policy.ts +247 -0
- package/src/gjc-runtime/workflow-command-ref.ts +15 -11
- package/src/gjc-runtime/workflow-manifest.generated.json +413 -532
- package/src/gjc-runtime/workflow-manifest.ts +43 -83
- package/src/gjc-runtime/workflow-recovery-projection.ts +616 -0
- package/src/hooks/skill-keywords.ts +3 -3
- package/src/hooks/skill-state.ts +2 -2
- package/src/internal-urls/docs-index.generated.ts +26 -24
- package/src/lsp/index.ts +25 -5
- package/src/main.ts +2 -27
- package/src/modes/acp/acp-agent.ts +200 -75
- package/src/modes/components/model-selector.ts +216 -13
- package/src/modes/components/oauth-selector.ts +5 -5
- package/src/modes/components/skill-hud/render.ts +173 -35
- package/src/modes/components/smart-routing-panel.ts +531 -0
- package/src/modes/components/tool-status-header.ts +6 -3
- package/src/modes/controllers/command-controller.ts +17 -7
- package/src/modes/controllers/input-controller.ts +44 -9
- package/src/modes/controllers/runtime-mcp-command-controller.ts +4 -0
- package/src/modes/controllers/selector-controller.ts +298 -65
- package/src/modes/interactive-mode.ts +8 -1
- package/src/modes/shared/agent-wire/workflow-gate-types.ts +1 -1
- package/src/modes/types.ts +9 -1
- package/src/prompts/agents/planner.md +1 -1
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/prompts/system/volatile-project-context.md +2 -1
- package/src/prompts/tools/move-session.md +15 -0
- package/src/prompts/tools/python.md +12 -0
- package/src/prompts/tools/skill-discovery.md +1 -1
- package/src/prompts/tools/skill.md +2 -2
- package/src/prompts/tools/task-summary.md +1 -0
- package/src/prompts/tools/task.md +6 -0
- package/src/rlm/artifacts.ts +12 -10
- package/src/runtime/optional-runtime-services.ts +3 -2
- package/src/runtime/workspace-tree-service.ts +11 -3
- package/src/runtime-mcp/config.ts +8 -1
- package/src/runtime-mcp/manager.ts +33 -0
- package/src/sdk/acp/adapter.ts +48 -1
- package/src/sdk/broker/broker.ts +30 -2
- package/src/sdk/broker/discovery.ts +217 -2
- package/src/sdk/broker/lifecycle.ts +385 -59
- package/src/sdk/broker/session-index.ts +243 -48
- package/src/sdk/broker/transport.ts +1 -0
- package/src/sdk/bus/chat-daemon-control.ts +8 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +5 -0
- package/src/sdk/bus/index.ts +208 -11
- package/src/sdk/bus/kind-aware-reconciliation.ts +16 -0
- package/src/sdk/bus/reconciliation-store.ts +27 -5
- package/src/sdk/bus/telegram-daemon-contract.ts +12 -2
- package/src/sdk/client/client.ts +204 -22
- package/src/sdk/host/host.ts +16 -0
- package/src/sdk/host/internal-autorouting-state.ts +13 -0
- package/src/sdk/host/model-pin.ts +101 -0
- package/src/sdk/host/reverse-leases.ts +2 -1
- package/src/sdk/host/session-runtime.test.ts +224 -1
- package/src/sdk/host/session-runtime.ts +125 -37
- package/src/sdk/lifecycle/service.ts +3 -1
- package/src/sdk/lifecycle-session.ts +42 -1
- package/src/sdk/protocol/operation-inventory.generated.json +66 -0
- package/src/sdk/router/session-router.ts +375 -36
- package/src/sdk/session.ts +547 -47
- package/src/session/account-inventory.ts +1 -1
- package/src/session/agent-session.ts +765 -189
- package/src/session/artifacts.ts +234 -4
- package/src/session/internal/managed-session-storage.ts +91 -4
- package/src/session/provider-safety-stop-hint.ts +3 -2
- package/src/session/session-manager.ts +775 -16
- package/src/session/session-staging-paths.ts +8 -0
- package/src/setup/hermes/templates/operator-instructions.v1.md +0 -1
- package/src/setup/host-plugin-setup.ts +2 -2
- package/src/skill-state/active-state.ts +2 -2
- package/src/skill-state/canonical-skills.ts +1 -1
- package/src/skill-state/initial-phase.ts +1 -1
- package/src/skill-state/workflow-hud.ts +41 -41
- package/src/skill-state/workflow-mutation-guard.ts +26 -7
- package/src/slash-commands/builtin-registry.ts +77 -0
- package/src/slash-commands/helpers/autorouting-status.ts +55 -0
- package/src/system-prompt.ts +102 -5
- package/src/task/discovery.ts +2 -3
- package/src/task/executor.ts +617 -43
- package/src/task/index.ts +262 -15
- package/src/task/receipt.ts +30 -0
- package/src/task/render.ts +11 -1
- package/src/task/types.ts +316 -0
- package/src/tools/atomic-file-write.ts +594 -0
- package/src/tools/bisect.ts +3 -2
- package/src/tools/descriptors.test.ts +3 -0
- package/src/tools/descriptors.ts +24 -0
- package/src/tools/image-gen.ts +5 -1
- package/src/tools/index.ts +62 -5
- package/src/tools/move-session.ts +107 -0
- package/src/tools/python.ts +205 -0
- package/src/tools/read.ts +88 -15
- package/src/tools/renderers.ts +2 -0
- package/src/tools/resource-gc.ts +8 -177
- package/src/tools/search.ts +30 -6
- package/src/tools/skill-discovery.ts +1 -0
- package/src/tools/skill.ts +37 -7
- package/src/tools/subagent-render.ts +119 -13
- package/src/tools/subagent.ts +91 -74
- package/src/tools/tool-catalog.generated.ts +64 -2
- package/src/tools/write.ts +16 -12
- package/src/utils/xml-escape.ts +14 -0
- package/src/web/search/providers/codex.ts +2 -2
- package/src/workflow/workflow-intent-diff.ts +13 -12
- package/src/workflow/workflow-intent-report-consumer.ts +6 -6
- package/dist/types/autoresearch/helpers.d.ts +0 -24
- package/dist/types/autoresearch/index.d.ts +0 -2
- package/dist/types/autoresearch/state.d.ts +0 -17
- package/dist/types/autoresearch/storage.d.ts +0 -142
- package/dist/types/autoresearch/tools/init-experiment.d.ts +0 -31
- package/dist/types/autoresearch/tools/log-experiment.d.ts +0 -23
- package/dist/types/autoresearch/tools/run-experiment.d.ts +0 -8
- package/dist/types/autoresearch/tools/update-notes.d.ts +0 -12
- package/dist/types/commands/team.d.ts +0 -24
- package/dist/types/gjc-runtime/team-gc.d.ts +0 -7
- package/dist/types/gjc-runtime/team-launch.d.ts +0 -61
- package/dist/types/gjc-runtime/team-notify.d.ts +0 -45
- package/dist/types/gjc-runtime/team-runtime.d.ts +0 -336
- package/dist/types/gjc-runtime/team-store.d.ts +0 -168
- package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +0 -43
- package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +0 -92
- package/dist/types/gjc-runtime/team-workers.d.ts +0 -83
- package/dist/types/rlm/index.d.ts +0 -47
- package/dist/types/rlm/preset.d.ts +0 -23
- package/dist/types/rlm/python-tool.d.ts +0 -16
- package/src/autoresearch/command-resume.md +0 -14
- package/src/autoresearch/index.ts +0 -536
- package/src/autoresearch/prompt.md +0 -103
- package/src/autoresearch/resume-message.md +0 -10
- package/src/autoresearch/state.ts +0 -273
- package/src/autoresearch/storage.ts +0 -699
- package/src/autoresearch/tools/init-experiment.ts +0 -272
- package/src/autoresearch/tools/log-experiment.ts +0 -524
- package/src/autoresearch/tools/run-experiment.ts +0 -475
- package/src/autoresearch/tools/update-notes.ts +0 -109
- package/src/commands/rlm.ts +0 -19
- package/src/commands/team.ts +0 -238
- package/src/defaults/gjc/skills/deep-interview/auto-research-greenfield.md +0 -42
- package/src/defaults/gjc/skills/team/SKILL.md +0 -467
- package/src/gjc-runtime/team-gc.ts +0 -49
- package/src/gjc-runtime/team-launch.ts +0 -404
- package/src/gjc-runtime/team-notify.ts +0 -326
- package/src/gjc-runtime/team-runtime.ts +0 -5923
- package/src/gjc-runtime/team-store.ts +0 -878
- package/src/gjc-runtime/team-worker-heartbeat.ts +0 -149
- package/src/gjc-runtime/team-worker-memory-guard.ts +0 -363
- package/src/gjc-runtime/team-workers.ts +0 -1123
- package/src/prompts/system/rlm-report-command.md +0 -1
- package/src/prompts/system/rlm-research.md +0 -23
- package/src/rlm/index.ts +0 -365
- package/src/rlm/preset.ts +0 -76
- package/src/rlm/python-tool.ts +0 -68
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
type SessionIndex,
|
|
10
10
|
} from "../broker/session-index";
|
|
11
11
|
import { lifecycleRequestTimeoutMs } from "../broker/startup-budget";
|
|
12
|
-
import { SdkClient } from "../client/client";
|
|
12
|
+
import { SdkClient, type SdkDispatchContext, type SdkDispatchHandler } from "../client/client";
|
|
13
13
|
import { readSdkBrokerDiscovery, readSdkSessionEndpoint, type SdkSessionEndpoint } from "../client/discovery";
|
|
14
14
|
import {
|
|
15
15
|
type ActivatedPreparedSession,
|
|
@@ -60,6 +60,19 @@ export interface SessionAttachment {
|
|
|
60
60
|
readonly generation: number;
|
|
61
61
|
isCurrent(): boolean;
|
|
62
62
|
send(frame: Record<string, unknown>): unknown;
|
|
63
|
+
/**
|
|
64
|
+
* Idempotent provider-lease heartbeat send that skips the pre-send authority
|
|
65
|
+
* reconcile (#4689).
|
|
66
|
+
*
|
|
67
|
+
* Optional so existing exported-capability implementations (including
|
|
68
|
+
* consumer-provided `resolveAttachment` callbacks on the Discord/Slack daemon
|
|
69
|
+
* options) stay source- and runtime-compatible (#4730 review). It is NOT a
|
|
70
|
+
* fail-open fallback: a caller that finds it absent must fail closed rather
|
|
71
|
+
* than route the heartbeat through `send()`, which would restore the 5s
|
|
72
|
+
* heartbeat-forced locked rescan this fix exists to remove. Router-owned
|
|
73
|
+
* attachments always provide it.
|
|
74
|
+
*/
|
|
75
|
+
sendMaintenance?(leaseId: string): unknown;
|
|
63
76
|
/** Revoke this exact capability after provider admission or replay fails closed. */
|
|
64
77
|
retire?(): Promise<void>;
|
|
65
78
|
}
|
|
@@ -93,7 +106,16 @@ export interface SessionRouterClient {
|
|
|
93
106
|
onFrame(handler: (frame: Record<string, unknown>) => void): () => void;
|
|
94
107
|
onReconnect?(handler: () => void): () => void;
|
|
95
108
|
connect?(): Promise<void>;
|
|
96
|
-
request(
|
|
109
|
+
request(
|
|
110
|
+
frame: Record<string, unknown>,
|
|
111
|
+
options?: {
|
|
112
|
+
timeoutMs?: number;
|
|
113
|
+
/** Synchronous pre-send observer; a throw aborts the dispatch before the wire. */
|
|
114
|
+
beforeDispatch?: (context: SdkDispatchContext) => void;
|
|
115
|
+
/** Synchronous post-send boundary observer for transport-close-aware consumers. */
|
|
116
|
+
onDispatch?: (context: SdkDispatchContext) => void;
|
|
117
|
+
},
|
|
118
|
+
): Promise<Record<string, unknown>>;
|
|
97
119
|
/** Current private transport connection identity, surfaced only through its exact attachment. */
|
|
98
120
|
readonly connectionId?: string;
|
|
99
121
|
|
|
@@ -126,6 +148,8 @@ export interface SessionRouterDeps {
|
|
|
126
148
|
createBrokerClient?: () => Promise<SessionRouterClient>;
|
|
127
149
|
/** Receives only an opaque capability and correlated provider-neutral frames. */
|
|
128
150
|
onFrame?: (attachment: SessionAttachment, frame: SessionRouterFrame) => Promise<void> | void;
|
|
151
|
+
/** Test/runtime observer invoked after a frame's delivery and cursor update settle. */
|
|
152
|
+
onFrameSettled?: (attachment: SessionAttachment, frame: SessionRouterFrame) => void;
|
|
129
153
|
onAttachment?: (attachment: SessionAttachment) => Promise<void> | void;
|
|
130
154
|
/** Called only after the opaque capability becomes externally current. */
|
|
131
155
|
onAttachmentReady?: (attachment: SessionAttachment) => Promise<void> | void;
|
|
@@ -147,6 +171,8 @@ export interface SessionRouterDeps {
|
|
|
147
171
|
clearInterval?: typeof clearInterval;
|
|
148
172
|
setTimeout?: typeof setTimeout;
|
|
149
173
|
clearTimeout?: typeof clearTimeout;
|
|
174
|
+
/** Test seam for the idle liveness-sweep cadence (#4689). */
|
|
175
|
+
idleSweepMs?: number;
|
|
150
176
|
}
|
|
151
177
|
|
|
152
178
|
export type SessionRouterProviderDeps = Pick<
|
|
@@ -168,6 +194,28 @@ export interface SessionRouterOptions {
|
|
|
168
194
|
correlateFrame?: SessionRouterFrameCorrelator;
|
|
169
195
|
}
|
|
170
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Builds the observer-facing frame for router dispatch callbacks: the injected
|
|
199
|
+
* session endpoint token (and any other credential-shaped field) is removed,
|
|
200
|
+
* and the result is deep-frozen so a malicious observer can neither read
|
|
201
|
+
* credentials nor mutate what the wire carries. The internal wire frame keeps
|
|
202
|
+
* the token; only the callback copy is redacted.
|
|
203
|
+
*/
|
|
204
|
+
function redactDispatchFrame(frame: Record<string, unknown>): Record<string, unknown> {
|
|
205
|
+
const redacted: Record<string, unknown> = { ...frame };
|
|
206
|
+
delete redacted.token;
|
|
207
|
+
const frozen = deepFreeze(redacted);
|
|
208
|
+
return frozen as Record<string, unknown>;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function deepFreeze<T>(value: T): T {
|
|
212
|
+
if (value && typeof value === "object") {
|
|
213
|
+
for (const nested of Object.values(value as Record<string, unknown>)) deepFreeze(nested);
|
|
214
|
+
Object.freeze(value);
|
|
215
|
+
}
|
|
216
|
+
return value;
|
|
217
|
+
}
|
|
218
|
+
|
|
171
219
|
export type SessionRouterErrorPhase = "pre_send" | "ambiguous";
|
|
172
220
|
|
|
173
221
|
export class SessionRouterError extends Error {
|
|
@@ -218,6 +266,15 @@ const DELIVERY_ATTEMPT_LIMIT = 3;
|
|
|
218
266
|
const ATTACH_CONCURRENCY = 4;
|
|
219
267
|
const ATTACH_CONNECT_TIMEOUT_MS = 10_000;
|
|
220
268
|
const NOTIFICATION_WORK_TIMEOUT_MS = 5_000;
|
|
269
|
+
/**
|
|
270
|
+
* Idle liveness-sweep cadence (#4689). When the session index is unchanged and
|
|
271
|
+
* no adoption is pending, the 2s reconcile tick is only a change-stamp check;
|
|
272
|
+
* the full attach/retire body runs on index changes and on this sweep, which
|
|
273
|
+
* bounds time-driven transitions (dead host pid, aged heartbeat, dropped
|
|
274
|
+
* transport revival) without re-projecting the whole index every tick. 30s
|
|
275
|
+
* stays well inside the index's own 2×60s heartbeat-freshness window.
|
|
276
|
+
*/
|
|
277
|
+
const SESSION_ROUTER_IDLE_SWEEP_MS = 30_000;
|
|
221
278
|
const NOTIFICATION_WORK_TIMEOUT = Symbol("notification_work_timeout");
|
|
222
279
|
/**
|
|
223
280
|
* Client-message types the native session server authorizes with the
|
|
@@ -354,10 +411,25 @@ export class SessionRouter {
|
|
|
354
411
|
{ generation: number; frames: Array<{ seq: number; frame: Record<string, unknown> }> }
|
|
355
412
|
>();
|
|
356
413
|
readonly #reviving = new Set<string>();
|
|
414
|
+
/**
|
|
415
|
+
* Endpoint-file inode captured when each attachment was published (#4730
|
|
416
|
+
* review). Maintenance renewal compares against this so a rename-replace that
|
|
417
|
+
* preserves size, mtime and body cannot keep a superseded endpoint authorized.
|
|
418
|
+
*/
|
|
419
|
+
readonly #endpointInodes = new Map<string, bigint>();
|
|
357
420
|
readonly #notificationReceipts = new Map<string, NotificationCleanupReceipt>();
|
|
358
421
|
#stopTimer: (() => void) | undefined;
|
|
359
422
|
#reconcileTail: Promise<void> = Promise.resolve();
|
|
360
|
-
#reconcilePending: { readonly runEpoch: number } | undefined;
|
|
423
|
+
#reconcilePending: { readonly runEpoch: number; force: boolean } | undefined;
|
|
424
|
+
/**
|
|
425
|
+
* Set when a live indexed session failed to attach; keeps the idle gate
|
|
426
|
+
* from parking retries until the 30s sweep (#4689 review).
|
|
427
|
+
*/
|
|
428
|
+
#retryPending = false;
|
|
429
|
+
/** Monotonic time of the last completed full reconcile body (#4689). */
|
|
430
|
+
#lastReconcileSweepAt = 0;
|
|
431
|
+
/** Index sequence the last completed full reconcile body processed (#4689). */
|
|
432
|
+
#lastReconciledIndexSeq = -1;
|
|
361
433
|
#ready = false;
|
|
362
434
|
#started = false;
|
|
363
435
|
#stopController = new AbortController();
|
|
@@ -389,9 +461,17 @@ export class SessionRouter {
|
|
|
389
461
|
this.#reconcileTail = Promise.resolve();
|
|
390
462
|
this.#reconcilePending = undefined;
|
|
391
463
|
this.#frameTails.clear();
|
|
464
|
+
// A restart must re-run the full body on the first tick: reset the
|
|
465
|
+
// idle-gate markers so stale state cannot carry across run epochs.
|
|
466
|
+
this.#lastReconcileSweepAt = 0;
|
|
467
|
+
this.#lastReconciledIndexSeq = -1;
|
|
392
468
|
}
|
|
393
469
|
try {
|
|
394
|
-
|
|
470
|
+
// Seed a cold reader from the snapshot before the forced authority refresh.
|
|
471
|
+
// Forced passes must not reopen the index repeatedly, but startup still
|
|
472
|
+
// needs the one-time open/replay boundary for compacted indexes.
|
|
473
|
+
await this.#index.open();
|
|
474
|
+
await this.#serialReconcile(runEpoch, false, true);
|
|
395
475
|
if (!this.#running(runEpoch)) return;
|
|
396
476
|
const timer = (this.#deps.setInterval ?? setInterval)(
|
|
397
477
|
() => this.#schedule(this.#serialReconcile(runEpoch, true)),
|
|
@@ -405,8 +485,12 @@ export class SessionRouter {
|
|
|
405
485
|
}
|
|
406
486
|
|
|
407
487
|
/** Exposed for deterministic callers and reconciliation tests. */
|
|
408
|
-
async reconcile(): Promise<void> {
|
|
409
|
-
|
|
488
|
+
async reconcile(options: { waitForReplay?: boolean } = {}): Promise<void> {
|
|
489
|
+
const waitForReplay = options.waitForReplay ?? true;
|
|
490
|
+
// Explicit callers always force the full body (#4689): the idle gate must
|
|
491
|
+
// never make an explicit reconcile a no-op.
|
|
492
|
+
await this.#serialReconcile(this.#runEpoch, !waitForReplay, true);
|
|
493
|
+
if (!waitForReplay) return;
|
|
410
494
|
// Periodic reconciliation may have published an attachment while its
|
|
411
495
|
// initial replay continues on that attachment's isolated ready tail.
|
|
412
496
|
// Explicit callers retain the historical synchronous contract without
|
|
@@ -477,7 +561,7 @@ export class SessionRouter {
|
|
|
477
561
|
const indexedCurrent =
|
|
478
562
|
listing.warnings.length === 0 ? listing.sessions.find(item => item.sessionId === sessionId) : undefined;
|
|
479
563
|
if (indexedCurrent && sameIndexedAuthority(indexed, indexedCurrent))
|
|
480
|
-
await this.#serialReconcile(this.#runEpoch, true);
|
|
564
|
+
await this.#serialReconcile(this.#runEpoch, true, true);
|
|
481
565
|
return capability;
|
|
482
566
|
}
|
|
483
567
|
|
|
@@ -549,11 +633,15 @@ export class SessionRouter {
|
|
|
549
633
|
frame: Record<string, unknown>,
|
|
550
634
|
expectedGeneration?: number,
|
|
551
635
|
expectedAttachment?: SessionAttachment,
|
|
552
|
-
options?: {
|
|
636
|
+
options?: {
|
|
637
|
+
timeoutMs?: number;
|
|
638
|
+
beforeDispatch?: (context: SdkDispatchContext) => void;
|
|
639
|
+
onDispatch?: SdkDispatchHandler;
|
|
640
|
+
},
|
|
553
641
|
): Promise<Record<string, unknown>> {
|
|
554
642
|
const publishing = this.#sessions.get(sessionId);
|
|
555
643
|
if (!expectedAttachment || publishing?.capability !== expectedAttachment || !publishing.initializingPublication)
|
|
556
|
-
await this.#serialReconcile(this.#runEpoch, true);
|
|
644
|
+
await this.#serialReconcile(this.#runEpoch, true, true);
|
|
557
645
|
const attached = this.#sessions.get(sessionId);
|
|
558
646
|
if (!attached || !this.#attachmentPublished(attached))
|
|
559
647
|
throw new SessionRouterError("pre_send", "SDK session attachment is unavailable: session not published.");
|
|
@@ -573,12 +661,38 @@ export class SessionRouter {
|
|
|
573
661
|
throw new SessionRouterError("pre_send", "SDK session attachment changed during publication.");
|
|
574
662
|
}
|
|
575
663
|
}
|
|
576
|
-
// A caller that sized its own budget keeps
|
|
577
|
-
// long-lived session budget instead of the transport's one-shot
|
|
578
|
-
// which a cold host's first credential-collecting query
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
664
|
+
// A caller that sized its own budget keeps its own; everything else gets
|
|
665
|
+
// the long-lived session budget instead of the transport's one-shot
|
|
666
|
+
// default, which a cold host's first credential-collecting query
|
|
667
|
+
// outruns (#4258).
|
|
668
|
+
// Dispatch observers must never see the injected session endpoint
|
|
669
|
+
// token: the wire frame alone carries credentials, and the observer
|
|
670
|
+
// context is a deep-frozen, token-redacted copy (#4640 review).
|
|
671
|
+
const wireFrame = this.#prepareFrame(attached, frame);
|
|
672
|
+
const { beforeDispatch, onDispatch, ...requestOptions } = options ?? {};
|
|
673
|
+
const response = await attached.client.request(wireFrame, {
|
|
674
|
+
...requestOptions,
|
|
675
|
+
timeoutMs: requestOptions.timeoutMs ?? SESSION_REQUEST_TIMEOUT_MS,
|
|
676
|
+
...(beforeDispatch
|
|
677
|
+
? {
|
|
678
|
+
beforeDispatch: (context: SdkDispatchContext) => {
|
|
679
|
+
return beforeDispatch({
|
|
680
|
+
...context,
|
|
681
|
+
frame: redactDispatchFrame(context.frame),
|
|
682
|
+
});
|
|
683
|
+
},
|
|
684
|
+
}
|
|
685
|
+
: {}),
|
|
686
|
+
...(onDispatch
|
|
687
|
+
? {
|
|
688
|
+
onDispatch: (context: SdkDispatchContext) => {
|
|
689
|
+
return onDispatch({
|
|
690
|
+
...context,
|
|
691
|
+
frame: redactDispatchFrame(context.frame),
|
|
692
|
+
});
|
|
693
|
+
},
|
|
694
|
+
}
|
|
695
|
+
: {}),
|
|
582
696
|
});
|
|
583
697
|
if (
|
|
584
698
|
!this.#attachmentPublished(attached) ||
|
|
@@ -717,18 +831,25 @@ export class SessionRouter {
|
|
|
717
831
|
}
|
|
718
832
|
}
|
|
719
833
|
|
|
720
|
-
#serialReconcile(runEpoch: number, deferReplay = false): Promise<void> {
|
|
834
|
+
#serialReconcile(runEpoch: number, deferReplay = false, force = false): Promise<void> {
|
|
721
835
|
if (!this.#running(runEpoch)) return Promise.resolve();
|
|
722
836
|
const pending = this.#reconcilePending;
|
|
723
|
-
if (pending?.runEpoch === runEpoch)
|
|
724
|
-
|
|
837
|
+
if (pending?.runEpoch === runEpoch) {
|
|
838
|
+
// A dispatch that needs the exact authority body must not inherit an
|
|
839
|
+
// already-queued idle-timer pass: escalate the queued pass (#4689
|
|
840
|
+
// review). If the pass already started, this call queues a forced
|
|
841
|
+
// follow-up on the tail instead.
|
|
842
|
+
if (force) pending.force = true;
|
|
843
|
+
return this.#reconcileTail;
|
|
844
|
+
}
|
|
845
|
+
const queued = { runEpoch, force };
|
|
725
846
|
this.#reconcilePending = queued;
|
|
726
847
|
const task = this.#reconcileTail
|
|
727
848
|
.catch(() => undefined)
|
|
728
849
|
.then(async () => {
|
|
729
850
|
if (this.#reconcilePending === queued) this.#reconcilePending = undefined;
|
|
730
851
|
try {
|
|
731
|
-
await this.#reconcile(runEpoch, deferReplay);
|
|
852
|
+
await this.#reconcile(runEpoch, deferReplay, queued.force);
|
|
732
853
|
if (!this.#running(runEpoch)) return;
|
|
733
854
|
this.#ready = true;
|
|
734
855
|
this.#deps.onReconciled?.();
|
|
@@ -741,12 +862,48 @@ export class SessionRouter {
|
|
|
741
862
|
return task;
|
|
742
863
|
}
|
|
743
864
|
|
|
744
|
-
async #reconcile(runEpoch: number, deferReplay = false): Promise<void> {
|
|
745
|
-
if (!this.#running(runEpoch)) return;
|
|
746
|
-
await this.#index.open();
|
|
865
|
+
async #reconcile(runEpoch: number, deferReplay = false, force = false): Promise<void> {
|
|
747
866
|
if (!this.#running(runEpoch)) return;
|
|
748
|
-
|
|
867
|
+
// Idle-poll fast path (#4689) is for TIMER TICKS ONLY. A forced pass backs
|
|
868
|
+
// a dispatch, adoption, explicit reconcile, or start, and must serialize
|
|
869
|
+
// against the index lock: the unlocked stamp cut cannot order itself
|
|
870
|
+
// against a writer that commits an unregister/re-registration between the
|
|
871
|
+
// stat and the listSessions()/request() below, which would let a forced
|
|
872
|
+
// request go out through a no-longer-authoritative attachment (#4730
|
|
873
|
+
// review). Forced passes therefore take the locked authority read.
|
|
874
|
+
// `refresh()` is the locked read; `open()` is deliberately NOT re-run per
|
|
875
|
+
// forced pass -- re-entering it disturbs the shared open-group state that
|
|
876
|
+
// live attachments and replay cursors depend on, which broke socket-loss
|
|
877
|
+
// resume.
|
|
878
|
+
let changed: boolean;
|
|
879
|
+
if (force) {
|
|
880
|
+
await this.#index.refresh();
|
|
881
|
+
changed = true;
|
|
882
|
+
} else changed = await this.#index.refreshIfChanged();
|
|
749
883
|
if (!this.#running(runEpoch)) return;
|
|
884
|
+
// Idle gate (#4689), timer ticks ONLY: dispatch, adoption, explicit
|
|
885
|
+
// reconcile, and start pass force=true and keep the exact locked
|
|
886
|
+
// authority body. A gated tick must also never park local recovery: a
|
|
887
|
+
// failed replay barrier, a retired-but-listed attachment, or an
|
|
888
|
+
// unpublished attachment all force the body. `changed` covers durable
|
|
889
|
+
// index updates; a corrupt suffix never takes the stamp fast path.
|
|
890
|
+
const sweepDue =
|
|
891
|
+
performance.now() - this.#lastReconcileSweepAt >= (this.#deps.idleSweepMs ?? SESSION_ROUTER_IDLE_SWEEP_MS);
|
|
892
|
+
if (
|
|
893
|
+
!force &&
|
|
894
|
+
!changed &&
|
|
895
|
+
!sweepDue &&
|
|
896
|
+
this.#adopted.size === 0 &&
|
|
897
|
+
!this.#retryPending &&
|
|
898
|
+
!this.#hasUnhealthyAttachment() &&
|
|
899
|
+
this.#index.indexSeq === this.#lastReconciledIndexSeq
|
|
900
|
+
) {
|
|
901
|
+
// The 2s tick is also the session-transport revival mechanism:
|
|
902
|
+
// connect() is a cheap no-op on a healthy socket, so skipped ticks
|
|
903
|
+
// keep the reconnect latency without re-projecting the index.
|
|
904
|
+
for (const attached of this.#sessions.values()) this.#reviveTransport(attached);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
750
907
|
const indexed = this.#index.listSessions();
|
|
751
908
|
const live =
|
|
752
909
|
indexed.warnings.length === 0
|
|
@@ -795,6 +952,7 @@ export class SessionRouter {
|
|
|
795
952
|
}
|
|
796
953
|
}
|
|
797
954
|
let nextAttachment = 0;
|
|
955
|
+
let attachThrew = false;
|
|
798
956
|
const attachWorkers = Array.from({ length: Math.min(ATTACH_CONCURRENCY, live.length) }, async () => {
|
|
799
957
|
for (;;) {
|
|
800
958
|
if (!this.#running(runEpoch)) return;
|
|
@@ -804,6 +962,11 @@ export class SessionRouter {
|
|
|
804
962
|
if (await this.#attach(session, runEpoch, undefined, false, false, deferReplay))
|
|
805
963
|
attachedIds.add(session.sessionId);
|
|
806
964
|
} catch {
|
|
965
|
+
// A live row that failed to attach is local recovery state the
|
|
966
|
+
// durable index cannot see: keep ticks retrying it (#4689).
|
|
967
|
+
// A definitive no-endpoint #attach miss (returned false) does
|
|
968
|
+
// not latch — that row can never attach.
|
|
969
|
+
attachThrew = true;
|
|
807
970
|
const failed = this.#sessions.get(session.sessionId);
|
|
808
971
|
if (failed?.runEpoch === runEpoch)
|
|
809
972
|
await this.#retireAttachment(failed, liveIds.has(session.sessionId) ? "replaced" : "removed");
|
|
@@ -831,9 +994,83 @@ export class SessionRouter {
|
|
|
831
994
|
}
|
|
832
995
|
}
|
|
833
996
|
if (cleanupErrors.length > 0) throw new AggregateError(cleanupErrors, "SessionRouter stale cleanup failed.");
|
|
997
|
+
this.#lastReconcileSweepAt = performance.now();
|
|
998
|
+
// Record the snapshot the body actually enumerated: a nested exact
|
|
999
|
+
// refresh during endpoint validation may have already advanced the
|
|
1000
|
+
// index past it, and those newer events were NOT processed here.
|
|
1001
|
+
this.#lastReconciledIndexSeq = indexed.indexSeq;
|
|
1002
|
+
// The retry latch is set only where a live row's attach throws (see the
|
|
1003
|
+
// attach worker); a definitive no-endpoint miss can never attach.
|
|
1004
|
+
this.#retryPending = attachThrew && live.some(session => !attachedIds.has(session.sessionId));
|
|
834
1005
|
}
|
|
835
1006
|
|
|
836
|
-
|
|
1007
|
+
/**
|
|
1008
|
+
* Bounded, lock-free endpoint-authority read for maintenance heartbeats
|
|
1009
|
+
* (#4730 review). `#readEndpoint` finishes with a locked `#index.refresh()`,
|
|
1010
|
+
* which on the 5s lease heartbeat would restore exactly the locked full index
|
|
1011
|
+
* scan this work removes. The heartbeat does not need index re-projection: it
|
|
1012
|
+
* only needs to know that THIS endpoint record still carries the authority
|
|
1013
|
+
* the attachment was published with, which the endpoint file itself proves.
|
|
1014
|
+
* Returns true only when the durable record still matches.
|
|
1015
|
+
*/
|
|
1016
|
+
async #endpointAuthorityUnchanged(attached: AttachedSession): Promise<boolean> {
|
|
1017
|
+
const indexed = attached.indexed;
|
|
1018
|
+
if (!isSessionAuthorityEligible(indexed)) return false;
|
|
1019
|
+
if (indexed.endpointMtimeMs === undefined || !Number.isFinite(indexed.endpointMtimeMs)) return false;
|
|
1020
|
+
// mtime alone is not a replacement-safe identity (#4730 review): a rewrite or
|
|
1021
|
+
// rename-replace inside one filesystem tick can preserve it. The inode the
|
|
1022
|
+
// attachment was PUBLISHED against is therefore the identity, and the
|
|
1023
|
+
// generation is compared so a re-registration reusing the same url/token/pid
|
|
1024
|
+
// cannot keep the old attachment authorized.
|
|
1025
|
+
const publishedIno = this.#endpointInodes.get(attached.id);
|
|
1026
|
+
if (publishedIno === undefined) return false;
|
|
1027
|
+
const statBefore = await fs.stat(attached.endpoint.path).catch(() => undefined);
|
|
1028
|
+
const inoBefore = await fs
|
|
1029
|
+
.stat(attached.endpoint.path, { bigint: true })
|
|
1030
|
+
.then(value => value.ino)
|
|
1031
|
+
.catch(() => undefined);
|
|
1032
|
+
if (!statBefore || inoBefore === undefined || inoBefore !== publishedIno) return false;
|
|
1033
|
+
if (statBefore.mtimeMs !== indexed.endpointMtimeMs) return false;
|
|
1034
|
+
let raw: Record<string, unknown>;
|
|
1035
|
+
try {
|
|
1036
|
+
const parsed = JSON.parse(await Bun.file(attached.endpoint.path).text());
|
|
1037
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
|
|
1038
|
+
raw = parsed as Record<string, unknown>;
|
|
1039
|
+
} catch {
|
|
1040
|
+
return false;
|
|
1041
|
+
}
|
|
1042
|
+
if (
|
|
1043
|
+
raw.sessionId !== indexed.sessionId ||
|
|
1044
|
+
raw.pid !== attached.pid ||
|
|
1045
|
+
raw.stale === true ||
|
|
1046
|
+
raw.url !== attached.endpoint.url ||
|
|
1047
|
+
raw.token !== attached.endpoint.token
|
|
1048
|
+
)
|
|
1049
|
+
return false;
|
|
1050
|
+
// The endpoint record itself carries no generation, so generation identity
|
|
1051
|
+
// comes from the indexed row this attachment was published against.
|
|
1052
|
+
if (indexed.endpointGeneration !== attached.generation) return false;
|
|
1053
|
+
if (raw.endpointGeneration !== undefined && raw.endpointGeneration !== attached.generation) return false;
|
|
1054
|
+
const statAfter = await fs.stat(attached.endpoint.path).catch(() => undefined);
|
|
1055
|
+
const inoAfter = await fs
|
|
1056
|
+
.stat(attached.endpoint.path, { bigint: true })
|
|
1057
|
+
.then(value => value.ino)
|
|
1058
|
+
.catch(() => undefined);
|
|
1059
|
+
return (
|
|
1060
|
+
statAfter !== undefined &&
|
|
1061
|
+
inoAfter !== undefined &&
|
|
1062
|
+
statAfter.mtimeMs === indexed.endpointMtimeMs &&
|
|
1063
|
+
inoAfter === publishedIno
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Returns the endpoint together with the inode this read PROVED (#4730
|
|
1069
|
+
* review). The identity travels with the value instead of living in a side
|
|
1070
|
+
* map, so every consumer decision compares the same proof and the two cannot
|
|
1071
|
+
* drift apart.
|
|
1072
|
+
*/
|
|
1073
|
+
async #readProvenEndpoint(indexed: IndexedSession): Promise<{ endpoint: SdkSessionEndpoint; ino: bigint } | null> {
|
|
837
1074
|
if (!isSessionAuthorityEligible(indexed)) return null;
|
|
838
1075
|
const repo = path.resolve(indexed.locator.repo);
|
|
839
1076
|
const defaultStateRoot = path.join(repo, ".gjc", "state");
|
|
@@ -855,7 +1092,15 @@ export class SessionRouter {
|
|
|
855
1092
|
const endpoint = await readSdkSessionEndpoint(repo, indexed.sessionId, scope);
|
|
856
1093
|
if (!endpoint || endpoint.stale || endpoint.pid !== indexed.pid) return null;
|
|
857
1094
|
const endpointStat = await fs.stat(endpoint.path).catch(() => undefined);
|
|
858
|
-
|
|
1095
|
+
const endpointIno = await fs
|
|
1096
|
+
.stat(endpoint.path, { bigint: true })
|
|
1097
|
+
.then(value => value.ino)
|
|
1098
|
+
.catch(() => undefined);
|
|
1099
|
+
if (!endpointStat || endpointIno === undefined || endpointStat.mtimeMs !== indexed.endpointMtimeMs) return null;
|
|
1100
|
+
// Identity is proven INSIDE this authority read (#4730 review): sampling it
|
|
1101
|
+
// afterwards would let an identical rename between the read and the sample
|
|
1102
|
+
// install the replacement's inode as the trusted baseline.
|
|
1103
|
+
const provenIno = endpointIno;
|
|
859
1104
|
let raw: Record<string, unknown>;
|
|
860
1105
|
try {
|
|
861
1106
|
const parsed = JSON.parse(await Bun.file(endpoint.path).text());
|
|
@@ -872,14 +1117,30 @@ export class SessionRouter {
|
|
|
872
1117
|
raw.token !== endpoint.token
|
|
873
1118
|
)
|
|
874
1119
|
return null;
|
|
1120
|
+
// Fail CLOSED on stat error and on any identity change across the body read.
|
|
875
1121
|
const endpointStatAfterRead = await fs.stat(endpoint.path).catch(() => undefined);
|
|
876
|
-
|
|
1122
|
+
const inoAfterRead = await fs
|
|
1123
|
+
.stat(endpoint.path, { bigint: true })
|
|
1124
|
+
.then(value => value.ino)
|
|
1125
|
+
.catch(() => undefined);
|
|
1126
|
+
if (
|
|
1127
|
+
!endpointStatAfterRead ||
|
|
1128
|
+
inoAfterRead === undefined ||
|
|
1129
|
+
endpointStatAfterRead.mtimeMs !== indexed.endpointMtimeMs ||
|
|
1130
|
+
inoAfterRead !== provenIno
|
|
1131
|
+
)
|
|
1132
|
+
return null;
|
|
877
1133
|
await this.#index.refresh();
|
|
878
1134
|
const listing = this.#index.listSessions();
|
|
879
1135
|
if (listing.warnings.length > 0) return null;
|
|
880
1136
|
const current = listing.sessions.find(session => session.sessionId === indexed.sessionId);
|
|
881
1137
|
if (!current || !sameIndexedAuthority(indexed, current)) return null;
|
|
882
|
-
return endpoint;
|
|
1138
|
+
return { endpoint, ino: provenIno };
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/** Endpoint-only view for callers that do not make an authority decision. */
|
|
1142
|
+
async #readEndpoint(indexed: IndexedSession): Promise<SdkSessionEndpoint | null> {
|
|
1143
|
+
return (await this.#readProvenEndpoint(indexed))?.endpoint ?? null;
|
|
883
1144
|
}
|
|
884
1145
|
|
|
885
1146
|
async #createAttachedClient(
|
|
@@ -942,7 +1203,17 @@ export class SessionRouter {
|
|
|
942
1203
|
if (retirement) await retirement;
|
|
943
1204
|
if (!this.#running(runEpoch)) return false;
|
|
944
1205
|
if (indexed.endpointMtimeMs === undefined) return false;
|
|
945
|
-
|
|
1206
|
+
// The identity travels with the endpoint from its authority read (#4730
|
|
1207
|
+
// review). A pre-resolved endpoint (adoption/lifecycle handoff) had no
|
|
1208
|
+
// authority read of its own, so it samples once here.
|
|
1209
|
+
const proven = resolvedEndpoint === undefined ? await this.#readProvenEndpoint(indexed) : null;
|
|
1210
|
+
const endpoint = resolvedEndpoint ?? proven?.endpoint ?? null;
|
|
1211
|
+
let provenIno = proven?.ino;
|
|
1212
|
+
if (resolvedEndpoint !== undefined)
|
|
1213
|
+
provenIno = await fs
|
|
1214
|
+
.stat(resolvedEndpoint.path, { bigint: true })
|
|
1215
|
+
.then(value => value.ino)
|
|
1216
|
+
.catch(() => undefined);
|
|
946
1217
|
const retirementAfterValidation = this.#retirements.get(indexed.sessionId);
|
|
947
1218
|
if (retirementAfterValidation) await retirementAfterValidation;
|
|
948
1219
|
if ((this.#retirementVersions.get(indexed.sessionId) ?? 0) !== retirementVersion)
|
|
@@ -1041,11 +1312,34 @@ export class SessionRouter {
|
|
|
1041
1312
|
await this.#retireAttachment(attached, endpoint ? "replaced_same_generation" : undefined);
|
|
1042
1313
|
throw new SessionRouterError("pre_send", "SDK session attachment changed during publication.");
|
|
1043
1314
|
}
|
|
1044
|
-
} else await this.#serialReconcile(runEpoch, true);
|
|
1315
|
+
} else await this.#serialReconcile(runEpoch, true, true);
|
|
1045
1316
|
if (!attached || !this.#attachmentPublished(attached))
|
|
1046
1317
|
throw new SessionRouterError("pre_send", "SDK session attachment is stale.");
|
|
1047
1318
|
attached.client.send(this.#prepareFrame(attached, frame));
|
|
1048
1319
|
},
|
|
1320
|
+
/**
|
|
1321
|
+
* Provider-lease heartbeats skip the pre-send authority reconcile
|
|
1322
|
+
* (#4689): the frame is idempotent and advisory, the native server
|
|
1323
|
+
* drops wrong-token/stale-connection frames, and the 2s tick plus
|
|
1324
|
+
* 30s sweep revalidate authority on their own cadence. The frame
|
|
1325
|
+
* shape is fixed here so no command traffic can take this path;
|
|
1326
|
+
* dispatch keeps using send().
|
|
1327
|
+
*/
|
|
1328
|
+
sendMaintenance: async (leaseId: string) => {
|
|
1329
|
+
if (!attached || !this.#attachmentPublished(attached))
|
|
1330
|
+
throw new SessionRouterError("pre_send", "SDK session attachment is stale.");
|
|
1331
|
+
// Renewal must fail closed against durable authority (#4730 review):
|
|
1332
|
+
// the idle path can defer full projection to the 30s sweep, so an
|
|
1333
|
+
// endpoint replaced in that window would otherwise keep receiving lease
|
|
1334
|
+
// renewals. This is the bounded LOCK-FREE check: it reads only this
|
|
1335
|
+
// endpoint record and never re-enters the locked index rescan, which on
|
|
1336
|
+
// a 5s heartbeat would restore the very cost #4689 removes.
|
|
1337
|
+
if (!(await this.#endpointAuthorityUnchanged(attached)))
|
|
1338
|
+
throw new SessionRouterError("pre_send", "SDK session endpoint authority changed before lease renewal.");
|
|
1339
|
+
if (!attached || !this.#attachmentPublished(attached))
|
|
1340
|
+
throw new SessionRouterError("pre_send", "SDK session attachment is stale.");
|
|
1341
|
+
attached.client.send(this.#prepareFrame(attached, { type: "provider_heartbeat", leaseId }));
|
|
1342
|
+
},
|
|
1049
1343
|
retire: async () => {
|
|
1050
1344
|
if (attached) await this.#retireAttachment(attached);
|
|
1051
1345
|
},
|
|
@@ -1119,9 +1413,31 @@ export class SessionRouter {
|
|
|
1119
1413
|
new SessionRouterError("pre_send", "SDK session publication was detached before completion."),
|
|
1120
1414
|
);
|
|
1121
1415
|
barrier.held = undefined;
|
|
1416
|
+
// Every teardown path (retirement, replacement, stop, failed
|
|
1417
|
+
// publication) routes through dispose(), so the published-inode entry
|
|
1418
|
+
// is dropped here rather than at one call site (#4730 review).
|
|
1419
|
+
if (attached) this.#endpointInodes.delete(attached.id);
|
|
1122
1420
|
},
|
|
1123
1421
|
};
|
|
1422
|
+
// Re-check at publication (#4730 review): the endpoint must STILL be the
|
|
1423
|
+
// file authority validated. Fail closed on stat error or any identity
|
|
1424
|
+
// change instead of publishing an attachment bound to a successor.
|
|
1425
|
+
// Every rejection here must roll the connected client back fully (#4730
|
|
1426
|
+
// review): the transport is already open at this point, so returning without
|
|
1427
|
+
// closing it leaks an unowned connection.
|
|
1428
|
+
const rollback = async (): Promise<false> => {
|
|
1429
|
+
attached.dispose();
|
|
1430
|
+
await client.close().catch(() => undefined);
|
|
1431
|
+
return false;
|
|
1432
|
+
};
|
|
1433
|
+
if (provenIno === undefined) return await rollback();
|
|
1434
|
+
const publishIno = await fs
|
|
1435
|
+
.stat(endpoint.path, { bigint: true })
|
|
1436
|
+
.then(value => value.ino)
|
|
1437
|
+
.catch(() => undefined);
|
|
1438
|
+
if (publishIno === undefined || publishIno !== provenIno) return await rollback();
|
|
1124
1439
|
this.#sessions.set(indexed.sessionId, attached);
|
|
1440
|
+
this.#endpointInodes.set(attached.id, provenIno);
|
|
1125
1441
|
if (deferPublication)
|
|
1126
1442
|
this.#adopted.set(indexed.sessionId, {
|
|
1127
1443
|
generation: indexed.endpointGeneration,
|
|
@@ -1159,15 +1475,23 @@ export class SessionRouter {
|
|
|
1159
1475
|
async #publishAttachment(attached: AttachedSession, skipReplay: boolean, deferReplay = false): Promise<boolean> {
|
|
1160
1476
|
if (attached.published) return this.#attachmentPublished(attached);
|
|
1161
1477
|
if (!this.#attachmentLive(attached)) return false;
|
|
1162
|
-
const
|
|
1478
|
+
const proven = await this.#readProvenEndpoint(attached.indexed).catch(() => null);
|
|
1163
1479
|
if (!this.#attachmentLive(attached)) return false;
|
|
1480
|
+
// The commit point compares the PROVEN inode alongside url/token/pid
|
|
1481
|
+
// (#4730 review): an identical-byte rename during the pre-publication
|
|
1482
|
+
// hooks matches every other field, so identity is what rejects it. The
|
|
1483
|
+
// value compared here is the one the authority read proved, not a later
|
|
1484
|
+
// observation, so the proof and its consumer cannot drift apart.
|
|
1485
|
+
const publishedIno = this.#endpointInodes.get(attached.id);
|
|
1164
1486
|
if (
|
|
1165
|
-
!
|
|
1166
|
-
endpoint.url !== attached.endpoint.url ||
|
|
1167
|
-
endpoint.token !== attached.endpoint.token ||
|
|
1168
|
-
endpoint.pid !== attached.pid
|
|
1487
|
+
!proven ||
|
|
1488
|
+
proven.endpoint.url !== attached.endpoint.url ||
|
|
1489
|
+
proven.endpoint.token !== attached.endpoint.token ||
|
|
1490
|
+
proven.endpoint.pid !== attached.pid ||
|
|
1491
|
+
publishedIno === undefined ||
|
|
1492
|
+
proven.ino !== publishedIno
|
|
1169
1493
|
) {
|
|
1170
|
-
await this.#retireAttachment(attached,
|
|
1494
|
+
await this.#retireAttachment(attached, proven ? "replaced_same_generation" : undefined);
|
|
1171
1495
|
return false;
|
|
1172
1496
|
}
|
|
1173
1497
|
if (!skipReplay) attached.barrier.held ??= [];
|
|
@@ -1271,6 +1595,17 @@ export class SessionRouter {
|
|
|
1271
1595
|
return this.#started && runEpoch === this.#runEpoch;
|
|
1272
1596
|
}
|
|
1273
1597
|
|
|
1598
|
+
/**
|
|
1599
|
+
* Idle-gate guard (#4689): local recovery state the durable index cannot
|
|
1600
|
+
* see. A failed/detached replay barrier or an unpublished attachment must
|
|
1601
|
+
* force the next full body instead of waiting for the sweep.
|
|
1602
|
+
*/
|
|
1603
|
+
#hasUnhealthyAttachment(): boolean {
|
|
1604
|
+
for (const attached of this.#sessions.values()) {
|
|
1605
|
+
if (attached.barrier.failed || attached.barrier.detached || !attached.published) return true;
|
|
1606
|
+
}
|
|
1607
|
+
return false;
|
|
1608
|
+
}
|
|
1274
1609
|
#attachmentLive(attached: AttachedSession): boolean {
|
|
1275
1610
|
return (
|
|
1276
1611
|
this.#running(attached.runEpoch) &&
|
|
@@ -1518,14 +1853,16 @@ export class SessionRouter {
|
|
|
1518
1853
|
}
|
|
1519
1854
|
const publicationId =
|
|
1520
1855
|
seq !== undefined && ownsSequence ? `${attached.sessionId}:${attached.generation}:${seq}` : undefined;
|
|
1856
|
+
let notificationFrame: SessionRouterFrame;
|
|
1521
1857
|
try {
|
|
1522
|
-
|
|
1858
|
+
notificationFrame = publicationId === undefined ? correlated : { ...correlated, publicationId };
|
|
1523
1859
|
this.#dispatchNotificationFrame(attached, notificationFrame);
|
|
1524
1860
|
await this.#deps.onFrame?.(attached.capability, notificationFrame);
|
|
1525
1861
|
} catch (error) {
|
|
1526
1862
|
if (!this.#attachmentLive(attached)) return;
|
|
1527
1863
|
if (seq === undefined || !ownsSequence) throw error;
|
|
1528
1864
|
this.#failDelivery(attached, seq, error);
|
|
1865
|
+
this.#deps.onFrameSettled?.(attached.capability, { ...correlated, seq });
|
|
1529
1866
|
return;
|
|
1530
1867
|
}
|
|
1531
1868
|
if (!this.#attachmentLive(attached)) return;
|
|
@@ -1534,6 +1871,8 @@ export class SessionRouter {
|
|
|
1534
1871
|
this.#removeRecoveredFrame(attached.sessionId, attached.generation, seq);
|
|
1535
1872
|
if (seq > attached.cursor.seq) attached.cursor.seq = seq;
|
|
1536
1873
|
}
|
|
1874
|
+
if (seq !== undefined && ownsSequence)
|
|
1875
|
+
this.#deps.onFrameSettled?.(attached.capability, { ...notificationFrame, seq });
|
|
1537
1876
|
});
|
|
1538
1877
|
this.#frameTails.set(attached.id, current);
|
|
1539
1878
|
void current.then(
|