agents-can-communicate 0.4.3 → 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 +5 -3
- 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 +2 -1
- package/docs/GETTING_STARTED.md +4 -2
- package/docs/HOW_IT_WORKS.md +2 -1
- package/docs/RELEASING.md +1 -1
- package/docs/SECURITY_MODEL.md +12 -5
- package/docs/TROUBLESHOOTING.md +5 -2
- package/docs/UPGRADING.md +54 -13
- 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 +6 -2
- 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-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/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 +3 -4
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -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 +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/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/package.json +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 +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/package.json +2 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { openCodexAppServer, parseStableVersion } from "./app-server-client.mjs";
|
|
3
|
+
import { socketIsReady } from "./native-endpoint.mjs";
|
|
4
|
+
import { absolute, approvedProcessIsDead, failMaintenance, maintenanceContext,
|
|
5
|
+
managedExecutablePaths, metadataExists, probeMaintenanceInstall, readMaintenancePid, runMaintenanceCommand,
|
|
6
|
+
startTimeValid, verifyMaintenanceProcess } from "./maintenance-host.mjs";
|
|
7
|
+
import { maintenanceWorkload } from "./maintenance-threads.mjs";
|
|
8
|
+
|
|
9
|
+
const identityKeys = ["serviceId", "pid", "processStartTime", "codexHome", "cliPath", "cliVersion",
|
|
10
|
+
"serverVersion", "managedPath", "managedVersion", "socketPath"];
|
|
11
|
+
const installationKeys = ["codexHome", "cliPath", "cliVersion", "managedPath", "managedVersion", "socketPath"];
|
|
12
|
+
const refused = (reasonCode, snapshot) => ({ ok: false, reasonCode, ...(snapshot ? { snapshot } : {}) });
|
|
13
|
+
const sameFields = (left, right, fields) => fields.every(key => left?.[key] === right?.[key]);
|
|
14
|
+
|
|
15
|
+
function validApproval(value) {
|
|
16
|
+
return value?.serviceId === "codex-app-server" && ["ready", "busy"].includes(value.state)
|
|
17
|
+
&& Number.isSafeInteger(value.pid) && value.pid > 1 && startTimeValid(value.processStartTime)
|
|
18
|
+
&& ["codexHome", "cliPath", "managedPath", "socketPath"].every(key => absolute(value[key]))
|
|
19
|
+
&& ["cliVersion", "serverVersion", "managedVersion"].every(key => parseStableVersion(value[key]))
|
|
20
|
+
&& value.managedVersion === value.cliVersion
|
|
21
|
+
&& managedExecutablePaths(value.codexHome).includes(value.managedPath)
|
|
22
|
+
&& value.socketPath === path.join(value.codexHome, "app-server-control/app-server-control.sock");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function sameMaintenanceIdentity(expected, current) {
|
|
26
|
+
return validApproval(expected) && validApproval(current) && sameFields(expected, current, identityKeys);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createCodexMaintenance({ run = runMaintenanceCommand, open = openCodexAppServer } = {}) {
|
|
30
|
+
async function inspectMaintenance(context) {
|
|
31
|
+
let snapshot = { serviceId: "codex-app-server", state: "unsupported", reasonCode: null,
|
|
32
|
+
pid: null, processStartTime: null, codexHome: null, cliPath: null, cliVersion: null,
|
|
33
|
+
serverVersion: null, managedPath: null, managedVersion: null, socketPath: null,
|
|
34
|
+
busyThreads: 0, queuedRequests: 0 };
|
|
35
|
+
try {
|
|
36
|
+
const paths = await maintenanceContext(context);
|
|
37
|
+
Object.assign(snapshot, { codexHome: paths.codexHome, socketPath: paths.socketPath, managedPath: paths.managedPath });
|
|
38
|
+
if (!await metadataExists(paths.socketPath) && !await metadataExists(paths.pidPath)) return null;
|
|
39
|
+
if (paths.platform !== "darwin-arm64") failMaintenance("maintenance_platform_unsupported");
|
|
40
|
+
if (!await socketIsReady(paths.socketPath)) failMaintenance("daemon_socket_unproven");
|
|
41
|
+
Object.assign(snapshot, await probeMaintenanceInstall(paths, run));
|
|
42
|
+
const version = await run(snapshot.cliPath, ["app-server", "daemon", "version"], paths.options);
|
|
43
|
+
if (version.status !== 0) failMaintenance("daemon_version_unavailable");
|
|
44
|
+
const observed = JSON.parse(version.stdout);
|
|
45
|
+
if (observed.status !== "running" || observed.backend !== "pid") failMaintenance("maintenance_backend_unsupported");
|
|
46
|
+
if (observed.managedCodexPath !== snapshot.managedPath || observed.socketPath !== snapshot.socketPath
|
|
47
|
+
|| observed.cliVersion !== snapshot.cliVersion || observed.managedCodexVersion !== snapshot.managedVersion
|
|
48
|
+
|| !parseStableVersion(observed.appServerVersion)) failMaintenance("daemon_identity_unavailable");
|
|
49
|
+
snapshot.serverVersion = observed.appServerVersion;
|
|
50
|
+
Object.assign(snapshot, await readMaintenancePid(paths.pidPath));
|
|
51
|
+
await verifyMaintenanceProcess(snapshot, paths, run);
|
|
52
|
+
Object.assign(snapshot, await maintenanceWorkload(snapshot, open));
|
|
53
|
+
const finalPid = await readMaintenancePid(paths.pidPath);
|
|
54
|
+
if (!sameFields(snapshot, finalPid, ["pid", "processStartTime"])) failMaintenance("service_identity_changed");
|
|
55
|
+
await verifyMaintenanceProcess(snapshot, paths, run);
|
|
56
|
+
snapshot.state = snapshot.busyThreads || snapshot.queuedRequests ? "busy" : "ready";
|
|
57
|
+
snapshot.reasonCode = snapshot.state === "busy" ? "service_busy" : null;
|
|
58
|
+
} catch (error) { snapshot.reasonCode = error.reasonCode ?? "maintenance_inspection_failed"; }
|
|
59
|
+
return snapshot;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function stopForMaintenance({ context, expected } = {}) {
|
|
63
|
+
let stopAttempted = false;
|
|
64
|
+
const refuseStop = (reasonCode, snapshot) => ({ ...refused(reasonCode, snapshot), stopAttempted });
|
|
65
|
+
if (!validApproval(expected)) return refuseStop("invalid_maintenance_approval");
|
|
66
|
+
try {
|
|
67
|
+
const current = await inspectMaintenance(context);
|
|
68
|
+
if (!sameMaintenanceIdentity(expected, current)) return refuseStop("service_identity_changed", current);
|
|
69
|
+
if (current.state !== "ready" || current.busyThreads !== 0 || current.queuedRequests !== 0) {
|
|
70
|
+
return refuseStop("service_busy", current);
|
|
71
|
+
}
|
|
72
|
+
const paths = await maintenanceContext(context);
|
|
73
|
+
stopAttempted = true;
|
|
74
|
+
const stopped = await run(current.cliPath, ["app-server", "daemon", "stop"], paths.options);
|
|
75
|
+
if (stopped.status !== 0) return refuseStop("daemon_stop_failed");
|
|
76
|
+
if (!await approvedProcessIsDead(expected, paths, run)) return refuseStop("daemon_death_unverified");
|
|
77
|
+
return { ok: true, reasonCode: null, stopAttempted };
|
|
78
|
+
} catch { return refuseStop("daemon_stop_failed"); }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function startAfterMaintenance({ context, expected } = {}) {
|
|
82
|
+
if (!validApproval(expected)) return refused("invalid_maintenance_approval");
|
|
83
|
+
try {
|
|
84
|
+
const pinned = { ...context, env: { ...(context?.env ?? process.env), CODEX_HOME: expected.codexHome } };
|
|
85
|
+
const paths = await maintenanceContext(pinned);
|
|
86
|
+
if (paths.platform !== "darwin-arm64") return refused("maintenance_platform_unsupported");
|
|
87
|
+
const install = { ...paths, ...await probeMaintenanceInstall(paths, run) };
|
|
88
|
+
if (!sameFields(expected, install, installationKeys)) return refused("service_identity_changed");
|
|
89
|
+
const existing = await inspectMaintenance(pinned);
|
|
90
|
+
if (existing) {
|
|
91
|
+
if (["ready", "busy"].includes(existing.state) && sameFields(expected, existing, installationKeys)
|
|
92
|
+
&& existing.serverVersion === expected.cliVersion) return { ok: true, reasonCode: null, snapshot: existing };
|
|
93
|
+
return refused("service_identity_changed", existing);
|
|
94
|
+
}
|
|
95
|
+
if (!await approvedProcessIsDead(expected, paths, run)) return refused("daemon_death_unverified");
|
|
96
|
+
const started = await run(install.cliPath, ["app-server", "daemon", "start"], paths.options);
|
|
97
|
+
if (started.status !== 0) return refused("daemon_start_failed");
|
|
98
|
+
const current = await inspectMaintenance(pinned);
|
|
99
|
+
if (!current || !["ready", "busy"].includes(current.state)
|
|
100
|
+
|| !sameFields(expected, current, installationKeys) || current.serverVersion !== expected.cliVersion) {
|
|
101
|
+
return refused("daemon_start_unverified", current);
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, reasonCode: null, snapshot: current };
|
|
104
|
+
} catch { return refused("daemon_start_failed"); }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { inspectMaintenance, stopForMaintenance, startAfterMaintenance };
|
|
108
|
+
}
|
|
@@ -58,7 +58,7 @@ export const COMMANDS = Object.freeze({
|
|
|
58
58
|
uninstall: { required: [], optional: ["home"], repeated: ["adapter"], flags: ["dry-run"] },
|
|
59
59
|
// Asking npm whether there is a newer ACC. The one command that touches the
|
|
60
60
|
// network, and never on the hook path.
|
|
61
|
-
update: { required: [], optional: ["auto", "pin"], flags: ["check", "apply"] },
|
|
61
|
+
update: { required: [], optional: ["auto", "pin"], flags: ["check", "apply", "yes"] },
|
|
62
62
|
// The two things a person types first after installing from a registry. The
|
|
63
63
|
// CLI answered neither: `acc --version` and `acc --help` were both "unknown
|
|
64
64
|
// command", and `acc` on its own asked for a command without naming one.
|
|
@@ -15,6 +15,7 @@ import { ALL_ADAPTERS, clientContext, probeTimeout } from "./install-command.mjs
|
|
|
15
15
|
import { describePresence } from "./main.mjs";
|
|
16
16
|
import { platformPaths } from "./platform-paths.mjs";
|
|
17
17
|
import { noticeUpdate } from "./update-check.mjs";
|
|
18
|
+
import { managedUpdateDiagnostic } from "./managed-runtime/diagnostics.mjs";
|
|
18
19
|
import { readControl } from "./managed-runtime/state.mjs";
|
|
19
20
|
import { diagnoseFilesystemStore, repairFilesystemStore }
|
|
20
21
|
from "@agents-can-communicate/storage-filesystem";
|
|
@@ -207,9 +208,7 @@ export async function runDoctor({ options, context, runtime }) {
|
|
|
207
208
|
const update = manager === null
|
|
208
209
|
? await noticeUpdate({ dataHome, running, env: runtime?.env ?? {},
|
|
209
210
|
now: Date.parse(clock.now()), get: runtime?.fetch, io: { readFile, writeFile, mkdir } })
|
|
210
|
-
:
|
|
211
|
-
newer: manager.pending !== null, auto: manager.auto, pin: manager.pin,
|
|
212
|
-
pending: manager.pending?.version ?? null, phase: manager.phase, notice: manager.notice };
|
|
211
|
+
: await managedUpdateDiagnostic(runtime.managerRoot, manager, running);
|
|
213
212
|
|
|
214
213
|
// Before the store is read for anything else. `collectStatus` reads every
|
|
215
214
|
// record, so on the store this command exists to describe it threw first and
|
|
@@ -276,7 +275,7 @@ export async function runDoctor({ options, context, runtime }) {
|
|
|
276
275
|
...adapters.filter(adapter => adapter.present && adapter.outgoingDelivery)
|
|
277
276
|
.map(adapter => ` ${adapter.displayName} ${adapter.outgoingDelivery.diagnostic}`),
|
|
278
277
|
...(manager === null ? [] : [` automatic updates ${manager.auto ? "on" : "off"}; ACC ${manager.active.version}`
|
|
279
|
-
+ (manager.pin ? `; pinned to ${manager.pin}` : ""), ...(
|
|
278
|
+
+ (manager.pin ? `; pinned to ${manager.pin}` : ""), ...(update.notice ? [` ${update.notice}`] : [])]),
|
|
280
279
|
...data.remediation.map(line => ` ${line}`),
|
|
281
280
|
// `0 of 4` is a true line that reads as a broken machine, and on an
|
|
282
281
|
// MCP-only one it would read that way on every run forever. The server needs
|
|
@@ -106,6 +106,8 @@ const NOTES = Object.freeze({
|
|
|
106
106
|
"Use acc update --check to only report whether a newer release exists.",
|
|
107
107
|
"An initial acc install enables automatic updates; reinstalling preserves your choice. Activation waits for active clients and ACC processes.",
|
|
108
108
|
"--auto on|off changes automatic updates; --pin VERSION holds an exact stable version; --pin none follows stable releases.",
|
|
109
|
+
"An eligible Codex service restart asks once; a detached worker waits for idle work and verifies completion. Open clients disconnect; acc doctor reports progress.",
|
|
110
|
+
"--yes explicitly consents to that restart without a prompt; --check never schedules maintenance.",
|
|
109
111
|
"--apply remains accepted as a compatibility alias for acc update."],
|
|
110
112
|
});
|
|
111
113
|
|
|
@@ -18,6 +18,7 @@ import { runUpdateCommand } from "./update-command.mjs";
|
|
|
18
18
|
import { runConfigCommand } from "./config-command.mjs";
|
|
19
19
|
import { runInstallCommand } from "./install-command.mjs";
|
|
20
20
|
import { runDoctor } from "./doctor-command.mjs";
|
|
21
|
+
import { runManagementDoctor } from "./managed-runtime/diagnostics.mjs";
|
|
21
22
|
import { createGitProbe } from "./git-probe.mjs";
|
|
22
23
|
import { canonicalClaim } from "./claim-spelling.mjs";
|
|
23
24
|
import { platformDataHome, runtimePaths } from "./runtime-paths.mjs";
|
|
@@ -412,7 +413,8 @@ export async function main(argv, runtime) {
|
|
|
412
413
|
let parsed;
|
|
413
414
|
try {
|
|
414
415
|
parsed = parseArgs(argv);
|
|
415
|
-
|
|
416
|
+
const managementDoctor = runtime.managementOnly && parsed.command === "doctor" && !parsed.options.repair;
|
|
417
|
+
if (runtime.managementOnly && !managementDoctor && !["help", "version", "update"].includes(parsed.command)) {
|
|
416
418
|
throw new AccError(EXIT.DATA, "runtime unavailable; retry after the update or run acc update to recover");
|
|
417
419
|
}
|
|
418
420
|
// `config` is the one command that must work on a workspace ACC cannot
|
|
@@ -423,7 +425,7 @@ export async function main(argv, runtime) {
|
|
|
423
425
|
// discovery cannot open the workspace - that is what a user is trying to
|
|
424
426
|
// find out - install touches client configuration rather than ACC state,
|
|
425
427
|
// and `help` has to answer in a directory that is no workspace at all.
|
|
426
|
-
const context = NO_WORKSPACE.includes(parsed.command)
|
|
428
|
+
const context = managementDoctor || NO_WORKSPACE.includes(parsed.command)
|
|
427
429
|
? null
|
|
428
430
|
: parsed.command === "doctor"
|
|
429
431
|
? await openDiagnosticContext(parsed.options, runtime)
|
|
@@ -434,7 +436,7 @@ export async function main(argv, runtime) {
|
|
|
434
436
|
const options = context === null ? parsed.options
|
|
435
437
|
: await resolveOwner({ command: parsed.command, options: parsed.options,
|
|
436
438
|
context, env: runtime.env });
|
|
437
|
-
const { data, text, error: outcome } = await HANDLERS[parsed.command](
|
|
439
|
+
const { data, text, error: outcome } = await (managementDoctor ? runManagementDoctor : HANDLERS[parsed.command])(
|
|
438
440
|
{ options, context, runtime });
|
|
439
441
|
// A handler may have done real work and still failed: `acc install` writes
|
|
440
442
|
// for the clients it could and reports the one it could not. The data is
|
|
@@ -53,11 +53,49 @@ export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive } =
|
|
|
53
53
|
return holds;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** Activation calls this under the admission mutex; diagnostics are a snapshot.
|
|
57
|
+
* The updater may ignore its own leases, never a native client's lifetime.
|
|
58
|
+
* Project only process facts: leases and bindings carry private owner tokens.
|
|
59
|
+
*/
|
|
60
|
+
export async function listActivationBlockers(root, { pidIsAlive = defaultPidIsAlive, ignorePid = null } = {}) {
|
|
61
|
+
const leases = (await listRuntimeHolds(root, { pidIsAlive })).filter(lease => lease.pid !== ignorePid);
|
|
62
|
+
const native = await listNativeHolds(root, { pidIsAlive });
|
|
63
|
+
const holds = [...leases.map(({ pid, kind }) => ({ pid, kind, nativeBindings: 0 })),
|
|
64
|
+
...native.map(hold => ({ ...hold, nativeBindings: hold.reason === "unknown_workspace" ? 0 : 1 }))];
|
|
65
|
+
const byPid = new Map(), unidentified = [];
|
|
66
|
+
for (const hold of holds) {
|
|
67
|
+
const pid = hold.pid ?? null;
|
|
68
|
+
const nativeBindings = hold.nativeBindings;
|
|
69
|
+
if (pid === null) {
|
|
70
|
+
unidentified.push({ pid, kinds: [hold.kind], reason: hold.reason, nativeBindings });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const blocker = byPid.get(pid) ?? { pid, kinds: [], reason: "process_running", nativeBindings: 0 };
|
|
74
|
+
if (!blocker.kinds.includes(hold.kind)) blocker.kinds.push(hold.kind);
|
|
75
|
+
blocker.nativeBindings += nativeBindings;
|
|
76
|
+
byPid.set(pid, blocker);
|
|
77
|
+
}
|
|
78
|
+
return [...byPid.values()].sort((a, b) => a.pid - b.pid)
|
|
79
|
+
.map(blocker => ({ ...blocker, kinds: blocker.kinds.sort() })).concat(unidentified);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function activationBlockerNotice(version, blockers) {
|
|
83
|
+
if (!blockers.length) return `ACC ${version} is ready; no active or unidentified processes are blocking activation.`;
|
|
84
|
+
const details = blockers.slice(0, 10).map(blocker => {
|
|
85
|
+
const facts = [blocker.kinds.map(safeText).join(", ")];
|
|
86
|
+
if (blocker.nativeBindings) facts.push(`${blocker.nativeBindings} native binding${blocker.nativeBindings === 1 ? "" : "s"}`);
|
|
87
|
+
if (blocker.pid === null) facts.push(blocker.reason.replaceAll("_", " "));
|
|
88
|
+
return `${blocker.pid === null ? "unidentified process" : `PID ${blocker.pid}`} (${facts.join("; ")})`;
|
|
89
|
+
});
|
|
90
|
+
return `ACC ${version} is ready; waiting for ${blockers.length} active or unidentified process(es): `
|
|
91
|
+
+ details.join("; ") + (blockers.length > 10 ? `; and ${blockers.length - 10} more` : "") + ".";
|
|
92
|
+
}
|
|
93
|
+
|
|
56
94
|
async function prepareCandidate(control, root, env) {
|
|
57
95
|
const file = path.join(control.pending.root, "node_modules", "@agents-can-communicate", "cli",
|
|
58
96
|
"src", "managed-runtime", "refresh.mjs");
|
|
59
97
|
const { prepareRefresh } = await import(pathToFileURL(file).href);
|
|
60
|
-
return prepareRefresh({ control, root, env });
|
|
98
|
+
return prepareRefresh({ control, root, env, callerProtocol: 2 });
|
|
61
99
|
}
|
|
62
100
|
// JSON parser diagnostics may quote private config bytes. Keep the cause and
|
|
63
101
|
// known artifact paths, never the offending input or terminal control bytes.
|
|
@@ -71,7 +109,7 @@ const recipe = c => JSON.stringify([c.active, c.pending, c.home, c.targets, c.au
|
|
|
71
109
|
|
|
72
110
|
/** Detection is outside the fence; every integration write and commit stays inside it. */
|
|
73
111
|
export async function activatePending(root, { prepare = prepareCandidate, env = process.env,
|
|
74
|
-
pidIsAlive = defaultPidIsAlive, ignorePid = null } = {}) {
|
|
112
|
+
pidIsAlive = defaultPidIsAlive, ignorePid = null, beforeActivation = null } = {}) {
|
|
75
113
|
root = await canonicalManagerRoot(root);
|
|
76
114
|
const before = await readControl(root);
|
|
77
115
|
if (!before?.pending) return { activated: false, reason: "no_pending_update" };
|
|
@@ -79,12 +117,12 @@ export async function activatePending(root, { prepare = prepareCandidate, env =
|
|
|
79
117
|
return withManagerLock(root, async () => {
|
|
80
118
|
const current = await readControl(root);
|
|
81
119
|
if (!current || recipe(current) !== recipe(before)) return { activated: false, reason: "state_changed" };
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
const notice =
|
|
120
|
+
if (beforeActivation) await beforeActivation(current);
|
|
121
|
+
const blockers = await listActivationBlockers(root, { pidIsAlive, ignorePid });
|
|
122
|
+
if (blockers.length) {
|
|
123
|
+
const notice = activationBlockerNotice(current.pending.version, blockers);
|
|
86
124
|
await writeControl(root, { ...current, notice });
|
|
87
|
-
return { activated: false, reason: "processes_active", holds:
|
|
125
|
+
return { activated: false, reason: "processes_active", holds: blockers.length, blockers, notice };
|
|
88
126
|
}
|
|
89
127
|
const activating = { ...current, phase: "activating", notice: "Finishing integration refresh." };
|
|
90
128
|
await writeControl(root, activating);
|
|
@@ -4,8 +4,13 @@ import { withManagerLock } from "./mutex.mjs";
|
|
|
4
4
|
import { scheduleWorker } from "./schedule.mjs";
|
|
5
5
|
import { readControl, writeControl } from "./state.mjs";
|
|
6
6
|
import { performUpdate, runWorker } from "./worker.mjs";
|
|
7
|
+
import { requestMaintenance } from "./maintenance.mjs";
|
|
8
|
+
import { retireLegacyUpdateWorker, stageNewerManagementRuntime } from "./management-recovery.mjs";
|
|
7
9
|
|
|
8
10
|
export function validateUpdateOptions(options) {
|
|
11
|
+
if (options.yes && (options.check || options.auto !== undefined || options.pin !== undefined)) {
|
|
12
|
+
throw new AccError(EXIT.USAGE, "--yes only consents to client maintenance during acc update");
|
|
13
|
+
}
|
|
9
14
|
if (options.auto !== undefined && !["on", "off"].includes(options.auto)) {
|
|
10
15
|
throw new AccError(EXIT.USAGE, "--auto expects on or off");
|
|
11
16
|
}
|
|
@@ -40,9 +45,15 @@ export async function runManagedUpdate({ options, runtime }) {
|
|
|
40
45
|
return { data: { auto: control.auto, pin: control.pin, running: control.active.version },
|
|
41
46
|
text: `Automatic updates ${control.auto ? "on" : "off"}. ${control.pin ? `Pinned to ${control.pin}.` : "Following stable releases."}` };
|
|
42
47
|
}
|
|
48
|
+
if (!options.check) await stageNewerManagementRuntime(root, runtime);
|
|
43
49
|
const result = options.check
|
|
44
50
|
? await performUpdate(root, { check: true, env })
|
|
45
51
|
: await runWorker(root, { force: true, env, ignorePid: process.pid });
|
|
52
|
+
if (!options.check && (result.reason === "processes_active" || result.reason === "maintenance_pending"
|
|
53
|
+
|| result.activated || result.checked && !result.newer)) {
|
|
54
|
+
const maintenance = await requestMaintenance({ root, control: await readControl(root), options, runtime });
|
|
55
|
+
if (maintenance) return maintenance;
|
|
56
|
+
}
|
|
46
57
|
const text = result.activated ? `Updated ACC to ${result.version}; integrations refreshed.`
|
|
47
58
|
: result.notice && result.reason === "processes_active" ? result.notice
|
|
48
59
|
: result.reason === "refresh_failed" ? [result.notice,
|
|
@@ -57,6 +68,10 @@ export async function runManagedUpdate({ options, runtime }) {
|
|
|
57
68
|
...(result.reason === "refresh_failed" ? { error: new AccError(EXIT.DATA, text) } : {}) };
|
|
58
69
|
} catch (error) {
|
|
59
70
|
if (/manager lock held/.test(error.message)) {
|
|
71
|
+
if (!options.check && options.auto === undefined && options.pin === undefined && !runtime.legacyWorkerRetried
|
|
72
|
+
&& await retireLegacyUpdateWorker(root).catch(() => false)) {
|
|
73
|
+
return runManagedUpdate({ options, runtime: { ...runtime, legacyWorkerRetried: true } });
|
|
74
|
+
}
|
|
60
75
|
return { data: { inProgress: true }, text: "An update is already in progress; ACC will activate it when running clients exit." };
|
|
61
76
|
}
|
|
62
77
|
throw error instanceof AccError ? error : new AccError(EXIT.DATA, error.message);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { activationBlockerNotice, listActivationBlockers } from "./activation.mjs";
|
|
2
|
+
import { MAINTENANCE_ACTIVE, maintenanceNotice, maintenanceReport, readMaintenance } from "./maintenance-state.mjs";
|
|
3
|
+
import { readControl } from "./state.mjs";
|
|
4
|
+
|
|
5
|
+
/** Available when workspace admission is fenced, and from a newer management
|
|
6
|
+
* implementation before activation. Never opens a workspace or probes clients.
|
|
7
|
+
*/
|
|
8
|
+
export async function runManagementDoctor({ runtime }) {
|
|
9
|
+
const manager = await readControl(runtime.managerRoot);
|
|
10
|
+
if (!manager) throw new Error("managed update state is unavailable; run acc update to recover");
|
|
11
|
+
const update = await managedUpdateDiagnostic(runtime.managerRoot, manager, manager.active.version);
|
|
12
|
+
return { data: { scope: "update", workspaceInspection: "unavailable_during_update", update,
|
|
13
|
+
remediation: ["acc update"] },
|
|
14
|
+
text: ["Update diagnostics only; workspace and integrations were not inspected.",
|
|
15
|
+
update.notice ?? `ACC ${manager.active.version}; update phase ${manager.phase}.`,
|
|
16
|
+
"Run acc update to finish, then acc doctor for the full workspace report."].join("\n") };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function managedUpdateDiagnostic(root, manager, running) {
|
|
20
|
+
const blockers = manager.pending ? await listActivationBlockers(root, { ignorePid: process.pid }) : null;
|
|
21
|
+
const update = { checked: false, running, latest: manager.pending?.version ?? null,
|
|
22
|
+
newer: manager.pending !== null, auto: manager.auto, pin: manager.pin,
|
|
23
|
+
pending: manager.pending?.version ?? null, phase: manager.phase,
|
|
24
|
+
notice: blockers === null ? manager.notice : manager.phase === "activating" && !blockers.length
|
|
25
|
+
? "Integration refresh is incomplete; run acc update to recover."
|
|
26
|
+
: activationBlockerNotice(manager.pending.version, blockers),
|
|
27
|
+
...(blockers === null ? {} : { holds: blockers.length, blockers }) };
|
|
28
|
+
const maintenance = await readMaintenance(root);
|
|
29
|
+
if (maintenance && (maintenance.target.root === (manager.pending ?? manager.active).root
|
|
30
|
+
|| MAINTENANCE_ACTIVE.includes(maintenance.status))) {
|
|
31
|
+
update.maintenance = maintenanceReport(maintenance);
|
|
32
|
+
update.notice = maintenanceNotice(maintenance);
|
|
33
|
+
}
|
|
34
|
+
return update;
|
|
35
|
+
}
|
|
@@ -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,12 +107,18 @@ 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
|
|
@@ -106,5 +138,5 @@ export async function runEntry({ kind, packageRoot, managerRoot, managedRequired
|
|
|
106
138
|
return;
|
|
107
139
|
}
|
|
108
140
|
const runtime = await import(pathToFileURL(path.join(selected, "bin", "entrypoints", `${kind}.mjs`)).href);
|
|
109
|
-
await runtime.main({ managerRoot: managed, packageRoot: selected });
|
|
141
|
+
await runtime.main({ managerRoot: managed, packageRoot: selected, ...(managementOnly ? { managementOnly } : {}) });
|
|
110
142
|
}
|
|
@@ -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
|
+
}
|