@timurproko/a1 0.2.7-dev.1241 → 0.2.7-dev.1245
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/README.md +24 -2
- package/bin/cli.js +17 -0
- package/dist/app/session-shell/dock-chrome.js +1 -4
- package/dist/cli/capabilities.d.ts +2 -0
- package/dist/cli/capabilities.js +2 -1
- package/dist/cli/dispatch.d.ts +11 -0
- package/dist/cli/dispatch.js +22 -6
- package/dist/cli/package-messages.js +4 -3
- package/dist/cli/session-context.js +18 -1
- package/dist/cli/uninstall.d.ts +30 -0
- package/dist/cli/uninstall.js +117 -0
- package/dist/composition/release-update-observation.js +2 -0
- package/dist/composition/session-runtime-registry.d.ts +4 -7
- package/dist/composition/session-runtime-registry.js +51 -35
- package/dist/foundation/launch-guardian/exit-watcher.js +19 -10
- package/dist/foundation/lifecycle/native-claim-gate.d.ts +5 -0
- package/dist/foundation/lifecycle/native-claim-gate.js +41 -0
- package/dist/foundation/lifecycle/session-claim-lock.d.ts +13 -0
- package/dist/foundation/lifecycle/session-claim-lock.js +131 -0
- package/dist/foundation/lifecycle/session-repository-context.js +3 -104
- package/dist/foundation/process-containment/addon.d.ts +5 -0
- package/dist/foundation/release/automatic-update.d.ts +1 -1
- package/dist/foundation/release/automatic-update.js +1 -1
- package/dist/foundation/release/bootstrap.js +34 -31
- package/dist/foundation/release/cohort-selection.d.ts +0 -6
- package/dist/foundation/release/cohort-selection.js +5 -13
- package/dist/foundation/release/cohort-state.d.ts +2 -0
- package/dist/foundation/release/endpoint-lock.d.ts +2 -0
- package/dist/foundation/release/endpoint-lock.js +37 -0
- package/dist/foundation/release/endpoint-recovery.d.ts +10 -0
- package/dist/foundation/release/endpoint-recovery.js +45 -0
- package/dist/foundation/release/endpoints.d.ts +10 -2
- package/dist/foundation/release/endpoints.js +42 -24
- package/dist/foundation/release/index.d.ts +3 -1
- package/dist/foundation/release/index.js +3 -1
- package/dist/foundation/release/process-cleanup.js +12 -2
- package/dist/foundation/release/supervisor-identity.d.ts +15 -0
- package/dist/foundation/release/supervisor-identity.js +63 -0
- package/dist/foundation/release/update.js +17 -8
- package/dist/foundation/resident-tabs/boot-identity.d.ts +6 -0
- package/dist/foundation/resident-tabs/boot-identity.js +16 -0
- package/dist/foundation/resident-tabs/bounded-host-command.d.ts +2 -0
- package/dist/foundation/resident-tabs/bounded-host-command.js +26 -3
- package/dist/foundation/resident-tabs/continuity.js +11 -3
- package/dist/foundation/resident-tabs/runtime-control.js +2 -1
- package/dist/foundation/storage/control-store.d.ts +5 -3
- package/dist/foundation/storage/control-store.js +8 -7
- package/dist/foundation/supervision/main.js +3 -1
- package/dist/foundation/supervision/server.js +75 -53
- package/dist/integrations/pi/components/shell-footer-status.js +2 -4
- package/dist/integrations/pi/components/shell-shared-facade.d.ts +0 -3
- package/dist/integrations/pi/components/shell-shared-facade.js +0 -8
- package/dist/native/darwin-arm64/manifest.json +3 -3
- package/dist/native/darwin-arm64/process-guardian.node +0 -0
- package/dist/native/darwin-arm64/terminal-host.manifest.json +3 -3
- package/dist/native/darwin-arm64/terminal-host.node +0 -0
- package/dist/native/darwin-x64/manifest.json +3 -3
- package/dist/native/darwin-x64/process-guardian.node +0 -0
- package/dist/native/darwin-x64/terminal-host.manifest.json +3 -3
- package/dist/native/darwin-x64/terminal-host.node +0 -0
- package/dist/native/linux-x64/manifest.json +3 -3
- package/dist/native/linux-x64/process-guardian.node +0 -0
- package/dist/native/linux-x64/terminal-host.manifest.json +3 -3
- package/dist/native/linux-x64/terminal-host.node +0 -0
- package/dist/native/win32-x64/manifest.json +3 -3
- package/dist/native/win32-x64/process-guardian.node +0 -0
- package/dist/native/win32-x64/terminal-host.manifest.json +3 -3
- package/dist/native/win32-x64/terminal-host.node +0 -0
- package/docs/architecture/project-structure.md +4 -0
- package/docs/launch-recovery.md +23 -0
- package/docs/worktree-link-recovery.md +37 -0
- package/package.json +6 -3
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { OwnershipProbe } from "./cohort-selection.js";
|
|
2
|
+
import { type ObserveSupervisorProcess } from "./supervisor-identity.js";
|
|
2
3
|
import type { SupervisorEndpointMetadata } from "./cohort-state.js";
|
|
3
4
|
import type { CohortEndpointPaths, ProductPaths } from "../lifecycle/index.js";
|
|
4
5
|
export interface RecordedEndpoint {
|
|
@@ -20,5 +21,12 @@ export declare function sweepDeadEndpoints(paths: ProductPaths): Promise<readonl
|
|
|
20
21
|
/** The releases that an identity-verified live cohort runs from and that must be retained. */
|
|
21
22
|
export declare function liveReleaseIds(paths: ProductPaths): Promise<readonly string[]>;
|
|
22
23
|
export declare function readEndpointMetadata(path: string): Promise<SupervisorEndpointMetadata | null>;
|
|
23
|
-
export
|
|
24
|
-
|
|
24
|
+
export interface OwnershipProbeOptions {
|
|
25
|
+
readonly observe?: ObserveSupervisorProcess;
|
|
26
|
+
readonly attempts?: number;
|
|
27
|
+
readonly timeoutMs?: number;
|
|
28
|
+
}
|
|
29
|
+
/** Reprobe transient failures; only native absence or a replaced generation establishes death. */
|
|
30
|
+
export declare function probeOwnership(metadata: SupervisorEndpointMetadata, options?: OwnershipProbeOptions): Promise<OwnershipProbe>;
|
|
31
|
+
/** Retire only a still-dead exact publication. Socket removal belongs to the gated listener. */
|
|
32
|
+
export declare function removeEndpointArtifacts(metadataPath: string, endpoint: string, expected: SupervisorEndpointMetadata): Promise<boolean>;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { readdir, readFile, rm } from "node:fs/promises";
|
|
2
2
|
import { connect } from "node:net";
|
|
3
|
-
import { platform } from "node:os";
|
|
4
3
|
import { resolve } from "node:path";
|
|
5
4
|
import { PRODUCT_IDENTITY } from "../../product-identity.js";
|
|
6
5
|
import { encodeFrame, LineFrameDecoder } from "../protocol/index.js";
|
|
7
|
-
import {
|
|
6
|
+
import { supervisorProcessState } from "./supervisor-identity.js";
|
|
7
|
+
import { withEndpointLock } from "./endpoint-lock.js";
|
|
8
8
|
/**
|
|
9
9
|
* Every endpoint recorded under this runtime directory: one per cohort, plus the single
|
|
10
10
|
* endpoint published by releases that predate cohort-scoped identity. More than one can be
|
|
@@ -12,9 +12,12 @@ import { processIsAlive } from "./process-cleanup.js";
|
|
|
12
12
|
*/
|
|
13
13
|
export async function listRecordedEndpoints(paths) {
|
|
14
14
|
const recorded = [];
|
|
15
|
-
|
|
15
|
+
// Compatibility: a cohort listener passes its scoped paths here; legacy discovery still
|
|
16
|
+
// names the runtime directory's fixed metadata path, not that listener's own record.
|
|
17
|
+
const legacyPath = resolve(paths.runtimeDir, PRODUCT_IDENTITY.endpoint.metadataFilename);
|
|
18
|
+
const legacy = await readEndpointMetadata(legacyPath);
|
|
16
19
|
if (legacy) {
|
|
17
|
-
recorded.push({ metadata: legacy, paths: { endpoint:
|
|
20
|
+
recorded.push({ metadata: legacy, paths: { endpoint: legacy.endpoint, endpointMetadataPath: legacyPath } });
|
|
18
21
|
}
|
|
19
22
|
const names = await readdir(paths.endpointsDir).catch(() => []);
|
|
20
23
|
for (const name of names) {
|
|
@@ -35,10 +38,11 @@ export async function listRecordedEndpoints(paths) {
|
|
|
35
38
|
export async function sweepDeadEndpoints(paths) {
|
|
36
39
|
const swept = [];
|
|
37
40
|
for (const recorded of await listRecordedEndpoints(paths)) {
|
|
38
|
-
if (
|
|
41
|
+
if (await supervisorProcessState(recorded.metadata) !== "gone")
|
|
39
42
|
continue;
|
|
40
|
-
await removeEndpointArtifacts(recorded.paths.endpointMetadataPath, recorded.paths.endpoint)
|
|
41
|
-
|
|
43
|
+
if (await removeEndpointArtifacts(recorded.paths.endpointMetadataPath, recorded.paths.endpoint, recorded.metadata)) {
|
|
44
|
+
swept.push(recorded.metadata.releaseId);
|
|
45
|
+
}
|
|
42
46
|
}
|
|
43
47
|
return swept;
|
|
44
48
|
}
|
|
@@ -82,18 +86,24 @@ export async function readEndpointMetadata(path) {
|
|
|
82
86
|
return null;
|
|
83
87
|
}
|
|
84
88
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
89
|
+
/** Reprobe transient failures; only native absence or a replaced generation establishes death. */
|
|
90
|
+
export async function probeOwnership(metadata, options = {}) {
|
|
91
|
+
const attempts = options.attempts ?? 3;
|
|
92
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
93
|
+
const identity = await requestIdentity(metadata.endpoint, options.timeoutMs ?? 500);
|
|
94
|
+
if (identity) {
|
|
95
|
+
return identity.supervisorId === metadata.supervisorId
|
|
96
|
+
&& identity.bootNonce === metadata.bootNonce
|
|
97
|
+
&& identity.pidStartIdentity === metadata.pidStartIdentity
|
|
98
|
+
&& identity.releaseId === metadata.releaseId
|
|
99
|
+
? "live-verified" : "identity-mismatch";
|
|
100
|
+
}
|
|
101
|
+
if (await supervisorProcessState(metadata, options.observe) === "gone")
|
|
102
|
+
return "dead";
|
|
103
|
+
if (attempt + 1 < attempts)
|
|
104
|
+
await new Promise(resolvePromise => setTimeout(resolvePromise, 50));
|
|
105
|
+
}
|
|
106
|
+
return "unresponsive";
|
|
97
107
|
}
|
|
98
108
|
async function requestIdentity(endpoint, timeoutMs) {
|
|
99
109
|
return await new Promise(resolvePromise => {
|
|
@@ -104,6 +114,7 @@ async function requestIdentity(endpoint, timeoutMs) {
|
|
|
104
114
|
if (settled)
|
|
105
115
|
return;
|
|
106
116
|
settled = true;
|
|
117
|
+
clearTimeout(timer);
|
|
107
118
|
socket.destroy();
|
|
108
119
|
resolvePromise(value);
|
|
109
120
|
};
|
|
@@ -121,14 +132,21 @@ async function requestIdentity(endpoint, timeoutMs) {
|
|
|
121
132
|
finish(null);
|
|
122
133
|
}
|
|
123
134
|
});
|
|
124
|
-
setTimeout(() => finish(null), timeoutMs)
|
|
135
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
125
136
|
});
|
|
126
137
|
}
|
|
127
138
|
function isStringArray(value) {
|
|
128
139
|
return Array.isArray(value) && value.every(item => typeof item === "string" && item.length > 0);
|
|
129
140
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
141
|
+
/** Retire only a still-dead exact publication. Socket removal belongs to the gated listener. */
|
|
142
|
+
export async function removeEndpointArtifacts(metadataPath, endpoint, expected) {
|
|
143
|
+
return await withEndpointLock(metadataPath, async () => {
|
|
144
|
+
const current = await readEndpointMetadata(metadataPath);
|
|
145
|
+
if (!current || current.endpoint !== endpoint || JSON.stringify(current) !== JSON.stringify(expected))
|
|
146
|
+
return false;
|
|
147
|
+
if (await supervisorProcessState(current) !== "gone" || await probeOwnership(current, { attempts: 1 }) !== "dead")
|
|
148
|
+
return false;
|
|
149
|
+
await rm(metadataPath, { force: true });
|
|
150
|
+
return true;
|
|
151
|
+
});
|
|
134
152
|
}
|
|
@@ -8,6 +8,8 @@ export type { ActiveReleaseTransactionReference, CohortState, EndpointOwnership,
|
|
|
8
8
|
export { DEPENDENCY_LAYER_MANIFEST, DEPENDENCY_LAYER_SCHEMA, RUNTIME_PAYLOAD_INVENTORY, dependencyLayerCertificationPath, dependencyReference, generateDependencyRuntimePayload, legacyDependencyLayerCertificationPath, materializeDependencyLayer, readCertifiedDependencyLayer, selectDependencyRuntimePayload, selectPublishedDependencyRuntimePayload, verifyDependencyLayer, } from "./dependency-layer.js";
|
|
9
9
|
export type { DependencyLayerIdentity, DependencyLayerOperationEvent, DependencyLayerReference, GeneratedRuntimePayload, MaterializeDependencyLayerOptions, MaterializedDependencyLayer, PublishedRuntimeFileClassification, PublishedRuntimePayload, ReadCertifiedDependencyLayerOptions, RuntimePayloadExclusion, RuntimePayloadInventory, SelectedRuntimePayload, } from "./dependency-layer.js";
|
|
10
10
|
export { listRecordedEndpoints, liveReleaseIds, probeOwnership, readEndpointMetadata, removeEndpointArtifacts, sweepDeadEndpoints } from "./endpoints.js";
|
|
11
|
+
export { currentSupervisorStartIdentity, observeSupervisorProcess, supervisorProcessState } from "./supervisor-identity.js";
|
|
12
|
+
export { withEndpointLock } from "./endpoint-lock.js";
|
|
11
13
|
export type { RecordedEndpoint } from "./endpoints.js";
|
|
12
14
|
export { cleanupProvenIdleOwner, cleanupVerifiedOwner, processIsAlive } from "./process-cleanup.js";
|
|
13
15
|
export type { CleanupDiagnostics } from "./process-cleanup.js";
|
|
@@ -26,7 +28,7 @@ export type { ProcessRequest, ProcessResult, SelfUpdateOptions, UpdateActivation
|
|
|
26
28
|
export { UPDATE_ACTIVATION_CONTRACT, UPDATE_ACTIVATION_ENTRY, UPDATE_ACTIVATION_MANIFEST_FIELD, UpdateActivationBlockedError, activateInstalledRelease, delegateActivation, readActivationContracts, runActivationEntry, } from "./update-activation.js";
|
|
27
29
|
export type { UpdateActivationCallbacks, UpdateActivationEvent, UpdateActivationRequest } from "./update-activation.js";
|
|
28
30
|
export { selectSupervisorLaunchReleaseId, selectUpdateLaunchRelease } from "./update-launch.js";
|
|
29
|
-
export { UPDATE_RECOVERY_SCHEMA, cleanupUpdateRecoveryCapsules, inspectUpdateLauncherSet, npmPrefixForGlobalRoot, prepareUpdateRecoveryCapsule, readUpdateRecoveryCapsule, removeUpdateRecoveryCapsule, runProtectedPackageReplacement, updateLauncherPaths, updateNpmInstallArguments, } from "./update-recovery.js";
|
|
31
|
+
export { UPDATE_RECOVERY_SCHEMA, cleanupUpdateRecoveryCapsules, inspectUpdateLauncherSet, npmPrefixForGlobalRoot, prepareUpdateRecoveryCapsule, readUpdateRecoveryCapsule, removeUpdateRecoveryCapsule, resolveNpmCli, runProtectedPackageReplacement, updateLauncherPaths, updateNpmInstallArguments, } from "./update-recovery.js";
|
|
30
32
|
export type { ProtectedPackageReplacementOptions, ProtectedPackageReplacementResult, UpdateRecoveryCapsule, UpdateRecoveryResult } from "./update-recovery.js";
|
|
31
33
|
export { UPDATE_JOURNAL_SCHEMA, UpdateTransactionStore } from "./update-transaction.js";
|
|
32
34
|
export type { UpdateRecoveryState, UpdateTransaction, UpdateTransactionPhase } from "./update-transaction.js";
|
|
@@ -4,6 +4,8 @@ export { selectOrdinaryLaunchReleaseId } from "./ordinary-launch-selection.js";
|
|
|
4
4
|
export { CohortStateStore, RELEASE_COHORT_SCHEMA, emptyState, planProtectedReleases } from "./cohort-state.js";
|
|
5
5
|
export { DEPENDENCY_LAYER_MANIFEST, DEPENDENCY_LAYER_SCHEMA, RUNTIME_PAYLOAD_INVENTORY, dependencyLayerCertificationPath, dependencyReference, generateDependencyRuntimePayload, legacyDependencyLayerCertificationPath, materializeDependencyLayer, readCertifiedDependencyLayer, selectDependencyRuntimePayload, selectPublishedDependencyRuntimePayload, verifyDependencyLayer, } from "./dependency-layer.js";
|
|
6
6
|
export { listRecordedEndpoints, liveReleaseIds, probeOwnership, readEndpointMetadata, removeEndpointArtifacts, sweepDeadEndpoints } from "./endpoints.js";
|
|
7
|
+
export { currentSupervisorStartIdentity, observeSupervisorProcess, supervisorProcessState } from "./supervisor-identity.js";
|
|
8
|
+
export { withEndpointLock } from "./endpoint-lock.js";
|
|
7
9
|
export { cleanupProvenIdleOwner, cleanupVerifiedOwner, processIsAlive } from "./process-cleanup.js";
|
|
8
10
|
export { PRODUCT_PACKAGE_NAME, createReleaseIdentity, deriveReleaseIdentity, digestManifestFiles, discoverReleasePayload, packageRootFromModule, releaseFileIdentity, resolveWithin, } from "./release.js";
|
|
9
11
|
export { collectRelease, prepareReleaseCleanup, runBoundedReleaseCleanup, runReleaseCleanupWorker, scheduleReleaseCleanup } from "./release-gc.js";
|
|
@@ -13,6 +15,6 @@ export { STABLE_RELEASE_SCHEMA, createStableReleaseEvidence, verifyStableRegistr
|
|
|
13
15
|
export { PACKAGE_UNLOCK_PATIENCE_MS, UPDATE_ACTIVATION_BLOCKED_EXIT_CODE, UPDATE_ALREADY_RUNNING_EXIT_CODE, PRODUCT_PACKAGE, assertUpdatePerformanceBudget, createNpmProcessRunner, createUpdateLifecycleCoordinator, isPackageLockError, lockedPackageDiagnostic, planUpdateOwnership, renderUpdateProgressBar, runSelfUpdate, } from "./update.js";
|
|
14
16
|
export { UPDATE_ACTIVATION_CONTRACT, UPDATE_ACTIVATION_ENTRY, UPDATE_ACTIVATION_MANIFEST_FIELD, UpdateActivationBlockedError, activateInstalledRelease, delegateActivation, readActivationContracts, runActivationEntry, } from "./update-activation.js";
|
|
15
17
|
export { selectSupervisorLaunchReleaseId, selectUpdateLaunchRelease } from "./update-launch.js";
|
|
16
|
-
export { UPDATE_RECOVERY_SCHEMA, cleanupUpdateRecoveryCapsules, inspectUpdateLauncherSet, npmPrefixForGlobalRoot, prepareUpdateRecoveryCapsule, readUpdateRecoveryCapsule, removeUpdateRecoveryCapsule, runProtectedPackageReplacement, updateLauncherPaths, updateNpmInstallArguments, } from "./update-recovery.js";
|
|
18
|
+
export { UPDATE_RECOVERY_SCHEMA, cleanupUpdateRecoveryCapsules, inspectUpdateLauncherSet, npmPrefixForGlobalRoot, prepareUpdateRecoveryCapsule, readUpdateRecoveryCapsule, removeUpdateRecoveryCapsule, resolveNpmCli, runProtectedPackageReplacement, updateLauncherPaths, updateNpmInstallArguments, } from "./update-recovery.js";
|
|
17
19
|
export { UPDATE_JOURNAL_SCHEMA, UpdateTransactionStore } from "./update-transaction.js";
|
|
18
20
|
export { warmMaterializedRelease } from "./warmup.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { supervisorProcessState } from "./supervisor-identity.js";
|
|
2
3
|
export function processIsAlive(pid) {
|
|
3
4
|
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
4
5
|
return false;
|
|
@@ -15,17 +16,26 @@ export async function cleanupProvenIdleOwner(metadata, graceMs = 1_500) {
|
|
|
15
16
|
}
|
|
16
17
|
export async function cleanupVerifiedOwner(metadata, options = {}) {
|
|
17
18
|
const graceMs = options.graceMs ?? 1_500;
|
|
18
|
-
if (!options.allowLiveInstances && (metadata.ownership.liveInstanceIds.length > 0 || metadata.ownership.nonResumableInstanceIds.length > 0)) {
|
|
19
|
+
if (!options.allowLiveInstances && (metadata.ownership.liveInstanceIds.length > 0 || metadata.ownership.nonResumableInstanceIds.length > 0 || metadata.ownership.uncertainInstanceIds.length > 0)) {
|
|
19
20
|
throw new Error("refusing to terminate an owner with recorded live generations");
|
|
20
21
|
}
|
|
21
22
|
const started = Date.now();
|
|
22
23
|
const attempted = [];
|
|
23
|
-
|
|
24
|
+
const state = await supervisorProcessState(metadata);
|
|
25
|
+
if (state === "gone")
|
|
24
26
|
return { pid: metadata.pid, attempted: ["owner-already-dead"], terminated: true, elapsedMs: Date.now() - started };
|
|
27
|
+
// Security: ordinary recovery never signals a live owner, even with an empty durable list.
|
|
28
|
+
// Explicit update cleanup also needs the exact native generation, not merely an old handshake.
|
|
29
|
+
if (!options.allowLiveInstances || state !== "same") {
|
|
30
|
+
return { pid: metadata.pid, attempted: ["live-owner-preserved"], terminated: false, elapsedMs: Date.now() - started };
|
|
31
|
+
}
|
|
25
32
|
attempted.push("graceful-termination");
|
|
26
33
|
await terminate(metadata.pid, false);
|
|
27
34
|
if (await waitUntilDead(metadata.pid, graceMs))
|
|
28
35
|
return { pid: metadata.pid, attempted, terminated: true, elapsedMs: Date.now() - started };
|
|
36
|
+
if (await supervisorProcessState(metadata) !== "same") {
|
|
37
|
+
return { pid: metadata.pid, attempted, terminated: await supervisorProcessState(metadata) === "gone", elapsedMs: Date.now() - started };
|
|
38
|
+
}
|
|
29
39
|
attempted.push("forced-process-tree-termination");
|
|
30
40
|
await terminate(metadata.pid, true);
|
|
31
41
|
const terminated = await waitUntilDead(metadata.pid, graceMs);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { NativeProcessIdentity } from "../lifecycle/index.js";
|
|
2
|
+
import type { SupervisorEndpointMetadata } from "./cohort-state.js";
|
|
3
|
+
export type SupervisorProcessState = "same" | "gone" | "unknown";
|
|
4
|
+
export type ObserveSupervisorProcess = (pid: number) => Promise<NativeProcessIdentity | null>;
|
|
5
|
+
/** Inspection code comes from this runtime, never from a path in a stale endpoint record. */
|
|
6
|
+
export declare function observeSupervisorProcess(pid: number): Promise<NativeProcessIdentity | null>;
|
|
7
|
+
/** New supervisors use a native creation token as their opaque handshake identity as well. */
|
|
8
|
+
export declare function currentSupervisorStartIdentity(): string;
|
|
9
|
+
/** The current runtime also owns the OS gate ABI; metadata cannot choose a different addon. */
|
|
10
|
+
export declare function acquireSupervisorGate(path: string): {
|
|
11
|
+
readonly acquired: boolean;
|
|
12
|
+
release(): void;
|
|
13
|
+
};
|
|
14
|
+
/** A failed inspection is uncertainty, never permission to erase a possibly live owner. */
|
|
15
|
+
export declare function supervisorProcessState(metadata: SupervisorEndpointMetadata, observe?: ObserveSupervisorProcess): Promise<SupervisorProcessState>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { createNativeProcessInspector, loadProcessGuardianAddon, resolveProcessGuardianPath } from "../process-containment/index.js";
|
|
3
|
+
const ownRoot = fileURLToPath(new URL("../../../", import.meta.url));
|
|
4
|
+
/** Inspection code comes from this runtime, never from a path in a stale endpoint record. */
|
|
5
|
+
export async function observeSupervisorProcess(pid) {
|
|
6
|
+
return await (await createNativeProcessInspector(process.env, process.platform, ownRoot)).observe(pid);
|
|
7
|
+
}
|
|
8
|
+
/** New supervisors use a native creation token as their opaque handshake identity as well. */
|
|
9
|
+
export function currentSupervisorStartIdentity() {
|
|
10
|
+
try {
|
|
11
|
+
const token = loadProcessGuardianAddon(resolveProcessGuardianPath(ownRoot, process.env)).inspectProcessStart(process.pid);
|
|
12
|
+
if (!validNativeToken(token))
|
|
13
|
+
throw new Error("native process identity is unavailable");
|
|
14
|
+
return token;
|
|
15
|
+
}
|
|
16
|
+
catch (cause) {
|
|
17
|
+
throw Object.assign(new Error("cannot verify A1's background service process; check installation and process-inspection permissions", { cause }), { code: "SUPERVISOR_RECOVERY_UNAVAILABLE" });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** The current runtime also owns the OS gate ABI; metadata cannot choose a different addon. */
|
|
21
|
+
export function acquireSupervisorGate(path) {
|
|
22
|
+
const addon = loadProcessGuardianAddon(resolveProcessGuardianPath(ownRoot, process.env));
|
|
23
|
+
if (!addon.tryAcquireProcessGate)
|
|
24
|
+
throw new Error("this installation lacks startup recovery support; update or repair A1");
|
|
25
|
+
return addon.tryAcquireProcessGate(path);
|
|
26
|
+
}
|
|
27
|
+
/** A failed inspection is uncertainty, never permission to erase a possibly live owner. */
|
|
28
|
+
export async function supervisorProcessState(metadata, observe = observeSupervisorProcess) {
|
|
29
|
+
let identity;
|
|
30
|
+
try {
|
|
31
|
+
identity = await observe(metadata.pid);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return "unknown";
|
|
35
|
+
}
|
|
36
|
+
if (identity === null)
|
|
37
|
+
return "gone";
|
|
38
|
+
if (identity.pid !== metadata.pid)
|
|
39
|
+
return "unknown";
|
|
40
|
+
if (metadata.nativeStartIdentity !== undefined) {
|
|
41
|
+
// Compatibility: only compare tokens of the same native format, not an old Node timestamp.
|
|
42
|
+
if (!validNativeToken(metadata.nativeStartIdentity) || !validNativeToken(identity.startIdentity)
|
|
43
|
+
|| identity.startIdentity.split(":")[0] !== metadata.nativeStartIdentity.split(":")[0])
|
|
44
|
+
return "unknown";
|
|
45
|
+
return identity.startIdentity === metadata.nativeStartIdentity ? "same" : "gone";
|
|
46
|
+
}
|
|
47
|
+
// Compatibility: legacy publication happened after process creation. A native creation time
|
|
48
|
+
// strictly later than that publication (with a one-second clock margin) disproves ownership;
|
|
49
|
+
// a near timestamp is NOT proof of ownership. Linux tick-only tokens cannot prove this.
|
|
50
|
+
const published = Date.parse(metadata.startedAt);
|
|
51
|
+
const created = nativeCreationTime(identity.startIdentity);
|
|
52
|
+
return Number.isFinite(published) && created !== null && created > published + 1_000 ? "gone" : "unknown";
|
|
53
|
+
}
|
|
54
|
+
function validNativeToken(value) {
|
|
55
|
+
return typeof value === "string" && /^(?:windows-filetime:\d{1,20}|linux-proc-start:\d{1,20}|darwin-proc-start:\d{1,20}:\d{1,6})$/.test(value);
|
|
56
|
+
}
|
|
57
|
+
function nativeCreationTime(identity) {
|
|
58
|
+
const windows = /^windows-filetime:(\d+)$/.exec(identity);
|
|
59
|
+
if (windows)
|
|
60
|
+
return Number(BigInt(windows[1]) / 10000n - 11644473600000n);
|
|
61
|
+
const darwin = /^darwin-proc-start:(\d+):(\d+)$/.exec(identity);
|
|
62
|
+
return darwin ? Number(darwin[1]) * 1_000 + Number(darwin[2]) / 1_000 : null;
|
|
63
|
+
}
|
|
@@ -155,7 +155,7 @@ export function createUpdateLifecycleCoordinator(environment = process.env, file
|
|
|
155
155
|
}
|
|
156
156
|
const plan = planUpdateOwnership(ownership, await canonicalImmutableRoot(paths.dataDir, endpoint.releaseRoot));
|
|
157
157
|
if (plan === "clean-dead-record") {
|
|
158
|
-
await removeEndpointArtifacts(owner.paths.endpointMetadataPath, owner.paths.endpoint);
|
|
158
|
+
await removeEndpointArtifacts(owner.paths.endpointMetadataPath, owner.paths.endpoint, endpoint);
|
|
159
159
|
return { priorActiveVersion };
|
|
160
160
|
}
|
|
161
161
|
// Rationale: leaving sessions alone is the expected outcome, and saying so mid-update tears
|
|
@@ -182,7 +182,7 @@ export function createUpdateLifecycleCoordinator(environment = process.env, file
|
|
|
182
182
|
throw new Error(`verified ${PRODUCT_TEXT.displayName} owner ${endpoint.pid} did not terminate`);
|
|
183
183
|
});
|
|
184
184
|
}
|
|
185
|
-
await removeEndpointArtifacts(owner.paths.endpointMetadataPath, owner.paths.endpoint);
|
|
185
|
+
await removeEndpointArtifacts(owner.paths.endpointMetadataPath, owner.paths.endpoint, endpoint);
|
|
186
186
|
return { priorActiveVersion };
|
|
187
187
|
},
|
|
188
188
|
async verifyPackageUnlocked(packageRoot) {
|
|
@@ -757,7 +757,7 @@ export async function runSelfUpdate(options) {
|
|
|
757
757
|
const message = errorMessage(error);
|
|
758
758
|
if (error instanceof UpdateAutomaticDeferred)
|
|
759
759
|
return 1;
|
|
760
|
-
const blockedExit = await preparedActivationExit(error, transaction, mode, output, transactionStore);
|
|
760
|
+
const blockedExit = await preparedActivationExit(error, transaction, mode, output, transactionStore, targetVersion, paths.configDir);
|
|
761
761
|
if (blockedExit !== null)
|
|
762
762
|
return blockedExit;
|
|
763
763
|
if (error instanceof UpdatePreflightFailure) {
|
|
@@ -769,7 +769,7 @@ export async function runSelfUpdate(options) {
|
|
|
769
769
|
: await rollbackPriorCohort(paths.dataDir, environment, transaction?.priorActiveReleaseId ?? null).catch(rollbackError => `rollback failed: ${errorMessage(rollbackError)}`);
|
|
770
770
|
if (transaction)
|
|
771
771
|
await transactionStore.finish(rollback === "rolled back" ? "rolled-back" : "failed", `${message}; ${rollback}`);
|
|
772
|
-
output.stderr(
|
|
772
|
+
output.stderr(updateFailureLine(message, rollback, transactionStore.path));
|
|
773
773
|
return error instanceof UpdateFailure ? error.exitCode : 1;
|
|
774
774
|
}
|
|
775
775
|
});
|
|
@@ -869,13 +869,22 @@ async function activateUpdateTransaction(lifecycle, transaction, packageRoot, ta
|
|
|
869
869
|
else
|
|
870
870
|
await lifecycle.activateInstalled(packageRoot, targetVersion, phase, materializing, warmup);
|
|
871
871
|
}
|
|
872
|
-
async function preparedActivationExit(error, transaction, mode, output, journal) {
|
|
872
|
+
async function preparedActivationExit(error, transaction, mode, output, journal, targetVersion, configDir) {
|
|
873
873
|
if (!(error instanceof UpdateActivationBlockedError) || transaction === null || phaseBefore(transaction.phase, "certified"))
|
|
874
874
|
return null;
|
|
875
|
-
if (mode === "manual")
|
|
876
|
-
output.stderr(`${PRODUCT_TEXT.diagnostic(`update prepared, but activation is blocked: ${error.message}. The prior client remains selected; run the update again after the resident blocker clears.`)}\n`);
|
|
877
875
|
await journal.recordActiveError?.(error.message);
|
|
878
|
-
|
|
876
|
+
if (mode === "automatic")
|
|
877
|
+
return UPDATE_ACTIVATION_BLOCKED_EXIT_CODE;
|
|
878
|
+
// Invariant: a certified candidate waiting only for resident continuity is an installed update;
|
|
879
|
+
// the next startup check retries activation without another installation.
|
|
880
|
+
await invalidateReleaseCheckCache(configDir).catch(() => undefined);
|
|
881
|
+
output.stdout(`${PRODUCT_TEXT.commandName} updated to ${targetVersion}; it takes over automatically once running Agents can switch\n`);
|
|
882
|
+
return 0;
|
|
883
|
+
}
|
|
884
|
+
/** One reason and one next step; routine rollback detail stays in the journal. */
|
|
885
|
+
function updateFailureLine(message, rollback, journalPath) {
|
|
886
|
+
const note = rollback.startsWith("rollback failed") ? ` (${rollback})` : "";
|
|
887
|
+
return `${PRODUCT_TEXT.diagnostic(`update failed: ${message}${note}. Run ${PRODUCT_TEXT.commandName} update to retry. Details: ${journalPath}`)}\n`;
|
|
879
888
|
}
|
|
880
889
|
function phaseBefore(current, target) {
|
|
881
890
|
const phases = ["shutdown-intent", "ownership-released", "package-installed", "materialized", "certified", "active-reference-committed", "supervisor-verified"];
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mirrors the native boot matching rule. Legacy `macos-boot:<sec>:<usec>` records drift on clock
|
|
3
|
+
* corrections, so two of them match within the tolerance; only the native host can prove one
|
|
4
|
+
* against a current identity.
|
|
5
|
+
*/
|
|
6
|
+
export declare function sameBootIdentity(left: string, right: string): boolean;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Seconds a legacy clock-derived boot identity may drift and still name the same boot. */
|
|
2
|
+
const LEGACY_BOOT_TOLERANCE_SECONDS = 2;
|
|
3
|
+
const LEGACY_MACOS_BOOT = /^macos-boot:(\d+):\d+$/u;
|
|
4
|
+
/**
|
|
5
|
+
* Mirrors the native boot matching rule. Legacy `macos-boot:<sec>:<usec>` records drift on clock
|
|
6
|
+
* corrections, so two of them match within the tolerance; only the native host can prove one
|
|
7
|
+
* against a current identity.
|
|
8
|
+
*/
|
|
9
|
+
export function sameBootIdentity(left, right) {
|
|
10
|
+
if (left === right)
|
|
11
|
+
return true;
|
|
12
|
+
const first = LEGACY_MACOS_BOOT.exec(left)?.[1];
|
|
13
|
+
const second = LEGACY_MACOS_BOOT.exec(right)?.[1];
|
|
14
|
+
return first !== undefined && second !== undefined
|
|
15
|
+
&& Math.abs(Number(first) - Number(second)) <= LEGACY_BOOT_TOLERANCE_SECONDS;
|
|
16
|
+
}
|
|
@@ -2,6 +2,8 @@ import type { TerminalHostCommand } from "./ensure-terminal-host.js";
|
|
|
2
2
|
export interface BoundedHostCommandResult {
|
|
3
3
|
readonly code: number;
|
|
4
4
|
readonly stdout: string;
|
|
5
|
+
/** The command's last diagnostic line, bounded and stripped of control characters; never parsed. */
|
|
6
|
+
readonly stderr: string;
|
|
5
7
|
}
|
|
6
8
|
export interface BoundedHostCommandLimits {
|
|
7
9
|
/** Output beyond this many bytes fails the exchange instead of being parsed. */
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
/** Diagnostics beyond this many bytes are dropped rather than buffered. */
|
|
3
|
+
const MAX_STDERR_BYTES = 4096;
|
|
2
4
|
/**
|
|
3
5
|
* Runs one terminal-host command whose report is small. An oversized report kills the command and
|
|
4
6
|
* reports a failed exchange; a command that outlives its deadline is killed and rejected.
|
|
@@ -8,9 +10,18 @@ export async function runBoundedHostCommand(command, arguments_, limits) {
|
|
|
8
10
|
const child = spawn(command.executable, [command.entry, ...arguments_], {
|
|
9
11
|
env: limits.environment,
|
|
10
12
|
windowsHide: true,
|
|
11
|
-
stdio: ["ignore", "pipe", "
|
|
13
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
12
14
|
});
|
|
13
15
|
const stdout = [];
|
|
16
|
+
const stderr = [];
|
|
17
|
+
let stderrSize = 0;
|
|
18
|
+
child.stderr.on("data", (chunk) => {
|
|
19
|
+
if (stderrSize >= MAX_STDERR_BYTES)
|
|
20
|
+
return;
|
|
21
|
+
const kept = chunk.subarray(0, MAX_STDERR_BYTES - stderrSize);
|
|
22
|
+
stderrSize += kept.length;
|
|
23
|
+
stderr.push(Buffer.from(kept));
|
|
24
|
+
});
|
|
14
25
|
let size = 0;
|
|
15
26
|
let overflowed = false;
|
|
16
27
|
child.stdout.on("data", chunk => {
|
|
@@ -26,9 +37,10 @@ export async function runBoundedHostCommand(command, arguments_, limits) {
|
|
|
26
37
|
child.once("error", rejectPromise);
|
|
27
38
|
child.once("close", code => {
|
|
28
39
|
clearTimeout(timer);
|
|
40
|
+
const diagnostic = lastDiagnosticLine(Buffer.concat(stderr).toString("utf8"));
|
|
29
41
|
resolvePromise(overflowed
|
|
30
|
-
? { code: code === 0 || code === null ? 1 : code, stdout: "" }
|
|
31
|
-
: { code: code ?? 1, stdout: Buffer.concat(stdout).toString("utf8") });
|
|
42
|
+
? { code: code === 0 || code === null ? 1 : code, stdout: "", stderr: diagnostic }
|
|
43
|
+
: { code: code ?? 1, stdout: Buffer.concat(stdout).toString("utf8"), stderr: diagnostic });
|
|
32
44
|
});
|
|
33
45
|
const timer = setTimeout(() => {
|
|
34
46
|
child.kill("SIGKILL");
|
|
@@ -37,3 +49,14 @@ export async function runBoundedHostCommand(command, arguments_, limits) {
|
|
|
37
49
|
timer.unref();
|
|
38
50
|
});
|
|
39
51
|
}
|
|
52
|
+
/** Keeps one terminal-safe line: the native host reports its reason last. */
|
|
53
|
+
function lastDiagnosticLine(text) {
|
|
54
|
+
const lines = text.split(/\r?\n/u)
|
|
55
|
+
// Security: diagnostics never carry terminal controls into a later message.
|
|
56
|
+
.map(line => line
|
|
57
|
+
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/gu, "")
|
|
58
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "")
|
|
59
|
+
.trim())
|
|
60
|
+
.filter(line => line.length > 0);
|
|
61
|
+
return (lines.at(-1) ?? "").slice(0, 300);
|
|
62
|
+
}
|
|
@@ -10,7 +10,7 @@ export async function assertResidentContinuity(options) {
|
|
|
10
10
|
const first = await inspect(context);
|
|
11
11
|
const second = await inspect(context);
|
|
12
12
|
if (first !== second && second !== await inspect(context)) {
|
|
13
|
-
throw new Error("Resident workspace changed during update readiness
|
|
13
|
+
throw new Error("Resident workspace changed during update readiness");
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
/** Native inspection/import is read-only toward execution and never starts or stops a source. */
|
|
@@ -40,16 +40,24 @@ export async function inspectResidentContinuity(options) {
|
|
|
40
40
|
"workspace-check", "--profile", options.profileRoot,
|
|
41
41
|
"--data-dir", options.dataDir, "--runtime-dir", options.runtimeDir,
|
|
42
42
|
], { label: "resident continuity check", maxStdoutBytes: 4096, timeoutMs: 8_000, environment: options.environment });
|
|
43
|
+
const unverified = new Error(continuityFailure(result.stderr));
|
|
43
44
|
let report;
|
|
44
45
|
try {
|
|
45
46
|
report = JSON.parse(result.stdout.trim());
|
|
46
47
|
}
|
|
47
48
|
catch {
|
|
48
|
-
throw
|
|
49
|
+
throw unverified;
|
|
49
50
|
}
|
|
50
51
|
if (result.code !== 0 || report.schema !== "resident-workspace-check-v1" || report.compatible !== true
|
|
51
52
|
|| typeof report.fingerprint !== "string" || !/^[a-f0-9]{64}$/u.test(report.fingerprint)) {
|
|
52
|
-
throw
|
|
53
|
+
throw unverified;
|
|
53
54
|
}
|
|
54
55
|
return report.fingerprint;
|
|
55
56
|
}
|
|
57
|
+
/** Names the native reason when the host reported one, without the host's own prefix. */
|
|
58
|
+
function continuityFailure(diagnostic) {
|
|
59
|
+
const reason = diagnostic.replace(/^a1-terminal-host:\s*/u, "").replace(/[.\s]+$/u, "");
|
|
60
|
+
return reason.length > 0
|
|
61
|
+
? `Resident control compatibility could not be verified: ${reason}`
|
|
62
|
+
: "Resident control compatibility could not be verified";
|
|
63
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sameBootIdentity } from "./boot-identity.js";
|
|
1
2
|
import { runTerminalHostMaintenance } from "./maintenance.js";
|
|
2
3
|
/** Mandatory native control semantics; optional methods never establish source authority. */
|
|
3
4
|
export const RUNTIME_CONTROL_API = "resident-control-v1";
|
|
@@ -21,7 +22,7 @@ export function assessRuntimeControl(value, expected) {
|
|
|
21
22
|
return blocked("invalid-contract");
|
|
22
23
|
}
|
|
23
24
|
if (value.releaseId !== expected.releaseId || value.scope !== expected.scope
|
|
24
|
-
|| value.bootId
|
|
25
|
+
|| !sameBootIdentity(value.bootId, expected.bootId) || value.osSessionId !== expected.osSessionId)
|
|
25
26
|
return blocked("source-mismatch");
|
|
26
27
|
if (value.epoch !== expected.epoch)
|
|
27
28
|
return blocked("stale-epoch");
|
|
@@ -5,14 +5,16 @@ export declare class ControlStore {
|
|
|
5
5
|
#private;
|
|
6
6
|
readonly database: DatabaseSync;
|
|
7
7
|
readonly bootNonce: string | null;
|
|
8
|
-
constructor(path: string, bootNonce?: string | null
|
|
8
|
+
constructor(path: string, bootNonce?: string | null, options?: {
|
|
9
|
+
readonly reconcileOnOpen?: boolean;
|
|
10
|
+
});
|
|
9
11
|
migrate(): void;
|
|
10
12
|
createLaunchInstance(instance: LaunchInstance): void;
|
|
11
13
|
activateLaunchInstance(instanceId: LaunchInstanceId, ownerClientId: string, rootIdentity: NativeProcessIdentity, containmentIdentity: ProcessContainmentIdentity, activatedAt: string): boolean;
|
|
12
14
|
beginLaunchInstanceStop(instanceId: LaunchInstanceId, ownerClientId: string, stoppingAt: string): boolean;
|
|
13
15
|
completeLaunchInstance(instanceId: LaunchInstanceId, ownerClientId: string, terminalState: "completed" | "interrupted", outcome: LaunchInstanceOutcome, completedAt: string): boolean;
|
|
14
|
-
loadActiveLaunchInstances(): LaunchInstance[];
|
|
16
|
+
loadActiveLaunchInstances(bootNonce?: string): LaunchInstance[];
|
|
15
17
|
loadLaunchInstance(instanceId: LaunchInstanceId): LaunchInstance | null;
|
|
16
|
-
reconcilePriorBootLaunchInstances(bootNonce: string, reconciledAt?: string): number;
|
|
18
|
+
reconcilePriorBootLaunchInstances(bootNonce: string, reconciledAt?: string, protectedBoots?: readonly string[]): number;
|
|
17
19
|
close(): void;
|
|
18
20
|
}
|
|
@@ -30,7 +30,7 @@ const LAUNCH_INSTANCES_TABLE = `
|
|
|
30
30
|
export class ControlStore {
|
|
31
31
|
database;
|
|
32
32
|
bootNonce;
|
|
33
|
-
constructor(path, bootNonce = null) {
|
|
33
|
+
constructor(path, bootNonce = null, options = {}) {
|
|
34
34
|
this.bootNonce = bootNonce;
|
|
35
35
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
36
36
|
this.database = new DatabaseSync(path);
|
|
@@ -38,7 +38,7 @@ export class ControlStore {
|
|
|
38
38
|
this.database.exec("PRAGMA journal_mode = WAL");
|
|
39
39
|
this.database.exec("PRAGMA foreign_keys = ON");
|
|
40
40
|
this.migrate();
|
|
41
|
-
if (bootNonce !== null)
|
|
41
|
+
if (bootNonce !== null && options.reconcileOnOpen !== false)
|
|
42
42
|
this.reconcilePriorBootLaunchInstances(bootNonce);
|
|
43
43
|
}
|
|
44
44
|
catch (error) {
|
|
@@ -284,16 +284,16 @@ export class ControlStore {
|
|
|
284
284
|
.run(terminalState, completedAt, JSON.stringify(outcome), instanceId, ownerClientId, this.bootNonce);
|
|
285
285
|
return Number(result.changes) === 1;
|
|
286
286
|
}
|
|
287
|
-
loadActiveLaunchInstances() {
|
|
287
|
+
loadActiveLaunchInstances(bootNonce) {
|
|
288
288
|
return this.database.prepare(`SELECT * FROM launch_instances
|
|
289
|
-
WHERE state IN ('requested', 'active', 'stopping') ORDER BY created_at, id`).all()
|
|
289
|
+
WHERE state IN ('requested', 'active', 'stopping') ${bootNonce === undefined ? "" : "AND owner_boot_nonce = ?"} ORDER BY created_at, id`).all(...(bootNonce === undefined ? [] : [bootNonce]))
|
|
290
290
|
.map(launchInstanceFromRow);
|
|
291
291
|
}
|
|
292
292
|
loadLaunchInstance(instanceId) {
|
|
293
293
|
const row = this.database.prepare("SELECT * FROM launch_instances WHERE id = ?").get(instanceId);
|
|
294
294
|
return row ? launchInstanceFromRow(row) : null;
|
|
295
295
|
}
|
|
296
|
-
reconcilePriorBootLaunchInstances(bootNonce, reconciledAt = new Date().toISOString()) {
|
|
296
|
+
reconcilePriorBootLaunchInstances(bootNonce, reconciledAt = new Date().toISOString(), protectedBoots = []) {
|
|
297
297
|
const outcome = {
|
|
298
298
|
kind: "interrupted",
|
|
299
299
|
reason: "supervisor-disconnect",
|
|
@@ -301,8 +301,9 @@ export class ControlStore {
|
|
|
301
301
|
};
|
|
302
302
|
const result = this.database.prepare(`UPDATE launch_instances
|
|
303
303
|
SET state = 'interrupted', completed_at = ?, outcome_json = ?
|
|
304
|
-
WHERE state IN ('requested', 'active', 'stopping') AND owner_boot_nonce <>
|
|
305
|
-
|
|
304
|
+
WHERE state IN ('requested', 'active', 'stopping') AND owner_boot_nonce <> ?
|
|
305
|
+
${protectedBoots.length === 0 ? "" : `AND owner_boot_nonce NOT IN (${protectedBoots.map(() => "?").join(",")})`}`)
|
|
306
|
+
.run(reconciledAt, JSON.stringify(outcome), bootNonce, ...protectedBoots);
|
|
306
307
|
return Number(result.changes);
|
|
307
308
|
}
|
|
308
309
|
close() {
|
|
@@ -36,7 +36,9 @@ export async function runSupervisor(arguments_ = []) {
|
|
|
36
36
|
paths = { ...productPaths, ...resolveCohortEndpoint(productPaths, release.releaseId) };
|
|
37
37
|
stage = "storage";
|
|
38
38
|
const bootNonce = randomUUID();
|
|
39
|
-
|
|
39
|
+
// Concurrency: opening SQLite does not establish endpoint authority. The winning listener
|
|
40
|
+
// reconciles old boots only after binding; losing startup attempts must not mutate live rows.
|
|
41
|
+
const store = new ControlStore(paths.databasePath, bootNonce, { reconcileOnOpen: false });
|
|
40
42
|
const cohortState = new CohortStateStore(paths.dataDir);
|
|
41
43
|
server = new SupervisorServer(store, paths, release, bootNonce, undefined, undefined, undefined, async () => {
|
|
42
44
|
const transaction = await new UpdateTransactionStore(paths.dataDir).read();
|