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.
Files changed (71) hide show
  1. package/README.md +8 -4
  2. package/bin/acc-maintenance-worker.mjs +6 -0
  3. package/bin/entrypoints/acc-hook.mjs +58 -5
  4. package/docs/ARCHITECTURE.md +21 -9
  5. package/docs/CLI.md +21 -10
  6. package/docs/CONCEPTS.md +2 -1
  7. package/docs/CONFIGURATION.md +2 -1
  8. package/docs/GETTING_STARTED.md +4 -2
  9. package/docs/HOW_IT_WORKS.md +2 -1
  10. package/docs/PROTOCOL.md +7 -0
  11. package/docs/RELEASING.md +1 -1
  12. package/docs/SECURITY_MODEL.md +16 -7
  13. package/docs/TROUBLESHOOTING.md +13 -6
  14. package/docs/UPGRADING.md +121 -22
  15. package/docs/WHY_ACC.md +1 -1
  16. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  17. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  18. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +10 -4
  19. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +4 -0
  20. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +120 -0
  21. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-threads.mjs +49 -0
  22. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance.mjs +108 -0
  23. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +67 -22
  24. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  25. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  26. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  27. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  28. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  29. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -1
  30. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +63 -16
  31. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +16 -1
  32. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  33. package/node_modules/@agents-can-communicate/cli/src/args.mjs +1 -1
  34. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +10 -5
  35. package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -0
  36. package/node_modules/@agents-can-communicate/cli/src/index.mjs +5 -0
  37. package/node_modules/@agents-can-communicate/cli/src/main.mjs +5 -3
  38. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +225 -11
  39. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/command.mjs +15 -0
  40. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/diagnostics.mjs +36 -0
  41. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +17 -2
  42. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +54 -14
  43. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +44 -25
  44. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/install.mjs +46 -29
  45. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/legacy-maintenance.mjs +47 -0
  46. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-state.mjs +57 -0
  47. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-worker.mjs +118 -0
  48. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +140 -0
  49. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/management-recovery.mjs +80 -0
  50. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/pins.mjs +125 -0
  51. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +6 -7
  52. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/retire.mjs +56 -0
  53. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +4 -2
  54. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/staging.mjs +95 -0
  55. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +8 -1
  56. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +34 -12
  57. package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +83 -7
  58. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  59. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  60. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +44 -2
  61. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  62. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +10 -1
  63. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +55 -6
  64. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  65. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +2 -2
  66. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +10 -3
  67. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  68. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  69. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  70. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +38 -4
  71. package/package.json +3 -1
@@ -2,9 +2,11 @@ import { execFile } from "node:child_process";
2
2
  import { access, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { promisify } from "node:util";
5
+ import { declaredStoreVersion } from "./activation.mjs";
5
6
  import { stageOwnGeneration } from "./generation.mjs";
6
7
  import { verifyGeneration } from "./install.mjs";
7
8
  import { managedDirectory } from "./state.mjs";
9
+ import { releaseStagingHold } from "./staging.mjs";
8
10
  import { ENTRY_KINDS } from "./entry.mjs";
9
11
 
10
12
  const NAME = "agents-can-communicate";
@@ -65,7 +67,20 @@ export async function downloadRelease(root, release, { env = process.env } = {})
65
67
  for (const name of ENTRY_KINDS) await access(path.join(packageRoot, "bin", "entrypoints", `${name}.mjs`));
66
68
  await access(path.join(packageRoot, "bin", "acc-update-worker.mjs"));
67
69
  const generation = await stageOwnGeneration({ packageRoot, managerRoot: root });
68
- await verifyGeneration(generation, { env });
69
- return { version: generation.version, root: generation.root };
70
+ // A failed verify, or any other failure before this function actually
71
+ // returns, means this candidate is abandoned here, so release its hold
72
+ // now - declaredStoreVersion included, so a future change to it (or any
73
+ // other exception in this region) cannot silently leak the hold instead
74
+ // of releasing it. Only a clean return hands the hold on to the caller
75
+ // (worker.mjs), since publication happens later there, and it is
76
+ // responsible for releasing it once that publish attempt resolves.
77
+ try {
78
+ await verifyGeneration(generation, { env });
79
+ return { version: generation.version, root: generation.root,
80
+ storeVersion: await declaredStoreVersion(generation.root), hold: generation.hold };
81
+ } catch (error) {
82
+ await releaseStagingHold(generation.hold);
83
+ throw error;
84
+ }
70
85
  } finally { await rm(temporary, { recursive: true, force: true }); }
