@gajae-code/coding-agent 0.15.6 → 0.16.1
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 +99 -0
- package/dist/types/async/job-manager.d.ts +32 -1
- package/dist/types/browser-backend.d.ts +12 -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 +14 -0
- package/dist/types/cli.d.ts +7 -0
- package/dist/types/commands/launch.d.ts +10 -0
- package/dist/types/commands/sdk.d.ts +2 -0
- package/dist/types/config/autorouting-tier-map.d.ts +24 -0
- package/dist/types/config/billing-path.d.ts +25 -0
- package/dist/types/config/config-schema-version.d.ts +13 -1
- package/dist/types/config/file-lock.d.ts +5 -0
- package/dist/types/config/model-registry.d.ts +22 -0
- package/dist/types/config/settings-schema.d.ts +27 -8
- 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/extensibility/extensions/types.d.ts +8 -3
- package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
- package/dist/types/gjc-runtime/launch-worktree.d.ts +25 -1
- package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +22 -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/main.d.ts +1 -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/acp/acp-agent.d.ts +21 -21
- package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
- package/dist/types/modes/components/model-selector.d.ts +4 -0
- package/dist/types/modes/components/oauth-selector.d.ts +1 -0
- package/dist/types/modes/components/session-selector.d.ts +1 -1
- package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
- package/dist/types/modes/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/interactive-mode.d.ts +16 -2
- package/dist/types/modes/print-mode.d.ts +17 -0
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/modes/ui-language.d.ts +7 -1
- package/dist/types/sdk/broker/broker.d.ts +79 -2
- package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
- package/dist/types/sdk/broker/ensure.d.ts +3 -0
- package/dist/types/sdk/broker/index.d.ts +3 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +6 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +40 -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/startup-budget.d.ts +52 -0
- package/dist/types/sdk/broker/state-version.d.ts +21 -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/discord-daemon.d.ts +1 -1
- package/dist/types/sdk/bus/index.d.ts +9 -1
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +1 -1
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +1 -1
- package/dist/types/sdk/bus/slack-conversation.d.ts +1 -1
- package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/cli/index.d.ts +1 -0
- package/dist/types/sdk/cli/master-cli.d.ts +63 -0
- package/dist/types/sdk/cli/rows.d.ts +12 -5
- package/dist/types/sdk/cli/session-cli.d.ts +20 -3
- 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/events.d.ts +1 -0
- package/dist/types/sdk/host/host.d.ts +10 -0
- package/dist/types/sdk/host/query/cursor.d.ts +4 -2
- package/dist/types/sdk/host/query/handlers.d.ts +1 -0
- package/dist/types/sdk/host/query/revision-store.d.ts +1 -0
- package/dist/types/sdk/host/session-runtime.d.ts +70 -17
- 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 +3 -1
- package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -5
- package/dist/types/sdk/receipt-state.d.ts +2 -0
- package/dist/types/sdk/router/session-router.d.ts +6 -0
- package/dist/types/sdk/session.d.ts +16 -0
- package/dist/types/sdk/startup-capability.d.ts +2 -1
- package/dist/types/sdk/turn-result.d.ts +6 -0
- package/dist/types/session/agent-session.d.ts +52 -7
- package/dist/types/session/credential-store-errors.d.ts +1 -0
- package/dist/types/session/fold-coordinator.d.ts +29 -2
- package/dist/types/session/session-manager.d.ts +79 -2
- package/dist/types/session/yield-queue.d.ts +5 -4
- package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
- package/dist/types/tools/ask.d.ts +1 -0
- package/dist/types/tools/bash.d.ts +16 -0
- package/dist/types/tools/browser/launch.d.ts +10 -0
- package/dist/types/tools/browser/registry.d.ts +8 -0
- package/dist/types/tools/gh.d.ts +8 -0
- package/dist/types/tools/index.d.ts +10 -10
- package/dist/types/tools/job.d.ts +3 -1
- package/dist/types/utils/external-editor.d.ts +7 -0
- package/examples/extensions/README.md +1 -1
- package/examples/extensions/pirate.ts +7 -4
- package/package.json +7 -7
- package/scripts/generate-sdk-operation-inventory.ts +13 -1
- package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
- package/src/async/job-manager.ts +107 -9
- package/src/browser-backend.ts +29 -0
- 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/plugin-cli.ts +29 -0
- package/src/cli/root-flags.ts +5 -0
- package/src/cli/update-cli.ts +140 -23
- package/src/commands/launch.ts +86 -18
- package/src/commands/sdk.ts +135 -28
- package/src/config/autorouting-tier-map.ts +12 -0
- package/src/config/billing-path.ts +42 -0
- package/src/config/config-schema-version.ts +18 -1
- package/src/config/file-lock.ts +158 -83
- package/src/config/model-preset-registry.ts +178 -23
- package/src/config/model-profiles.ts +5 -5
- package/src/config/model-registry.ts +291 -42
- package/src/config/model-resolver.ts +5 -0
- package/src/config/settings-schema.ts +27 -8
- package/src/config/settings.ts +51 -15
- package/src/coordinator-mcp/projection-scan.ts +27 -6
- package/src/coordinator-mcp/question-state.ts +9 -11
- package/src/coordinator-mcp/server.ts +115 -21
- package/src/crash/index-store.ts +2 -0
- package/src/crash/record-loader.ts +10 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
- package/src/extensibility/extensions/runner.ts +27 -0
- package/src/extensibility/extensions/types.ts +8 -3
- package/src/extensibility/gjc-plugins/installer.ts +51 -11
- package/src/extensibility/runtime-skill-discovery.ts +110 -3
- package/src/extensibility/skills.ts +22 -12
- package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
- package/src/gjc-runtime/launch-worktree.ts +285 -4
- package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
- package/src/gjc-runtime/session-state-lock.ts +213 -14
- package/src/gjc-runtime/session-state-sidecar.ts +938 -39
- package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
- package/src/gjc-runtime/tmux-owner-isolation.ts +18 -3
- 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 +15 -15
- package/src/internal-urls/issue-pr-protocol.ts +4 -2
- package/src/main.ts +61 -3
- 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 +610 -161
- package/src/modes/components/footer.ts +4 -2
- package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
- package/src/modes/components/model-selector.ts +76 -8
- package/src/modes/components/oauth-selector.ts +6 -1
- package/src/modes/components/session-selector.ts +23 -15
- package/src/modes/components/sessions-dashboard.ts +8 -5
- 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/command-controller.ts +1 -1
- package/src/modes/controllers/event-controller.ts +30 -4
- package/src/modes/controllers/extension-ui-controller.ts +13 -13
- package/src/modes/controllers/input-controller.ts +30 -2
- package/src/modes/controllers/selector-controller.ts +200 -98
- package/src/modes/interactive-mode.ts +36 -12
- package/src/modes/print-mode.ts +64 -0
- package/src/modes/prompt-action-autocomplete.ts +30 -4
- package/src/modes/runtime-init.ts +12 -9
- package/src/modes/theme/theme.ts +5 -0
- package/src/modes/types.ts +2 -0
- package/src/modes/ui-language.ts +49 -2
- package/src/modes/utils/ui-helpers.ts +22 -3
- package/src/prompts/agent-fragments/browser-aside.md +22 -0
- package/src/prompts/system/master-mode.md +28 -0
- package/src/prompts/tools/ask.md +1 -1
- package/src/prompts/tools/bash.md +1 -0
- package/src/prompts/tools/job.md +1 -1
- package/src/sdk/acp/adapter.ts +5 -0
- package/src/sdk/broker/broker.ts +1646 -27
- package/src/sdk/broker/endpoint-authority.ts +86 -0
- package/src/sdk/broker/ensure.ts +81 -3
- package/src/sdk/broker/index.ts +3 -0
- package/src/sdk/broker/lifecycle-ledger.ts +6 -0
- package/src/sdk/broker/lifecycle.ts +440 -75
- 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 +591 -0
- package/src/sdk/broker/startup-budget.ts +144 -2
- package/src/sdk/broker/state-version.ts +43 -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 +136 -18
- package/src/sdk/bus/discord-daemon.ts +50 -10
- package/src/sdk/bus/index.ts +84 -7
- package/src/sdk/bus/kind-aware-reconciliation.ts +296 -48
- package/src/sdk/bus/lifecycle-commands.ts +4 -0
- package/src/sdk/bus/notification-service.ts +22 -5
- package/src/sdk/bus/prompt-reconciliation.ts +7 -2
- package/src/sdk/bus/reconciliation-store.ts +55 -31
- package/src/sdk/bus/slack-conversation.ts +1 -1
- package/src/sdk/bus/slack-daemon.ts +72 -38
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/cli/index.ts +1 -0
- package/src/sdk/cli/master-cli.ts +259 -0
- package/src/sdk/cli/rows.ts +41 -15
- package/src/sdk/cli/session-cli.ts +297 -26
- package/src/sdk/client/client.ts +14 -8
- package/src/sdk/host/control/dispatch.ts +101 -40
- 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/events.ts +1 -0
- package/src/sdk/host/host.ts +29 -2
- package/src/sdk/host/query/cursor.ts +52 -7
- package/src/sdk/host/query/handlers.ts +33 -12
- package/src/sdk/host/query/revision-store.ts +12 -0
- package/src/sdk/host/session-runtime.test.ts +673 -14
- package/src/sdk/host/session-runtime.ts +674 -103
- package/src/sdk/host/surface-policy.ts +3 -3
- package/src/sdk/lifecycle/broker-client.ts +28 -0
- package/src/sdk/lifecycle/service.ts +279 -20
- package/src/sdk/lifecycle-session.ts +17 -4
- package/src/sdk/prompt-deadline-manager.ts +2 -1
- package/src/sdk/protocol/operation-inventory.generated.json +105 -3
- package/src/sdk/protocol/operation-registry.ts +14 -4
- package/src/sdk/receipt-state.ts +10 -0
- package/src/sdk/router/session-router.ts +219 -118
- package/src/sdk/session.ts +416 -195
- package/src/sdk/startup-capability.ts +1 -0
- package/src/sdk/turn-result.ts +22 -0
- package/src/session/agent-session.ts +1628 -318
- package/src/session/blob-store.ts +15 -5
- package/src/session/credential-store-errors.ts +2 -0
- package/src/session/fold-coordinator.ts +40 -3
- package/src/session/internal/managed-session-storage.ts +153 -78
- package/src/session/session-manager.ts +850 -97
- package/src/session/yield-queue.ts +24 -10
- package/src/setup/frictionless-onboarding.ts +2 -0
- package/src/slash-commands/builtin-registry.ts +41 -0
- package/src/task/executor.ts +9 -4
- package/src/telemetry/events.ts +1138 -12
- package/src/tools/ask-contract.test.ts +64 -0
- package/src/tools/ask-contract.ts +36 -1
- package/src/tools/ask.ts +10 -8
- package/src/tools/bash-interactive.ts +11 -2
- package/src/tools/bash.ts +412 -75
- package/src/tools/browser/launch.ts +37 -0
- package/src/tools/browser/registry.ts +99 -9
- package/src/tools/cron.ts +6 -4
- package/src/tools/descriptors.test.ts +15 -0
- package/src/tools/descriptors.ts +5 -1
- package/src/tools/gh.ts +104 -48
- package/src/tools/github-cache.ts +2 -2
- package/src/tools/index.ts +10 -6
- package/src/tools/job.ts +4 -1
- package/src/tools/monitor.ts +12 -9
- package/src/tools/resolve.ts +30 -10
- package/src/tools/tool-catalog.generated.ts +2 -2
- package/src/utils/external-editor.ts +72 -1
|
@@ -3,6 +3,12 @@ import * as fsSync from "node:fs";
|
|
|
3
3
|
import * as fs from "node:fs/promises";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import type { AssistantMessage } from "@gajae-code/ai/core";
|
|
6
|
+
import {
|
|
7
|
+
exactReplacePath,
|
|
8
|
+
exactUnlinkDirect,
|
|
9
|
+
type NativeExactFileIdentity,
|
|
10
|
+
renameNoReplacePath,
|
|
11
|
+
} from "@gajae-code/natives";
|
|
6
12
|
import { normalizePathForComparison, postmortem } from "@gajae-code/utils";
|
|
7
13
|
import { withFileLock } from "../config/file-lock";
|
|
8
14
|
import {
|
|
@@ -165,6 +171,14 @@ export const __sessionStateSidecarPerfCounters = {
|
|
|
165
171
|
},
|
|
166
172
|
};
|
|
167
173
|
|
|
174
|
+
/** Test-only barriers for deterministic rescope transaction race coverage. */
|
|
175
|
+
export const __sessionStateSidecarTestHooks: {
|
|
176
|
+
afterRescopeLocksAcquired?: () => void | Promise<void>;
|
|
177
|
+
beforeRescopeJournalWrite?: (cwd: string) => void | Promise<void>;
|
|
178
|
+
beforePersistFromEvent?: (eventType: string, cwd: string) => void | Promise<void>;
|
|
179
|
+
beforeRescopePublish?: () => void | Promise<void>;
|
|
180
|
+
} = {};
|
|
181
|
+
|
|
168
182
|
interface RuntimeStateEvent {
|
|
169
183
|
type: string;
|
|
170
184
|
messages?: unknown[];
|
|
@@ -636,6 +650,12 @@ export interface RuntimeStateContext {
|
|
|
636
650
|
ownerTerminal?: OwnerTerminalContext | null;
|
|
637
651
|
/** Internal fail-closed marker set only when managed owner metadata is malformed or missing. */
|
|
638
652
|
ownerTerminalMetadataInvalid?: boolean;
|
|
653
|
+
/**
|
|
654
|
+
* The session file as it was BEFORE a committed rescope, used only by
|
|
655
|
+
* {@link relocateCoordinatorRuntimeStateForRescope} to authenticate the predecessor
|
|
656
|
+
* payload against its old identity. Absent outside a move.
|
|
657
|
+
*/
|
|
658
|
+
previousSessionFile?: string | null;
|
|
639
659
|
}
|
|
640
660
|
|
|
641
661
|
interface RuntimeStateIdentity {
|
|
@@ -992,6 +1012,65 @@ class PreviousRuntimeStateReadError extends Error {
|
|
|
992
1012
|
}
|
|
993
1013
|
}
|
|
994
1014
|
|
|
1015
|
+
/**
|
|
1016
|
+
* A readable, well-formed marker recorded against a different workspace path.
|
|
1017
|
+
*
|
|
1018
|
+
* Separate from {@link PreviousRuntimeStateReadError} because the two demand different
|
|
1019
|
+
* operator responses: an unreadable marker points at file damage, while a foreign marker
|
|
1020
|
+
* points at the same session directory being reachable from two checkouts.
|
|
1021
|
+
*/
|
|
1022
|
+
class ForeignRuntimeStateError extends Error {
|
|
1023
|
+
readonly recordedCwd: string;
|
|
1024
|
+
readonly currentCwd: string;
|
|
1025
|
+
constructor(previous: Record<string, unknown>, input: RuntimeStateIdentity) {
|
|
1026
|
+
const recorded = typeof previous.cwd === "string" ? previous.cwd : "<unknown>";
|
|
1027
|
+
super(
|
|
1028
|
+
`Existing runtime state marker belongs to a different workspace; refusing to overwrite. ` +
|
|
1029
|
+
`recorded cwd ${recorded}, current cwd ${input.cwd}. ` +
|
|
1030
|
+
`A live or in-flight marker is never adopted; delete it only if no session is running there.`,
|
|
1031
|
+
);
|
|
1032
|
+
this.name = "ForeignRuntimeStateError";
|
|
1033
|
+
this.recordedCwd = recorded;
|
|
1034
|
+
this.currentCwd = input.cwd;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* A foreign marker is adoptable only when every one of these holds:
|
|
1040
|
+
*
|
|
1041
|
+
* - it already reached a terminal state and is explicitly not live, so no owner can still
|
|
1042
|
+
* be writing to it (`live` must be `false` rather than merely absent: an older or
|
|
1043
|
+
* truncated marker that omits the field says nothing about whether its owner runs);
|
|
1044
|
+
* - the marker file itself lives inside the current workspace, which is what distinguishes
|
|
1045
|
+
* a session directory that travelled here with its repository from one that still
|
|
1046
|
+
* belongs to a different workspace on this machine.
|
|
1047
|
+
*
|
|
1048
|
+
* The second condition is the load-bearing one. Terminal-and-not-live alone would let a
|
|
1049
|
+
* finished `C:\...` session be adopted by a `D:\...` workspace, and those are unrelated
|
|
1050
|
+
* working trees that must never inherit each other's runtime identity.
|
|
1051
|
+
*/
|
|
1052
|
+
function isAdoptableForeignRuntimeState(
|
|
1053
|
+
previous: Record<string, unknown>,
|
|
1054
|
+
input: RuntimeStateIdentity,
|
|
1055
|
+
stateFile: string | null,
|
|
1056
|
+
): boolean {
|
|
1057
|
+
if (previous.live !== false) return false;
|
|
1058
|
+
if (previous.state !== "completed" && previous.state !== "errored") return false;
|
|
1059
|
+
if (!stateFile) return false;
|
|
1060
|
+
return pathIsInside(stateFile, input.cwd, input.platform);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/** True when `candidate` resolves to `root` itself or something beneath it. */
|
|
1064
|
+
function pathIsInside(candidate: string, root: string, platform: NodeJS.Platform = process.platform): boolean {
|
|
1065
|
+
if (sameResolvedPath(candidate, root, platform)) return true;
|
|
1066
|
+
const normalizedRoot = normalizePathForComparison(root, platform);
|
|
1067
|
+
const normalizedCandidate = normalizePathForComparison(candidate, platform);
|
|
1068
|
+
if (!normalizedRoot || !normalizedCandidate) return false;
|
|
1069
|
+
const separator = platform === "win32" ? "\\" : "/";
|
|
1070
|
+
const prefix = normalizedRoot.endsWith(separator) ? normalizedRoot : `${normalizedRoot}${separator}`;
|
|
1071
|
+
return normalizedCandidate.startsWith(prefix);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
995
1074
|
class RuntimeToolActivityRefusedError extends Error {
|
|
996
1075
|
constructor(reason: string) {
|
|
997
1076
|
super(`Refusing to overwrite the coordinator tool-activity snapshot: ${reason}.`);
|
|
@@ -1071,6 +1150,78 @@ function readPreviousPayload(stateFile: string): Record<string, unknown> {
|
|
|
1071
1150
|
}
|
|
1072
1151
|
}
|
|
1073
1152
|
|
|
1153
|
+
type RelocationPayloadObservation = {
|
|
1154
|
+
payload: Record<string, unknown>;
|
|
1155
|
+
identity: NativeExactFileIdentity | null;
|
|
1156
|
+
};
|
|
1157
|
+
|
|
1158
|
+
function sameRelocationFileIdentity(left: fsSync.BigIntStats, right: fsSync.BigIntStats): boolean {
|
|
1159
|
+
return (
|
|
1160
|
+
left.dev === right.dev &&
|
|
1161
|
+
left.ino === right.ino &&
|
|
1162
|
+
left.nlink === right.nlink &&
|
|
1163
|
+
left.size === right.size &&
|
|
1164
|
+
left.mtimeNs === right.mtimeNs &&
|
|
1165
|
+
left.ctimeNs === right.ctimeNs &&
|
|
1166
|
+
left.isFile() &&
|
|
1167
|
+
right.isFile() &&
|
|
1168
|
+
!left.isSymbolicLink() &&
|
|
1169
|
+
!right.isSymbolicLink()
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/** Read relocation authority from one no-follow, identity-revalidated regular file. */
|
|
1174
|
+
function readRegularFileForRelocation(stateFile: string): { raw: string; identity: NativeExactFileIdentity } | null {
|
|
1175
|
+
let before: fsSync.BigIntStats;
|
|
1176
|
+
try {
|
|
1177
|
+
before = fsSync.lstatSync(stateFile, { bigint: true });
|
|
1178
|
+
} catch (error) {
|
|
1179
|
+
if (isAbsentStateFileError(error)) return null;
|
|
1180
|
+
throw new PreviousRuntimeStateReadError(error);
|
|
1181
|
+
}
|
|
1182
|
+
if (!before.isFile() || before.isSymbolicLink()) throw new PreviousRuntimeStateReadError();
|
|
1183
|
+
const flags = fsSync.constants.O_RDONLY | (process.platform === "win32" ? 0 : (fsSync.constants.O_NOFOLLOW ?? 0));
|
|
1184
|
+
let descriptor: number | undefined;
|
|
1185
|
+
try {
|
|
1186
|
+
descriptor = fsSync.openSync(stateFile, flags);
|
|
1187
|
+
const opened = fsSync.fstatSync(descriptor, { bigint: true });
|
|
1188
|
+
if (!sameRelocationFileIdentity(before, opened)) throw new PreviousRuntimeStateReadError();
|
|
1189
|
+
const raw = fsSync.readFileSync(descriptor, "utf8");
|
|
1190
|
+
const after = fsSync.lstatSync(stateFile, { bigint: true });
|
|
1191
|
+
const finalOpened = fsSync.fstatSync(descriptor, { bigint: true });
|
|
1192
|
+
const parent = fsSync.lstatSync(path.dirname(stateFile), { bigint: true });
|
|
1193
|
+
if (!sameRelocationFileIdentity(opened, finalOpened) || !sameRelocationFileIdentity(opened, after))
|
|
1194
|
+
throw new PreviousRuntimeStateReadError();
|
|
1195
|
+
if (!parent.isDirectory() || parent.isSymbolicLink()) throw new PreviousRuntimeStateReadError();
|
|
1196
|
+
return {
|
|
1197
|
+
raw,
|
|
1198
|
+
identity: {
|
|
1199
|
+
dev: opened.dev,
|
|
1200
|
+
ino: opened.ino,
|
|
1201
|
+
nlink: opened.nlink,
|
|
1202
|
+
parentDev: parent.dev,
|
|
1203
|
+
parentIno: parent.ino,
|
|
1204
|
+
size: opened.size,
|
|
1205
|
+
mtimeNs: opened.mtimeNs,
|
|
1206
|
+
sha256: createHash("sha256").update(raw).digest("hex"),
|
|
1207
|
+
quarantineName: `.gjc-delete-runtime-state-${randomUUID()}.json`,
|
|
1208
|
+
},
|
|
1209
|
+
};
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
if (error instanceof PreviousRuntimeStateReadError) throw error;
|
|
1212
|
+
throw new PreviousRuntimeStateReadError(error);
|
|
1213
|
+
} finally {
|
|
1214
|
+
if (descriptor !== undefined) fsSync.closeSync(descriptor);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function readPreviousPayloadForRelocation(stateFile: string): RelocationPayloadObservation {
|
|
1219
|
+
const observed = readRegularFileForRelocation(stateFile);
|
|
1220
|
+
return observed
|
|
1221
|
+
? { payload: parsePreviousPayload(observed.raw), identity: observed.identity }
|
|
1222
|
+
: { payload: {}, identity: null };
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1074
1225
|
/**
|
|
1075
1226
|
* Reads the authoritative bytes on disk, every time, inside the state-file lock.
|
|
1076
1227
|
*
|
|
@@ -1132,7 +1283,11 @@ function shouldPreserveTerminalPayload(previous: RuntimeStateSidecarPayload, inp
|
|
|
1132
1283
|
);
|
|
1133
1284
|
}
|
|
1134
1285
|
|
|
1135
|
-
function assertPreviousRuntimeStateIdentity(
|
|
1286
|
+
function assertPreviousRuntimeStateIdentity(
|
|
1287
|
+
previous: Record<string, unknown>,
|
|
1288
|
+
input: RuntimeStateIdentity,
|
|
1289
|
+
stateFile: string | null = null,
|
|
1290
|
+
): void {
|
|
1136
1291
|
if (Object.keys(previous).length === 0) return;
|
|
1137
1292
|
if (previous.session_id !== input.sessionId) throw new PreviousRuntimeStateReadError();
|
|
1138
1293
|
// Signed coordinator mode never trusts an unsigned predecessor. The sole exception
|
|
@@ -1169,20 +1324,47 @@ function assertPreviousRuntimeStateIdentity(previous: Record<string, unknown>, i
|
|
|
1169
1324
|
if (coordinatorSeed) return;
|
|
1170
1325
|
// If the previous payload has runtime identity fields, verify them fully.
|
|
1171
1326
|
if (typeof previous.cwd === "string" && typeof previous.workdir === "string") {
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
(previous.session_file
|
|
1176
|
-
|
|
1177
|
-
typeof previous.session_file === "string" &&
|
|
1327
|
+
const pathsMatch =
|
|
1328
|
+
sameResolvedPath(previous.cwd, input.cwd, input.platform) &&
|
|
1329
|
+
sameResolvedPath(previous.workdir, input.cwd, input.platform) &&
|
|
1330
|
+
(previous.session_file === input.sessionFile ||
|
|
1331
|
+
(typeof previous.session_file === "string" &&
|
|
1178
1332
|
typeof input.sessionFile === "string" &&
|
|
1179
|
-
sameResolvedPath(previous.session_file, input.sessionFile, input.platform)
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1333
|
+
sameResolvedPath(previous.session_file, input.sessionFile, input.platform)));
|
|
1334
|
+
if (!pathsMatch) {
|
|
1335
|
+
// A marker whose recorded workspace differs from the current one is readable and
|
|
1336
|
+
// well-formed; it simply belongs to another checkout. Reporting that as a read
|
|
1337
|
+
// failure sends operators hunting for file corruption that does not exist.
|
|
1338
|
+
//
|
|
1339
|
+
// A session directory committed to version control reaches a second machine this
|
|
1340
|
+
// way, so the mismatch is ordinary rather than exceptional. When the recorded
|
|
1341
|
+
// marker is already terminal and not live, nothing can still be writing to it and
|
|
1342
|
+
// adopting it is safe; anything else still refuses, because a live or in-flight
|
|
1343
|
+
// marker from another workspace may have a running owner behind it.
|
|
1344
|
+
if (!isAdoptableForeignRuntimeState(previous, input, stateFile))
|
|
1345
|
+
throw new ForeignRuntimeStateError(previous, input);
|
|
1346
|
+
}
|
|
1183
1347
|
}
|
|
1184
1348
|
}
|
|
1185
1349
|
|
|
1350
|
+
/** Relocation never accepts the coordinator bootstrap exception or partial identity rows. */
|
|
1351
|
+
function assertRelocationRuntimeStateIdentity(previous: Record<string, unknown>, input: RuntimeStateIdentity): void {
|
|
1352
|
+
assertPreviousRuntimeStateIdentity(previous, input);
|
|
1353
|
+
if (
|
|
1354
|
+
typeof previous.cwd !== "string" ||
|
|
1355
|
+
typeof previous.workdir !== "string" ||
|
|
1356
|
+
!sameResolvedPath(previous.cwd, input.cwd, input.platform) ||
|
|
1357
|
+
!sameResolvedPath(previous.workdir, input.cwd, input.platform) ||
|
|
1358
|
+
(previous.session_file !== input.sessionFile &&
|
|
1359
|
+
!(
|
|
1360
|
+
typeof previous.session_file === "string" &&
|
|
1361
|
+
typeof input.sessionFile === "string" &&
|
|
1362
|
+
sameResolvedPath(previous.session_file, input.sessionFile, input.platform)
|
|
1363
|
+
))
|
|
1364
|
+
)
|
|
1365
|
+
throw new PreviousRuntimeStateReadError();
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1186
1368
|
function runtimeStateFileForContext(context: RuntimeStateContext): string | null {
|
|
1187
1369
|
const explicit = process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim();
|
|
1188
1370
|
if (explicit) return explicit;
|
|
@@ -1402,6 +1584,44 @@ async function writeStateFileSync(
|
|
|
1402
1584
|
await writeStateFile(stateFile, finalizeSidecarPayload(payload, keyId));
|
|
1403
1585
|
}
|
|
1404
1586
|
|
|
1587
|
+
async function writeStateFileSyncConditional(
|
|
1588
|
+
stateFile: string,
|
|
1589
|
+
payload: Record<string, unknown>,
|
|
1590
|
+
keyId: string | null,
|
|
1591
|
+
expectedDestination: NativeExactFileIdentity | null,
|
|
1592
|
+
): Promise<void> {
|
|
1593
|
+
const contents = `${JSON.stringify(finalizeSidecarPayload(payload, keyId))}\n`;
|
|
1594
|
+
await writeCoordinatorAtomic(stateFile, contents, {
|
|
1595
|
+
rename: async (source, destination) => {
|
|
1596
|
+
if (!expectedDestination) {
|
|
1597
|
+
const published = renameNoReplacePath(source, destination);
|
|
1598
|
+
if (!published.ok) throw new PreviousRuntimeStateReadError();
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
const stat = fsSync.lstatSync(source, { bigint: true });
|
|
1602
|
+
const parent = fsSync.lstatSync(path.dirname(source), { bigint: true });
|
|
1603
|
+
if (!stat.isFile() || stat.isSymbolicLink() || !parent.isDirectory() || parent.isSymbolicLink())
|
|
1604
|
+
throw new PreviousRuntimeStateReadError();
|
|
1605
|
+
const replaced = exactReplacePath(
|
|
1606
|
+
source,
|
|
1607
|
+
destination,
|
|
1608
|
+
{
|
|
1609
|
+
dev: stat.dev,
|
|
1610
|
+
ino: stat.ino,
|
|
1611
|
+
nlink: stat.nlink,
|
|
1612
|
+
parentDev: parent.dev,
|
|
1613
|
+
parentIno: parent.ino,
|
|
1614
|
+
size: stat.size,
|
|
1615
|
+
mtimeNs: stat.mtimeNs,
|
|
1616
|
+
sha256: createHash("sha256").update(contents).digest("hex"),
|
|
1617
|
+
},
|
|
1618
|
+
expectedDestination,
|
|
1619
|
+
);
|
|
1620
|
+
if (!replaced.ok) throw new PreviousRuntimeStateReadError();
|
|
1621
|
+
},
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1405
1625
|
/**
|
|
1406
1626
|
* The state-file critical section, shared byte-for-byte with the Coordinator MCP writer.
|
|
1407
1627
|
*
|
|
@@ -1412,12 +1632,9 @@ async function writeStateFileSync(
|
|
|
1412
1632
|
* than faulting forever.
|
|
1413
1633
|
*/
|
|
1414
1634
|
async function withStateFileLock<T>(stateFile: string, operation: () => Promise<T>): Promise<T> {
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
if (error instanceof SessionStateLockUnavailableError) throw new PreviousRuntimeStateReadError(error);
|
|
1419
|
-
throw error;
|
|
1420
|
-
}
|
|
1635
|
+
// Preserve the lock's typed path/reason diagnostics. A transition timeout means the
|
|
1636
|
+
// state file was never entered, not that its existing JSON marker was unreadable.
|
|
1637
|
+
return await withSessionStateFileLock(stateFile, operation);
|
|
1421
1638
|
}
|
|
1422
1639
|
|
|
1423
1640
|
function coordinatorTransactionLockFile(stateFile: string): string {
|
|
@@ -1430,19 +1647,110 @@ function coordinatorTransactionLockFile(stateFile: string): string {
|
|
|
1430
1647
|
* protocol.
|
|
1431
1648
|
*/
|
|
1432
1649
|
async function withCoordinatorTransactionLock<T>(stateFile: string, operation: () => Promise<T>): Promise<T> {
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
}
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1650
|
+
// Namespace-lock failures are contention or filesystem failures, not evidence that
|
|
1651
|
+
// runtime-state.json is malformed. Let the original diagnostic reach the caller.
|
|
1652
|
+
return await withFileLock(coordinatorTransactionLockFile(stateFile), operation, {
|
|
1653
|
+
staleMs: 30_000,
|
|
1654
|
+
retries: 12_000,
|
|
1655
|
+
retryDelayMs: 5,
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
function orderedDistinctStateFiles(stateFiles: readonly string[], platform: NodeJS.Platform): string[] {
|
|
1660
|
+
const byIdentity = new Map<string, string>();
|
|
1661
|
+
for (const stateFile of stateFiles) {
|
|
1662
|
+
const resolved = path.resolve(stateFile);
|
|
1663
|
+
byIdentity.set(normalizePathForComparison(resolved, platform), resolved);
|
|
1664
|
+
}
|
|
1665
|
+
return [...byIdentity.entries()]
|
|
1666
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
1667
|
+
.map(([, stateFile]) => stateFile);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
function canonicalDirectoryWithExistingAncestor(directory: string): string {
|
|
1671
|
+
let current = path.resolve(directory);
|
|
1672
|
+
const missing: string[] = [];
|
|
1673
|
+
for (;;) {
|
|
1674
|
+
try {
|
|
1675
|
+
return path.join(fsSync.realpathSync(current), ...missing.reverse());
|
|
1676
|
+
} catch (error) {
|
|
1677
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw new PreviousRuntimeStateReadError(error);
|
|
1678
|
+
const parent = path.dirname(current);
|
|
1679
|
+
if (parent === current) throw new PreviousRuntimeStateReadError(error);
|
|
1680
|
+
missing.push(path.basename(current));
|
|
1681
|
+
current = parent;
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
function canonicalStateFilePath(stateFile: string): string {
|
|
1687
|
+
return path.join(canonicalDirectoryWithExistingAncestor(path.dirname(stateFile)), path.basename(stateFile));
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
function assertPinnedDirectoryIdentity(cwd: string, expected: { dev: bigint; ino: bigint }): void {
|
|
1691
|
+
const stat = fsSync.lstatSync(cwd, { bigint: true });
|
|
1692
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.dev !== expected.dev || stat.ino !== expected.ino)
|
|
1693
|
+
throw new PreviousRuntimeStateReadError();
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
function assertNoSymlinkDirectoryComponents(root: string, directory: string): void {
|
|
1697
|
+
const relative = path.relative(root, directory);
|
|
1698
|
+
if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
|
|
1699
|
+
throw new PreviousRuntimeStateReadError();
|
|
1700
|
+
let current = root;
|
|
1701
|
+
for (const component of relative.split(path.sep)) {
|
|
1702
|
+
current = path.join(current, component);
|
|
1703
|
+
try {
|
|
1704
|
+
const stat = fsSync.lstatSync(current);
|
|
1705
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new PreviousRuntimeStateReadError();
|
|
1706
|
+
} catch (error) {
|
|
1707
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") break;
|
|
1708
|
+
throw error;
|
|
1709
|
+
}
|
|
1443
1710
|
}
|
|
1444
1711
|
}
|
|
1445
1712
|
|
|
1713
|
+
function confinedJournalPath(cwd: string, sessionId: string, platform: NodeJS.Platform): string {
|
|
1714
|
+
const canonicalRoot = fsSync.realpathSync(cwd);
|
|
1715
|
+
const rawJournalFile = runtimeStateRescopeJournalPath(canonicalRoot, sessionId);
|
|
1716
|
+
assertNoSymlinkDirectoryComponents(canonicalRoot, path.dirname(rawJournalFile));
|
|
1717
|
+
const journalFile = canonicalStateFilePath(rawJournalFile);
|
|
1718
|
+
const pathImpl = platform === "win32" ? path.win32 : path;
|
|
1719
|
+
const relative = pathImpl.relative(canonicalRoot, journalFile);
|
|
1720
|
+
if (
|
|
1721
|
+
relative === "" ||
|
|
1722
|
+
relative === ".." ||
|
|
1723
|
+
relative.startsWith(`..${pathImpl.sep}`) ||
|
|
1724
|
+
pathImpl.isAbsolute(relative)
|
|
1725
|
+
)
|
|
1726
|
+
throw new PreviousRuntimeStateReadError();
|
|
1727
|
+
return journalFile;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
async function withSerializedStateFiles<T>(stateFiles: readonly string[], operation: () => Promise<T>): Promise<T> {
|
|
1731
|
+
const [stateFile, ...remaining] = stateFiles;
|
|
1732
|
+
if (!stateFile) return await operation();
|
|
1733
|
+
return await serializeStateFileWrite(stateFile, async () => await withSerializedStateFiles(remaining, operation));
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
async function withCoordinatorTransactionLocks<T>(
|
|
1737
|
+
stateFiles: readonly string[],
|
|
1738
|
+
operation: () => Promise<T>,
|
|
1739
|
+
): Promise<T> {
|
|
1740
|
+
const [stateFile, ...remaining] = stateFiles;
|
|
1741
|
+
if (!stateFile) return await operation();
|
|
1742
|
+
return await withCoordinatorTransactionLock(
|
|
1743
|
+
stateFile,
|
|
1744
|
+
async () => await withCoordinatorTransactionLocks(remaining, operation),
|
|
1745
|
+
);
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
async function withStateFileLocks<T>(stateFiles: readonly string[], operation: () => Promise<T>): Promise<T> {
|
|
1749
|
+
const [stateFile, ...remaining] = stateFiles;
|
|
1750
|
+
if (!stateFile) return await operation();
|
|
1751
|
+
return await withStateFileLock(stateFile, async () => await withStateFileLocks(remaining, operation));
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1446
1754
|
async function writeStateFile(stateFile: string, payload: Record<string, unknown>): Promise<void> {
|
|
1447
1755
|
await writeCoordinatorAtomic(stateFile, `${JSON.stringify(payload)}\n`);
|
|
1448
1756
|
}
|
|
@@ -1477,9 +1785,10 @@ async function persistCoordinatorRuntimeToolActivity(
|
|
|
1477
1785
|
stateFile,
|
|
1478
1786
|
async () =>
|
|
1479
1787
|
await withStateFileLock(stateFile, async () => {
|
|
1788
|
+
assertNoRuntimeStateRescopeJournal(context, identity);
|
|
1480
1789
|
const previous = await readPreviousPayloadForEvent(stateFile);
|
|
1481
1790
|
if (Object.keys(previous).length === 0) return;
|
|
1482
|
-
assertPreviousRuntimeStateIdentity(previous, identity);
|
|
1791
|
+
assertPreviousRuntimeStateIdentity(previous, identity, stateFile);
|
|
1483
1792
|
// A tool event that lands after the session settled must never
|
|
1484
1793
|
// resurrect it into a live-looking state.
|
|
1485
1794
|
if (previous.state === "completed" || previous.state === "errored") return;
|
|
@@ -1532,6 +1841,8 @@ export async function persistCoordinatorRuntimeStateFromEvent(
|
|
|
1532
1841
|
const stateFile = runtimeStateFileForContext(context);
|
|
1533
1842
|
const state = stateForEvent(event);
|
|
1534
1843
|
if (!stateFile) return;
|
|
1844
|
+
const beforePersistFromEvent = __sessionStateSidecarTestHooks.beforePersistFromEvent;
|
|
1845
|
+
if (beforePersistFromEvent) await beforePersistFromEvent(event.type, context.cwd);
|
|
1535
1846
|
if (!state) {
|
|
1536
1847
|
const activityPhase = toolActivityPhaseForEvent(event);
|
|
1537
1848
|
if (!activityPhase) return;
|
|
@@ -1554,10 +1865,11 @@ export async function persistCoordinatorRuntimeStateFromEvent(
|
|
|
1554
1865
|
stateFile,
|
|
1555
1866
|
async () =>
|
|
1556
1867
|
await withStateFileLock(stateFile, async () => {
|
|
1868
|
+
assertNoRuntimeStateRescopeJournal(context, identity);
|
|
1557
1869
|
const nowMs = Date.now();
|
|
1558
1870
|
const now = new Date(nowMs).toISOString();
|
|
1559
1871
|
const previous = await readPreviousPayloadForEvent(stateFile);
|
|
1560
|
-
assertPreviousRuntimeStateIdentity(previous, identity);
|
|
1872
|
+
assertPreviousRuntimeStateIdentity(previous, identity, stateFile);
|
|
1561
1873
|
const finalResponse = finalResponseForEvent(event);
|
|
1562
1874
|
const terminalReceipt =
|
|
1563
1875
|
state === "completed" || state === "errored"
|
|
@@ -1629,9 +1941,10 @@ export async function persistCoordinatorWorkerIntegrationOutcome(
|
|
|
1629
1941
|
stateFile,
|
|
1630
1942
|
async () =>
|
|
1631
1943
|
await withStateFileLock(stateFile, async () => {
|
|
1944
|
+
assertNoRuntimeStateRescopeJournal(context, identity);
|
|
1632
1945
|
const previous = await readPreviousPayloadForEvent(stateFile);
|
|
1633
1946
|
if (Object.keys(previous).length === 0) return;
|
|
1634
|
-
assertPreviousRuntimeStateIdentity(previous, identity);
|
|
1947
|
+
assertPreviousRuntimeStateIdentity(previous, identity, stateFile);
|
|
1635
1948
|
const now = new Date().toISOString();
|
|
1636
1949
|
const terminalPersistenceFailed =
|
|
1637
1950
|
outcome.kind === "terminal_persistence" && outcome.status !== "completed";
|
|
@@ -1681,6 +1994,586 @@ export async function persistCoordinatorWorkerIntegrationOutcome(
|
|
|
1681
1994
|
);
|
|
1682
1995
|
}
|
|
1683
1996
|
|
|
1997
|
+
/**
|
|
1998
|
+
* The old identity a rescued predecessor payload must authenticate against: the launch-root
|
|
1999
|
+
* cwd and the session file as they were BEFORE the move. Signed mode still requires the
|
|
2000
|
+
* predecessor's key id and signature; unsigned mode requires an exact cwd/workdir/session
|
|
2001
|
+
* file match. Reusing {@link normalizedIdentity} keeps the platform, key-id, and path
|
|
2002
|
+
* resolution rules byte-identical with the persist path's fence.
|
|
2003
|
+
*/
|
|
2004
|
+
function previousRescopeIdentity(context: RuntimeStateContext, previousCwd: string): RuntimeStateIdentity {
|
|
2005
|
+
return normalizedIdentity({
|
|
2006
|
+
sessionId: context.sessionId,
|
|
2007
|
+
cwd: previousCwd,
|
|
2008
|
+
sessionFile: context.previousSessionFile ?? context.sessionFile ?? null,
|
|
2009
|
+
platform: context.platform,
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
interface CoordinatorRuntimeStateRescopeJournal {
|
|
2014
|
+
schema_version: 1;
|
|
2015
|
+
move_id: string;
|
|
2016
|
+
session_id: string;
|
|
2017
|
+
previous_cwd: string;
|
|
2018
|
+
new_cwd: string;
|
|
2019
|
+
previous_session_file: string | null;
|
|
2020
|
+
new_session_file: string | null;
|
|
2021
|
+
old_state_file: string;
|
|
2022
|
+
new_state_file: string;
|
|
2023
|
+
state_file_mode: "pinned" | "derived";
|
|
2024
|
+
source_sha256: string | null;
|
|
2025
|
+
phase: "prepared" | "publishing";
|
|
2026
|
+
key_id: string | null;
|
|
2027
|
+
signature: string | null;
|
|
2028
|
+
created_at: string;
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
const RUNTIME_STATE_RESCOPE_MOVE_ID_PATTERN =
|
|
2032
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
2033
|
+
|
|
2034
|
+
function runtimeStateRescopeJournalPath(cwd: string, sessionId: string): string {
|
|
2035
|
+
return path.join(sessionRuntimeDir(cwd, sessionId), "runtime-state-rescope.json");
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
function sameOptionalResolvedPath(
|
|
2039
|
+
left: string | null,
|
|
2040
|
+
right: string | null,
|
|
2041
|
+
platform: NodeJS.Platform = process.platform,
|
|
2042
|
+
): boolean {
|
|
2043
|
+
return (
|
|
2044
|
+
left === right ||
|
|
2045
|
+
(typeof left === "string" && typeof right === "string" && sameResolvedPath(left, right, platform))
|
|
2046
|
+
);
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
function parseRuntimeStateRescopeJournal(raw: string): CoordinatorRuntimeStateRescopeJournal {
|
|
2050
|
+
let value: unknown;
|
|
2051
|
+
try {
|
|
2052
|
+
value = JSON.parse(raw);
|
|
2053
|
+
} catch {
|
|
2054
|
+
throw new PreviousRuntimeStateReadError();
|
|
2055
|
+
}
|
|
2056
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new PreviousRuntimeStateReadError();
|
|
2057
|
+
const journal = value as Record<string, unknown>;
|
|
2058
|
+
if (
|
|
2059
|
+
!hasExactOwnKeys(journal, [
|
|
2060
|
+
"schema_version",
|
|
2061
|
+
"move_id",
|
|
2062
|
+
"session_id",
|
|
2063
|
+
"previous_cwd",
|
|
2064
|
+
"new_cwd",
|
|
2065
|
+
"previous_session_file",
|
|
2066
|
+
"new_session_file",
|
|
2067
|
+
"old_state_file",
|
|
2068
|
+
"new_state_file",
|
|
2069
|
+
"state_file_mode",
|
|
2070
|
+
"source_sha256",
|
|
2071
|
+
"phase",
|
|
2072
|
+
"key_id",
|
|
2073
|
+
"signature",
|
|
2074
|
+
"created_at",
|
|
2075
|
+
]) ||
|
|
2076
|
+
journal.schema_version !== 1 ||
|
|
2077
|
+
typeof journal.move_id !== "string" ||
|
|
2078
|
+
!RUNTIME_STATE_RESCOPE_MOVE_ID_PATTERN.test(journal.move_id) ||
|
|
2079
|
+
typeof journal.session_id !== "string" ||
|
|
2080
|
+
typeof journal.previous_cwd !== "string" ||
|
|
2081
|
+
typeof journal.new_cwd !== "string" ||
|
|
2082
|
+
(journal.previous_session_file !== null && typeof journal.previous_session_file !== "string") ||
|
|
2083
|
+
(journal.new_session_file !== null && typeof journal.new_session_file !== "string") ||
|
|
2084
|
+
typeof journal.old_state_file !== "string" ||
|
|
2085
|
+
typeof journal.new_state_file !== "string" ||
|
|
2086
|
+
(journal.state_file_mode !== "pinned" && journal.state_file_mode !== "derived") ||
|
|
2087
|
+
(journal.source_sha256 !== null &&
|
|
2088
|
+
(typeof journal.source_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(journal.source_sha256))) ||
|
|
2089
|
+
(journal.phase !== "prepared" && journal.phase !== "publishing") ||
|
|
2090
|
+
(journal.key_id !== null && typeof journal.key_id !== "string") ||
|
|
2091
|
+
(journal.signature !== null && typeof journal.signature !== "string") ||
|
|
2092
|
+
!isCanonicalIsoTimestamp(journal.created_at)
|
|
2093
|
+
)
|
|
2094
|
+
throw new PreviousRuntimeStateReadError();
|
|
2095
|
+
return journal as unknown as CoordinatorRuntimeStateRescopeJournal;
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
function rescopeStateFiles(
|
|
2099
|
+
sessionId: string,
|
|
2100
|
+
previousCwd: string,
|
|
2101
|
+
newCwd: string,
|
|
2102
|
+
): { oldStateFile: string; newStateFile: string; mode: "pinned" | "derived" } {
|
|
2103
|
+
const explicit = process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim();
|
|
2104
|
+
if (explicit) {
|
|
2105
|
+
const stateFile = canonicalStateFilePath(explicit);
|
|
2106
|
+
return { oldStateFile: stateFile, newStateFile: stateFile, mode: "pinned" };
|
|
2107
|
+
}
|
|
2108
|
+
return {
|
|
2109
|
+
oldStateFile: canonicalStateFilePath(
|
|
2110
|
+
path.join(sessionRuntimeDir(fsSync.realpathSync(previousCwd), sessionId), "runtime-state.json"),
|
|
2111
|
+
),
|
|
2112
|
+
newStateFile: canonicalStateFilePath(
|
|
2113
|
+
path.join(sessionRuntimeDir(fsSync.realpathSync(newCwd), sessionId), "runtime-state.json"),
|
|
2114
|
+
),
|
|
2115
|
+
mode: "derived",
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
function journalSignaturePayload(journal: CoordinatorRuntimeStateRescopeJournal): Record<string, unknown> {
|
|
2120
|
+
const { signature: _signature, ...unsigned } = journal;
|
|
2121
|
+
return unsigned;
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
function verifyRuntimeStateRescopeJournal(journal: CoordinatorRuntimeStateRescopeJournal): void {
|
|
2125
|
+
if (journal.key_id === null) {
|
|
2126
|
+
if (journal.signature !== null) throw new PreviousRuntimeStateReadError();
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
if (
|
|
2130
|
+
journal.signature === null ||
|
|
2131
|
+
!coordinatorSidecarSigningKey ||
|
|
2132
|
+
!verify(
|
|
2133
|
+
null,
|
|
2134
|
+
Buffer.from(canonicalCoordinatorSidecarPayload(journalSignaturePayload(journal))),
|
|
2135
|
+
createPublicKey(coordinatorSidecarSigningKey),
|
|
2136
|
+
Buffer.from(journal.signature, "base64"),
|
|
2137
|
+
)
|
|
2138
|
+
)
|
|
2139
|
+
throw new PreviousRuntimeStateReadError();
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
function assertNoRuntimeStateRescopeJournal(context: RuntimeStateContext, identity: RuntimeStateIdentity): void {
|
|
2143
|
+
let canonicalCwd: string;
|
|
2144
|
+
try {
|
|
2145
|
+
canonicalCwd = fsSync.realpathSync(context.cwd);
|
|
2146
|
+
} catch (error) {
|
|
2147
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
2148
|
+
throw error;
|
|
2149
|
+
}
|
|
2150
|
+
const journalFile = runtimeStateRescopeJournalPath(canonicalCwd, context.sessionId);
|
|
2151
|
+
const observed = readRegularFileForRelocation(journalFile);
|
|
2152
|
+
if (!observed) return;
|
|
2153
|
+
const journal = parseRuntimeStateRescopeJournal(observed.raw);
|
|
2154
|
+
verifyRuntimeStateRescopeJournal(journal);
|
|
2155
|
+
if (journal.session_id !== context.sessionId || journal.key_id !== identity.sidecarKeyId)
|
|
2156
|
+
throw new PreviousRuntimeStateReadError();
|
|
2157
|
+
if (
|
|
2158
|
+
!sameResolvedPath(journal.previous_cwd, identity.cwd, identity.platform) &&
|
|
2159
|
+
!sameResolvedPath(journal.new_cwd, identity.cwd, identity.platform)
|
|
2160
|
+
)
|
|
2161
|
+
throw new PreviousRuntimeStateReadError();
|
|
2162
|
+
throw new PreviousRuntimeStateReadError();
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
export async function prepareCoordinatorRuntimeStateRescope(input: {
|
|
2166
|
+
moveId?: string;
|
|
2167
|
+
sessionId: string;
|
|
2168
|
+
previousCwd: string;
|
|
2169
|
+
newCwd: string;
|
|
2170
|
+
previousCwdIdentity?: { dev: bigint; ino: bigint };
|
|
2171
|
+
newCwdIdentity?: { dev: bigint; ino: bigint };
|
|
2172
|
+
previousSessionFile?: string | null;
|
|
2173
|
+
newSessionFile?: string | null;
|
|
2174
|
+
}): Promise<string> {
|
|
2175
|
+
const previousStat = fsSync.lstatSync(input.previousCwd, { bigint: true });
|
|
2176
|
+
const newStat = fsSync.lstatSync(input.newCwd, { bigint: true });
|
|
2177
|
+
const previousCwdIdentity = input.previousCwdIdentity ?? { dev: previousStat.dev, ino: previousStat.ino };
|
|
2178
|
+
const newCwdIdentity = input.newCwdIdentity ?? { dev: newStat.dev, ino: newStat.ino };
|
|
2179
|
+
assertPinnedDirectoryIdentity(input.previousCwd, previousCwdIdentity);
|
|
2180
|
+
assertPinnedDirectoryIdentity(input.newCwd, newCwdIdentity);
|
|
2181
|
+
const previousCwd = fsSync.realpathSync(input.previousCwd);
|
|
2182
|
+
const newCwd = fsSync.realpathSync(input.newCwd);
|
|
2183
|
+
const stateFiles = rescopeStateFiles(input.sessionId, previousCwd, newCwd);
|
|
2184
|
+
const moveId = input.moveId ?? randomUUID();
|
|
2185
|
+
if (!RUNTIME_STATE_RESCOPE_MOVE_ID_PATTERN.test(moveId)) throw new PreviousRuntimeStateReadError();
|
|
2186
|
+
const identity = normalizedIdentity({
|
|
2187
|
+
sessionId: input.sessionId,
|
|
2188
|
+
cwd: newCwd,
|
|
2189
|
+
sessionFile: input.newSessionFile ?? null,
|
|
2190
|
+
});
|
|
2191
|
+
const previousJournalFile = confinedJournalPath(previousCwd, input.sessionId, identity.platform);
|
|
2192
|
+
const newJournalFile = confinedJournalPath(newCwd, input.sessionId, identity.platform);
|
|
2193
|
+
const oldIdentity = previousRescopeIdentity(
|
|
2194
|
+
{
|
|
2195
|
+
sessionId: input.sessionId,
|
|
2196
|
+
cwd: newCwd,
|
|
2197
|
+
sessionFile: input.newSessionFile ?? null,
|
|
2198
|
+
previousSessionFile: input.previousSessionFile ?? null,
|
|
2199
|
+
},
|
|
2200
|
+
previousCwd,
|
|
2201
|
+
);
|
|
2202
|
+
return await withSerializedStateFiles([stateFiles.oldStateFile], async () =>
|
|
2203
|
+
withCoordinatorTransactionLocks([stateFiles.oldStateFile], async () =>
|
|
2204
|
+
withStateFileLocks([stateFiles.oldStateFile], async () => {
|
|
2205
|
+
const sourceObservation = readPreviousPayloadForRelocation(stateFiles.oldStateFile);
|
|
2206
|
+
if (Object.keys(sourceObservation.payload).length > 0)
|
|
2207
|
+
assertRelocationRuntimeStateIdentity(sourceObservation.payload, oldIdentity);
|
|
2208
|
+
const journal: CoordinatorRuntimeStateRescopeJournal = {
|
|
2209
|
+
schema_version: 1,
|
|
2210
|
+
move_id: moveId,
|
|
2211
|
+
session_id: input.sessionId,
|
|
2212
|
+
previous_cwd: previousCwd,
|
|
2213
|
+
new_cwd: newCwd,
|
|
2214
|
+
previous_session_file: input.previousSessionFile ? path.resolve(input.previousSessionFile) : null,
|
|
2215
|
+
new_session_file: input.newSessionFile ? path.resolve(input.newSessionFile) : null,
|
|
2216
|
+
old_state_file: stateFiles.oldStateFile,
|
|
2217
|
+
new_state_file: stateFiles.newStateFile,
|
|
2218
|
+
state_file_mode: stateFiles.mode,
|
|
2219
|
+
source_sha256: sourceObservation.identity?.sha256 ?? null,
|
|
2220
|
+
phase: "prepared",
|
|
2221
|
+
key_id: identity.sidecarKeyId,
|
|
2222
|
+
signature: null,
|
|
2223
|
+
created_at: new Date().toISOString(),
|
|
2224
|
+
};
|
|
2225
|
+
if (journal.key_id !== null) {
|
|
2226
|
+
if (!coordinatorSidecarSigningKey) throw new PreviousRuntimeStateReadError();
|
|
2227
|
+
journal.signature = sign(
|
|
2228
|
+
null,
|
|
2229
|
+
Buffer.from(canonicalCoordinatorSidecarPayload(journalSignaturePayload(journal))),
|
|
2230
|
+
coordinatorSidecarSigningKey,
|
|
2231
|
+
).toString("base64");
|
|
2232
|
+
}
|
|
2233
|
+
const writeJournal = async (journalFile: string): Promise<void> => {
|
|
2234
|
+
await writeCoordinatorAtomic(journalFile, `${JSON.stringify(journal)}\n`, {
|
|
2235
|
+
rename: async (source, destination) => {
|
|
2236
|
+
const published = renameNoReplacePath(source, destination);
|
|
2237
|
+
if (!published.ok) throw new PreviousRuntimeStateReadError();
|
|
2238
|
+
},
|
|
2239
|
+
});
|
|
2240
|
+
};
|
|
2241
|
+
await __sessionStateSidecarTestHooks.beforeRescopeJournalWrite?.(previousCwd);
|
|
2242
|
+
assertPinnedDirectoryIdentity(input.previousCwd, previousCwdIdentity);
|
|
2243
|
+
await writeJournal(previousJournalFile);
|
|
2244
|
+
if (confinedJournalPath(previousCwd, input.sessionId, identity.platform) !== previousJournalFile)
|
|
2245
|
+
throw new PreviousRuntimeStateReadError();
|
|
2246
|
+
assertPinnedDirectoryIdentity(input.previousCwd, previousCwdIdentity);
|
|
2247
|
+
await __sessionStateSidecarTestHooks.beforeRescopeJournalWrite?.(newCwd);
|
|
2248
|
+
assertPinnedDirectoryIdentity(input.newCwd, newCwdIdentity);
|
|
2249
|
+
await writeJournal(newJournalFile);
|
|
2250
|
+
if (confinedJournalPath(newCwd, input.sessionId, identity.platform) !== newJournalFile)
|
|
2251
|
+
throw new PreviousRuntimeStateReadError();
|
|
2252
|
+
assertPinnedDirectoryIdentity(input.previousCwd, previousCwdIdentity);
|
|
2253
|
+
assertPinnedDirectoryIdentity(input.newCwd, newCwdIdentity);
|
|
2254
|
+
return journal.move_id;
|
|
2255
|
+
}),
|
|
2256
|
+
),
|
|
2257
|
+
);
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
export async function markCoordinatorRuntimeStateRescopePublishing(
|
|
2261
|
+
context: RuntimeStateContext,
|
|
2262
|
+
previousCwd: string,
|
|
2263
|
+
moveId: string,
|
|
2264
|
+
): Promise<void> {
|
|
2265
|
+
const identity = normalizedIdentity(context);
|
|
2266
|
+
const journalFiles = orderedDistinctStateFiles(
|
|
2267
|
+
[previousCwd, context.cwd].map(cwd =>
|
|
2268
|
+
runtimeStateRescopeJournalPath(fsSync.realpathSync(cwd), context.sessionId),
|
|
2269
|
+
),
|
|
2270
|
+
identity.platform,
|
|
2271
|
+
);
|
|
2272
|
+
for (const journalFile of journalFiles) {
|
|
2273
|
+
const observed = readRegularFileForRelocation(journalFile);
|
|
2274
|
+
if (!observed) throw new PreviousRuntimeStateReadError();
|
|
2275
|
+
const journal = parseRuntimeStateRescopeJournal(observed.raw);
|
|
2276
|
+
verifyRuntimeStateRescopeJournal(journal);
|
|
2277
|
+
if (
|
|
2278
|
+
journal.move_id !== moveId ||
|
|
2279
|
+
journal.session_id !== context.sessionId ||
|
|
2280
|
+
journal.key_id !== identity.sidecarKeyId ||
|
|
2281
|
+
journal.phase !== "prepared"
|
|
2282
|
+
)
|
|
2283
|
+
throw new PreviousRuntimeStateReadError();
|
|
2284
|
+
journal.phase = "publishing";
|
|
2285
|
+
journal.signature = null;
|
|
2286
|
+
if (journal.key_id !== null) {
|
|
2287
|
+
if (!coordinatorSidecarSigningKey) throw new PreviousRuntimeStateReadError();
|
|
2288
|
+
journal.signature = sign(
|
|
2289
|
+
null,
|
|
2290
|
+
Buffer.from(canonicalCoordinatorSidecarPayload(journalSignaturePayload(journal))),
|
|
2291
|
+
coordinatorSidecarSigningKey,
|
|
2292
|
+
).toString("base64");
|
|
2293
|
+
}
|
|
2294
|
+
const contents = `${JSON.stringify(journal)}\n`;
|
|
2295
|
+
await writeCoordinatorAtomic(journalFile, contents, {
|
|
2296
|
+
rename: async (source, destination) => {
|
|
2297
|
+
const sourceStat = fsSync.lstatSync(source, { bigint: true });
|
|
2298
|
+
const sourceParent = fsSync.lstatSync(path.dirname(source), { bigint: true });
|
|
2299
|
+
const replaced = exactReplacePath(
|
|
2300
|
+
source,
|
|
2301
|
+
destination,
|
|
2302
|
+
{
|
|
2303
|
+
dev: sourceStat.dev,
|
|
2304
|
+
ino: sourceStat.ino,
|
|
2305
|
+
nlink: sourceStat.nlink,
|
|
2306
|
+
parentDev: sourceParent.dev,
|
|
2307
|
+
parentIno: sourceParent.ino,
|
|
2308
|
+
size: sourceStat.size,
|
|
2309
|
+
mtimeNs: sourceStat.mtimeNs,
|
|
2310
|
+
sha256: createHash("sha256").update(contents).digest("hex"),
|
|
2311
|
+
},
|
|
2312
|
+
observed.identity,
|
|
2313
|
+
);
|
|
2314
|
+
if (!replaced.ok) throw new PreviousRuntimeStateReadError();
|
|
2315
|
+
},
|
|
2316
|
+
});
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
export async function clearCoordinatorRuntimeStateRescope(
|
|
2321
|
+
context: RuntimeStateContext,
|
|
2322
|
+
expectedMoveId?: string,
|
|
2323
|
+
previousCwd?: string,
|
|
2324
|
+
): Promise<void> {
|
|
2325
|
+
const identity = normalizedIdentity(context);
|
|
2326
|
+
const candidateCwds = [context.cwd, previousCwd].filter((cwd): cwd is string => typeof cwd === "string");
|
|
2327
|
+
const journalFiles = orderedDistinctStateFiles(
|
|
2328
|
+
candidateCwds.map(cwd => runtimeStateRescopeJournalPath(fsSync.realpathSync(cwd), context.sessionId)),
|
|
2329
|
+
identity.platform,
|
|
2330
|
+
);
|
|
2331
|
+
for (const journalFile of journalFiles) {
|
|
2332
|
+
const observed = readRegularFileForRelocation(journalFile);
|
|
2333
|
+
if (!observed) continue;
|
|
2334
|
+
const journal = parseRuntimeStateRescopeJournal(observed.raw);
|
|
2335
|
+
verifyRuntimeStateRescopeJournal(journal);
|
|
2336
|
+
if (expectedMoveId !== undefined && journal.move_id !== expectedMoveId) throw new PreviousRuntimeStateReadError();
|
|
2337
|
+
if (journal.session_id !== context.sessionId || journal.key_id !== identity.sidecarKeyId)
|
|
2338
|
+
throw new PreviousRuntimeStateReadError();
|
|
2339
|
+
const removed = exactUnlinkDirect(journalFile, observed.identity);
|
|
2340
|
+
if (!removed.ok && removed.code !== "not_found") throw new PreviousRuntimeStateReadError();
|
|
2341
|
+
await syncCoordinatorDirectory(path.dirname(journalFile));
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
/**
|
|
2346
|
+
* Cheap synchronous probe for a pending rescope-recovery journal at this cwd. Sessions
|
|
2347
|
+
* that never moved (the overwhelming majority) have none, so callers use this to avoid
|
|
2348
|
+
* arming a startup-turn barrier for a no-op recovery \u2014 an armed barrier is not free even
|
|
2349
|
+
* when it resolves immediately: it flips the session's startup gate from absent to present
|
|
2350
|
+
* and reorders barrier-gated post-turn work.
|
|
2351
|
+
*/
|
|
2352
|
+
export function hasCoordinatorRuntimeStateRescopeJournal(context: RuntimeStateContext): boolean {
|
|
2353
|
+
try {
|
|
2354
|
+
return readRegularFileForRelocation(runtimeStateRescopeJournalPath(context.cwd, context.sessionId)) !== null;
|
|
2355
|
+
} catch {
|
|
2356
|
+
return false;
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
export async function recoverCoordinatorRuntimeStateRescope(context: RuntimeStateContext): Promise<void> {
|
|
2361
|
+
const journalFile = runtimeStateRescopeJournalPath(context.cwd, context.sessionId);
|
|
2362
|
+
const observed = readRegularFileForRelocation(journalFile);
|
|
2363
|
+
if (!observed) return;
|
|
2364
|
+
const journal = parseRuntimeStateRescopeJournal(observed.raw);
|
|
2365
|
+
verifyRuntimeStateRescopeJournal(journal);
|
|
2366
|
+
const current = normalizedIdentity(context);
|
|
2367
|
+
const configuredStateFiles = rescopeStateFiles(journal.session_id, journal.previous_cwd, journal.new_cwd);
|
|
2368
|
+
if (
|
|
2369
|
+
journal.session_id !== context.sessionId ||
|
|
2370
|
+
journal.key_id !== current.sidecarKeyId ||
|
|
2371
|
+
journal.state_file_mode !== configuredStateFiles.mode ||
|
|
2372
|
+
!sameResolvedPath(journal.old_state_file, configuredStateFiles.oldStateFile, current.platform) ||
|
|
2373
|
+
!sameResolvedPath(journal.new_state_file, configuredStateFiles.newStateFile, current.platform)
|
|
2374
|
+
)
|
|
2375
|
+
throw new PreviousRuntimeStateReadError();
|
|
2376
|
+
if (
|
|
2377
|
+
sameResolvedPath(journal.previous_cwd, current.cwd, current.platform) &&
|
|
2378
|
+
sameOptionalResolvedPath(journal.previous_session_file, current.sessionFile, current.platform)
|
|
2379
|
+
) {
|
|
2380
|
+
if (journal.phase !== "prepared") throw new PreviousRuntimeStateReadError();
|
|
2381
|
+
const source = readPreviousPayloadForRelocation(journal.old_state_file);
|
|
2382
|
+
if (Object.keys(source.payload).length === 0) {
|
|
2383
|
+
if (journal.source_sha256 !== null) throw new PreviousRuntimeStateReadError();
|
|
2384
|
+
} else {
|
|
2385
|
+
assertRelocationRuntimeStateIdentity(
|
|
2386
|
+
source.payload,
|
|
2387
|
+
previousRescopeIdentity(
|
|
2388
|
+
{ ...context, previousSessionFile: journal.previous_session_file },
|
|
2389
|
+
journal.previous_cwd,
|
|
2390
|
+
),
|
|
2391
|
+
);
|
|
2392
|
+
if (source.identity?.sha256 !== journal.source_sha256) throw new PreviousRuntimeStateReadError();
|
|
2393
|
+
}
|
|
2394
|
+
await clearCoordinatorRuntimeStateRescope(context, journal.move_id, journal.new_cwd);
|
|
2395
|
+
return;
|
|
2396
|
+
}
|
|
2397
|
+
if (
|
|
2398
|
+
!sameResolvedPath(journal.new_cwd, current.cwd, current.platform) ||
|
|
2399
|
+
!sameOptionalResolvedPath(journal.new_session_file, current.sessionFile, current.platform) ||
|
|
2400
|
+
journal.phase !== "publishing"
|
|
2401
|
+
)
|
|
2402
|
+
throw new PreviousRuntimeStateReadError();
|
|
2403
|
+
const completed = await relocateCoordinatorRuntimeStateForRescope(
|
|
2404
|
+
{ ...context, previousSessionFile: journal.previous_session_file },
|
|
2405
|
+
journal.previous_cwd,
|
|
2406
|
+
);
|
|
2407
|
+
if (!completed) throw new PreviousRuntimeStateReadError();
|
|
2408
|
+
await clearCoordinatorRuntimeStateRescope(context, journal.move_id, journal.previous_cwd);
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
function sameExactFileIdentity(left: NativeExactFileIdentity | null, right: NativeExactFileIdentity | null): boolean {
|
|
2412
|
+
return (
|
|
2413
|
+
left === right ||
|
|
2414
|
+
(left !== null &&
|
|
2415
|
+
right !== null &&
|
|
2416
|
+
left.dev === right.dev &&
|
|
2417
|
+
left.ino === right.ino &&
|
|
2418
|
+
left.nlink === right.nlink &&
|
|
2419
|
+
left.size === right.size &&
|
|
2420
|
+
left.mtimeNs === right.mtimeNs &&
|
|
2421
|
+
left.sha256 === right.sha256)
|
|
2422
|
+
);
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
/**
|
|
2426
|
+
* Repairs the coordinator-shared runtime state after a committed cwd rescope, for every
|
|
2427
|
+
* move surface (`move_session`, TUI `/move`, SDK/ACP `session.cwd.move`) through the one
|
|
2428
|
+
* shared {@link SessionManager.moveTo} seam.
|
|
2429
|
+
*
|
|
2430
|
+
* The identity fence in {@link assertPreviousRuntimeStateIdentity} rejects any write whose
|
|
2431
|
+
* predecessor payload records a different cwd/workdir than the live session. A rescope
|
|
2432
|
+
* changes the session cwd but leaves the predecessor payload pointing at the launch root,
|
|
2433
|
+
* so every later persist for the session would be refused as a foreign marker.
|
|
2434
|
+
*
|
|
2435
|
+
* The predecessor is fully authenticated against its OLD identity before it is trusted:
|
|
2436
|
+
* signed mode verifies the predecessor's key id and Ed25519 signature; unsigned mode
|
|
2437
|
+
* requires an exact old cwd/workdir/session-file match (the same checks the fence applies).
|
|
2438
|
+
* Only a predecessor that passes is rewritten to the new identity and re-signed. When the
|
|
2439
|
+
* state file is cwd-derived rather than pinned by
|
|
2440
|
+
* {@link GJC_COORDINATOR_SESSION_STATE_FILE_ENV}, the payload is migrated to the new path
|
|
2441
|
+
* and the orphaned launch-root file is removed. A payload already self-healed at the new
|
|
2442
|
+
* cwd is left untouched.
|
|
2443
|
+
*
|
|
2444
|
+
* Both the old and new cwd-derived paths are locked (new first, then old) so a concurrent
|
|
2445
|
+
* writer at either path serializes behind this repair. Best-effort by contract: the move
|
|
2446
|
+
* is already durable, so a failure here is logged by the caller and never resurfaced as a
|
|
2447
|
+
* rescope rejection.
|
|
2448
|
+
*/
|
|
2449
|
+
export async function relocateCoordinatorRuntimeStateForRescope(
|
|
2450
|
+
context: RuntimeStateContext,
|
|
2451
|
+
previousCwd: string,
|
|
2452
|
+
): Promise<boolean> {
|
|
2453
|
+
const canonicalNewCwd = fsSync.realpathSync(context.cwd);
|
|
2454
|
+
const canonicalPreviousCwd = fsSync.realpathSync(previousCwd);
|
|
2455
|
+
context = { ...context, cwd: canonicalNewCwd };
|
|
2456
|
+
const rawNewStateFile = runtimeStateFileForContext(context);
|
|
2457
|
+
const newStateFile = rawNewStateFile ? canonicalStateFilePath(rawNewStateFile) : null;
|
|
2458
|
+
if (!newStateFile) return true;
|
|
2459
|
+
const identity = normalizedIdentity(context);
|
|
2460
|
+
const oldIdentity = previousRescopeIdentity(context, previousCwd);
|
|
2461
|
+
const explicitStateFile = process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim();
|
|
2462
|
+
// A pinned state file never moves with the cwd; a cwd-derived one lives under the
|
|
2463
|
+
// per-session runtime directory rooted at the (now former) cwd.
|
|
2464
|
+
const oldStateFile = explicitStateFile
|
|
2465
|
+
? newStateFile
|
|
2466
|
+
: canonicalStateFilePath(
|
|
2467
|
+
path.join(sessionRuntimeDir(canonicalPreviousCwd, identity.sessionId), "runtime-state.json"),
|
|
2468
|
+
);
|
|
2469
|
+
const samePath = sameResolvedPath(oldStateFile, newStateFile, identity.platform);
|
|
2470
|
+
let expectedSourceSha256: string | null | undefined;
|
|
2471
|
+
const journalObservation = readRegularFileForRelocation(
|
|
2472
|
+
runtimeStateRescopeJournalPath(canonicalNewCwd, context.sessionId),
|
|
2473
|
+
);
|
|
2474
|
+
if (journalObservation) {
|
|
2475
|
+
const journal = parseRuntimeStateRescopeJournal(journalObservation.raw);
|
|
2476
|
+
verifyRuntimeStateRescopeJournal(journal);
|
|
2477
|
+
if (
|
|
2478
|
+
journal.session_id !== context.sessionId ||
|
|
2479
|
+
journal.key_id !== identity.sidecarKeyId ||
|
|
2480
|
+
!sameResolvedPath(journal.previous_cwd, canonicalPreviousCwd, identity.platform) ||
|
|
2481
|
+
!sameResolvedPath(journal.new_cwd, canonicalNewCwd, identity.platform) ||
|
|
2482
|
+
!sameResolvedPath(journal.old_state_file, oldStateFile, identity.platform) ||
|
|
2483
|
+
!sameResolvedPath(journal.new_state_file, newStateFile, identity.platform)
|
|
2484
|
+
)
|
|
2485
|
+
throw new PreviousRuntimeStateReadError();
|
|
2486
|
+
expectedSourceSha256 = journal.source_sha256;
|
|
2487
|
+
}
|
|
2488
|
+
const stateFiles = orderedDistinctStateFiles([oldStateFile, newStateFile], identity.platform);
|
|
2489
|
+
return await withSerializedStateFiles(
|
|
2490
|
+
stateFiles,
|
|
2491
|
+
async () =>
|
|
2492
|
+
await withCoordinatorTransactionLocks(
|
|
2493
|
+
stateFiles,
|
|
2494
|
+
async () =>
|
|
2495
|
+
await withStateFileLocks(stateFiles, async () => {
|
|
2496
|
+
await __sessionStateSidecarTestHooks.afterRescopeLocksAcquired?.();
|
|
2497
|
+
const destinationObservation = readPreviousPayloadForRelocation(newStateFile);
|
|
2498
|
+
const destination = destinationObservation.payload;
|
|
2499
|
+
if (samePath && Object.keys(destination).length > 0) {
|
|
2500
|
+
try {
|
|
2501
|
+
assertRelocationRuntimeStateIdentity(destination, identity);
|
|
2502
|
+
return true;
|
|
2503
|
+
} catch {
|
|
2504
|
+
// A pinned predecessor still carries the old identity and is migrated below.
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
let destinationIsCurrent = false;
|
|
2508
|
+
if (!samePath && Object.keys(destination).length > 0) {
|
|
2509
|
+
try {
|
|
2510
|
+
assertRelocationRuntimeStateIdentity(destination, identity);
|
|
2511
|
+
} catch {
|
|
2512
|
+
return false;
|
|
2513
|
+
}
|
|
2514
|
+
destinationIsCurrent = true;
|
|
2515
|
+
}
|
|
2516
|
+
const sourceObservation = samePath
|
|
2517
|
+
? destinationObservation
|
|
2518
|
+
: readPreviousPayloadForRelocation(oldStateFile);
|
|
2519
|
+
const source = sourceObservation.payload;
|
|
2520
|
+
if (Object.keys(source).length === 0) {
|
|
2521
|
+
return expectedSourceSha256 === undefined || expectedSourceSha256 === null || destinationIsCurrent;
|
|
2522
|
+
}
|
|
2523
|
+
if (
|
|
2524
|
+
expectedSourceSha256 !== undefined &&
|
|
2525
|
+
(expectedSourceSha256 === null || sourceObservation.identity?.sha256 !== expectedSourceSha256)
|
|
2526
|
+
)
|
|
2527
|
+
return false;
|
|
2528
|
+
// Trust the predecessor only if it authenticates against its OLD identity. A payload
|
|
2529
|
+
// whose signature, key, or old cwd/session-file does not match is left untouched; the
|
|
2530
|
+
// fence, not this repair, remains the authority for a foreign marker.
|
|
2531
|
+
try {
|
|
2532
|
+
assertRelocationRuntimeStateIdentity(source, oldIdentity);
|
|
2533
|
+
} catch {
|
|
2534
|
+
return false;
|
|
2535
|
+
}
|
|
2536
|
+
const publishSource = !destinationIsCurrent;
|
|
2537
|
+
if (publishSource) {
|
|
2538
|
+
const migrated = {
|
|
2539
|
+
...source,
|
|
2540
|
+
cwd: identity.cwd,
|
|
2541
|
+
workdir: identity.workdir,
|
|
2542
|
+
session_file: identity.sessionFile,
|
|
2543
|
+
branch: branchForContext(context),
|
|
2544
|
+
source: "session_rescope",
|
|
2545
|
+
event: "move_session",
|
|
2546
|
+
updated_at: new Date().toISOString(),
|
|
2547
|
+
};
|
|
2548
|
+
await __sessionStateSidecarTestHooks.beforeRescopePublish?.();
|
|
2549
|
+
await writeStateFileSyncConditional(
|
|
2550
|
+
newStateFile,
|
|
2551
|
+
migrated,
|
|
2552
|
+
identity.sidecarKeyId,
|
|
2553
|
+
destinationObservation.identity,
|
|
2554
|
+
);
|
|
2555
|
+
}
|
|
2556
|
+
if (samePath) return true;
|
|
2557
|
+
// Remove the orphan while both path transactions remain held. Re-read and authenticate
|
|
2558
|
+
// so an unexpected non-cooperating replacement is never removed as this session's state.
|
|
2559
|
+
const orphanObservation = readPreviousPayloadForRelocation(oldStateFile);
|
|
2560
|
+
const orphan = orphanObservation.payload;
|
|
2561
|
+
if (Object.keys(orphan).length === 0) return true;
|
|
2562
|
+
try {
|
|
2563
|
+
assertRelocationRuntimeStateIdentity(orphan, oldIdentity);
|
|
2564
|
+
} catch {
|
|
2565
|
+
return false;
|
|
2566
|
+
}
|
|
2567
|
+
if (!sameExactFileIdentity(sourceObservation.identity, orphanObservation.identity)) return false;
|
|
2568
|
+
if (!orphanObservation.identity) throw new PreviousRuntimeStateReadError();
|
|
2569
|
+
const removed = exactUnlinkDirect(oldStateFile, orphanObservation.identity);
|
|
2570
|
+
if (!removed.ok && removed.code !== "not_found") throw new PreviousRuntimeStateReadError();
|
|
2571
|
+
return true;
|
|
2572
|
+
}),
|
|
2573
|
+
),
|
|
2574
|
+
);
|
|
2575
|
+
}
|
|
2576
|
+
|
|
1684
2577
|
function ownerTerminalSignal(reason: postmortem.Reason): TerminalSignal {
|
|
1685
2578
|
if (reason === postmortem.Reason.SIGTERM) return "SIGTERM";
|
|
1686
2579
|
if (reason === postmortem.Reason.SIGINT) return "SIGINT";
|
|
@@ -1902,8 +2795,9 @@ export async function persistCoordinatorRuntimeStateFromPostmortem(
|
|
|
1902
2795
|
stateFile,
|
|
1903
2796
|
async () =>
|
|
1904
2797
|
await withStateFileLock(stateFile, async () => {
|
|
2798
|
+
assertNoRuntimeStateRescopeJournal(context, identity);
|
|
1905
2799
|
const previous = readPreviousPayload(stateFile);
|
|
1906
|
-
assertPreviousRuntimeStateIdentity(previous, identity);
|
|
2800
|
+
assertPreviousRuntimeStateIdentity(previous, identity, stateFile);
|
|
1907
2801
|
if (shouldPreserveTerminalPayload(previous as RuntimeStateSidecarPayload, identity)) return;
|
|
1908
2802
|
// The immutable owner verdict remains in its lifecycle artifact; never replace a
|
|
1909
2803
|
// complete agent terminal payload merely to mirror that verdict here.
|
|
@@ -1973,16 +2867,21 @@ export async function persistCoordinatorRuntimeStateFromPostmortem(
|
|
|
1973
2867
|
});
|
|
1974
2868
|
}
|
|
1975
2869
|
|
|
1976
|
-
export function registerCoordinatorRuntimeStateFinalizer(
|
|
2870
|
+
export function registerCoordinatorRuntimeStateFinalizer(
|
|
2871
|
+
context: RuntimeStateContext,
|
|
2872
|
+
resolveContext?: () => RuntimeStateContext,
|
|
2873
|
+
): () => void {
|
|
1977
2874
|
if (!runtimeStateFileForContext(context)) return () => {};
|
|
1978
|
-
const ownerTerminal = ownerTerminalContextFromEnvironment();
|
|
1979
|
-
const finalizerContext: RuntimeStateContext =
|
|
1980
|
-
ownerTerminal === "invalid"
|
|
1981
|
-
? { ...context, ownerTerminalMetadataInvalid: true }
|
|
1982
|
-
: ownerTerminal
|
|
1983
|
-
? { ...context, ownerTerminal }
|
|
1984
|
-
: context;
|
|
1985
2875
|
return postmortem.register("coordinator-runtime-state", async reason => {
|
|
2876
|
+
const liveContext = resolveContext?.() ?? context;
|
|
2877
|
+
const ownerTerminal = ownerTerminalContextFromEnvironment();
|
|
2878
|
+
const finalizerContext: RuntimeStateContext =
|
|
2879
|
+
ownerTerminal === "invalid"
|
|
2880
|
+
? { ...liveContext, ownerTerminalMetadataInvalid: true }
|
|
2881
|
+
: ownerTerminal
|
|
2882
|
+
? { ...liveContext, ownerTerminal }
|
|
2883
|
+
: liveContext;
|
|
2884
|
+
await recoverCoordinatorRuntimeStateRescope(finalizerContext);
|
|
1986
2885
|
await persistCoordinatorRuntimeStateFromPostmortem(reason, finalizerContext);
|
|
1987
2886
|
});
|
|
1988
2887
|
}
|