agents-can-communicate 0.9.1 → 0.10.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 (70) 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 +19 -0
  5. package/docs/CLI.md +11 -1
  6. package/docs/GETTING_STARTED.md +4 -3
  7. package/docs/UPGRADING.md +50 -0
  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 +19 -6
  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/schedule.mjs +17 -1
  33. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/store-upgrade.mjs +105 -0
  34. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +5 -0
  35. package/node_modules/@agents-can-communicate/cli/src/store-migration-command.mjs +39 -0
  36. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  37. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +21 -52
  38. package/node_modules/@agents-can-communicate/core/src/decision-changes.mjs +16 -11
  39. package/node_modules/@agents-can-communicate/core/src/inbox.mjs +12 -4
  40. package/node_modules/@agents-can-communicate/core/src/message-retry.mjs +43 -0
  41. package/node_modules/@agents-can-communicate/core/src/ports.mjs +12 -1
  42. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +20 -6
  43. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  44. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  45. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +23 -3
  46. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  47. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  48. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  49. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +1 -0
  50. package/node_modules/@agents-can-communicate/protocol/src/transaction-indexes.mjs +22 -0
  51. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  52. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +22 -10
  53. package/node_modules/@agents-can-communicate/storage-filesystem/src/index-cache.mjs +144 -0
  54. package/node_modules/@agents-can-communicate/storage-filesystem/src/index-io.mjs +65 -0
  55. package/node_modules/@agents-can-communicate/storage-filesystem/src/index-pages.mjs +85 -0
  56. package/node_modules/@agents-can-communicate/storage-filesystem/src/index-reclaim-progress.mjs +35 -0
  57. package/node_modules/@agents-can-communicate/storage-filesystem/src/index-reclaim.mjs +142 -0
  58. package/node_modules/@agents-can-communicate/storage-filesystem/src/index-tree.mjs +142 -0
  59. package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +1 -0
  60. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +2 -0
  61. package/node_modules/@agents-can-communicate/storage-filesystem/src/portable-fs.mjs +41 -20
  62. package/node_modules/@agents-can-communicate/storage-filesystem/src/prune-durability.mjs +29 -0
  63. package/node_modules/@agents-can-communicate/storage-filesystem/src/reclaim.mjs +13 -3
  64. package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +9 -3
  65. package/node_modules/@agents-can-communicate/storage-filesystem/src/session-read.mjs +6 -4
  66. package/node_modules/@agents-can-communicate/storage-filesystem/src/state-reads.mjs +43 -0
  67. package/node_modules/@agents-can-communicate/storage-filesystem/src/store-migration.mjs +56 -0
  68. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +61 -110
  69. package/node_modules/@agents-can-communicate/storage-filesystem/src/transaction-view.mjs +131 -0
  70. package/package.json +5 -4
@@ -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,4 +1,7 @@
1
1
  import { createHash } from "node:crypto";
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.
2
5
  import { readdir } from "node:fs/promises";
3
6
  import path from "node:path";
4
7
  import { pathToFileURL } from "node:url";