71
86
  }
@@ -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,30 +107,44 @@ 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 lease = await acquireRuntime(root, { pid: process.pid, kind });
85
- selected = lease.runtime.root;
86
- managed = root;
87
- const quiet = kind === "acc" && ["update", "install", "uninstall", "help", "version",
88
- "--help", "-h", "--version", "-v", "-V"].includes(process.argv[2]);
89
- if (!quiet) await scheduleWorker(root, control);
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
93
125
  // workspace command. The CLI parser enforces this restricted path; merely
94
126
  // putting --help in a message value cannot bypass runtime admission.
127
+ // `selected` was read before this catch and, on this path, holds no
128
+ // lease or pin of its own - a generation reclaimed in the meantime makes
129
+ // this import fail with ENOENT. Degrade below like any other admission
130
+ // failure instead of crashing with a raw stack.
95
131
  if (kind === "acc" && selected) {
96
- const runtime = await import(pathToFileURL(path.join(selected, "bin", "entrypoints", "acc.mjs")).href);
97
- await runtime.main({ managerRoot: root ?? null, packageRoot: selected, managementOnly: true });
98
- return;
132
+ try {
133
+ const runtime = await import(pathToFileURL(path.join(selected, "bin", "entrypoints", "acc.mjs")).href);
134
+ await runtime.main({ managerRoot: root ?? null, packageRoot: selected, managementOnly: true });
135
+ return;
136
+ } catch { /* Fall through to the same graceful unavailability below. */ }
99
137
  }
100
138
  unavailable(kind);
101
139
  if (kind === "acc-claude-channel" && selected) {
102
- const { startInertChannel } = await import(pathToFileURL(path.join(selected,
103
- "bin", "entrypoints", "claude-channel-stdio.mjs")).href);
104
- startInertChannel();
140
+ try {
141
+ const { startInertChannel } = await import(pathToFileURL(path.join(selected,
142
+ "bin", "entrypoints", "claude-channel-stdio.mjs")).href);
143
+ startInertChannel();
144
+ } catch { /* The stderr notice above already fired either way. */ }
105
145
  }
106
146
  return;
107
147
  }
108
148
  const runtime = await import(pathToFileURL(path.join(selected, "bin", "entrypoints", `${kind}.mjs`)).href);
109
- await runtime.main({ managerRoot: managed, packageRoot: selected });
149
+ await runtime.main({ managerRoot: managed, packageRoot: selected, ...(managementOnly ? { managementOnly } : {}) });
110
150
  }
@@ -3,6 +3,8 @@ import { lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm }
3
3
  from "node:fs/promises";
4
4
  import path from "node:path";
5
5
 
6
+ import { attachStagingTemp, holdStagedGeneration, releaseStagingHold } from "./staging.mjs";
7
+
6
8
  const NAME = "agents-can-communicate";
7
9
  const STABLE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
8
10
 
