agents-can-communicate 0.9.0 → 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/CAPABILITIES.md +1 -1
- package/docs/CLI.md +11 -1
- package/docs/CONFIGURATION.md +26 -0
- package/docs/GETTING_STARTED.md +4 -3
- package/docs/SECURITY_MODEL.md +6 -0
- package/docs/TROUBLESHOOTING.md +13 -0
- package/docs/UPGRADING.md +64 -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-antigravity/plugin/skills/acc/SKILL.md +3 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +3 -0
- 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/plugin/skills/acc/SKILL.md +3 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +2 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/allow-rule.mjs +129 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +11 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +18 -4
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +3 -0
- 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-grok/plugin/skills/acc/SKILL.md +3 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +3 -0
- 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/adapter-sdk/src/own-version.mjs +27 -1
- 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/installer/src/ownership.mjs +6 -2
- 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
|
@@ -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 };
|
|
@@ -2,7 +2,7 @@ import { AccError, EXIT, assertDecisionChange } from "@agents-can-communicate/pr
|
|
|
2
2
|
|
|
3
3
|
// Resolve inherited recipients before retry comparison. Target messages and
|
|
4
4
|
// their recipient sets are immutable, so receipt advancement cannot alter a retry.
|
|
5
|
-
export function prepareDecisionChange(tx, input, session) {
|
|
5
|
+
export async function prepareDecisionChange(tx, input, session) {
|
|
6
6
|
if (input.decisionChange !== undefined) {
|
|
7
7
|
throw new AccError(EXIT.USAGE, "send a decision change using supersedes or withdraws");
|
|
8
8
|
}
|
|
@@ -15,22 +15,27 @@ export function prepareDecisionChange(tx, input, session) {
|
|
|
15
15
|
messageIds: input.supersedes ?? input.withdraws,
|
|
16
16
|
});
|
|
17
17
|
decisionChange.messageIds = [...decisionChange.messageIds].sort();
|
|
18
|
-
const targets =
|
|
19
|
-
|
|
18
|
+
const targets = [];
|
|
19
|
+
for (const messageId of decisionChange.messageIds) {
|
|
20
|
+
const message = await tx.load("message", messageId);
|
|
20
21
|
if (message?.kind !== "decision" || message.workspaceId !== session.workspaceId) {
|
|
21
22
|
throw new AccError(EXIT.DATA, "a decision change must reference existing decisions in this workspace",
|
|
22
23
|
{ messageId });
|
|
23
24
|
}
|
|
24
|
-
|
|
25
|
-
}
|
|
25
|
+
targets.push(message);
|
|
26
|
+
}
|
|
26
27
|
if (new Set(input.toParticipantIds).size !== input.toParticipantIds.length) {
|
|
27
28
|
throw new AccError(EXIT.USAGE, "a participant may be addressed only once");
|
|
28
29
|
}
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
.
|
|
33
|
-
|
|
30
|
+
const inherited = targets.map(message => message.fromParticipantId);
|
|
31
|
+
for (const message of targets) {
|
|
32
|
+
for (const id of await tx.lookup("receiptsByMessage", [session.workspaceId, message.messageId])) {
|
|
33
|
+
const receipt = await tx.load("receipt", id);
|
|
34
|
+
if (receipt !== null && receipt.workspaceId === session.workspaceId
|
|
35
|
+
&& receipt.messageId === message.messageId) inherited.push(receipt.recipientParticipantId);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
34
38
|
return { ...input, decisionChange,
|
|
35
|
-
toParticipantIds: [...new Set([...input.toParticipantIds,
|
|
39
|
+
toParticipantIds: [...new Set([...input.toParticipantIds,
|
|
40
|
+
...inherited.filter(id => id !== session.participantId)])].sort() };
|
|
36
41
|
}
|
|
@@ -2,6 +2,7 @@ import { AccError, EXIT, SCHEMA_VERSION, advanceReceipt }
|
|
|
2
2
|
from "@agents-can-communicate/protocol";
|
|
3
3
|
|
|
4
4
|
import { receiptId, recordMessageInTransaction } from "./conversations.mjs";
|
|
5
|
+
import { assertExactTransaction } from "./ports.mjs";
|
|
5
6
|
import { messagePage, messageSummary } from "./message-pages.mjs";
|
|
6
7
|
import { decisionView, isCurrentDecision } from "./decision-state.mjs";
|
|
7
8
|
|
|
@@ -109,6 +110,10 @@ export function createInboxService(ports, sessions) {
|
|
|
109
110
|
const session = await requireOpen(input, "acknowledge a message");
|
|
110
111
|
const now = clock.now();
|
|
111
112
|
return store.transaction(async tx => {
|
|
113
|
+
assertExactTransaction(tx);
|
|
114
|
+
await tx.load("session", input.sessionId);
|
|
115
|
+
await tx.load("message", input.messageId);
|
|
116
|
+
await tx.load("receipt", receiptId(input.messageId, session.participantId));
|
|
112
117
|
await requireOpen(input, "acknowledge a message", tx);
|
|
113
118
|
const { message, receipt } = requireOwnedReceipt(tx, session, input.messageId);
|
|
114
119
|
if (message.obligation === "reply" && receipt.state !== "acknowledged") {
|
|
@@ -117,18 +122,21 @@ export function createInboxService(ports, sessions) {
|
|
|
117
122
|
{ messageId: input.messageId, obligation: message.obligation });
|
|
118
123
|
}
|
|
119
124
|
return advanceOwned(tx, session, input.messageId, "acknowledged", now).receipt;
|
|
120
|
-
}, { kinds: ["session", "message", "receipt"] });
|
|
125
|
+
}, { kinds: ["session", "message", "receipt"], exactKinds: ["session", "message", "receipt"] });
|
|
121
126
|
}
|
|
122
127
|
|
|
123
128
|
async function replyToMessage(input) {
|
|
124
129
|
const session = await requireOpen(input, "reply to a message");
|
|
125
130
|
const now = clock.now();
|
|
126
131
|
const replyId = ids.next("message");
|
|
127
|
-
return store.transaction(tx => {
|
|
132
|
+
return store.transaction(async tx => {
|
|
133
|
+
assertExactTransaction(tx);
|
|
134
|
+
await tx.load("message", input.messageId);
|
|
135
|
+
await tx.load("receipt", receiptId(input.messageId, session.participantId));
|
|
128
136
|
const original = requireOwnedReceipt(tx, session, input.messageId);
|
|
129
137
|
// A receipt settles delivery, not the conversation. Distinct replies may
|
|
130
138
|
// follow an ack or an earlier answer; recording still enforces retry keys.
|
|
131
|
-
const recorded = recordMessageInTransaction({ tx, session, now, messageId: replyId, ids,
|
|
139
|
+
const recorded = await recordMessageInTransaction({ tx, session, now, messageId: replyId, ids,
|
|
132
140
|
action: "reply to a message", input: {
|
|
133
141
|
clientMessageId: input.clientMessageId,
|
|
134
142
|
toParticipantIds: [original.message.fromParticipantId],
|
|
@@ -142,7 +150,7 @@ export function createInboxService(ports, sessions) {
|
|
|
142
150
|
} });
|
|
143
151
|
const receipt = advanceOwned(tx, session, input.messageId, "acknowledged", now).receipt;
|
|
144
152
|
return { reply: recorded.message, receipt };
|
|
145
|
-
}, { kinds: ["participant", "session", "message", "receipt"] });
|
|
153
|
+
}, { kinds: ["participant", "session", "message", "receipt"], exactKinds: ["message", "receipt"] });
|
|
146
154
|
}
|
|
147
155
|
|
|
148
156
|
return { listInbox, readInbox, replyToMessage, acknowledgeMessage };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
3
|
+
|
|
4
|
+
const logicalContent = message => ({
|
|
5
|
+
toParticipantIds: message.toParticipantIds,
|
|
6
|
+
kind: message.kind,
|
|
7
|
+
obligation: message.obligation,
|
|
8
|
+
subject: message.subject,
|
|
9
|
+
body: message.body,
|
|
10
|
+
inReplyTo: message.inReplyTo,
|
|
11
|
+
artifacts: message.artifacts,
|
|
12
|
+
handoff: message.handoff,
|
|
13
|
+
decisionChange: message.decisionChange ?? null,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const normalizedContent = input => logicalContent({
|
|
17
|
+
toParticipantIds: input.toParticipantIds ?? [],
|
|
18
|
+
kind: input.kind,
|
|
19
|
+
obligation: input.obligation,
|
|
20
|
+
subject: input.subject,
|
|
21
|
+
body: input.body,
|
|
22
|
+
inReplyTo: input.inReplyTo ?? null,
|
|
23
|
+
artifacts: input.artifacts ?? [],
|
|
24
|
+
handoff: input.handoff ?? null,
|
|
25
|
+
decisionChange: input.decisionChange,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export async function findMessageRetry(tx, session, input) {
|
|
29
|
+
const candidates = await tx.lookup("messageByClientKey",
|
|
30
|
+
[session.workspaceId, session.participantId, input.clientMessageId]);
|
|
31
|
+
for (const id of candidates) {
|
|
32
|
+
const existing = await tx.load("message", id);
|
|
33
|
+
if (existing === null || existing.workspaceId !== session.workspaceId
|
|
34
|
+
|| existing.fromParticipantId !== session.participantId
|
|
35
|
+
|| existing.clientMessageId !== input.clientMessageId) continue;
|
|
36
|
+
if (!isDeepStrictEqual(logicalContent(existing), normalizedContent(input))) {
|
|
37
|
+
throw new AccError(EXIT.CONFLICT,
|
|
38
|
+
"clientMessageId was already used with different message content",
|
|
39
|
+
{ clientMessageId: input.clientMessageId, messageId: existing.messageId });
|
|
40
|
+
}
|
|
41
|
+
return existing;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -4,12 +4,14 @@ import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
|
4
4
|
* @typedef {{ now(): string }} Clock
|
|
5
5
|
* @typedef {{ next(kind: string): string }} IdSource
|
|
6
6
|
* @typedef {{ get(kind: string, id: string): object | null,
|
|
7
|
+
* load(kind: string, id: string): Promise<object | null>,
|
|
8
|
+
* lookup(index: string, tuple: string[]): Promise<string[]>,
|
|
7
9
|
* generationOf(kind: string, id: string): string | null,
|
|
8
10
|
* list(kind: string, predicate?: (record: object) => boolean): object[],
|
|
9
11
|
* put(kind: string, id: string, record: object, expectedGeneration?: string | null): string,
|
|
10
12
|
* append(event: object): object }} CoordinationTransaction
|
|
11
13
|
* @typedef {{ transaction(callback: (tx: CoordinationTransaction) => unknown,
|
|
12
|
-
* options?: { kinds?: string[], deadlineAt?: number }): Promise<unknown>,
|
|
14
|
+
* options?: { kinds?: string[], exactKinds?: string[], deadlineAt?: number }): Promise<unknown>,
|
|
13
15
|
* eventsSince(workspaceId: string, cursor: string | null, limit: number): Promise<object>,
|
|
14
16
|
* snapshot(workspaceId: string): Promise<object> }} CoordinationStore
|
|
15
17
|
*/
|
|
@@ -26,6 +28,15 @@ const REQUIRED = Object.freeze({
|
|
|
26
28
|
// vanish with their session.
|
|
27
29
|
const EPHEMERAL = ["get", "put", "update", "delete", "list"];
|
|
28
30
|
|
|
31
|
+
export function assertExactTransaction(tx) {
|
|
32
|
+
for (const method of ["load", "lookup"]) {
|
|
33
|
+
if (typeof tx?.[method] !== "function") {
|
|
34
|
+
throw new AccError(EXIT.USAGE, `the exact transaction port must implement ${method}()`,
|
|
35
|
+
{ port: "store.transaction", method });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
// Ports are validated at construction rather than at first use. A core that
|
|
30
41
|
// silently falls back to ambient time or randomness produces tests that pass
|
|
31
42
|
// for the wrong reason and races that only appear on someone else's machine.
|