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,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) {
|
|
@@ -37,7 +37,7 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
|
|
|
37
37
|
const installContext = { ...context,
|
|
38
38
|
clientVersion: operation.clientVersion, platform: operation.platform,
|
|
39
39
|
requestedLivePolicy: operation.livePolicy ?? "off",
|
|
40
|
-
livePolicy: operation.effectiveLivePolicy ?? "off" };
|
|
40
|
+
livePolicy: operation.configuredLivePolicy ?? operation.effectiveLivePolicy ?? "off" };
|
|
41
41
|
const outcome = await adapter.install(installContext);
|
|
42
42
|
// A consented activation is applied after the adapter's own wiring, in
|
|
43
43
|
// a fixed order; an explicit off takes a recorded one back first so the
|
|
@@ -50,7 +50,7 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
|
|
|
50
50
|
notes.push(...describeTeardown(report));
|
|
51
51
|
retainedMechanisms = report.retainedMechanisms;
|
|
52
52
|
}
|
|
53
|
-
let native = null;
|
|
53
|
+
let native = operation.retainedNativeActivation ?? null;
|
|
54
54
|
let appendedRcBlock = false;
|
|
55
55
|
if (operation.nativeActivation !== undefined) {
|
|
56
56
|
const applied = await applyNativeActivation({ adapter,
|
|
@@ -108,6 +108,10 @@ export function planInstallation({ adapters, detected, context, action = "instal
|
|
|
108
108
|
const liveDeliverySupported = native?.state === "eligible"
|
|
109
109
|
&& native.activationPlan?.eligible === true;
|
|
110
110
|
const effectiveLivePolicy = liveDeliverySupported ? delivery : "off";
|
|
111
|
+
const previous = recordedById.get(entry.adapterId)?.nativeActivation ?? null;
|
|
112
|
+
const retainedActivation = action === "install" && delivery !== "off"
|
|
113
|
+
&& !liveDeliverySupported && previous?.livePolicy === delivery ? previous : null;
|
|
114
|
+
const configuredLivePolicy = retainedActivation ? delivery : effectiveLivePolicy;
|
|
111
115
|
const deliveryDiagnostic = action === "install" && delivery !== "off"
|
|
112
116
|
&& !liveDeliverySupported
|
|
113
117
|
? entry.deliveryDiagnostic ?? adapter.deliveryFallback?.diagnostic
|
|
@@ -117,20 +121,21 @@ export function planInstallation({ adapters, detected, context, action = "instal
|
|
|
117
121
|
const deliverySummary = action === "install"
|
|
118
122
|
? describeInstallDelivery(entry, delivery, effectiveLivePolicy) : null;
|
|
119
123
|
const installContext = { ...context, requestedLivePolicy: delivery,
|
|
120
|
-
livePolicy:
|
|
124
|
+
livePolicy: configuredLivePolicy, clientVersion: entry.version, platform: entry.platform };
|
|
121
125
|
const setupNotes = action === "install" && delivery !== "off"
|
|
122
126
|
? [entry.outgoingDelivery?.setup, entry.nativeSetup].filter(note => typeof note === "string") : [];
|
|
123
127
|
// A consented activation that this run keeps, activates, or takes back.
|
|
124
128
|
// Only an explicit off or an uninstall removes one; an absent record never
|
|
125
129
|
// creates one.
|
|
126
|
-
const previous = recordedById.get(entry.adapterId)?.nativeActivation ?? null;
|
|
127
130
|
const nativeActivation = action === "install" && effectiveLivePolicy !== "off"
|
|
128
131
|
? { livePolicy: effectiveLivePolicy, protocolContract: native.eligibility.protocolContract,
|
|
129
132
|
shell: context?.shell ?? null, rcFile: rcFileFor(context?.home, context?.shell),
|
|
130
133
|
shimDir: typeof context?.stateRoot === "string" ? shimDirFor(context.stateRoot) : null,
|
|
131
134
|
mechanisms: native.activationPlan.mechanisms }
|
|
132
135
|
: null;
|
|
133
|
-
|
|
136
|
+
// A failed readiness probe is not revocation. Keep existing guarded launch
|
|
137
|
+
// setup without applying service commands or claiming it is available.
|
|
138
|
+
const retirements = retainedActivation ? [] : planActivationRetirements({ previous, desired: nativeActivation });
|
|
134
139
|
const deactivation = retirements.length > 0 ? { ...previous, mechanisms: retirements } : null;
|
|
135
140
|
const artifacts = (record?.artifacts ?? adapter.planInstall(installContext))
|
|
136
141
|
.map(artifact => ({ path: artifact.path, kind: artifact.kind ?? "file" }))
|
|
@@ -149,6 +154,7 @@ export function planInstallation({ adapters, detected, context, action = "instal
|
|
|
149
154
|
alreadyInstalled: entry.installed === true,
|
|
150
155
|
livePolicy: delivery,
|
|
151
156
|
effectiveLivePolicy,
|
|
157
|
+
...(retainedActivation ? { configuredLivePolicy, retainedNativeActivation: retainedActivation } : {}),
|
|
152
158
|
...(deliveryDiagnostic === null ? {} : { deliveryDiagnostic }),
|
|
153
159
|
...(deliverySummary === null ? {} : { deliverySummary }),
|
|
154
160
|
...(setupNotes.length ? { setupNotes } : {}),
|
|
@@ -161,6 +167,7 @@ export function planInstallation({ adapters, detected, context, action = "instal
|
|
|
161
167
|
...(entry.present ? [] : [`${adapter.displayName ?? adapter.id} is no longer on `
|
|
162
168
|
+ "this machine; removing what ACC recorded writing"]),
|
|
163
169
|
...(deliverySummary === null ? [] : [deliverySummary]),
|
|
170
|
+
...(retainedActivation ? ["keep existing native delivery setup; current readiness is unverified"] : []),
|
|
164
171
|
...setupNotes,
|
|
165
172
|
...artifacts.filter(a => a.kind === "tree")
|
|
166
173
|
.map(a => `${action === "install" ? "create" : "remove"} ${a.path}`),
|
package/package.json
CHANGED