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
|
@@ -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.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AccError, EXIT, SCHEMA_VERSION, advanceReceipt }
|
|
2
2
|
from "@agents-can-communicate/protocol";
|
|
3
3
|
|
|
4
|
+
import { assertExactTransaction } from "./ports.mjs";
|
|
4
5
|
import { receiptId } from "./conversations.mjs";
|
|
5
6
|
|
|
6
7
|
// How long a live offer may stand with nothing after it before the next turn
|
|
@@ -78,6 +79,12 @@ function requireReceipt(tx, messageId, recipientParticipantId) {
|
|
|
78
79
|
return { message, receipt };
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
async function loadReceipt(tx, input) {
|
|
83
|
+
assertExactTransaction(tx);
|
|
84
|
+
await tx.load("receipt", receiptId(input.messageId, input.recipientParticipantId));
|
|
85
|
+
await tx.load("message", input.messageId);
|
|
86
|
+
}
|
|
87
|
+
|
|
81
88
|
function requireOfferableReceipt(tx, input, { allowRoomNextTurn }) {
|
|
82
89
|
const found = requireReceipt(tx, input.messageId, input.recipientParticipantId);
|
|
83
90
|
if (found.message.toParticipantIds.length === 0
|
|
@@ -105,8 +112,10 @@ export function createReceiptService(ports) {
|
|
|
105
112
|
const { store, clock, ids } = ports;
|
|
106
113
|
|
|
107
114
|
async function readReceipt(input) {
|
|
108
|
-
return store.transaction(tx =>
|
|
109
|
-
|
|
115
|
+
return store.transaction(async tx => {
|
|
116
|
+
await loadReceipt(tx, input);
|
|
117
|
+
return requireReceipt(tx, input.messageId, input.recipientParticipantId).receipt;
|
|
118
|
+
}, { kinds: ["message", "receipt"], exactKinds: ["message", "receipt"] });
|
|
110
119
|
}
|
|
111
120
|
|
|
112
121
|
async function recordOfferSucceeded(input) {
|
|
@@ -116,7 +125,9 @@ export function createReceiptService(ports) {
|
|
|
116
125
|
{ messageId: input.messageId, transport: input.transport });
|
|
117
126
|
}
|
|
118
127
|
const now = clock.now();
|
|
119
|
-
return store.transaction(tx => {
|
|
128
|
+
return store.transaction(async tx => {
|
|
129
|
+
await loadReceipt(tx, input);
|
|
130
|
+
await tx.load("session", input.targetSessionId);
|
|
120
131
|
const id = receiptId(input.messageId, input.recipientParticipantId);
|
|
121
132
|
const receipt = requireOfferableReceipt(tx, input, { allowRoomNextTurn: true });
|
|
122
133
|
const target = requireTarget(tx, input, { mustBeOpen: true });
|
|
@@ -153,7 +164,8 @@ export function createReceiptService(ports) {
|
|
|
153
164
|
tx.put("receipt", id, offered, tx.generationOf("receipt", id));
|
|
154
165
|
success({});
|
|
155
166
|
return offered;
|
|
156
|
-
}, { kinds: ["session", "message", "receipt"],
|
|
167
|
+
}, { kinds: ["session", "message", "receipt"],
|
|
168
|
+
exactKinds: ["session", "message", "receipt"], deadlineAt: input.deadlineAt });
|
|
157
169
|
}
|
|
158
170
|
|
|
159
171
|
async function recordOfferFailed(input) {
|
|
@@ -162,7 +174,9 @@ export function createReceiptService(ports) {
|
|
|
162
174
|
{ safeErrorCode: input.safeErrorCode });
|
|
163
175
|
}
|
|
164
176
|
const now = clock.now();
|
|
165
|
-
return store.transaction(tx => {
|
|
177
|
+
return store.transaction(async tx => {
|
|
178
|
+
await loadReceipt(tx, input);
|
|
179
|
+
await tx.load("session", input.targetSessionId);
|
|
166
180
|
const receipt = requireOfferableReceipt(tx, input, { allowRoomNextTurn: false });
|
|
167
181
|
const target = requireTarget(tx, input, { mustBeOpen: false });
|
|
168
182
|
return tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
|
|
@@ -174,7 +188,7 @@ export function createReceiptService(ports) {
|
|
|
174
188
|
targetGeneration: target.generation,
|
|
175
189
|
transport: input.transport, adapterId: input.adapterId,
|
|
176
190
|
clientVersion: input.clientVersion, safeErrorCode: input.safeErrorCode } });
|
|
177
|
-
}, { kinds: ["session", "message", "receipt"] });
|
|
191
|
+
}, { kinds: ["session", "message", "receipt"], exactKinds: ["session", "message", "receipt"] });
|
|
178
192
|
}
|
|
179
193
|
|
|
180
194
|
return { readReceipt, recordOfferSucceeded, recordOfferFailed };
|
|
@@ -35,7 +35,7 @@ import { appendStartOwner, appendToolOwner, assertOwnerQuotable, ownerHeader, ow
|
|
|
35
35
|
const DEFAULT_BUDGET_MS = 5_000;
|
|
36
36
|
// Failures the hook output explains with advice of its own; any other error
|
|
37
37
|
// text stays out of what the client shows.
|
|
38
|
-
const KNOWN_FAILURES = new Set(["workspace_contains_runtime", "workspace_path_unquotable"]);
|
|
38
|
+
const KNOWN_FAILURES = new Set(["workspace_contains_runtime", "workspace_path_unquotable", "store_migration_required"]);
|
|
39
39
|
// Windows reads the table through WMI in Script Host, 145 ms on a
|
|
40
40
|
// windows-latest runner, and through PowerShell where Script Host is off, whose
|
|
41
41
|
// first start on a machine took 3.8 s. It gets more of the budget there, and
|
|
@@ -822,6 +822,7 @@ export async function runHook({ adapterId, payload, adapters, dataHome, env,
|
|
|
822
822
|
if (adapter === undefined) throw new Error(`no adapter named ${adapterId}`);
|
|
823
823
|
|
|
824
824
|
const event = await adapter.normalizeHook(payload, { args });
|
|
825
|
+
trail.kind = event.kind;
|
|
825
826
|
// A session start reads the process table once, and it depends on nothing
|
|
826
827
|
// below: it starts now, while the store opens. On Windows it is the part
|
|
827
828
|
// of a start that runs another program.
|
|
@@ -900,15 +901,34 @@ export async function runHook({ adapterId, payload, adapters, dataHome, env,
|
|
|
900
901
|
});
|
|
901
902
|
return await Promise.race([execute(), budget]);
|
|
902
903
|
} catch (error) {
|
|
904
|
+
const failureCode = error instanceof AccError && KNOWN_FAILURES.has(error.details?.reasonCode)
|
|
905
|
+
? error.details.reasonCode : null;
|
|
903
906
|
return { ...fallback, failed: true, reason: error.message,
|
|
904
|
-
...(
|
|
905
|
-
|
|
907
|
+
...(failureCode === null ? {} : { failureCode }),
|
|
908
|
+
...(failureCode === "store_migration_required"
|
|
909
|
+
? { stdout: migrationNotice(adapters?.[adapterId], trail.kind) } : {}),
|
|
906
910
|
...(Date.now() >= deadline ? { timedOut: true, phase: trail.phase } : {}) };
|
|
907
911
|
} finally {
|
|
908
912
|
clearTimeout(timer);
|
|
909
913
|
}
|
|
910
914
|
}
|
|
911
915
|
|
|
916
|
+
// What a person reads when their workspace's store waits for its migration: a
|
|
917
|
+
// client that shows hook output to the user says so, instead of the stderr line
|
|
918
|
+
// no client showed (2026-10-06). Never a hook input or error text.
|
|
919
|
+
const MIGRATION_NOTICE = "ACC: coordination is paused in this workspace until its store moves "
|
|
920
|
+
+ "to the new format. ACC does this by itself once older ACC clients have stopped; to do it "
|
|
921
|
+
+ "now, stop them and run: acc doctor --migrate-store";
|
|
922
|
+
function migrationNotice(adapter, kind) {
|
|
923
|
+
if (typeof adapter?.failOpenNotice !== "function" || typeof kind !== "string") return "";
|
|
924
|
+
try {
|
|
925
|
+
const rendered = adapter.failOpenNotice({ kind, message: MIGRATION_NOTICE });
|
|
926
|
+
return typeof rendered === "string" ? rendered : "";
|
|
927
|
+
} catch {
|
|
928
|
+
return "";
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
912
932
|
/**
|
|
913
933
|
* Register a client session before its first turn (#167).
|
|
914
934
|
*
|
|
@@ -12,3 +12,4 @@ export { CONFIG_FILENAME, CONFIG_SCHEMA_VERSION, RUNTIME_KEYS, defaultProjectCon
|
|
|
12
12
|
export { RECEIPT_STATES, advanceReceipt } from "./states.mjs";
|
|
13
13
|
export { assertMatchableResource, normaliseResource } from "./resources.mjs";
|
|
14
14
|
export { assertDecisionChange } from "./decision-changes.mjs";
|
|
15
|
+
export { TRANSACTION_INDEXES, assertIndexTuple, indexKeysFor } from "./transaction-indexes.mjs";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { AccError, EXIT } from "./errors.mjs";
|
|
2
|
+
import { assertPortableId } from "./ids.mjs";
|
|
3
|
+
|
|
4
|
+
export const TRANSACTION_INDEXES = Object.freeze({
|
|
5
|
+
messageByClientKey: Object.freeze({ kind: "message", fields: ["workspaceId", "fromParticipantId", "clientMessageId"] }),
|
|
6
|
+
receiptsByMessage: Object.freeze({ kind: "receipt", fields: ["workspaceId", "messageId"] }),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export function assertIndexTuple(index, tuple) {
|
|
10
|
+
const definition = Object.hasOwn(TRANSACTION_INDEXES, index) ? TRANSACTION_INDEXES[index] : null;
|
|
11
|
+
if (definition === null || !Array.isArray(tuple) || tuple.length !== definition.fields.length) {
|
|
12
|
+
throw new AccError(EXIT.DATA, "invalid transaction index tuple", { index });
|
|
13
|
+
}
|
|
14
|
+
tuple.forEach((value, position) => assertPortableId(value, definition.fields[position]));
|
|
15
|
+
return tuple;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function indexKeysFor(kind, record) {
|
|
19
|
+
return Object.entries(TRANSACTION_INDEXES).filter(([, definition]) => definition.kind === kind)
|
|
20
|
+
.map(([index, definition]) => ({ index,
|
|
21
|
+
tuple: assertIndexTuple(index, definition.fields.map(field => record[field])) }));
|
|
22
|
+
}
|
|
@@ -4,17 +4,26 @@ import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protoc
|
|
|
4
4
|
|
|
5
5
|
import { encode, nameCommittedLater, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
|
|
6
6
|
|
|
7
|
-
export const STORE_VERSION =
|
|
7
|
+
export const STORE_VERSION = 7;
|
|
8
8
|
|
|
9
9
|
export function identityPath(paths) {
|
|
10
10
|
return path.join(paths.root, "protocol.json");
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
function assertIdentity(record, workspaceId, filePath) {
|
|
14
|
-
if (record?.storeVersion
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
function assertIdentity(record, workspaceId, filePath, versions = [STORE_VERSION]) {
|
|
14
|
+
if (!versions.includes(record?.storeVersion)) {
|
|
15
|
+
const message = record?.storeVersion === 6
|
|
16
|
+
? "store contract 6 requires explicit migration: stop other store users and run acc doctor --migrate-store"
|
|
17
|
+
: "unknown store version";
|
|
18
|
+
throw new AccError(EXIT.DATA, message, { filePath,
|
|
19
|
+
storeVersion: record?.storeVersion,
|
|
20
|
+
...(record?.storeVersion === 6 ? { reasonCode: "store_migration_required" } : {}) });
|
|
17
21
|
}
|
|
22
|
+
if (Object.keys(record).sort().join(",") !== "initialisedAt,storeVersion,workspaceId"
|
|
23
|
+
|| typeof record.initialisedAt !== "string" || !Number.isFinite(Date.parse(record.initialisedAt))) {
|
|
24
|
+
throw new AccError(EXIT.DATA, "invalid store identity", { filePath });
|
|
25
|
+
}
|
|
26
|
+
assertPortableId(record.workspaceId, "store workspace id");
|
|
18
27
|
if (record.workspaceId !== workspaceId) {
|
|
19
28
|
throw new AccError(EXIT.DATA, "store belongs to a different workspace",
|
|
20
29
|
{ filePath, expected: workspaceId, actual: record.workspaceId });
|
|
@@ -63,9 +72,12 @@ export async function requireStoreIdentity(paths, { workspaceId, clock, create =
|
|
|
63
72
|
export async function readStoreIdentity(paths) {
|
|
64
73
|
const found = await readJsonIfPresent(identityPath(paths), paths.root);
|
|
65
74
|
if (found === null) return null;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
75
|
+
return assertIdentity(found.value, found.value?.workspaceId, identityPath(paths));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function readMigrationIdentity(paths, workspaceId) {
|
|
79
|
+
assertPortableId(workspaceId, "workspace id");
|
|
80
|
+
const found = await readJsonIfPresent(identityPath(paths), paths.root);
|
|
81
|
+
if (found === null) throw new AccError(EXIT.DATA, "store is not initialised", { filePath: identityPath(paths) });
|
|
82
|
+
return assertIdentity(found.value, workspaceId, identityPath(paths), [6, STORE_VERSION]);
|
|
71
83
|
}
|