agents-can-communicate 0.6.3 → 0.7.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 +3 -1
- package/bin/entrypoints/hook-output.mjs +12 -0
- package/docs/ARCHITECTURE.md +5 -0
- package/docs/CAPABILITIES.md +1 -1
- package/docs/CLI.md +50 -2
- package/docs/CONCEPTS.md +7 -0
- package/docs/GETTING_STARTED.md +2 -2
- package/docs/PROTOCOL.md +31 -0
- package/docs/TROUBLESHOOTING.md +4 -3
- 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 +8 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/install.mjs +2 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/native-delivery.mjs +57 -11
- 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 +8 -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 +8 -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/extension/skills/acc/SKILL.md +8 -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 +8 -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 +8 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +15 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt-writer.mjs +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt.mjs +3 -1
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +6 -0
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +13 -1
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -1
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +33 -8
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/attention.mjs +7 -2
- package/node_modules/@agents-can-communicate/core/src/conversations.mjs +2 -2
- package/node_modules/@agents-can-communicate/core/src/intents.mjs +16 -0
- package/node_modules/@agents-can-communicate/core/src/next-turn-delivery.mjs +21 -4
- package/node_modules/@agents-can-communicate/core/src/prune.mjs +241 -0
- package/node_modules/@agents-can-communicate/core/src/receipts.mjs +66 -10
- package/node_modules/@agents-can-communicate/core/src/service.mjs +3 -0
- package/node_modules/@agents-can-communicate/core/src/status.mjs +25 -1
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +15 -2
- 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/native-attempt.mjs +60 -7
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +62 -27
- 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/mcp-server/src/tools.mjs +5 -3
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +26 -7
- package/node_modules/@agents-can-communicate/storage-filesystem/src/doomed-directory.mjs +98 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/event-floor.mjs +52 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/reclaim.mjs +272 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +54 -7
- package/node_modules/@agents-can-communicate/storage-filesystem/src/stage-sweep.mjs +174 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +97 -5
- package/package.json +1 -1
|
@@ -267,7 +267,19 @@ export async function runDoctor({ options, context, runtime }) {
|
|
|
267
267
|
// command documented as saying "what to run next" said it only to `--json`.
|
|
268
268
|
// A person running `acc doctor` on a client wired to an older plugin was told
|
|
269
269
|
// the store was healthy and nothing else.
|
|
270
|
-
|
|
270
|
+
// Named rather than left to be inferred: staging files are reclaimed a
|
|
271
|
+
// bounded amount at a time, so a store still carrying a large accumulation
|
|
272
|
+
// should say so instead of reading as if nothing were held.
|
|
273
|
+
const held = [
|
|
274
|
+
report.staged > 0 ? `${report.staged} staged` : null,
|
|
275
|
+
report.partials > 0 ? `${report.partials} partial` : null,
|
|
276
|
+
// A retired journal entry has no reader at all, so a count above zero says
|
|
277
|
+
// the automatic pass has not caught up rather than that anything is wrong.
|
|
278
|
+
report.retired > 0 ? `${report.retired} retired journal` : null,
|
|
279
|
+
report.trimmedThrough === null ? null : `history from ${report.trimmedThrough}`,
|
|
280
|
+
].filter(Boolean);
|
|
281
|
+
const staging = held.length === 0 ? "" : ` (${held.join(", ")})`;
|
|
282
|
+
const text = [`store healthy${staging}; ${describePresence(status.counts)}; `
|
|
271
283
|
+ `protection ${status.protection}; ${installed} of ${adapters.length} adapter(s) installed`,
|
|
272
284
|
...adapters.filter(adapter => (adapter.present || adapter.installed)
|
|
273
285
|
&& adapter.nativeDelivery.reasonCode === "native_delivery_unsupported"
|
|
@@ -17,7 +17,7 @@ import { needsOwner } from "./session-owner.mjs";
|
|
|
17
17
|
* protocol's enum choices before a caller has to attempt a mutation.
|
|
18
18
|
*/
|
|
19
19
|
const GROUPS = Object.freeze([
|
|
20
|
-
["Set up", ["install", "uninstall", "doctor", "config"]],
|
|
20
|
+
["Set up", ["install", "uninstall", "doctor", "config", "prune"]],
|
|
21
21
|
["In a session", ["status", "sync", "work", "claim", "release", "inbox", "reply",
|
|
22
22
|
"ack", "message", "request", "finish"]],
|
|
23
23
|
["Session lifecycle", ["attach", "heartbeat", "detach"]],
|
|
@@ -30,6 +30,7 @@ const SUMMARY = Object.freeze({
|
|
|
30
30
|
doctor: "clients, versions, install health, and what to run next",
|
|
31
31
|
config: "write or check acc.workspace.json (init | validate)",
|
|
32
32
|
status: "who else is here, what they hold, how protected this workspace is",
|
|
33
|
+
prune: "report what this workspace no longer needs; --apply to reclaim it",
|
|
33
34
|
sync: "events since a cursor, or bounded message history",
|
|
34
35
|
work: "publish what this session is doing, or --clear when it has stopped",
|
|
35
36
|
claim: "reserve a resource; exit 5 when someone else already holds it",
|
|
@@ -174,6 +174,20 @@ export function describePresence({ live = 0, stale = 0 } = {}) {
|
|
|
174
174
|
return `${live} live (${stale} not answering)`;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
export function describeStatus(status) {
|
|
178
|
+
// `live` counts everyone present, which includes sessions gone stale - a
|
|
179
|
+
// client that exited without its session being closed keeps a record that
|
|
180
|
+
// stops being answered but does not disappear. Printing the number alone
|
|
181
|
+
// said "1 live" about a workspace where the last agent had left minutes
|
|
182
|
+
// before, which is the one thing a person reads this line to find out.
|
|
183
|
+
const line = `${describePresence(status.counts)}; ${status.counts.claims} claim(s); `
|
|
184
|
+
+ `protection ${status.protection}`;
|
|
185
|
+
// A transport accepting a message is not a model reading it. Said only when
|
|
186
|
+
// it is true of something, so the everyday line stays the everyday line.
|
|
187
|
+
const offered = status.counts.unretrieved?.offered ?? 0;
|
|
188
|
+
return offered === 0 ? line : `${line}; ${offered} offered, not retrieved`;
|
|
189
|
+
}
|
|
190
|
+
|
|
177
191
|
const HANDLERS = Object.freeze({
|
|
178
192
|
attach: async ({ options, context }) => {
|
|
179
193
|
const session = await context.service.openSession({
|
|
@@ -336,14 +350,7 @@ const HANDLERS = Object.freeze({
|
|
|
336
350
|
status: async ({ options, context }) => {
|
|
337
351
|
const status = await context.service.collectStatus({
|
|
338
352
|
participantId: options.participant, all: options.all === true });
|
|
339
|
-
|
|
340
|
-
// client that exited without its session being closed keeps a record that
|
|
341
|
-
// stops being answered but does not disappear. Printing the number alone
|
|
342
|
-
// said "1 live" about a workspace where the last agent had left minutes
|
|
343
|
-
// before, which is the one thing a person reads this line to find out.
|
|
344
|
-
const text = `${describePresence(status.counts)}; ${status.counts.claims} claim(s); `
|
|
345
|
-
+ `protection ${status.protection}`;
|
|
346
|
-
return { data: status, text };
|
|
353
|
+
return { data: status, text: describeStatus(status) };
|
|
347
354
|
},
|
|
348
355
|
|
|
349
356
|
config: async ({ options, runtime }) => {
|
|
@@ -379,6 +386,24 @@ const HANDLERS = Object.freeze({
|
|
|
379
386
|
uninstall: async ({ options, runtime }) =>
|
|
380
387
|
runInstallCommand({ options, runtime, action: "uninstall" }),
|
|
381
388
|
|
|
389
|
+
prune: async ({ options, context }) => {
|
|
390
|
+
const result = await context.service.prune({
|
|
391
|
+
classes: options.class, before: options.before,
|
|
392
|
+
apply: options.apply === true, limit: Infinity });
|
|
393
|
+
const named = Object.entries(result.counts)
|
|
394
|
+
.map(([name, count]) => `${count} ${name}`).join(", ");
|
|
395
|
+
// The reporting run says what it would do; the applying run says what the
|
|
396
|
+
// store gave back, which is not the same number - a record and the markers
|
|
397
|
+
// it owns leave together.
|
|
398
|
+
const text = result.applied
|
|
399
|
+
? `reclaimed ${result.reclaimed} file(s) from ${named}`
|
|
400
|
+
+ (result.trimmedThrough === null ? "" : `; history starts after ${result.trimmedThrough}`)
|
|
401
|
+
+ (result.skipped > 0 ? `; ${result.skipped} changed and were left` : "")
|
|
402
|
+
+ (result.remaining ? "; more remains, run it again" : "")
|
|
403
|
+
: `would prune ${named}; nothing was changed, add --apply to act`;
|
|
404
|
+
return { data: result, text };
|
|
405
|
+
},
|
|
406
|
+
|
|
382
407
|
doctor: async ({ options, context, runtime }) => runDoctor({ options, context, runtime }),
|
|
383
408
|
|
|
384
409
|
update: async ({ options, runtime }) => runUpdateCommand({ options, runtime }),
|
|
@@ -3,6 +3,7 @@ import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
|
3
3
|
import { overlaps } from "./claims.mjs";
|
|
4
4
|
import { classifySessionPresence } from "./sessions.mjs";
|
|
5
5
|
import { decisionView, isCurrentDecision } from "./decision-state.mjs";
|
|
6
|
+
import { isCurrentIntent } from "./intents.mjs";
|
|
6
7
|
|
|
7
8
|
export const ATTENTION_PRIORITY = Object.freeze({
|
|
8
9
|
reply_required: 1,
|
|
@@ -65,7 +66,11 @@ function expiredClaims(snapshot, session, now) {
|
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
function claimConflicts(snapshot, session, now) {
|
|
68
|
-
|
|
69
|
+
// Both kinds read resourceHints as a statement of what a session means to
|
|
70
|
+
// touch. Finishing that work retracts the statement, so a done record must
|
|
71
|
+
// not keep raising conflicts on either side of it.
|
|
72
|
+
const mine = (snapshot.intents ?? []).find(intent => intent.sessionId === session?.sessionId
|
|
73
|
+
&& isCurrentIntent(intent));
|
|
69
74
|
if (mine === undefined) return [];
|
|
70
75
|
return (snapshot.claims ?? []).filter(claim => claim.ownerSessionId !== session.sessionId
|
|
71
76
|
&& Date.parse(claim.expiresAt) > Date.parse(now)
|
|
@@ -78,7 +83,7 @@ function claimConflicts(snapshot, session, now) {
|
|
|
78
83
|
function claimContended(snapshot, session, now) {
|
|
79
84
|
if (session == null) return [];
|
|
80
85
|
const peerIntents = (snapshot.intents ?? [])
|
|
81
|
-
.filter(intent => intent.sessionId !== session.sessionId);
|
|
86
|
+
.filter(intent => intent.sessionId !== session.sessionId && isCurrentIntent(intent));
|
|
82
87
|
return (snapshot.claims ?? []).filter(claim => claim.ownerSessionId === session.sessionId
|
|
83
88
|
&& Date.parse(claim.expiresAt) > Date.parse(now)).flatMap(claim => {
|
|
84
89
|
const peer = peerIntents.find(intent => intent.resourceHints
|
|
@@ -292,8 +292,8 @@ export function createConversationService(ports, sessions) {
|
|
|
292
292
|
return (await nextTurnDelivery(input)).queuedMessages;
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
-
function nextTurnDelivery(input) {
|
|
296
|
-
return selectNextTurnDelivery(store, input);
|
|
295
|
+
function nextTurnDelivery(input = {}) {
|
|
296
|
+
return selectNextTurnDelivery(store, { ...input, now: clock.now() });
|
|
297
297
|
}
|
|
298
298
|
|
|
299
299
|
return { sendMessage, finishSession, pendingMessages, nextTurnDelivery };
|
|
@@ -3,6 +3,22 @@ import { AccError, EXIT, SCHEMA_VERSION, validateRecord }
|
|
|
3
3
|
|
|
4
4
|
import { isMaterialised } from "./materialisation.mjs";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Does this record still answer "what is this session doing now?".
|
|
8
|
+
*
|
|
9
|
+
* `clearIntent` marks a durable intent done instead of erasing it, so the
|
|
10
|
+
* workspace keeps what each session worked on. That leaves every reader to
|
|
11
|
+
* exclude the finished ones, and this is the single place that decides which
|
|
12
|
+
* states count - status and attention disagreeing about the same record is
|
|
13
|
+
* exactly the defect this exists to prevent.
|
|
14
|
+
*
|
|
15
|
+
* Stopped is not finished. A blocked or waiting session still owns its work and
|
|
16
|
+
* the resourceHints that announce it, so only "done" gives them up.
|
|
17
|
+
*/
|
|
18
|
+
export function isCurrentIntent(intent) {
|
|
19
|
+
return intent.state !== "done";
|
|
20
|
+
}
|
|
21
|
+
|
|
6
22
|
// Intent answers "what is this session doing now?". It is awareness, not
|
|
7
23
|
// authorisation: an edit intent never substitutes for a claim, which is why
|
|
8
24
|
// resourceHints are advisory strings rather than reservations.
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { decisionView, isCurrentDecision } from "./decision-state.mjs";
|
|
2
|
+
import { dueForRepeat, offerFacts } from "./receipts.mjs";
|
|
3
|
+
|
|
1
4
|
export async function nextTurnDelivery(store, input = {}) {
|
|
2
5
|
if (typeof input.participantId !== "string" || input.participantId === "") {
|
|
3
6
|
return { queuedMessages: [], liveOfferedMessageIds: [], reminderMessageIds: [],
|
|
4
|
-
roomMessageIds: [] };
|
|
7
|
+
roomMessageIds: [], repeatMessages: [], repeatOffers: [] };
|
|
5
8
|
}
|
|
6
9
|
const snapshot = await store.snapshot(input.workspaceId ?? store.workspaceId,
|
|
7
10
|
{ kinds: ["message", "receipt"] });
|
|
@@ -13,19 +16,33 @@ export async function nextTurnDelivery(store, input = {}) {
|
|
|
13
16
|
&& message.fromSessionId !== input.exceptSessionId)
|
|
14
17
|
.sort((left, right) => left.sentAt.localeCompare(right.sentAt)
|
|
15
18
|
|| left.messageId.localeCompare(right.messageId));
|
|
19
|
+
// Only a caller that will show them asks. A repeat taken out of the reminder
|
|
20
|
+
// lists and then never shown would be the one message this turn forgot.
|
|
21
|
+
const repeatMessages = input.repeats === true
|
|
22
|
+
? eligible.filter(message => dueForRepeat(message,
|
|
23
|
+
receiptByMessage.get(message.messageId), input.now))
|
|
24
|
+
: [];
|
|
25
|
+
const repeating = new Set(repeatMessages.map(message => message.messageId));
|
|
16
26
|
return {
|
|
17
27
|
queuedMessages: eligible.filter(message =>
|
|
18
28
|
receiptByMessage.get(message.messageId).state === "queued"),
|
|
19
29
|
liveOfferedMessageIds: eligible.filter(message => message.toParticipantIds.length > 0
|
|
20
|
-
&& receiptByMessage.get(message.messageId).state === "offered"
|
|
30
|
+
&& receiptByMessage.get(message.messageId).state === "offered"
|
|
31
|
+
&& !repeating.has(message.messageId))
|
|
21
32
|
.map(message => message.messageId),
|
|
22
33
|
// A missing body is not evidence of delivery: a degraded adapter may have
|
|
23
34
|
// withheld it. Only this recipient's durable receipt permits a reminder.
|
|
24
35
|
reminderMessageIds: eligible.filter(message => message.obligation !== "none"
|
|
25
|
-
&& ["offered", "retrieved"].includes(receiptByMessage.get(message.messageId).state)
|
|
36
|
+
&& ["offered", "retrieved"].includes(receiptByMessage.get(message.messageId).state)
|
|
37
|
+
&& !repeating.has(message.messageId))
|
|
26
38
|
.map(message => message.messageId),
|
|
27
39
|
roomMessageIds: eligible.filter(message => message.toParticipantIds.length === 0)
|
|
28
40
|
.map(message => message.messageId),
|
|
41
|
+
// After the queued bodies, so a new message wins the byte budget first.
|
|
42
|
+
repeatMessages,
|
|
43
|
+
repeatOffers: repeatMessages.map(message => {
|
|
44
|
+
const { transport, at } = offerFacts(receiptByMessage.get(message.messageId));
|
|
45
|
+
return { messageId: message.messageId, transport, at };
|
|
46
|
+
}),
|
|
29
47
|
};
|
|
30
48
|
}
|
|
31
|
-
import { decisionView, isCurrentDecision } from "./decision-state.mjs";
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
2
|
+
|
|
3
|
+
import { classifySessionPresence } from "./sessions.mjs";
|
|
4
|
+
|
|
5
|
+
// What an operator can ask to reclaim. The workspace record is deliberately
|
|
6
|
+
// absent: removing it would not clean a workspace, it would erase one.
|
|
7
|
+
export const PRUNE_CLASSES = Object.freeze(["sessions", "intents", "claims", "participants"]);
|
|
8
|
+
|
|
9
|
+
function assertClasses(classes) {
|
|
10
|
+
for (const name of classes) {
|
|
11
|
+
if (!PRUNE_CLASSES.includes(name)) {
|
|
12
|
+
throw new AccError(EXIT.USAGE,
|
|
13
|
+
`prune class is one of ${PRUNE_CLASSES.join(", ")}`, { class: name });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const of = (envelopes, kind) => envelopes.filter(envelope => envelope.kind === kind);
|
|
19
|
+
|
|
20
|
+
const CURSOR = /^[0-9]{16}$/;
|
|
21
|
+
const EVENT_PAGE = 500;
|
|
22
|
+
|
|
23
|
+
function assertBefore(before) {
|
|
24
|
+
if (typeof before !== "string" || !CURSOR.test(before)) {
|
|
25
|
+
throw new AccError(EXIT.USAGE,
|
|
26
|
+
"before is the 16-digit cursor a previous sync returned", { before });
|
|
27
|
+
}
|
|
28
|
+
return before;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read the log up to the boundary, counting it and naming what it recorded.
|
|
33
|
+
*
|
|
34
|
+
* The boundary is a cursor rather than a date on purpose: a cursor is what
|
|
35
|
+
* `sync` already returns and what a peer already holds, so an operator trimming
|
|
36
|
+
* to one names the same point its readers name. A duration would have to be
|
|
37
|
+
* resolved to a sequence anyway, and the resolution could move between the
|
|
38
|
+
* report and the apply.
|
|
39
|
+
*/
|
|
40
|
+
async function readBelow(store, workspaceId, before) {
|
|
41
|
+
const messageIds = new Set();
|
|
42
|
+
let events = 0;
|
|
43
|
+
let cursor = null;
|
|
44
|
+
for (;;) {
|
|
45
|
+
const page = await store.eventsSince(workspaceId, cursor, EVENT_PAGE);
|
|
46
|
+
if (page.events.length === 0) break;
|
|
47
|
+
let past = false;
|
|
48
|
+
for (const event of page.events) {
|
|
49
|
+
if (event.sequence > before) {
|
|
50
|
+
past = true;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
events += 1;
|
|
54
|
+
if (event.type === "message.recorded") messageIds.add(event.payload.messageId);
|
|
55
|
+
}
|
|
56
|
+
if (past) break;
|
|
57
|
+
cursor = page.cursor;
|
|
58
|
+
}
|
|
59
|
+
return { events, messageIds };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A message is resolved when nobody is still owed it.
|
|
64
|
+
*
|
|
65
|
+
* Offered is not read and retrieved is not model attention, so only an
|
|
66
|
+
* acknowledged receipt settles the obligation. A recipient that no longer
|
|
67
|
+
* exists cannot acknowledge anything, and holding the message for it would
|
|
68
|
+
* keep the whole thread forever.
|
|
69
|
+
*/
|
|
70
|
+
function resolvedMessages(envelopes, messageIds) {
|
|
71
|
+
const participants = new Set(of(envelopes, "participant").map(envelope => envelope.id));
|
|
72
|
+
const open = new Set(of(envelopes, "receipt").map(envelope => envelope.record)
|
|
73
|
+
.filter(receipt => receipt.state !== "acknowledged"
|
|
74
|
+
&& participants.has(receipt.recipientParticipantId))
|
|
75
|
+
.map(receipt => receipt.messageId));
|
|
76
|
+
return of(envelopes, "message")
|
|
77
|
+
.filter(envelope => messageIds.has(envelope.id) && !open.has(envelope.id));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Which records describe work that has stopped for good.
|
|
82
|
+
*
|
|
83
|
+
* Only `offline` qualifies for a session, which is a confirmed dead pid or a
|
|
84
|
+
* full day without a heartbeat. `stale` is a session that has gone quiet, and a
|
|
85
|
+
* quiet session is still someone's.
|
|
86
|
+
*/
|
|
87
|
+
function eligibleSessions(envelopes, now, pidIsAlive) {
|
|
88
|
+
// A live claim names the session that holds it, and that name is how a peer
|
|
89
|
+
// blocked by the claim finds someone to ask. Removing the session leaves the
|
|
90
|
+
// claim reading as owned by nobody, so a session keeping a live lease stays
|
|
91
|
+
// however long it has been quiet. Its claim expiring makes both eligible.
|
|
92
|
+
const holding = new Set(of(envelopes, "claim")
|
|
93
|
+
.filter(envelope => Date.parse(envelope.record.expiresAt) > Date.parse(now))
|
|
94
|
+
.map(envelope => envelope.record.ownerSessionId));
|
|
95
|
+
return of(envelopes, "session")
|
|
96
|
+
.filter(envelope => !holding.has(envelope.id)
|
|
97
|
+
&& classifySessionPresence(envelope.record, now, pidIsAlive) === "offline");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A participant is kept while anything still points at it.
|
|
102
|
+
*
|
|
103
|
+
* `removedSessions` is what this pass actually takes, not what it could take.
|
|
104
|
+
* The difference matters when an operator names classes: asked for participants
|
|
105
|
+
* alone, every session stays, so every participant is still pointed at and none
|
|
106
|
+
* is eligible. Judging by what is merely *eligible* would remove a participant
|
|
107
|
+
* and leave its sessions naming a roster entry that is gone.
|
|
108
|
+
*
|
|
109
|
+
* Messages are not pruned unless a boundary says so, and the roster is where
|
|
110
|
+
* "who sent this" is answered, so a name on a surviving message stays too.
|
|
111
|
+
*/
|
|
112
|
+
function eligibleParticipants(envelopes, removedSessions, messages) {
|
|
113
|
+
const surviving = new Set(of(envelopes, "session")
|
|
114
|
+
.filter(envelope => !removedSessions.has(envelope.id))
|
|
115
|
+
.map(envelope => envelope.record.participantId));
|
|
116
|
+
const named = new Set(messages.flatMap(message =>
|
|
117
|
+
[message.fromParticipantId, ...message.toParticipantIds]));
|
|
118
|
+
return of(envelopes, "participant")
|
|
119
|
+
.filter(envelope => !surviving.has(envelope.id) && !named.has(envelope.id));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createPruneService(ports) {
|
|
123
|
+
const { store, clock, pidIsAlive } = ports;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* What `prune` would reclaim, as records rather than as counts.
|
|
127
|
+
*
|
|
128
|
+
* The plan is computed outside the writer mutex and applied inside it, so
|
|
129
|
+
* every entry carries the generation that proves it is still the record this
|
|
130
|
+
* judged. A claim renewed in between is skipped by the store rather than
|
|
131
|
+
* removed.
|
|
132
|
+
*/
|
|
133
|
+
async function planPrune(input = {}) {
|
|
134
|
+
const classes = input.classes ?? PRUNE_CLASSES;
|
|
135
|
+
assertClasses(classes);
|
|
136
|
+
const workspaceId = input.workspaceId ?? store.workspaceId;
|
|
137
|
+
const now = clock.now();
|
|
138
|
+
const envelopes = await store.stateEnvelopes(workspaceId);
|
|
139
|
+
const wants = name => classes.includes(name);
|
|
140
|
+
|
|
141
|
+
const eligible = wants("sessions") || wants("intents") || wants("participants")
|
|
142
|
+
? eligibleSessions(envelopes, now, pidIsAlive)
|
|
143
|
+
: [];
|
|
144
|
+
const sessions = wants("sessions") ? eligible : [];
|
|
145
|
+
const removedSessions = new Set(sessions.map(envelope => envelope.id));
|
|
146
|
+
const eligibleSessionIds = new Set(eligible.map(envelope => envelope.id));
|
|
147
|
+
|
|
148
|
+
// An intent belongs to one session and outlives nothing, so a session
|
|
149
|
+
// leaving takes its intent with it whether or not the operator named the
|
|
150
|
+
// class. Asking for intents alone reaches further: every intent whose
|
|
151
|
+
// session has stopped, including sessions this pass is keeping. Nothing
|
|
152
|
+
// points at an intent, so removing one leaves nothing dangling - but
|
|
153
|
+
// leaving one behind when its session goes would.
|
|
154
|
+
const owning = wants("intents") ? eligibleSessionIds : removedSessions;
|
|
155
|
+
const intents = of(envelopes, "intent")
|
|
156
|
+
.filter(envelope => owning.has(envelope.record.sessionId));
|
|
157
|
+
const claims = wants("claims")
|
|
158
|
+
? of(envelopes, "claim")
|
|
159
|
+
.filter(envelope => Date.parse(envelope.record.expiresAt) <= Date.parse(now))
|
|
160
|
+
: [];
|
|
161
|
+
|
|
162
|
+
// History is named by a boundary rather than by a class, because there is
|
|
163
|
+
// no such thing as an event that has stopped being true - only one the
|
|
164
|
+
// operator has decided nobody will read again.
|
|
165
|
+
const before = input.before === undefined ? null : assertBefore(input.before);
|
|
166
|
+
const below = before === null ? { events: 0, messageIds: new Set() }
|
|
167
|
+
: await readBelow(store, workspaceId, before);
|
|
168
|
+
const messages = before === null ? [] : resolvedMessages(envelopes, below.messageIds);
|
|
169
|
+
const doomedMessages = new Set(messages.map(envelope => envelope.id));
|
|
170
|
+
const receipts = of(envelopes, "receipt")
|
|
171
|
+
.filter(envelope => doomedMessages.has(envelope.record.messageId));
|
|
172
|
+
|
|
173
|
+
// Decided after the messages, and against the ones that will survive this
|
|
174
|
+
// pass. A participant named only by a message the same run removes is a
|
|
175
|
+
// participant nothing will point at once the run finishes; judging it
|
|
176
|
+
// against every message that exists now would keep it, report no work
|
|
177
|
+
// remaining, and need a second identical run to reclaim it.
|
|
178
|
+
const participants = wants("participants")
|
|
179
|
+
? eligibleParticipants(envelopes, removedSessions, of(envelopes, "message")
|
|
180
|
+
.map(envelope => envelope.record).filter(record => !doomedMessages.has(record.messageId)))
|
|
181
|
+
: [];
|
|
182
|
+
|
|
183
|
+
const named = { sessions, intents, claims, participants, messages, receipts };
|
|
184
|
+
// Ordered so that anything holding a reference goes before what it refers
|
|
185
|
+
// to. A pass can stop at its budget or its deadline, and what it has done
|
|
186
|
+
// by then is a prefix of this list - so the prefix has to be a state the
|
|
187
|
+
// store can be left in. A receipt names a message, a message and an intent
|
|
188
|
+
// and a claim each name a session, and a session names a participant.
|
|
189
|
+
// Removing them in that order means an interrupted pass never leaves a
|
|
190
|
+
// record pointing at one that is already gone.
|
|
191
|
+
const order = ["receipts", "messages", "intents", "claims", "sessions", "participants"];
|
|
192
|
+
const entries = order.flatMap(name => named[name])
|
|
193
|
+
.map(({ kind, id, generation }) => ({ kind, id, generation }));
|
|
194
|
+
return { workspaceId,
|
|
195
|
+
before,
|
|
196
|
+
counts: { ...Object.fromEntries(Object.entries(named)
|
|
197
|
+
.map(([name, list]) => [name, list.length])), events: below.events },
|
|
198
|
+
entries };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Reclaim what the plan named, or report it and change nothing.
|
|
203
|
+
*
|
|
204
|
+
* The dry run is the default because this is the one command that takes
|
|
205
|
+
* records out of the store. A caller that means it says so.
|
|
206
|
+
*/
|
|
207
|
+
async function prune(input = {}) {
|
|
208
|
+
let plan = await planPrune(input);
|
|
209
|
+
if (input.apply !== true) {
|
|
210
|
+
return { ...plan, applied: false, reclaimed: 0, skipped: 0, remaining: false,
|
|
211
|
+
trimmedThrough: null };
|
|
212
|
+
}
|
|
213
|
+
// Decided again inside the writer mutex, and that second reading is the one
|
|
214
|
+
// acted on. Eligibility here is relational - a participant is eligible
|
|
215
|
+
// because no session of theirs survives - and a generation proves only that
|
|
216
|
+
// one record did not change. A session opening between the report and the
|
|
217
|
+
// apply changes the answer without changing any record this had named.
|
|
218
|
+
let decided = plan;
|
|
219
|
+
const result = await store.reclaimRecords(async () => {
|
|
220
|
+
decided = await planPrune(input);
|
|
221
|
+
return decided.entries;
|
|
222
|
+
}, { limit: input.limit, deadlineAt: input.deadlineAt });
|
|
223
|
+
if (decided.before === null) {
|
|
224
|
+
return { ...decided, applied: true, ...result, trimmedThrough: null };
|
|
225
|
+
}
|
|
226
|
+
plan = decided;
|
|
227
|
+
// Records first, then the log. A message removed while its recording event
|
|
228
|
+
// survives reads as history describing a record that is gone, which is
|
|
229
|
+
// ordinary. The reverse - an event log that starts after a message it never
|
|
230
|
+
// mentions - is the gap the floor is there to report.
|
|
231
|
+
const trimmed = await store.trimHistory(plan.before,
|
|
232
|
+
{ limit: input.limit, deadlineAt: input.deadlineAt });
|
|
233
|
+
return { ...plan, applied: true,
|
|
234
|
+
reclaimed: result.reclaimed + trimmed.reclaimed,
|
|
235
|
+
skipped: result.skipped,
|
|
236
|
+
trimmedThrough: trimmed.trimmedThrough,
|
|
237
|
+
remaining: result.remaining || trimmed.remaining };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { planPrune, prune };
|
|
241
|
+
}
|
|
@@ -3,6 +3,42 @@ import { AccError, EXIT, SCHEMA_VERSION, advanceReceipt }
|
|
|
3
3
|
|
|
4
4
|
import { receiptId } from "./conversations.mjs";
|
|
5
5
|
|
|
6
|
+
// How long a live offer may stand with nothing after it before the next turn
|
|
7
|
+
// shows the body once more. Above the 90th percentile of the time models took
|
|
8
|
+
// to retrieve, acknowledge or answer an offered message on real stores.
|
|
9
|
+
export const REPEAT_OFFER_AFTER_MS = 15 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How and when a transport accepted this receipt's message, if anyone recorded it.
|
|
13
|
+
*
|
|
14
|
+
* Kept in `extensions` because a new receipt field would fail validation in an
|
|
15
|
+
* older ACC reading the same store. A receipt that an older ACC offered carries
|
|
16
|
+
* nothing here, and reads as `null`: unknown, never guessed.
|
|
17
|
+
*/
|
|
18
|
+
export function offerFacts(receipt) {
|
|
19
|
+
const offer = receipt?.extensions?.offer;
|
|
20
|
+
if (offer === null || typeof offer !== "object" || Array.isArray(offer)
|
|
21
|
+
|| typeof offer.transport !== "string" || typeof offer.at !== "string"
|
|
22
|
+
|| !Number.isFinite(Date.parse(offer.at))) return null;
|
|
23
|
+
return { transport: offer.transport, at: offer.at,
|
|
24
|
+
repeatedAt: offer.repeatedAt == null ? null : String(offer.repeatedAt) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A live transport accepted this message, and nothing followed for long enough.
|
|
29
|
+
*
|
|
30
|
+
* A next-turn offer is recorded only after the hook's stdout carried the body,
|
|
31
|
+
* which is as close to the model as ACC can see, so it is never repeated. A live
|
|
32
|
+
* offer proves a transport took the bytes. Once, the next turn shows it again.
|
|
33
|
+
*/
|
|
34
|
+
export function dueForRepeat(message, receipt, now) {
|
|
35
|
+
const offer = offerFacts(receipt);
|
|
36
|
+
return message != null && message.toParticipantIds.length > 0
|
|
37
|
+
&& receipt?.state === "offered" && offer !== null
|
|
38
|
+
&& offer.transport !== "next-turn" && offer.repeatedAt === null
|
|
39
|
+
&& Date.parse(now) - Date.parse(offer.at) >= REPEAT_OFFER_AFTER_MS;
|
|
40
|
+
}
|
|
41
|
+
|
|
6
42
|
export const SAFE_OFFER_ERROR_CODES = Object.freeze([
|
|
7
43
|
"ambiguous_recipient_sessions",
|
|
8
44
|
"delivery_disabled",
|
|
@@ -59,28 +95,48 @@ export function createReceiptService(ports) {
|
|
|
59
95
|
}
|
|
60
96
|
|
|
61
97
|
async function recordOfferSucceeded(input) {
|
|
98
|
+
const repeat = input.repeat === true;
|
|
99
|
+
if (repeat && input.transport !== "next-turn") {
|
|
100
|
+
throw new AccError(EXIT.DATA, "a repeated offer travels only through the next turn",
|
|
101
|
+
{ messageId: input.messageId, transport: input.transport });
|
|
102
|
+
}
|
|
62
103
|
const now = clock.now();
|
|
63
104
|
return store.transaction(tx => {
|
|
64
105
|
const id = receiptId(input.messageId, input.recipientParticipantId);
|
|
65
106
|
const receipt = requireOfferableReceipt(tx, input, { allowRoomNextTurn: true });
|
|
66
107
|
const target = requireTarget(tx, input, { mustBeOpen: true });
|
|
108
|
+
const success = extra => tx.append({ schemaVersion: SCHEMA_VERSION,
|
|
109
|
+
eventId: ids.next("event"), workspaceId: receipt.workspaceId,
|
|
110
|
+
actorSessionId: target.sessionId, type: "message.offer_succeeded", occurredAt: now,
|
|
111
|
+
payload: { messageId: input.messageId,
|
|
112
|
+
recipientParticipantId: input.recipientParticipantId,
|
|
113
|
+
targetSessionId: input.targetSessionId,
|
|
114
|
+
targetGeneration: input.targetGeneration,
|
|
115
|
+
transport: input.transport, adapterId: input.adapterId,
|
|
116
|
+
clientVersion: input.clientVersion, ...extra } });
|
|
117
|
+
if (repeat) {
|
|
118
|
+
// Judged again under the writer: a retrieval, an acknowledgement or
|
|
119
|
+
// another hook's repeat may have landed since this one was selected.
|
|
120
|
+
// The receipt keeps its state and its time; only the attempt is new.
|
|
121
|
+
if (!dueForRepeat(tx.get("message", input.messageId), receipt, now)) return receipt;
|
|
122
|
+
// Spread from the stored object, never from offerFacts: a later ACC
|
|
123
|
+
// may keep more about an offer, and this write must not drop it.
|
|
124
|
+
const repeated = { ...receipt, extensions: { ...receipt.extensions,
|
|
125
|
+
offer: { ...receipt.extensions.offer, repeatedAt: now } } };
|
|
126
|
+
tx.put("receipt", id, repeated, tx.generationOf("receipt", id));
|
|
127
|
+
success({ repeat: true });
|
|
128
|
+
return repeated;
|
|
129
|
+
}
|
|
67
130
|
// A retrieval or acknowledgement may win after a transport accepted but
|
|
68
131
|
// before this transaction acquired the writer. That stronger truth must
|
|
69
132
|
// remain in place. The same rule makes a repeated successful commit a
|
|
70
133
|
// read-only operation rather than another success event.
|
|
71
134
|
if (receipt.state !== "queued") return receipt;
|
|
72
135
|
const offered = { ...receipt, state: advanceReceipt(receipt.state, "offered"),
|
|
73
|
-
updatedAt: now
|
|
136
|
+
updatedAt: now, extensions: { ...receipt.extensions,
|
|
137
|
+
offer: { transport: input.transport, at: now, repeatedAt: null } } };
|
|
74
138
|
tx.put("receipt", id, offered, tx.generationOf("receipt", id));
|
|
75
|
-
|
|
76
|
-
workspaceId: receipt.workspaceId, actorSessionId: target.sessionId,
|
|
77
|
-
type: "message.offer_succeeded", occurredAt: now,
|
|
78
|
-
payload: { messageId: input.messageId,
|
|
79
|
-
recipientParticipantId: input.recipientParticipantId,
|
|
80
|
-
targetSessionId: input.targetSessionId,
|
|
81
|
-
targetGeneration: input.targetGeneration,
|
|
82
|
-
transport: input.transport, adapterId: input.adapterId,
|
|
83
|
-
clientVersion: input.clientVersion } });
|
|
139
|
+
success({});
|
|
84
140
|
return offered;
|
|
85
141
|
}, { kinds: ["session", "message", "receipt"], deadlineAt: input.deadlineAt });
|
|
86
142
|
}
|
|
@@ -5,6 +5,7 @@ import { createIntentService } from "./intents.mjs";
|
|
|
5
5
|
import { createInboxService } from "./inbox.mjs";
|
|
6
6
|
import { defaultPidIsAlive } from "./pid.mjs";
|
|
7
7
|
import { assertPorts } from "./ports.mjs";
|
|
8
|
+
import { createPruneService } from "./prune.mjs";
|
|
8
9
|
import { createReceiptService } from "./receipts.mjs";
|
|
9
10
|
import { createSessionService } from "./sessions.mjs";
|
|
10
11
|
import { createGuardStateService, createStatusService } from "./status.mjs";
|
|
@@ -37,6 +38,7 @@ export function createCoordinationService({ store, clock, ids,
|
|
|
37
38
|
const sync = createSyncService(ports, sessions);
|
|
38
39
|
const status = createStatusService(ports, sessions, deliveryBindings);
|
|
39
40
|
const guardState = createGuardStateService(ports);
|
|
41
|
+
const prune = createPruneService(ports);
|
|
40
42
|
return Object.freeze({
|
|
41
43
|
store,
|
|
42
44
|
clock,
|
|
@@ -62,6 +64,7 @@ export function createCoordinationService({ store, clock, ids,
|
|
|
62
64
|
...receipts,
|
|
63
65
|
...sync,
|
|
64
66
|
...status,
|
|
67
|
+
...prune,
|
|
65
68
|
guardState,
|
|
66
69
|
});
|
|
67
70
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { classifySessionPresence } from "./sessions.mjs";
|
|
2
2
|
import { computeAttention } from "./attention.mjs";
|
|
3
|
+
import { isCurrentIntent } from "./intents.mjs";
|
|
4
|
+
import { decisionView, isCurrentDecision } from "./decision-state.mjs";
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Protection level, reported from what is actually enforceable.
|
|
@@ -86,6 +88,22 @@ export function createStatusService(ports, sessions, deliveryBindings) {
|
|
|
86
88
|
const claims = snapshot.claims
|
|
87
89
|
.filter(claim => Date.parse(claim.expiresAt) > Date.parse(now));
|
|
88
90
|
const currentBindings = await deliveryBindings.currentBindings(now);
|
|
91
|
+
// What each recipient has not fetched yet, and how far it got. Offered is
|
|
92
|
+
// not read: a transport took the bytes. Queued sits beside it so "0
|
|
93
|
+
// offered" cannot be mistaken for "nothing waits". Replaced decisions
|
|
94
|
+
// leave these counts exactly as they leave the inbox.
|
|
95
|
+
const current = new Set(snapshot.messages.map(decisionView(snapshot.messages))
|
|
96
|
+
.filter(isCurrentDecision).map(message => message.messageId));
|
|
97
|
+
const unretrieved = new Map();
|
|
98
|
+
const tally = key => unretrieved.get(key) ?? { queued: 0, offered: 0 };
|
|
99
|
+
for (const receipt of snapshot.receipts) {
|
|
100
|
+
if (!current.has(receipt.messageId)
|
|
101
|
+
|| !["queued", "offered"].includes(receipt.state)) continue;
|
|
102
|
+
for (const key of [receipt.recipientParticipantId, null]) {
|
|
103
|
+
const counts = tally(key);
|
|
104
|
+
unretrieved.set(key, { ...counts, [receipt.state]: counts[receipt.state] + 1 });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
89
107
|
|
|
90
108
|
return {
|
|
91
109
|
workspaceId,
|
|
@@ -108,7 +126,12 @@ export function createStatusService(ports, sessions, deliveryBindings) {
|
|
|
108
126
|
enforcement: session.enforcement ?? "advisory",
|
|
109
127
|
lifecycle: session.lifecycle ?? "manual",
|
|
110
128
|
presence,
|
|
111
|
-
|
|
129
|
+
// A finished intent reads as no intent at all. `--clear` exists so a
|
|
130
|
+
// peer stops seeing work that has stopped, and reporting the summary of
|
|
131
|
+
// a done record made the command and this line contradict each other.
|
|
132
|
+
intent: intents.find(intent => intent.sessionId === session.sessionId
|
|
133
|
+
&& isCurrentIntent(intent))?.summary ?? null,
|
|
134
|
+
unretrieved: tally(session.participantId),
|
|
112
135
|
})),
|
|
113
136
|
// The owner is named twice on purpose. Every command that reaches a peer
|
|
114
137
|
// takes a participant id, so a claim that gave only a session id sent the
|
|
@@ -143,6 +166,7 @@ export function createStatusService(ports, sessions, deliveryBindings) {
|
|
|
143
166
|
stale: live.filter(item => item.presence === "stale").length,
|
|
144
167
|
claims: claims.length,
|
|
145
168
|
messages: snapshot.messages.length,
|
|
169
|
+
unretrieved: tally(null),
|
|
146
170
|
},
|
|
147
171
|
};
|
|
148
172
|
}
|