@@ -31,7 +34,11 @@ async function isMcpContinuity(record, name, workspaceRoot, workspaceId) {
31
34
  for (const id of [parts[1], parts[2], record.accSessionId, record.generation]) assertPortableId(id, "MCP continuity id");
32
35
  const expected = createHash("sha256").update(record.harnessSessionId).digest("hex").slice(0, 32) + ".json";
33
36
  if (name !== expected) return false;
34
- const owner = await readSessionRecord({ root: workspaceRoot, workspaceId, sessionId: record.accSessionId });
37
+ // This is ownership metadata, never a service open or a write. Supported
38
+ // v6 continuity must remain classifiable before/after migrating another
39
+ // workspace; its actual process lifetime is still fenced by its lease.
40
+ const owner = await readSessionRecord({ root: workspaceRoot, workspaceId,
41
+ sessionId: record.accSessionId, allowLegacyIdentity: true });
35
42
  return owner?.harness === "mcp" && owner.participantId === parts[1]
36
43
  && owner.workspaceId === workspaceId && owner.sessionId === record.accSessionId
37
44
  && owner.generation === record.generation;
@@ -39,20 +46,26 @@ async function isMcpContinuity(record, name, workspaceRoot, workspaceId) {
39
46
  }
40
47
 
41
48
  /** Native bindings outlive finish/TTL; MCP process lifetime is covered by its runtime lease. */
42
- export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive } = {}) {
49
+ export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive, strict = false } = {}) {
43
50
  const workspaces = path.join(path.dirname(await canonicalManagerRoot(root)), "workspaces");
44
51
  if (!await managedDirectory(workspaces)) return [];
45
52
  const holds = [];
46
53
  for (const entry of await readdir(workspaces, { withFileTypes: true })) {
47
54
  if (!entry.isDirectory()) {
48
- if (entry.isSymbolicLink()) holds.push({ kind: "native", reason: "unknown_workspace" });
55
+ if (entry.isSymbolicLink()) {
56
+ if (strict) throw new Error("unsafe native workspace directory");
57
+ holds.push({ kind: "native", reason: "unknown_workspace" });
58
+ }
49
59
  continue;
50
60
  }
51
61
  const bindings = path.join(workspaces, entry.name, "bindings");
52
62
  if (!await managedDirectory(bindings)) continue;
53
63
  for (const name of await readdir(bindings)) {
54
64
  if (!name.endsWith(".json")) continue;
55
- const record = await readManagedJson(path.join(bindings, name)).catch(() => null);
65
+ const record = await readManagedJson(path.join(bindings, name)).catch(error => {
66
+ if (strict) throw error;
67
+ return null;
68
+ });
56
69
  if (record === undefined) continue; // Concurrent lifecycle removal, before fencing.
57
70
  if (await isMcpContinuity(record, name, path.dirname(bindings), entry.name)) continue;
58
71
  const pid = record?.schemaVersion === 1 && Number.isSafeInteger(record.clientPid)
@@ -78,9 +91,9 @@ export const blocksActivation = (hold, incomingStoreVersion) =>
78
91
  * generation's, or when either side declares none.
79
92
  */
80
93
  export async function listActivationBlockers(root, { pidIsAlive = defaultPidIsAlive,
81
- ignorePid = null, incomingStoreVersion = null } = {}) {
94
+ ignorePid = null, incomingStoreVersion = null, strictNative = false } = {}) {
82
95
  const leases = (await listRuntimeHolds(root, { pidIsAlive })).filter(lease => lease.pid !== ignorePid);
83
- const native = await listNativeHolds(root, { pidIsAlive });
96
+ const native = await listNativeHolds(root, { pidIsAlive, strict: strictNative });
84
97
  const holds = [
85
98
  ...leases.map(({ pid, kind, runtime }) => ({ pid, kind, nativeBindings: 0,
86
99
  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);
@@ -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.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,54 +1,17 @@
1
1
  import { createHash } from "node:crypto";
2
- import { isDeepStrictEqual } from "node:util";
3
2
  import { AccError, EXIT, SCHEMA_VERSION, validateRecord }
4
3
  from "@agents-can-communicate/protocol";
5
4
 
6
5
  import { projectReleasedClaim, reconstructFinishRetry } from "./finish-retries.mjs";
7
6
  import { ensureMaterialised } from "./materialisation.mjs";
8
7
  import { nextTurnDelivery as selectNextTurnDelivery } from "./next-turn-delivery.mjs";
8
+ import { assertExactTransaction } from "./ports.mjs";
9
+ import { findMessageRetry } from "./message-retry.mjs";
9
10
  import { prepareDecisionChange } from "./decision-changes.mjs";
10
11
 
11
12
  export const receiptId = (messageId, participantId) => `receipt_${createHash("sha256")
12
13
  .update(JSON.stringify([messageId, participantId])).digest("base64url")}`;
13
14
 
14
- const logicalContent = message => ({
15
- toParticipantIds: message.toParticipantIds,
16
- kind: message.kind,
17
- obligation: message.obligation,
18
- subject: message.subject,
19
- body: message.body,
20
- inReplyTo: message.inReplyTo,
21
- artifacts: message.artifacts,
22
- handoff: message.handoff,
23
- decisionChange: message.decisionChange ?? null,
24
- });
25
-
26
- const normalizedContent = input => logicalContent({
27
- toParticipantIds: input.toParticipantIds ?? [],
28
- kind: input.kind,
29
- obligation: input.obligation,
30
- subject: input.subject,
31
- body: input.body,
32
- inReplyTo: input.inReplyTo ?? null,
33
- artifacts: input.artifacts ?? [],
34
- handoff: input.handoff ?? null,
35
- decisionChange: input.decisionChange,
36
- });
37
-
38
- function existingMessage(tx, session, input) {
39
- const existing = tx.list("message", message =>
40
- message.workspaceId === session.workspaceId
41
- && message.fromParticipantId === session.participantId
42
- && message.clientMessageId === input.clientMessageId).at(0);
43
- if (existing !== undefined
44
- && !isDeepStrictEqual(logicalContent(existing), normalizedContent(input))) {
45
- throw new AccError(EXIT.CONFLICT,
46
- "clientMessageId was already used with different message content",
47
- { clientMessageId: input.clientMessageId, messageId: existing.messageId });
48
- }
49
- return existing;
50
- }
51
-
52
15
  function assertCurrentSession(tx, session, action) {
53
16
  const current = tx.get("session", session.sessionId);
54
17
  if (current === null || current.state !== "open"
@@ -114,9 +77,9 @@ function addressedRecipients(tx, message, session) {
114
77
  return unique;
115
78
  }
116
79
 
117
- function threadFor(tx, messageId, inReplyTo) {
80
+ async function threadFor(tx, messageId, inReplyTo) {
118
81
  if (inReplyTo === null) return messageId;
119
- const parent = tx.get("message", inReplyTo);
82
+ const parent = await tx.load("message", inReplyTo);
120
83
  if (parent === null) {
121
84
  throw new AccError(EXIT.DATA, "the message being replied to does not exist",
122
85
  { inReplyTo });
@@ -124,15 +87,16 @@ function threadFor(tx, messageId, inReplyTo) {
124
87
  return parent.threadId;
125
88
  }
126
89
 
127
- export function recordMessageInTransaction({ tx, session, input: raw, now, messageId, ids,
90
+ export async function recordMessageInTransaction({ tx, session, input: raw, now, messageId, ids,
128
91
  action = "send a message" }) {
92
+ assertExactTransaction(tx);
129
93
  assertCurrentSession(tx, session, action);
130
94
  // Resolved before anything else reads the recipients, so the stored message
131
95
  // names real participants and a retry compares against the same content. A
132
96
  // record that kept the client name would address nobody when it is read back.
133
- const input = prepareDecisionChange(tx, { ...raw,
97
+ const input = await prepareDecisionChange(tx, { ...raw,
134
98
  toParticipantIds: resolveAddressees(tx, raw.toParticipantIds ?? [], session) }, session);
135
- const existing = existingMessage(tx, session, input);
99
+ const existing = await findMessageRetry(tx, session, input);
136
100
  if (existing !== undefined) {
137
101
  return { message: existing, recipientParticipantIds: [], created: false };
138
102
  }
@@ -141,7 +105,7 @@ export function recordMessageInTransaction({ tx, session, input: raw, now, messa
141
105
  const message = validateRecord("message", {
142
106
  schemaVersion: SCHEMA_VERSION,
143
107
  messageId,
144
- threadId: threadFor(tx, messageId, inReplyTo),
108
+ threadId: await threadFor(tx, messageId, inReplyTo),
145
109
  clientMessageId: input.clientMessageId,
146
110
  workspaceId: session.workspaceId,
147
111
  fromParticipantId: session.participantId,
@@ -158,8 +122,10 @@ export function recordMessageInTransaction({ tx, session, input: raw, now, messa
158
122
  sentAt: now,
159
123
  });
160
124
  const recipientParticipantIds = addressedRecipients(tx, message, session);
125
+ await tx.load("message", messageId);
161
126
  tx.put("message", messageId, message);
162
127
  for (const participantId of recipientParticipantIds) {
128
+ await tx.load("receipt", receiptId(messageId, participantId));
163
129
  tx.put("receipt", receiptId(messageId, participantId), validateRecord("receipt", {
164
130
  schemaVersion: SCHEMA_VERSION,
165
131
  messageId,
@@ -202,9 +168,9 @@ export function createConversationService(ports, sessions) {
202
168
  const session = await requireSession(input, "send a message");
203
169
  await ensureMaterialised(ports, { workspaceId: session.workspaceId,
204
170
  descriptor: input.descriptor, reason: "durable_object" });
205
- return store.transaction(tx => recordMessageInTransaction({ tx, session, input,
206
- now: clock.now(), messageId: ids.next("message"), ids }).message,
207
- { kinds: ["participant", "session", "message", "receipt"] });
171
+ return store.transaction(async tx => (await recordMessageInTransaction({ tx, session, input,
172
+ now: clock.now(), messageId: ids.next("message"), ids })).message,
173
+ { kinds: ["participant", "session", "message", "receipt"], exactKinds: ["message", "receipt"] });
208
174
  }
209
175
 
210
176
  async function finishSession(input) {
@@ -235,13 +201,15 @@ export function createConversationService(ports, sessions) {
235
201
  handoff,
236
202
  };
237
203
  const now = clock.now();
238
- const outcome = await store.transaction(tx => {
204
+ const outcome = await store.transaction(async tx => {
205
+ assertExactTransaction(tx);
239
206
  const current = tx.get("session", session.sessionId);
240
207
  if (current === null || current.generation !== session.generation) {
241
208
  throw new AccError(EXIT.CONFLICT, "cannot finish from this session generation",
242
209
  { sessionId: session.sessionId });
243
210
  }
244
- const existing = existingMessage(tx, session, messageInput);
211
+ const existing = await findMessageRetry(tx, session, { ...messageInput,
212
+ toParticipantIds: resolveAddressees(tx, messageInput.toParticipantIds, session) });
245
213
  if (existing !== undefined) {
246
214
  if (existing.fromSessionId !== session.sessionId) {
247
215
  throw new AccError(EXIT.CONFLICT,
@@ -256,7 +224,7 @@ export function createConversationService(ports, sessions) {
256
224
  }
257
225
  const releasedClaims = tx.list("claim",
258
226
  claim => claim.ownerSessionId === session.sessionId);
259
- const recorded = recordMessageInTransaction({ tx, session, now,
227
+ const recorded = await recordMessageInTransaction({ tx, session, now,
260
228
  messageId: ids.next("message"), ids, action: "finish",
261
229
  input: messageInput });
262
230
  for (const claim of releasedClaims) {
@@ -278,7 +246,8 @@ export function createConversationService(ports, sessions) {
278
246
  releasedClaimIds: releasedClaims.map(claim => claim.claimId) } });
279
247
  return { retry: false, message: recorded.message,
280
248
  releasedClaims: releasedClaims.map(projectReleasedClaim), session: closed };
281
- }, { kinds: ["participant", "session", "claim", "message", "receipt"] });
249
+ }, { kinds: ["participant", "session", "claim", "message", "receipt"],
250
+ exactKinds: ["message", "receipt"] });
282
251
  if (!outcome.retry) {
283
252
  return { message: outcome.message, releasedClaims: outcome.releasedClaims,
284
253
  session: outcome.session };