agents-can-communicate 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/bin/acc-maintenance-worker.mjs +6 -0
- package/bin/entrypoints/acc-hook.mjs +58 -5
- package/docs/ARCHITECTURE.md +21 -9
- package/docs/CLI.md +21 -10
- package/docs/CONCEPTS.md +2 -1
- package/docs/CONFIGURATION.md +2 -1
- package/docs/GETTING_STARTED.md +4 -2
- package/docs/HOW_IT_WORKS.md +2 -1
- package/docs/PROTOCOL.md +7 -0
- package/docs/RELEASING.md +1 -1
- package/docs/SECURITY_MODEL.md +16 -7
- package/docs/TROUBLESHOOTING.md +13 -6
- package/docs/UPGRADING.md +121 -22
- package/docs/WHY_ACC.md +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +10 -4
- package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +4 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +120 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-threads.mjs +49 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance.mjs +108 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +67 -22
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +63 -16
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +16 -1
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +1 -1
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +10 -5
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -0
- package/node_modules/@agents-can-communicate/cli/src/index.mjs +5 -0
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +5 -3
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +225 -11
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/command.mjs +15 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/diagnostics.mjs +36 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +17 -2
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +54 -14
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +44 -25
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/install.mjs +46 -29
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/legacy-maintenance.mjs +47 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-state.mjs +57 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-worker.mjs +118 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +140 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/management-recovery.mjs +80 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/pins.mjs +125 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +6 -7
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/retire.mjs +56 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +4 -2
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/staging.mjs +95 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +8 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +34 -12
- package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +83 -7
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +44 -2
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +10 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +55 -6
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +2 -2
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +10 -3
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +38 -4
- package/package.json +3 -1
|
@@ -2,9 +2,11 @@ import { execFile } from "node:child_process";
|
|
|
2
2
|
import { access, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
|
+
import { declaredStoreVersion } from "./activation.mjs";
|
|
5
6
|
import { stageOwnGeneration } from "./generation.mjs";
|
|
6
7
|
import { verifyGeneration } from "./install.mjs";
|
|
7
8
|
import { managedDirectory } from "./state.mjs";
|
|
9
|
+
import { releaseStagingHold } from "./staging.mjs";
|
|
8
10
|
import { ENTRY_KINDS } from "./entry.mjs";
|
|
9
11
|
|
|
10
12
|
const NAME = "agents-can-communicate";
|
|
@@ -65,7 +67,20 @@ export async function downloadRelease(root, release, { env = process.env } = {})
|
|
|
65
67
|
for (const name of ENTRY_KINDS) await access(path.join(packageRoot, "bin", "entrypoints", `${name}.mjs`));
|
|
66
68
|
await access(path.join(packageRoot, "bin", "acc-update-worker.mjs"));
|
|
67
69
|
const generation = await stageOwnGeneration({ packageRoot, managerRoot: root });
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
// A failed verify, or any other failure before this function actually
|
|
71
|
+
// returns, means this candidate is abandoned here, so release its hold
|
|
72
|
+
// now - declaredStoreVersion included, so a future change to it (or any
|
|
73
|
+
// other exception in this region) cannot silently leak the hold instead
|
|
74
|
+
// of releasing it. Only a clean return hands the hold on to the caller
|
|
75
|
+
// (worker.mjs), since publication happens later there, and it is
|
|
76
|
+
// responsible for releasing it once that publish attempt resolves.
|
|
77
|
+
try {
|
|
78
|
+
await verifyGeneration(generation, { env });
|
|
79
|
+
return { version: generation.version, root: generation.root,
|
|
80
|
+
storeVersion: await declaredStoreVersion(generation.root), hold: generation.hold };
|
|
81
|
+
} catch (error) {
|
|
82
|
+
await releaseStagingHold(generation.hold);
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
70
85
|
} finally { await rm(temporary, { recursive: true, force: true }); }
|
|
71
86
|
}
|
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
4
|
import { scheduleWorker } from "./schedule.mjs";
|
|
5
5
|
import { acquireRuntime } from "./leases.mjs";
|
|
6
|
-
import { canonicalManagerRoot, readControl } from "./state.mjs";
|
|
6
|
+
import { canonicalManagerRoot, readControl, readManagedJson } from "./state.mjs";
|
|
7
7
|
|
|
8
8
|
export const ENTRY_KINDS = Object.freeze([
|
|
9
9
|
"acc", "acc-hook", "acc-mcp", "acc-bootstrap", "acc-claude-channel",
|
|
@@ -53,6 +53,31 @@ function unavailable(kind) {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
const stableVersion = value => typeof value === "string" && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value);
|
|
57
|
+
function newerVersion(left, right) {
|
|
58
|
+
if (!stableVersion(left) || !stableVersion(right)) return false;
|
|
59
|
+
const a = left.split(".").map(BigInt), b = right.split(".").map(BigInt);
|
|
60
|
+
for (let index = 0; index < 3; index++) if (a[index] !== b[index]) return a[index] > b[index];
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Pending means downloaded and verified. Older packages do not advertise this
|
|
65
|
+
* management-only protocol; never load them speculatively for workspace work.
|
|
66
|
+
*/
|
|
67
|
+
async function updateImplementation(packageRoot, control) {
|
|
68
|
+
let selected = null;
|
|
69
|
+
for (const candidate of [{ root: packageRoot }, control.pending].filter(candidate => candidate?.root)) {
|
|
70
|
+
try {
|
|
71
|
+
const manifest = await readManagedJson(path.join(candidate.root, "package.json"));
|
|
72
|
+
if (manifest?.name !== "agents-can-communicate" || manifest.accManagedUpdateProtocol !== 2
|
|
73
|
+
|| candidate.version !== undefined && manifest.version !== candidate.version
|
|
74
|
+
|| !newerVersion(manifest.version, selected?.version ?? control.active.version)) continue;
|
|
75
|
+
selected = { root: candidate.root, version: manifest.version };
|
|
76
|
+
} catch { /* Unreadable alternatives leave the active implementation available. */ }
|
|
77
|
+
}
|
|
78
|
+
return selected?.root ?? null;
|
|
79
|
+
}
|
|
80
|
+
|
|
56
81
|
/** The lease lasts until OS process death, including callbacks after main returns. */
|
|
57
82
|
export async function runEntry({ kind, packageRoot, managerRoot, managedRequired = false }) {
|
|
58
83
|
if (!ENTRY_KINDS.includes(kind)) throw new Error("unknown ACC entry point");
|
|
@@ -66,6 +91,7 @@ export async function runEntry({ kind, packageRoot, managerRoot, managedRequired
|
|
|
66
91
|
}
|
|
67
92
|
let selected = packageRoot;
|
|
68
93
|
let managed = null;
|
|
94
|
+
let managementOnly = false;
|
|
69
95
|
let root;
|
|
70
96
|
try {
|
|
71
97
|
const requestedDataHome = bootstrapOptions === null ? null : await canonicalManagerRoot(bootstrapOptions.dataHome);
|
|
@@ -81,30 +107,44 @@ export async function runEntry({ kind, packageRoot, managerRoot, managedRequired
|
|
|
81
107
|
if (control !== null) {
|
|
82
108
|
selected = control.active.root;
|
|
83
109
|
managed = root;
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
110
|
+
const update = kind === "acc" && ["update", "doctor"].includes(process.argv[2])
|
|
111
|
+
? await updateImplementation(packageRoot, control) : null;
|
|
112
|
+
if (update !== null) {
|
|
113
|
+
selected = update;
|
|
114
|
+
managementOnly = true;
|
|
115
|
+
} else {
|
|
116
|
+
const lease = await acquireRuntime(root, { pid: process.pid, kind });
|
|
117
|
+
selected = lease.runtime.root;
|
|
118
|
+
const quiet = kind === "acc" && ["update", "install", "uninstall", "help", "version",
|
|
119
|
+
"--help", "-h", "--version", "-v", "-V"].includes(process.argv[2]);
|
|
120
|
+
if (!quiet) await scheduleWorker(root, control);
|
|
121
|
+
}
|
|
90
122
|
} else if (managedRequired) throw new Error("managed runtime is not initialized");
|
|
91
123
|
} catch {
|
|
92
124
|
// Help and update recovery must remain reachable when state cannot admit a
|
|
93
125
|
// workspace command. The CLI parser enforces this restricted path; merely
|
|
94
126
|
// putting --help in a message value cannot bypass runtime admission.
|
|
127
|
+
// `selected` was read before this catch and, on this path, holds no
|
|
128
|
+
// lease or pin of its own - a generation reclaimed in the meantime makes
|
|
129
|
+
// this import fail with ENOENT. Degrade below like any other admission
|
|
130
|
+
// failure instead of crashing with a raw stack.
|
|
95
131
|
if (kind === "acc" && selected) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
132
|
+
try {
|
|
133
|
+
const runtime = await import(pathToFileURL(path.join(selected, "bin", "entrypoints", "acc.mjs")).href);
|
|
134
|
+
await runtime.main({ managerRoot: root ?? null, packageRoot: selected, managementOnly: true });
|
|
135
|
+
return;
|
|
136
|
+
} catch { /* Fall through to the same graceful unavailability below. */ }
|
|
99
137
|
}
|
|
100
138
|
unavailable(kind);
|
|
101
139
|
if (kind === "acc-claude-channel" && selected) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
140
|
+
try {
|
|
141
|
+
const { startInertChannel } = await import(pathToFileURL(path.join(selected,
|
|
142
|
+
"bin", "entrypoints", "claude-channel-stdio.mjs")).href);
|
|
143
|
+
startInertChannel();
|
|
144
|
+
} catch { /* The stderr notice above already fired either way. */ }
|
|
105
145
|
}
|
|
106
146
|
return;
|
|
107
147
|
}
|
|
108
148
|
const runtime = await import(pathToFileURL(path.join(selected, "bin", "entrypoints", `${kind}.mjs`)).href);
|
|
109
|
-
await runtime.main({ managerRoot: managed, packageRoot: selected });
|
|
149
|
+
await runtime.main({ managerRoot: managed, packageRoot: selected, ...(managementOnly ? { managementOnly } : {}) });
|
|
110
150
|
}
|
|
@@ -3,6 +3,8 @@ import { lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm }
|
|
|
3
3
|
from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
|
|
6
|
+
import { attachStagingTemp, holdStagedGeneration, releaseStagingHold } from "./staging.mjs";
|
|
7
|
+
|
|
6
8
|
const NAME = "agents-can-communicate";
|
|
7
9
|
const STABLE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
8
10
|
|
|
@@ -134,34 +136,51 @@ export async function stageOwnGeneration({ packageRoot, managerRoot }) {
|
|
|
134
136
|
await directory(generations);
|
|
135
137
|
const target = path.join(generations, `${manifest.version}-${digest.slice(0, 24)}`);
|
|
136
138
|
const result = { version: manifest.version, root: target, digest };
|
|
137
|
-
|
|
138
|
-
|
|
139
|
+
// Held before the check that may return `target` unchanged (it can already
|
|
140
|
+
// be sitting on disk unprotected from an earlier run) and, on the staging
|
|
141
|
+
// path below, before the rename that makes a freshly built `target`
|
|
142
|
+
// visible at all: reclaim must never observe the directory without also
|
|
143
|
+
// observing why it exists. The caller now owns this hold - see its own
|
|
144
|
+
// `hold` on the returned value - and must release it on every exit from
|
|
145
|
+
// its own attempt, once the generation is either published or abandoned.
|
|
146
|
+
// A hold this function itself never hands off (because it throws instead
|
|
147
|
+
// of returning) is released here, in its own catch, rather than left for
|
|
148
|
+
// a confirmed-dead reap to eventually find.
|
|
149
|
+
const hold = await holdStagedGeneration({ root, generationRoot: target });
|
|
139
150
|
try {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
await
|
|
144
|
-
|
|
145
|
-
|
|
151
|
+
if (await existingMatches(target, files)) return { ...result, hold };
|
|
152
|
+
const staging = await mkdtemp(path.join(generations, ".staging-"));
|
|
153
|
+
try {
|
|
154
|
+
await attachStagingTemp(hold, staging);
|
|
155
|
+
const directories = new Set([staging]);
|
|
156
|
+
for (const [name, file] of files) {
|
|
157
|
+
const destination = path.join(staging, name);
|
|
158
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
159
|
+
for (let parent = path.dirname(destination); parent !== staging; parent = path.dirname(parent)) directories.add(parent);
|
|
160
|
+
const handle = await open(destination, "wx", file.mode);
|
|
161
|
+
try {
|
|
162
|
+
await handle.writeFile(file.bytes);
|
|
163
|
+
await handle.chmod(file.mode);
|
|
164
|
+
await handle.sync();
|
|
165
|
+
} finally { await handle.close(); }
|
|
166
|
+
}
|
|
167
|
+
for (const directory of [...directories].sort((a, b) => b.length - a.length)) {
|
|
168
|
+
const handle = await open(directory, "r");
|
|
169
|
+
try { await handle.sync(); } finally { await handle.close(); }
|
|
170
|
+
}
|
|
146
171
|
try {
|
|
147
|
-
await
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
for (const directory of [...directories].sort((a, b) => b.length - a.length)) {
|
|
153
|
-
const handle = await open(directory, "r");
|
|
172
|
+
await rename(staging, target);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (!["EEXIST", "ENOTEMPTY"].includes(error.code) || !await existingMatches(target, files)) throw error;
|
|
175
|
+
}
|
|
176
|
+
const handle = await open(generations, "r");
|
|
154
177
|
try { await handle.sync(); } finally { await handle.close(); }
|
|
178
|
+
return { ...result, hold };
|
|
179
|
+
} finally {
|
|
180
|
+
await rm(staging, { recursive: true, force: true });
|
|
155
181
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (!["EEXIST", "ENOTEMPTY"].includes(error.code) || !await existingMatches(target, files)) throw error;
|
|
160
|
-
}
|
|
161
|
-
const handle = await open(generations, "r");
|
|
162
|
-
try { await handle.sync(); } finally { await handle.close(); }
|
|
163
|
-
return result;
|
|
164
|
-
} finally {
|
|
165
|
-
await rm(staging, { recursive: true, force: true });
|
|
182
|
+
} catch (error) {
|
|
183
|
+
await releaseStagingHold(hold);
|
|
184
|
+
throw error;
|
|
166
185
|
}
|
|
167
186
|
}
|
|
@@ -3,12 +3,15 @@ import { mkdtemp, rm } from "node:fs/promises";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
|
+
import { declaredStoreVersion } from "./activation.mjs";
|
|
6
7
|
import { validateManagedLocation } from "./location.mjs";
|
|
7
8
|
import { stageOwnGeneration } from "./generation.mjs";
|
|
8
9
|
import { writeLaunchers } from "./launchers.mjs";
|
|
9
10
|
import { canonicalManagerRoot, readControl, writeControl } from "./state.mjs";
|
|
10
11
|
import { scheduleWorker } from "./schedule.mjs";
|
|
12
|
+
import { releaseStagingHold } from "./staging.mjs";
|
|
11
13
|
import { withManagerLock } from "./mutex.mjs";
|
|
14
|
+
import { retireManagedHolds } from "./retire.mjs";
|
|
12
15
|
|
|
13
16
|
const exec = promisify(execFile);
|
|
14
17
|
export async function verifyGeneration(runtime, { env = process.env } = {}) {
|
|
@@ -27,35 +30,44 @@ export async function verifyGeneration(runtime, { env = process.env } = {}) {
|
|
|
27
30
|
export async function installManaged({ packageRoot, managerRoot, dataHome, home, targets, env, apply, cwd }) {
|
|
28
31
|
const root = await validateManagedLocation({ managerRoot, dataHome, home, cwd, env });
|
|
29
32
|
const candidate = await stageOwnGeneration({ packageRoot, managerRoot: root });
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
await
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
33
|
+
// The hold candidate.hold carries survives verification and publication;
|
|
34
|
+
// release it on every exit from this attempt, once the generation is
|
|
35
|
+
// either published (independently protected by the control pointer this
|
|
36
|
+
// writes below) or abandoned (any throw between here and there).
|
|
37
|
+
try {
|
|
38
|
+
await verifyGeneration(candidate, { env });
|
|
39
|
+
return await withManagerLock(root, async () => {
|
|
40
|
+
const previous = await readControl(root);
|
|
41
|
+
if (previous !== null && previous.home !== home) {
|
|
42
|
+
throw new Error("this ACC data home manages another client home; use a separate ACC_DATA_HOME");
|
|
43
|
+
}
|
|
44
|
+
if (previous !== null && previous.active.root !== candidate.root) {
|
|
45
|
+
throw new Error("install from the active managed runtime; use acc update to switch versions");
|
|
46
|
+
}
|
|
47
|
+
const runtime = { version: candidate.version, root: candidate.root,
|
|
48
|
+
storeVersion: await declaredStoreVersion(candidate.root) };
|
|
49
|
+
const autoPreference = previous?.autoPreference ?? previous?.auto ?? true;
|
|
50
|
+
// Full removal pauses workers without revoking the user's choice. Keep a
|
|
51
|
+
// partial-removal pause until explicit opt-in; this install may omit its failed target.
|
|
52
|
+
const auto = previous?.targets.length ? previous.auto : autoPreference;
|
|
53
|
+
const control = { schemaVersion: 1, active: runtime, pending: runtime, phase: "activating",
|
|
54
|
+
auto, autoPreference, pin: previous?.pin ?? null,
|
|
55
|
+
checkedAt: previous?.checkedAt ?? null, home,
|
|
56
|
+
targets: [...new Set([...(previous?.targets ?? []), ...targets])], notice: null };
|
|
57
|
+
await writeControl(root, control);
|
|
58
|
+
const paths = await writeLaunchers(root, runtime.root);
|
|
59
|
+
const result = await apply({ ...paths, preserveVersions: true });
|
|
60
|
+
if (result.failed.length === 0) {
|
|
61
|
+
const ready = await writeControl(root, { ...control, phase: "ready", pending: null });
|
|
62
|
+
await scheduleWorker(root, ready, { env });
|
|
63
|
+
} else {
|
|
64
|
+
await writeControl(root, { ...control, notice: "Integration refresh is incomplete; run acc update to recover." });
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
});
|
|
68
|
+
} finally {
|
|
69
|
+
await releaseStagingHold(candidate.hold);
|
|
70
|
+
}
|
|
59
71
|
}
|
|
60
72
|
|
|
61
73
|
/** Removal updates enrollment under the same fence, so an updater cannot reinstall it. */
|
|
@@ -69,6 +81,11 @@ export async function uninstallManaged({ managerRoot, apply }) {
|
|
|
69
81
|
const removed = new Set(result.operations.filter(operation => operation.applied)
|
|
70
82
|
.map(operation => operation.adapterId));
|
|
71
83
|
const targets = control.targets.filter(id => !removed.has(id));
|
|
84
|
+
// Holds this install published must stop blocking a later activation. The
|
|
85
|
+
// client processes keep running; only the records ACC owns are retired.
|
|
86
|
+
if (targets.length === 0) {
|
|
87
|
+
await retireManagedHolds({ root, workspaces: path.join(path.dirname(root), "workspaces") });
|
|
88
|
+
}
|
|
72
89
|
await writeControl(root, { ...control, targets,
|
|
73
90
|
autoPreference: control.autoPreference ?? control.auto,
|
|
74
91
|
auto: targets.length > 0 && result.failed.length === 0 && control.auto,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { askConfirmation } from "../confirm.mjs";
|
|
4
|
+
import { ALL_ADAPTERS } from "../install-command.mjs";
|
|
5
|
+
import { MAINTENANCE_ACTIVE, readMaintenance } from "./maintenance-state.mjs";
|
|
6
|
+
|
|
7
|
+
const inspectServices = async options => (await import("./maintenance.mjs")).inspectMaintenanceServices(options);
|
|
8
|
+
const request = async options => (await import("./maintenance.mjs")).requestMaintenance(options);
|
|
9
|
+
const handoff = () => Object.assign(new Error("ACC maintenance needs confirmation or recovery; run acc update."),
|
|
10
|
+
{ code: "ACC_LEGACY_MAINTENANCE_HANDOFF" });
|
|
11
|
+
|
|
12
|
+
async function isLegacyWorker(argv, control, root) {
|
|
13
|
+
if (argv.length !== 3) return false;
|
|
14
|
+
try {
|
|
15
|
+
return await realpath(argv[1]) === await realpath(path.join(control.active.root, "bin", "acc-update-worker.mjs"))
|
|
16
|
+
&& await realpath(argv[2]) === await realpath(root);
|
|
17
|
+
} catch { return false; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Old activation imports this candidate before taking its admission fence.
|
|
21
|
+
* Only request/schedule here: the detached job waits for this process to exit.
|
|
22
|
+
* Throwing in the old background worker unwinds its idle-loop lock normally.
|
|
23
|
+
*/
|
|
24
|
+
export async function bridgeLegacyMaintenance({ control, root, env = process.env, callerProtocol = 1 }, {
|
|
25
|
+
argv = process.argv, input = process.stdin, output = process.stdout,
|
|
26
|
+
inspect = inspectServices, requestMaintenance = request,
|
|
27
|
+
} = {}) {
|
|
28
|
+
if (callerProtocol >= 2) return null;
|
|
29
|
+
const worker = await isLegacyWorker(argv, control, root);
|
|
30
|
+
const interactive = argv[2] === "update" && !argv.includes("--json")
|
|
31
|
+
&& input.isTTY === true && output.isTTY === true;
|
|
32
|
+
if (!worker && !interactive) return null;
|
|
33
|
+
const existing = await readMaintenance(root);
|
|
34
|
+
let services;
|
|
35
|
+
if (!MAINTENANCE_ACTIVE.includes(existing?.status)) {
|
|
36
|
+
if (!control.pending) return null;
|
|
37
|
+
services = await inspect({ root, control, env, adapters: ALL_ADAPTERS() });
|
|
38
|
+
if (!services.length) return null;
|
|
39
|
+
if (worker) throw handoff();
|
|
40
|
+
}
|
|
41
|
+
const result = await requestMaintenance({ root, control, options: { json: false }, services,
|
|
42
|
+
runtime: { env, input, output, confirm: askConfirmation,
|
|
43
|
+
isInteractive: () => interactive, packageRoot: control.pending?.root ?? control.active.root } });
|
|
44
|
+
if (worker) throw handoff();
|
|
45
|
+
if (result?.text) output.write(`${result.text}\n`);
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { managedDirectory, readManagedJson, validateRuntime, writeManagedJson } from "./state.mjs";
|
|
3
|
+
|
|
4
|
+
export const MAINTENANCE_ACTIVE = ["waiting", "stopping", "refreshing", "restarting", "recovery"];
|
|
5
|
+
const statuses = [...MAINTENANCE_ACTIVE, "completed", "failed", "cancelled"];
|
|
6
|
+
export const maintenanceRecipe = c => JSON.stringify([c.active, c.pending, c.home, c.targets, c.auto, c.pin]);
|
|
7
|
+
|
|
8
|
+
export async function readMaintenance(root) {
|
|
9
|
+
if (!await managedDirectory(root)) return null;
|
|
10
|
+
const value = await readManagedJson(path.join(root, "maintenance.json"));
|
|
11
|
+
if (value === undefined) return null;
|
|
12
|
+
if (value.schemaVersion !== 1 || typeof value.id !== "string" || !/^[a-f0-9-]{36}$/.test(value.id)
|
|
13
|
+
|| !statuses.includes(value.status) || typeof value.recipe !== "string"
|
|
14
|
+
|| typeof value.workerRoot !== "string" || !path.isAbsolute(value.workerRoot)
|
|
15
|
+
|| !Number.isFinite(value.deadline) || !Number.isSafeInteger(value.callerPid) || value.callerPid < 1
|
|
16
|
+
|| !(value.workerPid === null || Number.isSafeInteger(value.workerPid) && value.workerPid > 0)
|
|
17
|
+
|| !Array.isArray(value.services) || value.services.length !== 1
|
|
18
|
+
|| !value.services.every(s => typeof s.adapterId === "string" && s.snapshot
|
|
19
|
+
&& Number.isSafeInteger(s.snapshot.pid) && s.snapshot.pid > 0)
|
|
20
|
+
|| !Array.isArray(value.attempted) || !value.attempted.every(id => value.services.some(s => s.adapterId === id))) {
|
|
21
|
+
throw new Error("invalid update maintenance record");
|
|
22
|
+
}
|
|
23
|
+
await validateRuntime(root, value.target);
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Caller holds the admission mutex or the exclusive maintenance-worker lock. */
|
|
28
|
+
export async function writeMaintenance(root, job) {
|
|
29
|
+
await writeManagedJson(path.join(root, "maintenance.json"), job);
|
|
30
|
+
return job;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function maintenanceReport(job) {
|
|
34
|
+
return { id: job.id, status: job.status, version: job.target.version,
|
|
35
|
+
reasonCode: job.reasonCode ?? null, workerPid: job.workerPid,
|
|
36
|
+
...(job.status === "waiting" && job.waiting ? { waiting: job.waiting } : {}),
|
|
37
|
+
services: job.services.map(({ adapterId, snapshot }) => ({ adapterId,
|
|
38
|
+
serviceId: snapshot.serviceId, pid: snapshot.pid, clientVersion: snapshot.cliVersion,
|
|
39
|
+
serverVersion: snapshot.serverVersion })) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function maintenanceNotice(job) {
|
|
43
|
+
const version = job.target.version;
|
|
44
|
+
if (job.status === "completed") return `ACC ${version} update maintenance completed; the client service was restarted and verified.`;
|
|
45
|
+
if (job.status === "cancelled") return `Update maintenance cancelled (${job.reasonCode}); no client service was stopped.`;
|
|
46
|
+
if (job.status === "failed") return `Update maintenance failed (${job.reasonCode}); run acc update to retry.`;
|
|
47
|
+
if (job.status === "recovery") return `Client service recovery is pending (${job.reasonCode}); run acc update to retry recovery.`;
|
|
48
|
+
if (job.status === "waiting") {
|
|
49
|
+
const waiting = job.waiting?.reason === "service_busy" ? `idle client work (${job.waiting.busyThreads ?? "unknown"} active turns, ${job.waiting.queuedRequests ?? "unknown"} queued requests)`
|
|
50
|
+
: job.waiting?.reason === "other_processes" ? `${job.waiting.processes} other or unidentified process(es) to exit`
|
|
51
|
+
: job.waiting?.reason === "caller_exit" ? "the requesting command to exit"
|
|
52
|
+
: job.waiting?.reason === "update_worker" ? "another ACC updater"
|
|
53
|
+
: "idle client work and any ACC process still holding this update";
|
|
54
|
+
return `ACC ${version} update maintenance is scheduled; waiting for ${waiting}. Open clients will disconnect during restart. Check acc doctor for progress.`;
|
|
55
|
+
}
|
|
56
|
+
return `ACC ${version} update maintenance: ${job.status}. Check acc doctor for the result.`;
|
|
57
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { ALL_ADAPTERS } from "../install-command.mjs";
|
|
2
|
+
import { activatePending, listActivationBlockers } from "./activation.mjs";
|
|
3
|
+
import { confirmedDead, defaultPidIsAlive, withManagerLock } from "./mutex.mjs";
|
|
4
|
+
import { readControl } from "./state.mjs";
|
|
5
|
+
import { maintenanceContext } from "./maintenance.mjs";
|
|
6
|
+
import { MAINTENANCE_ACTIVE, maintenanceRecipe, readMaintenance, writeMaintenance } from "./maintenance-state.mjs";
|
|
7
|
+
|
|
8
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
9
|
+
const deferred = waiting => Object.assign(new Error("maintenance waiting"), { code: "MAINTENANCE_WAIT", waiting });
|
|
10
|
+
const refused = reasonCode => Object.assign(new Error("maintenance refused"), { reasonCode });
|
|
11
|
+
|
|
12
|
+
/** No runtime lease: this worker must outlive the client that requested it.
|
|
13
|
+
* Its own mutex serializes checkpoints; admission remains separately fenced.
|
|
14
|
+
*/
|
|
15
|
+
export async function runMaintenance(root, { jobId, env = process.env, adapters = ALL_ADAPTERS(),
|
|
16
|
+
pidIsAlive = defaultPidIsAlive, pause = sleep, now = Date.now, activate = activatePending } = {}) {
|
|
17
|
+
return withManagerLock(`${root}/maintenance-worker`, async () => {
|
|
18
|
+
let job;
|
|
19
|
+
// Publication owns admission until spawn's PID is durable. Wait for it
|
|
20
|
+
// before writing checkpoints, including when recovering an older job.
|
|
21
|
+
await withManagerLock(root, async () => {
|
|
22
|
+
job = await readMaintenance(root);
|
|
23
|
+
if (job && (!jobId || job.id === jobId) && MAINTENANCE_ACTIVE.includes(job.status)) {
|
|
24
|
+
job = await writeMaintenance(root, { ...job, workerPid: process.pid });
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
if (!job || jobId && job.id !== jobId || !MAINTENANCE_ACTIVE.includes(job.status)) return job;
|
|
28
|
+
const save = async changes => { job = await writeMaintenance(root, { ...job, ...changes }); return job; };
|
|
29
|
+
const waitFor = async waiting => {
|
|
30
|
+
if (JSON.stringify(job.waiting) !== JSON.stringify(waiting)) await save({ waiting });
|
|
31
|
+
await pause(1000);
|
|
32
|
+
};
|
|
33
|
+
const adapter = adapters.find(a => a.id === job.services[0].adapterId);
|
|
34
|
+
if (!adapter?.stopForMaintenance || !adapter.startAfterMaintenance) return save({ status: "failed", reasonCode: "maintenance_adapter_unavailable" });
|
|
35
|
+
const expected = job.services[0].snapshot;
|
|
36
|
+
let control = await readControl(root);
|
|
37
|
+
const context = maintenanceContext(control, env);
|
|
38
|
+
|
|
39
|
+
async function restore(result, reasonCode = null) {
|
|
40
|
+
await save({ status: "restarting", reasonCode });
|
|
41
|
+
let restarted;
|
|
42
|
+
try { restarted = await adapter.startAfterMaintenance({ context, expected }); }
|
|
43
|
+
catch { restarted = { ok: false, reasonCode: "service_start_failed" }; }
|
|
44
|
+
if (!restarted?.ok) return save({ status: "recovery", reasonCode: restarted?.reasonCode ?? "service_start_failed" });
|
|
45
|
+
return save({ status: result?.activated ? "completed" : "failed",
|
|
46
|
+
reasonCode: result?.activated ? null : reasonCode ?? result?.reason ?? "update_incomplete" });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A crash after the stop checkpoint can mean either side of the command.
|
|
50
|
+
// Never repeat it. Forward repair still obeys native/ACC lifetime fences,
|
|
51
|
+
// then the idempotent adapter start restores the already-approved service.
|
|
52
|
+
if (job.attempted.length) {
|
|
53
|
+
let result;
|
|
54
|
+
try {
|
|
55
|
+
control = await readControl(root);
|
|
56
|
+
if (!control.pending && control.active.root === job.target.root && control.phase === "ready") result = { activated: true };
|
|
57
|
+
else if (maintenanceRecipe(control) === job.recipe) {
|
|
58
|
+
result = await withManagerLock(`${root}/worker`, () => activate(root, { env, pidIsAlive }));
|
|
59
|
+
}
|
|
60
|
+
} catch { /* Restoration still runs when forward repair is unavailable. */ }
|
|
61
|
+
return restore(result, result?.reason ?? "maintenance_interrupted");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (;;) {
|
|
65
|
+
control = await readControl(root);
|
|
66
|
+
if (maintenanceRecipe(control) !== job.recipe) return save({ status: "cancelled", reasonCode: "update_state_changed" });
|
|
67
|
+
if (now() > job.deadline) return save({ status: "cancelled", reasonCode: "idle_wait_expired" });
|
|
68
|
+
if (!await confirmedDead(job.callerPid, pidIsAlive)) { await waitFor({ reason: "caller_exit" }); continue; }
|
|
69
|
+
let result;
|
|
70
|
+
try {
|
|
71
|
+
result = await withManagerLock(`${root}/worker`, async () => {
|
|
72
|
+
const beforeActivation = async current => {
|
|
73
|
+
if (maintenanceRecipe(current) !== job.recipe) throw refused("update_state_changed");
|
|
74
|
+
// Same fallback as maintenance.mjs's own target: judge other
|
|
75
|
+
// holds against the pending generation's contract when one is
|
|
76
|
+
// being activated, otherwise against the generation already
|
|
77
|
+
// running, since there is no incoming generation to compare to.
|
|
78
|
+
// Pick the whole generation record, not a per-field fallback, so
|
|
79
|
+
// a pending generation declaring no contract of its own is
|
|
80
|
+
// judged as "none" rather than silently borrowing active's.
|
|
81
|
+
const blockers = await listActivationBlockers(root, { pidIsAlive,
|
|
82
|
+
incomingStoreVersion: (current.pending ?? current.active).storeVersion });
|
|
83
|
+
const others = blockers.filter(b => b.pid !== expected.pid || b.kinds.some(kind => kind !== "native"));
|
|
84
|
+
if (others.length) throw deferred({ reason: "other_processes", processes: others.length });
|
|
85
|
+
const snapshot = await adapter.inspectMaintenance(context);
|
|
86
|
+
if (snapshot?.state === "busy") throw deferred({ reason: "service_busy",
|
|
87
|
+
busyThreads: snapshot.busyThreads, queuedRequests: snapshot.queuedRequests });
|
|
88
|
+
if (snapshot?.state !== "ready") throw refused("service_readiness_changed");
|
|
89
|
+
// Durable before the vendor command: recovery restores without
|
|
90
|
+
// assuming whether the previous worker reached the actual stop.
|
|
91
|
+
await save({ status: "stopping", attempted: [adapter.id], waiting: null });
|
|
92
|
+
const stopped = await adapter.stopForMaintenance({ context, expected });
|
|
93
|
+
if (stopped?.ok === false && stopped.stopAttempted === false) {
|
|
94
|
+
await save({ status: "waiting", attempted: [] });
|
|
95
|
+
if (stopped.reasonCode === "service_busy") throw deferred({ reason: "service_busy",
|
|
96
|
+
busyThreads: stopped.snapshot?.busyThreads, queuedRequests: stopped.snapshot?.queuedRequests });
|
|
97
|
+
}
|
|
98
|
+
if (!stopped?.ok) throw refused(stopped?.reasonCode ?? "service_stop_failed");
|
|
99
|
+
await save({ status: "refreshing" });
|
|
100
|
+
};
|
|
101
|
+
if (control.pending) return activate(root, { env, pidIsAlive, beforeActivation });
|
|
102
|
+
return withManagerLock(root, async () => {
|
|
103
|
+
await beforeActivation(await readControl(root));
|
|
104
|
+
return { activated: true, version: control.active.version };
|
|
105
|
+
});
|
|
106
|
+
}, { timeoutMs: 100 });
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (!job.attempted.length && (error.code === "MAINTENANCE_WAIT" || /manager lock held/.test(error.message))) {
|
|
109
|
+
await waitFor(error.waiting ?? { reason: "update_worker" }); continue;
|
|
110
|
+
}
|
|
111
|
+
if (job.attempted.length) return restore(null, error.reasonCode ?? "maintenance_failed");
|
|
112
|
+
return save({ status: "failed", reasonCode: error.reasonCode ?? "maintenance_failed" });
|
|
113
|
+
}
|
|
114
|
+
if (job.attempted.length) return restore(result);
|
|
115
|
+
return save({ status: "cancelled", reasonCode: result?.reason ?? "update_state_changed" });
|
|
116
|
+
}
|
|
117
|
+
}, { timeoutMs: 100, pidIsAlive });
|
|
118
|
+
}
|