@gajae-code/coding-agent 0.15.5 → 0.16.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 +85 -1
- package/dist/types/async/job-manager.d.ts +83 -0
- package/dist/types/cli/args.d.ts +2 -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/root-flags.d.ts +14 -0
- package/dist/types/cli/update-cli.d.ts +15 -0
- package/dist/types/cli.d.ts +7 -0
- package/dist/types/commands/auth-gateway.d.ts +3 -0
- package/dist/types/commands/gc.d.ts +13 -0
- package/dist/types/commands/launch.d.ts +10 -0
- package/dist/types/commands/model-presets.d.ts +21 -0
- package/dist/types/config/autorouting-tier-map.d.ts +120 -0
- package/dist/types/config/file-lock.d.ts +54 -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 +17 -5
- package/dist/types/config/settings-schema.d.ts +19 -1
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
- package/dist/types/coordinator-mcp/projection-scan.d.ts +54 -0
- package/dist/types/crash/record-loader.d.ts +15 -0
- package/dist/types/extensibility/extensions/types.d.ts +2 -5
- 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/launch-worktree-reservation.d.ts +21 -0
- package/dist/types/gjc-runtime/managed-owner-supervisor.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 +72 -1
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/main.d.ts +3 -1
- package/dist/types/master-mode/context.d.ts +21 -0
- package/dist/types/master-mode/first-request.d.ts +34 -0
- package/dist/types/master-mode/snapshot.d.ts +32 -0
- package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
- package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
- package/dist/types/modes/components/oauth-selector.d.ts +1 -0
- package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
- package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
- package/dist/types/modes/components/status-line/segments.d.ts +10 -0
- package/dist/types/modes/jobs-observer.d.ts +36 -0
- package/dist/types/modes/print-mode.d.ts +17 -0
- package/dist/types/modes/ui-language.d.ts +7 -1
- package/dist/types/sdk/broker/broker.d.ts +80 -1
- package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
- package/dist/types/sdk/broker/index.d.ts +3 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +26 -0
- package/dist/types/sdk/broker/master-capability.d.ts +10 -0
- package/dist/types/sdk/broker/session-index.d.ts +40 -11
- package/dist/types/sdk/broker/session-scope.d.ts +72 -0
- package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
- package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
- package/dist/types/sdk/broker/state-version.d.ts +4 -1
- package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
- package/dist/types/sdk/bus/index.d.ts +6 -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 +1 -1
- package/dist/types/sdk/cli/index.d.ts +1 -0
- package/dist/types/sdk/cli/master-cli.d.ts +63 -0
- package/dist/types/sdk/cli/rows.d.ts +4 -3
- package/dist/types/sdk/cli/session-cli.d.ts +18 -2
- package/dist/types/sdk/client/client.d.ts +3 -2
- package/dist/types/sdk/client/discovery.d.ts +1 -0
- package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
- package/dist/types/sdk/host/control/index.d.ts +1 -1
- package/dist/types/sdk/host/control/operations.d.ts +2 -0
- package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
- package/dist/types/sdk/host/host.d.ts +10 -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 +67 -1
- package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
- package/dist/types/sdk/lifecycle/service.d.ts +15 -3
- package/dist/types/sdk/lifecycle-session.d.ts +2 -0
- 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 +11 -0
- package/dist/types/sdk/session.d.ts +16 -0
- package/dist/types/sdk/startup-capability.d.ts +2 -1
- package/dist/types/session/account-inventory.d.ts +1 -0
- package/dist/types/session/agent-session.d.ts +24 -7
- package/dist/types/session/credential-store-errors.d.ts +1 -0
- package/dist/types/session/fold-coordinator.d.ts +201 -0
- 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/session/session-manager.d.ts +65 -0
- package/dist/types/session/yield-queue.d.ts +5 -4
- package/dist/types/setup/frictionless-onboarding.d.ts +1 -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/bash.d.ts +4 -0
- package/dist/types/tools/gh.d.ts +8 -0
- package/dist/types/tools/index.d.ts +10 -5
- 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 +9 -3
- package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
- package/src/async/job-manager.ts +671 -39
- package/src/cli/accounts-cli.ts +9 -5
- package/src/cli/args.ts +17 -0
- 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 +4 -0
- package/src/cli/model-presets-cli.ts +100 -0
- package/src/cli/root-flags.ts +5 -0
- package/src/cli/update-cli.ts +117 -16
- package/src/cli.ts +6 -2
- package/src/commands/auth-gateway.ts +12 -8
- package/src/commands/gc.ts +19 -0
- package/src/commands/launch.ts +86 -18
- package/src/commands/model-presets.ts +24 -0
- package/src/commands/sdk.ts +113 -21
- package/src/config/autorouting-tier-map.ts +44 -0
- package/src/config/file-lock-gc.ts +9 -4
- package/src/config/file-lock.ts +890 -99
- 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 +2795 -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 +1044 -122
- package/src/config/model-resolver.ts +5 -0
- package/src/config/settings-schema.ts +19 -1
- package/src/config/settings.ts +11 -1
- package/src/coordinator-mcp/model-preset.ts +32 -17
- package/src/coordinator-mcp/projection-scan.ts +592 -0
- package/src/coordinator-mcp/question-state.ts +9 -11
- package/src/coordinator-mcp/server.ts +111 -42
- package/src/coordinator-mcp/session-reaper.ts +9 -3
- package/src/crash/index-store.ts +2 -0
- package/src/crash/record-loader.ts +10 -0
- package/src/extensibility/extensions/loader.ts +0 -1
- package/src/extensibility/extensions/types.ts +2 -3
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -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-reservation.ts +242 -0
- package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
- package/src/gjc-runtime/session-state-lock.ts +1321 -147
- package/src/gjc-runtime/session-state-sidecar.ts +1004 -25
- package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
- package/src/gjc-runtime/tmux-owner-isolation.ts +2 -2
- package/src/gjc-runtime/tmux-sessions.ts +252 -13
- package/src/hooks/skill-state.ts +6 -0
- package/src/index.ts +1 -0
- package/src/internal-urls/docs-index.generated.ts +14 -13
- package/src/internal-urls/issue-pr-protocol.ts +4 -2
- package/src/lsp/config.ts +4 -3
- package/src/lsp/path-trust.ts +12 -6
- package/src/main.ts +66 -4
- package/src/master-mode/context.ts +50 -0
- package/src/master-mode/first-request.ts +65 -0
- package/src/master-mode/snapshot.ts +205 -0
- package/src/modes/acp/acp-agent.ts +616 -161
- package/src/modes/components/footer.ts +4 -2
- package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
- package/src/modes/components/jobs-overlay-model.ts +99 -16
- package/src/modes/components/model-selector.ts +158 -18
- package/src/modes/components/oauth-selector.ts +6 -1
- package/src/modes/components/settings-selector.ts +1 -0
- package/src/modes/components/status-line/model-name.ts +53 -0
- package/src/modes/components/status-line/priority-row.ts +107 -0
- package/src/modes/components/status-line/segments.ts +29 -10
- package/src/modes/components/tool-status-header.ts +151 -15
- package/src/modes/controllers/extension-ui-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +51 -6
- package/src/modes/controllers/selector-controller.ts +191 -96
- package/src/modes/interactive-mode.ts +14 -1
- package/src/modes/jobs-observer.ts +149 -16
- package/src/modes/print-mode.ts +64 -0
- package/src/modes/runtime-init.ts +1 -1
- package/src/modes/ui-language.ts +49 -2
- package/src/modes/utils/injected-user-submission.ts +15 -1
- package/src/prompts/system/master-mode.md +28 -0
- 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 +1671 -34
- package/src/sdk/broker/endpoint-authority.ts +86 -0
- package/src/sdk/broker/ensure.ts +5 -0
- package/src/sdk/broker/index.ts +3 -0
- package/src/sdk/broker/lifecycle.ts +217 -46
- package/src/sdk/broker/master-capability.ts +232 -0
- package/src/sdk/broker/session-index.ts +121 -17
- package/src/sdk/broker/session-scope.ts +272 -0
- package/src/sdk/broker/spawn-authority.ts +1031 -0
- package/src/sdk/broker/spawn-substrate.ts +548 -0
- package/src/sdk/broker/state-version.ts +26 -14
- package/src/sdk/broker/transport.ts +1 -0
- package/src/sdk/broker/worktree-occupancy.ts +90 -0
- package/src/sdk/bus/chat-daemon-control.ts +5 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +46 -5
- package/src/sdk/bus/index.ts +51 -4
- package/src/sdk/bus/kind-aware-reconciliation.ts +36 -6
- package/src/sdk/bus/prompt-reconciliation.ts +28 -4
- package/src/sdk/bus/slack-conversation.ts +5 -1
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/cli/index.ts +1 -0
- package/src/sdk/cli/master-cli.ts +259 -0
- package/src/sdk/cli/rows.ts +11 -8
- package/src/sdk/cli/session-cli.ts +276 -6
- package/src/sdk/client/client.ts +14 -8
- package/src/sdk/client/discovery.ts +3 -1
- package/src/sdk/host/control/dispatch.ts +101 -37
- package/src/sdk/host/control/index.ts +2 -0
- package/src/sdk/host/control/operations.ts +2 -0
- package/src/sdk/host/control/runtime-gate.ts +203 -11
- package/src/sdk/host/host.ts +27 -2
- 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 +581 -43
- package/src/sdk/host/session-runtime.ts +655 -101
- package/src/sdk/lifecycle/broker-client.ts +28 -0
- package/src/sdk/lifecycle/service.ts +279 -20
- package/src/sdk/lifecycle-session.ts +10 -2
- 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 +53 -2
- package/src/sdk/protocol/operation-registry.ts +17 -4
- package/src/sdk/router/session-router.ts +294 -154
- package/src/sdk/session.ts +440 -167
- package/src/sdk/startup-capability.ts +1 -0
- package/src/session/account-inventory.ts +27 -11
- package/src/session/agent-session.ts +1292 -180
- package/src/session/blob-store.ts +15 -5
- package/src/session/credential-store-errors.ts +2 -0
- package/src/session/fold-coordinator.ts +433 -0
- package/src/session/internal/managed-session-storage.ts +153 -78
- 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 +342 -28
- package/src/session/yield-queue.ts +24 -10
- package/src/setup/frictionless-onboarding.ts +2 -0
- 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 +1268 -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 +1028 -109
- package/src/tools/gh.ts +104 -48
- package/src/tools/github-cache.ts +2 -2
- package/src/tools/index.ts +11 -5
- 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
package/src/sdk/broker/broker.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
1
|
+
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
2
2
|
import type { BigIntStats } from "node:fs";
|
|
3
3
|
import * as fs from "node:fs/promises";
|
|
4
4
|
import * as os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import type { NativeDirectoryTreeSnapshot } from "@gajae-code/natives";
|
|
7
|
-
import { logger } from "@gajae-code/utils";
|
|
7
|
+
import { logger, resolveEquivalentPath } from "@gajae-code/utils";
|
|
8
8
|
import type { ModelProfileErrorDetails } from "../../config/model-profile-contract";
|
|
9
9
|
import { planLaunchWorktree } from "../../gjc-runtime/launch-worktree";
|
|
10
|
+
import { SdkClient, SdkClientError } from "../client";
|
|
11
|
+
import {
|
|
12
|
+
BROKER_RUNTIME_ABORT_CAPABILITY_FIELD,
|
|
13
|
+
BROKER_RUNTIME_CLOSE_CAPABILITY_FIELD,
|
|
14
|
+
} from "../host/control/runtime-gate";
|
|
10
15
|
import { createDefaultSdkHostModelResolver, type SdkHostModelResolver } from "../host/model-pin";
|
|
11
16
|
import {
|
|
12
17
|
type DirectoryMigrationPolicy,
|
|
@@ -28,15 +33,42 @@ import {
|
|
|
28
33
|
readBrokerDiscovery,
|
|
29
34
|
redactBrokerDiscovery,
|
|
30
35
|
} from "./discovery";
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
36
|
+
import { matchesIndexedEndpointFile, readEndpointFile } from "./endpoint-authority";
|
|
37
|
+
import { deriveIdempotencyIdentity, getBrokerIdentityKey } from "./identity";
|
|
38
|
+
import {
|
|
39
|
+
canonicalDeleteLocatorPath,
|
|
40
|
+
executeLifecycle,
|
|
41
|
+
isCanonicalSessionId,
|
|
42
|
+
prepareSpawnChildHostLaunch,
|
|
43
|
+
validateBrokerModelPresetSync,
|
|
44
|
+
} from "./lifecycle";
|
|
33
45
|
import {
|
|
34
46
|
type LifecycleDurableEffectsReceipt,
|
|
35
47
|
LifecycleLedger,
|
|
36
48
|
type LifecycleStartupFailureReceipt,
|
|
37
49
|
type LifecycleState,
|
|
38
50
|
} from "./lifecycle-ledger";
|
|
51
|
+
import { createMasterCapabilityVerifier, readEndpoint } from "./master-capability";
|
|
39
52
|
import { type IndexedSession, isSessionAuthorityEligible, SessionIndex, type SessionList } from "./session-index";
|
|
53
|
+
import {
|
|
54
|
+
type ResolvedScopeV1,
|
|
55
|
+
resolveScopeRequest,
|
|
56
|
+
ScopeRequestValidationError,
|
|
57
|
+
scopeMatchesLocator,
|
|
58
|
+
scopeRequestV1,
|
|
59
|
+
} from "./session-scope";
|
|
60
|
+
import {
|
|
61
|
+
type MasterCapabilityVerifier,
|
|
62
|
+
type SeedDeliveryV2,
|
|
63
|
+
SpawnAuthorityStore,
|
|
64
|
+
type SpawnAuthorityV1,
|
|
65
|
+
type SpawnClaimDecision,
|
|
66
|
+
type SpawnClaimV2,
|
|
67
|
+
type SpawnSubstrateFailure,
|
|
68
|
+
type SpawnSubstrateProof,
|
|
69
|
+
type SpawnSubstrateProvider,
|
|
70
|
+
} from "./spawn-authority";
|
|
71
|
+
import { createSpawnSubstrateProvider } from "./spawn-substrate";
|
|
40
72
|
import { BrokerTransport } from "./transport";
|
|
41
73
|
|
|
42
74
|
export interface BrokerSettings {
|
|
@@ -46,6 +78,16 @@ export interface BrokerSettings {
|
|
|
46
78
|
heartbeatTtlMs?: number;
|
|
47
79
|
/** Broker-owned migration policy. Client lifecycle frames cannot select it. */
|
|
48
80
|
resolveDirectoryMigration?: (_cwd: string) => Promise<DirectoryMigrationPolicy>;
|
|
81
|
+
/** Exact managed-substrate authority. Tests inject an in-memory provider. */
|
|
82
|
+
spawnSubstrateProvider?: SpawnSubstrateProvider;
|
|
83
|
+
/** Ordered Q26 host control seam; production uses exact endpoint attachments. */
|
|
84
|
+
spawnPromptLayer?: SpawnPromptLayer;
|
|
85
|
+
/** Live-only, host-mediated capability verifier. It retains no request input. */
|
|
86
|
+
masterCapabilityVerifier?: MasterCapabilityVerifier;
|
|
87
|
+
/** Grace before an orphaned spawn child closes; schema-bounded in production. */
|
|
88
|
+
masterOrphanGraceMs?: number;
|
|
89
|
+
/** Host model resolver override for lifecycle tests and embedders. */
|
|
90
|
+
resolveModelPin?: SdkHostModelResolver;
|
|
49
91
|
}
|
|
50
92
|
|
|
51
93
|
type ResolvedBrokerSettings = {
|
|
@@ -54,6 +96,10 @@ type ResolvedBrokerSettings = {
|
|
|
54
96
|
port: number;
|
|
55
97
|
heartbeatTtlMs: number;
|
|
56
98
|
resolveDirectoryMigration: (_cwd: string) => Promise<DirectoryMigrationPolicy>;
|
|
99
|
+
masterCapabilityVerifier?: MasterCapabilityVerifier;
|
|
100
|
+
spawnSubstrateProvider?: SpawnSubstrateProvider;
|
|
101
|
+
spawnPromptLayer?: SpawnPromptLayer;
|
|
102
|
+
masterOrphanGraceMs: number;
|
|
57
103
|
};
|
|
58
104
|
|
|
59
105
|
function modelResolutionCwd(input: Record<string, unknown>): string | undefined {
|
|
@@ -179,7 +225,7 @@ export type BrokerResponse =
|
|
|
179
225
|
error: {
|
|
180
226
|
code: BrokerErrorCode;
|
|
181
227
|
message: string;
|
|
182
|
-
details?: ModelProfileErrorDetails;
|
|
228
|
+
details?: ModelProfileErrorDetails | SpawnSubstrateFailure;
|
|
183
229
|
endpoint?: "unavailable";
|
|
184
230
|
cleanup?: BrokerCleanupEvidence;
|
|
185
231
|
};
|
|
@@ -188,6 +234,14 @@ export type BrokerResponse =
|
|
|
188
234
|
startupFailure?: LifecycleStartupFailureReceipt;
|
|
189
235
|
};
|
|
190
236
|
const error = (code: BrokerErrorCode, message: string): BrokerResponse => ({ ok: false, error: { code, message } });
|
|
237
|
+
const spawnFailureError = (failure: SpawnSubstrateFailure): BrokerResponse => ({
|
|
238
|
+
ok: false,
|
|
239
|
+
error: {
|
|
240
|
+
code: "spawn_failed",
|
|
241
|
+
message: `session.spawn substrate could not be safely established: ${failure.message}`,
|
|
242
|
+
details: failure,
|
|
243
|
+
},
|
|
244
|
+
});
|
|
191
245
|
|
|
192
246
|
function isCleanupPending(response: BrokerResponse): boolean {
|
|
193
247
|
if (response.ok) return false;
|
|
@@ -253,12 +307,151 @@ type SessionListCursor = {
|
|
|
253
307
|
limit: number;
|
|
254
308
|
offset: number;
|
|
255
309
|
expiresAt: number;
|
|
310
|
+
scope?: ResolvedScopeV1;
|
|
311
|
+
observedAt?: string;
|
|
256
312
|
};
|
|
257
313
|
|
|
258
314
|
const SESSION_LIST_DEFAULT_LIMIT = 100;
|
|
259
315
|
const SESSION_LIST_MAX_LIMIT = 100;
|
|
260
316
|
const SESSION_LIST_CURSOR_TTL_MS = 15 * 60 * 1_000;
|
|
261
317
|
const SESSION_LIST_MAX_CURSORS = 32;
|
|
318
|
+
type SpawnInFlight = {
|
|
319
|
+
completion: Promise<BrokerResponse>;
|
|
320
|
+
resolve: (response: BrokerResponse) => void;
|
|
321
|
+
claimId?: string;
|
|
322
|
+
phase?: SpawnClaimV2["state"];
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
/** Complete five-leg child endpoint pin captured at registration. */
|
|
326
|
+
export type SpawnHostRegistration = {
|
|
327
|
+
sessionId: string;
|
|
328
|
+
endpointGeneration: number;
|
|
329
|
+
pid: number;
|
|
330
|
+
processIncarnation: string;
|
|
331
|
+
/** Workspace the child was launched into; bound so a colliding pid elsewhere cannot pass. */
|
|
332
|
+
cwd: string;
|
|
333
|
+
stateRoot: string;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
export type SpawnPromptDispatch =
|
|
337
|
+
| { kind: "accepted"; commandId: string; turnId: string; acceptedAt: number }
|
|
338
|
+
| { kind: "pre_send_rejected" }
|
|
339
|
+
| { kind: "uncertain" };
|
|
340
|
+
|
|
341
|
+
export type SpawnQ26Reconciliation = {
|
|
342
|
+
status: "accepted" | "in_flight" | "terminal_ok" | "failed" | "unknown";
|
|
343
|
+
clientRef?: string;
|
|
344
|
+
commandId?: string;
|
|
345
|
+
turnId?: string;
|
|
346
|
+
acceptedAt?: number;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
/** Q26-only host control seam. It never receives or returns durable request material. */
|
|
350
|
+
export interface SpawnPromptLayer {
|
|
351
|
+
awaitRegistration(input: {
|
|
352
|
+
childId: string;
|
|
353
|
+
cwd: string;
|
|
354
|
+
stateRoot: string;
|
|
355
|
+
}): Promise<{ ok: true; registration: SpawnHostRegistration } | { ok: false }>;
|
|
356
|
+
dispatch(input: {
|
|
357
|
+
sessionId: string;
|
|
358
|
+
task: string;
|
|
359
|
+
clientRef: string;
|
|
360
|
+
/** Endpoint identity proven at registration; implementations must not talk to another endpoint. */
|
|
361
|
+
pinned: SpawnHostRegistration;
|
|
362
|
+
}): Promise<SpawnPromptDispatch>;
|
|
363
|
+
reconcile(input: {
|
|
364
|
+
sessionId: string;
|
|
365
|
+
clientRef: string;
|
|
366
|
+
pinned: SpawnHostRegistration;
|
|
367
|
+
}): Promise<SpawnQ26Reconciliation>;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
type SpawnAdmissionInput = {
|
|
371
|
+
task: string;
|
|
372
|
+
masterCapability: string;
|
|
373
|
+
ownerSessionId: string;
|
|
374
|
+
attestationEpoch: string;
|
|
375
|
+
cwd: string;
|
|
376
|
+
modelId?: string;
|
|
377
|
+
modelPreset?: string;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
function parseSpawnInput(
|
|
381
|
+
input: Record<string, unknown>,
|
|
382
|
+
idempotencyKey: string | undefined,
|
|
383
|
+
): SpawnAdmissionInput | BrokerResponse {
|
|
384
|
+
if (!idempotencyKey || idempotencyKey.length > 512)
|
|
385
|
+
return error("invalid_input", "idempotencyKey is required for session.spawn");
|
|
386
|
+
const allowed = new Set([
|
|
387
|
+
"task",
|
|
388
|
+
"prompt",
|
|
389
|
+
"masterCapability",
|
|
390
|
+
"ownerSessionId",
|
|
391
|
+
"attestationEpoch",
|
|
392
|
+
"cwd",
|
|
393
|
+
"modelId",
|
|
394
|
+
"modelPreset",
|
|
395
|
+
]);
|
|
396
|
+
if (Object.keys(input).some(key => !allowed.has(key)))
|
|
397
|
+
return error("invalid_input", "session.spawn input is invalid");
|
|
398
|
+
const task = input.task ?? input.prompt;
|
|
399
|
+
if (
|
|
400
|
+
typeof task !== "string" ||
|
|
401
|
+
task.length === 0 ||
|
|
402
|
+
task.length > 1_000_000 ||
|
|
403
|
+
(input.task !== undefined && input.prompt !== undefined && input.task !== input.prompt)
|
|
404
|
+
)
|
|
405
|
+
return error("invalid_input", "session.spawn task is invalid");
|
|
406
|
+
if (
|
|
407
|
+
typeof input.masterCapability !== "string" ||
|
|
408
|
+
input.masterCapability.length === 0 ||
|
|
409
|
+
input.masterCapability.length > 16_384
|
|
410
|
+
)
|
|
411
|
+
return error("invalid_input", "session.spawn capability is invalid");
|
|
412
|
+
if (
|
|
413
|
+
typeof input.ownerSessionId !== "string" ||
|
|
414
|
+
!isCanonicalSessionId(input.ownerSessionId) ||
|
|
415
|
+
typeof input.attestationEpoch !== "string" ||
|
|
416
|
+
input.attestationEpoch.length === 0 ||
|
|
417
|
+
input.attestationEpoch.length > 512 ||
|
|
418
|
+
typeof input.cwd !== "string" ||
|
|
419
|
+
input.cwd.length === 0 ||
|
|
420
|
+
(input.modelId !== undefined && (typeof input.modelId !== "string" || input.modelId.trim().length === 0)) ||
|
|
421
|
+
(input.modelPreset !== undefined && (typeof input.modelPreset !== "string" || input.modelPreset.length === 0))
|
|
422
|
+
)
|
|
423
|
+
return error("invalid_input", "session.spawn input is invalid");
|
|
424
|
+
return {
|
|
425
|
+
task,
|
|
426
|
+
masterCapability: input.masterCapability,
|
|
427
|
+
ownerSessionId: input.ownerSessionId,
|
|
428
|
+
attestationEpoch: input.attestationEpoch,
|
|
429
|
+
cwd: path.resolve(input.cwd),
|
|
430
|
+
...(typeof input.modelId === "string" ? { modelId: input.modelId.trim() } : {}),
|
|
431
|
+
...(typeof input.modelPreset === "string" ? { modelPreset: input.modelPreset } : {}),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function spawnBindingMac(
|
|
436
|
+
key: string,
|
|
437
|
+
admission: Pick<SpawnAdmissionInput, "ownerSessionId" | "attestationEpoch" | "cwd">,
|
|
438
|
+
modelId: string | null,
|
|
439
|
+
modelPreset: string | null,
|
|
440
|
+
): string {
|
|
441
|
+
return createHmac("sha256", Buffer.from(key, "hex"))
|
|
442
|
+
.update(
|
|
443
|
+
canonicalJson({
|
|
444
|
+
version: 1,
|
|
445
|
+
operation: "session.spawn",
|
|
446
|
+
ownerSessionId: admission.ownerSessionId,
|
|
447
|
+
attestationEpoch: admission.attestationEpoch,
|
|
448
|
+
cwd: admission.cwd,
|
|
449
|
+
modelId,
|
|
450
|
+
modelPreset,
|
|
451
|
+
}),
|
|
452
|
+
)
|
|
453
|
+
.digest("hex");
|
|
454
|
+
}
|
|
262
455
|
|
|
263
456
|
function sessionListLimit(input: Record<string, unknown>): number | BrokerResponse {
|
|
264
457
|
const limit = input.limit;
|
|
@@ -272,6 +465,125 @@ function isBrokerResponse(value: unknown): value is BrokerResponse {
|
|
|
272
465
|
return typeof value === "object" && value !== null && "ok" in value && typeof value.ok === "boolean";
|
|
273
466
|
}
|
|
274
467
|
|
|
468
|
+
const SPAWN_HOST_REGISTRATION_TIMEOUT_MS = 10_000;
|
|
469
|
+
const SPAWN_HOST_REGISTRATION_POLL_MS = 50;
|
|
470
|
+
const SPAWN_PROMPT_EXCHANGE_TIMEOUT_MS = 10_000;
|
|
471
|
+
const MASTER_ORPHAN_GRACE_DEFAULT_MS = 120_000;
|
|
472
|
+
|
|
473
|
+
/** The five legs every usable pin must carry; a partial pin is missing authority. */
|
|
474
|
+
type CompleteSpawnPinAuthority = SpawnAuthorityV1 & {
|
|
475
|
+
endpointGeneration: number;
|
|
476
|
+
endpointPid: number;
|
|
477
|
+
endpointIncarnation: string;
|
|
478
|
+
endpointCwd: string;
|
|
479
|
+
endpointStateRoot: string;
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* A pin is COMPLETE or it is missing authority. The fields are optional in the
|
|
484
|
+
* schema so older rows still reopen, but a partially populated pin carries
|
|
485
|
+
* strictly weaker evidence than the exchange assumes: generation plus pid is
|
|
486
|
+
* collidable across workspaces, so a partial pin must never be usable.
|
|
487
|
+
*/
|
|
488
|
+
function isCompleteSpawnPin(authority: SpawnAuthorityV1): authority is CompleteSpawnPinAuthority {
|
|
489
|
+
return (
|
|
490
|
+
authority.endpointGeneration !== undefined &&
|
|
491
|
+
authority.endpointPid !== undefined &&
|
|
492
|
+
authority.endpointIncarnation !== undefined &&
|
|
493
|
+
authority.endpointCwd !== undefined &&
|
|
494
|
+
authority.endpointStateRoot !== undefined
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Rebuilds the proven child-endpoint pin from durable authority. Returns
|
|
500
|
+
* undefined for a pre-pin or partially pinned row, which recovery must treat as
|
|
501
|
+
* missing authority.
|
|
502
|
+
*/
|
|
503
|
+
function spawnPinFromAuthority(authority: SpawnAuthorityV1): SpawnHostRegistration | undefined {
|
|
504
|
+
// A pin is COMPLETE or it is missing authority. The fields are optional in the
|
|
505
|
+
// schema so older rows still reopen, but a partially populated pin carries
|
|
506
|
+
// strictly weaker evidence than the exchange assumes: generation plus pid is
|
|
507
|
+
// collidable across workspaces, so a partial pin must never be usable.
|
|
508
|
+
if (!isCompleteSpawnPin(authority)) return undefined;
|
|
509
|
+
return {
|
|
510
|
+
sessionId: authority.childId,
|
|
511
|
+
endpointGeneration: authority.endpointGeneration,
|
|
512
|
+
pid: authority.endpointPid,
|
|
513
|
+
processIncarnation: authority.endpointIncarnation,
|
|
514
|
+
cwd: authority.endpointCwd,
|
|
515
|
+
stateRoot: authority.endpointStateRoot,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Exact match of a live index row against a complete endpoint pin. */
|
|
520
|
+
function matchesSpawnPin(candidate: IndexedSession, pinned: SpawnHostRegistration): boolean {
|
|
521
|
+
return (
|
|
522
|
+
candidate.endpointGeneration === pinned.endpointGeneration &&
|
|
523
|
+
candidate.pid === pinned.pid &&
|
|
524
|
+
(candidate.hostIncarnation ?? candidate.processIncarnation) === pinned.processIncarnation &&
|
|
525
|
+
resolveEquivalentPath(candidate.locator.cwd) === resolveEquivalentPath(pinned.cwd) &&
|
|
526
|
+
resolveEquivalentPath(candidate.locator.stateRoot) === resolveEquivalentPath(pinned.stateRoot)
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Rebuilds the provider proof from durable authority facts only. */
|
|
531
|
+
function spawnProofFromAuthority(authority: SpawnAuthorityV1): SpawnSubstrateProof {
|
|
532
|
+
return {
|
|
533
|
+
substrateKind: authority.substrateKind,
|
|
534
|
+
providerIdentity: authority.providerIdentity,
|
|
535
|
+
...(authority.nativeSessionId === undefined ? {} : { nativeSessionId: authority.nativeSessionId }),
|
|
536
|
+
...(authority.pid === undefined ? {} : { pid: authority.pid }),
|
|
537
|
+
...(authority.processIncarnation === undefined ? {} : { processIncarnation: authority.processIncarnation }),
|
|
538
|
+
...(authority.ownerGeneration === undefined ? {} : { ownerGeneration: authority.ownerGeneration }),
|
|
539
|
+
...(authority.stateFileProof === undefined ? {} : { stateFileProof: authority.stateFileProof }),
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function objectRecord(value: unknown): Record<string, unknown> | undefined {
|
|
544
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
545
|
+
? (value as Record<string, unknown>)
|
|
546
|
+
: undefined;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function safeSpawnOpaque(value: unknown): value is string {
|
|
550
|
+
return typeof value === "string" && value.length > 0 && value.length <= 512;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function q26FromResponse(value: unknown): SpawnQ26Reconciliation {
|
|
554
|
+
const outer = objectRecord(value);
|
|
555
|
+
const result = objectRecord(outer?.result) ?? outer;
|
|
556
|
+
const rawStatus = result?.status;
|
|
557
|
+
const status =
|
|
558
|
+
rawStatus === "accepted" || rawStatus === "in_flight" || rawStatus === "terminal_ok" || rawStatus === "failed"
|
|
559
|
+
? rawStatus
|
|
560
|
+
: "unknown";
|
|
561
|
+
return {
|
|
562
|
+
status,
|
|
563
|
+
...(safeSpawnOpaque(result?.clientRef) ? { clientRef: result.clientRef } : {}),
|
|
564
|
+
...(safeSpawnOpaque(result?.commandId) ? { commandId: result.commandId } : {}),
|
|
565
|
+
...(safeSpawnOpaque(result?.turnId) ? { turnId: result.turnId } : {}),
|
|
566
|
+
...(typeof result?.acceptedAt === "number" && Number.isSafeInteger(result.acceptedAt) && result.acceptedAt >= 0
|
|
567
|
+
? { acceptedAt: result.acceptedAt }
|
|
568
|
+
: {}),
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function promptAcceptanceFromResponse(
|
|
573
|
+
value: unknown,
|
|
574
|
+
clientRef: string,
|
|
575
|
+
): { commandId: string; turnId: string } | undefined {
|
|
576
|
+
const outer = objectRecord(value);
|
|
577
|
+
const result = objectRecord(outer?.result) ?? outer;
|
|
578
|
+
if (
|
|
579
|
+
result?.accepted !== true ||
|
|
580
|
+
!safeSpawnOpaque(result.commandId) ||
|
|
581
|
+
!safeSpawnOpaque(result.turnId) ||
|
|
582
|
+
(result.clientRef !== undefined && result.clientRef !== clientRef)
|
|
583
|
+
)
|
|
584
|
+
return undefined;
|
|
585
|
+
return { commandId: result.commandId, turnId: result.turnId };
|
|
586
|
+
}
|
|
275
587
|
function normalizeAliasedString(
|
|
276
588
|
input: Record<string, unknown>,
|
|
277
589
|
canonical: string,
|
|
@@ -316,6 +628,8 @@ function normalizeBrokerInput(operation: string, input: Record<string, unknown>)
|
|
|
316
628
|
return error("invalid_input", "cursor must be a non-empty opaque string");
|
|
317
629
|
const limit = sessionListLimit(input);
|
|
318
630
|
if (isBrokerResponse(limit)) return limit;
|
|
631
|
+
if (input.scope !== undefined && !scopeRequestV1(input.scope) && input.cursor === undefined)
|
|
632
|
+
return error("invalid_input", "scope must be a valid ScopeRequestV1");
|
|
319
633
|
return { input: normalized };
|
|
320
634
|
}
|
|
321
635
|
if (
|
|
@@ -385,7 +699,13 @@ function credentialFreeLifecycleResponse(value: unknown): unknown {
|
|
|
385
699
|
if (value === null || typeof value !== "object") return value;
|
|
386
700
|
const output: Record<string, unknown> = {};
|
|
387
701
|
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
|
388
|
-
if (
|
|
702
|
+
if (
|
|
703
|
+
key === BROKER_RUNTIME_ABORT_CAPABILITY_FIELD ||
|
|
704
|
+
key === BROKER_RUNTIME_CLOSE_CAPABILITY_FIELD ||
|
|
705
|
+
key === "token" ||
|
|
706
|
+
key === "url" ||
|
|
707
|
+
(key === "endpoint" && nested !== null && typeof nested === "object")
|
|
708
|
+
)
|
|
389
709
|
continue;
|
|
390
710
|
output[key] = credentialFreeLifecycleResponse(nested);
|
|
391
711
|
}
|
|
@@ -456,11 +776,98 @@ function sameEndpointRecord(expected: IndexedSession, current: IndexedSession):
|
|
|
456
776
|
current.endpointGeneration === expected.endpointGeneration &&
|
|
457
777
|
current.pid === expected.pid &&
|
|
458
778
|
current.endpointMtimeMs === expected.endpointMtimeMs &&
|
|
459
|
-
|
|
779
|
+
(expected.endpointFileId === undefined || current.endpointFileId === expected.endpointFileId) &&
|
|
780
|
+
(expected.processIncarnation === undefined || current.processIncarnation === expected.processIncarnation) &&
|
|
781
|
+
(expected.hostIncarnation === undefined || current.hostIncarnation === expected.hostIncarnation) &&
|
|
782
|
+
path.resolve(current.locator.cwd) === path.resolve(expected.locator.cwd) &&
|
|
460
783
|
path.resolve(current.locator.stateRoot) === path.resolve(expected.locator.stateRoot)
|
|
461
784
|
);
|
|
462
785
|
}
|
|
463
786
|
|
|
787
|
+
const BROKER_SESSION_CONTROL_FIELDS = new Set(["sessionId", "operation", "input", "confirm"]);
|
|
788
|
+
const BROKER_SESSION_CONTROL_ABORT_FIELDS = new Set(["mode", "scope", "operator"]);
|
|
789
|
+
const BROKER_SESSION_CONTROL_TIMEOUT_MS = 10_000;
|
|
790
|
+
|
|
791
|
+
type BrokerSessionControlRequest = {
|
|
792
|
+
sessionId: string;
|
|
793
|
+
abortInput: { mode: "terminal"; scope: "turn" | "owned"; operator: true };
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
type BrokerSessionControlAuthority = {
|
|
797
|
+
record: IndexedSession;
|
|
798
|
+
endpoint: Record<string, unknown>;
|
|
799
|
+
endpointIdentity: {
|
|
800
|
+
dev: bigint;
|
|
801
|
+
ino: bigint;
|
|
802
|
+
size: bigint;
|
|
803
|
+
mtimeNs: bigint;
|
|
804
|
+
ctimeNs: bigint;
|
|
805
|
+
};
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
function brokerSessionControlRequest(input: unknown): BrokerSessionControlRequest | BrokerResponse {
|
|
809
|
+
const frame = objectRecord(input);
|
|
810
|
+
if (!frame) return error("invalid_input", "session.control input must be an object");
|
|
811
|
+
for (const key of Object.keys(frame))
|
|
812
|
+
if (!BROKER_SESSION_CONTROL_FIELDS.has(key))
|
|
813
|
+
return error("invalid_input", `Unknown session.control field: ${key}`);
|
|
814
|
+
if (typeof frame.sessionId !== "string" || !isCanonicalSessionId(frame.sessionId))
|
|
815
|
+
return error("invalid_input", "sessionId must be a canonical safe identifier");
|
|
816
|
+
if (frame.operation !== "turn.abort") return error("invalid_input", "session.control only supports turn.abort");
|
|
817
|
+
if (frame.confirm !== true) return error("invalid_input", "session.control requires confirm:true");
|
|
818
|
+
const abortInput = objectRecord(frame.input);
|
|
819
|
+
if (!abortInput) return error("invalid_input", "session.control input must be an object");
|
|
820
|
+
for (const key of Object.keys(abortInput))
|
|
821
|
+
if (!BROKER_SESSION_CONTROL_ABORT_FIELDS.has(key))
|
|
822
|
+
return error("invalid_input", `Unknown turn.abort terminal field: ${key}`);
|
|
823
|
+
if (abortInput.mode !== "terminal") return error("invalid_input", 'turn.abort mode must be "terminal"');
|
|
824
|
+
const scope = abortInput.scope === undefined ? "turn" : abortInput.scope;
|
|
825
|
+
if (scope !== "turn" && scope !== "owned")
|
|
826
|
+
return error("invalid_input", 'turn.abort scope must be "turn" or "owned"');
|
|
827
|
+
if (abortInput.operator !== true) return error("invalid_input", "session.control requires operator:true");
|
|
828
|
+
return {
|
|
829
|
+
sessionId: frame.sessionId,
|
|
830
|
+
abortInput: { mode: "terminal", scope, operator: true },
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function brokerControlResponse(value: unknown): BrokerResponse {
|
|
835
|
+
const response = objectRecord(value);
|
|
836
|
+
if (!response || typeof response.ok !== "boolean")
|
|
837
|
+
return error("unavailable", "session endpoint returned an invalid control response");
|
|
838
|
+
if (response.ok)
|
|
839
|
+
return response.result === undefined
|
|
840
|
+
? { ok: true }
|
|
841
|
+
: { ok: true, result: credentialFreeLifecycleResponse(response.result) };
|
|
842
|
+
const failure = objectRecord(response.error);
|
|
843
|
+
const code = typeof failure?.code === "string" ? failure.code : "unavailable";
|
|
844
|
+
const message = typeof failure?.message === "string" ? failure.message : "session endpoint control failed";
|
|
845
|
+
return { ok: false, error: { code, message } };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function sameSessionControlAuthority(
|
|
849
|
+
left: BrokerSessionControlAuthority,
|
|
850
|
+
right: BrokerSessionControlAuthority,
|
|
851
|
+
): boolean {
|
|
852
|
+
return (
|
|
853
|
+
sameEndpointRecord(left.record, right.record) &&
|
|
854
|
+
left.record.processIncarnation === right.record.processIncarnation &&
|
|
855
|
+
left.record.hostIncarnation === right.record.hostIncarnation &&
|
|
856
|
+
left.record.lifecycleRequestId === right.record.lifecycleRequestId &&
|
|
857
|
+
endpointIncarnation(left.record, left.record.sessionId) ===
|
|
858
|
+
endpointIncarnation(right.record, right.record.sessionId) &&
|
|
859
|
+
left.endpoint.sessionId === right.endpoint.sessionId &&
|
|
860
|
+
left.endpoint.pid === right.endpoint.pid &&
|
|
861
|
+
left.endpoint.url === right.endpoint.url &&
|
|
862
|
+
left.endpoint.token === right.endpoint.token &&
|
|
863
|
+
left.endpointIdentity.dev === right.endpointIdentity.dev &&
|
|
864
|
+
left.endpointIdentity.ino === right.endpointIdentity.ino &&
|
|
865
|
+
left.endpointIdentity.size === right.endpointIdentity.size &&
|
|
866
|
+
left.endpointIdentity.mtimeNs === right.endpointIdentity.mtimeNs &&
|
|
867
|
+
left.endpointIdentity.ctimeNs === right.endpointIdentity.ctimeNs
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
|
|
464
871
|
function lifecycleTarget(operation: string, input: Record<string, unknown>): unknown {
|
|
465
872
|
const target = input.target as Record<string, unknown> | undefined;
|
|
466
873
|
const string = (...values: unknown[]): string | undefined =>
|
|
@@ -848,6 +1255,11 @@ export class Broker {
|
|
|
848
1255
|
#owner = randomBytes(12).toString("hex");
|
|
849
1256
|
#sessionListCursors = new Map<string, SessionListCursor>();
|
|
850
1257
|
#chains = new Map<string, Promise<void>>();
|
|
1258
|
+
#spawnInFlight = new Map<string, SpawnInFlight>();
|
|
1259
|
+
#spawnTasks = new WeakMap<SpawnInFlight, string>();
|
|
1260
|
+
#spawnAuthority: SpawnAuthorityStore | null = null;
|
|
1261
|
+
#spawnPromptLayer: SpawnPromptLayer;
|
|
1262
|
+
#spawnReapInFlight = false;
|
|
851
1263
|
#admitted = new Set<Promise<void>>();
|
|
852
1264
|
#startupAdmissions = new StartupAdmissionQueue(sdkHostStartupConcurrency());
|
|
853
1265
|
#publication: RetainedBrokerDiscovery | null = null;
|
|
@@ -863,7 +1275,8 @@ export class Broker {
|
|
|
863
1275
|
#completion!: Promise<void>;
|
|
864
1276
|
#resolveCompletion!: () => void;
|
|
865
1277
|
#rejectCompletion!: (error: unknown) => void;
|
|
866
|
-
|
|
1278
|
+
#resolveModelPin: SdkHostModelResolver;
|
|
1279
|
+
#ownsResolveModelPin: boolean;
|
|
867
1280
|
constructor(settings: BrokerSettings) {
|
|
868
1281
|
this.settings = {
|
|
869
1282
|
agentDir: settings.agentDir,
|
|
@@ -871,16 +1284,1031 @@ export class Broker {
|
|
|
871
1284
|
port: settings.port ?? 0,
|
|
872
1285
|
heartbeatTtlMs: settings.heartbeatTtlMs ?? BROKER_HEARTBEAT_TTL_MS,
|
|
873
1286
|
resolveDirectoryMigration: settings.resolveDirectoryMigration ?? (async () => "copy-retain"),
|
|
1287
|
+
masterCapabilityVerifier: settings.masterCapabilityVerifier,
|
|
1288
|
+
spawnSubstrateProvider: settings.spawnSubstrateProvider,
|
|
1289
|
+
spawnPromptLayer: settings.spawnPromptLayer,
|
|
1290
|
+
masterOrphanGraceMs: settings.masterOrphanGraceMs ?? MASTER_ORPHAN_GRACE_DEFAULT_MS,
|
|
874
1291
|
};
|
|
875
1292
|
this.index = new SessionIndex(settings.agentDir);
|
|
876
1293
|
this.ledger = new LifecycleLedger(settings.agentDir);
|
|
877
|
-
this.#
|
|
1294
|
+
this.#ownsResolveModelPin = settings.resolveModelPin === undefined;
|
|
1295
|
+
this.#resolveModelPin = settings.resolveModelPin ?? createDefaultSdkHostModelResolver(this.settings.agentDir);
|
|
1296
|
+
if (!this.settings.masterCapabilityVerifier)
|
|
1297
|
+
this.settings.masterCapabilityVerifier = createMasterCapabilityVerifier(this.index);
|
|
1298
|
+
this.#spawnPromptLayer = settings.spawnPromptLayer ?? {
|
|
1299
|
+
awaitRegistration: async input => await this.#awaitSpawnHostRegistration(input),
|
|
1300
|
+
dispatch: async input => await this.#dispatchSpawnPrompt(input),
|
|
1301
|
+
reconcile: async input => await this.#reconcileSpawnPrompt(input),
|
|
1302
|
+
};
|
|
878
1303
|
this.#lock = path.join(settings.agentDir, "sdk", "broker.lock");
|
|
879
1304
|
const completion = Promise.withResolvers<void>();
|
|
880
1305
|
this.#completion = completion.promise;
|
|
881
1306
|
this.#resolveCompletion = completion.resolve;
|
|
882
1307
|
this.#rejectCompletion = completion.reject;
|
|
883
1308
|
}
|
|
1309
|
+
/** Host capability validation is live-only; no request material is retained. */
|
|
1310
|
+
async verifyMasterCapability(
|
|
1311
|
+
ownerSessionId: string,
|
|
1312
|
+
rawCapability: string,
|
|
1313
|
+
attestationEpoch: string,
|
|
1314
|
+
): Promise<{ allowed: boolean }> {
|
|
1315
|
+
const verifier = this.settings.masterCapabilityVerifier;
|
|
1316
|
+
return verifier
|
|
1317
|
+
? await verifier.verifyMasterCapability(ownerSessionId, rawCapability, attestationEpoch)
|
|
1318
|
+
: { allowed: false };
|
|
1319
|
+
}
|
|
1320
|
+
async #handleSpawn(input: Record<string, unknown>, idempotencyKey: string | undefined): Promise<BrokerResponse> {
|
|
1321
|
+
const admission = parseSpawnInput(input, idempotencyKey);
|
|
1322
|
+
if (isBrokerResponse(admission)) return admission;
|
|
1323
|
+
const lifecycleIdentity = await deriveIdempotencyIdentity(
|
|
1324
|
+
this.settings.agentDir,
|
|
1325
|
+
"session.spawn",
|
|
1326
|
+
idempotencyKey!,
|
|
1327
|
+
);
|
|
1328
|
+
const active = this.#spawnInFlight.get(lifecycleIdentity);
|
|
1329
|
+
if (active) {
|
|
1330
|
+
if (this.#spawnTasks.get(active) !== admission.task)
|
|
1331
|
+
return error("idempotency_conflict", "idempotency key conflicts with a live session.spawn request");
|
|
1332
|
+
return error("spawn_in_progress", `session.spawn is ${active.phase ?? "prepared"}`);
|
|
1333
|
+
}
|
|
1334
|
+
const completion = Promise.withResolvers<BrokerResponse>();
|
|
1335
|
+
const inFlight: SpawnInFlight = { completion: completion.promise, resolve: completion.resolve };
|
|
1336
|
+
// Installed before verification or a durable mutation. Task ownership is a
|
|
1337
|
+
// weak in-memory association and cannot reach durable generic code.
|
|
1338
|
+
this.#spawnInFlight.set(lifecycleIdentity, inFlight);
|
|
1339
|
+
this.#spawnTasks.set(inFlight, admission.task);
|
|
1340
|
+
let response: BrokerResponse | undefined;
|
|
1341
|
+
let becameOwner = false;
|
|
1342
|
+
const finish = (result: BrokerResponse): BrokerResponse => {
|
|
1343
|
+
response = result;
|
|
1344
|
+
return result;
|
|
1345
|
+
};
|
|
1346
|
+
try {
|
|
1347
|
+
let verified: { allowed: boolean };
|
|
1348
|
+
try {
|
|
1349
|
+
verified = await this.verifyMasterCapability(
|
|
1350
|
+
admission.ownerSessionId,
|
|
1351
|
+
admission.masterCapability,
|
|
1352
|
+
admission.attestationEpoch,
|
|
1353
|
+
);
|
|
1354
|
+
} finally {
|
|
1355
|
+
admission.masterCapability = "";
|
|
1356
|
+
}
|
|
1357
|
+
if (!verified.allowed) return finish(error("spawn_failed", "master capability verification was denied"));
|
|
1358
|
+
const key = await getBrokerIdentityKey(this.settings.agentDir);
|
|
1359
|
+
const authority = this.#spawnAuthority;
|
|
1360
|
+
if (!authority) return finish(error("unavailable", "spawn authority is unavailable"));
|
|
1361
|
+
const requestBindingMac = spawnBindingMac(
|
|
1362
|
+
key,
|
|
1363
|
+
admission,
|
|
1364
|
+
admission.modelId ?? null,
|
|
1365
|
+
admission.modelPreset ?? null,
|
|
1366
|
+
);
|
|
1367
|
+
const existing = authority.claim(lifecycleIdentity);
|
|
1368
|
+
const resolveModels = async (): Promise<BrokerResponse | undefined> => {
|
|
1369
|
+
// Validate the optional profile BEFORE any new durable claim or substrate
|
|
1370
|
+
// effect. Generic lifecycle create validates it; spawn must do the same.
|
|
1371
|
+
if (admission.modelPreset !== undefined) {
|
|
1372
|
+
const validated = validateBrokerModelPresetSync(this.settings.agentDir, admission.modelPreset);
|
|
1373
|
+
if (isBrokerResponse(validated)) return validated;
|
|
1374
|
+
admission.modelPreset = validated;
|
|
1375
|
+
}
|
|
1376
|
+
// Resolve the explicit model pin before a new durable claim. Raw
|
|
1377
|
+
// selectors must never reach a newly launched child.
|
|
1378
|
+
if (admission.modelId !== undefined) {
|
|
1379
|
+
const resolved = await this.#resolveModelPin(admission.modelId, { cwd: admission.cwd });
|
|
1380
|
+
if (!resolved.ok) return error("unknown_model", resolved.error);
|
|
1381
|
+
if (resolved.model === null)
|
|
1382
|
+
return error("unknown_model", "session.spawn modelId could not be resolved.");
|
|
1383
|
+
admission.modelId = resolved.model;
|
|
1384
|
+
}
|
|
1385
|
+
return undefined;
|
|
1386
|
+
};
|
|
1387
|
+
let decision: SpawnClaimDecision;
|
|
1388
|
+
if (existing !== undefined) {
|
|
1389
|
+
// An existing claim carries the raw-selector binding, so terminal and
|
|
1390
|
+
// replay responses do not depend on today's model/profile catalog.
|
|
1391
|
+
let existingBindingMac: string | undefined;
|
|
1392
|
+
if (existing.requestBindingMac === undefined) {
|
|
1393
|
+
// Claims written before raw-selector evidence was introduced only have
|
|
1394
|
+
// the canonical binding. Re-resolve that historical selector before
|
|
1395
|
+
// comparison; if the catalog no longer proves it, fail closed rather
|
|
1396
|
+
// than guessing or weakening idempotency conflict protection.
|
|
1397
|
+
const modelError = await resolveModels();
|
|
1398
|
+
if (modelError) return finish(modelError);
|
|
1399
|
+
existingBindingMac = spawnBindingMac(
|
|
1400
|
+
key,
|
|
1401
|
+
admission,
|
|
1402
|
+
admission.modelId ?? null,
|
|
1403
|
+
admission.modelPreset ?? null,
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
decision = await authority.claimOrJoin(
|
|
1407
|
+
lifecycleIdentity,
|
|
1408
|
+
existingBindingMac,
|
|
1409
|
+
existingBindingMac ?? requestBindingMac,
|
|
1410
|
+
);
|
|
1411
|
+
if (decision.kind === "owner") {
|
|
1412
|
+
becameOwner = true;
|
|
1413
|
+
// A recovered pre-send claim may still need to launch. Preserve the
|
|
1414
|
+
// fail-closed model validation for that effectful path and reject a
|
|
1415
|
+
// catalog remap rather than launching a different model.
|
|
1416
|
+
const modelError = await resolveModels();
|
|
1417
|
+
if (modelError) return finish(modelError);
|
|
1418
|
+
const recoveredBindingMac = spawnBindingMac(
|
|
1419
|
+
key,
|
|
1420
|
+
admission,
|
|
1421
|
+
admission.modelId ?? null,
|
|
1422
|
+
admission.modelPreset ?? null,
|
|
1423
|
+
);
|
|
1424
|
+
if (recoveredBindingMac !== decision.claim.bindingMac)
|
|
1425
|
+
return finish(
|
|
1426
|
+
error("idempotency_conflict", "session.spawn model selection differs from its durable claim"),
|
|
1427
|
+
);
|
|
1428
|
+
}
|
|
1429
|
+
} else {
|
|
1430
|
+
const modelError = await resolveModels();
|
|
1431
|
+
if (modelError) {
|
|
1432
|
+
// A concurrent claimant may have durably established this exact raw
|
|
1433
|
+
// request while catalog loading was in flight. Re-check its opaque
|
|
1434
|
+
// binding before returning the validation failure.
|
|
1435
|
+
const raced = authority.claim(lifecycleIdentity);
|
|
1436
|
+
if (raced === undefined) return finish(modelError);
|
|
1437
|
+
decision = await authority.claimOrJoin(lifecycleIdentity, undefined, requestBindingMac);
|
|
1438
|
+
if (decision.kind === "owner") {
|
|
1439
|
+
becameOwner = true;
|
|
1440
|
+
return finish(modelError);
|
|
1441
|
+
}
|
|
1442
|
+
} else {
|
|
1443
|
+
const bindingMac = spawnBindingMac(
|
|
1444
|
+
key,
|
|
1445
|
+
admission,
|
|
1446
|
+
admission.modelId ?? null,
|
|
1447
|
+
admission.modelPreset ?? null,
|
|
1448
|
+
);
|
|
1449
|
+
decision = await authority.claimOrJoin(lifecycleIdentity, bindingMac, requestBindingMac);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
if (decision.kind === "idempotency_conflict")
|
|
1453
|
+
return finish(
|
|
1454
|
+
error("idempotency_conflict", "idempotency key conflicts with an existing session.spawn claim"),
|
|
1455
|
+
);
|
|
1456
|
+
if (decision.kind === "in_progress")
|
|
1457
|
+
return finish(error("spawn_in_progress", `session.spawn is ${decision.claim.state}`));
|
|
1458
|
+
if (decision.kind === "terminal_uncertain")
|
|
1459
|
+
return finish(error("terminal_uncertain", "session.spawn outcome is uncertain"));
|
|
1460
|
+
if (decision.kind === "terminal") return finish(this.#spawnTerminalResponse(decision.claim));
|
|
1461
|
+
if (decision.kind === "replay")
|
|
1462
|
+
return finish(await this.#reconcileSpawnReplay(lifecycleIdentity, decision.claim));
|
|
1463
|
+
becameOwner = true;
|
|
1464
|
+
inFlight.claimId = decision.claim.claimId;
|
|
1465
|
+
inFlight.phase = decision.claim.state;
|
|
1466
|
+
// Claim fsync precedes this audit mirror. Substrate work may start
|
|
1467
|
+
// only after the mirror succeeds; a startup reconciler rebuilds it claim-first.
|
|
1468
|
+
await this.ledger.begin(lifecycleIdentity, decision.claim.bindingMac);
|
|
1469
|
+
return finish(
|
|
1470
|
+
await this.#driveSpawn(lifecycleIdentity, decision.claim, admission, inFlight, decision.recovery),
|
|
1471
|
+
);
|
|
1472
|
+
} catch {
|
|
1473
|
+
return finish(error("spawn_failed", "session.spawn admission could not be durably established"));
|
|
1474
|
+
} finally {
|
|
1475
|
+
completion.resolve(response ?? error("spawn_failed", "session.spawn admission failed"));
|
|
1476
|
+
this.#spawnTasks.delete(inFlight);
|
|
1477
|
+
this.#spawnInFlight.delete(lifecycleIdentity);
|
|
1478
|
+
if (becameOwner) await this.#spawnAuthority?.releaseOwner(lifecycleIdentity);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/** Safe, allowlisted spawn result projection; never carries request material. */
|
|
1483
|
+
#spawnResult(code: "spawn_accepted" | "spawn_replayed", claim: SpawnClaimV2): Record<string, unknown> {
|
|
1484
|
+
const authorityRecord = this.#spawnAuthority?.authority(claim.lifecycleIdentity);
|
|
1485
|
+
const seed = claim.seed;
|
|
1486
|
+
return {
|
|
1487
|
+
code,
|
|
1488
|
+
claimId: claim.claimId,
|
|
1489
|
+
...(claim.childId === undefined ? {} : { sessionId: claim.childId }),
|
|
1490
|
+
...(authorityRecord === undefined ? {} : { substrateKind: authorityRecord.substrateKind }),
|
|
1491
|
+
...(seed === undefined
|
|
1492
|
+
? {}
|
|
1493
|
+
: {
|
|
1494
|
+
seed: {
|
|
1495
|
+
phase: seed.phase,
|
|
1496
|
+
clientRef: seed.clientRef,
|
|
1497
|
+
...(seed.commandId === undefined ? {} : { commandId: seed.commandId }),
|
|
1498
|
+
...(seed.turnId === undefined ? {} : { turnId: seed.turnId }),
|
|
1499
|
+
...(seed.lastQ26Status === undefined ? {} : { status: seed.lastQ26Status }),
|
|
1500
|
+
},
|
|
1501
|
+
}),
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
#spawnTerminalResponse(claim: SpawnClaimV2): BrokerResponse {
|
|
1506
|
+
if (claim.state === "accepted") return { ok: true, result: this.#spawnResult("spawn_replayed", claim) };
|
|
1507
|
+
if (claim.state === "pre_send_rejected")
|
|
1508
|
+
return claim.failure
|
|
1509
|
+
? spawnFailureError(claim.failure)
|
|
1510
|
+
: error("spawn_failed", "session.spawn was rejected before seed handoff");
|
|
1511
|
+
return error("resource_gone", "session.spawn claim is closed");
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
#spawnSubstrateProvider(): SpawnSubstrateProvider {
|
|
1515
|
+
if (this.settings.spawnSubstrateProvider) return this.settings.spawnSubstrateProvider;
|
|
1516
|
+
this.settings.spawnSubstrateProvider = createSpawnSubstrateProvider();
|
|
1517
|
+
return this.settings.spawnSubstrateProvider;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/**
|
|
1521
|
+
* Drives one exclusively owned claim through the durable spawn state machine.
|
|
1522
|
+
* Every effect is fenced by a prior fsynced transition: substrate_starting
|
|
1523
|
+
* precedes launch, dispatching (lease consumption) precedes the prompt frame,
|
|
1524
|
+
* and accepted precedes the success response.
|
|
1525
|
+
*/
|
|
1526
|
+
async #driveSpawn(
|
|
1527
|
+
lifecycleIdentity: string,
|
|
1528
|
+
claim: SpawnClaimV2,
|
|
1529
|
+
admission: SpawnAdmissionInput,
|
|
1530
|
+
inFlight: SpawnInFlight,
|
|
1531
|
+
recovered = false,
|
|
1532
|
+
): Promise<BrokerResponse> {
|
|
1533
|
+
const store = this.#spawnAuthority;
|
|
1534
|
+
if (!store) return error("unavailable", "spawn authority is unavailable");
|
|
1535
|
+
const provider = this.#spawnSubstrateProvider();
|
|
1536
|
+
let current = claim;
|
|
1537
|
+
let handedOff = false;
|
|
1538
|
+
// Set the moment a substrate exists. After this point a failure is
|
|
1539
|
+
// ambiguous, never an ordinary pre-effect failure.
|
|
1540
|
+
let launchedProof: SpawnSubstrateProof | undefined;
|
|
1541
|
+
let pinnedRegistration: SpawnHostRegistration | undefined;
|
|
1542
|
+
try {
|
|
1543
|
+
if (current.state === "prepared") {
|
|
1544
|
+
const prep = prepareSpawnChildHostLaunch(this, {
|
|
1545
|
+
cwd: admission.cwd,
|
|
1546
|
+
...(admission.modelId === undefined ? {} : { modelId: admission.modelId }),
|
|
1547
|
+
...(admission.modelPreset === undefined ? {} : { modelPreset: admission.modelPreset }),
|
|
1548
|
+
});
|
|
1549
|
+
current = (
|
|
1550
|
+
await store.persistTransition(lifecycleIdentity, {
|
|
1551
|
+
claimId: current.claimId,
|
|
1552
|
+
from: "prepared",
|
|
1553
|
+
to: "substrate_starting",
|
|
1554
|
+
childId: prep.childId,
|
|
1555
|
+
})
|
|
1556
|
+
).claim;
|
|
1557
|
+
inFlight.phase = current.state;
|
|
1558
|
+
const launched = await provider.launch({
|
|
1559
|
+
childSessionId: prep.childId,
|
|
1560
|
+
cwd: prep.cwd,
|
|
1561
|
+
argv: prep.argv,
|
|
1562
|
+
inheritedEnv: prep.inheritedEnv,
|
|
1563
|
+
env: prep.env,
|
|
1564
|
+
});
|
|
1565
|
+
if (launched.ok) launchedProof = launched.proof;
|
|
1566
|
+
if (!launched.ok) {
|
|
1567
|
+
const failure: SpawnSubstrateFailure = {
|
|
1568
|
+
substrateKind: launched.code === "substrate_unavailable" ? "headless" : "tmux",
|
|
1569
|
+
code: launched.code,
|
|
1570
|
+
message: launched.message,
|
|
1571
|
+
};
|
|
1572
|
+
current = (
|
|
1573
|
+
await store.persistTransition(lifecycleIdentity, {
|
|
1574
|
+
claimId: current.claimId,
|
|
1575
|
+
from: "substrate_starting",
|
|
1576
|
+
to: "pre_send_rejected",
|
|
1577
|
+
failure,
|
|
1578
|
+
})
|
|
1579
|
+
).claim;
|
|
1580
|
+
return spawnFailureError(failure);
|
|
1581
|
+
}
|
|
1582
|
+
const registration = await this.#spawnPromptLayer.awaitRegistration({
|
|
1583
|
+
childId: prep.childId,
|
|
1584
|
+
cwd: prep.cwd,
|
|
1585
|
+
stateRoot: prep.stateRoot,
|
|
1586
|
+
});
|
|
1587
|
+
if (!registration.ok) {
|
|
1588
|
+
await this.#releaseUnownedSubstrate(provider, launchedProof);
|
|
1589
|
+
launchedProof = undefined;
|
|
1590
|
+
current = (
|
|
1591
|
+
await store.persistTransition(lifecycleIdentity, {
|
|
1592
|
+
claimId: current.claimId,
|
|
1593
|
+
from: "substrate_starting",
|
|
1594
|
+
to: "uncertain",
|
|
1595
|
+
})
|
|
1596
|
+
).claim;
|
|
1597
|
+
return error("terminal_uncertain", "session.spawn child registration is uncertain");
|
|
1598
|
+
}
|
|
1599
|
+
pinnedRegistration = registration.registration;
|
|
1600
|
+
const now = Date.now();
|
|
1601
|
+
const proof = launched.proof;
|
|
1602
|
+
const authorityRecord: SpawnAuthorityV1 = {
|
|
1603
|
+
version: 1,
|
|
1604
|
+
authorityId: randomBytes(24).toString("base64url"),
|
|
1605
|
+
claimId: current.claimId,
|
|
1606
|
+
childId: prep.childId,
|
|
1607
|
+
ownerSessionId: admission.ownerSessionId,
|
|
1608
|
+
lifecycleIdentity,
|
|
1609
|
+
substrateKind: proof.substrateKind,
|
|
1610
|
+
providerIdentity: proof.providerIdentity,
|
|
1611
|
+
...(proof.nativeSessionId === undefined ? {} : { nativeSessionId: proof.nativeSessionId }),
|
|
1612
|
+
...(proof.pid === undefined ? {} : { pid: proof.pid }),
|
|
1613
|
+
...(proof.processIncarnation === undefined ? {} : { processIncarnation: proof.processIncarnation }),
|
|
1614
|
+
...(proof.ownerGeneration === undefined ? {} : { ownerGeneration: proof.ownerGeneration }),
|
|
1615
|
+
...(proof.stateFileProof === undefined ? {} : { stateFileProof: proof.stateFileProof }),
|
|
1616
|
+
endpointGeneration: registration.registration.endpointGeneration,
|
|
1617
|
+
endpointPid: registration.registration.pid,
|
|
1618
|
+
endpointIncarnation: registration.registration.processIncarnation,
|
|
1619
|
+
endpointCwd: registration.registration.cwd,
|
|
1620
|
+
endpointStateRoot: registration.registration.stateRoot,
|
|
1621
|
+
closeState: "active",
|
|
1622
|
+
createdAt: now,
|
|
1623
|
+
updatedAt: now,
|
|
1624
|
+
};
|
|
1625
|
+
current = (
|
|
1626
|
+
await store.persistTransition(lifecycleIdentity, {
|
|
1627
|
+
claimId: current.claimId,
|
|
1628
|
+
from: "substrate_starting",
|
|
1629
|
+
to: "authority_active",
|
|
1630
|
+
childId: prep.childId,
|
|
1631
|
+
authority: authorityRecord,
|
|
1632
|
+
})
|
|
1633
|
+
).claim;
|
|
1634
|
+
inFlight.phase = current.state;
|
|
1635
|
+
}
|
|
1636
|
+
if (current.state === "authority_active") {
|
|
1637
|
+
const seed: SeedDeliveryV2 = {
|
|
1638
|
+
version: 2,
|
|
1639
|
+
phase: "prepared",
|
|
1640
|
+
clientRef: randomBytes(24).toString("base64url"),
|
|
1641
|
+
};
|
|
1642
|
+
current = (
|
|
1643
|
+
await store.persistTransition(lifecycleIdentity, {
|
|
1644
|
+
claimId: current.claimId,
|
|
1645
|
+
from: "authority_active",
|
|
1646
|
+
to: "seed_prepared",
|
|
1647
|
+
seed,
|
|
1648
|
+
})
|
|
1649
|
+
).claim;
|
|
1650
|
+
inFlight.phase = current.state;
|
|
1651
|
+
}
|
|
1652
|
+
if (
|
|
1653
|
+
current.state !== "seed_prepared" ||
|
|
1654
|
+
current.preSendLease?.status !== "owned" ||
|
|
1655
|
+
!current.seed ||
|
|
1656
|
+
!current.childId
|
|
1657
|
+
)
|
|
1658
|
+
return error("terminal_uncertain", "session.spawn cannot proceed from its durable state");
|
|
1659
|
+
// A recovery owner resumes a claim whose substrate was launched by an
|
|
1660
|
+
// earlier process. Re-prove that exact substrate before handing off the
|
|
1661
|
+
// seed: a replaced or vanished substrate must never receive the prompt.
|
|
1662
|
+
if (recovered) {
|
|
1663
|
+
const recoveredAuthority = store.authority(lifecycleIdentity);
|
|
1664
|
+
if (!recoveredAuthority)
|
|
1665
|
+
return error("terminal_uncertain", "session.spawn authority is unavailable after restart");
|
|
1666
|
+
// A recovery owner has no in-process pin, so it must come from durable
|
|
1667
|
+
// authority. An absent pin is missing evidence, not permission to
|
|
1668
|
+
// match on session id alone.
|
|
1669
|
+
pinnedRegistration = spawnPinFromAuthority(recoveredAuthority);
|
|
1670
|
+
let verdict: "verified" | "mismatch" | "gone";
|
|
1671
|
+
try {
|
|
1672
|
+
verdict =
|
|
1673
|
+
pinnedRegistration === undefined
|
|
1674
|
+
? "gone"
|
|
1675
|
+
: await provider.verify(spawnProofFromAuthority(recoveredAuthority));
|
|
1676
|
+
} catch {
|
|
1677
|
+
verdict = "gone";
|
|
1678
|
+
}
|
|
1679
|
+
if (verdict !== "verified") {
|
|
1680
|
+
current = (
|
|
1681
|
+
await store.persistTransition(lifecycleIdentity, {
|
|
1682
|
+
claimId: current.claimId,
|
|
1683
|
+
from: current.state,
|
|
1684
|
+
to: "uncertain",
|
|
1685
|
+
seed: { ...current.seed, phase: "uncertain" },
|
|
1686
|
+
})
|
|
1687
|
+
).claim;
|
|
1688
|
+
inFlight.phase = current.state;
|
|
1689
|
+
return error("terminal_uncertain", "session.spawn substrate could not be re-proven after restart");
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
const childId = current.childId;
|
|
1693
|
+
const preparedSeed = current.seed;
|
|
1694
|
+
// Dispatch without a complete pin must never consume the pre-send lease.
|
|
1695
|
+
if (pinnedRegistration === undefined)
|
|
1696
|
+
return error("terminal_uncertain", "session.spawn endpoint pin is unavailable before dispatch");
|
|
1697
|
+
current = (
|
|
1698
|
+
await store.persistTransition(lifecycleIdentity, {
|
|
1699
|
+
claimId: current.claimId,
|
|
1700
|
+
from: "seed_prepared",
|
|
1701
|
+
to: "dispatching",
|
|
1702
|
+
leaseEpoch: current.preSendLease.epoch,
|
|
1703
|
+
seed: { ...preparedSeed, phase: "dispatching" },
|
|
1704
|
+
})
|
|
1705
|
+
).claim;
|
|
1706
|
+
inFlight.phase = current.state;
|
|
1707
|
+
handedOff = true;
|
|
1708
|
+
const dispatched = await this.#spawnPromptLayer.dispatch({
|
|
1709
|
+
sessionId: childId,
|
|
1710
|
+
task: admission.task,
|
|
1711
|
+
clientRef: preparedSeed.clientRef,
|
|
1712
|
+
pinned: pinnedRegistration,
|
|
1713
|
+
});
|
|
1714
|
+
if (dispatched.kind === "accepted") {
|
|
1715
|
+
current = (
|
|
1716
|
+
await store.persistTransition(lifecycleIdentity, {
|
|
1717
|
+
claimId: current.claimId,
|
|
1718
|
+
from: "dispatching",
|
|
1719
|
+
to: "accepted",
|
|
1720
|
+
seed: {
|
|
1721
|
+
...preparedSeed,
|
|
1722
|
+
phase: "accepted",
|
|
1723
|
+
commandId: dispatched.commandId,
|
|
1724
|
+
turnId: dispatched.turnId,
|
|
1725
|
+
acceptedAt: dispatched.acceptedAt,
|
|
1726
|
+
lastQ26Status: "accepted",
|
|
1727
|
+
observedAt: Date.now(),
|
|
1728
|
+
},
|
|
1729
|
+
})
|
|
1730
|
+
).claim;
|
|
1731
|
+
inFlight.phase = current.state;
|
|
1732
|
+
return { ok: true, result: this.#spawnResult("spawn_accepted", current) };
|
|
1733
|
+
}
|
|
1734
|
+
if (dispatched.kind === "pre_send_rejected") {
|
|
1735
|
+
current = (
|
|
1736
|
+
await store.persistTransition(lifecycleIdentity, {
|
|
1737
|
+
claimId: current.claimId,
|
|
1738
|
+
from: "dispatching",
|
|
1739
|
+
to: "pre_send_rejected",
|
|
1740
|
+
provenNoHandoff: true,
|
|
1741
|
+
seed: { ...preparedSeed, phase: "pre_send_rejected" },
|
|
1742
|
+
})
|
|
1743
|
+
).claim;
|
|
1744
|
+
inFlight.phase = current.state;
|
|
1745
|
+
await this.#closeSpawnSubstrate(lifecycleIdentity);
|
|
1746
|
+
return error("spawn_failed", "session.spawn seed delivery was rejected before handoff");
|
|
1747
|
+
}
|
|
1748
|
+
current = (
|
|
1749
|
+
await store.persistTransition(lifecycleIdentity, {
|
|
1750
|
+
claimId: current.claimId,
|
|
1751
|
+
from: "dispatching",
|
|
1752
|
+
to: "uncertain",
|
|
1753
|
+
seed: { ...preparedSeed, phase: "uncertain" },
|
|
1754
|
+
})
|
|
1755
|
+
).claim;
|
|
1756
|
+
inFlight.phase = current.state;
|
|
1757
|
+
return error("terminal_uncertain", "session.spawn seed delivery outcome is uncertain");
|
|
1758
|
+
} catch {
|
|
1759
|
+
// Once a substrate exists the outcome is ambiguous even before handoff:
|
|
1760
|
+
// reporting an ordinary failure would downgrade retained uncertainty.
|
|
1761
|
+
const ambiguous = handedOff || launchedProof !== undefined;
|
|
1762
|
+
// A launched substrate with no persisted authority is invisible to the
|
|
1763
|
+
// reaper, so in-process cleanup is its only chance. This must run on
|
|
1764
|
+
// EVERY post-launch failure exit, not just a returned registration
|
|
1765
|
+
// failure: a throw from awaitRegistration, verify, close, or a durable
|
|
1766
|
+
// transition all land here.
|
|
1767
|
+
if (!handedOff) await this.#releaseUnownedSubstrate(provider, launchedProof);
|
|
1768
|
+
return ambiguous
|
|
1769
|
+
? error("terminal_uncertain", "session.spawn state could not be advanced durably")
|
|
1770
|
+
: error("spawn_failed", "session.spawn could not be advanced durably");
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
/**
|
|
1775
|
+
* Closes a substrate that was launched but never got a durable authority row.
|
|
1776
|
+
* The orphan reaper iterates authorities, so such a substrate has no durable
|
|
1777
|
+
* owner and would leak forever. `verify` and `close` are each contained: a
|
|
1778
|
+
* throw or a falsy close result must not skip the remaining work or escape.
|
|
1779
|
+
*/
|
|
1780
|
+
async #releaseUnownedSubstrate(
|
|
1781
|
+
provider: SpawnSubstrateProvider,
|
|
1782
|
+
proof: SpawnSubstrateProof | undefined,
|
|
1783
|
+
): Promise<void> {
|
|
1784
|
+
if (!proof) return;
|
|
1785
|
+
let verdict: "verified" | "mismatch" | "gone";
|
|
1786
|
+
try {
|
|
1787
|
+
verdict = await provider.verify(proof);
|
|
1788
|
+
} catch {
|
|
1789
|
+
// An unprovable substrate is never mutated; uncertainty is retained by
|
|
1790
|
+
// the caller's durable transition instead.
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
if (verdict !== "verified") return;
|
|
1794
|
+
try {
|
|
1795
|
+
await provider.close(proof);
|
|
1796
|
+
} catch {
|
|
1797
|
+
// The substrate may survive. The caller still records uncertainty, which
|
|
1798
|
+
// is the honest durable outcome for an unclosed unowned substrate.
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
/** Best-effort exact-close of a rejected claim's substrate; never name/PID-only. */
|
|
1803
|
+
async #closeSpawnSubstrate(lifecycleIdentity: string): Promise<void> {
|
|
1804
|
+
try {
|
|
1805
|
+
const authorityRecord = this.#spawnAuthority?.authority(lifecycleIdentity);
|
|
1806
|
+
if (authorityRecord?.closeState !== "active") return;
|
|
1807
|
+
const provider = this.#spawnSubstrateProvider();
|
|
1808
|
+
const proof = spawnProofFromAuthority(authorityRecord);
|
|
1809
|
+
if ((await provider.verify(proof)) !== "verified") return;
|
|
1810
|
+
await provider.close(proof);
|
|
1811
|
+
} catch {
|
|
1812
|
+
// Close is reconciled again by the reaper path; failure retains authority.
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
/**
|
|
1817
|
+
* Resolves a joined non-owner claim from stored facts only. dispatching+
|
|
1818
|
+
* replays exclusively through the stored opaque Q26 clientRef; unknown
|
|
1819
|
+
* outcomes stay retained-uncertain and never re-prompt.
|
|
1820
|
+
*/
|
|
1821
|
+
async #reconcileSpawnReplay(lifecycleIdentity: string, claim: SpawnClaimV2): Promise<BrokerResponse> {
|
|
1822
|
+
const store = this.#spawnAuthority;
|
|
1823
|
+
if (!store) return error("unavailable", "spawn authority is unavailable");
|
|
1824
|
+
if (claim.state === "dispatching") {
|
|
1825
|
+
const childId = claim.childId;
|
|
1826
|
+
const seed = claim.seed;
|
|
1827
|
+
if (!childId || !seed) return error("terminal_uncertain", "session.spawn dispatch facts are unavailable");
|
|
1828
|
+
// Q26 replay must talk to the proven endpoint, not any live row with this
|
|
1829
|
+
// session id: a foreign same-id host could otherwise answer and have its
|
|
1830
|
+
// command/turn facts persisted as this claim's acceptance.
|
|
1831
|
+
const replayAuthority = store.authority(lifecycleIdentity);
|
|
1832
|
+
const replayPin = replayAuthority ? spawnPinFromAuthority(replayAuthority) : undefined;
|
|
1833
|
+
if (!replayPin)
|
|
1834
|
+
return error("terminal_uncertain", "session.spawn endpoint authority is unavailable for replay");
|
|
1835
|
+
const q26 = await this.#spawnPromptLayer.reconcile({
|
|
1836
|
+
sessionId: childId,
|
|
1837
|
+
clientRef: seed.clientRef,
|
|
1838
|
+
pinned: replayPin,
|
|
1839
|
+
});
|
|
1840
|
+
// A present-but-unequal clientRef means the responder answered for another
|
|
1841
|
+
// correlation; binding its facts would falsify this claim's acceptance.
|
|
1842
|
+
const refMismatch = q26.clientRef !== undefined && q26.clientRef !== seed.clientRef;
|
|
1843
|
+
if (
|
|
1844
|
+
!refMismatch &&
|
|
1845
|
+
(q26.status === "accepted" || q26.status === "in_flight" || q26.status === "terminal_ok") &&
|
|
1846
|
+
q26.commandId !== undefined &&
|
|
1847
|
+
q26.turnId !== undefined
|
|
1848
|
+
) {
|
|
1849
|
+
try {
|
|
1850
|
+
const advanced = await store.persistTransition(lifecycleIdentity, {
|
|
1851
|
+
claimId: claim.claimId,
|
|
1852
|
+
from: "dispatching",
|
|
1853
|
+
to: "accepted",
|
|
1854
|
+
seed: {
|
|
1855
|
+
...seed,
|
|
1856
|
+
phase: "accepted",
|
|
1857
|
+
commandId: q26.commandId,
|
|
1858
|
+
turnId: q26.turnId,
|
|
1859
|
+
acceptedAt: q26.acceptedAt ?? Date.now(),
|
|
1860
|
+
lastQ26Status: q26.status,
|
|
1861
|
+
observedAt: Date.now(),
|
|
1862
|
+
},
|
|
1863
|
+
});
|
|
1864
|
+
return { ok: true, result: this.#spawnResult("spawn_replayed", advanced.claim) };
|
|
1865
|
+
} catch {
|
|
1866
|
+
return error("terminal_uncertain", "session.spawn replay could not be advanced durably");
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
if (!refMismatch && q26.status === "failed") {
|
|
1870
|
+
try {
|
|
1871
|
+
await store.persistSeedObservation(lifecycleIdentity, {
|
|
1872
|
+
...seed,
|
|
1873
|
+
lastQ26Status: "failed",
|
|
1874
|
+
observedAt: Date.now(),
|
|
1875
|
+
});
|
|
1876
|
+
} catch {
|
|
1877
|
+
// The observation is best effort; the durable claim already proves dispatch.
|
|
1878
|
+
}
|
|
1879
|
+
return error("spawn_failed", "session.spawn seed turn failed after handoff");
|
|
1880
|
+
}
|
|
1881
|
+
try {
|
|
1882
|
+
await store.persistSeedObservation(lifecycleIdentity, {
|
|
1883
|
+
...seed,
|
|
1884
|
+
lastQ26Status: "unknown",
|
|
1885
|
+
observedAt: Date.now(),
|
|
1886
|
+
});
|
|
1887
|
+
} catch {
|
|
1888
|
+
// Retained uncertainty never blocks the typed response below.
|
|
1889
|
+
}
|
|
1890
|
+
return error("terminal_uncertain", "session.spawn outcome is unknown");
|
|
1891
|
+
}
|
|
1892
|
+
if (claim.state === "substrate_starting")
|
|
1893
|
+
return error("terminal_uncertain", "session.spawn substrate state requires reconciliation");
|
|
1894
|
+
return error("spawn_in_progress", `session.spawn is ${claim.state}`);
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
/**
|
|
1898
|
+
* Startup reconciliation of non-terminal spawn claims. It never creates a
|
|
1899
|
+
* replacement child or re-sends a prompt: substrate_starting without exact
|
|
1900
|
+
* authority proof retains uncertainty, authority_active durably allocates the
|
|
1901
|
+
* opaque clientRef so one recovery lease may retry, and dispatching resolves
|
|
1902
|
+
* through Q26 only.
|
|
1903
|
+
*/
|
|
1904
|
+
async #recoverSpawnClaims(): Promise<void> {
|
|
1905
|
+
const store = this.#spawnAuthority;
|
|
1906
|
+
if (!store) return;
|
|
1907
|
+
for (const claim of store.claims()) {
|
|
1908
|
+
try {
|
|
1909
|
+
if (claim.state === "substrate_starting") {
|
|
1910
|
+
await store.persistTransition(claim.lifecycleIdentity, {
|
|
1911
|
+
claimId: claim.claimId,
|
|
1912
|
+
from: "substrate_starting",
|
|
1913
|
+
to: "uncertain",
|
|
1914
|
+
});
|
|
1915
|
+
continue;
|
|
1916
|
+
}
|
|
1917
|
+
if (claim.state === "authority_active") {
|
|
1918
|
+
// Only exact evidence that the original substrate is still active may
|
|
1919
|
+
// advance this claim; otherwise it retains uncertainty rather than
|
|
1920
|
+
// letting a later recovery owner prompt a replaced substrate.
|
|
1921
|
+
const authorityRecord = store.authority(claim.lifecycleIdentity);
|
|
1922
|
+
// A thrown verify is not evidence of a healthy substrate. Treat it
|
|
1923
|
+
// exactly like `gone`, so the claim retains uncertainty instead of
|
|
1924
|
+
// staying authority_active and later answering spawn_in_progress.
|
|
1925
|
+
let verdict: "verified" | "mismatch" | "gone" = "gone";
|
|
1926
|
+
// A row written before the endpoint pin existed carries no proof of
|
|
1927
|
+
// WHICH host answers on this session id. That is missing authority,
|
|
1928
|
+
// so the claim fails closed here rather than becoming eligible for a
|
|
1929
|
+
// recovery lease that would match by session id alone.
|
|
1930
|
+
if (authorityRecord && spawnPinFromAuthority(authorityRecord)) {
|
|
1931
|
+
try {
|
|
1932
|
+
verdict = await this.#spawnSubstrateProvider().verify(spawnProofFromAuthority(authorityRecord));
|
|
1933
|
+
} catch {
|
|
1934
|
+
verdict = "gone";
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
if (verdict !== "verified") {
|
|
1938
|
+
await store.persistTransition(claim.lifecycleIdentity, {
|
|
1939
|
+
claimId: claim.claimId,
|
|
1940
|
+
from: "authority_active",
|
|
1941
|
+
to: "uncertain",
|
|
1942
|
+
});
|
|
1943
|
+
continue;
|
|
1944
|
+
}
|
|
1945
|
+
await store.persistTransition(claim.lifecycleIdentity, {
|
|
1946
|
+
claimId: claim.claimId,
|
|
1947
|
+
from: "authority_active",
|
|
1948
|
+
to: "seed_prepared",
|
|
1949
|
+
seed: { version: 2, phase: "prepared", clientRef: randomBytes(24).toString("base64url") },
|
|
1950
|
+
});
|
|
1951
|
+
continue;
|
|
1952
|
+
}
|
|
1953
|
+
if (claim.state === "dispatching") await this.#reconcileSpawnReplay(claim.lifecycleIdentity, claim);
|
|
1954
|
+
} catch {
|
|
1955
|
+
// Recovery is fail-closed: an unreconciled claim stays retained as-is.
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
async #awaitSpawnHostRegistration(input: {
|
|
1961
|
+
childId: string;
|
|
1962
|
+
cwd: string;
|
|
1963
|
+
stateRoot: string;
|
|
1964
|
+
}): Promise<{ ok: true; registration: SpawnHostRegistration } | { ok: false }> {
|
|
1965
|
+
const deadline = Date.now() + SPAWN_HOST_REGISTRATION_TIMEOUT_MS;
|
|
1966
|
+
for (;;) {
|
|
1967
|
+
try {
|
|
1968
|
+
await this.index.refresh();
|
|
1969
|
+
// The launch locator is authority: a same-id row registered by an
|
|
1970
|
+
// unrelated workspace must never be adopted as this spawn's child.
|
|
1971
|
+
const row = this.index.listSessionIdentities().find(
|
|
1972
|
+
candidate =>
|
|
1973
|
+
candidate.sessionId === input.childId &&
|
|
1974
|
+
candidate.endpointGeneration > 0 &&
|
|
1975
|
+
candidate.live &&
|
|
1976
|
+
!candidate.terminal &&
|
|
1977
|
+
!candidate.terminalUncertain &&
|
|
1978
|
+
// Path IDENTITY, not spelling. The child publishes a
|
|
1979
|
+
// realpath-canonicalized locator while the launch spec carries the
|
|
1980
|
+
// caller's lexical path, so `path.resolve` never reconciles a
|
|
1981
|
+
// symlinked workspace (macOS /var vs /private/var) and a
|
|
1982
|
+
// legitimate child would never match.
|
|
1983
|
+
resolveEquivalentPath(candidate.locator.cwd) === resolveEquivalentPath(input.cwd) &&
|
|
1984
|
+
resolveEquivalentPath(candidate.locator.stateRoot) === resolveEquivalentPath(input.stateRoot),
|
|
1985
|
+
);
|
|
1986
|
+
if (row) {
|
|
1987
|
+
const incarnation = row.hostIncarnation ?? row.processIncarnation;
|
|
1988
|
+
// An incarnation-less row is incomplete endpoint evidence; a partial pin
|
|
1989
|
+
// previously just failed later at dispatch, so failing at registration keeps one boundary.
|
|
1990
|
+
if (incarnation !== undefined) {
|
|
1991
|
+
return {
|
|
1992
|
+
ok: true,
|
|
1993
|
+
registration: {
|
|
1994
|
+
sessionId: row.sessionId,
|
|
1995
|
+
endpointGeneration: row.endpointGeneration,
|
|
1996
|
+
pid: row.pid,
|
|
1997
|
+
processIncarnation: incarnation,
|
|
1998
|
+
cwd: row.locator.cwd,
|
|
1999
|
+
stateRoot: row.locator.stateRoot,
|
|
2000
|
+
},
|
|
2001
|
+
};
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
} catch {
|
|
2005
|
+
// A transient index read failure only delays the poll.
|
|
2006
|
+
}
|
|
2007
|
+
if (Date.now() > deadline) return { ok: false };
|
|
2008
|
+
await Bun.sleep(SPAWN_HOST_REGISTRATION_POLL_MS);
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
/** One nonce-correlated frame exchange over a child host's authenticated endpoint. */
|
|
2013
|
+
async #spawnChildExchange(input: {
|
|
2014
|
+
sessionId: string;
|
|
2015
|
+
/** Endpoint identity captured at registration; the exchange refuses to talk to anything else. */
|
|
2016
|
+
pinned: SpawnHostRegistration;
|
|
2017
|
+
frame: (id: string) => Record<string, unknown>;
|
|
2018
|
+
responseType: string;
|
|
2019
|
+
timeoutMs: number;
|
|
2020
|
+
}): Promise<{ handedOff: boolean; frame?: Record<string, unknown> }> {
|
|
2021
|
+
let row: IndexedSession | undefined;
|
|
2022
|
+
try {
|
|
2023
|
+
await this.index.refresh();
|
|
2024
|
+
row = this.index.listSessionIdentities().find(
|
|
2025
|
+
candidate =>
|
|
2026
|
+
candidate.sessionId === input.sessionId &&
|
|
2027
|
+
candidate.endpointGeneration > 0 &&
|
|
2028
|
+
candidate.live &&
|
|
2029
|
+
!candidate.terminal &&
|
|
2030
|
+
!candidate.terminalUncertain &&
|
|
2031
|
+
// Bind to the exact endpoint proven at registration. Without this a
|
|
2032
|
+
// replaced host or an alternate endpoint for the same id could
|
|
2033
|
+
// receive the seed prompt or answer a Q26 reconciliation.
|
|
2034
|
+
// Every pin leg is required. Identity fields alone collide across
|
|
2035
|
+
// workspaces (pids are reused), so a complete pin is required evidence.
|
|
2036
|
+
matchesSpawnPin(candidate, input.pinned),
|
|
2037
|
+
);
|
|
2038
|
+
} catch {
|
|
2039
|
+
row = undefined;
|
|
2040
|
+
}
|
|
2041
|
+
if (!row) return { handedOff: false };
|
|
2042
|
+
const endpoint = await readEndpoint(row);
|
|
2043
|
+
if (!endpoint) return { handedOff: false };
|
|
2044
|
+
const id = randomBytes(16).toString("base64url");
|
|
2045
|
+
const settled = Promise.withResolvers<Record<string, unknown> | undefined>();
|
|
2046
|
+
let handedOff = false;
|
|
2047
|
+
let complete = false;
|
|
2048
|
+
const settle = (frame?: Record<string, unknown>): void => {
|
|
2049
|
+
if (complete) return;
|
|
2050
|
+
complete = true;
|
|
2051
|
+
settled.resolve(frame);
|
|
2052
|
+
};
|
|
2053
|
+
let socket: WebSocket | undefined;
|
|
2054
|
+
try {
|
|
2055
|
+
const url = new URL(endpoint.url);
|
|
2056
|
+
url.searchParams.set("token", endpoint.token);
|
|
2057
|
+
socket = new WebSocket(url);
|
|
2058
|
+
socket.addEventListener("error", () => settle(undefined));
|
|
2059
|
+
socket.addEventListener("close", () => settle(undefined));
|
|
2060
|
+
socket.addEventListener("message", event => {
|
|
2061
|
+
let frame: Record<string, unknown>;
|
|
2062
|
+
try {
|
|
2063
|
+
const parsed = JSON.parse(String(event.data));
|
|
2064
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
|
|
2065
|
+
frame = parsed as Record<string, unknown>;
|
|
2066
|
+
} catch {
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
if (frame.type === "hello") {
|
|
2070
|
+
try {
|
|
2071
|
+
socket?.send(JSON.stringify(input.frame(id)));
|
|
2072
|
+
handedOff = true;
|
|
2073
|
+
} catch {
|
|
2074
|
+
settle(undefined);
|
|
2075
|
+
}
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
if (frame.type !== input.responseType || frame.id !== id) return;
|
|
2079
|
+
settle(frame);
|
|
2080
|
+
});
|
|
2081
|
+
const frame = await Promise.race([settled.promise, Bun.sleep(input.timeoutMs).then(() => undefined)]);
|
|
2082
|
+
return { handedOff, frame };
|
|
2083
|
+
} catch {
|
|
2084
|
+
return { handedOff };
|
|
2085
|
+
} finally {
|
|
2086
|
+
try {
|
|
2087
|
+
socket?.close();
|
|
2088
|
+
} catch {
|
|
2089
|
+
// Closing an already failed attachment is best effort.
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
async #dispatchSpawnPrompt(input: {
|
|
2095
|
+
sessionId: string;
|
|
2096
|
+
task: string;
|
|
2097
|
+
clientRef: string;
|
|
2098
|
+
pinned: SpawnHostRegistration;
|
|
2099
|
+
}): Promise<SpawnPromptDispatch> {
|
|
2100
|
+
const exchange = await this.#spawnChildExchange({
|
|
2101
|
+
sessionId: input.sessionId,
|
|
2102
|
+
pinned: input.pinned,
|
|
2103
|
+
frame: id => ({
|
|
2104
|
+
type: "control_request",
|
|
2105
|
+
id,
|
|
2106
|
+
operation: "turn.prompt",
|
|
2107
|
+
input: { text: input.task, clientRef: input.clientRef },
|
|
2108
|
+
}),
|
|
2109
|
+
responseType: "control_response",
|
|
2110
|
+
timeoutMs: SPAWN_PROMPT_EXCHANGE_TIMEOUT_MS,
|
|
2111
|
+
});
|
|
2112
|
+
if (!exchange.handedOff) return { kind: "pre_send_rejected" };
|
|
2113
|
+
const frame = exchange.frame;
|
|
2114
|
+
if (!frame) return { kind: "uncertain" };
|
|
2115
|
+
if (frame.ok === true) {
|
|
2116
|
+
const acceptance = promptAcceptanceFromResponse(frame, input.clientRef);
|
|
2117
|
+
return acceptance === undefined
|
|
2118
|
+
? { kind: "uncertain" }
|
|
2119
|
+
: { kind: "accepted", commandId: acceptance.commandId, turnId: acceptance.turnId, acceptedAt: Date.now() };
|
|
2120
|
+
}
|
|
2121
|
+
// An explicit rejection response proves the prompt never reached the turn loop.
|
|
2122
|
+
return frame.ok === false ? { kind: "pre_send_rejected" } : { kind: "uncertain" };
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
async #reconcileSpawnPrompt(input: {
|
|
2126
|
+
sessionId: string;
|
|
2127
|
+
clientRef: string;
|
|
2128
|
+
pinned: SpawnHostRegistration;
|
|
2129
|
+
}): Promise<SpawnQ26Reconciliation> {
|
|
2130
|
+
const exchange = await this.#spawnChildExchange({
|
|
2131
|
+
sessionId: input.sessionId,
|
|
2132
|
+
pinned: input.pinned,
|
|
2133
|
+
frame: id => ({
|
|
2134
|
+
type: "query_request",
|
|
2135
|
+
id,
|
|
2136
|
+
query: "turn.result",
|
|
2137
|
+
input: { kind: "prompt", clientRef: input.clientRef },
|
|
2138
|
+
}),
|
|
2139
|
+
responseType: "query_response",
|
|
2140
|
+
timeoutMs: SPAWN_PROMPT_EXCHANGE_TIMEOUT_MS,
|
|
2141
|
+
});
|
|
2142
|
+
const frame = exchange.frame;
|
|
2143
|
+
if (frame?.ok !== true) return { status: "unknown" };
|
|
2144
|
+
return q26FromResponse(frame);
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
/**
|
|
2148
|
+
* Routes session.close for a spawn-created child through its claim/authority
|
|
2149
|
+
* record. Non-spawn sessions fall through to the generic lifecycle path.
|
|
2150
|
+
*/
|
|
2151
|
+
async #maybeCloseSpawnChild(input: Record<string, unknown>): Promise<BrokerResponse | undefined> {
|
|
2152
|
+
const store = this.#spawnAuthority;
|
|
2153
|
+
// This runs BEFORE generic normalization, so it must resolve the same
|
|
2154
|
+
// `id` alias that normalizeBrokerInput accepts. Reading only `sessionId`
|
|
2155
|
+
// let `session.close {id}` fall through to the generic path, which can
|
|
2156
|
+
// signal the child PID without the provider's exact substrate proof.
|
|
2157
|
+
const alias = normalizeAliasedString(input, "sessionId", ["id"]);
|
|
2158
|
+
const sessionId = alias.error ? undefined : alias.value;
|
|
2159
|
+
if (!store || !sessionId) return undefined;
|
|
2160
|
+
const claim = store.claims().find(candidate => candidate.childId === sessionId);
|
|
2161
|
+
if (!claim) return undefined;
|
|
2162
|
+
const outcome = await this.#closeSpawnAuthority(claim.lifecycleIdentity);
|
|
2163
|
+
if (outcome === "closed") return { ok: true, result: { code: "spawn_child_closed", sessionId } };
|
|
2164
|
+
if (outcome === "uncertain")
|
|
2165
|
+
return error("terminal_uncertain", "session.close substrate identity could not be re-proven");
|
|
2166
|
+
return error("close_refused", "session.close could not complete for the spawned child");
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
/**
|
|
2170
|
+
* Ordinary exact close for one spawn authority. It mutates only a re-proven
|
|
2171
|
+
* substrate; identity mismatch retains durable uncertainty and never falls
|
|
2172
|
+
* back to name-only or PID-only cleanup. Repeated close replays safely.
|
|
2173
|
+
*/
|
|
2174
|
+
async #closeSpawnAuthority(lifecycleIdentity: string): Promise<"closed" | "uncertain" | "retained"> {
|
|
2175
|
+
const store = this.#spawnAuthority;
|
|
2176
|
+
if (!store) return "retained";
|
|
2177
|
+
try {
|
|
2178
|
+
const claim = store.claim(lifecycleIdentity);
|
|
2179
|
+
let authority = store.authority(lifecycleIdentity);
|
|
2180
|
+
if (!claim || !authority) return "retained";
|
|
2181
|
+
if (claim.state === "closed" && authority.closeState === "closed") return "closed";
|
|
2182
|
+
if (authority.closeState === "closed") return "closed";
|
|
2183
|
+
const provider = this.#spawnSubstrateProvider();
|
|
2184
|
+
const proof = spawnProofFromAuthority(authority);
|
|
2185
|
+
const verdict = await provider.verify(proof);
|
|
2186
|
+
if (verdict === "mismatch") {
|
|
2187
|
+
if (authority.closeState !== "uncertain") {
|
|
2188
|
+
const at = Math.max(Date.now(), authority.updatedAt + 1);
|
|
2189
|
+
await store.persistAuthority(lifecycleIdentity, {
|
|
2190
|
+
...authority,
|
|
2191
|
+
closeState: "uncertain",
|
|
2192
|
+
updatedAt: at,
|
|
2193
|
+
});
|
|
2194
|
+
}
|
|
2195
|
+
return "uncertain";
|
|
2196
|
+
}
|
|
2197
|
+
if (authority.closeState === "active") {
|
|
2198
|
+
const at = Math.max(Date.now(), authority.updatedAt + 1);
|
|
2199
|
+
authority = (
|
|
2200
|
+
await store.persistAuthority(lifecycleIdentity, {
|
|
2201
|
+
...authority,
|
|
2202
|
+
closeState: "close_requested",
|
|
2203
|
+
closeRequestedAt: at,
|
|
2204
|
+
updatedAt: at,
|
|
2205
|
+
})
|
|
2206
|
+
).authority!;
|
|
2207
|
+
}
|
|
2208
|
+
if (verdict === "verified") {
|
|
2209
|
+
const closedSubstrate = await provider.close(proof);
|
|
2210
|
+
if (!closedSubstrate.ok) return "retained";
|
|
2211
|
+
// Signal delivery is not terminal evidence: only a second exact proof
|
|
2212
|
+
// that observes this substrate gone permits the durable close.
|
|
2213
|
+
if ((await provider.verify(proof)) !== "gone") return "retained";
|
|
2214
|
+
}
|
|
2215
|
+
const at = Math.max(Date.now(), authority.updatedAt + 1);
|
|
2216
|
+
const closedAuthority: SpawnAuthorityV1 = { ...authority, closeState: "closed", closedAt: at, updatedAt: at };
|
|
2217
|
+
const currentClaim = store.claim(lifecycleIdentity);
|
|
2218
|
+
if (!currentClaim) return "retained";
|
|
2219
|
+
if (currentClaim.state !== "closed")
|
|
2220
|
+
await store.persistTransition(lifecycleIdentity, {
|
|
2221
|
+
claimId: currentClaim.claimId,
|
|
2222
|
+
from: currentClaim.state,
|
|
2223
|
+
to: "closed",
|
|
2224
|
+
authority: closedAuthority,
|
|
2225
|
+
});
|
|
2226
|
+
return "closed";
|
|
2227
|
+
} catch {
|
|
2228
|
+
return "retained";
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
/**
|
|
2233
|
+
* Replays pending exact closes, then reaps children after confirmed master
|
|
2234
|
+
* loss. Orphan clocks survive recovery and expiry converges through the same
|
|
2235
|
+
* exact close path for only the matching owned child.
|
|
2236
|
+
*/
|
|
2237
|
+
/** Deterministic single reap pass; used by maintenance paths and tests. */
|
|
2238
|
+
async reapSpawnOrphansOnce(): Promise<void> {
|
|
2239
|
+
await this.#reapSpawnOrphans();
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
async #reapSpawnOrphans(): Promise<void> {
|
|
2243
|
+
if (this.#spawnReapInFlight || this.#stopping) return;
|
|
2244
|
+
this.#spawnReapInFlight = true;
|
|
2245
|
+
try {
|
|
2246
|
+
const store = this.#spawnAuthority;
|
|
2247
|
+
if (!store) return;
|
|
2248
|
+
const grace = this.settings.masterOrphanGraceMs;
|
|
2249
|
+
let rows: readonly IndexedSession[] | undefined;
|
|
2250
|
+
for (const claim of store.claims()) {
|
|
2251
|
+
const authority = store.authority(claim.lifecycleIdentity);
|
|
2252
|
+
if (!authority || (authority.closeState !== "active" && authority.closeState !== "close_requested"))
|
|
2253
|
+
continue;
|
|
2254
|
+
if (authority.closeState === "close_requested") {
|
|
2255
|
+
try {
|
|
2256
|
+
await this.#closeSpawnAuthority(claim.lifecycleIdentity);
|
|
2257
|
+
} catch {
|
|
2258
|
+
// One authority's reap failure never blocks the remaining scan.
|
|
2259
|
+
}
|
|
2260
|
+
continue;
|
|
2261
|
+
}
|
|
2262
|
+
if (rows === undefined) {
|
|
2263
|
+
try {
|
|
2264
|
+
await this.index.refresh();
|
|
2265
|
+
} catch {
|
|
2266
|
+
return;
|
|
2267
|
+
}
|
|
2268
|
+
rows = this.index.listSessionIdentities();
|
|
2269
|
+
}
|
|
2270
|
+
const masterAlive = rows.some(
|
|
2271
|
+
row =>
|
|
2272
|
+
row.endpointGeneration > 0 &&
|
|
2273
|
+
row.live &&
|
|
2274
|
+
!row.terminal &&
|
|
2275
|
+
!row.terminalUncertain &&
|
|
2276
|
+
row.masterRole?.role === "master" &&
|
|
2277
|
+
row.masterRole.ownerSessionId === authority.ownerSessionId,
|
|
2278
|
+
);
|
|
2279
|
+
const orphanPending =
|
|
2280
|
+
authority.orphanedAt !== undefined &&
|
|
2281
|
+
(authority.orphanRecoveredAt === undefined || authority.orphanRecoveredAt < authority.orphanedAt);
|
|
2282
|
+
const now = Date.now();
|
|
2283
|
+
try {
|
|
2284
|
+
if (masterAlive) {
|
|
2285
|
+
if (orphanPending) {
|
|
2286
|
+
const at = Math.max(now, authority.updatedAt + 1);
|
|
2287
|
+
await store.persistAuthority(claim.lifecycleIdentity, {
|
|
2288
|
+
...authority,
|
|
2289
|
+
orphanRecoveredAt: at,
|
|
2290
|
+
updatedAt: at,
|
|
2291
|
+
});
|
|
2292
|
+
}
|
|
2293
|
+
continue;
|
|
2294
|
+
}
|
|
2295
|
+
if (!orphanPending) {
|
|
2296
|
+
const at = Math.max(now, authority.updatedAt + 1);
|
|
2297
|
+
const { orphanRecoveredAt: _cleared, ...rest } = authority;
|
|
2298
|
+
await store.persistAuthority(claim.lifecycleIdentity, { ...rest, orphanedAt: at, updatedAt: at });
|
|
2299
|
+
continue;
|
|
2300
|
+
}
|
|
2301
|
+
if (authority.orphanedAt !== undefined && now - authority.orphanedAt >= grace)
|
|
2302
|
+
await this.#closeSpawnAuthority(claim.lifecycleIdentity);
|
|
2303
|
+
} catch {
|
|
2304
|
+
// One authority's reap failure never blocks the remaining scan.
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
} finally {
|
|
2308
|
+
this.#spawnReapInFlight = false;
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
|
|
884
2312
|
runStartup<T>(
|
|
885
2313
|
queueWaitMs: number,
|
|
886
2314
|
timing: StartupAdmissionTiming,
|
|
@@ -1023,6 +2451,8 @@ export class Broker {
|
|
|
1023
2451
|
this.#resolveCompletion = completion.resolve;
|
|
1024
2452
|
this.#rejectCompletion = completion.reject;
|
|
1025
2453
|
this.#completionTask = null;
|
|
2454
|
+
if (this.#ownsResolveModelPin)
|
|
2455
|
+
this.#resolveModelPin = createDefaultSdkHostModelResolver(this.settings.agentDir);
|
|
1026
2456
|
// A drained queue refuses every later startup by design, so a restarted broker
|
|
1027
2457
|
// needs a new one or it would admit nothing for the rest of the process.
|
|
1028
2458
|
this.#startupAdmissions = new StartupAdmissionQueue(sdkHostStartupConcurrency());
|
|
@@ -1080,6 +2510,16 @@ export class Broker {
|
|
|
1080
2510
|
try {
|
|
1081
2511
|
await this.index.open();
|
|
1082
2512
|
await this.ledger.open();
|
|
2513
|
+
const brokerIdentityKey = await getBrokerIdentityKey(this.settings.agentDir);
|
|
2514
|
+
this.#spawnAuthority = new SpawnAuthorityStore(this.settings.agentDir, brokerIdentityKey);
|
|
2515
|
+
await this.#spawnAuthority.open();
|
|
2516
|
+
for (const claim of this.#spawnAuthority.claims()) {
|
|
2517
|
+
const mirror = this.ledger.get(claim.lifecycleIdentity);
|
|
2518
|
+
if (!mirror) await this.ledger.begin(claim.lifecycleIdentity, claim.bindingMac);
|
|
2519
|
+
else if (mirror.requestHash !== claim.bindingMac)
|
|
2520
|
+
throw new Error("Spawn claim lifecycle mirror binding differs from durable authority.");
|
|
2521
|
+
}
|
|
2522
|
+
await this.#recoverSpawnClaims();
|
|
1083
2523
|
const now = Date.now();
|
|
1084
2524
|
const incarnation = brokerProcessIncarnation(process.pid);
|
|
1085
2525
|
if (!incarnation) throw new Error("Broker process incarnation is unavailable.");
|
|
@@ -1107,7 +2547,10 @@ export class Broker {
|
|
|
1107
2547
|
10,
|
|
1108
2548
|
Math.min(BROKER_PUBLICATION_CADENCE_MS, Math.floor(this.settings.heartbeatTtlMs / 3)),
|
|
1109
2549
|
);
|
|
1110
|
-
this.#heartbeatTimer = setInterval(() =>
|
|
2550
|
+
this.#heartbeatTimer = setInterval(() => {
|
|
2551
|
+
void this.#watchPublication();
|
|
2552
|
+
void this.#reapSpawnOrphans();
|
|
2553
|
+
}, cadenceMs);
|
|
1111
2554
|
await this.#checkpointSessionHeartbeats();
|
|
1112
2555
|
return this.discovery;
|
|
1113
2556
|
} catch (error) {
|
|
@@ -1285,25 +2728,35 @@ export class Broker {
|
|
|
1285
2728
|
if (this.#heartbeatTimer) clearInterval(this.#heartbeatTimer);
|
|
1286
2729
|
this.#heartbeatTimer = null;
|
|
1287
2730
|
this.#completionTask = (async () => {
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
2731
|
+
try {
|
|
2732
|
+
await this.#transport?.stop();
|
|
2733
|
+
this.#transport = null;
|
|
2734
|
+
if (mode === "lost-root")
|
|
2735
|
+
await Promise.race([Promise.allSettled(this.#admitted), Bun.sleep(BROKER_SETTLEMENT_MS)]);
|
|
2736
|
+
else await Promise.allSettled(this.#admitted);
|
|
2737
|
+
this.#publication?.close();
|
|
2738
|
+
this.#publication = null;
|
|
2739
|
+
if (mode === "owned-root" && this.discovery?.ownerId === this.#owner) {
|
|
2740
|
+
try {
|
|
2741
|
+
const disk = JSON.parse(await fs.readFile(brokerDiscoveryPath(this.settings.agentDir), "utf8")) as {
|
|
2742
|
+
ownerId?: string;
|
|
2743
|
+
};
|
|
2744
|
+
if (disk.ownerId === this.#owner) await fs.unlink(brokerDiscoveryPath(this.settings.agentDir));
|
|
2745
|
+
} catch (e) {
|
|
2746
|
+
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
|
|
2747
|
+
}
|
|
2748
|
+
await this.#releaseOwnedLock();
|
|
1303
2749
|
}
|
|
1304
|
-
|
|
2750
|
+
} finally {
|
|
2751
|
+
const disposeModelPin = this.#ownsResolveModelPin ? this.#resolveModelPin.dispose?.() : undefined;
|
|
2752
|
+
if (mode === "lost-root" && disposeModelPin !== undefined) {
|
|
2753
|
+
void disposeModelPin.catch(() => undefined);
|
|
2754
|
+
await Promise.race([disposeModelPin, Bun.sleep(BROKER_SETTLEMENT_MS)]);
|
|
2755
|
+
} else {
|
|
2756
|
+
await disposeModelPin;
|
|
2757
|
+
}
|
|
2758
|
+
this.discovery = null;
|
|
1305
2759
|
}
|
|
1306
|
-
this.discovery = null;
|
|
1307
2760
|
})();
|
|
1308
2761
|
void this.#completionTask.then(this.#resolveCompletion, this.#rejectCompletion);
|
|
1309
2762
|
return this.#completionTask;
|
|
@@ -1417,14 +2870,22 @@ export class Broker {
|
|
|
1417
2870
|
|
|
1418
2871
|
try {
|
|
1419
2872
|
const endpointPath = path.join(record.locator.stateRoot, "sdk", `${record.sessionId}.json`);
|
|
1420
|
-
const
|
|
1421
|
-
|
|
2873
|
+
const file = await readEndpointFile(endpointPath);
|
|
2874
|
+
if (!file) {
|
|
2875
|
+
try {
|
|
2876
|
+
await fs.lstat(endpointPath);
|
|
2877
|
+
} catch (statError) {
|
|
2878
|
+
if ((statError as NodeJS.ErrnoException).code === "ENOENT")
|
|
2879
|
+
return error("resource_gone", "session endpoint record is gone");
|
|
2880
|
+
}
|
|
2881
|
+
return error("endpoint_stale", "session endpoint is stale");
|
|
2882
|
+
}
|
|
2883
|
+
const endpoint = JSON.parse(file.source) as Record<string, unknown>;
|
|
1422
2884
|
if (
|
|
1423
2885
|
endpoint.sessionId !== record.sessionId ||
|
|
1424
2886
|
endpoint.pid !== record.pid ||
|
|
1425
2887
|
endpoint.stale === true ||
|
|
1426
|
-
record
|
|
1427
|
-
Math.abs(metadata.mtimeMs - record.endpointMtimeMs) > 0.001
|
|
2888
|
+
!matchesIndexedEndpointFile(file, record)
|
|
1428
2889
|
)
|
|
1429
2890
|
return error("endpoint_stale", "session endpoint is stale");
|
|
1430
2891
|
await this.index.refresh();
|
|
@@ -1438,6 +2899,139 @@ export class Broker {
|
|
|
1438
2899
|
throw e;
|
|
1439
2900
|
}
|
|
1440
2901
|
}
|
|
2902
|
+
async #sessionControlAuthority(sessionId: string): Promise<BrokerSessionControlAuthority | BrokerResponse> {
|
|
2903
|
+
await this.index.refresh();
|
|
2904
|
+
const indexed = this.index.listSessions().sessions.find(session => session.sessionId === sessionId);
|
|
2905
|
+
if (
|
|
2906
|
+
!indexed ||
|
|
2907
|
+
!isSessionAuthorityEligible(indexed) ||
|
|
2908
|
+
!indexed.live ||
|
|
2909
|
+
indexed.terminal ||
|
|
2910
|
+
indexed.terminalUncertain
|
|
2911
|
+
)
|
|
2912
|
+
return error("resource_gone", "session endpoint record is gone");
|
|
2913
|
+
if (
|
|
2914
|
+
!Number.isSafeInteger(indexed.endpointGeneration) ||
|
|
2915
|
+
indexed.endpointGeneration <= 0 ||
|
|
2916
|
+
!Number.isSafeInteger(indexed.pid) ||
|
|
2917
|
+
indexed.pid <= 0 ||
|
|
2918
|
+
indexed.endpointMtimeMs === undefined ||
|
|
2919
|
+
!Number.isFinite(indexed.endpointMtimeMs) ||
|
|
2920
|
+
indexed.endpointMtimeMs <= 0 ||
|
|
2921
|
+
typeof indexed.lifecycleRequestId !== "string" ||
|
|
2922
|
+
indexed.lifecycleRequestId.length === 0 ||
|
|
2923
|
+
indexed.lifecycleRequestId.length > 128 ||
|
|
2924
|
+
!/^[A-Za-z0-9._-]+$/u.test(indexed.lifecycleRequestId) ||
|
|
2925
|
+
endpointIncarnation(indexed, indexed.sessionId) === undefined
|
|
2926
|
+
)
|
|
2927
|
+
return error("endpoint_stale", "session endpoint authority is incomplete");
|
|
2928
|
+
let endpointResponse: BrokerResponse;
|
|
2929
|
+
try {
|
|
2930
|
+
endpointResponse = await this.#readEndpoint(indexed, {});
|
|
2931
|
+
} catch {
|
|
2932
|
+
return error("endpoint_stale", "session endpoint is stale");
|
|
2933
|
+
}
|
|
2934
|
+
if (!endpointResponse.ok) return endpointResponse;
|
|
2935
|
+
const endpoint = objectRecord(endpointResponse.result);
|
|
2936
|
+
if (
|
|
2937
|
+
!endpoint ||
|
|
2938
|
+
endpoint.sessionId !== indexed.sessionId ||
|
|
2939
|
+
endpoint.pid !== indexed.pid ||
|
|
2940
|
+
typeof endpoint.url !== "string" ||
|
|
2941
|
+
endpoint.url.length === 0 ||
|
|
2942
|
+
typeof endpoint.token !== "string" ||
|
|
2943
|
+
endpoint.token.length === 0
|
|
2944
|
+
)
|
|
2945
|
+
return error("endpoint_stale", "session endpoint is malformed");
|
|
2946
|
+
try {
|
|
2947
|
+
const endpointIdentity = await fs.lstat(
|
|
2948
|
+
path.join(indexed.locator.stateRoot, "sdk", `${indexed.sessionId}.json`),
|
|
2949
|
+
{ bigint: true },
|
|
2950
|
+
);
|
|
2951
|
+
if (!endpointIdentity.isFile()) return error("endpoint_stale", "session endpoint is not a regular file");
|
|
2952
|
+
return {
|
|
2953
|
+
record: indexed,
|
|
2954
|
+
endpoint,
|
|
2955
|
+
endpointIdentity: {
|
|
2956
|
+
dev: endpointIdentity.dev,
|
|
2957
|
+
ino: endpointIdentity.ino,
|
|
2958
|
+
size: endpointIdentity.size,
|
|
2959
|
+
mtimeNs: endpointIdentity.mtimeNs,
|
|
2960
|
+
ctimeNs: endpointIdentity.ctimeNs,
|
|
2961
|
+
},
|
|
2962
|
+
};
|
|
2963
|
+
} catch (caught) {
|
|
2964
|
+
if ((caught as NodeJS.ErrnoException).code === "ENOENT")
|
|
2965
|
+
return error("resource_gone", "session endpoint record is gone");
|
|
2966
|
+
return error("endpoint_stale", "session endpoint is stale");
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
async #sessionControl(input: Record<string, unknown>, idempotencyKey?: string): Promise<BrokerResponse> {
|
|
2970
|
+
if (
|
|
2971
|
+
typeof idempotencyKey !== "string" ||
|
|
2972
|
+
idempotencyKey.length === 0 ||
|
|
2973
|
+
/[\u0000-\u001f\u007f]/u.test(idempotencyKey) ||
|
|
2974
|
+
new TextEncoder().encode(idempotencyKey).length > 128
|
|
2975
|
+
)
|
|
2976
|
+
return error("invalid_input", "idempotencyKey must be a bounded non-empty string");
|
|
2977
|
+
const request = brokerSessionControlRequest(input);
|
|
2978
|
+
if (isBrokerResponse(request)) return request;
|
|
2979
|
+
const target = `session.control\u0000${request.sessionId}`;
|
|
2980
|
+
const previous = this.#chains.get(target) ?? Promise.resolve();
|
|
2981
|
+
let release!: () => void;
|
|
2982
|
+
const current = new Promise<void>(resolve => (release = resolve));
|
|
2983
|
+
const chain = previous.then(() => current);
|
|
2984
|
+
this.#chains.set(target, chain);
|
|
2985
|
+
await previous;
|
|
2986
|
+
try {
|
|
2987
|
+
const authority = await this.#sessionControlAuthority(request.sessionId);
|
|
2988
|
+
if (isBrokerResponse(authority)) return authority;
|
|
2989
|
+
let client: SdkClient;
|
|
2990
|
+
try {
|
|
2991
|
+
client = await SdkClient.connect(authority.endpoint.url as string, authority.endpoint.token as string, {
|
|
2992
|
+
timeoutMs: BROKER_SESSION_CONTROL_TIMEOUT_MS,
|
|
2993
|
+
reconnectAttempts: 0,
|
|
2994
|
+
});
|
|
2995
|
+
} catch (caught) {
|
|
2996
|
+
return caught instanceof SdkClientError
|
|
2997
|
+
? error(caught.code, caught.message)
|
|
2998
|
+
: error("unavailable", "session endpoint is unavailable");
|
|
2999
|
+
}
|
|
3000
|
+
try {
|
|
3001
|
+
const currentAuthority = await this.#sessionControlAuthority(request.sessionId);
|
|
3002
|
+
if (isBrokerResponse(currentAuthority)) return currentAuthority;
|
|
3003
|
+
if (!sameSessionControlAuthority(authority, currentAuthority))
|
|
3004
|
+
return error("endpoint_stale", "session endpoint changed before control dispatch");
|
|
3005
|
+
const response = await client.control(
|
|
3006
|
+
"turn.abort",
|
|
3007
|
+
{
|
|
3008
|
+
...request.abortInput,
|
|
3009
|
+
[BROKER_RUNTIME_ABORT_CAPABILITY_FIELD]: authority.record.lifecycleRequestId,
|
|
3010
|
+
},
|
|
3011
|
+
{
|
|
3012
|
+
confirm: true,
|
|
3013
|
+
idempotencyKey,
|
|
3014
|
+
beforeDispatch: () => {
|
|
3015
|
+
if (!this.runSynchronousEffectWithFreshPublicationAuthority(() => undefined).authorized)
|
|
3016
|
+
throw new SdkClientError("unavailable", "broker publication is unavailable");
|
|
3017
|
+
},
|
|
3018
|
+
},
|
|
3019
|
+
);
|
|
3020
|
+
return brokerControlResponse(response);
|
|
3021
|
+
} catch (caught) {
|
|
3022
|
+
return caught instanceof SdkClientError
|
|
3023
|
+
? error(caught.code, caught.message)
|
|
3024
|
+
: error("unavailable", "session endpoint control is unavailable");
|
|
3025
|
+
} finally {
|
|
3026
|
+
await client.close().catch(() => undefined);
|
|
3027
|
+
}
|
|
3028
|
+
} catch {
|
|
3029
|
+
return error("unavailable", "session control is unavailable");
|
|
3030
|
+
} finally {
|
|
3031
|
+
release();
|
|
3032
|
+
if (this.#chains.get(target) === chain) this.#chains.delete(target);
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
1441
3035
|
#storeSessionListCursor(cursor: SessionListCursor, replacingToken?: string): string | BrokerResponse {
|
|
1442
3036
|
const now = Date.now();
|
|
1443
3037
|
for (const [token, stored] of this.#sessionListCursors) {
|
|
@@ -1452,7 +3046,7 @@ export class Broker {
|
|
|
1452
3046
|
return token;
|
|
1453
3047
|
}
|
|
1454
3048
|
|
|
1455
|
-
#sessionListPage(input: Record<string, unknown>, result: SessionList): BrokerResponse {
|
|
3049
|
+
async #sessionListPage(input: Record<string, unknown>, result: SessionList): Promise<BrokerResponse> {
|
|
1456
3050
|
const requestedLimit = input.limit === undefined ? undefined : sessionListLimit(input);
|
|
1457
3051
|
if (isBrokerResponse(requestedLimit)) return requestedLimit;
|
|
1458
3052
|
const cursor = input.cursor;
|
|
@@ -1463,14 +3057,46 @@ export class Broker {
|
|
|
1463
3057
|
}
|
|
1464
3058
|
if (stored && requestedLimit !== undefined && stored.limit !== requestedLimit)
|
|
1465
3059
|
return error("invalid_input", "limit must match the cursor page shape");
|
|
3060
|
+
let scope: ResolvedScopeV1 | undefined;
|
|
3061
|
+
if (stored?.scope !== undefined) {
|
|
3062
|
+
if (input.scope !== undefined) {
|
|
3063
|
+
const supplied = scopeRequestV1(input.scope);
|
|
3064
|
+
if (!supplied) return error("scope_cursor_mismatch", "scope must match the cursor snapshot");
|
|
3065
|
+
try {
|
|
3066
|
+
scope = await resolveScopeRequest(supplied);
|
|
3067
|
+
} catch (cause) {
|
|
3068
|
+
if (cause instanceof ScopeRequestValidationError)
|
|
3069
|
+
return error("scope_cursor_mismatch", "scope must match the cursor snapshot");
|
|
3070
|
+
throw cause;
|
|
3071
|
+
}
|
|
3072
|
+
if (JSON.stringify(scope) !== JSON.stringify(stored.scope))
|
|
3073
|
+
return error("scope_cursor_mismatch", "scope must match the cursor snapshot");
|
|
3074
|
+
}
|
|
3075
|
+
scope = stored.scope;
|
|
3076
|
+
} else if (input.scope !== undefined) {
|
|
3077
|
+
if (stored !== undefined) return error("scope_cursor_mismatch", "scope must match the cursor snapshot");
|
|
3078
|
+
const request = scopeRequestV1(input.scope);
|
|
3079
|
+
if (!request) return error("invalid_input", "scope must be a valid ScopeRequestV1");
|
|
3080
|
+
try {
|
|
3081
|
+
scope = await resolveScopeRequest(request);
|
|
3082
|
+
} catch (cause) {
|
|
3083
|
+
if (cause instanceof ScopeRequestValidationError) return error("invalid_input", cause.message);
|
|
3084
|
+
throw cause;
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
1466
3087
|
const limit = stored?.limit ?? requestedLimit ?? SESSION_LIST_DEFAULT_LIMIT;
|
|
3088
|
+
const observedAt = stored?.observedAt ?? (scope === undefined ? undefined : new Date().toISOString());
|
|
1467
3089
|
const snapshot = stored ?? {
|
|
1468
|
-
sessions:
|
|
3090
|
+
sessions:
|
|
3091
|
+
scope === undefined
|
|
3092
|
+
? [...result.sessions]
|
|
3093
|
+
: result.sessions.filter(session => scopeMatchesLocator(scope, session.locator)),
|
|
1469
3094
|
indexSeq: result.indexSeq,
|
|
1470
3095
|
warnings: [...result.warnings],
|
|
1471
3096
|
limit,
|
|
1472
3097
|
offset: 0,
|
|
1473
3098
|
expiresAt: Date.now() + SESSION_LIST_CURSOR_TTL_MS,
|
|
3099
|
+
...(scope === undefined ? {} : { scope, observedAt: observedAt! }),
|
|
1474
3100
|
};
|
|
1475
3101
|
const sessions = snapshot.sessions.slice(snapshot.offset, snapshot.offset + snapshot.limit).map(session => {
|
|
1476
3102
|
const { lifecycleRequestId: _lifecycleRequestId, ...publicSession } = session;
|
|
@@ -1492,6 +3118,7 @@ export class Broker {
|
|
|
1492
3118
|
indexSeq: snapshot.indexSeq,
|
|
1493
3119
|
sessions,
|
|
1494
3120
|
warnings: snapshot.warnings,
|
|
3121
|
+
...(snapshot.scope === undefined ? {} : { scope: snapshot.scope, observedAt: snapshot.observedAt }),
|
|
1495
3122
|
...(continuationCursor ? { continuationCursor } : {}),
|
|
1496
3123
|
},
|
|
1497
3124
|
indexSeq: snapshot.indexSeq,
|
|
@@ -1514,13 +3141,21 @@ export class Broker {
|
|
|
1514
3141
|
idempotencyKey?: string,
|
|
1515
3142
|
): Promise<BrokerResponse> {
|
|
1516
3143
|
if (this.#stopping) return error("broker_restarting", "broker is stopping");
|
|
3144
|
+
// Spawn bypasses generic raw-input transforms. They must never observe task
|
|
3145
|
+
// or capability fields, even transiently.
|
|
3146
|
+
if (operation === "session.spawn") return this.#handleSpawn(input, idempotencyKey);
|
|
3147
|
+
if (operation === "session.close") {
|
|
3148
|
+
const spawnClose = await this.#maybeCloseSpawnChild(input);
|
|
3149
|
+
if (spawnClose) return spawnClose;
|
|
3150
|
+
}
|
|
3151
|
+
if (operation === "session.control") return this.#sessionControl(input, idempotencyKey);
|
|
1517
3152
|
const fingerprint = lifecycleFingerprint(operation, input);
|
|
1518
3153
|
const normalization = normalizeBrokerInput(operation, input);
|
|
1519
3154
|
if (isBrokerResponse(normalization)) return normalization;
|
|
1520
3155
|
input = normalization.input;
|
|
1521
3156
|
if (operation === "session.list") {
|
|
1522
3157
|
if (input.cursor === undefined) await this.index.refresh();
|
|
1523
|
-
const page = this.#sessionListPage(input, this.index.listSessions());
|
|
3158
|
+
const page = await this.#sessionListPage(input, this.index.listSessions());
|
|
1524
3159
|
if (!page.ok) return page;
|
|
1525
3160
|
const pageResult = page.result as Record<string, unknown>;
|
|
1526
3161
|
const resolveSessionId = typeof input.resolveSessionId === "string" ? input.resolveSessionId : undefined;
|
|
@@ -1578,6 +3213,8 @@ export class Broker {
|
|
|
1578
3213
|
const requestedFingerprint = typeof input.fingerprint === "string" ? input.fingerprint : undefined;
|
|
1579
3214
|
if (!idempotencyKey || !requestedOperation || !requestedFingerprint)
|
|
1580
3215
|
return error("invalid_input", "operation, idempotencyKey, and fingerprint are required");
|
|
3216
|
+
if (requestedOperation === "session.spawn")
|
|
3217
|
+
return error("invalid_input", "session.spawn does not support lifecycle lookup");
|
|
1581
3218
|
if (!LIFECYCLE_OPERATIONS.has(requestedOperation))
|
|
1582
3219
|
return error("not_found", "lifecycle operation was not found");
|
|
1583
3220
|
const identity = await deriveIdempotencyIdentity(this.settings.agentDir, requestedOperation, idempotencyKey);
|