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.
- package/README.md +46 -44
- package/bin/acc-update-worker.mjs +2 -1
- package/bin/entrypoints/hook-output.mjs +2 -0
- package/docs/ARCHITECTURE.md +19 -0
- package/docs/CLI.md +11 -1
- package/docs/GETTING_STARTED.md +4 -3
- package/docs/UPGRADING.md +50 -0
- package/docs/WHY_ACC.md +32 -9
- package/docs/index.md +7 -5
- package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +3 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +12 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +2 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +11 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +6 -0
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +4 -1
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +14 -0
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -0
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +12 -3
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +19 -6
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +1 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/portable-fs.mjs +41 -20
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +17 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/store-upgrade.mjs +105 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +5 -0
- package/node_modules/@agents-can-communicate/cli/src/store-migration-command.mjs +39 -0
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/conversations.mjs +21 -52
- package/node_modules/@agents-can-communicate/core/src/decision-changes.mjs +16 -11
- package/node_modules/@agents-can-communicate/core/src/inbox.mjs +12 -4
- package/node_modules/@agents-can-communicate/core/src/message-retry.mjs +43 -0
- package/node_modules/@agents-can-communicate/core/src/ports.mjs +12 -1
- package/node_modules/@agents-can-communicate/core/src/receipts.mjs +20 -6
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +23 -3
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/protocol/src/transaction-indexes.mjs +22 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +22 -10
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index-cache.mjs +144 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index-io.mjs +65 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index-pages.mjs +85 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index-reclaim-progress.mjs +35 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index-reclaim.mjs +142 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index-tree.mjs +142 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +2 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/portable-fs.mjs +41 -20
- package/node_modules/@agents-can-communicate/storage-filesystem/src/prune-durability.mjs +29 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/reclaim.mjs +13 -3
- package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +9 -3
- package/node_modules/@agents-can-communicate/storage-filesystem/src/session-read.mjs +6 -4
- package/node_modules/@agents-can-communicate/storage-filesystem/src/state-reads.mjs +43 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store-migration.mjs +56 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +61 -110
- package/node_modules/@agents-can-communicate/storage-filesystem/src/transaction-view.mjs +131 -0
- 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
|
|
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
|
|
522
|
-
|
|
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
|
-
|
|
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())
|
|
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(
|
|
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
|
-
|
|
78
|
-
|
|
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
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
116
|
-
//
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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,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.
|
|
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 =
|
|
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 =
|
|
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 };
|