agents-can-communicate 0.9.1 → 0.10.1

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 +46 -44
  2. package/bin/acc-update-worker.mjs +2 -1
  3. package/bin/entrypoints/hook-output.mjs +2 -0
  4. package/docs/ARCHITECTURE.md +26 -2
  5. package/docs/CLI.md +11 -1
  6. package/docs/GETTING_STARTED.md +4 -3
  7. package/docs/UPGRADING.md +62 -3
  8. package/docs/WHY_ACC.md +32 -9
  9. package/docs/index.md +7 -5
  10. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
  11. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  12. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  13. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +3 -1
  14. package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +12 -0
  15. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  16. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +2 -1
  17. package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +11 -0
  18. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  19. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  20. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  21. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  22. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  23. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +6 -0
  24. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  25. package/node_modules/@agents-can-communicate/cli/src/args.mjs +4 -1
  26. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +14 -0
  27. package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -0
  28. package/node_modules/@agents-can-communicate/cli/src/main.mjs +12 -3
  29. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +97 -10
  30. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +1 -1
  31. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/portable-fs.mjs +41 -20
  32. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +4 -0
  33. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +17 -1
  34. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/store-upgrade.mjs +105 -0
  35. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +5 -0
  36. package/node_modules/@agents-can-communicate/cli/src/store-migration-command.mjs +39 -0
  37. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  38. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +21 -52
  39. package/node_modules/@agents-can-communicate/core/src/decision-changes.mjs +16 -11
  40. package/node_modules/@agents-can-communicate/core/src/inbox.mjs +12 -4
  41. package/node_modules/@agents-can-communicate/core/src/message-retry.mjs +43 -0
  42. package/node_modules/@agents-can-communicate/core/src/ports.mjs +12 -1
  43. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +20 -6
  44. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  45. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  46. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +23 -3
  47. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  48. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  49. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  50. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +1 -0
  51. package/node_modules/@agents-can-communicate/protocol/src/transaction-indexes.mjs +22 -0
  52. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  53. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +22 -10
  54. package/node_modules/@agents-can-communicate/storage-filesystem/src/index-cache.mjs +144 -0
  55. package/node_modules/@agents-can-communicate/storage-filesystem/src/index-io.mjs +65 -0
  56. package/node_modules/@agents-can-communicate/storage-filesystem/src/index-pages.mjs +85 -0
  57. package/node_modules/@agents-can-communicate/storage-filesystem/src/index-reclaim-progress.mjs +35 -0
  58. package/node_modules/@agents-can-communicate/storage-filesystem/src/index-reclaim.mjs +142 -0
  59. package/node_modules/@agents-can-communicate/storage-filesystem/src/index-tree.mjs +142 -0
  60. package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +1 -0
  61. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +2 -0
  62. package/node_modules/@agents-can-communicate/storage-filesystem/src/portable-fs.mjs +41 -20
  63. package/node_modules/@agents-can-communicate/storage-filesystem/src/prune-durability.mjs +29 -0
  64. package/node_modules/@agents-can-communicate/storage-filesystem/src/reclaim.mjs +13 -3
  65. package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +9 -3
  66. package/node_modules/@agents-can-communicate/storage-filesystem/src/session-read.mjs +6 -4
  67. package/node_modules/@agents-can-communicate/storage-filesystem/src/state-reads.mjs +43 -0
  68. package/node_modules/@agents-can-communicate/storage-filesystem/src/store-migration.mjs +56 -0
  69. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +61 -110
  70. package/node_modules/@agents-can-communicate/storage-filesystem/src/transaction-view.mjs +131 -0
  71. package/package.json +5 -4
@@ -39,7 +39,7 @@ export const COMMANDS = Object.freeze({
39
39
  "client-message-id"],
40
40
  repeated: ["completed", "remaining", "blocker"] },
41
41
  status: { required: [], optional: ["session", "generation", "participant"], flags: ["all"] },
42
- doctor: { required: [], optional: ["home"], flags: ["repair"] },
42
+ doctor: { required: [], optional: ["home"], flags: ["repair", "migrate-store"] },
43
43
  // `--apply` rather than `--dry-run`: this is the one command that takes
44
44
  // records out of the store, so the reporting run is what happens by
45
45
  // default and meaning it is what has to be said out loud.
@@ -188,6 +188,9 @@ export function parseArgs(argv) {
188
188
  usage(`${command} requires --${name}`, { command, option: name });
189
189
  }
190
190
  }
191
+ if (command === "doctor" && options.repair && options.migrateStore) {
192
+ usage("doctor --repair and --migrate-store are separate operations");
193
+ }
191
194
  return { command, options: subcommand === undefined ? options : { ...options, subcommand } };
192
195
  }
193
196
 
@@ -1,4 +1,7 @@
1
1
  import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ // Kept above 300 lines so store, installation and native-session findings feed
