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.
- 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 +26 -2
- package/docs/CLI.md +11 -1
- package/docs/GETTING_STARTED.md +4 -3
- package/docs/UPGRADING.md +62 -3
- 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 +97 -10
- 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/refresh.mjs +4 -0
- 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
|
@@ -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
|
|
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,9 +1,13 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
|
|
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
|
-
|
|
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())
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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);
|
|
@@ -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
|
+
}
|