@gajae-code/coding-agent 0.16.3 → 0.16.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +113 -0
- package/dist/types/capability/types.d.ts +4 -0
- package/dist/types/cli/args.d.ts +2 -2
- package/dist/types/cli/customize-doctor.d.ts +22 -0
- package/dist/types/cli/root-flags.d.ts +4 -2
- package/dist/types/cli.d.ts +1 -1
- package/dist/types/commands/launch.d.ts +1 -1
- package/dist/types/config/autorouting-tier-map.d.ts +3 -0
- package/dist/types/config/file-lock.d.ts +31 -3
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/model-resolver.d.ts +9 -0
- package/dist/types/config/settings-schema.d.ts +29 -1
- package/dist/types/coordinator-mcp/question-state.d.ts +7 -0
- package/dist/types/coordinator-mcp/server.d.ts +7 -0
- package/dist/types/discovery/claude-plugins.d.ts +3 -1
- package/dist/types/discovery/helpers.d.ts +35 -0
- package/dist/types/exec/bash-executor.d.ts +17 -1
- package/dist/types/exec/bash-shell-guardian.d.ts +15 -0
- package/dist/types/exec/bash-shell-runtime-entry.d.ts +2 -0
- package/dist/types/exec/bash-shell-signals.d.ts +2 -0
- package/dist/types/exec/bash-shell-supervisor-entry.d.ts +1 -0
- package/dist/types/exec/bash-shell-supervisor.d.ts +10 -0
- package/dist/types/exec/bash-shell-worker-entry.d.ts +2 -0
- package/dist/types/exec/bash-shell-worker-protocol.d.ts +63 -0
- package/dist/types/exec/bash-shell-worker.d.ts +1 -0
- package/dist/types/exec/isolated-shell.d.ts +19 -0
- package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +9 -0
- package/dist/types/extensibility/extensions/function-hooks.d.ts +169 -0
- package/dist/types/extensibility/extensions/index.d.ts +1 -0
- package/dist/types/extensibility/extensions/runner.d.ts +46 -6
- package/dist/types/extensibility/extensions/types.d.ts +16 -0
- package/dist/types/extensibility/extensions/wrapper.d.ts +3 -1
- package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +35 -2
- package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +16 -0
- package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -0
- package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
- package/dist/types/extensibility/hooks/types.d.ts +4 -0
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +5 -2
- package/dist/types/extensibility/shared-events.d.ts +1 -1
- package/dist/types/extensibility/skill-management.d.ts +9 -4
- package/dist/types/extensibility/skills.d.ts +2 -0
- package/dist/types/gjc-runtime/empty-delete-gc.d.ts +4 -1
- package/dist/types/gjc-runtime/linux-proc.d.ts +2 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +14 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +41 -3
- package/dist/types/gjc-runtime/ultragoal-succession.d.ts +251 -0
- package/dist/types/modes/components/assistant-message.d.ts +1 -2
- package/dist/types/modes/components/bash-execution.d.ts +1 -0
- package/dist/types/modes/components/execution-shared.d.ts +1 -0
- package/dist/types/modes/components/settings-selector.d.ts +6 -1
- package/dist/types/modes/components/status-line/command.d.ts +35 -0
- package/dist/types/modes/components/status-line/index.d.ts +1 -0
- package/dist/types/modes/components/status-line/types.d.ts +5 -0
- package/dist/types/modes/components/tool-status-header.d.ts +14 -0
- package/dist/types/modes/controllers/event-controller.d.ts +6 -0
- package/dist/types/modes/interactive-mode.d.ts +3 -0
- package/dist/types/modes/types.d.ts +7 -0
- package/dist/types/runtime-mcp/client.d.ts +9 -1
- package/dist/types/runtime-mcp/manager.d.ts +7 -1
- package/dist/types/runtime-mcp/pool.d.ts +22 -3
- package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
- package/dist/types/runtime-mcp/types.d.ts +22 -0
- package/dist/types/sdk/broker/ensure.d.ts +32 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +0 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
- package/dist/types/sdk/broker/startup-failure.d.ts +4 -1
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -0
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +12 -0
- package/dist/types/sdk/bus/chat-effect-journal.d.ts +17 -2
- package/dist/types/sdk/bus/conversation-store.d.ts +11 -0
- package/dist/types/sdk/bus/lifecycle-commands.d.ts +8 -8
- package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
- package/dist/types/sdk/bus/slack-live-provider.d.ts +6 -0
- package/dist/types/sdk/bus/slack-provider.d.ts +12 -0
- package/dist/types/sdk/bus/topic-registry.d.ts +8 -0
- package/dist/types/sdk/client/client.d.ts +6 -0
- package/dist/types/sdk/host/host.d.ts +3 -1
- package/dist/types/sdk/host/session-runtime.d.ts +26 -1
- package/dist/types/sdk/lifecycle/service.d.ts +1 -0
- package/dist/types/sdk/router/session-router.d.ts +10 -2
- package/dist/types/sdk/session-api-key.d.ts +26 -0
- package/dist/types/sdk/session.d.ts +5 -1
- package/dist/types/sdk/transport/serve-cli.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +11 -0
- package/dist/types/session/fallback-chain-controller.d.ts +15 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
- package/dist/types/session/messages.d.ts +1 -0
- package/dist/types/session/session-manager.d.ts +3 -1
- package/dist/types/setup/paseo/announce.d.ts +112 -0
- package/dist/types/setup/paseo/setup-deps.d.ts +2 -1
- package/dist/types/skill-state/canonical-skills.d.ts +4 -0
- package/dist/types/system-prompt.d.ts +10 -0
- package/dist/types/task/executor.d.ts +5 -1
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tools/fetch.d.ts +7 -0
- package/dist/types/tools/github-cache.d.ts +2 -1
- package/dist/types/tools/index.d.ts +10 -0
- package/dist/types/tools/path-utils.d.ts +2 -0
- package/dist/types/tools/search.d.ts +6 -0
- package/package.json +9 -7
- package/scripts/capture-status-line-custom-editor-showcase.ts +151 -0
- package/scripts/generate-sdk-operation-inventory.ts +11 -0
- package/scripts/verify-status-line-custom-editor-showcase.ts +309 -0
- package/src/capability/index.ts +14 -2
- package/src/capability/types.ts +4 -0
- package/src/cli/args.ts +20 -9
- package/src/cli/customize-doctor.ts +155 -12
- package/src/cli/root-flags.ts +5 -3
- package/src/cli.ts +22 -0
- package/src/commands/sdk.ts +74 -31
- package/src/config/autorouting-tier-map.ts +1 -0
- package/src/config/file-lock-gc.ts +40 -2
- package/src/config/file-lock.ts +379 -31
- package/src/config/model-preset-registry.ts +120 -19
- package/src/config/model-profiles.ts +40 -0
- package/src/config/model-registry.ts +73 -23
- package/src/config/model-resolver.ts +48 -0
- package/src/config/settings-schema.ts +33 -1
- package/src/config/settings.ts +1 -0
- package/src/coordinator-mcp/question-state.ts +33 -2
- package/src/coordinator-mcp/server.ts +477 -101
- package/src/crash/sanitize.ts +1 -1
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +11 -11
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +13 -2
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +22 -0
- package/src/discovery/builtin.ts +52 -55
- package/src/discovery/claude-plugins.ts +12 -4
- package/src/discovery/helpers.ts +307 -34
- package/src/exec/bash-executor.ts +330 -50
- package/src/exec/bash-shell-guardian.ts +388 -0
- package/src/exec/bash-shell-runtime-entry.ts +5 -0
- package/src/exec/bash-shell-signals.ts +29 -0
- package/src/exec/bash-shell-supervisor-entry.ts +3 -0
- package/src/exec/bash-shell-supervisor.ts +359 -0
- package/src/exec/bash-shell-worker-entry.ts +5 -0
- package/src/exec/bash-shell-worker-protocol.ts +36 -0
- package/src/exec/bash-shell-worker.ts +116 -0
- package/src/exec/isolated-shell.ts +393 -0
- package/src/extensibility/extensions/function-hooks-internal.ts +73 -0
- package/src/extensibility/extensions/function-hooks.ts +959 -0
- package/src/extensibility/extensions/index.ts +22 -0
- package/src/extensibility/extensions/loader.ts +53 -2
- package/src/extensibility/extensions/runner.ts +895 -264
- package/src/extensibility/extensions/types.ts +42 -0
- package/src/extensibility/extensions/wrapper.ts +56 -15
- package/src/extensibility/gjc-plugins/compiler.ts +228 -60
- package/src/extensibility/gjc-plugins/constrained-hooks.ts +321 -23
- package/src/extensibility/gjc-plugins/installer.ts +13 -12
- package/src/extensibility/gjc-plugins/mcp-policy.ts +81 -52
- package/src/extensibility/gjc-plugins/paths.ts +13 -0
- package/src/extensibility/gjc-plugins/registry.ts +113 -1
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +758 -16
- package/src/extensibility/gjc-plugins/runtime-quarantine.ts +20 -2
- package/src/extensibility/gjc-plugins/schema.ts +121 -4
- package/src/extensibility/gjc-plugins/types.ts +25 -0
- package/src/extensibility/hooks/loader.ts +133 -16
- package/src/extensibility/hooks/types.ts +28 -0
- package/src/extensibility/runtime-skill-discovery.ts +149 -190
- package/src/extensibility/shared-events.ts +1 -1
- package/src/extensibility/skill-management.ts +200 -52
- package/src/extensibility/skills.ts +73 -25
- package/src/gjc-runtime/empty-delete-gc.ts +94 -4
- package/src/gjc-runtime/gc-runtime.ts +29 -0
- package/src/gjc-runtime/linux-proc.ts +14 -0
- package/src/gjc-runtime/repository-binding.ts +1 -1
- package/src/gjc-runtime/session-state-lock.ts +143 -10
- package/src/gjc-runtime/session-state-sidecar.ts +27 -11
- package/src/gjc-runtime/state-writer.ts +30 -29
- package/src/gjc-runtime/ultragoal-runtime.ts +189 -47
- package/src/gjc-runtime/ultragoal-succession.ts +1667 -0
- package/src/gjc-runtime/workflow-manifest.generated.json +52 -1
- package/src/gjc-runtime/workflow-manifest.ts +16 -0
- package/src/harness-control-plane/session-lease.ts +2 -2
- package/src/internal-urls/docs-index.generated.ts +12 -12
- package/src/lsp/config.ts +10 -3
- package/src/lsp/index.ts +1 -1
- package/src/lsp/path-trust.ts +69 -14
- package/src/main.ts +78 -35
- package/src/modes/DESIGN.md +54 -0
- package/src/modes/acp/acp-agent.ts +16 -3
- package/src/modes/components/assistant-message.ts +22 -9
- package/src/modes/components/bash-execution.ts +4 -1
- package/src/modes/components/execution-shared.ts +4 -1
- package/src/modes/components/model-selector.ts +166 -33
- package/src/modes/components/session-observer-overlay.ts +1 -1
- package/src/modes/components/settings-selector.ts +1075 -235
- package/src/modes/components/status-line/command.ts +190 -0
- package/src/modes/components/status-line/index.ts +1 -0
- package/src/modes/components/status-line/segments.ts +32 -5
- package/src/modes/components/status-line/types.ts +5 -0
- package/src/modes/components/tool-status-header.ts +396 -22
- package/src/modes/controllers/command-controller.ts +3 -0
- package/src/modes/controllers/event-controller.ts +151 -7
- package/src/modes/controllers/extension-ui-controller.ts +1 -0
- package/src/modes/controllers/input-controller.ts +6 -3
- package/src/modes/controllers/selector-controller.ts +21 -0
- package/src/modes/interactive-mode.ts +53 -6
- package/src/modes/print-mode.ts +5 -1
- package/src/modes/prompt-action-autocomplete.ts +54 -27
- package/src/modes/runtime-init.ts +2 -0
- package/src/modes/types.ts +17 -3
- package/src/modes/utils/ui-helpers.ts +2 -0
- package/src/prompts/system/system-prompt.md +8 -0
- package/src/prompts/tools/search.md +2 -0
- package/src/prompts/tools/subagent.md +1 -0
- package/src/runtime-mcp/client.ts +60 -17
- package/src/runtime-mcp/manager.ts +189 -14
- package/src/runtime-mcp/pool.test.ts +218 -0
- package/src/runtime-mcp/pool.ts +224 -23
- package/src/runtime-mcp/transports/stdio.ts +158 -24
- package/src/runtime-mcp/types.ts +24 -0
- package/src/sdk/broker/broker.ts +124 -32
- package/src/sdk/broker/ensure.ts +248 -41
- package/src/sdk/broker/lifecycle-ledger.ts +7 -17
- package/src/sdk/broker/lifecycle.ts +56 -6
- package/src/sdk/broker/startup-failure.ts +15 -6
- package/src/sdk/bus/chat-daemon-control.ts +3 -1
- package/src/sdk/bus/chat-daemon-runtime.ts +72 -6
- package/src/sdk/bus/chat-effect-journal.ts +132 -7
- package/src/sdk/bus/conversation-store.ts +29 -0
- package/src/sdk/bus/lifecycle-commands.ts +17 -11
- package/src/sdk/bus/slack-conversation.ts +1 -0
- package/src/sdk/bus/slack-daemon.ts +478 -49
- package/src/sdk/bus/slack-live-provider.ts +12 -0
- package/src/sdk/bus/slack-provider.ts +5 -0
- package/src/sdk/bus/telegram-daemon.ts +16 -2
- package/src/sdk/bus/topic-registry.ts +19 -0
- package/src/sdk/cli/master-cli.ts +1 -1
- package/src/sdk/cli/session-cli.ts +11 -8
- package/src/sdk/client/client.ts +66 -17
- package/src/sdk/host/host.ts +29 -9
- package/src/sdk/host/query/revision-store.ts +19 -13
- package/src/sdk/host/reverse-leases.ts +7 -4
- package/src/sdk/host/session-runtime.test.ts +52 -10
- package/src/sdk/host/session-runtime.ts +383 -44
- package/src/sdk/lifecycle/service.ts +4 -0
- package/src/sdk/protocol/operation-inventory.generated.json +77 -0
- package/src/sdk/router/session-router.ts +89 -38
- package/src/sdk/session-api-key.ts +94 -0
- package/src/sdk/session.ts +228 -55
- package/src/sdk/transport/relay.ts +35 -13
- package/src/sdk/transport/serve-cli.ts +3 -3
- package/src/session/agent-session.ts +240 -90
- package/src/session/contribution-prep.ts +214 -21
- package/src/session/fallback-chain-controller.ts +38 -0
- package/src/session/internal/managed-session-storage.ts +19 -5
- package/src/session/messages.ts +5 -0
- package/src/session/session-manager.ts +29 -5
- package/src/session-import/redact.ts +5 -1
- package/src/setup/paseo/announce.ts +456 -0
- package/src/setup/paseo/setup-deps.ts +15 -3
- package/src/skill-state/canonical-skills.ts +12 -0
- package/src/skills/skills.test.ts +41 -21
- package/src/slash-commands/builtin-registry.ts +1 -0
- package/src/system-prompt.ts +29 -3
- package/src/task/executor.ts +7 -1
- package/src/task/index.ts +21 -4
- package/src/tools/bash.ts +63 -19
- package/src/tools/cron.ts +1 -1
- package/src/tools/fetch.ts +29 -1
- package/src/tools/gh.ts +26 -3
- package/src/tools/github-cache.ts +68 -16
- package/src/tools/index.ts +10 -0
- package/src/tools/job.ts +21 -4
- package/src/tools/monitor.ts +8 -2
- package/src/tools/path-utils.ts +7 -0
- package/src/tools/read.ts +14 -2
- package/src/tools/render-utils.ts +1 -3
- package/src/tools/resource-gc.ts +28 -8
- package/src/tools/search.ts +116 -58
- package/src/tools/skill-discovery.ts +5 -0
- package/src/tools/skill.ts +37 -2
- package/src/tools/subagent.ts +6 -3
- package/src/tools/tool-catalog.generated.ts +10 -3
- package/src/tools/ultragoal-ask-guard.ts +2 -2
- package/src/web/insane/bridge.ts +113 -23
- package/src/web/search/providers/perplexity.ts +102 -41
package/src/sdk/broker/ensure.ts
CHANGED
|
@@ -4,6 +4,8 @@ import type { FileHandle } from "node:fs/promises";
|
|
|
4
4
|
import * as fs from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import packageJson from "../../../package.json" with { type: "json" };
|
|
7
|
+
import { acquireFileLock, type FileLockOptions, withFileLock } from "../../config/file-lock";
|
|
8
|
+
import { loadInstallationHostId, loadLegacyInstallationHostId } from "../../config/machine-identity";
|
|
7
9
|
import { SdkClient } from "../client/client";
|
|
8
10
|
import { type BrokerDiscovery, brokerProcessIncarnation, readBrokerDiscovery } from "./discovery";
|
|
9
11
|
import { resolveSdkInternalSpawnCommand, type SdkInternalSpawnCommand } from "./runtime";
|
|
@@ -14,7 +16,7 @@ function resolveExpectedBrokerGeneration(): string {
|
|
|
14
16
|
return typeof v === "string" && v.length > 0 ? v : "unknown";
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
function isBrokerGenerationCompatible(discovery: BrokerDiscovery | null): boolean {
|
|
19
|
+
export function isBrokerGenerationCompatible(discovery: BrokerDiscovery | null): boolean {
|
|
18
20
|
if (!discovery) return false;
|
|
19
21
|
return discovery.packageGeneration === resolveExpectedBrokerGeneration();
|
|
20
22
|
}
|
|
@@ -29,7 +31,110 @@ export interface EnsureBrokerSettings {
|
|
|
29
31
|
env?: NodeJS.ProcessEnv;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
const
|
|
34
|
+
const BROKER_PUBLICATION_TIMEOUT_MS = 15_000;
|
|
35
|
+
const STARTUP_LOCK_WAIT_MS = 9_000;
|
|
36
|
+
const STALE_BROKER_RETIREMENT_TIMEOUT_MS = 5_000;
|
|
37
|
+
/** Parent budget covers stale retirement, a full child-fence wait, and one publication attempt. */
|
|
38
|
+
const DISCOVERY_TIMEOUT_MS = STALE_BROKER_RETIREMENT_TIMEOUT_MS + STARTUP_LOCK_WAIT_MS + BROKER_PUBLICATION_TIMEOUT_MS;
|
|
39
|
+
/** A process that loses the spawn lock waits this long for the winner's discovery before giving up. */
|
|
40
|
+
const SPAWN_LOCK_WAIT_MS = STALE_BROKER_RETIREMENT_TIMEOUT_MS + DISCOVERY_TIMEOUT_MS + 5_000;
|
|
41
|
+
const SPAWN_LOCK_RETRY_DELAY_MS = 50;
|
|
42
|
+
const SPAWN_LOCK_TARGET_NAME = "broker.spawn";
|
|
43
|
+
const STARTUP_LOCK_TARGET_NAME = "broker.startup";
|
|
44
|
+
|
|
45
|
+
type SpawnLockOptions = Pick<FileLockOptions, "retries" | "retryDelayMs" | "signal">;
|
|
46
|
+
|
|
47
|
+
interface BrokerLockHostIdentity {
|
|
48
|
+
ownerHostId: string;
|
|
49
|
+
previousOwnerHostIds: readonly string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const brokerLockHostIdentityPromises = new Map<string, Promise<BrokerLockHostIdentity>>();
|
|
53
|
+
|
|
54
|
+
async function brokerLockHostIdentity(agentDir: string): Promise<BrokerLockHostIdentity> {
|
|
55
|
+
const coordinationRoot = path.resolve(agentDir, "sdk");
|
|
56
|
+
const pending =
|
|
57
|
+
brokerLockHostIdentityPromises.get(coordinationRoot) ??
|
|
58
|
+
Promise.all([loadInstallationHostId({ configRootDir: coordinationRoot }), loadLegacyInstallationHostId()]).then(
|
|
59
|
+
([ownerHostId, legacyHostId]) => ({
|
|
60
|
+
ownerHostId,
|
|
61
|
+
previousOwnerHostIds: legacyHostId === ownerHostId ? [] : [legacyHostId],
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
brokerLockHostIdentityPromises.set(coordinationRoot, pending);
|
|
65
|
+
try {
|
|
66
|
+
return await pending;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (brokerLockHostIdentityPromises.get(coordinationRoot) === pending)
|
|
69
|
+
brokerLockHostIdentityPromises.delete(coordinationRoot);
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function readBrokerDiscoveryBeforeDeadline(
|
|
75
|
+
agentDir: string,
|
|
76
|
+
heartbeatTtlMs: number | undefined,
|
|
77
|
+
deadline: number,
|
|
78
|
+
): Promise<BrokerDiscovery | null> {
|
|
79
|
+
const remainingMs = deadline - Date.now();
|
|
80
|
+
if (!Number.isFinite(remainingMs)) return await readBrokerDiscovery(agentDir, heartbeatTtlMs);
|
|
81
|
+
if (remainingMs <= 0) throw new Error("Timed out reading SDK broker discovery.");
|
|
82
|
+
const read = readBrokerDiscovery(agentDir, heartbeatTtlMs);
|
|
83
|
+
void read.catch(() => undefined);
|
|
84
|
+
const timeout = Promise.withResolvers<BrokerDiscovery | null>();
|
|
85
|
+
const timer: NodeJS.Timeout = setTimeout(
|
|
86
|
+
() => timeout.reject(new Error("Timed out reading SDK broker discovery.")),
|
|
87
|
+
remainingMs,
|
|
88
|
+
);
|
|
89
|
+
try {
|
|
90
|
+
return await Promise.race([read, timeout.promise]);
|
|
91
|
+
} finally {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Cross-process single-flight for the detached broker spawn (#5198).
|
|
98
|
+
*
|
|
99
|
+
* `ensureInFlight` only dedupes within one process. Every CLI invocation is
|
|
100
|
+
* its own process, so N concurrent invocations that all miss discovery each
|
|
101
|
+
* spawn a broker; the losers of the runtime's ownership lock then leave
|
|
102
|
+
* quarantine tombstones behind and the next start fails with
|
|
103
|
+
* `quarantine_collision`. The spawn itself must be exclusive across process
|
|
104
|
+
* death and PID reuse, so it uses the shared identity-bound file-lock protocol:
|
|
105
|
+
* complete owner metadata is published atomically, stale removal is bound to
|
|
106
|
+
* the exact directory generation, and ownership includes the OS incarnation.
|
|
107
|
+
*/
|
|
108
|
+
async function acquireSpawnLock(agentDir: string, options: SpawnLockOptions = {}): Promise<() => Promise<void>> {
|
|
109
|
+
const hostIdentity = await brokerLockHostIdentity(agentDir);
|
|
110
|
+
return acquireFileLock(path.join(agentDir, "sdk", SPAWN_LOCK_TARGET_NAME), {
|
|
111
|
+
retries: Math.ceil(SPAWN_LOCK_WAIT_MS / SPAWN_LOCK_RETRY_DELAY_MS),
|
|
112
|
+
retryDelayMs: SPAWN_LOCK_RETRY_DELAY_MS,
|
|
113
|
+
...hostIdentity,
|
|
114
|
+
...options,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Child-owned fence for the interval from bootstrap through discovery publication.
|
|
120
|
+
* The parent-held spawn lock prevents ordinary stampedes; this second fence keeps
|
|
121
|
+
* startup single-flight if that detached child's launcher dies and its lock is reclaimed.
|
|
122
|
+
*/
|
|
123
|
+
export async function withBrokerStartupLock<T>(
|
|
124
|
+
agentDir: string,
|
|
125
|
+
operation: (deadline: number) => Promise<T>,
|
|
126
|
+
): Promise<T> {
|
|
127
|
+
const hostIdentity = await brokerLockHostIdentity(agentDir);
|
|
128
|
+
return withFileLock(
|
|
129
|
+
path.join(agentDir, "sdk", STARTUP_LOCK_TARGET_NAME),
|
|
130
|
+
() => operation(Date.now() + STALE_BROKER_RETIREMENT_TIMEOUT_MS + BROKER_PUBLICATION_TIMEOUT_MS),
|
|
131
|
+
{
|
|
132
|
+
retries: Math.ceil(STARTUP_LOCK_WAIT_MS / SPAWN_LOCK_RETRY_DELAY_MS),
|
|
133
|
+
retryDelayMs: SPAWN_LOCK_RETRY_DELAY_MS,
|
|
134
|
+
...hostIdentity,
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
}
|
|
33
138
|
const FIXTURE_DISCOVERY_TIMEOUT_MS = 30_000;
|
|
34
139
|
// Bounded grace windows for reaping a spawned broker on failure, mirroring the
|
|
35
140
|
// owned-process teardown convention (SIGTERM -> grace -> SIGKILL -> hard cap).
|
|
@@ -260,7 +365,6 @@ function fixtureLeaseUnavailable(): Error {
|
|
|
260
365
|
return new Error("fixture_broker_lease_unavailable");
|
|
261
366
|
}
|
|
262
367
|
|
|
263
|
-
const STALE_BROKER_SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
264
368
|
const STALE_BROKER_POLL_MS = 50;
|
|
265
369
|
|
|
266
370
|
type BrokerOwnerIdentity = Pick<BrokerDiscovery, "ownerId" | "pid" | "incarnation">;
|
|
@@ -271,17 +375,34 @@ function sameBrokerOwner(left: BrokerOwnerIdentity | null, right: BrokerOwnerIde
|
|
|
271
375
|
);
|
|
272
376
|
}
|
|
273
377
|
|
|
274
|
-
|
|
378
|
+
/** Start client teardown, but never await it past the retirement operation's absolute deadline. */
|
|
379
|
+
export async function closeBrokerClientBeforeDeadline(
|
|
380
|
+
client: { close(): Promise<void> },
|
|
381
|
+
deadline: number,
|
|
382
|
+
): Promise<void> {
|
|
383
|
+
const close = client.close();
|
|
384
|
+
void close.catch(() => undefined);
|
|
385
|
+
const remainingMs = deadline - Date.now();
|
|
386
|
+
if (remainingMs <= 0) return;
|
|
387
|
+
await Promise.race([close, Bun.sleep(remainingMs)]);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function retireStaleBrokerGeneration(
|
|
391
|
+
stale: BrokerDiscovery,
|
|
392
|
+
settings: EnsureBrokerSettings,
|
|
393
|
+
outerDeadline = Number.POSITIVE_INFINITY,
|
|
394
|
+
): Promise<void> {
|
|
395
|
+
const deadline = Math.min(outerDeadline, Date.now() + STALE_BROKER_RETIREMENT_TIMEOUT_MS);
|
|
275
396
|
let shutdownSucceeded = false;
|
|
276
397
|
try {
|
|
277
|
-
const current = await
|
|
398
|
+
const current = await readBrokerDiscoveryBeforeDeadline(settings.agentDir, settings.heartbeatTtlMs, deadline);
|
|
278
399
|
if (!current || !sameBrokerOwner(current, stale)) return;
|
|
279
|
-
const client = await SdkClient.connect(current.url, current.token, { timeoutMs: 2_000 });
|
|
400
|
+
const client = await SdkClient.connect(current.url, current.token, { timeoutMs: 2_000, deadline });
|
|
280
401
|
try {
|
|
281
402
|
await client.global("broker.shutdown", {});
|
|
282
403
|
shutdownSucceeded = true;
|
|
283
404
|
} finally {
|
|
284
|
-
await client
|
|
405
|
+
await closeBrokerClientBeforeDeadline(client, deadline).catch(() => undefined);
|
|
285
406
|
}
|
|
286
407
|
} catch {}
|
|
287
408
|
if (!shutdownSucceeded) {
|
|
@@ -294,13 +415,25 @@ async function retireStaleBrokerGeneration(stale: BrokerDiscovery, settings: Ens
|
|
|
294
415
|
} catch {}
|
|
295
416
|
}
|
|
296
417
|
// Wait for the stale identity to disappear (owner-fenced: pid+incarnation).
|
|
297
|
-
const deadline = Date.now() + STALE_BROKER_SHUTDOWN_TIMEOUT_MS;
|
|
298
418
|
while (Date.now() < deadline) {
|
|
299
|
-
const current = await
|
|
419
|
+
const current = await readBrokerDiscoveryBeforeDeadline(settings.agentDir, settings.heartbeatTtlMs, deadline);
|
|
300
420
|
if (!sameBrokerOwner(current, stale)) break;
|
|
301
421
|
await sleep(STALE_BROKER_POLL_MS);
|
|
302
422
|
}
|
|
303
423
|
}
|
|
424
|
+
|
|
425
|
+
/** Reconcile an observed broker generation before entering a startup critical section. */
|
|
426
|
+
export async function reconcileBrokerGenerationForStartup(
|
|
427
|
+
settings: EnsureBrokerSettings,
|
|
428
|
+
deadline = Number.POSITIVE_INFINITY,
|
|
429
|
+
): Promise<BrokerDiscovery | undefined> {
|
|
430
|
+
const current = await readBrokerDiscoveryBeforeDeadline(settings.agentDir, settings.heartbeatTtlMs, deadline);
|
|
431
|
+
if (!current) return undefined;
|
|
432
|
+
if (isBrokerGenerationCompatible(current)) return current;
|
|
433
|
+
await retireStaleBrokerGeneration(current, settings, deadline);
|
|
434
|
+
const replacement = await readBrokerDiscoveryBeforeDeadline(settings.agentDir, settings.heartbeatTtlMs, deadline);
|
|
435
|
+
return replacement && isBrokerGenerationCompatible(replacement) ? replacement : undefined;
|
|
436
|
+
}
|
|
304
437
|
function createFixtureLeaseFromChild(child: ChildProcess, terminate: () => Promise<void>): ExactFixtureBrokerLease {
|
|
305
438
|
let termination: Promise<void> | undefined;
|
|
306
439
|
const hasExited = (): boolean => child.exitCode !== null || child.signalCode !== null || child.pid === undefined;
|
|
@@ -344,60 +477,79 @@ function createFixtureLease(owner: BrokerOwner, child: ChildProcess): ExactFixtu
|
|
|
344
477
|
}
|
|
345
478
|
|
|
346
479
|
async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: EnsureInitiator): Promise<EnsureOutcome> {
|
|
480
|
+
const initialDiscoveryDeadline =
|
|
481
|
+
Date.now() + (initiator === "fixture-lease" ? FIXTURE_DISCOVERY_TIMEOUT_MS : DISCOVERY_TIMEOUT_MS);
|
|
347
482
|
const priorOwner = owners.get(settings.agentDir);
|
|
348
|
-
const existing = await
|
|
483
|
+
const existing = await readBrokerDiscoveryBeforeDeadline(
|
|
484
|
+
settings.agentDir,
|
|
485
|
+
settings.heartbeatTtlMs,
|
|
486
|
+
initialDiscoveryDeadline,
|
|
487
|
+
);
|
|
349
488
|
if (initiator === "fixture-lease" && (priorOwner || existing)) throw fixtureLeaseUnavailable();
|
|
350
489
|
if (priorOwner) {
|
|
351
490
|
// A retained cleanup failure fences every discovery record. Only a ready
|
|
352
491
|
// record bound to this exact child incarnation may be reused.
|
|
353
492
|
if (priorOwner.canReuse(existing)) return { kind: "prior-local-owner", discovery: existing!, owner: priorOwner };
|
|
354
493
|
await priorOwner.stop();
|
|
355
|
-
const discoveredAfterCleanup = await
|
|
494
|
+
const discoveredAfterCleanup = await readBrokerDiscoveryBeforeDeadline(
|
|
495
|
+
settings.agentDir,
|
|
496
|
+
settings.heartbeatTtlMs,
|
|
497
|
+
initialDiscoveryDeadline,
|
|
498
|
+
);
|
|
356
499
|
if (discoveredAfterCleanup && isBrokerGenerationCompatible(discoveredAfterCleanup))
|
|
357
500
|
return { kind: "external-discovery", discovery: discoveredAfterCleanup };
|
|
358
|
-
//
|
|
359
|
-
if (!discoveredAfterCleanup) {
|
|
360
|
-
// no discovery left after cleanup — fall through to spawn
|
|
361
|
-
} else {
|
|
362
|
-
await retireStaleBrokerGeneration(discoveredAfterCleanup, settings);
|
|
363
|
-
const afterRetire = await readBrokerDiscovery(settings.agentDir, settings.heartbeatTtlMs);
|
|
364
|
-
if (afterRetire && isBrokerGenerationCompatible(afterRetire))
|
|
365
|
-
return { kind: "external-discovery", discovery: afterRetire };
|
|
366
|
-
}
|
|
501
|
+
// Stale-generation retirement is serialized under the spawn lock below.
|
|
367
502
|
} else if (existing) {
|
|
368
503
|
if (isBrokerGenerationCompatible(existing)) return { kind: "external-discovery", discovery: existing };
|
|
369
|
-
|
|
370
|
-
const afterRetire = await readBrokerDiscovery(settings.agentDir, settings.heartbeatTtlMs);
|
|
371
|
-
if (afterRetire && isBrokerGenerationCompatible(afterRetire))
|
|
372
|
-
return { kind: "external-discovery", discovery: afterRetire };
|
|
504
|
+
// Stale-generation retirement is serialized under the spawn lock below.
|
|
373
505
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
//
|
|
377
|
-
|
|
506
|
+
|
|
507
|
+
// Only one process may spawn for this agent dir at a time; everyone else
|
|
508
|
+
// waits for the exact lock generation to release, then rechecks discovery.
|
|
509
|
+
// Fixture leases always spawn their own.
|
|
510
|
+
let spawnLog: BrokerSpawnLog | undefined;
|
|
511
|
+
const releaseSpawnLock = initiator === "fixture-lease" ? async () => {} : await acquireSpawnLock(settings.agentDir);
|
|
378
512
|
try {
|
|
513
|
+
const deadline =
|
|
514
|
+
Date.now() + (initiator === "fixture-lease" ? FIXTURE_DISCOVERY_TIMEOUT_MS : DISCOVERY_TIMEOUT_MS);
|
|
515
|
+
// The lock winner may still find a discovery published by an earlier winner
|
|
516
|
+
// that finished between our first read and the lock acquisition.
|
|
517
|
+
const discoveredUnderLock = await reconcileBrokerGenerationForStartup(settings, deadline);
|
|
518
|
+
if (discoveredUnderLock) return { kind: "external-discovery", discovery: discoveredUnderLock };
|
|
519
|
+
const command = resolveSdkInternalSpawnCommand("broker-internal");
|
|
520
|
+
spawnLog = await openBrokerSpawnLog(settings.agentDir);
|
|
521
|
+
// A stale marker must never be misattributed to this spawn; clear it first.
|
|
522
|
+
await clearBrokerStartupFailureMarker(settings.agentDir);
|
|
379
523
|
const child = spawn(command.file, [...command.args, "--agent-dir", settings.agentDir], {
|
|
380
524
|
detached: true,
|
|
381
525
|
stdio: ["ignore", "ignore", spawnLog ? spawnLog.handle.fd : "ignore"],
|
|
382
526
|
env: brokerSpawnEnvironment(command, settings.env),
|
|
383
527
|
...(command.kind === "bun-source" ? { cwd: command.cwd } : {}),
|
|
384
528
|
});
|
|
385
|
-
// The child holds its own duplicate of the descriptor; this one is done.
|
|
386
|
-
await spawnLog?.handle.close();
|
|
387
|
-
child.unref();
|
|
388
529
|
let spawnError: Error | undefined;
|
|
389
530
|
child.once("error", error => {
|
|
390
531
|
spawnError = error;
|
|
391
532
|
});
|
|
533
|
+
const childIncarnation = child.pid === undefined ? undefined : brokerProcessIncarnation(child.pid);
|
|
392
534
|
const owner = registerBrokerOwner(settings.agentDir, child);
|
|
393
|
-
|
|
394
|
-
|
|
535
|
+
child.unref();
|
|
536
|
+
// The child holds its own duplicate of the descriptor. Failure to close the
|
|
537
|
+
// parent's diagnostic handle must not discard exact ownership of a live child.
|
|
538
|
+
await spawnLog?.handle.close().catch(() => undefined);
|
|
395
539
|
let discoveryError: unknown;
|
|
396
540
|
while (Date.now() < deadline) {
|
|
397
541
|
if (spawnError || child.exitCode !== null || child.signalCode !== null) break;
|
|
398
542
|
try {
|
|
399
|
-
const discovered = await
|
|
543
|
+
const discovered = await readBrokerDiscoveryBeforeDeadline(
|
|
544
|
+
settings.agentDir,
|
|
545
|
+
settings.heartbeatTtlMs,
|
|
546
|
+
deadline,
|
|
547
|
+
);
|
|
400
548
|
if (discovered) {
|
|
549
|
+
if (!isBrokerGenerationCompatible(discovered)) {
|
|
550
|
+
await sleep(50);
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
401
553
|
if (owner.markReady(discovered)) {
|
|
402
554
|
return initiator === "fixture-lease"
|
|
403
555
|
? { kind: "local-started-fixture", discovery: discovered, owner, child }
|
|
@@ -420,8 +572,12 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
|
|
|
420
572
|
// read failures fall through to the common cleanup + failure path below.
|
|
421
573
|
try {
|
|
422
574
|
for (let retry = 0; retry < 20; retry++) {
|
|
423
|
-
const winner = await
|
|
424
|
-
|
|
575
|
+
const winner = await readBrokerDiscoveryBeforeDeadline(
|
|
576
|
+
settings.agentDir,
|
|
577
|
+
settings.heartbeatTtlMs,
|
|
578
|
+
deadline,
|
|
579
|
+
);
|
|
580
|
+
if (winner && isBrokerGenerationCompatible(winner)) {
|
|
425
581
|
await owner.stop();
|
|
426
582
|
return { kind: "external-discovery", discovery: winner };
|
|
427
583
|
}
|
|
@@ -431,8 +587,53 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
|
|
|
431
587
|
// fall through to cleanup + failure
|
|
432
588
|
}
|
|
433
589
|
}
|
|
434
|
-
const spawnLogTail = exitedBeforeDiscovery && spawnLog ? await readBrokerSpawnLogTail(spawnLog.path) : "";
|
|
435
590
|
const marker = await readBrokerStartupFailureMarker(settings.agentDir);
|
|
591
|
+
const trustedMarker =
|
|
592
|
+
marker &&
|
|
593
|
+
child.pid !== undefined &&
|
|
594
|
+
childIncarnation !== undefined &&
|
|
595
|
+
marker.pid === child.pid &&
|
|
596
|
+
marker.incarnation === childIncarnation
|
|
597
|
+
? marker
|
|
598
|
+
: undefined;
|
|
599
|
+
const startupLockPath = path.join(settings.agentDir, "sdk", STARTUP_LOCK_TARGET_NAME);
|
|
600
|
+
let startupFenceContention =
|
|
601
|
+
exitedBeforeDiscovery &&
|
|
602
|
+
trustedMarker?.reason.startsWith(`Failed to acquire lock for ${startupLockPath} after `) === true;
|
|
603
|
+
if (!startupFenceContention && exitedBeforeDiscovery && child.exitCode !== 0) {
|
|
604
|
+
try {
|
|
605
|
+
startupFenceContention = (await fs.stat(`${startupLockPath}.lock`)).isDirectory();
|
|
606
|
+
} catch {
|
|
607
|
+
// The marker is best-effort and the fence may have been released
|
|
608
|
+
// between the child exit and this observation.
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (startupFenceContention) {
|
|
612
|
+
// A launcher can die after its detached child acquires the startup fence.
|
|
613
|
+
// The replacement child may exhaust its own fence wait before the incumbent
|
|
614
|
+
// reaches the end of its publication allowance, so keep the parent attached
|
|
615
|
+
// to the exact agent dir and give that incumbent one final bounded chance to
|
|
616
|
+
// publish before treating the replacement failure as terminal.
|
|
617
|
+
const recoveryDeadline = Math.min(deadline, Date.now() + BROKER_PUBLICATION_TIMEOUT_MS);
|
|
618
|
+
while (Date.now() < recoveryDeadline) {
|
|
619
|
+
try {
|
|
620
|
+
const incumbent = await readBrokerDiscoveryBeforeDeadline(
|
|
621
|
+
settings.agentDir,
|
|
622
|
+
settings.heartbeatTtlMs,
|
|
623
|
+
recoveryDeadline,
|
|
624
|
+
);
|
|
625
|
+
if (incumbent && isBrokerGenerationCompatible(incumbent)) {
|
|
626
|
+
await owner.stop();
|
|
627
|
+
return { kind: "external-discovery", discovery: incumbent };
|
|
628
|
+
}
|
|
629
|
+
} catch {
|
|
630
|
+
// Keep the bounded retry alive; a transient read failure is not
|
|
631
|
+
// authority to classify the incumbent as failed.
|
|
632
|
+
}
|
|
633
|
+
await sleep(50);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
const spawnLogTail = exitedBeforeDiscovery && spawnLog ? await readBrokerSpawnLogTail(spawnLog.path) : "";
|
|
436
637
|
// A marker only wins over the generic fallback when it was written by the
|
|
437
638
|
// exact child this call just spawned and reaped. The pre-spawn clear
|
|
438
639
|
// already prevents an old marker from surviving to this point, but a
|
|
@@ -440,7 +641,6 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
|
|
|
440
641
|
// still write a marker between the clear and this read; the pid binding
|
|
441
642
|
// rejects that marker instead of misattributing a foreign failure to this
|
|
442
643
|
// spawn's caller.
|
|
443
|
-
const trustedMarker = marker && child.pid !== undefined && marker.pid === child.pid ? marker : undefined;
|
|
444
644
|
const failure = spawnError
|
|
445
645
|
? new Error(`Failed to spawn detached SDK broker: ${spawnError.message}`)
|
|
446
646
|
: exitedBeforeDiscovery
|
|
@@ -464,6 +664,7 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
|
|
|
464
664
|
throw failure;
|
|
465
665
|
} finally {
|
|
466
666
|
if (spawnLog) await removeBrokerSpawnLog(spawnLog.path);
|
|
667
|
+
await releaseSpawnLock();
|
|
467
668
|
}
|
|
468
669
|
}
|
|
469
670
|
|
|
@@ -482,14 +683,16 @@ function startEnsure(settings: EnsureBrokerSettings, initiator: EnsureInitiator)
|
|
|
482
683
|
|
|
483
684
|
/** Starts the detached broker entrypoint when discovery has no live owner. */
|
|
484
685
|
export function ensureBroker(settings: EnsureBrokerSettings): Promise<BrokerDiscovery> {
|
|
485
|
-
const
|
|
686
|
+
const resolvedSettings = { ...settings, agentDir: path.resolve(settings.agentDir) };
|
|
687
|
+
const inFlight = ensureInFlight.get(resolvedSettings.agentDir) ?? startEnsure(resolvedSettings, "discovery");
|
|
486
688
|
return inFlight.discovery;
|
|
487
689
|
}
|
|
488
690
|
|
|
489
691
|
/** Starts one fresh fixture broker and returns its sole exact-child close lease. */
|
|
490
692
|
export function startFixtureBrokerWithLeaseForTest(settings: EnsureBrokerSettings): Promise<StartedFixtureBroker> {
|
|
491
|
-
|
|
492
|
-
|
|
693
|
+
const resolvedSettings = { ...settings, agentDir: path.resolve(settings.agentDir) };
|
|
694
|
+
if (ensureInFlight.has(resolvedSettings.agentDir)) return Promise.reject(fixtureLeaseUnavailable());
|
|
695
|
+
const inFlight = startEnsure(resolvedSettings, "fixture-lease");
|
|
493
696
|
return inFlight.promise.then(outcome => {
|
|
494
697
|
if (outcome.kind !== "local-started-fixture") throw fixtureLeaseUnavailable();
|
|
495
698
|
return { discovery: outcome.discovery, lease: createFixtureLease(outcome.owner, outcome.child) };
|
|
@@ -540,6 +743,10 @@ export function startFixtureBrokerCommandWithLeaseForTest(command: FixtureBroker
|
|
|
540
743
|
}
|
|
541
744
|
|
|
542
745
|
/** Test hook: returns a stop handle for the detached broker this process spawned. */
|
|
746
|
+
export const acquireSpawnLockForTest = acquireSpawnLock;
|
|
747
|
+
/** Test hook: proves discovery reads honor their enclosing absolute deadline. */
|
|
748
|
+
export const readBrokerDiscoveryBeforeDeadlineForTest = readBrokerDiscoveryBeforeDeadline;
|
|
749
|
+
|
|
543
750
|
export function brokerOwnerForTest(agentDir: string): BrokerOwner | undefined {
|
|
544
751
|
return owners.get(agentDir);
|
|
545
752
|
}
|
|
@@ -92,7 +92,6 @@ export interface LifecycleLedgerEntry {
|
|
|
92
92
|
unresolvedCleanupResponseDigest?: string;
|
|
93
93
|
uncertainCleanupSessionId?: string;
|
|
94
94
|
uncertainCleanupSessionIds?: string[];
|
|
95
|
-
uncertainCleanupAllSessions?: true;
|
|
96
95
|
ts: number;
|
|
97
96
|
}
|
|
98
97
|
export type BeginResult =
|
|
@@ -183,12 +182,6 @@ function pendingCleanupSessionId(response: unknown): string | undefined {
|
|
|
183
182
|
return canonicalCleanupSessionId(cleanup?.sessionId) ? cleanup.sessionId : undefined;
|
|
184
183
|
}
|
|
185
184
|
|
|
186
|
-
function hasCleanupAuthorityShape(response: unknown): boolean {
|
|
187
|
-
if (!response || typeof response !== "object") return false;
|
|
188
|
-
const error = (response as { error?: unknown }).error;
|
|
189
|
-
return Boolean(error && typeof error === "object" && "cleanup" in error);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
185
|
function hasValidTerminalDigests(entry: LifecycleLedgerEntry): boolean {
|
|
193
186
|
const response = entry.response as { ok?: unknown; error?: { code?: unknown; cleanup?: unknown } } | undefined;
|
|
194
187
|
const cleanupPendingResponse =
|
|
@@ -335,12 +328,10 @@ export class LifecycleLedger {
|
|
|
335
328
|
(candidate, index, candidates): candidate is string =>
|
|
336
329
|
canonicalCleanupSessionId(candidate) && candidates.indexOf(candidate) === index,
|
|
337
330
|
);
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
)
|
|
343
|
-
uncertain.uncertainCleanupAllSessions = true;
|
|
331
|
+
// No blanket fence: the synthetic marker stays scoped to the
|
|
332
|
+
// session ids the quarantined row actually named (#5364).
|
|
333
|
+
// Rows that named nothing fence nothing; resurrecting a
|
|
334
|
+
// maximally-destructive flag here is the poison shape.
|
|
344
335
|
syntheticUncertain.set(entry.identity, uncertain);
|
|
345
336
|
}
|
|
346
337
|
continue;
|
|
@@ -895,8 +886,6 @@ export class LifecycleLedger {
|
|
|
895
886
|
hasUncertainCleanupForSession(sessionId: string, excludingIdentity: string): boolean {
|
|
896
887
|
for (const current of this.#byIdentity.values()) {
|
|
897
888
|
if (current.state !== "terminal_uncertain") continue;
|
|
898
|
-
if (current.uncertainCleanupAllSessions === true) return true;
|
|
899
|
-
if (current.response === undefined) return true;
|
|
900
889
|
if (current.identity === excludingIdentity) continue;
|
|
901
890
|
const fencedSessions = [
|
|
902
891
|
current.uncertainCleanupSessionId,
|
|
@@ -905,8 +894,9 @@ export class LifecycleLedger {
|
|
|
905
894
|
pendingCleanupSessionId(current.unresolvedCleanupResponse),
|
|
906
895
|
...(current.uncertainCleanupSessionIds ?? []),
|
|
907
896
|
];
|
|
908
|
-
|
|
909
|
-
|
|
897
|
+
// An unbound uncertain entry names no session: it fences nothing.
|
|
898
|
+
// Fencing all ids on zero evidence is what made one refusal
|
|
899
|
+
// self-amplify into a permanent blanket fence (#5364).
|
|
910
900
|
if (fencedSessions.includes(sessionId)) return true;
|
|
911
901
|
}
|
|
912
902
|
return false;
|
|
@@ -812,6 +812,20 @@ function sameResumeSessionIdentity(left: ResumeScope, right: ResumeScope): boole
|
|
|
812
812
|
left.sessionIdentity.sha256 === right.sessionIdentity.sha256
|
|
813
813
|
);
|
|
814
814
|
}
|
|
815
|
+
function sameSavedTranscriptIdentity(
|
|
816
|
+
actual: { dev: bigint; ino: bigint; size: number; mtimeMs: number; mtimeNs: bigint; sha256: string },
|
|
817
|
+
expected: unknown,
|
|
818
|
+
): boolean {
|
|
819
|
+
return (
|
|
820
|
+
isSessionLifecycleTranscriptIdentity(expected) &&
|
|
821
|
+
actual.dev.toString() === expected.dev &&
|
|
822
|
+
actual.ino.toString() === expected.ino &&
|
|
823
|
+
actual.size === expected.size &&
|
|
824
|
+
actual.mtimeMs === expected.mtimeMs &&
|
|
825
|
+
actual.mtimeNs.toString() === expected.mtimeNs &&
|
|
826
|
+
actual.sha256 === expected.sha256
|
|
827
|
+
);
|
|
828
|
+
}
|
|
815
829
|
function sameLiveResumeRecord(expected: LiveResumeRecord, current: LiveResumeRecord): boolean {
|
|
816
830
|
return (
|
|
817
831
|
current.live &&
|
|
@@ -890,6 +904,7 @@ async function validateSavedTranscript(
|
|
|
890
904
|
suppliedPath: string | undefined,
|
|
891
905
|
expectedSessionId: string | undefined,
|
|
892
906
|
label: "Saved" | "Source",
|
|
907
|
+
expectedIdentity?: unknown,
|
|
893
908
|
): Promise<ValidatedTranscript | BrokerResponse> {
|
|
894
909
|
const inventory = await managedCandidates(broker, cwd, label);
|
|
895
910
|
if ("ok" in inventory) return inventory;
|
|
@@ -902,6 +917,11 @@ async function validateSavedTranscript(
|
|
|
902
917
|
if (matches.length !== 1 || !isCanonicalSessionId(matches[0]!.sessionId))
|
|
903
918
|
return fail("invalid_input", `${label} saved session does not match the requested workspace and session id.`);
|
|
904
919
|
const match = matches[0]!;
|
|
920
|
+
if (expectedIdentity !== undefined && !sameSavedTranscriptIdentity(match.identity, expectedIdentity))
|
|
921
|
+
return fail(
|
|
922
|
+
"invalid_input",
|
|
923
|
+
`${label} saved session does not match the requested workspace, session id, and transcript identity.`,
|
|
924
|
+
);
|
|
905
925
|
if (inventory.migrationPolicy === "disabled" && match.provenance === "legacy")
|
|
906
926
|
return fail("legacy_migration_disabled", `${label} legacy session migration is disabled for this workspace.`);
|
|
907
927
|
return { path: match.path, id: match.sessionId, identity: serializeTranscriptIdentity(match.identity) };
|
|
@@ -959,6 +979,8 @@ async function validateLiveResumeScope(
|
|
|
959
979
|
if (matches.length !== 1)
|
|
960
980
|
return fail("endpoint_stale", "Requested saved session does not match the live session scope.");
|
|
961
981
|
const session = matches[0]!;
|
|
982
|
+
if (input.sessionIdentity !== undefined && !sameSavedTranscriptIdentity(session.identity, input.sessionIdentity))
|
|
983
|
+
return fail("invalid_input", "Saved session transcript identity does not match the requested snapshot.");
|
|
962
984
|
if (inventory.migrationPolicy === "disabled" && matches[0]!.provenance === "legacy")
|
|
963
985
|
return fail("legacy_migration_disabled", "Saved legacy session migration is disabled for this workspace.");
|
|
964
986
|
return {
|
|
@@ -1375,7 +1397,7 @@ export async function reapDeadLifecycleMarkers(
|
|
|
1375
1397
|
return reaped;
|
|
1376
1398
|
}
|
|
1377
1399
|
|
|
1378
|
-
async function writeEffectMarker(root: string, id: string, marker: EffectMarker): Promise<void> {
|
|
1400
|
+
export async function writeEffectMarker(root: string, id: string, marker: EffectMarker): Promise<void> {
|
|
1379
1401
|
const directory = path.join(root, "sdk");
|
|
1380
1402
|
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
1381
1403
|
const temporary = path.join(directory, `.${id}.lifecycle.${randomUUID()}.tmp`);
|
|
@@ -2994,7 +3016,7 @@ async function reconcileLifecycleCleanup(
|
|
|
2994
3016
|
return completion;
|
|
2995
3017
|
}
|
|
2996
3018
|
|
|
2997
|
-
async function readSessionLifecycleFailure(
|
|
3019
|
+
export async function readSessionLifecycleFailure(
|
|
2998
3020
|
root: string,
|
|
2999
3021
|
id: string,
|
|
3000
3022
|
expected: EffectMarker,
|
|
@@ -4031,10 +4053,16 @@ async function currentReadyAuthority(
|
|
|
4031
4053
|
"alive"
|
|
4032
4054
|
)
|
|
4033
4055
|
return undefined;
|
|
4056
|
+
// Every other field of this authority comes from the indexed record, and
|
|
4057
|
+
// `matchesIndexedEndpointFile` above already proved the on-disk file is
|
|
4058
|
+
// that record's endpoint. Re-deriving the mtime from a fresh `stat` made
|
|
4059
|
+
// the tuple internally inconsistent: the float differed from the indexed
|
|
4060
|
+
// value in its last digits (…272.1147 vs …272.1145), and replay hashing
|
|
4061
|
+
// then read the same endpoint as replaced. Prefer the validated value.
|
|
4034
4062
|
return {
|
|
4035
4063
|
endpoint: endpoint as Record<string, unknown>,
|
|
4036
4064
|
endpointSource,
|
|
4037
|
-
endpointMtimeMs: endpointFile.mtimeMs,
|
|
4065
|
+
endpointMtimeMs: record.endpointMtimeMs ?? endpointFile.mtimeMs,
|
|
4038
4066
|
...(record.endpointFileId === undefined ? {} : { endpointFileId: record.endpointFileId }),
|
|
4039
4067
|
endpointGeneration: record.endpointGeneration,
|
|
4040
4068
|
};
|
|
@@ -4392,7 +4420,7 @@ async function launchInput(
|
|
|
4392
4420
|
if (!requested) return fail("invalid_input", "sessionId is required to resume a saved session.");
|
|
4393
4421
|
const savedPath = text(input.sessionPath);
|
|
4394
4422
|
if (!savedPath) return fail("invalid_input", "sessionPath is required to resume a saved session.");
|
|
4395
|
-
const saved = await validateSavedTranscript(broker, cwd, savedPath, requested, "Saved");
|
|
4423
|
+
const saved = await validateSavedTranscript(broker, cwd, savedPath, requested, "Saved", input.sessionIdentity);
|
|
4396
4424
|
if ("ok" in saved) return saved;
|
|
4397
4425
|
return {
|
|
4398
4426
|
id: requested,
|
|
@@ -4417,7 +4445,14 @@ async function launchInput(
|
|
|
4417
4445
|
const sourceSessionPath = text(input.sourceSessionPath) ?? text(input.sourcePath) ?? text(input.sessionPath);
|
|
4418
4446
|
if (!sourceSessionId && !sourceSessionPath)
|
|
4419
4447
|
return fail("invalid_input", "sourceSessionId or sourceSessionPath is required to fork a session.");
|
|
4420
|
-
const source = await validateSavedTranscript(
|
|
4448
|
+
const source = await validateSavedTranscript(
|
|
4449
|
+
broker,
|
|
4450
|
+
sourceCwd,
|
|
4451
|
+
sourceSessionPath,
|
|
4452
|
+
sourceSessionId,
|
|
4453
|
+
"Source",
|
|
4454
|
+
input.sourceSessionIdentity,
|
|
4455
|
+
);
|
|
4421
4456
|
if ("ok" in source) return source;
|
|
4422
4457
|
return {
|
|
4423
4458
|
id: randomUUID(),
|
|
@@ -4797,7 +4832,10 @@ type CloseRecord = {
|
|
|
4797
4832
|
processIncarnation?: string;
|
|
4798
4833
|
};
|
|
4799
4834
|
|
|
4800
|
-
function endpointIncarnation(
|
|
4835
|
+
function endpointIncarnation(
|
|
4836
|
+
record: Pick<CloseRecord, "endpointGeneration" | "endpointMtimeMs" | "pid">,
|
|
4837
|
+
sessionId: string,
|
|
4838
|
+
): string | undefined {
|
|
4801
4839
|
if (
|
|
4802
4840
|
!Number.isSafeInteger(record.endpointGeneration) ||
|
|
4803
4841
|
record.endpointGeneration <= 0 ||
|
|
@@ -4996,6 +5034,7 @@ async function executeLifecycleResponse(
|
|
|
4996
5034
|
sessionId: requestedSessionId,
|
|
4997
5035
|
cwd: finalScope.cwd,
|
|
4998
5036
|
endpointGeneration: current.endpointGeneration,
|
|
5037
|
+
endpointIncarnation: initialIncarnation,
|
|
4999
5038
|
pid: current.pid,
|
|
5000
5039
|
endpointMtimeMs: current.endpointMtimeMs,
|
|
5001
5040
|
endpoint: endpoint.result,
|
|
@@ -5470,12 +5509,23 @@ async function executeLifecycleResponse(
|
|
|
5470
5509
|
"Session readiness authority changed and its spawned process could not be verified dead.",
|
|
5471
5510
|
);
|
|
5472
5511
|
}
|
|
5512
|
+
if (!spawnedAuthority) return fail("endpoint_stale", "Session endpoint authority is unavailable.");
|
|
5513
|
+
const replayIncarnation = endpointIncarnation(
|
|
5514
|
+
{
|
|
5515
|
+
endpointGeneration: verified.endpointGeneration,
|
|
5516
|
+
endpointMtimeMs: verified.endpointMtimeMs,
|
|
5517
|
+
pid: spawnedAuthority.pid,
|
|
5518
|
+
},
|
|
5519
|
+
launch.id,
|
|
5520
|
+
);
|
|
5521
|
+
if (!replayIncarnation) return fail("endpoint_stale", "Session endpoint incarnation is unavailable.");
|
|
5473
5522
|
return {
|
|
5474
5523
|
ok: true,
|
|
5475
5524
|
result: {
|
|
5476
5525
|
sessionId: launch.id,
|
|
5477
5526
|
cwd: launch.cwd,
|
|
5478
5527
|
endpointGeneration: verified.endpointGeneration,
|
|
5528
|
+
endpointIncarnation: replayIncarnation,
|
|
5479
5529
|
pid: verified.endpoint.pid,
|
|
5480
5530
|
endpointMtimeMs: verified.endpointMtimeMs,
|
|
5481
5531
|
endpoint: verified.endpoint,
|