@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
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { PRODUCT_IDENTITY } from "../../product-identity.js";
|
|
6
|
+
let backend;
|
|
7
|
+
/** No PID-file fallback: only the platform descriptor can prove upgraded-writer exclusion. */
|
|
8
|
+
export function tryAcquireClaimGate(path) {
|
|
9
|
+
if (backend === undefined) {
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
// Security: resolve only this release's addon, never a binary supplied by tool cwd.
|
|
12
|
+
const own = resolve(fileURLToPath(new URL("../../../", import.meta.url)), "dist", "native", `${process.platform}-${process.arch}`, `${PRODUCT_IDENTITY.artifacts.terminalHostAddon}.node`);
|
|
13
|
+
const override = process.env[PRODUCT_IDENTITY.environment.developmentInstanceId]
|
|
14
|
+
? process.env[PRODUCT_IDENTITY.environment.terminalHostPath] : undefined;
|
|
15
|
+
for (const candidate of override ? [override, own] : [own]) {
|
|
16
|
+
if (!existsSync(candidate))
|
|
17
|
+
continue;
|
|
18
|
+
try {
|
|
19
|
+
const loaded = require(candidate);
|
|
20
|
+
if (typeof loaded.SessionLock?.tryAcquireGate === "function") {
|
|
21
|
+
backend = loaded.SessionLock;
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch { /* Compatibility: an unusable addon is unavailable, not contended. */ }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (backend === undefined)
|
|
29
|
+
throw Object.assign(new Error("native worktree coordination is unavailable; repair or update this A1 installation and retry"), {
|
|
30
|
+
code: "worktree-coordination-unavailable",
|
|
31
|
+
});
|
|
32
|
+
try {
|
|
33
|
+
const handle = backend.tryAcquireGate(path);
|
|
34
|
+
return handle.acquired ? handle : null;
|
|
35
|
+
}
|
|
36
|
+
catch (cause) {
|
|
37
|
+
throw Object.assign(new Error("cannot access the worktree mutation gate; check A1 data-directory permissions and filesystem availability", { cause }), {
|
|
38
|
+
code: "worktree-coordination-io",
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
interface ClaimLockOptions {
|
|
2
|
+
readonly signal?: AbortSignal;
|
|
3
|
+
readonly lockTimeoutMs?: number;
|
|
4
|
+
}
|
|
5
|
+
/** Inventory is advisory and read-only, including when abandoned metadata needs recovery. */
|
|
6
|
+
export declare function claimMutationPending(directory: string): Promise<boolean>;
|
|
7
|
+
/**
|
|
8
|
+
* The persistent directory excludes legacy wx-file writers. Only native descriptor authority
|
|
9
|
+
* permits upgraded mutations; owner.json is disposable evidence, never a liveness oracle.
|
|
10
|
+
* Never remove the barrier or authority file: doing so would create a second lock universe.
|
|
11
|
+
*/
|
|
12
|
+
export declare function withSessionClaimLock<T>(directory: string, options: ClaimLockOptions, operation: () => Promise<T>): Promise<T>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, open, readdir, realpath, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
+
import { tryAcquireClaimGate } from "./native-claim-gate.js";
|
|
6
|
+
const FILES = new Set(["owner.json", "owner.next", "recovery.json", "recovery.next"]);
|
|
7
|
+
/** Inventory is advisory and read-only, including when abandoned metadata needs recovery. */
|
|
8
|
+
export async function claimMutationPending(directory) {
|
|
9
|
+
try {
|
|
10
|
+
const barrier = join(directory, "mutation.lock");
|
|
11
|
+
const metadata = await lstat(barrier);
|
|
12
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink())
|
|
13
|
+
return true;
|
|
14
|
+
const files = await readdir(barrier);
|
|
15
|
+
return files.some(name => name !== "recovery.json");
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
return !hasCode(error, "ENOENT");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The persistent directory excludes legacy wx-file writers. Only native descriptor authority
|
|
23
|
+
* permits upgraded mutations; owner.json is disposable evidence, never a liveness oracle.
|
|
24
|
+
* Never remove the barrier or authority file: doing so would create a second lock universe.
|
|
25
|
+
*/
|
|
26
|
+
export async function withSessionClaimLock(directory, options, operation) {
|
|
27
|
+
options.signal?.throwIfAborted();
|
|
28
|
+
let canonical;
|
|
29
|
+
try {
|
|
30
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
31
|
+
canonical = await realpath(directory);
|
|
32
|
+
}
|
|
33
|
+
catch (cause) {
|
|
34
|
+
throw failure("worktree-coordination-io", "cannot access the A1 claim directory; check permissions and filesystem availability", cause);
|
|
35
|
+
}
|
|
36
|
+
const authority = join(canonical, "mutation.authority");
|
|
37
|
+
const barrier = join(canonical, "mutation.lock");
|
|
38
|
+
const deadline = Date.now() + (options.lockTimeoutMs ?? 2_000);
|
|
39
|
+
let gate;
|
|
40
|
+
while (gate === undefined) {
|
|
41
|
+
options.signal?.throwIfAborted();
|
|
42
|
+
const acquired = tryAcquireClaimGate(authority);
|
|
43
|
+
if (acquired !== null) {
|
|
44
|
+
gate = acquired;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
if (Date.now() >= deadline)
|
|
48
|
+
throw failure("worktree-claim-busy", "worktree mutation is temporarily busy; automatic runtime registration will retry, and linking can be retried");
|
|
49
|
+
await delay(Math.min(20, Math.max(1, deadline - Date.now())), undefined, { signal: options.signal });
|
|
50
|
+
}
|
|
51
|
+
let operationStarted = false;
|
|
52
|
+
try {
|
|
53
|
+
options.signal?.throwIfAborted();
|
|
54
|
+
try {
|
|
55
|
+
await mkdir(barrier, { mode: 0o700 });
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (!hasCode(error, "EEXIST"))
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
const metadata = await lstat(barrier);
|
|
62
|
+
if (metadata.isSymbolicLink() || (!metadata.isFile() && !metadata.isDirectory())) {
|
|
63
|
+
throw failure("worktree-coordination-boundary", "worktree mutation barrier is not a regular file or directory; retain it for inspection");
|
|
64
|
+
}
|
|
65
|
+
if (metadata.isFile()) {
|
|
66
|
+
// Security: a new OS gate proves nothing about an older writer that does not participate in it.
|
|
67
|
+
// Even a dead legacy PID does not exclude a competing legacy reclaimer's rename race.
|
|
68
|
+
throw failure("worktree-legacy-recovery-required", "legacy worktree lock prevents safe recovery; stop all older A1 processes, back up only session-worktree-claims/mutation.lock while stopped, then start the updated A1; do not delete sessions or worktree claims");
|
|
69
|
+
}
|
|
70
|
+
for (const name of await readdir(barrier)) {
|
|
71
|
+
const entry = await lstat(join(barrier, name));
|
|
72
|
+
if (!FILES.has(name) || !entry.isFile() || entry.isSymbolicLink()) {
|
|
73
|
+
throw failure("worktree-coordination-boundary", "unexpected worktree coordination entry; retain it for inspection rather than deleting the claim store");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const owner = join(barrier, "owner.json");
|
|
77
|
+
const pending = join(barrier, "owner.next");
|
|
78
|
+
if (await exists(owner) || await exists(pending)) {
|
|
79
|
+
// Security: keep one bounded summary, not an accumulating dump of potentially sensitive corrupt bytes.
|
|
80
|
+
await publish(barrier, "recovery", { reason: "abandoned-mutation-metadata", recoveredAt: new Date().toISOString() });
|
|
81
|
+
await rm(owner, { force: true });
|
|
82
|
+
await rm(pending, { force: true });
|
|
83
|
+
}
|
|
84
|
+
await publish(barrier, "owner", { generation: randomUUID(), acquiredAt: new Date().toISOString() });
|
|
85
|
+
try {
|
|
86
|
+
options.signal?.throwIfAborted();
|
|
87
|
+
operationStarted = true;
|
|
88
|
+
return await operation();
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
// Concurrency: authority is still held; no successor can publish until after this removal completes.
|
|
92
|
+
await rm(owner, { force: true });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (operationStarted)
|
|
97
|
+
throw error;
|
|
98
|
+
if (error instanceof Error && (error.name === "AbortError" || ("code" in error && String(error.code).startsWith("worktree-"))))
|
|
99
|
+
throw error;
|
|
100
|
+
throw failure("worktree-coordination-io", "worktree coordination I/O failed; check A1 data-directory permissions and filesystem availability, then retry", error);
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
gate.release();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function publish(directory, name, value) {
|
|
107
|
+
const pending = join(directory, `${name}.next`);
|
|
108
|
+
await rm(pending, { force: true });
|
|
109
|
+
const handle = await open(pending, "wx", 0o600);
|
|
110
|
+
try {
|
|
111
|
+
await handle.writeFile(`${JSON.stringify(value)}\n`);
|
|
112
|
+
await handle.sync();
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
await handle.close();
|
|
116
|
+
}
|
|
117
|
+
await rename(pending, join(directory, `${name}.json`));
|
|
118
|
+
}
|
|
119
|
+
async function exists(path) {
|
|
120
|
+
try {
|
|
121
|
+
await lstat(path);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (hasCode(error, "ENOENT"))
|
|
126
|
+
return false;
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function hasCode(error, code) { return error instanceof Error && "code" in error && error.code === code; }
|
|
131
|
+
function failure(code, message, cause) { return Object.assign(new Error(message, { cause }), { code }); }
|
|
@@ -4,6 +4,7 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { PRODUCT_IDENTITY } from "../../product-identity.js";
|
|
6
6
|
import { resolveProductPaths } from "./paths.js";
|
|
7
|
+
import { claimMutationPending, withSessionClaimLock } from "./session-claim-lock.js";
|
|
7
8
|
const CONTEXT_SCHEMA = "a1-session-repository-context-v1";
|
|
8
9
|
const RUNTIME_SCHEMA = "a1-session-runtime-v1";
|
|
9
10
|
const CLAIM_SCHEMA = "a1-session-worktree-claim-v1";
|
|
@@ -11,8 +12,6 @@ const MAX_RECORD_BYTES = 16 * 1024;
|
|
|
11
12
|
const MAX_HEADER_BYTES = 64 * 1024;
|
|
12
13
|
const MAX_WORKTREES = 256;
|
|
13
14
|
const GIT_TIMEOUT_MS = 5_000;
|
|
14
|
-
const LOCK_TIMEOUT_MS = 2_000;
|
|
15
|
-
const LOCK_RETRY_MS = 20;
|
|
16
15
|
/** Records without an agent id were written by a single-agent release and belong to its one agent. */
|
|
17
16
|
export const PRIMARY_SESSION_AGENT_ID = "primary";
|
|
18
17
|
const RECORD_FORMAT_VERSION = 2;
|
|
@@ -444,104 +443,10 @@ async function executeGit(arguments_, cwd, maxBuffer, signal) {
|
|
|
444
443
|
return await execute("git", [...arguments_], { cwd, windowsHide: true, timeout: GIT_TIMEOUT_MS, maxBuffer, encoding: "utf8", signal });
|
|
445
444
|
}
|
|
446
445
|
async function mutationLockExists(options) {
|
|
447
|
-
|
|
448
|
-
await lstat(join(claimStoreRoot(options), "mutation.lock"));
|
|
449
|
-
return true;
|
|
450
|
-
}
|
|
451
|
-
catch (error) {
|
|
452
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
453
|
-
return false;
|
|
454
|
-
return true;
|
|
455
|
-
}
|
|
446
|
+
return await claimMutationPending(claimStoreRoot(options));
|
|
456
447
|
}
|
|
457
448
|
async function withClaimLock(options, operation) {
|
|
458
|
-
|
|
459
|
-
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
460
|
-
const path = join(directory, "mutation.lock");
|
|
461
|
-
const deadline = Date.now() + (options.lockTimeoutMs ?? LOCK_TIMEOUT_MS);
|
|
462
|
-
const holder = await lockHolderIdentity(options);
|
|
463
|
-
let handle;
|
|
464
|
-
for (let attempt = 0; handle === undefined; attempt++) {
|
|
465
|
-
try {
|
|
466
|
-
handle = await open(path, "wx", 0o600);
|
|
467
|
-
}
|
|
468
|
-
catch (error) {
|
|
469
|
-
if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
|
|
470
|
-
throw error;
|
|
471
|
-
if (attempt === 0 && await evictDeadLockHolder(path, options))
|
|
472
|
-
continue;
|
|
473
|
-
if (Date.now() >= deadline)
|
|
474
|
-
throw contextError("worktree claim mutation is busy", "worktree-claim-busy");
|
|
475
|
-
await new Promise(resolvePromise => setTimeout(resolvePromise, LOCK_RETRY_MS));
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
try {
|
|
479
|
-
const record = {
|
|
480
|
-
pid: process.pid,
|
|
481
|
-
...(holder === null ? {} : { startIdentity: holder.startIdentity }),
|
|
482
|
-
acquiredAt: new Date().toISOString(),
|
|
483
|
-
nonce: randomUUID(),
|
|
484
|
-
};
|
|
485
|
-
await handle.writeFile(`${JSON.stringify(record)}\n`);
|
|
486
|
-
return await operation();
|
|
487
|
-
}
|
|
488
|
-
finally {
|
|
489
|
-
await handle.close().catch(() => undefined);
|
|
490
|
-
await rm(path, { force: true }).catch(() => undefined);
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
const lockHolderIdentities = new WeakMap();
|
|
494
|
-
/** This process's identity as recorded in the lock; null leaves the lock unevictable, never wrongly evictable. */
|
|
495
|
-
async function lockHolderIdentity(options) {
|
|
496
|
-
if (options.runtimeIdentity?.pid === process.pid)
|
|
497
|
-
return options.runtimeIdentity;
|
|
498
|
-
const inspection = options.inspectProcess;
|
|
499
|
-
if (inspection === undefined)
|
|
500
|
-
return null;
|
|
501
|
-
let identity = lockHolderIdentities.get(inspection);
|
|
502
|
-
if (identity === undefined) {
|
|
503
|
-
identity = inspection(process.pid)
|
|
504
|
-
.then(observed => observed !== null && observed.pid === process.pid ? observed : null)
|
|
505
|
-
.catch(() => null);
|
|
506
|
-
lockHolderIdentities.set(inspection, identity);
|
|
507
|
-
}
|
|
508
|
-
return await identity;
|
|
509
|
-
}
|
|
510
|
-
/**
|
|
511
|
-
* Evict the mutation lock only when its recorded holder is proven dead: the pid is gone or now names a process
|
|
512
|
-
* with another start identity. A lock without a holder identity, or a holder that cannot be inspected, is kept.
|
|
513
|
-
*/
|
|
514
|
-
async function evictDeadLockHolder(path, options) {
|
|
515
|
-
const inspection = options.inspectProcess;
|
|
516
|
-
if (inspection === undefined)
|
|
517
|
-
return false;
|
|
518
|
-
const holder = await readJsonRecord(path, isMutationLockRecord);
|
|
519
|
-
if (holder === null || holder.startIdentity === undefined || holder.pid === process.pid)
|
|
520
|
-
return false;
|
|
521
|
-
let observed;
|
|
522
|
-
try {
|
|
523
|
-
observed = await inspection(holder.pid);
|
|
524
|
-
}
|
|
525
|
-
catch {
|
|
526
|
-
return false;
|
|
527
|
-
}
|
|
528
|
-
if (observed !== null && observed.pid === holder.pid && observed.startIdentity === holder.startIdentity)
|
|
529
|
-
return false;
|
|
530
|
-
const evicted = `${path}.${randomUUID()}.evicted`;
|
|
531
|
-
try {
|
|
532
|
-
await rename(path, evicted);
|
|
533
|
-
}
|
|
534
|
-
catch {
|
|
535
|
-
return false;
|
|
536
|
-
}
|
|
537
|
-
const moved = await readJsonRecord(evicted, isMutationLockRecord);
|
|
538
|
-
if (moved?.nonce !== holder.nonce) {
|
|
539
|
-
// Concurrency: another waiter evicted first and a live holder took the lock since; hand it back untouched.
|
|
540
|
-
await rename(evicted, path).catch(() => undefined);
|
|
541
|
-
return false;
|
|
542
|
-
}
|
|
543
|
-
await rm(evicted, { force: true });
|
|
544
|
-
return true;
|
|
449
|
+
return await withSessionClaimLock(claimStoreRoot(options), options, operation);
|
|
545
450
|
}
|
|
546
451
|
async function atomicWrite(path, value) {
|
|
547
452
|
const directory = dirname(path);
|
|
@@ -668,12 +573,6 @@ function isStoredClaimRecord(value) {
|
|
|
668
573
|
&& typeof value.startIdentity === "string" && value.startIdentity.length > 0 && value.startIdentity.length <= 512
|
|
669
574
|
&& !value.startIdentity.includes("\0") && typeof value.updatedAt === "string";
|
|
670
575
|
}
|
|
671
|
-
function isMutationLockRecord(value) {
|
|
672
|
-
return isRecord(value) && Number.isSafeInteger(value.pid) && Number(value.pid) > 0
|
|
673
|
-
&& (value.startIdentity === undefined || (typeof value.startIdentity === "string" && value.startIdentity.length > 0
|
|
674
|
-
&& value.startIdentity.length <= 512 && !value.startIdentity.includes("\0")))
|
|
675
|
-
&& typeof value.acquiredAt === "string" && typeof value.nonce === "string" && value.nonce.length > 0;
|
|
676
|
-
}
|
|
677
576
|
function validRecordVersion(value) {
|
|
678
577
|
if (value.formatVersion === undefined)
|
|
679
578
|
return value.agentId === undefined;
|
|
@@ -25,6 +25,11 @@ export interface SpawnContainedOptions {
|
|
|
25
25
|
export interface ProcessGuardianAddon {
|
|
26
26
|
version(): ProcessGuardianVersion;
|
|
27
27
|
inspectProcessStart(pid: number): string | null;
|
|
28
|
+
/** Additive capability; older retained addons remain usable for containment, not recovery gates. */
|
|
29
|
+
tryAcquireProcessGate?(path: string): {
|
|
30
|
+
readonly acquired: boolean;
|
|
31
|
+
release(): void;
|
|
32
|
+
};
|
|
28
33
|
captureTerminalModes(): string;
|
|
29
34
|
restoreTerminalModes(encoded: string): void;
|
|
30
35
|
deliverExitNotice(path: string, encodedModes: string): void;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type AvailableRelease, type ReleaseChannel } from "./latest-release.js";
|
|
2
2
|
export declare const AUTOMATIC_UPDATE_STATE_SCHEMA = 1;
|
|
3
3
|
export declare const AUTOMATIC_UPDATE_RETRY_MS: number;
|
|
4
|
-
export declare const AUTOMATIC_UPDATE_ACTIVATION_RETRY_MS
|
|
4
|
+
export declare const AUTOMATIC_UPDATE_ACTIVATION_RETRY_MS = 15000;
|
|
5
5
|
export declare const ACTIVE_RELEASE_FALLBACK_MS = 5000;
|
|
6
6
|
export interface AutomaticUpdateState {
|
|
7
7
|
readonly schema: typeof AUTOMATIC_UPDATE_STATE_SCHEMA;
|
|
@@ -15,7 +15,7 @@ import { UpdateTransactionStore } from "./update-transaction.js";
|
|
|
15
15
|
import { runSelfUpdate, UPDATE_ACTIVATION_BLOCKED_EXIT_CODE, UPDATE_ALREADY_RUNNING_EXIT_CODE } from "./update.js";
|
|
16
16
|
export const AUTOMATIC_UPDATE_STATE_SCHEMA = 1;
|
|
17
17
|
export const AUTOMATIC_UPDATE_RETRY_MS = 24 * 60 * 60_000;
|
|
18
|
-
export const AUTOMATIC_UPDATE_ACTIVATION_RETRY_MS =
|
|
18
|
+
export const AUTOMATIC_UPDATE_ACTIVATION_RETRY_MS = 15_000;
|
|
19
19
|
const AUTOMATIC_UPDATE_STATE_FILENAME = "automatic-update.json";
|
|
20
20
|
const MAX_STATE_BYTES = 16_384;
|
|
21
21
|
const OBSERVER_POLL_MS = 250;
|
|
@@ -4,6 +4,7 @@ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
|
4
4
|
import { connect } from "node:net";
|
|
5
5
|
import { resolve } from "node:path";
|
|
6
6
|
import { selectCohortLaunch } from "./cohort-selection.js";
|
|
7
|
+
import { recoverSupervisorEndpoint } from "./endpoint-recovery.js";
|
|
7
8
|
import { selectOrdinaryLaunchReleaseId } from "./ordinary-launch-selection.js";
|
|
8
9
|
import { releaseChannelOf } from "./latest-release.js";
|
|
9
10
|
import { CohortStateStore } from "./cohort-state.js";
|
|
@@ -22,7 +23,16 @@ import { PRIVATE_ENVIRONMENT, isCurrentLaunchContract, launchContractTarget, rea
|
|
|
22
23
|
import { assertResidentContinuity, ensureTerminalHost, readResidentTabLaunchSettings, selectResidentHost } from "../resident-tabs/index.js";
|
|
23
24
|
const RELEASE_RESELECTION_MESSAGE = "a1-release-reselection";
|
|
24
25
|
export async function runBootstrap(options) {
|
|
25
|
-
|
|
26
|
+
try {
|
|
27
|
+
return await runBootstrapAttempt(options, 1);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (!(error instanceof Error && "code" in error && typeof error.code === "string" && error.code.startsWith("SUPERVISOR_RECOVERY_")))
|
|
31
|
+
throw error;
|
|
32
|
+
// Rationale: actionable recovery failures are a single line, not an uncaught Node stack trace.
|
|
33
|
+
(options.output ?? process.stderr).write(`${PRODUCT_TEXT.diagnostic(error.message)}\n`);
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
26
36
|
}
|
|
27
37
|
async function runBootstrapAttempt(options, retriesRemaining) {
|
|
28
38
|
const launchProfileId = options.launchIntent?.profileId ?? "a1";
|
|
@@ -122,7 +132,7 @@ async function runBootstrapAttempt(options, retriesRemaining) {
|
|
|
122
132
|
}
|
|
123
133
|
if (endpoint === null || probe === "dead") {
|
|
124
134
|
if (endpoint)
|
|
125
|
-
await removeEndpointArtifacts(endpointPaths.endpointMetadataPath, endpointPaths.endpoint);
|
|
135
|
+
await removeEndpointArtifacts(endpointPaths.endpointMetadataPath, endpointPaths.endpoint, endpoint);
|
|
126
136
|
await markStartupPhase(environment, "durable-validation-start");
|
|
127
137
|
const retained = await readRestartCertifiedRelease(active, paths.dataDir).catch(async () => {
|
|
128
138
|
let verified;
|
|
@@ -217,20 +227,12 @@ async function runBootstrapAttempt(options, retriesRemaining) {
|
|
|
217
227
|
probe = endpoint ? await probeOwnership(endpoint) : "dead";
|
|
218
228
|
let decision = selectCohortLaunch(candidate, state, endpoint, probe);
|
|
219
229
|
if (decision.action === "blocked") {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
return
|
|
223
|
-
}
|
|
224
|
-
if (decision.action === "clean-stale-owner" && endpoint) {
|
|
225
|
-
const diagnostics = await cleanupProvenIdleOwner(endpoint);
|
|
226
|
-
await writeFile(resolve(paths.dataDir, `cleanup-${Date.now()}.json`), JSON.stringify(diagnostics, null, 2));
|
|
227
|
-
if (!diagnostics.terminated)
|
|
228
|
-
throw new Error(`could not safely clean stale ${PRODUCT_TEXT.displayName} supervisor ${endpoint.pid}`);
|
|
229
|
-
await removeEndpointArtifacts(endpointPaths.endpointMetadataPath, endpointPaths.endpoint);
|
|
230
|
-
decision = selectCohortLaunch(candidate, await stateStore.read(), null, "dead");
|
|
230
|
+
// Concurrency: reread through the common recovery path rather than fail on an old snapshot.
|
|
231
|
+
await ensureSupervisor(candidate, environment);
|
|
232
|
+
return await launchSelected(candidate);
|
|
231
233
|
}
|
|
232
|
-
|
|
233
|
-
await removeEndpointArtifacts(endpointPaths.endpointMetadataPath, endpointPaths.endpoint);
|
|
234
|
+
if (endpoint && probe === "dead") {
|
|
235
|
+
await removeEndpointArtifacts(endpointPaths.endpointMetadataPath, endpointPaths.endpoint, endpoint);
|
|
234
236
|
}
|
|
235
237
|
if (decision.action === "launch-retained-ui") {
|
|
236
238
|
const retainedRecord = state.releases[decision.releaseId];
|
|
@@ -259,7 +261,7 @@ async function runBootstrapAttempt(options, retriesRemaining) {
|
|
|
259
261
|
output.write(`${PRODUCT_TEXT.diagnostic("could not complete bounded idle cohort shutdown; the candidate remains pending.")}\n`);
|
|
260
262
|
return 1;
|
|
261
263
|
}
|
|
262
|
-
await removeEndpointArtifacts(endpointPaths.endpointMetadataPath, endpointPaths.endpoint);
|
|
264
|
+
await removeEndpointArtifacts(endpointPaths.endpointMetadataPath, endpointPaths.endpoint, endpoint);
|
|
263
265
|
await stateStore.activate(candidate.releaseId);
|
|
264
266
|
decision = { action: "activate-candidate", releaseId: candidate.releaseId, releaseRoot: candidate.releaseRoot, reason: "idle cohort released ownership" };
|
|
265
267
|
}
|
|
@@ -377,17 +379,15 @@ export async function recordParentCertifiedRelease(release, dataDir) {
|
|
|
377
379
|
/** Reuse a verified owner, or join the winner if another launch starts the same cohort first. */
|
|
378
380
|
export async function ensureSupervisor(release, environment) {
|
|
379
381
|
const paths = resolveCohortEndpoint(resolveProductPaths(environment), release.releaseId, environment);
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
const startup = await startSupervisor(release, environment);
|
|
390
|
-
await waitForVerifiedEndpoint(paths.endpointMetadataPath, release, 8_000, startup);
|
|
382
|
+
await recoverSupervisorEndpoint({
|
|
383
|
+
metadataPath: paths.endpointMetadataPath,
|
|
384
|
+
endpoint: paths.endpoint,
|
|
385
|
+
matches: metadata => endpointMatchesRelease(metadata, release),
|
|
386
|
+
startAndWait: async () => {
|
|
387
|
+
const startup = await startSupervisor(release, environment);
|
|
388
|
+
await waitForVerifiedEndpoint(paths.endpointMetadataPath, release, 8_000, startup);
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
391
|
}
|
|
392
392
|
function endpointMatchesRelease(metadata, release) {
|
|
393
393
|
return metadata.releaseId === release.releaseId && metadata.releaseRoot === release.releaseRoot
|
|
@@ -463,7 +463,7 @@ export async function waitForVerifiedEndpoint(path, release, timeoutMs, startup)
|
|
|
463
463
|
void startup?.childOutcome.then(outcome => { childOutcome = outcome; });
|
|
464
464
|
while (Date.now() < deadline) {
|
|
465
465
|
const metadata = await readEndpointMetadata(path);
|
|
466
|
-
if (metadata && endpointMatchesRelease(metadata, release) && await probeOwnership(metadata) === "live-verified") {
|
|
466
|
+
if (metadata && endpointMatchesRelease(metadata, release) && await probeOwnership(metadata, { attempts: 1 }) === "live-verified") {
|
|
467
467
|
if (startup)
|
|
468
468
|
await rm(startup.resultPath, { force: true });
|
|
469
469
|
return;
|
|
@@ -507,18 +507,21 @@ async function activatePendingAfterBlockerExit(candidate, stateStore, paths, env
|
|
|
507
507
|
return;
|
|
508
508
|
if (!await releaseVerifiedIdleOwner(endpoint, paths.dataDir))
|
|
509
509
|
return;
|
|
510
|
-
await removeEndpointArtifacts(paths.endpointMetadataPath, paths.endpoint);
|
|
510
|
+
await removeEndpointArtifacts(paths.endpointMetadataPath, paths.endpoint, endpoint);
|
|
511
511
|
await stateStore.activate(candidate.releaseId);
|
|
512
512
|
await ensureSupervisor(candidate, environment);
|
|
513
513
|
}
|
|
514
514
|
export async function releaseVerifiedIdleOwner(metadata, dataDir, operations = {}) {
|
|
515
515
|
try {
|
|
516
|
+
// Protocol: the owner rechecks its boot nonce and live instances before cooperative exit.
|
|
517
|
+
// A durable idle snapshot alone never authorizes a signal to a still-live process.
|
|
518
|
+
await requestIdleOwnershipRelease(metadata, metadata.releaseId, 2_000);
|
|
516
519
|
await (operations.waitForExit ?? waitForProcessExit)(metadata.pid, 3_000);
|
|
517
520
|
return true;
|
|
518
521
|
}
|
|
519
522
|
catch {
|
|
520
|
-
// Security:
|
|
521
|
-
//
|
|
523
|
+
// Security: a lingering Windows handle can keep numeric PID probes positive after exit.
|
|
524
|
+
// Native observation may retire that exact dead boot, but never signals a still-live owner.
|
|
522
525
|
const diagnostics = await (operations.cleanup ?? cleanupProvenIdleOwner)(metadata);
|
|
523
526
|
await writeFile(resolve(dataDir, `cleanup-${Date.now()}.json`), JSON.stringify(diagnostics, null, 2));
|
|
524
527
|
return diagnostics.terminated;
|
|
@@ -23,12 +23,6 @@ export type CohortLaunchDecision = {
|
|
|
23
23
|
readonly releaseId: string;
|
|
24
24
|
readonly releaseRoot: string;
|
|
25
25
|
readonly reason: string;
|
|
26
|
-
} | {
|
|
27
|
-
readonly action: "clean-stale-owner";
|
|
28
|
-
readonly releaseId: string;
|
|
29
|
-
readonly releaseRoot: string;
|
|
30
|
-
readonly reason: string;
|
|
31
|
-
readonly pid: number;
|
|
32
26
|
} | {
|
|
33
27
|
readonly action: "blocked";
|
|
34
28
|
readonly releaseId: string;
|
|
@@ -30,20 +30,12 @@ export function selectCohortLaunch(candidate, state, endpoint, probe) {
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
if (endpoint && probe === "unresponsive") {
|
|
33
|
-
|
|
34
|
-
return {
|
|
35
|
-
action: "blocked",
|
|
36
|
-
releaseId: endpoint.releaseId,
|
|
37
|
-
releaseRoot: endpoint.releaseRoot,
|
|
38
|
-
reason: "ownership of live launch instances is uncertain; preserving the unresponsive supervisor",
|
|
39
|
-
pid: endpoint.pid,
|
|
40
|
-
};
|
|
41
|
-
}
|
|
33
|
+
// Security: even an empty persisted list cannot authorize killing an unresponsive process.
|
|
42
34
|
return {
|
|
43
|
-
action: "
|
|
44
|
-
releaseId:
|
|
45
|
-
releaseRoot:
|
|
46
|
-
reason: "
|
|
35
|
+
action: "blocked",
|
|
36
|
+
releaseId: endpoint.releaseId,
|
|
37
|
+
releaseRoot: endpoint.releaseRoot,
|
|
38
|
+
reason: "the background service did not respond after automatic recovery; retry shortly",
|
|
47
39
|
pid: endpoint.pid,
|
|
48
40
|
};
|
|
49
41
|
}
|
|
@@ -94,6 +94,8 @@ export interface SupervisorEndpointMetadata {
|
|
|
94
94
|
readonly endpoint: string;
|
|
95
95
|
readonly pid: number;
|
|
96
96
|
readonly pidStartIdentity: string;
|
|
97
|
+
/** Exact OS creation token; absent on historical supervisor metadata. */
|
|
98
|
+
readonly nativeStartIdentity?: string;
|
|
97
99
|
readonly bootNonce: string;
|
|
98
100
|
readonly startedAt: string;
|
|
99
101
|
readonly releaseId: string;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { mkdir, realpath } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
+
import { acquireSupervisorGate } from "./supervisor-identity.js";
|
|
5
|
+
/** Serialize endpoint publication and retirement using a lock released by the OS on process death. */
|
|
6
|
+
export async function withEndpointLock(metadataPath, operation) {
|
|
7
|
+
let authority;
|
|
8
|
+
try {
|
|
9
|
+
await mkdir(dirname(metadataPath), { recursive: true, mode: 0o700 });
|
|
10
|
+
authority = join(await realpath(dirname(metadataPath)), `${basename(metadataPath)}.authority`);
|
|
11
|
+
}
|
|
12
|
+
catch (cause) {
|
|
13
|
+
throw Object.assign(new Error("cannot access A1 startup state; check runtime-directory permissions and free disk space", { cause }), { code: "SUPERVISOR_RECOVERY_UNAVAILABLE" });
|
|
14
|
+
}
|
|
15
|
+
const deadline = Date.now() + 3_000;
|
|
16
|
+
for (;;) {
|
|
17
|
+
let gate;
|
|
18
|
+
try {
|
|
19
|
+
gate = acquireSupervisorGate(authority);
|
|
20
|
+
}
|
|
21
|
+
catch (cause) {
|
|
22
|
+
throw Object.assign(new Error("cannot access A1 startup coordination; check installation and runtime-directory permissions", { cause }), { code: "SUPERVISOR_RECOVERY_UNAVAILABLE" });
|
|
23
|
+
}
|
|
24
|
+
if (gate.acquired) {
|
|
25
|
+
try {
|
|
26
|
+
return await operation();
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
gate.release();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
gate.release();
|
|
33
|
+
if (Date.now() >= deadline)
|
|
34
|
+
throw Object.assign(new Error("another A1 startup is still preparing; retry shortly"), { code: "SUPERVISOR_RECOVERY_BUSY" });
|
|
35
|
+
await delay(20);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SupervisorEndpointMetadata } from "./cohort-state.js";
|
|
2
|
+
interface SupervisorRecoveryOptions {
|
|
3
|
+
readonly metadataPath: string;
|
|
4
|
+
readonly endpoint: string;
|
|
5
|
+
readonly matches: (metadata: SupervisorEndpointMetadata) => boolean;
|
|
6
|
+
readonly startAndWait: () => Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
/** Recover all launch routes identically; a retry never grants permission to kill a live process. */
|
|
9
|
+
export declare function recoverSupervisorEndpoint(options: SupervisorRecoveryOptions): Promise<void>;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { probeOwnership, readEndpointMetadata, removeEndpointArtifacts } from "./endpoints.js";
|
|
3
|
+
/** Recover all launch routes identically; a retry never grants permission to kill a live process. */
|
|
4
|
+
export async function recoverSupervisorEndpoint(options) {
|
|
5
|
+
const { metadataPath, endpoint } = options;
|
|
6
|
+
let recovered = false;
|
|
7
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
8
|
+
const metadata = await readEndpointMetadata(metadataPath);
|
|
9
|
+
if (metadata) {
|
|
10
|
+
const probe = await probeOwnership(metadata);
|
|
11
|
+
if (probe === "live-verified" && options.matches(metadata)) {
|
|
12
|
+
if (recovered)
|
|
13
|
+
await diagnostic(metadataPath, "recovered", attempt + 1);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
// Concurrency: another owner may have published while an earlier identity probe was pending.
|
|
17
|
+
if (JSON.stringify(await readEndpointMetadata(metadataPath)) !== JSON.stringify(metadata))
|
|
18
|
+
continue;
|
|
19
|
+
if (probe !== "dead") {
|
|
20
|
+
await diagnostic(metadataPath, probe, attempt + 1);
|
|
21
|
+
throw Object.assign(new Error(probe === "unresponsive"
|
|
22
|
+
? "A1's background service is not responding; retry shortly. Your sessions have been preserved."
|
|
23
|
+
: "A1's background service identity changed; retry startup. Your sessions have been preserved."), {
|
|
24
|
+
code: "SUPERVISOR_RECOVERY_BLOCKED",
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
recovered = true;
|
|
28
|
+
if (!await removeEndpointArtifacts(metadataPath, endpoint, metadata))
|
|
29
|
+
continue;
|
|
30
|
+
await diagnostic(metadataPath, "stale-owner-retired", attempt + 1);
|
|
31
|
+
}
|
|
32
|
+
// Concurrency: the child takes the same endpoint gate before binding/publication. If another launch wins,
|
|
33
|
+
// startAndWait joins its authenticated endpoint; it never unlinks that winner's socket.
|
|
34
|
+
await options.startAndWait();
|
|
35
|
+
if (recovered)
|
|
36
|
+
await diagnostic(metadataPath, "recovered", attempt + 1);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
await diagnostic(metadataPath, "ownership-changing", 3);
|
|
40
|
+
throw Object.assign(new Error("A1 startup changed repeatedly; retry shortly"), { code: "SUPERVISOR_RECOVERY_BUSY" });
|
|
41
|
+
}
|
|
42
|
+
async function diagnostic(path, reason, attempts) {
|
|
43
|
+
// Security: one bounded summary per endpoint, no raw metadata, user paths, or session content.
|
|
44
|
+
await writeFile(`${path}.recovery.json`, JSON.stringify({ reason, attempts, at: new Date().toISOString() }), { mode: 0o600 }).catch(() => undefined);
|
|
45
|
+
}
|