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.
Files changed (61) hide show
  1. package/README.md +3 -1
  2. package/bin/entrypoints/hook-output.mjs +12 -0
  3. package/docs/ARCHITECTURE.md +5 -0
  4. package/docs/CAPABILITIES.md +1 -1
  5. package/docs/CLI.md +50 -2
  6. package/docs/CONCEPTS.md +7 -0
  7. package/docs/GETTING_STARTED.md +2 -2
  8. package/docs/PROTOCOL.md +31 -0
  9. package/docs/TROUBLESHOOTING.md +4 -3
  10. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
  11. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  12. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/skills/acc/SKILL.md +8 -0
  13. package/node_modules/@agents-can-communicate/adapter-antigravity/src/install.mjs +2 -1
  14. package/node_modules/@agents-can-communicate/adapter-antigravity/src/native-delivery.mjs +57 -11
  15. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  16. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +8 -0
  17. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  18. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +8 -0
  19. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  20. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +8 -0
  21. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  22. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  23. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +8 -0
  24. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  25. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +8 -0
  26. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  27. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +15 -2
  28. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt-writer.mjs +1 -1
  29. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt.mjs +3 -1
  30. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  31. package/node_modules/@agents-can-communicate/cli/src/args.mjs +6 -0
  32. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +13 -1
  33. package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -1
  34. package/node_modules/@agents-can-communicate/cli/src/main.mjs +33 -8
  35. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  36. package/node_modules/@agents-can-communicate/core/src/attention.mjs +7 -2
  37. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +2 -2
  38. package/node_modules/@agents-can-communicate/core/src/intents.mjs +16 -0
  39. package/node_modules/@agents-can-communicate/core/src/next-turn-delivery.mjs +21 -4
  40. package/node_modules/@agents-can-communicate/core/src/prune.mjs +241 -0
  41. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +66 -10
  42. package/node_modules/@agents-can-communicate/core/src/service.mjs +3 -0
  43. package/node_modules/@agents-can-communicate/core/src/status.mjs +25 -1
  44. package/node_modules/@agents-can-communicate/core/src/sync.mjs +15 -2
  45. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  46. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  47. package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +60 -7
  48. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +62 -27
  49. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  50. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  51. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +5 -3
  52. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  53. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  54. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +26 -7
  55. package/node_modules/@agents-can-communicate/storage-filesystem/src/doomed-directory.mjs +98 -0
  56. package/node_modules/@agents-can-communicate/storage-filesystem/src/event-floor.mjs +52 -0
  57. package/node_modules/@agents-can-communicate/storage-filesystem/src/reclaim.mjs +272 -0
  58. package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +54 -7
  59. package/node_modules/@agents-can-communicate/storage-filesystem/src/stage-sweep.mjs +174 -0
  60. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +97 -5
  61. 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
- const text = [`store healthy; ${describePresence(status.counts)}; `
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
- // `live` counts everyone present, which includes sessions gone stale - a
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 }),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.6.3",
3
+ "version": "0.7.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
- const mine = (snapshot.intents ?? []).find(intent => intent.sessionId === session?.sessionId);
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
- tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
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
- intent: intents.find(intent => intent.sessionId === session.sessionId)?.summary ?? null,
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
  }