agents-can-communicate 0.4.4 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/bin/entrypoints/acc-hook.mjs +58 -5
- package/docs/ARCHITECTURE.md +9 -4
- package/docs/CLI.md +12 -8
- package/docs/GETTING_STARTED.md +1 -1
- package/docs/PROTOCOL.md +7 -0
- package/docs/RELEASING.md +1 -1
- package/docs/SECURITY_MODEL.md +4 -2
- package/docs/TROUBLESHOOTING.md +9 -5
- package/docs/UPGRADING.md +86 -28
- 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 +4 -2
- package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +4 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +67 -22
- package/node_modules/@agents-can-communicate/adapter-codex/src/permission-ownership.mjs +19 -2
- 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/doctor-command.mjs +7 -1
- package/node_modules/@agents-can-communicate/cli/src/index.mjs +5 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +187 -11
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/diagnostics.mjs +2 -1
- 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 +14 -6
- 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/maintenance-state.mjs +1 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-worker.mjs +9 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +52 -4
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/management-recovery.mjs +18 -9
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/pins.mjs +125 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/retire.mjs +56 -0
- 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 +18 -8
- 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/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 +2 -1
|
@@ -124,16 +124,24 @@ export async function runEntry({ kind, packageRoot, managerRoot, managedRequired
|
|
|
124
124
|
// Help and update recovery must remain reachable when state cannot admit a
|
|
125
125
|
// workspace command. The CLI parser enforces this restricted path; merely
|
|
126
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.
|
|
127
131
|
if (kind === "acc" && selected) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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. */ }
|
|
131
137
|
}
|
|
132
138
|
unavailable(kind);
|
|
133
139
|
if (kind === "acc-claude-channel" && selected) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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. */ }
|
|
137
145
|
}
|
|
138
146
|
return;
|
|
139
147
|
}
|
|
@@ -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,
|
|
@@ -50,7 +50,7 @@ export function maintenanceNotice(job) {
|
|
|
50
50
|
: job.waiting?.reason === "other_processes" ? `${job.waiting.processes} other or unidentified process(es) to exit`
|
|
51
51
|
: job.waiting?.reason === "caller_exit" ? "the requesting command to exit"
|
|
52
52
|
: job.waiting?.reason === "update_worker" ? "another ACC updater"
|
|
53
|
-
: "idle
|
|
53
|
+
: "idle client work and any ACC process still holding this update";
|
|
54
54
|
return `ACC ${version} update maintenance is scheduled; waiting for ${waiting}. Open clients will disconnect during restart. Check acc doctor for progress.`;
|
|
55
55
|
}
|
|
56
56
|
return `ACC ${version} update maintenance: ${job.status}. Check acc doctor for the result.`;
|
|
@@ -71,7 +71,15 @@ export async function runMaintenance(root, { jobId, env = process.env, adapters
|
|
|
71
71
|
result = await withManagerLock(`${root}/worker`, async () => {
|
|
72
72
|
const beforeActivation = async current => {
|
|
73
73
|
if (maintenanceRecipe(current) !== job.recipe) throw refused("update_state_changed");
|
|
74
|
-
|
|
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 });
|
|
75
83
|
const others = blockers.filter(b => b.pid !== expected.pid || b.kinds.some(kind => kind !== "native"));
|
|
76
84
|
if (others.length) throw deferred({ reason: "other_processes", processes: others.length });
|
|
77
85
|
const snapshot = await adapter.inspectMaintenance(context);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { evaluateVersionContract } from "@agents-can-communicate/adapter-sdk";
|
|
4
5
|
import { ALL_ADAPTERS } from "../install-command.mjs";
|
|
5
6
|
import { listActivationBlockers } from "./activation.mjs";
|
|
6
7
|
import { confirmedDead, withManagerLock } from "./mutex.mjs";
|
|
@@ -8,17 +9,64 @@ import { readControl, readManagedJson } from "./state.mjs";
|
|
|
8
9
|
import { MAINTENANCE_ACTIVE, maintenanceNotice, maintenanceRecipe, maintenanceReport,
|
|
9
10
|
readMaintenance, writeMaintenance } from "./maintenance-state.mjs";
|
|
10
11
|
|
|
12
|
+
const HOST_PLATFORM = `${process.platform}-${process.arch}`;
|
|
13
|
+
|
|
11
14
|
export function maintenanceContext(control, env) {
|
|
12
|
-
return { home: control.home, env, platform:
|
|
15
|
+
return { home: control.home, env, platform: HOST_PLATFORM };
|
|
13
16
|
}
|
|
14
17
|
|
|
18
|
+
/** The fifth site that used to compare a service's serving version with the
|
|
19
|
+
* CLI's, and the only one that acts on the answer by interrupting the user.
|
|
20
|
+
* The other four now judge the serving version against the adapter's captured
|
|
21
|
+
* native-delivery contract, and this one has to say the same thing: a Codex
|
|
22
|
+
* daemon still running the previous build while its CLI has updated is the
|
|
23
|
+
* ordinary state this release stops treating as a fault, and telling that user
|
|
24
|
+
* a restart is required - disconnecting every open client - is the complaint
|
|
25
|
+
* the work began from.
|
|
26
|
+
*
|
|
27
|
+
* A restart is still offered whenever the serving version does *not* satisfy
|
|
28
|
+
* that contract: below the captured minimum, on the denylist, a prerelease, or
|
|
29
|
+
* unreadable. Only a version the contract actually accepts stops being a
|
|
30
|
+
* reason on its own - and the native-binding blocker it is OR-ed with is
|
|
31
|
+
* untouched, so a genuinely stale or unbound service is still selected.
|
|
32
|
+
*
|
|
33
|
+
* The contract answers two different questions with one field, and reading
|
|
34
|
+
* every non-null code as a refusal conflated them. "platform_not_captured" and
|
|
35
|
+
* "native_delivery_unsupported" are not verdicts on any version: the first is
|
|
36
|
+
* a complete, valid declaration that records nothing for the platform this
|
|
37
|
+
* process runs on, and the second is an adapter that declares no native
|
|
38
|
+
* delivery at all. Every shipped adapter captures darwin-arm64 alone, so on
|
|
39
|
+
* Linux and on an Intel Mac the first of those is the ordinary answer - and it
|
|
40
|
+
* made every ready daemon a restart candidate, including one already serving
|
|
41
|
+
* exactly the version its CLI is running. That is the complaint this work
|
|
42
|
+
* began from, reproduced on every machine but one. With no opinion to act on,
|
|
43
|
+
* the decision falls back to the comparison that governed before this branch:
|
|
44
|
+
* the served version against the CLI's. */
|
|
45
|
+
const UNCAPTURED = new Set(["platform_not_captured", "native_delivery_unsupported"]);
|
|
46
|
+
|
|
47
|
+
const servingVersionIsContrary = (adapter, snapshot) => {
|
|
48
|
+
const { reasonCode } = evaluateVersionContract(adapter,
|
|
49
|
+
{ clientVersion: snapshot.serverVersion, platform: HOST_PLATFORM });
|
|
50
|
+
if (reasonCode === null) return false;
|
|
51
|
+
if (!UNCAPTURED.has(reasonCode)) return true;
|
|
52
|
+
return snapshot.serverVersion !== snapshot.cliVersion;
|
|
53
|
+
};
|
|
54
|
+
|
|
15
55
|
export async function inspectMaintenanceServices({ control, env = process.env, root, adapters = ALL_ADAPTERS() }) {
|
|
16
|
-
|
|
56
|
+
// Maintenance targets the pending generation when one exists (its contract
|
|
57
|
+
// is what activation will judge holds against), otherwise the active one:
|
|
58
|
+
// there is no incoming generation, only a stale daemon to bring in line
|
|
59
|
+
// with what is already running. Same fallback as this file's own `target`
|
|
60
|
+
// below: pick the whole generation record, not a per-field fallback, so a
|
|
61
|
+
// pending generation that declares no contract of its own is judged as
|
|
62
|
+
// "none" and not silently swapped for the active generation's.
|
|
63
|
+
const blockers = await listActivationBlockers(root, { ignorePid: process.pid,
|
|
64
|
+
incomingStoreVersion: (control.pending ?? control.active).storeVersion });
|
|
17
65
|
const services = [];
|
|
18
66
|
for (const adapter of adapters.filter(a => control.targets.includes(a.id) && a.inspectMaintenance)) {
|
|
19
67
|
const snapshot = await adapter.inspectMaintenance(maintenanceContext(control, env));
|
|
20
68
|
if (!snapshot || !["ready", "busy"].includes(snapshot.state)) continue;
|
|
21
|
-
if (
|
|
69
|
+
if (servingVersionIsContrary(adapter, snapshot)
|
|
22
70
|
|| blockers.some(b => b.pid === snapshot.pid && b.kinds.includes("native"))) {
|
|
23
71
|
services.push({ adapterId: adapter.id, snapshot });
|
|
24
72
|
}
|
|
@@ -75,7 +123,7 @@ export async function requestMaintenance({ root, control, options, runtime, serv
|
|
|
75
123
|
const approved = await runtime.confirm?.(`Restart ${names} to finish updating ACC? Open clients will disconnect; ACC will wait for idle turns, refresh integrations, restart the service and verify it. A turn started during the final restart check can be interrupted.`,
|
|
76
124
|
{ input: runtime.input, output: runtime.output });
|
|
77
125
|
if (approved !== true) return { data: { reason: "maintenance_declined" },
|
|
78
|
-
text: "Client restart declined; the update remains pending until
|
|
126
|
+
text: "Client restart declined; the update remains pending until the holds blocking it clear." };
|
|
79
127
|
}
|
|
80
128
|
return withManagerLock(root, async () => {
|
|
81
129
|
const current = await readControl(root), previous = await readMaintenance(root);
|
package/node_modules/@agents-can-communicate/cli/src/managed-runtime/management-recovery.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
|
+
import { declaredStoreVersion } from "./activation.mjs";
|
|
4
5
|
import { newerVersion } from "./download.mjs";
|
|
5
6
|
import { stageOwnGeneration } from "./generation.mjs";
|
|
6
7
|
import { verifyGeneration } from "./install.mjs";
|
|
7
8
|
import { confirmedDead, withManagerLock } from "./mutex.mjs";
|
|
8
9
|
import { readControl, readManagedJson, writeControl } from "./state.mjs";
|
|
10
|
+
import { releaseStagingHold } from "./staging.mjs";
|
|
9
11
|
|
|
10
12
|
/** A newly installed global CLI can supersede an older pending release locally.
|
|
11
13
|
* No workspace code or integration is admitted until normal activation passes.
|
|
@@ -18,15 +20,22 @@ export async function stageNewerManagementRuntime(root, { packageRoot, env }) {
|
|
|
18
20
|
|| manifest.accManagedUpdateProtocol !== 2 || before.pin && before.pin !== manifest.version
|
|
19
21
|
|| !newerVersion(manifest.version, before.pending?.version ?? before.active.version)) return false;
|
|
20
22
|
const candidate = await stageOwnGeneration({ packageRoot, managerRoot: root });
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
23
|
+
// See installManaged for why this releases only after the publish attempt
|
|
24
|
+
// - success or not - has fully resolved.
|
|
25
|
+
try {
|
|
26
|
+
await verifyGeneration(candidate, { env });
|
|
27
|
+
return await withManagerLock(root, async () => {
|
|
28
|
+
const current = await readControl(root);
|
|
29
|
+
if (current.phase !== "ready" || current.pin !== before.pin
|
|
30
|
+
|| JSON.stringify([current.active, current.pending]) !== JSON.stringify([before.active, before.pending])) return false;
|
|
31
|
+
await writeControl(root, { ...current, pending: { root: candidate.root,
|
|
32
|
+
version: candidate.version, storeVersion: await declaredStoreVersion(candidate.root) },
|
|
33
|
+
notice: `ACC ${candidate.version} is verified and ready to activate.` });
|
|
34
|
+
return true;
|
|
35
|
+
});
|
|
36
|
+
} finally {
|
|
37
|
+
await releaseStagingHold(candidate.hold);
|
|
38
|
+
}
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
async function inspectWorker(pid) {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { access, readdir, rm } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { STORE_VERSION } from "@agents-can-communicate/storage-filesystem";
|
|
6
|
+
|
|
7
|
+
import { confirmedDead, defaultPidIsAlive } from "./mutex.mjs";
|
|
8
|
+
import { canonicalManagerRoot, managedDirectory, readManagedJson, syncDirectory, validateRuntime,
|
|
9
|
+
writeManagedJson } from "./state.mjs";
|
|
10
|
+
|
|
11
|
+
const SCHEMA_VERSION = 1;
|
|
12
|
+
|
|
13
|
+
// Same hashing rule as session bindings: a foreign harness id never selects
|
|
14
|
+
// which file is written.
|
|
15
|
+
const fileFor = (root, harnessSessionId) => path.join(root, "pins",
|
|
16
|
+
`${createHash("sha256").update(String(harnessSessionId)).digest("hex").slice(0, 32)}.json`);
|
|
17
|
+
|
|
18
|
+
// Where a generation's own hook lives, given its root. Computed once here and
|
|
19
|
+
// reused by every caller that needs it - a hook resolving a pin and the hook
|
|
20
|
+
// actually importing it - so the path checked and the path imported can never
|
|
21
|
+
// drift apart.
|
|
22
|
+
export const hookEntrypointFor = root => path.join(root, "bin", "entrypoints", "acc-hook.mjs");
|
|
23
|
+
|
|
24
|
+
// Every function below takes whatever `root` its caller happens to have -
|
|
25
|
+
// `entry.mjs` already canonicalises it before a hook ever sees it, but the
|
|
26
|
+
// hook-runner writes a pin from a raw data-home path it built itself. If only
|
|
27
|
+
// one side resolved symlinks, a symlinked data home would make the writer and
|
|
28
|
+
// the reader agree on the file's *name* but not its *location*: delegation
|
|
29
|
+
// would silently never fire, with no error anywhere. Canonicalising here,
|
|
30
|
+
// once, on every entry point - matching every other managed-runtime module -
|
|
31
|
+
// is what makes "the root the writer used" and "the root the reader used"
|
|
32
|
+
// the same derivation instead of two that merely happen to agree today.
|
|
33
|
+
export async function writePin({ root, harnessSessionId, runtimeRoot, version, storeVersion,
|
|
34
|
+
clientPid }) {
|
|
35
|
+
root = await canonicalManagerRoot(root);
|
|
36
|
+
await managedDirectory(path.join(root, "pins"), { create: true });
|
|
37
|
+
await writeManagedJson(fileFor(root, harnessSessionId), { schemaVersion: SCHEMA_VERSION,
|
|
38
|
+
harnessSessionId, runtimeRoot, version, storeVersion: storeVersion ?? null,
|
|
39
|
+
clientPid: clientPid ?? null, createdAt: new Date().toISOString() });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function readPin({ root, harnessSessionId }) {
|
|
43
|
+
root = await canonicalManagerRoot(root);
|
|
44
|
+
// Mirrors reapPins: an internal symlink swapped in for the pins directory
|
|
45
|
+
// must not be followed into silently, whether writing or reading.
|
|
46
|
+
if (!await managedDirectory(path.join(root, "pins"))) return null;
|
|
47
|
+
const record = await readManagedJson(fileFor(root, harnessSessionId)).catch(() => null);
|
|
48
|
+
if (!record || record.schemaVersion !== SCHEMA_VERSION
|
|
49
|
+
|| typeof record.runtimeRoot !== "string" || record.runtimeRoot === "") return null;
|
|
50
|
+
return record;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function clearPin({ root, harnessSessionId }) {
|
|
54
|
+
root = await canonicalManagerRoot(root);
|
|
55
|
+
await rm(fileFor(root, harnessSessionId), { force: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Returns the pinned generation's root when a hook should delegate to it,
|
|
59
|
+
* and null when it should stay on the active generation. Resolution never
|
|
60
|
+
* throws: a hook that cannot resolve a pin must still let the client
|
|
61
|
+
* proceed, so every reason to say no - no pin, an already-active pin, a
|
|
62
|
+
* pinned generation outside the manager's own `generations` directory, one
|
|
63
|
+
* missing its own hook entrypoint, a pin declaring a store contract this
|
|
64
|
+
* generation does not speak, a store that will not read - collapses to the
|
|
65
|
+
* same null.
|
|
66
|
+
*
|
|
67
|
+
* Containment reuses `validateRuntime`, the same rule a control record's own
|
|
68
|
+
* generation pointer is held to, rather than trusting a pin's `runtimeRoot`
|
|
69
|
+
* on its word: a pin can be written by an unmanaged hook run sharing a data
|
|
70
|
+
* home (a development checkout is its own nearest `accStoreVersion`
|
|
71
|
+
* manifest), and importing whatever such a pin names would run this
|
|
72
|
+
* process's code from outside the runtime it was admitted into.
|
|
73
|
+
*
|
|
74
|
+
* The contract gate is the one check that is not about the pin being
|
|
75
|
+
* resolvable. A pin is deliberately not an activation hold, so a harness that
|
|
76
|
+
* lives only in hooks - no native binding, no long-lived channel process -
|
|
77
|
+
* holds nothing between turns and cannot keep an activation waiting. When
|
|
78
|
+
* that activation moves to a generation declaring a different
|
|
79
|
+
* `STORE_VERSION`, the pin survives it, and delegating to the generation it
|
|
80
|
+
* names would run code whose contract the store refuses by strict equality:
|
|
81
|
+
* the session would throw `unknown store version` on every hook and fall open
|
|
82
|
+
* for the rest of its life, silently uncoordinated, while its pin held the
|
|
83
|
+
* dead generation against reclamation. `STORE_VERSION` here is this
|
|
84
|
+
* generation's own declared contract - this module is loaded from the active
|
|
85
|
+
* generation, and its root manifest's `accStoreVersion` is asserted equal to
|
|
86
|
+
* this constant - so comparing against it compares the pin with the
|
|
87
|
+
* generation that would otherwise serve. Anything but proven equality (a
|
|
88
|
+
* differing contract, or a pin that declares none) falls back to the active
|
|
89
|
+
* generation, which is this function's answer to every other pin it cannot
|
|
90
|
+
* honour. That keeps the design's stated limit intact: a `STORE_VERSION`
|
|
91
|
+
* change still requires every live process to exit. */
|
|
92
|
+
export async function resolvePinnedGeneration({ root, harnessSessionId, active }) {
|
|
93
|
+
try {
|
|
94
|
+
const canonicalRoot = await canonicalManagerRoot(root);
|
|
95
|
+
const pin = await readPin({ root: canonicalRoot, harnessSessionId });
|
|
96
|
+
if (pin === null) return null;
|
|
97
|
+
const { root: resolved, storeVersion } = await validateRuntime(canonicalRoot,
|
|
98
|
+
{ version: pin.version, root: pin.runtimeRoot, storeVersion: pin.storeVersion });
|
|
99
|
+
if (storeVersion !== STORE_VERSION) return null;
|
|
100
|
+
const canonicalActive = typeof active === "string" ? await canonicalManagerRoot(active) : active;
|
|
101
|
+
if (resolved === canonicalActive) return null;
|
|
102
|
+
await access(hookEntrypointFor(resolved));
|
|
103
|
+
return resolved;
|
|
104
|
+
} catch { return null; }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A client that exits without SessionEnd leaves its pin behind. Admission
|
|
108
|
+
* already reaps dead leases; pins follow the same confirmed-death rule. */
|
|
109
|
+
export async function reapPins({ root, pidIsAlive = defaultPidIsAlive } = {}) {
|
|
110
|
+
root = await canonicalManagerRoot(root);
|
|
111
|
+
const directory = path.join(root, "pins");
|
|
112
|
+
if (!await managedDirectory(directory)) return;
|
|
113
|
+
let removed = false;
|
|
114
|
+
for (const name of await readdir(directory)) {
|
|
115
|
+
if (!name.endsWith(".json")) continue;
|
|
116
|
+
const file = path.join(directory, name);
|
|
117
|
+
const record = await readManagedJson(file).catch(() => null);
|
|
118
|
+
const pid = Number.isSafeInteger(record?.clientPid) ? record.clientPid : null;
|
|
119
|
+
if (pid !== null && await confirmedDead(pid, pidIsAlive)) {
|
|
120
|
+
await rm(file, { force: true });
|
|
121
|
+
removed = true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (removed) await syncDirectory(directory);
|
|
125
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readdir, rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { managedDirectory, readManagedJson, syncDirectory } from "./state.mjs";
|
|
5
|
+
|
|
6
|
+
// A relative runtimeRoot resolves against the caller's cwd, not this manager's
|
|
7
|
+
// own root, so it can never be owned regardless of where acc uninstall runs
|
|
8
|
+
// from. The generations directory itself is a container, not a generation, so
|
|
9
|
+
// an exact match (empty relative) is foreign too. On Windows, path.relative
|
|
10
|
+
// returns an absolute path when the two roots sit on different drives, which
|
|
11
|
+
// is foreign by construction. This matches every case validateRuntime
|
|
12
|
+
// (state.mjs) applies to a control pointer. pathImpl is injectable only so a
|
|
13
|
+
// test can pin the cross-drive case through path.win32 without depending on
|
|
14
|
+
// the host OS; production code always uses the real, platform-native path.
|
|
15
|
+
export function isOwnedRuntimeRoot(generations, runtimeRoot, pathImpl = path) {
|
|
16
|
+
if (typeof runtimeRoot !== "string" || !pathImpl.isAbsolute(runtimeRoot)) return false;
|
|
17
|
+
const relative = pathImpl.relative(generations, runtimeRoot);
|
|
18
|
+
return relative !== "" && relative !== ".." && !relative.startsWith(`..${pathImpl.sep}`)
|
|
19
|
+
&& !pathImpl.isAbsolute(relative);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Uninstall owns what it published. A binding naming a generation under this
|
|
23
|
+
* manager is ours; one naming a foreign root, or none at all, is not, and a
|
|
24
|
+
* client process is never signalled or terminated to clear a record. */
|
|
25
|
+
export async function retireManagedHolds({ root, workspaces }) {
|
|
26
|
+
const generations = path.join(root, "generations");
|
|
27
|
+
const counts = { bindings: 0, pins: 0 };
|
|
28
|
+
if (await managedDirectory(workspaces)) {
|
|
29
|
+
for (const entry of await readdir(workspaces, { withFileTypes: true })) {
|
|
30
|
+
if (!entry.isDirectory()) continue;
|
|
31
|
+
const directory = path.join(workspaces, entry.name, "bindings");
|
|
32
|
+
if (!await managedDirectory(directory)) continue;
|
|
33
|
+
let removed = false;
|
|
34
|
+
for (const name of await readdir(directory)) {
|
|
35
|
+
if (!name.endsWith(".json")) continue;
|
|
36
|
+
const file = path.join(directory, name);
|
|
37
|
+
const record = await readManagedJson(file).catch(() => null);
|
|
38
|
+
if (!isOwnedRuntimeRoot(generations, record?.runtimeRoot)) continue;
|
|
39
|
+
await rm(file, { force: true });
|
|
40
|
+
counts.bindings += 1;
|
|
41
|
+
removed = true;
|
|
42
|
+
}
|
|
43
|
+
if (removed) await syncDirectory(directory);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const pins = path.join(root, "pins");
|
|
47
|
+
if (await managedDirectory(pins)) {
|
|
48
|
+
for (const name of await readdir(pins)) {
|
|
49
|
+
if (!name.endsWith(".json")) continue;
|
|
50
|
+
await rm(path.join(pins, name), { force: true });
|
|
51
|
+
counts.pins += 1;
|
|
52
|
+
}
|
|
53
|
+
await syncDirectory(pins);
|
|
54
|
+
}
|
|
55
|
+
return counts;
|
|
56
|
+
}
|