agents-can-communicate 0.4.2 → 0.4.4
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 +12 -4
- package/bin/acc-maintenance-worker.mjs +6 -0
- package/docs/ARCHITECTURE.md +12 -5
- package/docs/CLI.md +13 -6
- package/docs/CONCEPTS.md +2 -1
- package/docs/CONFIGURATION.md +27 -1
- package/docs/GETTING_STARTED.md +10 -4
- package/docs/HOW_IT_WORKS.md +2 -1
- package/docs/PROTOCOL.md +4 -0
- package/docs/RELEASING.md +1 -1
- package/docs/SECURITY_MODEL.md +19 -5
- package/docs/TROUBLESHOOTING.md +22 -4
- package/docs/UPGRADING.md +60 -14
- 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-claude-code/src/channel.mjs +2 -4
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +3 -1
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +6 -2
- package/node_modules/@agents-can-communicate/adapter-codex/src/config-block.mjs +4 -88
- package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +36 -15
- package/node_modules/@agents-can-communicate/adapter-codex/src/live-permissions.mjs +110 -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 +1 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/permission-ownership.mjs +109 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/toml-scan.mjs +99 -0
- 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/channel-directory.mjs +8 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -0
- 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 +5 -4
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -0
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +3 -0
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +8 -4
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +45 -7
- 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 +35 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +40 -8
- 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 +110 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +92 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/management-recovery.mjs +71 -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/schedule.mjs +4 -2
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +16 -4
- package/node_modules/@agents-can-communicate/cli/src/native-delivery-status.mjs +1 -1
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/receipts.mjs +1 -0
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +4 -2
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +5 -2
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +15 -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/package.json +2 -1
|
@@ -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 clients and other ACC processes to exit";
|
|
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,110 @@
|
|
|
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
|
+
const blockers = await listActivationBlockers(root, { pidIsAlive });
|
|
75
|
+
const others = blockers.filter(b => b.pid !== expected.pid || b.kinds.some(kind => kind !== "native"));
|
|
76
|
+
if (others.length) throw deferred({ reason: "other_processes", processes: others.length });
|
|
77
|
+
const snapshot = await adapter.inspectMaintenance(context);
|
|
78
|
+
if (snapshot?.state === "busy") throw deferred({ reason: "service_busy",
|
|
79
|
+
busyThreads: snapshot.busyThreads, queuedRequests: snapshot.queuedRequests });
|
|
80
|
+
if (snapshot?.state !== "ready") throw refused("service_readiness_changed");
|
|
81
|
+
// Durable before the vendor command: recovery restores without
|
|
82
|
+
// assuming whether the previous worker reached the actual stop.
|
|
83
|
+
await save({ status: "stopping", attempted: [adapter.id], waiting: null });
|
|
84
|
+
const stopped = await adapter.stopForMaintenance({ context, expected });
|
|
85
|
+
if (stopped?.ok === false && stopped.stopAttempted === false) {
|
|
86
|
+
await save({ status: "waiting", attempted: [] });
|
|
87
|
+
if (stopped.reasonCode === "service_busy") throw deferred({ reason: "service_busy",
|
|
88
|
+
busyThreads: stopped.snapshot?.busyThreads, queuedRequests: stopped.snapshot?.queuedRequests });
|
|
89
|
+
}
|
|
90
|
+
if (!stopped?.ok) throw refused(stopped?.reasonCode ?? "service_stop_failed");
|
|
91
|
+
await save({ status: "refreshing" });
|
|
92
|
+
};
|
|
93
|
+
if (control.pending) return activate(root, { env, pidIsAlive, beforeActivation });
|
|
94
|
+
return withManagerLock(root, async () => {
|
|
95
|
+
await beforeActivation(await readControl(root));
|
|
96
|
+
return { activated: true, version: control.active.version };
|
|
97
|
+
});
|
|
98
|
+
}, { timeoutMs: 100 });
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (!job.attempted.length && (error.code === "MAINTENANCE_WAIT" || /manager lock held/.test(error.message))) {
|
|
101
|
+
await waitFor(error.waiting ?? { reason: "update_worker" }); continue;
|
|
102
|
+
}
|
|
103
|
+
if (job.attempted.length) return restore(null, error.reasonCode ?? "maintenance_failed");
|
|
104
|
+
return save({ status: "failed", reasonCode: error.reasonCode ?? "maintenance_failed" });
|
|
105
|
+
}
|
|
106
|
+
if (job.attempted.length) return restore(result);
|
|
107
|
+
return save({ status: "cancelled", reasonCode: result?.reason ?? "update_state_changed" });
|
|
108
|
+
}
|
|
109
|
+
}, { timeoutMs: 100, pidIsAlive });
|
|
110
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { ALL_ADAPTERS } from "../install-command.mjs";
|
|
5
|
+
import { listActivationBlockers } from "./activation.mjs";
|
|
6
|
+
import { confirmedDead, withManagerLock } from "./mutex.mjs";
|
|
7
|
+
import { readControl, readManagedJson } from "./state.mjs";
|
|
8
|
+
import { MAINTENANCE_ACTIVE, maintenanceNotice, maintenanceRecipe, maintenanceReport,
|
|
9
|
+
readMaintenance, writeMaintenance } from "./maintenance-state.mjs";
|
|
10
|
+
|
|
11
|
+
export function maintenanceContext(control, env) {
|
|
12
|
+
return { home: control.home, env, platform: `${process.platform}-${process.arch}` };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function inspectMaintenanceServices({ control, env = process.env, root, adapters = ALL_ADAPTERS() }) {
|
|
16
|
+
const blockers = await listActivationBlockers(root, { ignorePid: process.pid });
|
|
17
|
+
const services = [];
|
|
18
|
+
for (const adapter of adapters.filter(a => control.targets.includes(a.id) && a.inspectMaintenance)) {
|
|
19
|
+
const snapshot = await adapter.inspectMaintenance(maintenanceContext(control, env));
|
|
20
|
+
if (!snapshot || !["ready", "busy"].includes(snapshot.state)) continue;
|
|
21
|
+
if (snapshot.serverVersion !== snapshot.cliVersion
|
|
22
|
+
|| blockers.some(b => b.pid === snapshot.pid && b.kinds.includes("native"))) {
|
|
23
|
+
services.push({ adapterId: adapter.id, snapshot });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return services;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function spawnMaintenance({ root, job, packageRoot, env }) {
|
|
30
|
+
packageRoot = job.workerRoot ?? packageRoot;
|
|
31
|
+
const manifest = await readManagedJson(path.join(packageRoot, "package.json"));
|
|
32
|
+
if (manifest?.name !== "agents-can-communicate" || manifest.accManagedUpdateProtocol !== 2) {
|
|
33
|
+
throw new Error("maintenance worker implementation is unavailable");
|
|
34
|
+
}
|
|
35
|
+
const child = spawn(process.execPath, [path.join(packageRoot, "bin", "acc-maintenance-worker.mjs"), root, job.id],
|
|
36
|
+
{ env, cwd: root, detached: true, stdio: "ignore" });
|
|
37
|
+
await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
|
|
38
|
+
child.unref();
|
|
39
|
+
return child.pid;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const response = job => ({ data: { reason: "maintenance_pending", maintenance: maintenanceReport(job) }, text: maintenanceNotice(job) });
|
|
43
|
+
|
|
44
|
+
async function launch(root, job, runtime) {
|
|
45
|
+
if (job.workerPid !== null && !await confirmedDead(job.workerPid)) return job;
|
|
46
|
+
try {
|
|
47
|
+
// The child reads after publication; a separate lifetime lock prevents a
|
|
48
|
+
// second command from creating two workers or overwriting its checkpoint.
|
|
49
|
+
const workerPid = await (runtime.spawnMaintenance ?? spawnMaintenance)({ root, job,
|
|
50
|
+
packageRoot: runtime.packageRoot, env: runtime.env ?? process.env });
|
|
51
|
+
return writeMaintenance(root, { ...job, workerPid });
|
|
52
|
+
} catch {
|
|
53
|
+
return writeMaintenance(root, { ...job, status: job.attempted.length ? "recovery" : "failed",
|
|
54
|
+
reasonCode: "maintenance_worker_start_failed" });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Explicit command only. Background inspection never grants restart consent. */
|
|
59
|
+
export async function requestMaintenance({ root, control, options, runtime, services }) {
|
|
60
|
+
const existing = await readMaintenance(root);
|
|
61
|
+
if (existing && MAINTENANCE_ACTIVE.includes(existing.status)) {
|
|
62
|
+
return withManagerLock(root, async () => {
|
|
63
|
+
const latest = await readMaintenance(root);
|
|
64
|
+
if (!latest || !MAINTENANCE_ACTIVE.includes(latest.status)) return latest ? response(latest) : null;
|
|
65
|
+
return response(await launch(root, latest, runtime));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
services ??= await inspectMaintenanceServices({ root, control, env: runtime.env, adapters: runtime.adapters });
|
|
69
|
+
if (services.length !== 1) return null; // More than one service needs a separate captured contract.
|
|
70
|
+
const names = services.map(s => s.snapshot.serviceId).join(", ");
|
|
71
|
+
if (!options.yes) {
|
|
72
|
+
if (options.json || runtime.isInteractive?.() !== true) return {
|
|
73
|
+
data: { reason: "confirmation_required", services: services.map(s => ({ adapterId: s.adapterId, serviceId: s.snapshot.serviceId })) },
|
|
74
|
+
text: `Finishing the update requires restarting ${names}, disconnecting open clients. Run acc update in a terminal to confirm, or acc update --yes to consent explicitly.` };
|
|
75
|
+
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
|
+
{ input: runtime.input, output: runtime.output });
|
|
77
|
+
if (approved !== true) return { data: { reason: "maintenance_declined" },
|
|
78
|
+
text: "Client restart declined; the update remains pending until running clients exit." };
|
|
79
|
+
}
|
|
80
|
+
return withManagerLock(root, async () => {
|
|
81
|
+
const current = await readControl(root), previous = await readMaintenance(root);
|
|
82
|
+
if (previous && MAINTENANCE_ACTIVE.includes(previous.status)) return response(previous);
|
|
83
|
+
if (maintenanceRecipe(current) !== maintenanceRecipe(control)) return {
|
|
84
|
+
data: { reason: "state_changed" }, text: "Update settings changed before confirmation; run acc update again." };
|
|
85
|
+
const job = await writeMaintenance(root, { schemaVersion: 1, id: randomUUID(), status: "waiting",
|
|
86
|
+
target: current.pending ?? current.active, recipe: maintenanceRecipe(current), services,
|
|
87
|
+
workerRoot: runtime.packageRoot,
|
|
88
|
+
approvedAt: new Date().toISOString(), deadline: Date.now() + 15 * 60_000,
|
|
89
|
+
callerPid: process.pid, workerPid: null, attempted: [], reasonCode: null });
|
|
90
|
+
return response(await launch(root, job, runtime));
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { newerVersion } from "./download.mjs";
|
|
5
|
+
import { stageOwnGeneration } from "./generation.mjs";
|
|
6
|
+
import { verifyGeneration } from "./install.mjs";
|
|
7
|
+
import { confirmedDead, withManagerLock } from "./mutex.mjs";
|
|
8
|
+
import { readControl, readManagedJson, writeControl } from "./state.mjs";
|
|
9
|
+
|
|
10
|
+
/** A newly installed global CLI can supersede an older pending release locally.
|
|
11
|
+
* No workspace code or integration is admitted until normal activation passes.
|
|
12
|
+
*/
|
|
13
|
+
export async function stageNewerManagementRuntime(root, { packageRoot, env }) {
|
|
14
|
+
if (!packageRoot) return false;
|
|
15
|
+
const before = await readControl(root);
|
|
16
|
+
const manifest = await readManagedJson(path.join(packageRoot, "package.json"));
|
|
17
|
+
if (before?.phase !== "ready" || manifest?.name !== "agents-can-communicate"
|
|
18
|
+
|| manifest.accManagedUpdateProtocol !== 2 || before.pin && before.pin !== manifest.version
|
|
19
|
+
|| !newerVersion(manifest.version, before.pending?.version ?? before.active.version)) return false;
|
|
20
|
+
const candidate = await stageOwnGeneration({ packageRoot, managerRoot: root });
|
|
21
|
+
await verifyGeneration(candidate, { env });
|
|
22
|
+
return withManagerLock(root, async () => {
|
|
23
|
+
const current = await readControl(root);
|
|
24
|
+
if (current.phase !== "ready" || current.pin !== before.pin
|
|
25
|
+
|| JSON.stringify([current.active, current.pending]) !== JSON.stringify([before.active, before.pending])) return false;
|
|
26
|
+
await writeControl(root, { ...current, pending: { root: candidate.root, version: candidate.version },
|
|
27
|
+
notice: `ACC ${candidate.version} is verified and ready to activate.` });
|
|
28
|
+
return true;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function inspectWorker(pid) {
|
|
33
|
+
try {
|
|
34
|
+
const { stdout } = await promisify(execFile)("/bin/ps", ["-p", String(pid), "-o", "uid=,lstart=,command="],
|
|
35
|
+
{ timeout: 2000, maxBuffer: 16384 });
|
|
36
|
+
const match = /^\s*(\d+)\s+(.{24})\s+(.+)\s*$/.exec(stdout);
|
|
37
|
+
return match ? { uid: Number(match[1]), start: match[2], command: match[3] } : null;
|
|
38
|
+
} catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 0.4.2/0.4.3 kept this ACC-owned worker lock while sleeping indefinitely.
|
|
42
|
+
* Retire only an exactly identified old helper, under the admission mutex in
|
|
43
|
+
* ready phase. Never interrupt integration writes, a client, or an unknown PID.
|
|
44
|
+
*/
|
|
45
|
+
export async function retireLegacyUpdateWorker(root, { inspect = inspectWorker,
|
|
46
|
+
signal = pid => process.kill(pid, "SIGTERM"), pause = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
|
|
47
|
+
return withManagerLock(root, async () => {
|
|
48
|
+
const control = await readControl(root);
|
|
49
|
+
if (control?.phase !== "ready") return false;
|
|
50
|
+
const manifest = await readManagedJson(path.join(control.active.root, "package.json"));
|
|
51
|
+
if (manifest?.accManagedUpdateProtocol === 2) return false;
|
|
52
|
+
const ownerFile = path.join(root, "worker", "manager.lock", "owner.json");
|
|
53
|
+
const owner = await readManagedJson(ownerFile);
|
|
54
|
+
if (!Number.isSafeInteger(owner?.pid) || owner.pid < 2 || owner.pid === process.pid
|
|
55
|
+
|| typeof owner.token !== "string" || !Number.isFinite(Date.parse(owner.acquiredAt))) return false;
|
|
56
|
+
const observed = await inspect(owner.pid);
|
|
57
|
+
const suffix = ` ${path.join(control.active.root, "bin", "acc-update-worker.mjs")} ${root}`;
|
|
58
|
+
const executable = observed?.command.endsWith(suffix) ? observed.command.slice(0, -suffix.length) : null;
|
|
59
|
+
const elapsed = Date.parse(owner.acquiredAt) - Date.parse(observed?.start);
|
|
60
|
+
if (!executable || !path.isAbsolute(executable) || path.basename(executable) !== "node"
|
|
61
|
+
|| observed.uid !== process.getuid?.() || !Number.isFinite(elapsed) || elapsed < 0 || elapsed > 60_000) return false;
|
|
62
|
+
if (JSON.stringify(await readManagedJson(ownerFile)) !== JSON.stringify(owner)
|
|
63
|
+
|| JSON.stringify(await inspect(owner.pid)) !== JSON.stringify(observed)) return false;
|
|
64
|
+
signal(owner.pid);
|
|
65
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
66
|
+
if (await confirmedDead(owner.pid)) return true;
|
|
67
|
+
await pause(50);
|
|
68
|
+
}
|
|
69
|
+
return false; // Normal mutex reclamation still requires confirmed death.
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -3,9 +3,11 @@ import { applyPlan, detectInstallation, livePolicyOf, loadOwnership, planInstall
|
|
|
3
3
|
from "@agents-can-communicate/installer";
|
|
4
4
|
import { ALL_ADAPTERS, clientContext, probeTimeout } from "../install-command.mjs";
|
|
5
5
|
import { stablePaths, writeLaunchers } from "./launchers.mjs";
|
|
6
|
+
import { bridgeLegacyMaintenance } from "./legacy-maintenance.mjs";
|
|
6
7
|
|
|
7
8
|
/** Loaded from the candidate, so its own adapters supply its integration bytes. */
|
|
8
|
-
export async function prepareRefresh({ control, root, env = process.env }) {
|
|
9
|
+
export async function prepareRefresh({ control, root, env = process.env, callerProtocol = 1 }) {
|
|
10
|
+
await bridgeLegacyMaintenance({ control, root, env, callerProtocol });
|
|
9
11
|
const dataHome = path.dirname(path.dirname(root));
|
|
10
12
|
const wanted = new Set(control.targets);
|
|
11
13
|
const adapters = ALL_ADAPTERS().filter(adapter => wanted.has(adapter.id));
|
|
@@ -16,12 +18,9 @@ export async function prepareRefresh({ control, root, env = process.env }) {
|
|
|
16
18
|
const detected = await detectInstallation({ adapters, context, probeTimeoutMs: probeTimeout(env) });
|
|
17
19
|
const deliveryByAdapter = Object.fromEntries(adapters.map(adapter => [adapter.id,
|
|
18
20
|
livePolicyOf(recorded.find(record => record.adapterId === adapter.id))]));
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
throw new Error("native integration could not be verified; update remains pending");
|
|
23
|
-
}
|
|
24
|
-
}
|
|
21
|
+
// Refresh configuration even when the client's service is temporarily absent.
|
|
22
|
+
// The installer preserves consent and existing activation; readiness stays a
|
|
23
|
+
// separate, current probe result rather than an update prerequisite.
|
|
25
24
|
const plan = planInstallation({ adapters, detected, context, recorded,
|
|
26
25
|
accVersion: control.pending.version, requested: control.targets, deliveryByAdapter });
|
|
27
26
|
if (plan.skipped.length || plan.operations.length !== wanted.size) {
|
|
@@ -8,8 +8,10 @@ import { checkDue, networkDisabled } from "./policy.mjs";
|
|
|
8
8
|
export async function scheduleWorker(root, control, { env = process.env } = {}) {
|
|
9
9
|
if (!control?.auto || networkDisabled(env) || !control.pending && !checkDue(control)) return false;
|
|
10
10
|
try {
|
|
11
|
-
const
|
|
12
|
-
|
|
11
|
+
for (const directory of [path.join(root, "worker"), path.join(root, "worker", "poller")]) {
|
|
12
|
+
const owner = await readManagedJson(path.join(directory, "manager.lock", "owner.json"));
|
|
13
|
+
if (Number.isSafeInteger(owner?.pid) && !await confirmedDead(owner.pid)) return false;
|
|
14
|
+
}
|
|
13
15
|
const child = spawn(process.execPath,
|
|
14
16
|
[path.join(control.active.root, "bin", "acc-update-worker.mjs"), root], {
|
|
15
17
|
env, detached: true, stdio: "ignore", cwd: root,
|
|
@@ -2,6 +2,8 @@ import { activatePending } from "./activation.mjs";
|
|
|
2
2
|
import { downloadRelease, fetchRelease, newerVersion } from "./download.mjs";
|
|
3
3
|
import { withManagerLock } from "./mutex.mjs";
|
|
4
4
|
import { readControl, writeControl } from "./state.mjs";
|
|
5
|
+
import { MAINTENANCE_ACTIVE, maintenanceNotice, maintenanceReport, readMaintenance } from "./maintenance-state.mjs";
|
|
6
|
+
import { requestMaintenance } from "./maintenance.mjs";
|
|
5
7
|
|
|
6
8
|
import { checkDue, networkDisabled } from "./policy.mjs";
|
|
7
9
|
export { CHECK_INTERVAL_MS, networkDisabled } from "./policy.mjs";
|
|
@@ -14,6 +16,14 @@ export async function performUpdate(root, { force = false, check = false, env =
|
|
|
14
16
|
if (initial === null) throw new Error("ACC is not enrolled; run acc install first");
|
|
15
17
|
const data = { running: initial.active.version, auto: initial.auto, pin: initial.pin,
|
|
16
18
|
pending: initial.pending?.version ?? null, notice: initial.notice };
|
|
19
|
+
const maintenance = await readMaintenance(root);
|
|
20
|
+
if (MAINTENANCE_ACTIVE.includes(maintenance?.status)) {
|
|
21
|
+
if (check) return { ...data, checked: false, reason: "maintenance_pending",
|
|
22
|
+
maintenance: maintenanceReport(maintenance), notice: maintenanceNotice(maintenance) };
|
|
23
|
+
const response = await requestMaintenance({ root, control: initial, options: {},
|
|
24
|
+
runtime: { env, packageRoot: initial.pending?.root ?? initial.active.root, isInteractive: () => false } });
|
|
25
|
+
return { ...data, checked: false, ...response.data, notice: response.text };
|
|
26
|
+
}
|
|
17
27
|
if (!force && !check && !initial.auto) return { ...data, checked: false, reason: "auto_off" };
|
|
18
28
|
// Recovery is local; the hard no-network override must not strand a partial refresh.
|
|
19
29
|
if (initial.pending && !check && (force || !networkDisabled(env))) {
|
|
@@ -42,16 +52,18 @@ export async function performUpdate(root, { force = false, check = false, env =
|
|
|
42
52
|
return { ...checked, pending: generation.version, ...await activate(root, { env, ignorePid }) };
|
|
43
53
|
}
|
|
44
54
|
|
|
45
|
-
/**
|
|
55
|
+
/** One poller may wait; the update lock stays available between its passes. */
|
|
46
56
|
export async function runWorker(root, { force = false, env = process.env, wait = false, ignorePid = null } = {}) {
|
|
47
|
-
|
|
57
|
+
const poll = async () => {
|
|
48
58
|
for (;;) {
|
|
49
|
-
const result = await
|
|
59
|
+
const result = await withManagerLock(`${root}/worker`,
|
|
60
|
+
() => performUpdate(root, { force, env, ignorePid }), { timeoutMs: 100 });
|
|
50
61
|
if (!wait || result.reason !== "processes_active") return result;
|
|
51
62
|
await new Promise(resolve => setTimeout(resolve, 60_000));
|
|
52
63
|
if (!(await readControl(root))?.auto || networkDisabled(env)) return result;
|
|
53
64
|
}
|
|
54
|
-
}
|
|
65
|
+
};
|
|
66
|
+
return wait ? withManagerLock(`${root}/worker/poller`, poll, { timeoutMs: 100 }) : poll();
|
|
55
67
|
}
|
|
56
68
|
|
|
57
69
|
export async function recordWorkerFailure(root, error) {
|
|
@@ -32,7 +32,7 @@ export function nativeRemediation(entry) {
|
|
|
32
32
|
+ "then run acc doctor here");
|
|
33
33
|
}
|
|
34
34
|
if (native.reasonCode === "native_endpoint_unavailable") {
|
|
35
|
-
steps.push(`${entry.displayName}: check the client's local delivery service setup; `
|
|
35
|
+
steps.push(entry.nativeSetup ?? `${entry.displayName}: check the client's local delivery service setup; `
|
|
36
36
|
+ "ACC does not start or restart that service");
|
|
37
37
|
} else if (native.reasonCode === "native_session_unavailable") {
|
|
38
38
|
steps.push(`${entry.displayName}: open a session connected to the client's local delivery service`);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { refreshExpiredBinding } from "./refresh-binding.mjs";
|
|
2
2
|
|
|
3
3
|
const SAFE_ERRORS = new Set(["ambiguous_recipient_sessions", "delivery_disabled",
|
|
4
|
-
"recipient_busy", "recipient_unavailable", "transport_error", "transport_rejected",
|
|
4
|
+
"recipient_busy", "recipient_unavailable", "transport_error", "transport_rejected", "transport_permission_denied",
|
|
5
5
|
"unsupported_client_version"]);
|
|
6
6
|
const NAMED_LIVE_TRANSPORTS = new Set(["claude-channel", "codex-app-server"]);
|
|
7
7
|
|
|
@@ -35,8 +35,9 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
|
|
|
35
35
|
const createdDirectories = await missingArtifactParents({ home: context.home,
|
|
36
36
|
artifacts: operation.artifacts });
|
|
37
37
|
const installContext = { ...context,
|
|
38
|
+
clientVersion: operation.clientVersion, platform: operation.platform,
|
|
38
39
|
requestedLivePolicy: operation.livePolicy ?? "off",
|
|
39
|
-
livePolicy: operation.effectiveLivePolicy ?? "off" };
|
|
40
|
+
livePolicy: operation.configuredLivePolicy ?? operation.effectiveLivePolicy ?? "off" };
|
|
40
41
|
const outcome = await adapter.install(installContext);
|
|
41
42
|
// A consented activation is applied after the adapter's own wiring, in
|
|
42
43
|
// a fixed order; an explicit off takes a recorded one back first so the
|
|
@@ -49,7 +50,7 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
|
|
|
49
50
|
notes.push(...describeTeardown(report));
|
|
50
51
|
retainedMechanisms = report.retainedMechanisms;
|
|
51
52
|
}
|
|
52
|
-
let native = null;
|
|
53
|
+
let native = operation.retainedNativeActivation ?? null;
|
|
53
54
|
let appendedRcBlock = false;
|
|
54
55
|
if (operation.nativeActivation !== undefined) {
|
|
55
56
|
const applied = await applyNativeActivation({ adapter,
|
|
@@ -103,6 +104,7 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
|
|
|
103
104
|
retainedNativeActivation: retainedMechanisms.length === 0 ? null
|
|
104
105
|
: { ...operation.deactivation, mechanisms: retainedMechanisms } });
|
|
105
106
|
results.operations.push({ ...operation, applied: true,
|
|
107
|
+
needsAction: outcome.needsAction ?? [],
|
|
106
108
|
changes: outcome.changes ?? [], removed: owned.removed, kept: owned.kept,
|
|
107
109
|
removedDirectories: directories.removed, keptDirectories: directories.kept,
|
|
108
110
|
missingDirectories: directories.missing,
|
|
@@ -118,7 +118,7 @@ export async function detectInstallation({ adapters, context, probe = spawnProbe
|
|
|
118
118
|
// deterministic rather than dependent on registry order.
|
|
119
119
|
.sort((left, right) => left.id.localeCompare(right.id))
|
|
120
120
|
.map(async adapter => {
|
|
121
|
-
const entry = { adapterId: adapter.id, displayName: adapter.displayName,
|
|
121
|
+
const entry = { adapterId: adapter.id, displayName: adapter.displayName, platform,
|
|
122
122
|
present: false, version: null, versionOutput: null, installed: false,
|
|
123
123
|
diagnostics: [], needsAction: [], capabilities: effectiveCapabilities(adapter),
|
|
124
124
|
deliveryDiagnostic: null, error: null };
|
|
@@ -146,7 +146,10 @@ export async function detectInstallation({ adapters, context, probe = spawnProbe
|
|
|
146
146
|
? "native_delivery_unsupported" : "version_unavailable");
|
|
147
147
|
|
|
148
148
|
try {
|
|
149
|
-
const detected = await adapter.detect(context
|
|
149
|
+
const detected = await adapter.detect({ ...context, clientVersion: entry.version,
|
|
150
|
+
platform, nativeDelivery: entry.nativeDelivery });
|
|
151
|
+
if (detected.outgoingDelivery) entry.outgoingDelivery = detected.outgoingDelivery;
|
|
152
|
+
if (detected.nativeSetup) entry.nativeSetup = detected.nativeSetup;
|
|
150
153
|
entry.diagnostics = [...(detected.diagnostics ?? [])];
|
|
151
154
|
// What a person has to do, as opposed to what is true. Adapters that
|
|
152
155
|
// have nothing to ask for say nothing.
|