@gajae-code/coding-agent 0.15.6 → 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 +59 -0
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/root-flags.d.ts +14 -0
- package/dist/types/cli/update-cli.d.ts +13 -0
- package/dist/types/cli.d.ts +7 -0
- package/dist/types/commands/launch.d.ts +10 -0
- package/dist/types/config/autorouting-tier-map.d.ts +24 -0
- package/dist/types/config/file-lock.d.ts +5 -0
- package/dist/types/config/model-registry.d.ts +6 -0
- package/dist/types/config/settings-schema.d.ts +10 -1
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/coordinator-mcp/projection-scan.d.ts +8 -0
- package/dist/types/crash/record-loader.d.ts +15 -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-sidecar.d.ts +66 -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/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/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/print-mode.d.ts +17 -0
- package/dist/types/modes/ui-language.d.ts +7 -1
- package/dist/types/sdk/broker/broker.d.ts +77 -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/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/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/session-runtime.d.ts +52 -0
- 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/router/session-router.d.ts +2 -0
- package/dist/types/sdk/session.d.ts +16 -0
- package/dist/types/sdk/startup-capability.d.ts +2 -1
- package/dist/types/session/agent-session.d.ts +5 -0
- package/dist/types/session/credential-store-errors.d.ts +1 -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/tools/bash.d.ts +4 -0
- package/dist/types/tools/gh.d.ts +8 -0
- package/dist/types/tools/index.d.ts +4 -0
- package/package.json +7 -7
- package/scripts/generate-sdk-operation-inventory.ts +6 -0
- package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
- package/src/cli/accounts-cli.ts +9 -5
- package/src/cli/args.ts +17 -0
- package/src/cli/fast-help.ts +1 -0
- package/src/cli/root-flags.ts +5 -0
- package/src/cli/update-cli.ts +77 -11
- package/src/commands/launch.ts +86 -18
- package/src/commands/sdk.ts +113 -21
- package/src/config/autorouting-tier-map.ts +12 -0
- package/src/config/file-lock.ts +158 -83
- package/src/config/model-preset-registry.ts +1 -5
- package/src/config/model-profiles.ts +5 -5
- package/src/config/model-registry.ts +23 -0
- package/src/config/model-resolver.ts +5 -0
- package/src/config/settings-schema.ts +8 -1
- package/src/config/settings.ts +5 -0
- package/src/coordinator-mcp/projection-scan.ts +21 -0
- package/src/coordinator-mcp/question-state.ts +9 -11
- package/src/coordinator-mcp/server.ts +94 -20
- package/src/crash/index-store.ts +2 -0
- package/src/crash/record-loader.ts +10 -0
- 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 +79 -6
- package/src/gjc-runtime/session-state-sidecar.ts +929 -23
- 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/internal-urls/docs-index.generated.ts +7 -7
- package/src/internal-urls/issue-pr-protocol.ts +4 -2
- package/src/main.ts +57 -2
- 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 +605 -156
- package/src/modes/components/footer.ts +4 -2
- package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
- 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 +23 -9
- package/src/modes/components/tool-status-header.ts +151 -15
- package/src/modes/controllers/input-controller.ts +28 -0
- package/src/modes/controllers/selector-controller.ts +191 -96
- package/src/modes/print-mode.ts +64 -0
- package/src/modes/ui-language.ts +49 -2
- package/src/prompts/system/master-mode.md +28 -0
- package/src/sdk/broker/broker.ts +1636 -26
- 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 +186 -41
- 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 +35 -4
- package/src/sdk/bus/index.ts +51 -4
- 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 +264 -5
- package/src/sdk/client/client.ts +14 -8
- package/src/sdk/host/control/dispatch.ts +99 -38
- 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/session-runtime.test.ts +210 -6
- package/src/sdk/host/session-runtime.ts +291 -49
- 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/protocol/operation-inventory.generated.json +51 -0
- package/src/sdk/protocol/operation-registry.ts +13 -3
- package/src/sdk/router/session-router.ts +67 -59
- package/src/sdk/session.ts +357 -155
- package/src/sdk/startup-capability.ts +1 -0
- package/src/session/agent-session.ts +640 -103
- package/src/session/blob-store.ts +15 -5
- package/src/session/credential-store-errors.ts +2 -0
- package/src/session/internal/managed-session-storage.ts +153 -78
- package/src/session/session-manager.ts +337 -24
- package/src/session/yield-queue.ts +24 -10
- package/src/setup/frictionless-onboarding.ts +2 -0
- package/src/telemetry/events.ts +1138 -12
- package/src/tools/bash.ts +256 -42
- package/src/tools/gh.ts +104 -48
- package/src/tools/github-cache.ts +2 -2
- package/src/tools/index.ts +4 -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,14 @@ 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;
|
|
49
89
|
/** Host model resolver override for lifecycle tests and embedders. */
|
|
50
90
|
resolveModelPin?: SdkHostModelResolver;
|
|
51
91
|
}
|
|
@@ -56,6 +96,10 @@ type ResolvedBrokerSettings = {
|
|
|
56
96
|
port: number;
|
|
57
97
|
heartbeatTtlMs: number;
|
|
58
98
|
resolveDirectoryMigration: (_cwd: string) => Promise<DirectoryMigrationPolicy>;
|
|
99
|
+
masterCapabilityVerifier?: MasterCapabilityVerifier;
|
|
100
|
+
spawnSubstrateProvider?: SpawnSubstrateProvider;
|
|
101
|
+
spawnPromptLayer?: SpawnPromptLayer;
|
|
102
|
+
masterOrphanGraceMs: number;
|
|
59
103
|
};
|
|
60
104
|
|
|
61
105
|
function modelResolutionCwd(input: Record<string, unknown>): string | undefined {
|
|
@@ -181,7 +225,7 @@ export type BrokerResponse =
|
|
|
181
225
|
error: {
|
|
182
226
|
code: BrokerErrorCode;
|
|
183
227
|
message: string;
|
|
184
|
-
details?: ModelProfileErrorDetails;
|
|
228
|
+
details?: ModelProfileErrorDetails | SpawnSubstrateFailure;
|
|
185
229
|
endpoint?: "unavailable";
|
|
186
230
|
cleanup?: BrokerCleanupEvidence;
|
|
187
231
|
};
|
|
@@ -190,6 +234,14 @@ export type BrokerResponse =
|
|
|
190
234
|
startupFailure?: LifecycleStartupFailureReceipt;
|
|
191
235
|
};
|
|
192
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
|
+
});
|
|
193
245
|
|
|
194
246
|
function isCleanupPending(response: BrokerResponse): boolean {
|
|
195
247
|
if (response.ok) return false;
|
|
@@ -255,12 +307,151 @@ type SessionListCursor = {
|
|
|
255
307
|
limit: number;
|
|
256
308
|
offset: number;
|
|
257
309
|
expiresAt: number;
|
|
310
|
+
scope?: ResolvedScopeV1;
|
|
311
|
+
observedAt?: string;
|
|
258
312
|
};
|
|
259
313
|
|
|
260
314
|
const SESSION_LIST_DEFAULT_LIMIT = 100;
|
|
261
315
|
const SESSION_LIST_MAX_LIMIT = 100;
|
|
262
316
|
const SESSION_LIST_CURSOR_TTL_MS = 15 * 60 * 1_000;
|
|
263
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
|
+
}
|
|
264
455
|
|
|
265
456
|
function sessionListLimit(input: Record<string, unknown>): number | BrokerResponse {
|
|
266
457
|
const limit = input.limit;
|
|
@@ -274,6 +465,125 @@ function isBrokerResponse(value: unknown): value is BrokerResponse {
|
|
|
274
465
|
return typeof value === "object" && value !== null && "ok" in value && typeof value.ok === "boolean";
|
|
275
466
|
}
|
|
276
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
|
+
}
|
|
277
587
|
function normalizeAliasedString(
|
|
278
588
|
input: Record<string, unknown>,
|
|
279
589
|
canonical: string,
|
|
@@ -318,6 +628,8 @@ function normalizeBrokerInput(operation: string, input: Record<string, unknown>)
|
|
|
318
628
|
return error("invalid_input", "cursor must be a non-empty opaque string");
|
|
319
629
|
const limit = sessionListLimit(input);
|
|
320
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");
|
|
321
633
|
return { input: normalized };
|
|
322
634
|
}
|
|
323
635
|
if (
|
|
@@ -387,7 +699,13 @@ function credentialFreeLifecycleResponse(value: unknown): unknown {
|
|
|
387
699
|
if (value === null || typeof value !== "object") return value;
|
|
388
700
|
const output: Record<string, unknown> = {};
|
|
389
701
|
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
|
390
|
-
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
|
+
)
|
|
391
709
|
continue;
|
|
392
710
|
output[key] = credentialFreeLifecycleResponse(nested);
|
|
393
711
|
}
|
|
@@ -458,11 +776,98 @@ function sameEndpointRecord(expected: IndexedSession, current: IndexedSession):
|
|
|
458
776
|
current.endpointGeneration === expected.endpointGeneration &&
|
|
459
777
|
current.pid === expected.pid &&
|
|
460
778
|
current.endpointMtimeMs === expected.endpointMtimeMs &&
|
|
461
|
-
|
|
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) &&
|
|
462
783
|
path.resolve(current.locator.stateRoot) === path.resolve(expected.locator.stateRoot)
|
|
463
784
|
);
|
|
464
785
|
}
|
|
465
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
|
+
|
|
466
871
|
function lifecycleTarget(operation: string, input: Record<string, unknown>): unknown {
|
|
467
872
|
const target = input.target as Record<string, unknown> | undefined;
|
|
468
873
|
const string = (...values: unknown[]): string | undefined =>
|
|
@@ -850,6 +1255,11 @@ export class Broker {
|
|
|
850
1255
|
#owner = randomBytes(12).toString("hex");
|
|
851
1256
|
#sessionListCursors = new Map<string, SessionListCursor>();
|
|
852
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;
|
|
853
1263
|
#admitted = new Set<Promise<void>>();
|
|
854
1264
|
#startupAdmissions = new StartupAdmissionQueue(sdkHostStartupConcurrency());
|
|
855
1265
|
#publication: RetainedBrokerDiscovery | null = null;
|
|
@@ -874,17 +1284,1031 @@ export class Broker {
|
|
|
874
1284
|
port: settings.port ?? 0,
|
|
875
1285
|
heartbeatTtlMs: settings.heartbeatTtlMs ?? BROKER_HEARTBEAT_TTL_MS,
|
|
876
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,
|
|
877
1291
|
};
|
|
878
1292
|
this.index = new SessionIndex(settings.agentDir);
|
|
879
1293
|
this.ledger = new LifecycleLedger(settings.agentDir);
|
|
880
1294
|
this.#ownsResolveModelPin = settings.resolveModelPin === undefined;
|
|
881
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
|
+
};
|
|
882
1303
|
this.#lock = path.join(settings.agentDir, "sdk", "broker.lock");
|
|
883
1304
|
const completion = Promise.withResolvers<void>();
|
|
884
1305
|
this.#completion = completion.promise;
|
|
885
1306
|
this.#resolveCompletion = completion.resolve;
|
|
886
1307
|
this.#rejectCompletion = completion.reject;
|
|
887
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
|
+
|
|
888
2312
|
runStartup<T>(
|
|
889
2313
|
queueWaitMs: number,
|
|
890
2314
|
timing: StartupAdmissionTiming,
|
|
@@ -1086,6 +2510,16 @@ export class Broker {
|
|
|
1086
2510
|
try {
|
|
1087
2511
|
await this.index.open();
|
|
1088
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();
|
|
1089
2523
|
const now = Date.now();
|
|
1090
2524
|
const incarnation = brokerProcessIncarnation(process.pid);
|
|
1091
2525
|
if (!incarnation) throw new Error("Broker process incarnation is unavailable.");
|
|
@@ -1113,7 +2547,10 @@ export class Broker {
|
|
|
1113
2547
|
10,
|
|
1114
2548
|
Math.min(BROKER_PUBLICATION_CADENCE_MS, Math.floor(this.settings.heartbeatTtlMs / 3)),
|
|
1115
2549
|
);
|
|
1116
|
-
this.#heartbeatTimer = setInterval(() =>
|
|
2550
|
+
this.#heartbeatTimer = setInterval(() => {
|
|
2551
|
+
void this.#watchPublication();
|
|
2552
|
+
void this.#reapSpawnOrphans();
|
|
2553
|
+
}, cadenceMs);
|
|
1117
2554
|
await this.#checkpointSessionHeartbeats();
|
|
1118
2555
|
return this.discovery;
|
|
1119
2556
|
} catch (error) {
|
|
@@ -1433,25 +2870,22 @@ export class Broker {
|
|
|
1433
2870
|
|
|
1434
2871
|
try {
|
|
1435
2872
|
const endpointPath = path.join(record.locator.stateRoot, "sdk", `${record.sessionId}.json`);
|
|
1436
|
-
const
|
|
1437
|
-
if (!
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
)
|
|
1447
|
-
return error("endpoint_stale", "session endpoint changed during read");
|
|
1448
|
-
const endpoint = JSON.parse(source) as Record<string, unknown>;
|
|
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>;
|
|
1449
2884
|
if (
|
|
1450
2885
|
endpoint.sessionId !== record.sessionId ||
|
|
1451
2886
|
endpoint.pid !== record.pid ||
|
|
1452
2887
|
endpoint.stale === true ||
|
|
1453
|
-
record
|
|
1454
|
-
Math.abs(Number(metadata.mtimeNs) / 1_000_000 - record.endpointMtimeMs) > 0.001
|
|
2888
|
+
!matchesIndexedEndpointFile(file, record)
|
|
1455
2889
|
)
|
|
1456
2890
|
return error("endpoint_stale", "session endpoint is stale");
|
|
1457
2891
|
await this.index.refresh();
|
|
@@ -1465,6 +2899,139 @@ export class Broker {
|
|
|
1465
2899
|
throw e;
|
|
1466
2900
|
}
|
|
1467
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
|
+
}
|
|
1468
3035
|
#storeSessionListCursor(cursor: SessionListCursor, replacingToken?: string): string | BrokerResponse {
|
|
1469
3036
|
const now = Date.now();
|
|
1470
3037
|
for (const [token, stored] of this.#sessionListCursors) {
|
|
@@ -1479,7 +3046,7 @@ export class Broker {
|
|
|
1479
3046
|
return token;
|
|
1480
3047
|
}
|
|
1481
3048
|
|
|
1482
|
-
#sessionListPage(input: Record<string, unknown>, result: SessionList): BrokerResponse {
|
|
3049
|
+
async #sessionListPage(input: Record<string, unknown>, result: SessionList): Promise<BrokerResponse> {
|
|
1483
3050
|
const requestedLimit = input.limit === undefined ? undefined : sessionListLimit(input);
|
|
1484
3051
|
if (isBrokerResponse(requestedLimit)) return requestedLimit;
|
|
1485
3052
|
const cursor = input.cursor;
|
|
@@ -1490,14 +3057,46 @@ export class Broker {
|
|
|
1490
3057
|
}
|
|
1491
3058
|
if (stored && requestedLimit !== undefined && stored.limit !== requestedLimit)
|
|
1492
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
|
+
}
|
|
1493
3087
|
const limit = stored?.limit ?? requestedLimit ?? SESSION_LIST_DEFAULT_LIMIT;
|
|
3088
|
+
const observedAt = stored?.observedAt ?? (scope === undefined ? undefined : new Date().toISOString());
|
|
1494
3089
|
const snapshot = stored ?? {
|
|
1495
|
-
sessions:
|
|
3090
|
+
sessions:
|
|
3091
|
+
scope === undefined
|
|
3092
|
+
? [...result.sessions]
|
|
3093
|
+
: result.sessions.filter(session => scopeMatchesLocator(scope, session.locator)),
|
|
1496
3094
|
indexSeq: result.indexSeq,
|
|
1497
3095
|
warnings: [...result.warnings],
|
|
1498
3096
|
limit,
|
|
1499
3097
|
offset: 0,
|
|
1500
3098
|
expiresAt: Date.now() + SESSION_LIST_CURSOR_TTL_MS,
|
|
3099
|
+
...(scope === undefined ? {} : { scope, observedAt: observedAt! }),
|
|
1501
3100
|
};
|
|
1502
3101
|
const sessions = snapshot.sessions.slice(snapshot.offset, snapshot.offset + snapshot.limit).map(session => {
|
|
1503
3102
|
const { lifecycleRequestId: _lifecycleRequestId, ...publicSession } = session;
|
|
@@ -1519,6 +3118,7 @@ export class Broker {
|
|
|
1519
3118
|
indexSeq: snapshot.indexSeq,
|
|
1520
3119
|
sessions,
|
|
1521
3120
|
warnings: snapshot.warnings,
|
|
3121
|
+
...(snapshot.scope === undefined ? {} : { scope: snapshot.scope, observedAt: snapshot.observedAt }),
|
|
1522
3122
|
...(continuationCursor ? { continuationCursor } : {}),
|
|
1523
3123
|
},
|
|
1524
3124
|
indexSeq: snapshot.indexSeq,
|
|
@@ -1541,13 +3141,21 @@ export class Broker {
|
|
|
1541
3141
|
idempotencyKey?: string,
|
|
1542
3142
|
): Promise<BrokerResponse> {
|
|
1543
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);
|
|
1544
3152
|
const fingerprint = lifecycleFingerprint(operation, input);
|
|
1545
3153
|
const normalization = normalizeBrokerInput(operation, input);
|
|
1546
3154
|
if (isBrokerResponse(normalization)) return normalization;
|
|
1547
3155
|
input = normalization.input;
|
|
1548
3156
|
if (operation === "session.list") {
|
|
1549
3157
|
if (input.cursor === undefined) await this.index.refresh();
|
|
1550
|
-
const page = this.#sessionListPage(input, this.index.listSessions());
|
|
3158
|
+
const page = await this.#sessionListPage(input, this.index.listSessions());
|
|
1551
3159
|
if (!page.ok) return page;
|
|
1552
3160
|
const pageResult = page.result as Record<string, unknown>;
|
|
1553
3161
|
const resolveSessionId = typeof input.resolveSessionId === "string" ? input.resolveSessionId : undefined;
|
|
@@ -1605,6 +3213,8 @@ export class Broker {
|
|
|
1605
3213
|
const requestedFingerprint = typeof input.fingerprint === "string" ? input.fingerprint : undefined;
|
|
1606
3214
|
if (!idempotencyKey || !requestedOperation || !requestedFingerprint)
|
|
1607
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");
|
|
1608
3218
|
if (!LIFECYCLE_OPERATIONS.has(requestedOperation))
|
|
1609
3219
|
return error("not_found", "lifecycle operation was not found");
|
|
1610
3220
|
const identity = await deriveIdempotencyIdentity(this.settings.agentDir, requestedOperation, idempotencyKey);
|