@@ -134,34 +136,51 @@ export async function stageOwnGeneration({ packageRoot, managerRoot }) {
134
136
  await directory(generations);
135
137
  const target = path.join(generations, `${manifest.version}-${digest.slice(0, 24)}`);
136
138
  const result = { version: manifest.version, root: target, digest };
137
- if (await existingMatches(target, files)) return result;
138
- const staging = await mkdtemp(path.join(generations, ".staging-"));
139
+ // Held before the check that may return `target` unchanged (it can already
140
+ // be sitting on disk unprotected from an earlier run) and, on the staging
141
+ // path below, before the rename that makes a freshly built `target`
142
+ // visible at all: reclaim must never observe the directory without also
143
+ // observing why it exists. The caller now owns this hold - see its own
144
+ // `hold` on the returned value - and must release it on every exit from
145
+ // its own attempt, once the generation is either published or abandoned.
146
+ // A hold this function itself never hands off (because it throws instead
147
+ // of returning) is released here, in its own catch, rather than left for
148
+ // a confirmed-dead reap to eventually find.
149
+ const hold = await holdStagedGeneration({ root, generationRoot: target });
139
150
  try {
140
- const directories = new Set([staging]);
141
- for (const [name, file] of files) {
142
- const destination = path.join(staging, name);
143
- await mkdir(path.dirname(destination), { recursive: true });
144
- for (let parent = path.dirname(destination); parent !== staging; parent = path.dirname(parent)) directories.add(parent);
145
- const handle = await open(destination, "wx", file.mode);
151
+ if (await existingMatches(target, files)) return { ...result, hold };
152
+ const staging = await mkdtemp(path.join(generations, ".staging-"));
153
+ try {
154
+ await attachStagingTemp(hold, staging);
155
+ const directories = new Set([staging]);
156
+ for (const [name, file] of files) {
157
+ const destination = path.join(staging, name);
158
+ await mkdir(path.dirname(destination), { recursive: true });
159
+ for (let parent = path.dirname(destination); parent !== staging; parent = path.dirname(parent)) directories.add(parent);
160
+ const handle = await open(destination, "wx", file.mode);
161
+ try {
162
+ await handle.writeFile(file.bytes);
163
+ await handle.chmod(file.mode);
164
+ await handle.sync();
165
+ } finally { await handle.close(); }
166
+ }
167
+ for (const directory of [...directories].sort((a, b) => b.length - a.length)) {
168
+ const handle = await open(directory, "r");
169
+ try { await handle.sync(); } finally { await handle.close(); }
170
+ }
146
171
  try {
147
- await handle.writeFile(file.bytes);
148
- await handle.chmod(file.mode);
149
- await handle.sync();
150
- } finally { await handle.close(); }
151
- }
152
- for (const directory of [...directories].sort((a, b) => b.length - a.length)) {
153
- const handle = await open(directory, "r");
172
+ await rename(staging, target);
173
+ } catch (error) {
174
+ if (!["EEXIST", "ENOTEMPTY"].includes(error.code) || !await existingMatches(target, files)) throw error;
175
+ }
176
+ const handle = await open(generations, "r");
154
177
  try { await handle.sync(); } finally { await handle.close(); }
178
+ return { ...result, hold };
179
+ } finally {
180
+ await rm(staging, { recursive: true, force: true });
155
181
  }
156
- try {
157
- await rename(staging, target);
158
- } catch (error) {
159
- if (!["EEXIST", "ENOTEMPTY"].includes(error.code) || !await existingMatches(target, files)) throw error;
160
- }
161
- const handle = await open(generations, "r");
162
- try { await handle.sync(); } finally { await handle.close(); }
163
- return result;
164
- } finally {
165
- await rm(staging, { recursive: true, force: true });
182
+ } catch (error) {
183
+ await releaseStagingHold(hold);
184
+ throw error;
166
185
  }
167
186
  }
