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
@@ -102,19 +102,45 @@ function knownBadHit(contract, version) {
102
102
  : compareStableVersions(version, entry.from) >= 0 && compareStableVersions(version, entry.to) <= 0));
103
103
  }
104
104
 
105
- // The static half of the rule: platform, minimum, prerelease, and denylist.
106
- function evaluateStatic(adapter, { clientVersion, platform }) {
105
+ // Judges one reported client version against an adapter's captured
106
+ // native-delivery contract: is the platform captured, is the version at or
107
+ // above the captured minimum, and is it not on the denylist. Returns
108
+ // { reasonCode, minimumVersion, protocolContract }; reasonCode is null when
109
+ // the version satisfies the contract, otherwise one of
110
+ // "native_delivery_unsupported", "platform_not_captured",
111
+ // "version_unavailable", "prerelease_not_captured", "below_minimum_version",
112
+ // or "known_bad_version".
113
+ //
114
+ // This is the static half of the rule only: it never contacts the client, so
115
+ // passing here proves nothing about whether a live probe or handshake
116
+ // actually confirms the protocol or delivery modes. A caller that needs that
117
+ // layers a probe or handshake check on top (see evaluateNativeEligibility,
118
+ // validateNativeHandshake below, and the delivery router's own offer check,
119
+ // which uses this function alone because a response already carries no probe
120
+ // or handshake shape to check further).
121
+ export function evaluateVersionContract(adapter, { clientVersion, platform }) {
107
122
  const contract = adapter?.nativeDelivery;
108
- if (contract === undefined) {
123
+ // `== null` rather than `=== undefined`: a null declaration is as much "no
124
+ // contract" as a missing one, and reading `contract.minimumByPlatform` off it
125
+ // threw the very TypeError the guard below exists to prevent. defineAdapter
126
+ // cannot produce that shape; a hand-built registry entry can.
127
+ if (contract == null) {
109
128
  return { reasonCode: "native_delivery_unsupported", minimumVersion: null, protocolContract: null };
110
129
  }
111
- const minimumVersion = typeof platform === "string"
112
- ? (contract.minimumByPlatform[platform] ?? null) : null;
113
- if (minimumVersion === null) {
114
- return { reasonCode: "platform_not_captured", minimumVersion: null, protocolContract: null };
115
- }
116
- const anchor = contract.anchors.find(item => item.platform === platform
117
- && item.version === minimumVersion);
130
+ const uncaptured = { reasonCode: "platform_not_captured", minimumVersion: null,
131
+ protocolContract: null };
132
+ // validateNativeDeliveryContract guarantees a minimum map and a matching
133
+ // anchor, but this function is also handed adapter objects that never went
134
+ // through it. A declaration missing either half has captured nothing for
135
+ // this platform, which is a closed answer - not a TypeError raised deep
136
+ // inside a delivery offer, far from the declaration that caused it. Every
137
+ // other entry point below already answers malformed input this way.
138
+ if (typeof platform !== "string" || !isPlainObject(contract.minimumByPlatform)) return uncaptured;
139
+ const minimumVersion = contract.minimumByPlatform[platform] ?? null;
140
+ if (minimumVersion === null) return uncaptured;
141
+ const anchor = (Array.isArray(contract.anchors) ? contract.anchors : [])
142
+ .find(item => item.platform === platform && item.version === minimumVersion);
143
+ if (anchor === undefined) return uncaptured;
118
144
  const facts = { minimumVersion, protocolContract: anchor.protocolContract };
119
145
  if (!isText(clientVersion)) return { ...facts, reasonCode: "version_unavailable" };
120
146
  if (parseStableVersion(clientVersion) === null) {
@@ -147,7 +173,12 @@ function validateNativeProbe(probe) {
147
173
  }
148
174
 
149
175
  export function evaluateNativeEligibility(adapter, { clientVersion, platform, probe }) {
150
- const rule = evaluateStatic(adapter, { clientVersion, platform });
176
+ // The probe names the process that will serve the delivery. Judging the
177
+ // detected binary instead refuses a service that satisfies the contract.
178
+ // Only the version is read here; the shape is validated where it always was,
179
+ // so a malformed probe still returns a closed result rather than throwing.
180
+ const serving = isText(probe?.clientVersion) ? probe.clientVersion : clientVersion;
181
+ const rule = evaluateVersionContract(adapter, { clientVersion: serving, platform });
151
182
  const base = { eligible: false, reasonCode: null, minimumVersion: rule.minimumVersion,
152
183
  protocolContract: rule.protocolContract, modes: [] };
153
184
  const closedResult = reasonCode => deepFreeze({ ...base, reasonCode });
@@ -155,7 +186,6 @@ export function evaluateNativeEligibility(adapter, { clientVersion, platform, pr
155
186
  if (probe === null || probe === undefined) return closedResult("feature_probe_failed");
156
187
  const facts = validateNativeProbe(probe);
157
188
  if (facts.supported !== true) return closedResult(facts.reasonCode ?? "feature_probe_failed");
158
- if (facts.clientVersion !== clientVersion) return closedResult("probe_version_mismatch");
159
189
  if (facts.protocolContract !== rule.protocolContract) return closedResult("protocol_mismatch");
160
190
  const modes = orderedModes(facts.modes);
161
191
  if (!modes.includes("livePush")) return closedResult("feature_probe_failed");
@@ -191,19 +221,36 @@ function validateNativeHandshakeShape(handshake) {
191
221
 
192
222
  // The per-session half: the same static rule again, then the adapter's live
193
223
  // handshake facts. The launch-time executable fingerprint stays probe-only.
224
+ //
225
+ // An admitted verdict reports the version it admitted as `clientVersion`. That
226
+ // is the whole point of returning it: the caller publishes a binding, and the
227
+ // binding must record the version the admission actually rests on - the one
228
+ // that will serve - rather than the caller's own detected-CLI claim. 0.5.0
229
+ // judged the serving version here and left the caller publishing its claim, so
230
+ // the two records disagreed on every machine where a daemon had updated under
231
+ // its CLI, which is exactly the case the release exists to support. A refused
232
+ // verdict admits nothing and carries null.
194
233
  export function validateNativeHandshake(adapter, { clientVersion, platform, handshake }) {
195
- const rule = evaluateStatic(adapter, { clientVersion, platform });
196
- const base = { ok: false, reasonCode: null, protocolContract: rule.protocolContract, modes: [],
234
+ // Same rationale as the probe: the handshake names the session that will
235
+ // actually serve, so the static rule is judged against that version. Only
236
+ // the version is read here; the shape is validated where it always was, so
237
+ // a malformed handshake still returns a closed result rather than throwing.
238
+ const serving = isText(handshake?.clientVersion) ? handshake.clientVersion : clientVersion;
239
+ const rule = evaluateVersionContract(adapter, { clientVersion: serving, platform });
240
+ const base = { ok: false, reasonCode: null, clientVersion: null,
241
+ protocolContract: rule.protocolContract, modes: [],
197
242
  opaqueEndpointRef: null, leaseUntil: null };
198
243
  const closedResult = reasonCode => deepFreeze({ ...base, reasonCode });
199
244
  if (rule.reasonCode !== null) return closedResult(rule.reasonCode);
200
245
  if (handshake === null || handshake === undefined) return closedResult("handshake_failed");
201
246
  const facts = validateNativeHandshakeShape(handshake);
202
247
  if (facts.supported !== true) return closedResult(facts.reasonCode ?? "handshake_failed");
203
- if (facts.clientVersion !== clientVersion) return closedResult("handshake_version_mismatch");
204
248
  if (facts.protocolContract !== rule.protocolContract) return closedResult("protocol_mismatch");
205
249
  const modes = orderedModes(facts.modes);
206
250
  if (!modes.includes("livePush")) return closedResult("handshake_failed");
207
- return deepFreeze({ ok: true, reasonCode: null, protocolContract: rule.protocolContract, modes,
251
+ // `serving` is a text version whenever the rule admitted it: every other
252
+ // shape leaves evaluateVersionContract at "version_unavailable" above.
253
+ return deepFreeze({ ok: true, reasonCode: null, clientVersion: serving,
254
+ protocolContract: rule.protocolContract, modes,
208
255
  opaqueEndpointRef: facts.opaqueEndpointRef, leaseUntil: facts.leaseUntil });
209
256
  }
@@ -43,7 +43,7 @@ function assertBindingDeadline(deadlineAt) {
43
43
  }
44
44
 
45
45
  export async function storeSessionBinding({ runtimeDir, harnessSessionId, accSessionId,
46
- generation, clientVersion, platform, clientPid, deadlineAt }) {
46
+ generation, clientVersion, platform, clientPid, deadlineAt, storeVersion, runtimeRoot }) {
47
47
  assertBindingDeadline(deadlineAt);
48
48
  const file = fileFor(runtimeDir, harnessSessionId);
49
49
  const record = { schemaVersion: SCHEMA_VERSION, harnessSessionId, accSessionId, generation };
@@ -62,6 +62,21 @@ export async function storeSessionBinding({ runtimeDir, harnessSessionId, accSes
62
62
  }
63
63
  record.clientPid = clientPid;
64
64
  }
65
+ // Runtime facts, kept separate from `generation`, which names the ACC session
66
+ // generation and must not be overloaded with a runtime identity.
67
+ if (storeVersion !== undefined && storeVersion !== null) {
68
+ if (!Number.isSafeInteger(storeVersion) || storeVersion <= 0) {
69
+ throw new AccError(EXIT.USAGE, "session binding storeVersion must be a positive integer",
70
+ { storeVersion });
71
+ }
72
+ record.storeVersion = storeVersion;
73
+ }
74
+ if (runtimeRoot !== undefined && runtimeRoot !== null) {
75
+ if (typeof runtimeRoot !== "string" || runtimeRoot === "") {
76
+ throw new AccError(EXIT.USAGE, "session binding runtimeRoot must be a non-empty string", {});
77
+ }
78
+ record.runtimeRoot = runtimeRoot;
79
+ }
65
80
  const temporary = `${file}.${process.pid}.tmp`;
66
81
  await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, "utf8");
67
82
  // Replace rather than append: re-attaching supersedes the old generation, and
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/cli",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -58,7 +58,7 @@ export const COMMANDS = Object.freeze({
58
58
  uninstall: { required: [], optional: ["home"], repeated: ["adapter"], flags: ["dry-run"] },
59
59
  // Asking npm whether there is a newer ACC. The one command that touches the
60
60
  // network, and never on the hook path.
61
- update: { required: [], optional: ["auto", "pin"], flags: ["check", "apply"] },
61
+ update: { required: [], optional: ["auto", "pin"], flags: ["check", "apply", "yes"] },
62
62
  // The two things a person types first after installing from a registry. The
63
63
  // CLI answered neither: `acc --version` and `acc --help` were both "unknown
64
64
  // command", and `acc` on its own asked for a command without naming one.
@@ -15,6 +15,7 @@ import { ALL_ADAPTERS, clientContext, probeTimeout } from "./install-command.mjs
15
15
  import { describePresence } from "./main.mjs";
16
16
  import { platformPaths } from "./platform-paths.mjs";
17
17
  import { noticeUpdate } from "./update-check.mjs";
18
+ import { managedUpdateDiagnostic } from "./managed-runtime/diagnostics.mjs";
18
19
  import { readControl } from "./managed-runtime/state.mjs";
19
20
  import { diagnoseFilesystemStore, repairFilesystemStore }
20
21
  from "@agents-can-communicate/storage-filesystem";
@@ -207,9 +208,7 @@ export async function runDoctor({ options, context, runtime }) {
207
208
  const update = manager === null
208
209
  ? await noticeUpdate({ dataHome, running, env: runtime?.env ?? {},
209
210
  now: Date.parse(clock.now()), get: runtime?.fetch, io: { readFile, writeFile, mkdir } })
210
- : { checked: false, running, latest: manager.pending?.version ?? null,
211
- newer: manager.pending !== null, auto: manager.auto, pin: manager.pin,
212
- pending: manager.pending?.version ?? null, phase: manager.phase, notice: manager.notice };
211
+ : await managedUpdateDiagnostic(runtime.managerRoot, manager, running);
213
212
 
214
213
  // Before the store is read for anything else. `collectStatus` reads every
215
214
  // record, so on the store this command exists to describe it threw first and
@@ -233,7 +232,13 @@ export async function runDoctor({ options, context, runtime }) {
233
232
  const service = context.service ?? await context.openService();
234
233
  const status = await service.collectStatus({});
235
234
  updateNativeRuntime(adapters, status.deliveryBindings);
236
- await updateNativeSessions(adapters, { service, status, root, now: clock.now() });
235
+ // The runtime state above is read from the binding records alone. The
236
+ // session pass then asks each adapter whether the receiver those records
237
+ // name can still take a push, and downgrades anything that only looked
238
+ // active. It needs the adapter objects to ask, which the diagnostic entries
239
+ // do not carry.
240
+ await updateNativeSessions(adapters, { service, status, root, now: clock.now(),
241
+ registry: ALL_ADAPTERS() });
237
242
  for (const adapter of adapters) {
238
243
  adapter.remediation.push(...nativeRemediation(adapter));
239
244
  }
@@ -276,7 +281,7 @@ export async function runDoctor({ options, context, runtime }) {
276
281
  ...adapters.filter(adapter => adapter.present && adapter.outgoingDelivery)
277
282
  .map(adapter => ` ${adapter.displayName} ${adapter.outgoingDelivery.diagnostic}`),
278
283
  ...(manager === null ? [] : [` automatic updates ${manager.auto ? "on" : "off"}; ACC ${manager.active.version}`
279
- + (manager.pin ? `; pinned to ${manager.pin}` : ""), ...(manager.notice ? [` ${manager.notice}`] : [])]),
284
+ + (manager.pin ? `; pinned to ${manager.pin}` : ""), ...(update.notice ? [` ${update.notice}`] : [])]),
280
285
  ...data.remediation.map(line => ` ${line}`),
281
286
  // `0 of 4` is a true line that reads as a broken machine, and on an
282
287
  // MCP-only one it would read that way on every run forever. The server needs
@@ -106,6 +106,8 @@ const NOTES = Object.freeze({
106
106
  "Use acc update --check to only report whether a newer release exists.",
107
107
  "An initial acc install enables automatic updates; reinstalling preserves your choice. Activation waits for active clients and ACC processes.",
108
108
  "--auto on|off changes automatic updates; --pin VERSION holds an exact stable version; --pin none follows stable releases.",
109
+ "An eligible Codex service restart asks once; a detached worker waits for idle work and verifies completion. Open clients disconnect; acc doctor reports progress.",
110
+ "--yes explicitly consents to that restart without a prompt; --check never schedules maintenance.",
109
111
  "--apply remains accepted as a compatibility alias for acc update."],
110
112
  });
111
113
 
@@ -9,3 +9,8 @@ export { discoverWorkspace } from "./workspace-discovery.mjs";
9
9
  export { createGitProbe, hermeticEnv } from "./git-probe.mjs";
10
10
  export { platformDataHome, runtimePaths } from "./runtime-paths.mjs";
11
11
  export { platformPaths } from "./platform-paths.mjs";
12
+ // A pin names the generation a session started with, kept beside its binding
13
+ // so a hook can be made to keep loading that generation once a newer one
14
+ // activates.
15
+ export { clearPin, hookEntrypointFor, readPin, reapPins, resolvePinnedGeneration, writePin }
16
+ from "./managed-runtime/pins.mjs";
@@ -18,6 +18,7 @@ import { runUpdateCommand } from "./update-command.mjs";
18
18
  import { runConfigCommand } from "./config-command.mjs";
19
19
  import { runInstallCommand } from "./install-command.mjs";
20
20
  import { runDoctor } from "./doctor-command.mjs";
21
+ import { runManagementDoctor } from "./managed-runtime/diagnostics.mjs";
21
22
  import { createGitProbe } from "./git-probe.mjs";
22
23
  import { canonicalClaim } from "./claim-spelling.mjs";
23
24
  import { platformDataHome, runtimePaths } from "./runtime-paths.mjs";
@@ -412,7 +413,8 @@ export async function main(argv, runtime) {
412
413
  let parsed;
413
414
  try {
414
415
  parsed = parseArgs(argv);
415
- if (runtime.managementOnly && !["help", "version", "update"].includes(parsed.command)) {
416
+ const managementDoctor = runtime.managementOnly && parsed.command === "doctor" && !parsed.options.repair;
417
+ if (runtime.managementOnly && !managementDoctor && !["help", "version", "update"].includes(parsed.command)) {
416
418
  throw new AccError(EXIT.DATA, "runtime unavailable; retry after the update or run acc update to recover");
417
419
  }
418
420
  // `config` is the one command that must work on a workspace ACC cannot
@@ -423,7 +425,7 @@ export async function main(argv, runtime) {
423
425
  // discovery cannot open the workspace - that is what a user is trying to
424
426
  // find out - install touches client configuration rather than ACC state,
425
427
  // and `help` has to answer in a directory that is no workspace at all.
426
- const context = NO_WORKSPACE.includes(parsed.command)
428
+ const context = managementDoctor || NO_WORKSPACE.includes(parsed.command)
427
429
  ? null
428
430
  : parsed.command === "doctor"
429
431
  ? await openDiagnosticContext(parsed.options, runtime)
@@ -434,7 +436,7 @@ export async function main(argv, runtime) {
434
436
  const options = context === null ? parsed.options
435
437
  : await resolveOwner({ command: parsed.command, options: parsed.options,
436
438
  context, env: runtime.env });
437
- const { data, text, error: outcome } = await HANDLERS[parsed.command](
439
+ const { data, text, error: outcome } = await (managementDoctor ? runManagementDoctor : HANDLERS[parsed.command])(
438
440
  { options, context, runtime });
439
441
  // A handler may have done real work and still failed: `acc install` writes
440
442
  // for the clients it could and reports the one it could not. The data is
@@ -1,12 +1,22 @@
1
1
  import { createHash } from "node:crypto";
2
- import { readdir } from "node:fs/promises";
2
+ import { readdir, rm } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { assertPortableId } from "@agents-can-communicate/protocol";
6
6
  import { readSessionRecord } from "@agents-can-communicate/storage-filesystem";
7
7
  import { listRuntimeHolds } from "./leases.mjs";
8
8
  import { confirmedDead, defaultPidIsAlive, withManagerLock } from "./mutex.mjs";
9
- import { canonicalManagerRoot, managedDirectory, readControl, readManagedJson, writeControl } from "./state.mjs";
9
+ import { reapPins } from "./pins.mjs";
10
+ import { reapStagingHolds } from "./staging.mjs";
11
+ import { canonicalManagerRoot, managedDirectory, readControl, readManagedJson, syncDirectory, writeControl } from "./state.mjs";
12
+
13
+ /** The staged generation states its own contract in its manifest, so the
14
+ * manager never imports code from a generation it has not activated. */
15
+ export async function declaredStoreVersion(generationRoot) {
16
+ const manifest = await readManagedJson(path.join(generationRoot, "package.json")).catch(() => null);
17
+ return Number.isSafeInteger(manifest?.accStoreVersion) && manifest.accStoreVersion > 0
18
+ ? manifest.accStoreVersion : null;
19
+ }
10
20
 
11
21
  // Both 0.3.1 and current MCP write this exact continuity record, without
12
22
  // native client facts. A foreign harness id may look like an MCP key: require
@@ -47,17 +57,208 @@ export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive } =
47
57
  const pid = record?.schemaVersion === 1 && Number.isSafeInteger(record.clientPid)
48
58
  && record.clientPid > 0 ? record.clientPid : null;
49
59
  if (pid !== null && await confirmedDead(pid, pidIsAlive)) continue;
50
- holds.push({ kind: "native", pid, reason: pid === null ? "unknown_client_pid" : "client_running" });
60
+ holds.push({ kind: "native", pid, reason: pid === null ? "unknown_client_pid" : "client_running",
61
+ storeVersion: Number.isSafeInteger(record?.storeVersion) ? record.storeVersion : null });
51
62
  }
52
63
  }
53
64
  return holds;
54
65
  }
55
66
 
67
+ /** Unknown on either side cannot be compared, and an uncomparable contract
68
+ * stays a hold. Equal contracts coexist by the store's own rule. */
69
+ export const blocksActivation = (hold, incomingStoreVersion) =>
70
+ !Number.isSafeInteger(incomingStoreVersion) || !Number.isSafeInteger(hold.storeVersion)
71
+ || hold.storeVersion !== incomingStoreVersion;
72
+
73
+ /** Activation calls this under the admission mutex; diagnostics are a snapshot.
74
+ * The updater may ignore its own leases, never a native client's lifetime.
75
+ * Project only process facts: leases and bindings carry private owner tokens.
76
+ * A hold blocks only when its declared contract differs from the incoming
77
+ * generation's, or when either side declares none.
78
+ */
79
+ export async function listActivationBlockers(root, { pidIsAlive = defaultPidIsAlive,
80
+ ignorePid = null, incomingStoreVersion = null } = {}) {
81
+ const leases = (await listRuntimeHolds(root, { pidIsAlive })).filter(lease => lease.pid !== ignorePid);
82
+ const native = await listNativeHolds(root, { pidIsAlive });
83
+ const holds = [
84
+ ...leases.map(({ pid, kind, runtime }) => ({ pid, kind, nativeBindings: 0,
85
+ storeVersion: runtime?.storeVersion ?? null })),
86
+ ...native.map(hold => ({ ...hold, nativeBindings: hold.reason === "unknown_workspace" ? 0 : 1,
87
+ storeVersion: hold.storeVersion ?? null })),
88
+ ];
89
+ const byPid = new Map(), unidentified = [];
90
+ for (const hold of holds) {
91
+ const pid = hold.pid ?? null;
92
+ const nativeBindings = hold.nativeBindings;
93
+ if (pid === null) {
94
+ unidentified.push({ pid, kinds: [hold.kind], reason: hold.reason, nativeBindings,
95
+ storeVersion: hold.storeVersion });
96
+ continue;
97
+ }
98
+ const blocker = byPid.get(pid) ?? { pid, kinds: [], reason: "process_running",
99
+ nativeBindings: 0, storeVersion: hold.storeVersion };
100
+ if (!blocker.kinds.includes(hold.kind)) blocker.kinds.push(hold.kind);
101
+ blocker.nativeBindings += nativeBindings;
102
+ // One process can publish several holds. Only an unbroken run of holds
103
+ // declaring the same contract may keep it for the whole process; an
104
+ // unknown contract, or one that differs from what has already
105
+ // accumulated, collapses the process to uncomparable for good.
106
+ if (!Number.isSafeInteger(hold.storeVersion) || hold.storeVersion !== blocker.storeVersion) {
107
+ blocker.storeVersion = null;
108
+ }
109
+ byPid.set(pid, blocker);
110
+ }
111
+ return [...byPid.values()].sort((a, b) => a.pid - b.pid)
112
+ .map(blocker => ({ ...blocker, kinds: blocker.kinds.sort() })).concat(unidentified)
113
+ .filter(blocker => blocksActivation(blocker, incomingStoreVersion));
114
+ }
115
+
116
+ export function activationBlockerNotice(version, blockers) {
117
+ if (!blockers.length) return `ACC ${version} is ready; no active or unidentified processes are blocking activation.`;
118
+ const details = blockers.slice(0, 10).map(blocker => {
119
+ const facts = [blocker.kinds.map(safeText).join(", ")];
120
+ if (blocker.nativeBindings) facts.push(`${blocker.nativeBindings} native binding${blocker.nativeBindings === 1 ? "" : "s"}`);
121
+ facts.push(Number.isSafeInteger(blocker.storeVersion)
122
+ ? `store contract ${blocker.storeVersion}` : "contract unknown");
123
+ if (blocker.pid === null) facts.push(blocker.reason.replaceAll("_", " "));
124
+ return `${blocker.pid === null ? "unidentified process" : `PID ${blocker.pid}`} (${facts.join("; ")})`;
125
+ });
126
+ return `ACC ${version} is ready; waiting for ${blockers.length} active or unidentified process(es): `
127
+ + details.join("; ") + (blockers.length > 10 ? `; and ${blockers.length - 10} more` : "") + ".";
128
+ }
129
+
130
+ /** A candidate reference that is simply absent (no pending update, no
131
+ * explicit active override) is normal. One that is present but cannot be
132
+ * resolved to a real filesystem path is unknown - possibly corrupt data -
133
+ * and must postpone the whole pass rather than be silently ignored. Every
134
+ * candidate is resolved the same way a `generations/<name>` entry is, so a
135
+ * symlinked data home can never make a live reference look unrelated to the
136
+ * directory it actually names.
137
+ */
138
+ async function resolveCandidate(value) {
139
+ if (value === null || value === undefined) return { ok: true, root: null };
140
+ if (typeof value !== "string" || value === "") return { ok: false };
141
+ try { return { ok: true, root: await canonicalManagerRoot(value) }; }
142
+ catch { return { ok: false }; }
143
+ }
144
+
145
+ /** A generation is reclaimable only when nothing can still import from it:
146
+ * not the active or pending control pointer, not a live runtime lease, not a
147
+ * live session pin, and not a live staging hold for a generation still being
148
+ * verified before publication. An unidentified holder - an unreadable lease,
149
+ * a pin or staging hold that is missing, corrupt, or declares an
150
+ * unresolvable root, or the absence of control.json itself - postpones the
151
+ * whole pass rather than being assumed dead, matching the rule admission
152
+ * already applies to its own bookkeeping.
153
+ *
154
+ * Takes its own manager lock, so a caller already holding one (activation)
155
+ * must call this only after releasing it.
156
+ */
157
+ export async function reclaimGenerations({ root, active = null, pidIsAlive = defaultPidIsAlive } = {}) {
158
+ root = await canonicalManagerRoot(root);
159
+ return withManagerLock(root, async () => {
160
+ const generations = path.join(root, "generations");
161
+ if (!await managedDirectory(generations)) return { removed: [] };
162
+ // Candidates are captured before any holder is read, not after. A hold
163
+ // (or a control pointer) that lands later either names a directory this
164
+ // snapshot never saw - so it was never a deletion candidate regardless -
165
+ // or was already durable before this snapshot was taken, which, since
166
+ // every writer here writes its hold before the rename that *creates*
167
+ // the directory, guarantees it is already visible to every holder read
168
+ // below. Reading candidates last is exactly the window that left a
169
+ // staged-but-unpublished generation deletable; reading them first closes
170
+ // it for good, not just for the writer order this codebase happens to use.
171
+ //
172
+ // That guarantee is specifically about a directory the rename path
173
+ // creates. stageOwnGeneration's fast path (existingMatches) instead
174
+ // *adopts* a directory that already existed - from an earlier run,
175
+ // possibly one whose own hold is long gone - and only then writes this
176
+ // attempt's own hold for it. Such a directory can already be in this
177
+ // snapshot with no hold covering it yet, so a reclaim pass whose staging
178
+ // read happens to run before that adopting hold lands can still remove
179
+ // it. This is bounded, not open-ended: it can only affect a generation
180
+ // that is at that moment unreferenced by control, lease, and pin alike -
181
+ // the same shape as any other orphan - and it predates this reordering
182
+ // fix rather than being introduced by it.
183
+ const candidates = await readdir(generations, { withFileTypes: true });
184
+ const control = await readControl(root);
185
+ // No control at all is exactly as unknown as a corrupt one - which
186
+ // readControl already throws on, and every real caller of this function
187
+ // treats that throw as reason to postpone. Match that here explicitly:
188
+ // an absent control.json is not proof there is nothing to protect.
189
+ if (control === null) return { removed: [] };
190
+ const referenced = new Set();
191
+ for (const candidate of [active, control.active.root, control.pending?.root]) {
192
+ const resolved = await resolveCandidate(candidate);
193
+ if (!resolved.ok) return { removed: [] };
194
+ if (resolved.root) referenced.add(resolved.root);
195
+ }
196
+ let holds;
197
+ try { holds = await listRuntimeHolds(root, { pidIsAlive }); }
198
+ catch { return { removed: [] }; } // An unreadable lease is an unknown holder.
199
+ for (const lease of holds) {
200
+ const resolved = await resolveCandidate(lease.runtime?.root);
201
+ if (!resolved.ok) return { removed: [] };
202
+ if (resolved.root) referenced.add(resolved.root);
203
+ }
204
+ // A client that exits without a clean session end leaves its pin behind;
205
+ // reap confirmed-dead ones before treating every remaining pin as live,
206
+ // the same way admission already reaps confirmed-dead leases.
207
+ await reapPins({ root, pidIsAlive });
208
+ const pins = path.join(root, "pins");
209
+ if (await managedDirectory(pins)) {
210
+ for (const name of await readdir(pins)) {
211
+ if (!name.endsWith(".json")) continue;
212
+ const record = await readManagedJson(path.join(pins, name)).catch(() => null);
213
+ if (record === undefined) continue; // Concurrent pin removal, not a holder.
214
+ if (!record || record.schemaVersion !== 1 || typeof record.runtimeRoot !== "string"
215
+ || record.runtimeRoot === "") return { removed: [] }; // Malformed pin: unknown holder.
216
+ const resolved = await resolveCandidate(record.runtimeRoot);
217
+ if (!resolved.ok) return { removed: [] };
218
+ referenced.add(resolved.root);
219
+ }
220
+ }
221
+ // Staging renames a fully-formed generation into place, then runs it as a
222
+ // spawned process to verify it, before any control pointer, lease, or pin
223
+ // can reference it. A live hold names exactly the generation that step
224
+ // is building; a staging process that exited without publishing leaves
225
+ // its hold behind, reaped the same way an abandoned pin is.
226
+ await reapStagingHolds({ root, pidIsAlive });
227
+ const staging = path.join(root, "staging");
228
+ if (await managedDirectory(staging)) {
229
+ for (const name of await readdir(staging)) {
230
+ if (!name.endsWith(".json")) continue;
231
+ const record = await readManagedJson(path.join(staging, name)).catch(() => null);
232
+ if (record === undefined) continue; // Concurrent hold release, not a holder.
233
+ if (!record || record.schemaVersion !== 1 || typeof record.generationRoot !== "string"
234
+ || record.generationRoot === "") return { removed: [] }; // Malformed hold: unknown holder.
235
+ const resolved = await resolveCandidate(record.generationRoot);
236
+ if (!resolved.ok) return { removed: [] };
237
+ referenced.add(resolved.root);
238
+ }
239
+ }
240
+ const removed = [];
241
+ for (const entry of candidates) {
242
+ // A dot-prefixed entry is always an in-flight staging temp (mkdtemp's
243
+ // own naming), never a published generation. Its own creator cleans it
244
+ // up; guessing whether it is still being written into is not this
245
+ // function's job, and deleting one mid-write would corrupt that write.
246
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
247
+ const directory = path.join(generations, entry.name);
248
+ if (referenced.has(directory)) continue;
249
+ await rm(directory, { recursive: true, force: true });
250
+ removed.push(entry.name);
251
+ }
252
+ if (removed.length) await syncDirectory(generations);
253
+ return { removed };
254
+ }, { pidIsAlive });
255
+ }
256
+
56
257
  async function prepareCandidate(control, root, env) {
57
258
  const file = path.join(control.pending.root, "node_modules", "@agents-can-communicate", "cli",
58
259
  "src", "managed-runtime", "refresh.mjs");
59
260
  const { prepareRefresh } = await import(pathToFileURL(file).href);
60
- return prepareRefresh({ control, root, env });
261
+ return prepareRefresh({ control, root, env, callerProtocol: 2 });
61
262
  }
62
263
  // JSON parser diagnostics may quote private config bytes. Keep the cause and
63
264
  // known artifact paths, never the offending input or terminal control bytes.
@@ -71,20 +272,22 @@ const recipe = c => JSON.stringify([c.active, c.pending, c.home, c.targets, c.au
71
272
 
72
273
  /** Detection is outside the fence; every integration write and commit stays inside it. */
73
274
  export async function activatePending(root, { prepare = prepareCandidate, env = process.env,
74
- pidIsAlive = defaultPidIsAlive, ignorePid = null } = {}) {
275
+ pidIsAlive = defaultPidIsAlive, ignorePid = null, beforeActivation = null } = {}) {
75
276
  root = await canonicalManagerRoot(root);
76
277
  const before = await readControl(root);
77
278
  if (!before?.pending) return { activated: false, reason: "no_pending_update" };
78
279
  const apply = await prepare(before, root, env);
79
- return withManagerLock(root, async () => {
280
+ let activatedRoot = null;
281
+ const outcome = await withManagerLock(root, async () => {
80
282
  const current = await readControl(root);
81
283
  if (!current || recipe(current) !== recipe(before)) return { activated: false, reason: "state_changed" };
82
- const holds = (await listRuntimeHolds(root, { pidIsAlive })).filter(lease => lease.pid !== ignorePid);
83
- holds.push(...await listNativeHolds(root, { pidIsAlive }));
84
- if (holds.length) {
85
- const notice = `ACC ${current.pending.version} is ready; waiting for ${holds.length} active or unidentified process(es).`;
284
+ if (beforeActivation) await beforeActivation(current);
285
+ const blockers = await listActivationBlockers(root, { pidIsAlive, ignorePid,
286
+ incomingStoreVersion: current.pending.storeVersion });
287
+ if (blockers.length) {
288
+ const notice = activationBlockerNotice(current.pending.version, blockers);
86
289
  await writeControl(root, { ...current, notice });
87
- return { activated: false, reason: "processes_active", holds: holds.length, notice };
290
+ return { activated: false, reason: "processes_active", holds: blockers.length, blockers, notice };
88
291
  }
89
292
  const activating = { ...current, phase: "activating", notice: "Finishing integration refresh." };
90
293
  await writeControl(root, activating);
@@ -105,6 +308,17 @@ export async function activatePending(root, { prepare = prepareCandidate, env =
105
308
  }
106
309
  await writeControl(root, { ...activating, active: current.pending, pending: null,
107
310
  phase: "ready", notice: `ACC ${current.pending.version} is active.` });
311
+ activatedRoot = current.pending.root;
108
312
  return { activated: true, version: current.pending.version, installation: result };
109
313
  });
314
+ // Reclaim only after the fence has released and the new pointer is durable:
315
+ // reclaimGenerations takes its own manager lock, so calling it while still
316
+ // inside this one would deadlock, and a reclaim failure here must never
317
+ // turn an already-completed activation into a reported failure - it can
318
+ // always run again on the next activation.
319
+ if (outcome.activated) {
320
+ try { await reclaimGenerations({ root, active: activatedRoot, pidIsAlive }); }
321
+ catch { /* Best effort; never fails an activation that already succeeded. */ }
322
+ }
323
+ return outcome;
110
324
  }
@@ -4,8 +4,13 @@ import { withManagerLock } from "./mutex.mjs";
4
4
  import { scheduleWorker } from "./schedule.mjs";
5
5
  import { readControl, writeControl } from "./state.mjs";
6
6
  import { performUpdate, runWorker } from "./worker.mjs";
7
+ import { requestMaintenance } from "./maintenance.mjs";
8
+ import { retireLegacyUpdateWorker, stageNewerManagementRuntime } from "./management-recovery.mjs";
7
9
 
8
10
  export function validateUpdateOptions(options) {
11
+ if (options.yes && (options.check || options.auto !== undefined || options.pin !== undefined)) {
12
+ throw new AccError(EXIT.USAGE, "--yes only consents to client maintenance during acc update");
13
+ }
9
14
  if (options.auto !== undefined && !["on", "off"].includes(options.auto)) {
10
15
  throw new AccError(EXIT.USAGE, "--auto expects on or off");
11
16
  }
@@ -40,9 +45,15 @@ export async function runManagedUpdate({ options, runtime }) {
40
45
  return { data: { auto: control.auto, pin: control.pin, running: control.active.version },
41
46
  text: `Automatic updates ${control.auto ? "on" : "off"}. ${control.pin ? `Pinned to ${control.pin}.` : "Following stable releases."}` };
42
47
  }
48
+ if (!options.check) await stageNewerManagementRuntime(root, runtime);
43
49
  const result = options.check
44
50
  ? await performUpdate(root, { check: true, env })
45
51
  : await runWorker(root, { force: true, env, ignorePid: process.pid });
52
+ if (!options.check && (result.reason === "processes_active" || result.reason === "maintenance_pending"
53
+ || result.activated || result.checked && !result.newer)) {
54
+ const maintenance = await requestMaintenance({ root, control: await readControl(root), options, runtime });
55
+ if (maintenance) return maintenance;
56
+ }
46
57
  const text = result.activated ? `Updated ACC to ${result.version}; integrations refreshed.`
47
58
  : result.notice && result.reason === "processes_active" ? result.notice
48
59
  : result.reason === "refresh_failed" ? [result.notice,
@@ -57,6 +68,10 @@ export async function runManagedUpdate({ options, runtime }) {
57
68
  ...(result.reason === "refresh_failed" ? { error: new AccError(EXIT.DATA, text) } : {}) };
58
69
  } catch (error) {
59
70
  if (/manager lock held/.test(error.message)) {
71
+ if (!options.check && options.auto === undefined && options.pin === undefined && !runtime.legacyWorkerRetried
72
+ && await retireLegacyUpdateWorker(root).catch(() => false)) {
73
+ return runManagedUpdate({ options, runtime: { ...runtime, legacyWorkerRetried: true } });
74
+ }
60
75
  return { data: { inProgress: true }, text: "An update is already in progress; ACC will activate it when running clients exit." };
61
76
  }
62
77
  throw error instanceof AccError ? error : new AccError(EXIT.DATA, error.message);
@@ -0,0 +1,36 @@
1
+ import { activationBlockerNotice, listActivationBlockers } from "./activation.mjs";
2
+ import { MAINTENANCE_ACTIVE, maintenanceNotice, maintenanceReport, readMaintenance } from "./maintenance-state.mjs";
3
+ import { readControl } from "./state.mjs";
4
+
5
+ /** Available when workspace admission is fenced, and from a newer management
6
+ * implementation before activation. Never opens a workspace or probes clients.
7
+ */
8
+ export async function runManagementDoctor({ runtime }) {
9
+ const manager = await readControl(runtime.managerRoot);
10
+ if (!manager) throw new Error("managed update state is unavailable; run acc update to recover");
11
+ const update = await managedUpdateDiagnostic(runtime.managerRoot, manager, manager.active.version);
12
+ return { data: { scope: "update", workspaceInspection: "unavailable_during_update", update,
13
+ remediation: ["acc update"] },
14
+ text: ["Update diagnostics only; workspace and integrations were not inspected.",
15
+ update.notice ?? `ACC ${manager.active.version}; update phase ${manager.phase}.`,
16
+ "Run acc update to finish, then acc doctor for the full workspace report."].join("\n") };
17
+ }
18
+
19
+ export async function managedUpdateDiagnostic(root, manager, running) {
20
+ const blockers = manager.pending ? await listActivationBlockers(root, { ignorePid: process.pid,
21
+ incomingStoreVersion: manager.pending.storeVersion }) : null;
22
+ const update = { checked: false, running, latest: manager.pending?.version ?? null,
23
+ newer: manager.pending !== null, auto: manager.auto, pin: manager.pin,
24
+ pending: manager.pending?.version ?? null, phase: manager.phase,
25
+ notice: blockers === null ? manager.notice : manager.phase === "activating" && !blockers.length
26
+ ? "Integration refresh is incomplete; run acc update to recover."
27
+ : activationBlockerNotice(manager.pending.version, blockers),
28
+ ...(blockers === null ? {} : { holds: blockers.length, blockers }) };
29
+ const maintenance = await readMaintenance(root);
30
+ if (maintenance && (maintenance.target.root === (manager.pending ?? manager.active).root
31
+ || MAINTENANCE_ACTIVE.includes(maintenance.status))) {
32
+ update.maintenance = maintenanceReport(maintenance);
33
+ update.notice = maintenanceNotice(maintenance);
34
+ }
35
+ return update;
36
+ }