agents-can-communicate 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/bin/acc-maintenance-worker.mjs +6 -0
- package/bin/entrypoints/acc-hook.mjs +58 -5
- package/docs/ARCHITECTURE.md +21 -9
- package/docs/CLI.md +21 -10
- package/docs/CONCEPTS.md +2 -1
- package/docs/CONFIGURATION.md +2 -1
- package/docs/GETTING_STARTED.md +4 -2
- package/docs/HOW_IT_WORKS.md +2 -1
- package/docs/PROTOCOL.md +7 -0
- package/docs/RELEASING.md +1 -1
- package/docs/SECURITY_MODEL.md +16 -7
- package/docs/TROUBLESHOOTING.md +13 -6
- package/docs/UPGRADING.md +121 -22
- package/docs/WHY_ACC.md +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +10 -4
- package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +4 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +120 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-threads.mjs +49 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance.mjs +108 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +67 -22
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +63 -16
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +16 -1
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +1 -1
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +10 -5
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -0
- package/node_modules/@agents-can-communicate/cli/src/index.mjs +5 -0
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +5 -3
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +225 -11
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/command.mjs +15 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/diagnostics.mjs +36 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +17 -2
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +54 -14
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +44 -25
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/install.mjs +46 -29
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/legacy-maintenance.mjs +47 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-state.mjs +57 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-worker.mjs +118 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +140 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/management-recovery.mjs +80 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/pins.mjs +125 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +6 -7
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/retire.mjs +56 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +4 -2
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/staging.mjs +95 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +8 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +34 -12
- package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +83 -7
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +44 -2
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +10 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +55 -6
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +2 -2
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +10 -3
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +38 -4
- package/package.json +3 -1
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { evaluateVersionContract } from "@agents-can-communicate/adapter-sdk";
|
|
5
|
+
import { ALL_ADAPTERS } from "../install-command.mjs";
|
|
6
|
+
import { listActivationBlockers } from "./activation.mjs";
|
|
7
|
+
import { confirmedDead, withManagerLock } from "./mutex.mjs";
|
|
8
|
+
import { readControl, readManagedJson } from "./state.mjs";
|
|
9
|
+
import { MAINTENANCE_ACTIVE, maintenanceNotice, maintenanceRecipe, maintenanceReport,
|
|
10
|
+
readMaintenance, writeMaintenance } from "./maintenance-state.mjs";
|
|
11
|
+
|
|
12
|
+
const HOST_PLATFORM = `${process.platform}-${process.arch}`;
|
|
13
|
+
|
|
14
|
+
export function maintenanceContext(control, env) {
|
|
15
|
+
return { home: control.home, env, platform: HOST_PLATFORM };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The fifth site that used to compare a service's serving version with the
|
|
19
|
+
* CLI's, and the only one that acts on the answer by interrupting the user.
|
|
20
|
+
* The other four now judge the serving version against the adapter's captured
|
|
21
|
+
* native-delivery contract, and this one has to say the same thing: a Codex
|
|
22
|
+
* daemon still running the previous build while its CLI has updated is the
|
|
23
|
+
* ordinary state this release stops treating as a fault, and telling that user
|
|
24
|
+
* a restart is required - disconnecting every open client - is the complaint
|
|
25
|
+
* the work began from.
|
|
26
|
+
*
|
|
27
|
+
* A restart is still offered whenever the serving version does *not* satisfy
|
|
28
|
+
* that contract: below the captured minimum, on the denylist, a prerelease, or
|
|
29
|
+
* unreadable. Only a version the contract actually accepts stops being a
|
|
30
|
+
* reason on its own - and the native-binding blocker it is OR-ed with is
|
|
31
|
+
* untouched, so a genuinely stale or unbound service is still selected.
|
|
32
|
+
*
|
|
33
|
+
* The contract answers two different questions with one field, and reading
|
|
34
|
+
* every non-null code as a refusal conflated them. "platform_not_captured" and
|
|
35
|
+
* "native_delivery_unsupported" are not verdicts on any version: the first is
|
|
36
|
+
* a complete, valid declaration that records nothing for the platform this
|
|
37
|
+
* process runs on, and the second is an adapter that declares no native
|
|
38
|
+
* delivery at all. Every shipped adapter captures darwin-arm64 alone, so on
|
|
39
|
+
* Linux and on an Intel Mac the first of those is the ordinary answer - and it
|
|
40
|
+
* made every ready daemon a restart candidate, including one already serving
|
|
41
|
+
* exactly the version its CLI is running. That is the complaint this work
|
|
42
|
+
* began from, reproduced on every machine but one. With no opinion to act on,
|
|
43
|
+
* the decision falls back to the comparison that governed before this branch:
|
|
44
|
+
* the served version against the CLI's. */
|
|
45
|
+
const UNCAPTURED = new Set(["platform_not_captured", "native_delivery_unsupported"]);
|
|
46
|
+
|
|
47
|
+
const servingVersionIsContrary = (adapter, snapshot) => {
|
|
48
|
+
const { reasonCode } = evaluateVersionContract(adapter,
|
|
49
|
+
{ clientVersion: snapshot.serverVersion, platform: HOST_PLATFORM });
|
|
50
|
+
if (reasonCode === null) return false;
|
|
51
|
+
if (!UNCAPTURED.has(reasonCode)) return true;
|
|
52
|
+
return snapshot.serverVersion !== snapshot.cliVersion;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export async function inspectMaintenanceServices({ control, env = process.env, root, adapters = ALL_ADAPTERS() }) {
|
|
56
|
+
// Maintenance targets the pending generation when one exists (its contract
|
|
57
|
+
// is what activation will judge holds against), otherwise the active one:
|
|
58
|
+
// there is no incoming generation, only a stale daemon to bring in line
|
|
59
|
+
// with what is already running. Same fallback as this file's own `target`
|
|
60
|
+
// below: pick the whole generation record, not a per-field fallback, so a
|
|
61
|
+
// pending generation that declares no contract of its own is judged as
|
|
62
|
+
// "none" and not silently swapped for the active generation's.
|
|
63
|
+
const blockers = await listActivationBlockers(root, { ignorePid: process.pid,
|
|
64
|
+
incomingStoreVersion: (control.pending ?? control.active).storeVersion });
|
|
65
|
+
const services = [];
|
|
66
|
+
for (const adapter of adapters.filter(a => control.targets.includes(a.id) && a.inspectMaintenance)) {
|
|
67
|
+
const snapshot = await adapter.inspectMaintenance(maintenanceContext(control, env));
|
|
68
|
+
if (!snapshot || !["ready", "busy"].includes(snapshot.state)) continue;
|
|
69
|
+
if (servingVersionIsContrary(adapter, snapshot)
|
|
70
|
+
|| blockers.some(b => b.pid === snapshot.pid && b.kinds.includes("native"))) {
|
|
71
|
+
services.push({ adapterId: adapter.id, snapshot });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return services;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function spawnMaintenance({ root, job, packageRoot, env }) {
|
|
78
|
+
packageRoot = job.workerRoot ?? packageRoot;
|
|
79
|
+
const manifest = await readManagedJson(path.join(packageRoot, "package.json"));
|
|
80
|
+
if (manifest?.name !== "agents-can-communicate" || manifest.accManagedUpdateProtocol !== 2) {
|
|
81
|
+
throw new Error("maintenance worker implementation is unavailable");
|
|
82
|
+
}
|
|
83
|
+
const child = spawn(process.execPath, [path.join(packageRoot, "bin", "acc-maintenance-worker.mjs"), root, job.id],
|
|
84
|
+
{ env, cwd: root, detached: true, stdio: "ignore" });
|
|
85
|
+
await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
|
|
86
|
+
child.unref();
|
|
87
|
+
return child.pid;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const response = job => ({ data: { reason: "maintenance_pending", maintenance: maintenanceReport(job) }, text: maintenanceNotice(job) });
|
|
91
|
+
|
|
92
|
+
async function launch(root, job, runtime) {
|
|
93
|
+
if (job.workerPid !== null && !await confirmedDead(job.workerPid)) return job;
|
|
94
|
+
try {
|
|
95
|
+
// The child reads after publication; a separate lifetime lock prevents a
|
|
96
|
+
// second command from creating two workers or overwriting its checkpoint.
|
|
97
|
+
const workerPid = await (runtime.spawnMaintenance ?? spawnMaintenance)({ root, job,
|
|
98
|
+
packageRoot: runtime.packageRoot, env: runtime.env ?? process.env });
|
|
99
|
+
return writeMaintenance(root, { ...job, workerPid });
|
|
100
|
+
} catch {
|
|
101
|
+
return writeMaintenance(root, { ...job, status: job.attempted.length ? "recovery" : "failed",
|
|
102
|
+
reasonCode: "maintenance_worker_start_failed" });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Explicit command only. Background inspection never grants restart consent. */
|
|
107
|
+
export async function requestMaintenance({ root, control, options, runtime, services }) {
|
|
108
|
+
const existing = await readMaintenance(root);
|
|
109
|
+
if (existing && MAINTENANCE_ACTIVE.includes(existing.status)) {
|
|
110
|
+
return withManagerLock(root, async () => {
|
|
111
|
+
const latest = await readMaintenance(root);
|
|
112
|
+
if (!latest || !MAINTENANCE_ACTIVE.includes(latest.status)) return latest ? response(latest) : null;
|
|
113
|
+
return response(await launch(root, latest, runtime));
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
services ??= await inspectMaintenanceServices({ root, control, env: runtime.env, adapters: runtime.adapters });
|
|
117
|
+
if (services.length !== 1) return null; // More than one service needs a separate captured contract.
|
|
118
|
+
const names = services.map(s => s.snapshot.serviceId).join(", ");
|
|
119
|
+
if (!options.yes) {
|
|
120
|
+
if (options.json || runtime.isInteractive?.() !== true) return {
|
|
121
|
+
data: { reason: "confirmation_required", services: services.map(s => ({ adapterId: s.adapterId, serviceId: s.snapshot.serviceId })) },
|
|
122
|
+
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.` };
|
|
123
|
+
const approved = await runtime.confirm?.(`Restart ${names} to finish updating ACC? Open clients will disconnect; ACC will wait for idle turns, refresh integrations, restart the service and verify it. A turn started during the final restart check can be interrupted.`,
|
|
124
|
+
{ input: runtime.input, output: runtime.output });
|
|
125
|
+
if (approved !== true) return { data: { reason: "maintenance_declined" },
|
|
126
|
+
text: "Client restart declined; the update remains pending until the holds blocking it clear." };
|
|
127
|
+
}
|
|
128
|
+
return withManagerLock(root, async () => {
|
|
129
|
+
const current = await readControl(root), previous = await readMaintenance(root);
|
|
130
|
+
if (previous && MAINTENANCE_ACTIVE.includes(previous.status)) return response(previous);
|
|
131
|
+
if (maintenanceRecipe(current) !== maintenanceRecipe(control)) return {
|
|
132
|
+
data: { reason: "state_changed" }, text: "Update settings changed before confirmation; run acc update again." };
|
|
133
|
+
const job = await writeMaintenance(root, { schemaVersion: 1, id: randomUUID(), status: "waiting",
|
|
134
|
+
target: current.pending ?? current.active, recipe: maintenanceRecipe(current), services,
|
|
135
|
+
workerRoot: runtime.packageRoot,
|
|
136
|
+
approvedAt: new Date().toISOString(), deadline: Date.now() + 15 * 60_000,
|
|
137
|
+
callerPid: process.pid, workerPid: null, attempted: [], reasonCode: null });
|
|
138
|
+
return response(await launch(root, job, runtime));
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { declaredStoreVersion } from "./activation.mjs";
|
|
5
|
+
import { newerVersion } from "./download.mjs";
|
|
6
|
+
import { stageOwnGeneration } from "./generation.mjs";
|
|
7
|
+
import { verifyGeneration } from "./install.mjs";
|
|
8
|
+
import { confirmedDead, withManagerLock } from "./mutex.mjs";
|
|
9
|
+
import { readControl, readManagedJson, writeControl } from "./state.mjs";
|
|
10
|
+
import { releaseStagingHold } from "./staging.mjs";
|
|
11
|
+
|
|
12
|
+
/** A newly installed global CLI can supersede an older pending release locally.
|
|
13
|
+
* No workspace code or integration is admitted until normal activation passes.
|
|
14
|
+
*/
|
|
15
|
+
export async function stageNewerManagementRuntime(root, { packageRoot, env }) {
|
|
16
|
+
if (!packageRoot) return false;
|
|
17
|
+
const before = await readControl(root);
|
|
18
|
+
const manifest = await readManagedJson(path.join(packageRoot, "package.json"));
|
|
19
|
+
if (before?.phase !== "ready" || manifest?.name !== "agents-can-communicate"
|
|
20
|
+
|| manifest.accManagedUpdateProtocol !== 2 || before.pin && before.pin !== manifest.version
|
|
21
|
+
|| !newerVersion(manifest.version, before.pending?.version ?? before.active.version)) return false;
|
|
22
|
+
const candidate = await stageOwnGeneration({ packageRoot, managerRoot: root });
|
|
23
|
+
// See installManaged for why this releases only after the publish attempt
|
|
24
|
+
// - success or not - has fully resolved.
|
|
25
|
+
try {
|
|
26
|
+
await verifyGeneration(candidate, { env });
|
|
27
|
+
return await withManagerLock(root, async () => {
|
|
28
|
+
const current = await readControl(root);
|
|
29
|
+
if (current.phase !== "ready" || current.pin !== before.pin
|
|
30
|
+
|| JSON.stringify([current.active, current.pending]) !== JSON.stringify([before.active, before.pending])) return false;
|
|
31
|
+
await writeControl(root, { ...current, pending: { root: candidate.root,
|
|
32
|
+
version: candidate.version, storeVersion: await declaredStoreVersion(candidate.root) },
|
|
33
|
+
notice: `ACC ${candidate.version} is verified and ready to activate.` });
|
|
34
|
+
return true;
|
|
35
|
+
});
|
|
36
|
+
} finally {
|
|
37
|
+
await releaseStagingHold(candidate.hold);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function inspectWorker(pid) {
|
|
42
|
+
try {
|
|
43
|
+
const { stdout } = await promisify(execFile)("/bin/ps", ["-p", String(pid), "-o", "uid=,lstart=,command="],
|
|
44
|
+
{ timeout: 2000, maxBuffer: 16384 });
|
|
45
|
+
const match = /^\s*(\d+)\s+(.{24})\s+(.+)\s*$/.exec(stdout);
|
|
46
|
+
return match ? { uid: Number(match[1]), start: match[2], command: match[3] } : null;
|
|
47
|
+
} catch { return null; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 0.4.2/0.4.3 kept this ACC-owned worker lock while sleeping indefinitely.
|
|
51
|
+
* Retire only an exactly identified old helper, under the admission mutex in
|
|
52
|
+
* ready phase. Never interrupt integration writes, a client, or an unknown PID.
|
|
53
|
+
*/
|
|
54
|
+
export async function retireLegacyUpdateWorker(root, { inspect = inspectWorker,
|
|
55
|
+
signal = pid => process.kill(pid, "SIGTERM"), pause = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
|
|
56
|
+
return withManagerLock(root, async () => {
|
|
57
|
+
const control = await readControl(root);
|
|
58
|
+
if (control?.phase !== "ready") return false;
|
|
59
|
+
const manifest = await readManagedJson(path.join(control.active.root, "package.json"));
|
|
60
|
+
if (manifest?.accManagedUpdateProtocol === 2) return false;
|
|
61
|
+
const ownerFile = path.join(root, "worker", "manager.lock", "owner.json");
|
|
62
|
+
const owner = await readManagedJson(ownerFile);
|
|
63
|
+
if (!Number.isSafeInteger(owner?.pid) || owner.pid < 2 || owner.pid === process.pid
|
|
64
|
+
|| typeof owner.token !== "string" || !Number.isFinite(Date.parse(owner.acquiredAt))) return false;
|
|
65
|
+
const observed = await inspect(owner.pid);
|
|
66
|
+
const suffix = ` ${path.join(control.active.root, "bin", "acc-update-worker.mjs")} ${root}`;
|
|
67
|
+
const executable = observed?.command.endsWith(suffix) ? observed.command.slice(0, -suffix.length) : null;
|
|
68
|
+
const elapsed = Date.parse(owner.acquiredAt) - Date.parse(observed?.start);
|
|
69
|
+
if (!executable || !path.isAbsolute(executable) || path.basename(executable) !== "node"
|
|
70
|
+
|| observed.uid !== process.getuid?.() || !Number.isFinite(elapsed) || elapsed < 0 || elapsed > 60_000) return false;
|
|
71
|
+
if (JSON.stringify(await readManagedJson(ownerFile)) !== JSON.stringify(owner)
|
|
72
|
+
|| JSON.stringify(await inspect(owner.pid)) !== JSON.stringify(observed)) return false;
|
|
73
|
+
signal(owner.pid);
|
|
74
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
75
|
+
if (await confirmedDead(owner.pid)) return true;
|
|
76
|
+
await pause(50);
|
|
77
|
+
}
|
|
78
|
+
return false; // Normal mutex reclamation still requires confirmed death.
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { access, readdir, rm } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { STORE_VERSION } from "@agents-can-communicate/storage-filesystem";
|
|
6
|
+
|
|
7
|
+
import { confirmedDead, defaultPidIsAlive } from "./mutex.mjs";
|
|
8
|
+
import { canonicalManagerRoot, managedDirectory, readManagedJson, syncDirectory, validateRuntime,
|
|
9
|
+
writeManagedJson } from "./state.mjs";
|
|
10
|
+
|
|
11
|
+
const SCHEMA_VERSION = 1;
|
|
12
|
+
|
|
13
|
+
// Same hashing rule as session bindings: a foreign harness id never selects
|
|
14
|
+
// which file is written.
|
|
15
|
+
const fileFor = (root, harnessSessionId) => path.join(root, "pins",
|
|
16
|
+
`${createHash("sha256").update(String(harnessSessionId)).digest("hex").slice(0, 32)}.json`);
|
|
17
|
+
|
|
18
|
+
// Where a generation's own hook lives, given its root. Computed once here and
|
|
19
|
+
// reused by every caller that needs it - a hook resolving a pin and the hook
|
|
20
|
+
// actually importing it - so the path checked and the path imported can never
|
|
21
|
+
// drift apart.
|
|
22
|
+
export const hookEntrypointFor = root => path.join(root, "bin", "entrypoints", "acc-hook.mjs");
|
|
23
|
+
|
|
24
|
+
// Every function below takes whatever `root` its caller happens to have -
|
|
25
|
+
// `entry.mjs` already canonicalises it before a hook ever sees it, but the
|
|
26
|
+
// hook-runner writes a pin from a raw data-home path it built itself. If only
|
|
27
|
+
// one side resolved symlinks, a symlinked data home would make the writer and
|
|
28
|
+
// the reader agree on the file's *name* but not its *location*: delegation
|
|
29
|
+
// would silently never fire, with no error anywhere. Canonicalising here,
|
|
30
|
+
// once, on every entry point - matching every other managed-runtime module -
|
|
31
|
+
// is what makes "the root the writer used" and "the root the reader used"
|
|
32
|
+
// the same derivation instead of two that merely happen to agree today.
|
|
33
|
+
export async function writePin({ root, harnessSessionId, runtimeRoot, version, storeVersion,
|
|
34
|
+
clientPid }) {
|
|
35
|
+
root = await canonicalManagerRoot(root);
|
|
36
|
+
await managedDirectory(path.join(root, "pins"), { create: true });
|
|
37
|
+
await writeManagedJson(fileFor(root, harnessSessionId), { schemaVersion: SCHEMA_VERSION,
|
|
38
|
+
harnessSessionId, runtimeRoot, version, storeVersion: storeVersion ?? null,
|
|
39
|
+
clientPid: clientPid ?? null, createdAt: new Date().toISOString() });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function readPin({ root, harnessSessionId }) {
|
|
43
|
+
root = await canonicalManagerRoot(root);
|
|
44
|
+
// Mirrors reapPins: an internal symlink swapped in for the pins directory
|
|
45
|
+
// must not be followed into silently, whether writing or reading.
|
|
46
|
+
if (!await managedDirectory(path.join(root, "pins"))) return null;
|
|
47
|
+
const record = await readManagedJson(fileFor(root, harnessSessionId)).catch(() => null);
|
|
48
|
+
if (!record || record.schemaVersion !== SCHEMA_VERSION
|
|
49
|
+
|| typeof record.runtimeRoot !== "string" || record.runtimeRoot === "") return null;
|
|
50
|
+
return record;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function clearPin({ root, harnessSessionId }) {
|
|
54
|
+
root = await canonicalManagerRoot(root);
|
|
55
|
+
await rm(fileFor(root, harnessSessionId), { force: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Returns the pinned generation's root when a hook should delegate to it,
|
|
59
|
+
* and null when it should stay on the active generation. Resolution never
|
|
60
|
+
* throws: a hook that cannot resolve a pin must still let the client
|
|
61
|
+
* proceed, so every reason to say no - no pin, an already-active pin, a
|
|
62
|
+
* pinned generation outside the manager's own `generations` directory, one
|
|
63
|
+
* missing its own hook entrypoint, a pin declaring a store contract this
|
|
64
|
+
* generation does not speak, a store that will not read - collapses to the
|
|
65
|
+
* same null.
|
|
66
|
+
*
|
|
67
|
+
* Containment reuses `validateRuntime`, the same rule a control record's own
|
|
68
|
+
* generation pointer is held to, rather than trusting a pin's `runtimeRoot`
|
|
69
|
+
* on its word: a pin can be written by an unmanaged hook run sharing a data
|
|
70
|
+
* home (a development checkout is its own nearest `accStoreVersion`
|
|
71
|
+
* manifest), and importing whatever such a pin names would run this
|
|
72
|
+
* process's code from outside the runtime it was admitted into.
|
|
73
|
+
*
|
|
74
|
+
* The contract gate is the one check that is not about the pin being
|
|
75
|
+
* resolvable. A pin is deliberately not an activation hold, so a harness that
|
|
76
|
+
* lives only in hooks - no native binding, no long-lived channel process -
|
|
77
|
+
* holds nothing between turns and cannot keep an activation waiting. When
|
|
78
|
+
* that activation moves to a generation declaring a different
|
|
79
|
+
* `STORE_VERSION`, the pin survives it, and delegating to the generation it
|
|
80
|
+
* names would run code whose contract the store refuses by strict equality:
|
|
81
|
+
* the session would throw `unknown store version` on every hook and fall open
|
|
82
|
+
* for the rest of its life, silently uncoordinated, while its pin held the
|
|
83
|
+
* dead generation against reclamation. `STORE_VERSION` here is this
|
|
84
|
+
* generation's own declared contract - this module is loaded from the active
|
|
85
|
+
* generation, and its root manifest's `accStoreVersion` is asserted equal to
|
|
86
|
+
* this constant - so comparing against it compares the pin with the
|
|
87
|
+
* generation that would otherwise serve. Anything but proven equality (a
|
|
88
|
+
* differing contract, or a pin that declares none) falls back to the active
|
|
89
|
+
* generation, which is this function's answer to every other pin it cannot
|
|
90
|
+
* honour. That keeps the design's stated limit intact: a `STORE_VERSION`
|
|
91
|
+
* change still requires every live process to exit. */
|
|
92
|
+
export async function resolvePinnedGeneration({ root, harnessSessionId, active }) {
|
|
93
|
+
try {
|
|
94
|
+
const canonicalRoot = await canonicalManagerRoot(root);
|
|
95
|
+
const pin = await readPin({ root: canonicalRoot, harnessSessionId });
|
|
96
|
+
if (pin === null) return null;
|
|
97
|
+
const { root: resolved, storeVersion } = await validateRuntime(canonicalRoot,
|
|
98
|
+
{ version: pin.version, root: pin.runtimeRoot, storeVersion: pin.storeVersion });
|
|
99
|
+
if (storeVersion !== STORE_VERSION) return null;
|
|
100
|
+
const canonicalActive = typeof active === "string" ? await canonicalManagerRoot(active) : active;
|
|
101
|
+
if (resolved === canonicalActive) return null;
|
|
102
|
+
await access(hookEntrypointFor(resolved));
|
|
103
|
+
return resolved;
|
|
104
|
+
} catch { return null; }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A client that exits without SessionEnd leaves its pin behind. Admission
|
|
108
|
+
* already reaps dead leases; pins follow the same confirmed-death rule. */
|
|
109
|
+
export async function reapPins({ root, pidIsAlive = defaultPidIsAlive } = {}) {
|
|
110
|
+
root = await canonicalManagerRoot(root);
|
|
111
|
+
const directory = path.join(root, "pins");
|
|
112
|
+
if (!await managedDirectory(directory)) return;
|
|
113
|
+
let removed = false;
|
|
114
|
+
for (const name of await readdir(directory)) {
|
|
115
|
+
if (!name.endsWith(".json")) continue;
|
|
116
|
+
const file = path.join(directory, name);
|
|
117
|
+
const record = await readManagedJson(file).catch(() => null);
|
|
118
|
+
const pid = Number.isSafeInteger(record?.clientPid) ? record.clientPid : null;
|
|
119
|
+
if (pid !== null && await confirmedDead(pid, pidIsAlive)) {
|
|
120
|
+
await rm(file, { force: true });
|
|
121
|
+
removed = true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (removed) await syncDirectory(directory);
|
|
125
|
+
}
|
|
@@ -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) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readdir, rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { managedDirectory, readManagedJson, syncDirectory } from "./state.mjs";
|
|
5
|
+
|
|
6
|
+
// A relative runtimeRoot resolves against the caller's cwd, not this manager's
|
|
7
|
+
// own root, so it can never be owned regardless of where acc uninstall runs
|
|
8
|
+
// from. The generations directory itself is a container, not a generation, so
|
|
9
|
+
// an exact match (empty relative) is foreign too. On Windows, path.relative
|
|
10
|
+
// returns an absolute path when the two roots sit on different drives, which
|
|
11
|
+
// is foreign by construction. This matches every case validateRuntime
|
|
12
|
+
// (state.mjs) applies to a control pointer. pathImpl is injectable only so a
|
|
13
|
+
// test can pin the cross-drive case through path.win32 without depending on
|
|
14
|
+
// the host OS; production code always uses the real, platform-native path.
|
|
15
|
+
export function isOwnedRuntimeRoot(generations, runtimeRoot, pathImpl = path) {
|
|
16
|
+
if (typeof runtimeRoot !== "string" || !pathImpl.isAbsolute(runtimeRoot)) return false;
|
|
17
|
+
const relative = pathImpl.relative(generations, runtimeRoot);
|
|
18
|
+
return relative !== "" && relative !== ".." && !relative.startsWith(`..${pathImpl.sep}`)
|
|
19
|
+
&& !pathImpl.isAbsolute(relative);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Uninstall owns what it published. A binding naming a generation under this
|
|
23
|
+
* manager is ours; one naming a foreign root, or none at all, is not, and a
|
|
24
|
+
* client process is never signalled or terminated to clear a record. */
|
|
25
|
+
export async function retireManagedHolds({ root, workspaces }) {
|
|
26
|
+
const generations = path.join(root, "generations");
|
|
27
|
+
const counts = { bindings: 0, pins: 0 };
|
|
28
|
+
if (await managedDirectory(workspaces)) {
|
|
29
|
+
for (const entry of await readdir(workspaces, { withFileTypes: true })) {
|
|
30
|
+
if (!entry.isDirectory()) continue;
|
|
31
|
+
const directory = path.join(workspaces, entry.name, "bindings");
|
|
32
|
+
if (!await managedDirectory(directory)) continue;
|
|
33
|
+
let removed = false;
|
|
34
|
+
for (const name of await readdir(directory)) {
|
|
35
|
+
if (!name.endsWith(".json")) continue;
|
|
36
|
+
const file = path.join(directory, name);
|
|
37
|
+
const record = await readManagedJson(file).catch(() => null);
|
|
38
|
+
if (!isOwnedRuntimeRoot(generations, record?.runtimeRoot)) continue;
|
|
39
|
+
await rm(file, { force: true });
|
|
40
|
+
counts.bindings += 1;
|
|
41
|
+
removed = true;
|
|
42
|
+
}
|
|
43
|
+
if (removed) await syncDirectory(directory);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const pins = path.join(root, "pins");
|
|
47
|
+
if (await managedDirectory(pins)) {
|
|
48
|
+
for (const name of await readdir(pins)) {
|
|
49
|
+
if (!name.endsWith(".json")) continue;
|
|
50
|
+
await rm(path.join(pins, name), { force: true });
|
|
51
|
+
counts.pins += 1;
|
|
52
|
+
}
|
|
53
|
+
await syncDirectory(pins);
|
|
54
|
+
}
|
|
55
|
+
return counts;
|
|
56
|
+
}
|
|
@@ -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,
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readdir, rm } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { confirmedDead, defaultPidIsAlive } from "./mutex.mjs";
|
|
6
|
+
import { canonicalManagerRoot, managedDirectory, readManagedJson, syncDirectory, writeManagedJson } from "./state.mjs";
|
|
7
|
+
|
|
8
|
+
const SCHEMA_VERSION = 1;
|
|
9
|
+
|
|
10
|
+
/** Staging renames a fully-formed generation into place, then runs it as a
|
|
11
|
+
* spawned process to verify it, before any control pointer, lease, or pin
|
|
12
|
+
* can reference it - out of the manager lock, since verification can run for
|
|
13
|
+
* several seconds and holding the lock that long would stall every hook on
|
|
14
|
+
* the machine. This hold closes that window: the caller writes it for the
|
|
15
|
+
* generation's final path *before* either the check that may return that
|
|
16
|
+
* path unchanged, or the rename that makes a freshly staged one visible
|
|
17
|
+
* there - so the directory can never be observed without it.
|
|
18
|
+
*
|
|
19
|
+
* Returns the hold's own file path: an opaque handle for attachStagingTemp
|
|
20
|
+
* and releaseStagingHold. The caller owns its lifecycle end to end - this
|
|
21
|
+
* function never releases what it creates. */
|
|
22
|
+
export async function holdStagedGeneration({ root, generationRoot, stagingRoot = null, pid = process.pid }) {
|
|
23
|
+
const directory = path.join(root, "staging");
|
|
24
|
+
await managedDirectory(directory, { create: true });
|
|
25
|
+
const file = path.join(directory, `${randomUUID()}.json`);
|
|
26
|
+
await writeManagedJson(file, { schemaVersion: SCHEMA_VERSION, generationRoot, stagingRoot, pid,
|
|
27
|
+
createdAt: new Date().toISOString() });
|
|
28
|
+
return file;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Once mkdtemp names the actual in-flight temp, attach it to the same
|
|
32
|
+
* record a live hold already protects: a dead owner's abandoned temp is
|
|
33
|
+
* only ever found by way of this link, since reclaim never otherwise looks
|
|
34
|
+
* at a dot-prefixed generations entry.
|
|
35
|
+
*
|
|
36
|
+
* If the hold has already vanished (readManagedJson returns undefined on
|
|
37
|
+
* ENOENT), do nothing rather than write `{ stagingRoot }` alone: spreading
|
|
38
|
+
* an `undefined` record silently produces exactly the schemaVersion-less,
|
|
39
|
+
* generationRoot-less, pid-less fragment reclaimGenerations reads as a
|
|
40
|
+
* malformed hold - and reapStagingHolds can never reap it either, since it
|
|
41
|
+
* has no valid pid to confirm dead. Manufacturing that record would be a
|
|
42
|
+
* self-inflicted, permanent block on reclamation, not a corruption that
|
|
43
|
+
* needed an external cause. */
|
|
44
|
+
export async function attachStagingTemp(file, stagingRoot) {
|
|
45
|
+
const record = await readManagedJson(file);
|
|
46
|
+
if (record === undefined) return;
|
|
47
|
+
await writeManagedJson(file, { ...record, stagingRoot });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The caller must call this on every exit from its own staging attempt -
|
|
51
|
+
* success, a failed verify, a timeout, or a thrown error - once the
|
|
52
|
+
* generation is either published (and so independently protected by the
|
|
53
|
+
* control pointer) or abandoned. Confirmed-dead reaping is the backstop for
|
|
54
|
+
* a crash that skips this, never the release mechanism itself. */
|
|
55
|
+
export async function releaseStagingHold(file) {
|
|
56
|
+
if (typeof file === "string" && file) await rm(file, { force: true });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const nonempty = value => typeof value === "string" && value.length > 0;
|
|
60
|
+
|
|
61
|
+
/** A staging process that exits without releasing its hold (crash, kill, a
|
|
62
|
+
* SIGKILL mid-verify) leaves both the hold and, if it had gotten that far,
|
|
63
|
+
* its in-flight temp behind - the only sweeper either one ever gets. Same
|
|
64
|
+
* confirmed-death rule as reapPins. The temp is only ever removed alongside
|
|
65
|
+
* its owning record, and only after it is confirmed to sit directly inside
|
|
66
|
+
* this root's own `generations`, named the way mkdtemp names one: a corrupt
|
|
67
|
+
* or foreign path in the record must never turn a reap into an arbitrary
|
|
68
|
+
* delete. */
|
|
69
|
+
export async function reapStagingHolds({ root, pidIsAlive = defaultPidIsAlive } = {}) {
|
|
70
|
+
root = await canonicalManagerRoot(root);
|
|
71
|
+
const directory = path.join(root, "staging");
|
|
72
|
+
if (!await managedDirectory(directory)) return;
|
|
73
|
+
const generations = path.join(root, "generations");
|
|
74
|
+
let removed = false;
|
|
75
|
+
for (const name of await readdir(directory)) {
|
|
76
|
+
if (!name.endsWith(".json")) continue;
|
|
77
|
+
const file = path.join(directory, name);
|
|
78
|
+
const record = await readManagedJson(file).catch(() => null);
|
|
79
|
+
const pid = Number.isSafeInteger(record?.pid) ? record.pid : null;
|
|
80
|
+
if (pid !== null && await confirmedDead(pid, pidIsAlive)) {
|
|
81
|
+
if (nonempty(record.stagingRoot)) {
|
|
82
|
+
// Resolved the same way every generations entry is (see
|
|
83
|
+
// resolveCandidate in activation.mjs): a symlinked data home must
|
|
84
|
+
// never make a real temp look foreign, or a foreign path look real.
|
|
85
|
+
const resolved = await canonicalManagerRoot(record.stagingRoot).catch(() => null);
|
|
86
|
+
if (resolved && path.dirname(resolved) === generations && path.basename(resolved).startsWith(".staging-")) {
|
|
87
|
+
await rm(resolved, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
await rm(file, { force: true });
|
|
91
|
+
removed = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (removed) await syncDirectory(directory);
|
|
95
|
+
}
|
|
@@ -77,6 +77,13 @@ const nullableString = value => value === null || nonempty(value);
|
|
|
77
77
|
export async function validateRuntime(root, runtime) {
|
|
78
78
|
if (!runtime || !nonempty(runtime.version) || typeof runtime.root !== "string"
|
|
79
79
|
|| !path.isAbsolute(runtime.root)) throw new Error("invalid control generation");
|
|
80
|
+
// Absent means a pointer written before the contract field existed. Present
|
|
81
|
+
// but malformed is a corrupt record, and reading it as unknown would let an
|
|
82
|
+
// incompatible generation past the gate.
|
|
83
|
+
if (runtime.storeVersion !== undefined && runtime.storeVersion !== null
|
|
84
|
+
&& (!Number.isSafeInteger(runtime.storeVersion) || runtime.storeVersion <= 0)) {
|
|
85
|
+
throw new Error("invalid control generation");
|
|
86
|
+
}
|
|
80
87
|
const generations = path.join(root, "generations");
|
|
81
88
|
const resolved = await canonicalManagerRoot(runtime.root);
|
|
82
89
|
const relative = path.relative(generations, resolved);
|
|
@@ -88,7 +95,7 @@ export async function validateRuntime(root, runtime) {
|
|
|
88
95
|
if (await managedDirectory(generations)) {
|
|
89
96
|
if (await realpath(generations) !== generations) throw new Error("invalid control generations directory");
|
|
90
97
|
}
|
|
91
|
-
return { version: runtime.version, root: resolved };
|
|
98
|
+
return { version: runtime.version, root: resolved, storeVersion: runtime.storeVersion ?? null };
|
|
92
99
|
}
|
|
93
100
|
|
|
94
101
|
async function validateControl(root, value) {
|
|
@@ -2,6 +2,9 @@ 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 { releaseStagingHold } from "./staging.mjs";
|
|
6
|
+
import { MAINTENANCE_ACTIVE, maintenanceNotice, maintenanceReport, readMaintenance } from "./maintenance-state.mjs";
|
|
7
|
+
import { requestMaintenance } from "./maintenance.mjs";
|
|
5
8
|
|
|
6
9
|
import { checkDue, networkDisabled } from "./policy.mjs";
|
|
7
10
|
export { CHECK_INTERVAL_MS, networkDisabled } from "./policy.mjs";
|
|
@@ -14,6 +17,14 @@ export async function performUpdate(root, { force = false, check = false, env =
|
|
|
14
17
|
if (initial === null) throw new Error("ACC is not enrolled; run acc install first");
|
|
15
18
|
const data = { running: initial.active.version, auto: initial.auto, pin: initial.pin,
|
|
16
19
|
pending: initial.pending?.version ?? null, notice: initial.notice };
|
|
20
|
+
const maintenance = await readMaintenance(root);
|
|
21
|
+
if (MAINTENANCE_ACTIVE.includes(maintenance?.status)) {
|
|
22
|
+
if (check) return { ...data, checked: false, reason: "maintenance_pending",
|
|
23
|
+
maintenance: maintenanceReport(maintenance), notice: maintenanceNotice(maintenance) };
|
|
24
|
+
const response = await requestMaintenance({ root, control: initial, options: {},
|
|
25
|
+
runtime: { env, packageRoot: initial.pending?.root ?? initial.active.root, isInteractive: () => false } });
|
|
26
|
+
return { ...data, checked: false, ...response.data, notice: response.text };
|
|
27
|
+
}
|
|
17
28
|
if (!force && !check && !initial.auto) return { ...data, checked: false, reason: "auto_off" };
|
|
18
29
|
// Recovery is local; the hard no-network override must not strand a partial refresh.
|
|
19
30
|
if (initial.pending && !check && (force || !networkDisabled(env))) {
|
|
@@ -30,28 +41,39 @@ export async function performUpdate(root, { force = false, check = false, env =
|
|
|
30
41
|
const checked = { ...data, checked: true, latest: release.version, newer };
|
|
31
42
|
if (check || !newer) return checked;
|
|
32
43
|
const generation = await download(root, release, { env });
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
// generation.hold has protected this candidate since it was staged;
|
|
45
|
+
// release it once this publish attempt resolves either way - published,
|
|
46
|
+
// its own control pointer takes over protecting it, and abandoned
|
|
47
|
+
// (state_changed) it is no longer wanted at all.
|
|
48
|
+
let published;
|
|
49
|
+
try {
|
|
50
|
+
published = await withManagerLock(root, async () => {
|
|
51
|
+
const current = await readControl(root);
|
|
52
|
+
if (current.phase !== "ready" || current.active.root !== initial.active.root
|
|
53
|
+
|| current.pin !== initial.pin || !force && !current.auto) return false;
|
|
54
|
+
await writeControl(root, { ...current, pending: generation,
|
|
55
|
+
notice: `ACC ${generation.version} is downloaded and verified.` });
|
|
56
|
+
return true;
|
|
57
|
+
});
|
|
58
|
+
} finally {
|
|
59
|
+
await releaseStagingHold(generation.hold);
|
|
60
|
+
}
|
|
41
61
|
if (!published) return { ...checked, reason: "state_changed" };
|
|
42
62
|
return { ...checked, pending: generation.version, ...await activate(root, { env, ignorePid }) };
|
|
43
63
|
}
|
|
44
64
|
|
|
45
|
-
/**
|
|
65
|
+
/** One poller may wait; the update lock stays available between its passes. */
|
|
46
66
|
export async function runWorker(root, { force = false, env = process.env, wait = false, ignorePid = null } = {}) {
|
|
47
|
-
|
|
67
|
+
const poll = async () => {
|
|
48
68
|
for (;;) {
|
|
49
|
-
const result = await
|
|
69
|
+
const result = await withManagerLock(`${root}/worker`,
|
|
70
|
+
() => performUpdate(root, { force, env, ignorePid }), { timeoutMs: 100 });
|
|
50
71
|
if (!wait || result.reason !== "processes_active") return result;
|
|
51
72
|
await new Promise(resolve => setTimeout(resolve, 60_000));
|
|
52
73
|
if (!(await readControl(root))?.auto || networkDisabled(env)) return result;
|
|
53
74
|
}
|
|
54
|
-
}
|
|
75
|
+
};
|
|
76
|
+
return wait ? withManagerLock(`${root}/worker/poller`, poll, { timeoutMs: 100 }) : poll();
|
|
55
77
|
}
|
|
56
78
|
|
|
57
79
|
export async function recordWorkerFailure(root, error) {
|