@@ -3,12 +3,15 @@ import { mkdtemp, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import path from "node:path";
5
5
  import { promisify } from "node:util";
6
+ import { declaredStoreVersion } from "./activation.mjs";
6
7
  import { validateManagedLocation } from "./location.mjs";
7
8
  import { stageOwnGeneration } from "./generation.mjs";
8
9
  import { writeLaunchers } from "./launchers.mjs";
9
10
  import { canonicalManagerRoot, readControl, writeControl } from "./state.mjs";
10
11
  import { scheduleWorker } from "./schedule.mjs";
12
+ import { releaseStagingHold } from "./staging.mjs";
11
13
  import { withManagerLock } from "./mutex.mjs";
14
+ import { retireManagedHolds } from "./retire.mjs";
12
15
 
13
16
  const exec = promisify(execFile);
14
17
  export async function verifyGeneration(runtime, { env = process.env } = {}) {
@@ -27,35 +30,44 @@ export async function verifyGeneration(runtime, { env = process.env } = {}) {
27
30
  export async function installManaged({ packageRoot, managerRoot, dataHome, home, targets, env, apply, cwd }) {
28
31
  const root = await validateManagedLocation({ managerRoot, dataHome, home, cwd, env });
29
32
  const candidate = await stageOwnGeneration({ packageRoot, managerRoot: root });
30
- await verifyGeneration(candidate, { env });
31
- return withManagerLock(root, async () => {
32
- const previous = await readControl(root);
33
- if (previous !== null && previous.home !== home) {
34
- throw new Error("this ACC data home manages another client home; use a separate ACC_DATA_HOME");
35
- }
36
- if (previous !== null && previous.active.root !== candidate.root) {
37
- throw new Error("install from the active managed runtime; use acc update to switch versions");
38
- }
39
- const runtime = { version: candidate.version, root: candidate.root };
40
- const autoPreference = previous?.autoPreference ?? previous?.auto ?? true;
41
- // Full removal pauses workers without revoking the user's choice. Keep a
42
- // partial-removal pause until explicit opt-in; this install may omit its failed target.
43
- const auto = previous?.targets.length ? previous.auto : autoPreference;
44
- const control = { schemaVersion: 1, active: runtime, pending: runtime, phase: "activating",
45
- auto, autoPreference, pin: previous?.pin ?? null,
46
- checkedAt: previous?.checkedAt ?? null, home,
47
- targets: [...new Set([...(previous?.targets ?? []), ...targets])], notice: null };
48
- await writeControl(root, control);
49
- const paths = await writeLaunchers(root, runtime.root);
50
- const result = await apply({ ...paths, preserveVersions: true });
51
- if (result.failed.length === 0) {
52
- const ready = await writeControl(root, { ...control, phase: "ready", pending: null });
53
- await scheduleWorker(root, ready, { env });
54
- } else {
55
- await writeControl(root, { ...control, notice: "Integration refresh is incomplete; run acc update to recover." });
56
- }
57
- return result;
58
- });
33
+ // The hold candidate.hold carries survives verification and publication;
34
+ // release it on every exit from this attempt, once the generation is
35
+ // either published (independently protected by the control pointer this
36
+ // writes below) or abandoned (any throw between here and there).
37
+ try {
38
+ await verifyGeneration(candidate, { env });
39
+ return await withManagerLock(root, async () => {
40
+ const previous = await readControl(root);
41
+ if (previous !== null && previous.home !== home) {
42
+ throw new Error("this ACC data home manages another client home; use a separate ACC_DATA_HOME");
43
+ }
44
+ if (previous !== null && previous.active.root !== candidate.root) {
45
+ throw new Error("install from the active managed runtime; use acc update to switch versions");
46
+ }
47
+ const runtime = { version: candidate.version, root: candidate.root,
48
+ storeVersion: await declaredStoreVersion(candidate.root) };
49
+ const autoPreference = previous?.autoPreference ?? previous?.auto ?? true;
50
+ // Full removal pauses workers without revoking the user's choice. Keep a
51
+ // partial-removal pause until explicit opt-in; this install may omit its failed target.
52
+ const auto = previous?.targets.length ? previous.auto : autoPreference;
53
+ const control = { schemaVersion: 1, active: runtime, pending: runtime, phase: "activating",
54
+ auto, autoPreference, pin: previous?.pin ?? null,
55
+ checkedAt: previous?.checkedAt ?? null, home,
56
+ targets: [...new Set([...(previous?.targets ?? []), ...targets])], notice: null };
57
+ await writeControl(root, control);
58
+ const paths = await writeLaunchers(root, runtime.root);
59
+ const result = await apply({ ...paths, preserveVersions: true });
60
+ if (result.failed.length === 0) {
61
+ const ready = await writeControl(root, { ...control, phase: "ready", pending: null });
62
+ await scheduleWorker(root, ready, { env });
63
+ } else {
64
+ await writeControl(root, { ...control, notice: "Integration refresh is incomplete; run acc update to recover." });
65
+ }
66
+ return result;
67
+ });
68
+ } finally {
69
+ await releaseStagingHold(candidate.hold);
70
+ }
59
71
  }
60
72
 
61
73
  /** Removal updates enrollment under the same fence, so an updater cannot reinstall it. */
@@ -69,6 +81,11 @@ export async function uninstallManaged({ managerRoot, apply }) {
69
81
  const removed = new Set(result.operations.filter(operation => operation.applied)
70
82
  .map(operation => operation.adapterId));
71
83
  const targets = control.targets.filter(id => !removed.has(id));
84
+ // Holds this install published must stop blocking a later activation. The
85
+ // client processes keep running; only the records ACC owns are retired.
86
+ if (targets.length === 0) {
87
+ await retireManagedHolds({ root, workspaces: path.join(path.dirname(root), "workspaces") });
88
+ }
72
89
  await writeControl(root, { ...control, targets,
73
90
  autoPreference: control.autoPreference ?? control.auto,
74
91
  auto: targets.length > 0 && result.failed.length === 0 && control.auto,
@@ -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 client work and any ACC process still holding this update";
54
+ return `ACC ${version} update maintenance is scheduled; waiting for ${waiting}. Open clients will disconnect during restart. Check acc doctor for progress.`;
55
+ }
56
+ return `ACC ${version} update maintenance: ${job.status}. Check acc doctor for the result.`;
57
+ }
@@ -0,0 +1,118 @@
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
+ // Same fallback as maintenance.mjs's own target: judge other
75
+ // holds against the pending generation's contract when one is
76
+ // being activated, otherwise against the generation already
77
+ // running, since there is no incoming generation to compare to.
78
+ // Pick the whole generation record, not a per-field fallback, so
79
+ // a pending generation declaring no contract of its own is
80
+ // judged as "none" rather than silently borrowing active's.
81
+ const blockers = await listActivationBlockers(root, { pidIsAlive,
82
+ incomingStoreVersion: (current.pending ?? current.active).storeVersion });
83
+ const others = blockers.filter(b => b.pid !== expected.pid || b.kinds.some(kind => kind !== "native"));
84
+ if (others.length) throw deferred({ reason: "other_processes", processes: others.length });
85
+ const snapshot = await adapter.inspectMaintenance(context);
86
+ if (snapshot?.state === "busy") throw deferred({ reason: "service_busy",
87
+ busyThreads: snapshot.busyThreads, queuedRequests: snapshot.queuedRequests });
88
+ if (snapshot?.state !== "ready") throw refused("service_readiness_changed");
89
+ // Durable before the vendor command: recovery restores without
90
+ // assuming whether the previous worker reached the actual stop.
91
+ await save({ status: "stopping", attempted: [adapter.id], waiting: null });
92
+ const stopped = await adapter.stopForMaintenance({ context, expected });
93
+ if (stopped?.ok === false && stopped.stopAttempted === false) {
94
+ await save({ status: "waiting", attempted: [] });
95
+ if (stopped.reasonCode === "service_busy") throw deferred({ reason: "service_busy",
96
+ busyThreads: stopped.snapshot?.busyThreads, queuedRequests: stopped.snapshot?.queuedRequests });
97
+ }
98
+ if (!stopped?.ok) throw refused(stopped?.reasonCode ?? "service_stop_failed");
99
+ await save({ status: "refreshing" });
100
+ };
101
+ if (control.pending) return activate(root, { env, pidIsAlive, beforeActivation });
102
+ return withManagerLock(root, async () => {
103
+ await beforeActivation(await readControl(root));
104
+ return { activated: true, version: control.active.version };
105
+ });
106
+ }, { timeoutMs: 100 });
107
+ } catch (error) {
108
+ if (!job.attempted.length && (error.code === "MAINTENANCE_WAIT" || /manager lock held/.test(error.message))) {
109
+ await waitFor(error.waiting ?? { reason: "update_worker" }); continue;
110
+ }
111
+ if (job.attempted.length) return restore(null, error.reasonCode ?? "maintenance_failed");
112
+ return save({ status: "failed", reasonCode: error.reasonCode ?? "maintenance_failed" });
113
+ }
114
+ if (job.attempted.length) return restore(result);
115
+ return save({ status: "cancelled", reasonCode: result?.reason ?? "update_state_changed" });
116
+ }
117
+ }, { timeoutMs: 100, pidIsAlive });
118
+ }