3
+ // one report before service reads. Splitting the diagnostic assembly risks
4
+ // opening an unreadable store before its failure has been reported.
2
5
  import { homedir } from "node:os";
3
6
  import path from "node:path";
4
7
 
@@ -274,6 +277,16 @@ export async function runDoctor({ options, context, runtime }) {
274
277
  // took the diagnosis with it: one truncated file and `acc doctor` answered
275
278
  // "invalid JSON record", naming nothing, while `inspect` had already found
276
279
  // the file and put it in a list nobody ever saw.
280
+ // A contract-6 store is not ambiguous: its identity read and named its
281
+ // version. Before this, doctor called `protocol.json` unreadable and named no
282
+ // migration, on the one store a user just updated into (2026-10-06).
283
+ if (report.migrationRequired === true) {
284
+ throw new AccError(EXIT.DATA, "this workspace's store uses an older contract; ACC migrates it "
285
+ + "by itself once no older ACC client runs. To migrate it now, stop older ACC clients and run "
286
+ + "acc doctor --migrate-store", { workspaceId: context.descriptor.id, source: context.descriptor.source,
287
+ runtimeRoot: root, reasonCode: "store_migration_required", adapters,
288
+ remediation: ["acc doctor --migrate-store"] });
289
+ }
277
290
  if (!report.healthy) {
278
291
  const broken = [...report.blocked, ...report.corrupt];
279
292
  throw new AccError(EXIT.DATA,
@@ -311,6 +324,7 @@ export async function runDoctor({ options, context, runtime }) {
311
324
  materialised: status.materialised,
312
325
  protection: status.protection,
313
326
  store: report,
327
+ indexDiagnostics: service.store?.indexDiagnostics?.() ?? [],
314
328
  // Capabilities are reported from what is actually installed, never assumed.
315
329
  adapters,
316
330
  update,
@@ -77,6 +77,8 @@ const CHOICES = Object.freeze({
77
77
  });
78
78
 
79
79
  const NOTES = Object.freeze({
80
+ doctor: ["--repair completes recoverable writes without changing the store contract.",
81
+ "--migrate-store explicitly changes contract 6 to 7 after old clients and unmanaged store users stop; it cannot be combined with --repair."],
80
82
  install: ["Without --delivery, one default-No question covers all selected clients that need a decision.",
81
83
  "--delivery actionable|all gives explicit complete-setup consent; --delivery off disables incoming automatic requests.",
82
84
  "A dry run does not prompt, write configuration, or start a service. Service readiness does not establish a session binding."],
@@ -20,6 +20,7 @@ import { runConfigCommand } from "./config-command.mjs";
20
20
  import { ALL_ADAPTERS, runInstallCommand } from "./install-command.mjs";
21
21
  import { runDoctor } from "./doctor-command.mjs";
22
22
  import { runManagementDoctor } from "./managed-runtime/diagnostics.mjs";
23
+ import { runStoreMigration } from "./store-migration-command.mjs";
23
24
  import { createGitProbe } from "./git-probe.mjs";
24
25
  import { canonicalClaim } from "./claim-spelling.mjs";
25
26
  import { platformDataHome, runtimePaths } from "./runtime-paths.mjs";
@@ -480,7 +481,7 @@ const HANDLERS = Object.freeze({
480
481
  return { data: result, text };
481
482
  },
482
483
 
483
- doctor: async ({ options, context, runtime }) => runDoctor({ options, context, runtime }),
484
+ doctor: async input => input.options.migrateStore ? runStoreMigration(input) : runDoctor(input),
484
485
 
485
486
  update: async ({ options, runtime }) => runUpdateCommand({ options, runtime }),
486
487
 
@@ -518,8 +519,11 @@ export async function main(argv, runtime) {
518
519
  let parsed;
519
520
  try {
520
521
  parsed = parseArgs(argv);
521
- const managementDoctor = runtime.managementOnly && parsed.command === "doctor" && !parsed.options.repair;
522
- if (runtime.managementOnly && !managementDoctor && !["help", "version", "update"].includes(parsed.command)) {
522
+ const managementMigration = runtime.managementOnly && parsed.command === "doctor" && parsed.options.migrateStore;
523
+ const managementDoctor = runtime.managementOnly && parsed.command === "doctor"
524
+ && !parsed.options.repair && !parsed.options.migrateStore;
525
+ if (runtime.managementOnly && !managementDoctor && !managementMigration
526
+ && !["help", "version", "update"].includes(parsed.command)) {
523
527
  throw new AccError(EXIT.DATA, "runtime unavailable; retry after the update or run acc update to recover");
524
528
  }
525
529
  // `config` is the one command that must work on a workspace ACC cannot
@@ -547,6 +551,11 @@ export async function main(argv, runtime) {
547
551
  // for the clients it could and reports the one it could not. The data is
548
552
  // printed either way, and the command still fails.
549
553
  if (outcome != null) throw Object.assign(outcome, { details: { ...outcome.details, ...data } });
554
+ for (const diagnostic of context?.service?.store?.indexDiagnostics?.() ?? []) {
555
+ if (diagnostic.code === "index_unavailable") {
556
+ await write(runtime.stderr, `acc: transaction index unavailable (${diagnostic.reason}); the next indexed read will rebuild it\n`);
557
+ }
558
+ }
550
559
  // Machine mode writes exactly one JSON object to stdout and nothing else.
551
560
  if (parsed.options.json === true) await write(runtime.stdout, `${JSON.stringify(ok(data))}\n`);
552
561
  else if (text !== "") await write(runtime.stdout, `${human(text)}\n`);
@@ -1,9 +1,13 @@
1
1
  import { createHash } from "node:crypto";
2
- import { readdir } from "node:fs/promises";
2
+ // Kept above 300 lines because activation and generation retirement must use
3
+ // the same validated runtime/native hold definitions under the admission mutex.
4
+ // Splitting their safety rules risks disagreeing about which users are live.
5
+ import { readdir, rm } from "node:fs/promises";
3
6
  import path from "node:path";
4
7
  import { pathToFileURL } from "node:url";
8
+ import { classifySessionPresence } from "@agents-can-communicate/core";
5
9
  import { assertPortableId } from "@agents-can-communicate/protocol";
6
- import { readSessionRecord } from "@agents-can-communicate/storage-filesystem";
10
+ import { readSessionRecord, withWriterMutex } from "@agents-can-communicate/storage-filesystem";
7
11
  import { listRuntimeHolds } from "./leases.mjs";
8
12
  import { confirmedDead, defaultPidIsAlive, withManagerLock } from "./mutex.mjs";
9
13
  import { reapPins } from "./pins.mjs";
@@ -31,33 +35,67 @@ async function isMcpContinuity(record, name, workspaceRoot, workspaceId) {
31
35
  for (const id of [parts[1], parts[2], record.accSessionId, record.generation]) assertPortableId(id, "MCP continuity id");
32
36
  const expected = createHash("sha256").update(record.harnessSessionId).digest("hex").slice(0, 32) + ".json";
33
37
  if (name !== expected) return false;
34
- const owner = await readSessionRecord({ root: workspaceRoot, workspaceId, sessionId: record.accSessionId });
38
+ // This is ownership metadata, never a service open or a write. Supported
39
+ // v6 continuity must remain classifiable before/after migrating another
40
+ // workspace; its actual process lifetime is still fenced by its lease.
41
+ const owner = await readSessionRecord({ root: workspaceRoot, workspaceId,
42
+ sessionId: record.accSessionId, allowLegacyIdentity: true });
35
43
  return owner?.harness === "mcp" && owner.participantId === parts[1]
36
44
  && owner.workspaceId === workspaceId && owner.sessionId === record.accSessionId
37
45
  && owner.generation === record.generation;
38
46
  } catch { return false; } // Ambiguous or corrupt ownership remains a safety hold.
39
47
  }
40
48
 
49
+ const clientPidOf = record => record?.schemaVersion === 1 && Number.isSafeInteger(record.clientPid)
50
+ && record.clientPid > 0 ? record.clientPid : null;
51
+
52
+ /** A binding that names no client process cannot be judged by its own facts;
53
+ * its session record can. It is stale when the record is gone, when the record
54
+ * names a process that has exited, or, with no process recorded, when ACC's own
55
+ * presence rule calls the session offline - closed, or quiet past the floor for
56
+ * an unknown process - the rule retention removes sessions by. A live recorded
57
+ * process, or a record or store that cannot be read, keeps the binding a hold.
58
+ * On 2026-10-06 the maintainer's 0.10.0 waited behind 32 such bindings from
59
+ * 0.5.10 to 0.9.0 that no client could clear (#273). */
60
+ async function staleWithoutPid(workspaceRoot, workspaceId, record, pidIsAlive) {
61
+ if (record?.schemaVersion !== 1) return false;
62
+ let session;
63
+ try {
64
+ session = await readSessionRecord({ root: workspaceRoot, workspaceId,
65
+ sessionId: record.accSessionId, allowLegacyIdentity: true });
66
+ } catch { return false; }
67
+ if (session === null) return true;
68
+ if (Number.isSafeInteger(session.pid) && session.pid > 0) return confirmedDead(session.pid, pidIsAlive);
69
+ // No process to ask, so the probe is never called.
70
+ return classifySessionPresence(session, new Date().toISOString(), () => true) === "offline";
71
+ }
72
+
41
73
  /** Native bindings outlive finish/TTL; MCP process lifetime is covered by its runtime lease. */
42
- export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive } = {}) {
74
+ export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive, strict = false } = {}) {
43
75
  const workspaces = path.join(path.dirname(await canonicalManagerRoot(root)), "workspaces");
44
76
  if (!await managedDirectory(workspaces)) return [];
45
77
  const holds = [];
46
78
  for (const entry of await readdir(workspaces, { withFileTypes: true })) {
47
79
  if (!entry.isDirectory()) {
48
- if (entry.isSymbolicLink()) holds.push({ kind: "native", reason: "unknown_workspace" });
80
+ if (entry.isSymbolicLink()) {
81
+ if (strict) throw new Error("unsafe native workspace directory");
82
+ holds.push({ kind: "native", reason: "unknown_workspace" });
83
+ }
49
84
  continue;
50
85
  }
51
86
  const bindings = path.join(workspaces, entry.name, "bindings");
52
87
  if (!await managedDirectory(bindings)) continue;
53
88
  for (const name of await readdir(bindings)) {
54
89
  if (!name.endsWith(".json")) continue;
55
- const record = await readManagedJson(path.join(bindings, name)).catch(() => null);
90
+ const record = await readManagedJson(path.join(bindings, name)).catch(error => {
91
+ if (strict) throw error;
92
+ return null;
93
+ });
56
94
  if (record === undefined) continue; // Concurrent lifecycle removal, before fencing.
57
95
  if (await isMcpContinuity(record, name, path.dirname(bindings), entry.name)) continue;
58
- const pid = record?.schemaVersion === 1 && Number.isSafeInteger(record.clientPid)
59
- && record.clientPid > 0 ? record.clientPid : null;
96
+ const pid = clientPidOf(record);
60
97
  if (pid !== null && await confirmedDead(pid, pidIsAlive)) continue;
98
+ if (pid === null && await staleWithoutPid(path.dirname(bindings), entry.name, record, pidIsAlive)) continue;
61
99
  holds.push({ kind: "native", pid, reason: pid === null ? "unknown_client_pid" : "client_running",
62
100
  storeVersion: Number.isSafeInteger(record?.storeVersion) ? record.storeVersion : null });
63
101
  }
@@ -65,6 +103,55 @@ export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive } =
65
103
  return holds;
66
104
  }
67
105
 
106
+ // The lock a hook holds while it loads, renews and publishes a binding: the
107
+ // hook runner's session lifecycle mutex, keyed by the harness session id.
108
+ const LIFECYCLE_LOCK_MS = 2_000;
109
+ const lifecycleLocks = (workspaceRoot, harnessSessionId) => ({ locks: path.join(workspaceRoot,
110
+ "lifecycle-locks", createHash("sha256").update(String(harnessSessionId)).digest("hex")) });
111
+
112
+ /** Remove the bindings without a client pid that listNativeHolds no longer
113
+ * counts. An older generation activates its successor with its own rule, which
114
+ * counts every one of them as a live client; it loads the pending generation's
115
+ * refresh before it lists blockers, so that refresh calls this (#273). Each one
116
+ * is judged again and removed under its session's lifecycle lock, so a hook that
117
+ * renews it is either seen or comes after (AI review of #274); a lock that stays
118
+ * busy leaves the binding as it is. */
119
+ export async function sweepStaleBindings(root, { pidIsAlive = defaultPidIsAlive } = {}) {
120
+ const workspaces = path.join(path.dirname(await canonicalManagerRoot(root)), "workspaces");
121
+ if (!await managedDirectory(workspaces)) return 0;
122
+ const clock = { now: () => new Date().toISOString() };
123
+ let removed = 0;
124
+ for (const entry of await readdir(workspaces, { withFileTypes: true })) {
125
+ if (!entry.isDirectory()) continue;
126
+ const workspaceRoot = path.join(workspaces, entry.name);
127
+ const bindings = path.join(workspaceRoot, "bindings");
128
+ if (!await managedDirectory(bindings)) continue;
129
+ let changed = false;
130
+ for (const name of await readdir(bindings)) {
131
+ if (!name.endsWith(".json")) continue;
132
+ const file = path.join(bindings, name);
133
+ const stale = async record => record !== undefined && record !== null
134
+ && clientPidOf(record) === null && typeof record.harnessSessionId === "string"
135
+ && !await isMcpContinuity(record, name, workspaceRoot, entry.name)
136
+ && staleWithoutPid(workspaceRoot, entry.name, record, pidIsAlive);
137
+ const judged = await readManagedJson(file).catch(() => undefined);
138
+ if (!await stale(judged)) continue;
139
+ const gone = await withWriterMutex(lifecycleLocks(workspaceRoot, judged.harnessSessionId),
140
+ { root: workspaceRoot, clock, deadlineAt: Date.now() + LIFECYCLE_LOCK_MS }, async () => {
141
+ const current = await readManagedJson(file).catch(() => undefined);
142
+ if (current?.harnessSessionId !== judged.harnessSessionId || !await stale(current)) return false;
143
+ await rm(file, { force: true });
144
+ return true;
145
+ }).catch(() => false);
146
+ if (!gone) continue;
147
+ removed += 1;
148
+ changed = true;
149
+ }
150
+ if (changed) await syncDirectory(bindings);
151
+ }
152
+ return removed;
153
+ }
154
+
68
155
  /** Unknown on either side cannot be compared, and an uncomparable contract
69
156
  * stays a hold. Equal contracts coexist by the store's own rule. */
70
157
  export const blocksActivation = (hold, incomingStoreVersion) =>
@@ -78,9 +165,9 @@ export const blocksActivation = (hold, incomingStoreVersion) =>
78
165
  * generation's, or when either side declares none.
79
166
  */
80
167
  export async function listActivationBlockers(root, { pidIsAlive = defaultPidIsAlive,
81
- ignorePid = null, incomingStoreVersion = null } = {}) {
168
+ ignorePid = null, incomingStoreVersion = null, strictNative = false } = {}) {
82
169
  const leases = (await listRuntimeHolds(root, { pidIsAlive })).filter(lease => lease.pid !== ignorePid);
83
- const native = await listNativeHolds(root, { pidIsAlive });
170
+ const native = await listNativeHolds(root, { pidIsAlive, strict: strictNative });
84
171
  const holds = [
85
172
  ...leases.map(({ pid, kind, runtime }) => ({ pid, kind, nativeBindings: 0,
86
173
  storeVersion: runtime?.storeVersion ?? null })),
@@ -5,7 +5,7 @@ import { ENTRY_KINDS, RETIRED_ENTRY_KINDS } from "./entry.mjs";
5
5
  import { removeTree, renameEntry, renameReplacing } from "./portable-fs.mjs";
6
6
  import { managedDirectory, syncDirectory } from "./state.mjs";
7
7
 
8
- const MODULES = ["entry.mjs", "command-prefix.mjs", "state.mjs", "generation-files.mjs",
8
+ export const MODULES = ["entry.mjs", "command-prefix.mjs", "state.mjs", "generation-files.mjs",
9
9
  "mutex.mjs", "leases.mjs", "schedule.mjs", "policy.mjs", "portable-fs.mjs"];
10
10
  async function durableFile(file, bytes, mode = 0o600) {
11
11
  const handle = await open(file, "wx", mode);
@@ -74,8 +74,22 @@ export async function renameEntry(from, to, { platform = process.platform,
74
74
  }, error => BUSY.has(error.code), { deadlineAt, sleep });
75
75
  }
76
76
 
77
- const gone = (file, lstat) => lstat(file).then(() => false,
78
- error => error.code === "ENOENT");
77
+ // A name inside a directory another process is deleting - a waiting writer's
78
+ // rmdir of a lock its owner emptied - answers lstat with EPERM until the
79
+ // deletion is done (measured on windows-latest: lstat of writer.lock\owner.json
80
+ // while eight writers elected one, 2026-10-06, and the waiting writer failed
81
+ // instead of looking again). The name is asked again within the caller's
82
+ // deadline: what it settles to is the answer, and an EPERM that outlasts the
83
+ // deadline is kept.
84
+ async function lstatSettled(file, lstat, { deadlineAt, sleep }) {
85
+ try {
86
+ return await retrying(() => lstat(file, { bigint: true }), error => error.code === "EPERM",
87
+ { deadlineAt, sleep });
88
+ } catch (error) {
89
+ if (error.code === "ENOENT") return null;
90
+ throw error;
91
+ }
92
+ }
79
93
 
80
94
  const refusedLink = file => Object.assign(
81
95
  new Error(`ELOOP: too many symbolic links encountered, open '${file}'`),
@@ -99,32 +113,39 @@ export async function openNoFollow(file, flags, { mode, platform = process.platf
99
113
  open = fs.open, lstat = fs.lstat, deadlineAt, sleep } = {}) {
100
114
  if (!isWindows(platform)) return open(file, flags | constants.O_NOFOLLOW, mode);
101
115
  for (let attempt = 1; ; attempt += 1) {
102
- const before = await lstat(file, { bigint: true }).catch(error => {
103
- if (error.code === "ENOENT") return null;
104
- throw error;
105
- });
116
+ const before = await lstatSettled(file, lstat, { deadlineAt, sleep });
106
117
  if (before?.isSymbolicLink()) throw refusedLink(file);
107
- // A scanner that opened the file without read sharing refuses every reader
108
- // with EBUSY until it lets go.
109
- const handle = await retrying(() => open(file, flags, mode), error => error.code === "EBUSY",
110
- { deadlineAt, sleep }).catch(async error => {
118
+ let handle;
119
+ try {
120
+ // A scanner that opened the file without read sharing refuses every
121
+ // reader with EBUSY until it lets go.
122
+ handle = await retrying(() => open(file, flags, mode), error => error.code === "EBUSY",
123
+ { deadlineAt, sleep });
124
+ } catch (error) {
111
125
  // A name another process is deleting, or a lock changing hands around
112
126
  // it, refuses the open with EPERM, and the name is gone right after
113
127
  // (measured on windows-latest, for a name present before the open and
114
128
  // for one absent before it): that is ENOENT, as Linux says it. A name
115
- // that is still there keeps its EPERM, and so does a create on a name
116
- // that was free, which is a refused create.
129
+ // that names another file by then changed hands - a lock released and
130
+ // taken again while the open was refused (measured on windows-latest,
131
+ // 2026-10-06) - and is opened afresh, as a swap after the open is. A
132
+ // name still naming what it named keeps its EPERM, and so does a create
133
+ // on a name that was free, which is a refused create.
117
134
  const refusedCreate = before === null && (flags & constants.O_CREAT) !== 0;
118
- if (error.code !== "EPERM" || refusedCreate || !await gone(file, lstat)) throw error;
119
- throw Object.assign(new Error(`ENOENT: no such file or directory, open '${file}'`,
120
- { cause: error }), { code: "ENOENT", syscall: "open", path: file });
121
- });
135
+ if (error.code !== "EPERM" || refusedCreate) throw error;
136
+ const now = await lstatSettled(file, lstat, { deadlineAt, sleep }).catch(() => undefined);
137
+ if (now === null) {
138
+ throw Object.assign(new Error(`ENOENT: no such file or directory, open '${file}'`,
139
+ { cause: error }), { code: "ENOENT", syscall: "open", path: file });
140
+ }
141
+ const changedHands = now !== undefined
142
+ && (before === null || now.dev !== before.dev || now.ino !== before.ino);
143
+ if (changedHands && attempt < SWAPS) continue;
144
+ throw error;
145
+ }
122
146
  try {
123
147
  const opened = await handle.stat({ bigint: true });
124
- const named = await lstat(file, { bigint: true }).catch(error => {
125
- if (error.code === "ENOENT") return null;
126
- throw error;
127
- });
148
+ const named = await lstatSettled(file, lstat, { deadlineAt, sleep });
128
149
  if (named?.isSymbolicLink()) throw refusedLink(file);
129
150
  if (named !== null && named.dev === opened.dev && named.ino === opened.ino) return handle;
130
151
  if (attempt >= SWAPS) throw refusedLink(file);
@@ -2,12 +2,16 @@ import path from "node:path";
2
2
  import { applyPlan, detectInstallation, livePolicyOf, loadOwnership, planInstallation }
3
3
  from "@agents-can-communicate/installer";
4
4
  import { ALL_ADAPTERS, clientContext, probeTimeout } from "../install-command.mjs";
5
+ import { sweepStaleBindings } from "./activation.mjs";
5
6
  import { stablePaths, writeLaunchers } from "./launchers.mjs";
6
7
  import { bridgeLegacyMaintenance } from "./legacy-maintenance.mjs";
7
8
 
8
9
  /** Loaded from the candidate, so its own adapters supply its integration bytes. */
9
10
  export async function prepareRefresh({ control, root, env = process.env, callerProtocol = 1 }) {
10
11
  await bridgeLegacyMaintenance({ control, root, env, callerProtocol });
12
+ // An older generation lists its blockers right after this returns, by a rule
13
+ // that counts a binding without a client pid as a live client forever (#273).
14
+ await sweepStaleBindings(root).catch(() => {});
11
15
  const dataHome = path.dirname(path.dirname(root));
12
16
  const wanted = new Set(control.targets);
13
17
  const adapters = ALL_ADAPTERS().filter(adapter => wanted.has(adapter.id));
@@ -20,11 +20,27 @@ async function reclaimDue(root, control) {
20
20
  return !Number.isFinite(attempted) || Date.now() - attempted >= RECLAIM_RETRY_MS;
21
21
  }
22
22
 
23
+ // The active generation has not yet moved this data home's older stores to its
24
+ // contract (#263; `store-upgrade.json`, which its worker writes on every pass),
25
+ // or a pass left a store behind a minute or more ago. Until it succeeds that
26
+ // workspace has no coordination, so the retry is soon. This module is copied
27
+ // into every launcher and imports only launcher modules; an import of the
28
+ // migration itself failed every hook with ERR_MODULE_NOT_FOUND (2026-10-06).
29
+ export const STORE_UPGRADE_RETRY_MS = 60_000;
30
+ export async function storeUpgradeDue(root, control, { now = Date.now() } = {}) {
31
+ let marker;
32
+ try { marker = await readManagedJson(path.join(root, "store-upgrade.json")); } catch { return false; }
33
+ if (marker?.activeRoot !== control.active.root) return true;
34
+ if (marker.complete !== false) return false;
35
+ const attempted = Date.parse(marker.attemptedAt);
36
+ return !Number.isFinite(attempted) || now - attempted >= STORE_UPGRADE_RETRY_MS;
37
+ }
38
+
23
39
  /** No network waits here: this only starts an independent background process. */
24
40
  export async function scheduleWorker(root, control, { env = process.env } = {}) {
25
41
  if (!control?.active) return false;
26
42
  const update = control.auto && !networkDisabled(env) && (control.pending || checkDue(control));
27
- if (!update && !await reclaimDue(root, control)) return false;
43
+ if (!update && !await reclaimDue(root, control) && !await storeUpgradeDue(root, control)) return false;
28
44
  try {
29
45
  for (const directory of [path.join(root, "worker"), path.join(root, "worker", "poller")]) {
30
46
  const owner = await readManagedJson(path.join(directory, "manager.lock", "owner.json"));
@@ -0,0 +1,105 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { lstat, readdir, readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { assertPortableId, createId } from "@agents-can-communicate/protocol";
6
+ import { migrateFilesystemStore, STORE_VERSION } from "@agents-can-communicate/storage-filesystem";
7
+
8
+ import { listActivationBlockers } from "./activation.mjs";
9
+ import { withManagerLock } from "./mutex.mjs";
10
+ import { readControl, writeManagedJson } from "./state.mjs";
11
+
12
+ /**
13
+ * Move this data home's older stores to the contract this generation speaks.
14
+ *
15
+ * #263 made contract 6 to 7 an explicit `acc doctor --migrate-store` in each
16
+ * workspace. With automatic updates on, a user who ran nothing found every
17
+ * workspace without coordination after the update, its remedy on a stderr line
18
+ * Claude Code does not show (2026-10-06). The activation of a contract-7
19
+ * runtime already waits until no live process holds contract 6, so right after
20
+ * it - and on every later worker pass - the same check guards each store's
21
+ * migration, under the same admission mutex as the explicit command. The mutex
22
+ * is taken per store, so an entry waits at most one store's migration.
23
+ */
24
+ export const STORE_UPGRADE_MARKER = "store-upgrade.json";
25
+
26
+ const workspacesOf = root => path.join(path.dirname(root), "workspaces");
27
+ const systemClock = { now: () => new Date().toISOString() };
28
+ const systemIds = { next: kind => createId(kind, randomBytes) };
29
+
30
+ /**
31
+ * The stores in this data home whose contract is older than this code's, and
32
+ * how many entries could not be read now. An entry that is not a store (no
33
+ * identity, not a directory, a link, a name that is no workspace id, another
34
+ * workspace's identity) is skipped; one whose identity or listing failed to
35
+ * read is counted, so the pass stays open and a later one retries it (AI review
36
+ * of #264: such a store was dropped and the pass recorded itself complete).
37
+ */
38
+ export async function olderStores(root) {
39
+ const workspaces = workspacesOf(root);
40
+ let names;
41
+ try {
42
+ names = await readdir(workspaces);
43
+ } catch (error) {
44
+ return { stores: [], unreadable: error?.code === "ENOENT" ? 0 : 1 };
45
+ }
46
+ const stores = [];
47
+ let unreadable = 0;
48
+ for (const name of names) {
49
+ const dir = path.join(workspaces, name);
50
+ try { assertPortableId(name); } catch { continue; }
51
+ let identity;
52
+ try {
53
+ const info = await lstat(dir);
54
+ if (!info.isDirectory() || info.isSymbolicLink()) continue;
55
+ identity = JSON.parse(await readFile(path.join(dir, "protocol.json"), "utf8"));
56
+ } catch (error) {
57
+ if (error?.code !== "ENOENT") unreadable += 1;
58
+ continue;
59
+ }
60
+ if (identity?.workspaceId === name && Number.isSafeInteger(identity.storeVersion)
61
+ && identity.storeVersion < STORE_VERSION) {
62
+ stores.push({ workspaceId: name, dir, storeVersion: identity.storeVersion });
63
+ }
64
+ }
65
+ return { stores, unreadable };
66
+ }
67
+
68
+ /**
69
+ * Migrate every older store that no live older client holds. Records the pass
70
+ * in STORE_UPGRADE_MARKER and returns one outcome per store.
71
+ */
72
+ export async function migrateOlderStores(root, { ignorePid = process.pid, clock = systemClock,
73
+ ids = systemIds, platform = process.platform, migrate = migrateFilesystemStore } = {}) {
74
+ const control = await readControl(root);
75
+ if (!control?.active) return [];
76
+ const outcomes = [];
77
+ // Recorded even when this generation migrates nothing, so the launcher's
78
+ // scheduler, which knows no contract, stops asking for a pass.
79
+ const { stores, unreadable } = control.active.storeVersion === STORE_VERSION
80
+ ? await olderStores(root) : { stores: [], unreadable: 0 };
81
+ for (const store of stores) {
82
+ let state;
83
+ try {
84
+ state = await withManagerLock(root, async () => {
85
+ const current = await readControl(root);
86
+ if (current?.phase !== "ready" || current.active?.storeVersion !== STORE_VERSION) return "not_ready";
87
+ const blockers = await listActivationBlockers(root, { incomingStoreVersion: STORE_VERSION,
88
+ ignorePid, strictNative: true });
89
+ if (blockers.length > 0) return "blocked";
90
+ await migrate({ root: store.dir, workspaceId: store.workspaceId, clock, ids, platform,
91
+ allowMigration: true });
92
+ return "migrated";
93
+ });
94
+ } catch {
95
+ state = "failed";
96
+ }
97
+ outcomes.push({ workspaceId: store.workspaceId, state });
98
+ }
99
+ await writeManagedJson(path.join(root, STORE_UPGRADE_MARKER), { schemaVersion: 1,
100
+ activeRoot: control.active.root,
101
+ complete: unreadable === 0 && outcomes.every(item => item.state === "migrated"),
102
+ pending: unreadable + outcomes.filter(item => item.state !== "migrated").length,
103
+ attemptedAt: new Date().toISOString() });
104
+ return outcomes;
105
+ }
@@ -7,6 +7,7 @@ import { downloadRelease, fetchRelease, newerVersion } from "./download.mjs";
7
7
  import { withManagerLock } from "./mutex.mjs";
8
8
  import { readControl, writeControl, writeManagedJson } from "./state.mjs";
9
9
  import { releaseStagingHold } from "./staging.mjs";
10
+ import { migrateOlderStores } from "./store-upgrade.mjs";
10
11
  import { MAINTENANCE_ACTIVE, maintenanceNotice, maintenanceReport, readMaintenance } from "./maintenance-state.mjs";
11
12
  import { requestMaintenance } from "./maintenance.mjs";
12
13
 
@@ -103,6 +104,10 @@ export async function reclaimAsActive(root, generationRoot) {
103
104
  activeRoot: control.active.root, complete: result?.postponed !== true && !own,
104
105
  attemptedAt: new Date().toISOString() });
105
106
  } catch { /* Best effort; the next pass reclaims again. */ }
107
+ // This generation's stores, moved to its contract once no older client holds
108
+ // one. In the activating process its own older lease is not a holder.
109
+ try { await migrateOlderStores(root, { ignorePid: process.pid }); }
110
+ catch { /* Best effort; the scheduler retries an incomplete pass. */ }
106
111
  }
107
112
 
108
113
  /** One poller may wait; the update lock stays available between its passes. */
@@ -0,0 +1,39 @@
1
+ import path from "node:path";
2
+ import { AccError, EXIT } from "@agents-can-communicate/protocol";
3
+ import { migrateFilesystemStore, STORE_VERSION } from "@agents-can-communicate/storage-filesystem";
4
+ import { listActivationBlockers } from "./managed-runtime/activation.mjs";
5
+ import { withManagerLock } from "./managed-runtime/mutex.mjs";
6
+ import { readControl } from "./managed-runtime/state.mjs";
7
+
8
+ export async function runStoreMigration({ options, context, runtime }) {
9
+ const manager = runtime.managerRoot ?? path.join(context.dataHome, "acc", "runtime");
10
+ let result;
11
+ try {
12
+ result = await withManagerLock(manager, async () => {
13
+ const control = await readControl(manager);
14
+ if (control !== null && control.phase !== "ready") {
15
+ throw new AccError(EXIT.CONFLICT, "runtime admission is not ready; finish the update before store migration");
16
+ }
17
+ const blockers = await listActivationBlockers(manager, { incomingStoreVersion: STORE_VERSION,
18
+ ignorePid: process.pid, strictNative: true });
19
+ if (blockers.length > 0) {
20
+ throw new AccError(EXIT.CONFLICT, "store migration is blocked by live users of an old or unknown contract; stop them and retry",
21
+ { blockers });
22
+ }
23
+ return migrateFilesystemStore({ root: context.paths.root, workspaceId: context.descriptor.id,
24
+ clock: runtime.clock, ids: runtime.ids, platform: runtime.platform, allowMigration: true,
25
+ failAt: runtime.storeMigrationFailAt });
26
+ });
27
+ } catch (error) {
28
+ if (error instanceof AccError) throw error;
29
+ if (error.message === "manager lock held; acquisition timeout") {
30
+ throw new AccError(EXIT.CONFLICT, "runtime admission is busy; retry store migration");
31
+ }
32
+ // Management parser errors can quote private bytes; expose no raw cause.
33
+ throw new AccError(EXIT.DATA, "runtime admission state is unsafe or unreadable; store migration refused");
34
+ }
35
+ const data = { workspaceId: context.descriptor.id, ...result };
36
+ return { data, text: result.migrated
37
+ ? `store migrated from contract ${result.fromVersion} to ${result.toVersion}`
38
+ : `store already uses contract ${result.toVersion}; index verified` };
39
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.9.1",
3
+ "version": "0.10.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {