agents-can-communicate 0.1.17 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -138
- package/SECURITY.md +31 -0
- package/bin/acc-hook.mjs +96 -12
- package/bin/acc-mcp.mjs +6 -2
- package/bin/acc.mjs +6 -1
- package/docs/ADAPTER_AUTHORING.md +172 -0
- package/docs/ARCHITECTURE.md +131 -0
- package/docs/CAPABILITIES.md +105 -197
- package/docs/CLI.md +157 -0
- package/docs/CONCEPTS.md +134 -0
- package/docs/CONFIGURATION.md +143 -0
- package/docs/DESIGN_DECISIONS.md +89 -0
- package/docs/GETTING_STARTED.md +145 -0
- package/docs/GLOSSARY.md +26 -0
- package/docs/MCP.md +94 -0
- package/docs/PROTOCOL.md +200 -0
- package/docs/RELEASING.md +109 -0
- package/docs/SECURITY_MODEL.md +131 -0
- package/docs/TROUBLESHOOTING.md +102 -0
- package/docs/WHY_ACC.md +61 -0
- package/docs/index.md +42 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +78 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse-Edit.json +19 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse.json +17 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionEnd.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionStart.json +7 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/UserPromptSubmit.json +9 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +77 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.252.json +19 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +9 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +80 -160
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +15 -5
- package/node_modules/@agents-can-communicate/adapter-codex/certification.json +117 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/PreToolUse.json +14 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionEnd.json +7 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionStart.json +9 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/UserPromptSubmit.json +10 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +66 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.0.json +19 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +8 -1
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +80 -160
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +21 -12
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/certification.json +52 -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 +80 -160
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeAgent.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool-shell.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionEnd.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionStart.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/certification-provenance.json +66 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +8 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +10 -4
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +4 -2
- package/node_modules/@agents-can-communicate/adapter-grok/certification.json +3 -0
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +14 -0
- package/node_modules/@agents-can-communicate/adapter-grok/plugin/hooks/hooks.json +61 -0
- package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +152 -0
- package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +61 -0
- package/node_modules/@agents-can-communicate/adapter-grok/src/hooks.mjs +127 -0
- package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +101 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/certification.json +52 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Bash.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Write.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionHeartbeat.json +7 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionStart.json +9 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/UserPromptSubmit.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/certification-provenance.json +66 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +8 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +80 -160
- package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +10 -4
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +34 -18
- package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +158 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +139 -224
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +7 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +2 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +13 -4
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +13 -29
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +3 -0
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +5 -6
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +12 -3
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +109 -109
- package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +1 -1
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/attention.mjs +106 -0
- package/node_modules/@agents-can-communicate/core/src/conversations.mjs +276 -0
- package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +81 -0
- package/node_modules/@agents-can-communicate/core/src/finish-retries.mjs +97 -0
- package/node_modules/@agents-can-communicate/core/src/inbox.mjs +118 -0
- package/node_modules/@agents-can-communicate/core/src/index.mjs +3 -2
- package/node_modules/@agents-can-communicate/core/src/intents.mjs +0 -1
- package/node_modules/@agents-can-communicate/core/src/ports.mjs +3 -2
- package/node_modules/@agents-can-communicate/core/src/receipts.mjs +109 -0
- package/node_modules/@agents-can-communicate/core/src/service.mjs +14 -10
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +70 -20
- package/node_modules/@agents-can-communicate/core/src/status.mjs +11 -9
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +3 -258
- package/node_modules/@agents-can-communicate/delivery-router/package.json +12 -0
- package/node_modules/@agents-can-communicate/delivery-router/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +117 -0
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +20 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +156 -60
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +23 -7
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +20 -5
- package/node_modules/@agents-can-communicate/installer/src/index.mjs +3 -2
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +108 -12
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +19 -2
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/mcp-server/src/input-validator.mjs +79 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +23 -28
- package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +109 -71
- package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +74 -93
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/src/config.mjs +1 -1
- package/node_modules/@agents-can-communicate/protocol/src/conversations.mjs +64 -0
- package/node_modules/@agents-can-communicate/protocol/src/index.mjs +4 -1
- package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +49 -90
- package/node_modules/@agents-can-communicate/protocol/src/states.mjs +13 -40
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +230 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +77 -28
- package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +83 -35
- package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +112 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +18 -8
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +86 -28
- package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +121 -27
- package/package.json +22 -1
- package/node_modules/@agents-can-communicate/core/src/communication.mjs +0 -334
- package/node_modules/@agents-can-communicate/core/src/notify.mjs +0 -95
- package/node_modules/@agents-can-communicate/core/src/tasks.mjs +0 -244
- package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +0 -109
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { AccError, EXIT, SCHEMA_VERSION, advanceReceipt }
|
|
2
|
+
from "@agents-can-communicate/protocol";
|
|
3
|
+
|
|
4
|
+
import { receiptId } from "./conversations.mjs";
|
|
5
|
+
|
|
6
|
+
export const SAFE_OFFER_ERROR_CODES = Object.freeze([
|
|
7
|
+
"ambiguous_recipient_sessions",
|
|
8
|
+
"delivery_disabled",
|
|
9
|
+
"recipient_busy",
|
|
10
|
+
"recipient_unavailable",
|
|
11
|
+
"transport_error",
|
|
12
|
+
"transport_rejected",
|
|
13
|
+
"unsupported_client_version",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
function missingReceipt(messageId, recipientParticipantId) {
|
|
17
|
+
return new AccError(EXIT.CONFLICT, "no receipt exists for that recipient",
|
|
18
|
+
{ messageId, recipientParticipantId });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function requireReceipt(tx, messageId, recipientParticipantId) {
|
|
22
|
+
const receipt = tx.get("receipt", receiptId(messageId, recipientParticipantId));
|
|
23
|
+
if (receipt === null) throw missingReceipt(messageId, recipientParticipantId);
|
|
24
|
+
const message = tx.get("message", messageId);
|
|
25
|
+
if (message === null) throw missingReceipt(messageId, recipientParticipantId);
|
|
26
|
+
return { message, receipt };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function requireOfferableReceipt(tx, input, { allowRoomNextTurn }) {
|
|
30
|
+
const found = requireReceipt(tx, input.messageId, input.recipientParticipantId);
|
|
31
|
+
if (found.message.toParticipantIds.length === 0
|
|
32
|
+
&& (!allowRoomNextTurn || input.transport !== "next-turn")) {
|
|
33
|
+
throw new AccError(EXIT.CONFLICT, "room messages are not eligible for live offers",
|
|
34
|
+
{ messageId: input.messageId, recipientParticipantId: input.recipientParticipantId });
|
|
35
|
+
}
|
|
36
|
+
return found.receipt;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function requireTarget(tx, input, { mustBeOpen }) {
|
|
40
|
+
const target = tx.get("session", input.targetSessionId);
|
|
41
|
+
if (target === null || (mustBeOpen && target.state !== "open")
|
|
42
|
+
|| target.participantId !== input.recipientParticipantId
|
|
43
|
+
|| target.generation !== input.targetGeneration) {
|
|
44
|
+
throw new AccError(EXIT.CONFLICT,
|
|
45
|
+
"the offer target is not this recipient's session generation",
|
|
46
|
+
{ targetSessionId: input.targetSessionId,
|
|
47
|
+
recipientParticipantId: input.recipientParticipantId });
|
|
48
|
+
}
|
|
49
|
+
return target;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createReceiptService(ports) {
|
|
53
|
+
const { store, clock, ids } = ports;
|
|
54
|
+
|
|
55
|
+
async function readReceipt(input) {
|
|
56
|
+
return store.transaction(tx => requireReceipt(tx, input.messageId,
|
|
57
|
+
input.recipientParticipantId).receipt, { kinds: ["message", "receipt"] });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function recordOfferSucceeded(input) {
|
|
61
|
+
const now = clock.now();
|
|
62
|
+
return store.transaction(tx => {
|
|
63
|
+
const id = receiptId(input.messageId, input.recipientParticipantId);
|
|
64
|
+
const receipt = requireOfferableReceipt(tx, input, { allowRoomNextTurn: true });
|
|
65
|
+
const target = requireTarget(tx, input, { mustBeOpen: true });
|
|
66
|
+
// A retrieval or acknowledgement may win after a transport accepted but
|
|
67
|
+
// before this transaction acquired the writer. That stronger truth must
|
|
68
|
+
// remain in place. The same rule makes a repeated successful commit a
|
|
69
|
+
// read-only operation rather than another success event.
|
|
70
|
+
if (receipt.state !== "queued") return receipt;
|
|
71
|
+
const offered = { ...receipt, state: advanceReceipt(receipt.state, "offered"),
|
|
72
|
+
updatedAt: now };
|
|
73
|
+
tx.put("receipt", id, offered, tx.generationOf("receipt", id));
|
|
74
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
|
|
75
|
+
workspaceId: receipt.workspaceId, actorSessionId: target.sessionId,
|
|
76
|
+
type: "message.offer_succeeded", occurredAt: now,
|
|
77
|
+
payload: { messageId: input.messageId,
|
|
78
|
+
recipientParticipantId: input.recipientParticipantId,
|
|
79
|
+
targetSessionId: input.targetSessionId,
|
|
80
|
+
targetGeneration: input.targetGeneration,
|
|
81
|
+
transport: input.transport, adapterId: input.adapterId,
|
|
82
|
+
clientVersion: input.clientVersion } });
|
|
83
|
+
return offered;
|
|
84
|
+
}, { kinds: ["session", "message", "receipt"], deadlineAt: input.deadlineAt });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function recordOfferFailed(input) {
|
|
88
|
+
if (!SAFE_OFFER_ERROR_CODES.includes(input.safeErrorCode)) {
|
|
89
|
+
throw new AccError(EXIT.DATA, "safeErrorCode is not in the closed offer error set",
|
|
90
|
+
{ safeErrorCode: input.safeErrorCode });
|
|
91
|
+
}
|
|
92
|
+
const now = clock.now();
|
|
93
|
+
return store.transaction(tx => {
|
|
94
|
+
const receipt = requireOfferableReceipt(tx, input, { allowRoomNextTurn: false });
|
|
95
|
+
const target = requireTarget(tx, input, { mustBeOpen: false });
|
|
96
|
+
return tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
|
|
97
|
+
workspaceId: receipt.workspaceId, actorSessionId: target.sessionId,
|
|
98
|
+
type: "message.offer_failed", occurredAt: now,
|
|
99
|
+
payload: { messageId: input.messageId,
|
|
100
|
+
recipientParticipantId: input.recipientParticipantId,
|
|
101
|
+
targetSessionId: target.sessionId,
|
|
102
|
+
targetGeneration: target.generation,
|
|
103
|
+
transport: input.transport, adapterId: input.adapterId,
|
|
104
|
+
clientVersion: input.clientVersion, safeErrorCode: input.safeErrorCode } });
|
|
105
|
+
}, { kinds: ["session", "message", "receipt"] });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { readReceipt, recordOfferSucceeded, recordOfferFailed };
|
|
109
|
+
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { createClaimService } from "./claims.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { createConversationService } from "./conversations.mjs";
|
|
3
|
+
import { createDeliveryBindingService } from "./delivery-bindings.mjs";
|
|
3
4
|
import { createIntentService } from "./intents.mjs";
|
|
5
|
+
import { createInboxService } from "./inbox.mjs";
|
|
4
6
|
import { defaultPidIsAlive } from "./pid.mjs";
|
|
5
7
|
import { assertPorts } from "./ports.mjs";
|
|
8
|
+
import { createReceiptService } from "./receipts.mjs";
|
|
6
9
|
import { createSessionService } from "./sessions.mjs";
|
|
7
10
|
import { createGuardStateService, createStatusService } from "./status.mjs";
|
|
8
11
|
import { createSyncService } from "./sync.mjs";
|
|
9
|
-
import { createTaskService } from "./tasks.mjs";
|
|
10
|
-
import { createWorkstreamService } from "./workstreams.mjs";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Composition root for the domain services. Everything time- or
|
|
@@ -28,12 +29,13 @@ export function createCoordinationService({ store, clock, ids,
|
|
|
28
29
|
const ports = assertPorts({ store, clock, ids, pidIsAlive });
|
|
29
30
|
const sessions = createSessionService(ports);
|
|
30
31
|
const intents = createIntentService(ports, sessions);
|
|
31
|
-
const workstreams = createWorkstreamService(ports, sessions);
|
|
32
|
-
const tasks = createTaskService(ports, workstreams);
|
|
33
32
|
const claims = createClaimService(ports, sessions);
|
|
34
|
-
const
|
|
33
|
+
const conversations = createConversationService(ports, sessions);
|
|
34
|
+
const deliveryBindings = createDeliveryBindingService(ports, sessions);
|
|
35
|
+
const inbox = createInboxService(ports, sessions);
|
|
36
|
+
const receipts = createReceiptService(ports);
|
|
35
37
|
const sync = createSyncService(ports, sessions);
|
|
36
|
-
const status = createStatusService(ports, sessions);
|
|
38
|
+
const status = createStatusService(ports, sessions, deliveryBindings);
|
|
37
39
|
const guardState = createGuardStateService(ports);
|
|
38
40
|
return Object.freeze({
|
|
39
41
|
store,
|
|
@@ -42,10 +44,12 @@ export function createCoordinationService({ store, clock, ids,
|
|
|
42
44
|
policies: Object.freeze({ ...policies }),
|
|
43
45
|
...sessions,
|
|
44
46
|
...intents,
|
|
45
|
-
...workstreams,
|
|
46
|
-
...tasks,
|
|
47
47
|
...claims,
|
|
48
|
-
...
|
|
48
|
+
...conversations,
|
|
49
|
+
publishDeliveryBinding: deliveryBindings.publishDeliveryBinding,
|
|
50
|
+
listDeliveryBindings: deliveryBindings.listDeliveryBindings,
|
|
51
|
+
...inbox,
|
|
52
|
+
...receipts,
|
|
49
53
|
...sync,
|
|
50
54
|
...status,
|
|
51
55
|
guardState,
|
|
@@ -2,7 +2,6 @@ import { AccError, EXIT, SCHEMA_VERSION, assertPortableId, createId, validateRec
|
|
|
2
2
|
from "@agents-can-communicate/protocol";
|
|
3
3
|
|
|
4
4
|
import { ensureMaterialised, isMaterialised, materialise } from "./materialisation.mjs";
|
|
5
|
-
import { writeWorkResponse } from "./notify.mjs";
|
|
6
5
|
|
|
7
6
|
// A hook-only adapter heartbeats only when its harness gives it a turn, so the
|
|
8
7
|
// staleness window is a multiple of the cadence the session itself declared
|
|
@@ -154,7 +153,12 @@ export function createSessionService(ports) {
|
|
|
154
153
|
// The approved trigger is the SECOND live session, not the first: a lone
|
|
155
154
|
// session must be able to open and close without leaving a trace.
|
|
156
155
|
const live = (await store.ephemeral.list("session")).filter(item => item.state === "open");
|
|
157
|
-
|
|
156
|
+
// Recheck durable state only after our ephemeral writes. Another process
|
|
157
|
+
// may have staged the old ephemeral set after our first precheck, committed
|
|
158
|
+
// materialisation, and retired that set before these puts completed. In
|
|
159
|
+
// that case this attach is the only ephemeral session left, but it still
|
|
160
|
+
// has to run the idempotent promotion pass into the now-durable workspace.
|
|
161
|
+
if (live.length > 1 || await isMaterialised(store, workspaceId)) {
|
|
158
162
|
await materialise(ports, { workspaceId, descriptor: input.descriptor,
|
|
159
163
|
reason: "second_live_session" });
|
|
160
164
|
}
|
|
@@ -179,6 +183,53 @@ export function createSessionService(ports) {
|
|
|
179
183
|
return beaten;
|
|
180
184
|
}
|
|
181
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Continue the exact session named by a harness binding.
|
|
188
|
+
*
|
|
189
|
+
* Some clients emit SessionStart again after compacting their model context.
|
|
190
|
+
* The binding is already the continuation token: it names both the session
|
|
191
|
+
* and its generation. Refreshing that record preserves one identity without
|
|
192
|
+
* pretending an unrelated or closed generation is still ours.
|
|
193
|
+
*
|
|
194
|
+
* Returns null when the binding can no longer be resumed, so the hook may
|
|
195
|
+
* open a genuinely new session. No semantic event is appended: compaction is
|
|
196
|
+
* not a second agent arriving.
|
|
197
|
+
*/
|
|
198
|
+
async function resumeSession({ sessionId, workspaceId, generation, ...metadata }) {
|
|
199
|
+
const resume = current => {
|
|
200
|
+
if (current === null || current.state !== "open"
|
|
201
|
+
|| current.generation !== generation) return null;
|
|
202
|
+
return { ...current,
|
|
203
|
+
pid: metadata.pid ?? null,
|
|
204
|
+
checkoutRoot: metadata.checkoutRoot ?? current.checkoutRoot,
|
|
205
|
+
branch: metadata.branch ?? current.branch,
|
|
206
|
+
enforcement: metadata.enforcement ?? current.enforcement,
|
|
207
|
+
lifecycle: metadata.lifecycle ?? current.lifecycle,
|
|
208
|
+
heartbeatCadenceMs: metadata.heartbeatCadenceMs ?? current.heartbeatCadenceMs,
|
|
209
|
+
heartbeatAt: clock.now(),
|
|
210
|
+
};
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// The compare and replacement happen under the ephemeral store's writer
|
|
214
|
+
// lock. A close or a replacement generation can win before this update or
|
|
215
|
+
// after it, but can never be overwritten from a record read beforehand.
|
|
216
|
+
const ephemeral = await store.ephemeral.update("session", sessionId, resume);
|
|
217
|
+
if (ephemeral !== null) return ephemeral;
|
|
218
|
+
|
|
219
|
+
// Re-read and validate inside the durable transaction for the same reason.
|
|
220
|
+
// Using generationOf only as the put token is insufficient: it protects
|
|
221
|
+
// the envelope write, not the semantic generation carried by the record.
|
|
222
|
+
const resolvedWorkspace = workspaceId ?? store.workspaceId;
|
|
223
|
+
if (resolvedWorkspace === undefined) return null;
|
|
224
|
+
return store.transaction(async tx => {
|
|
225
|
+
const current = tx.get("session", sessionId);
|
|
226
|
+
const resumed = resume(current);
|
|
227
|
+
if (resumed === null) return null;
|
|
228
|
+
tx.put("session", sessionId, resumed, tx.generationOf("session", sessionId));
|
|
229
|
+
return resumed;
|
|
230
|
+
}, { kinds: ["session"] });
|
|
231
|
+
}
|
|
232
|
+
|
|
182
233
|
async function closeSession({ sessionId, workspaceId, generation }) {
|
|
183
234
|
const existing = await locate(sessionId, workspaceId);
|
|
184
235
|
if (existing === null) throw new AccError(EXIT.CONFLICT, "session is not open", { sessionId });
|
|
@@ -196,34 +247,33 @@ export function createSessionService(ports) {
|
|
|
196
247
|
|
|
197
248
|
await store.transaction(async tx => {
|
|
198
249
|
tx.put("session", sessionId, closed, tx.generationOf("session", sessionId));
|
|
199
|
-
// Work in progress goes back on the table. A task held by a session that
|
|
200
|
-
// has gone stayed `in_progress` forever, nobody else could take it, and
|
|
201
|
-
// whoever asked for it was never told - a request handed to an agent that
|
|
202
|
-
// closed its terminal simply vanished.
|
|
203
|
-
for (const task of tx.list("task")) {
|
|
204
|
-
if (task.assigneeSessionId !== sessionId) continue;
|
|
205
|
-
if (task.state === "done") continue;
|
|
206
|
-
const released = { ...task, assigneeSessionId: null, state: "pending" };
|
|
207
|
-
tx.put("task", task.taskId, released, tx.generationOf("task", task.taskId));
|
|
208
|
-
writeWorkResponse(tx, { task, actor: closed, workspaceId: closed.workspaceId,
|
|
209
|
-
now, ids, outcome: "released",
|
|
210
|
-
reason: "the session working on this closed before finishing it" });
|
|
211
|
-
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
|
|
212
|
-
workspaceId: closed.workspaceId, actorSessionId: sessionId,
|
|
213
|
-
type: "task.released", occurredAt: now, payload: { taskId: task.taskId } });
|
|
214
|
-
}
|
|
215
250
|
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
|
|
216
251
|
workspaceId: closed.workspaceId, actorSessionId: sessionId, type: "session.closed",
|
|
217
252
|
occurredAt: now, payload: {} });
|
|
218
|
-
|
|
219
|
-
}, { kinds: ["message", "receipt", "session", "task"] });
|
|
253
|
+
}, { kinds: ["session"] });
|
|
220
254
|
return closed;
|
|
221
255
|
}
|
|
222
256
|
|
|
257
|
+
async function listLiveSessions({ participantId, workspaceId, now = clock.now() }) {
|
|
258
|
+
const resolved = workspaceId ?? store.workspaceId;
|
|
259
|
+
const snapshot = resolved === undefined ? null
|
|
260
|
+
: await store.snapshot(resolved, { kinds: ["workspace", "session"] });
|
|
261
|
+
const records = snapshot?.workspace === null
|
|
262
|
+
? await store.ephemeral.list("session")
|
|
263
|
+
: snapshot?.sessions ?? await store.ephemeral.list("session");
|
|
264
|
+
return records
|
|
265
|
+
.filter(session => session.participantId === participantId
|
|
266
|
+
&& classifySessionPresence(session, now, pidIsAlive) !== "offline")
|
|
267
|
+
.sort((left, right) => left.sessionId.localeCompare(right.sessionId))
|
|
268
|
+
.map(session => ({ sessionId: session.sessionId, generation: session.generation }));
|
|
269
|
+
}
|
|
270
|
+
|
|
223
271
|
return {
|
|
224
272
|
openSession,
|
|
273
|
+
resumeSession,
|
|
225
274
|
heartbeatSession,
|
|
226
275
|
closeSession,
|
|
276
|
+
listLiveSessions,
|
|
227
277
|
locateSession: locate,
|
|
228
278
|
ensureMaterialised: options => ensureMaterialised(ports, options),
|
|
229
279
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { classifySessionPresence } from "./sessions.mjs";
|
|
2
|
-
import { computeAttention } from "./
|
|
2
|
+
import { computeAttention } from "./attention.mjs";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Protection level, reported from what is actually enforceable.
|
|
@@ -57,7 +57,7 @@ export function createGuardStateService(ports) {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
export function createStatusService(ports, sessions) {
|
|
60
|
+
export function createStatusService(ports, sessions, deliveryBindings) {
|
|
61
61
|
const { store, clock, pidIsAlive } = ports;
|
|
62
62
|
|
|
63
63
|
async function collectStatus(input = {}) {
|
|
@@ -85,6 +85,7 @@ export function createStatusService(ports, sessions) {
|
|
|
85
85
|
const live = classified.filter(item => item.presence !== "offline");
|
|
86
86
|
const claims = snapshot.claims
|
|
87
87
|
.filter(claim => Date.parse(claim.expiresAt) > Date.parse(now));
|
|
88
|
+
const currentBindings = await deliveryBindings.currentBindings(now);
|
|
88
89
|
|
|
89
90
|
return {
|
|
90
91
|
workspaceId,
|
|
@@ -109,12 +110,6 @@ export function createStatusService(ports, sessions) {
|
|
|
109
110
|
presence,
|
|
110
111
|
intent: intents.find(intent => intent.sessionId === session.sessionId)?.summary ?? null,
|
|
111
112
|
})),
|
|
112
|
-
workstreams: snapshot.workstreams.map(workstream => ({
|
|
113
|
-
workstreamId: workstream.workstreamId,
|
|
114
|
-
title: workstream.title,
|
|
115
|
-
state: workstream.state,
|
|
116
|
-
coordinatorSessionId: workstream.coordinatorSessionId,
|
|
117
|
-
})),
|
|
118
113
|
// The owner is named twice on purpose. Every command that reaches a peer
|
|
119
114
|
// takes a participant id, so a claim that gave only a session id sent the
|
|
120
115
|
// reader back through the roster to answer "who is holding this, and how
|
|
@@ -133,13 +128,20 @@ export function createStatusService(ports, sessions) {
|
|
|
133
128
|
.find(session => session.sessionId === claim.ownerSessionId)?.participantId ?? null,
|
|
134
129
|
expiresAt: claim.expiresAt,
|
|
135
130
|
})),
|
|
131
|
+
deliveryBindings: currentBindings.map(({ binding, reachable }) => ({
|
|
132
|
+
adapterId: binding.adapterId,
|
|
133
|
+
clientVersion: binding.clientVersion,
|
|
134
|
+
availableModes: [...binding.availableModes],
|
|
135
|
+
livePolicy: binding.livePolicy,
|
|
136
|
+
reachable,
|
|
137
|
+
leaseUntil: binding.leaseUntil,
|
|
138
|
+
})),
|
|
136
139
|
attention: computeAttention(snapshot, { session: null,
|
|
137
140
|
participantId: input.participantId, now, pidIsAlive }),
|
|
138
141
|
counts: {
|
|
139
142
|
live: live.length,
|
|
140
143
|
stale: live.filter(item => item.presence === "stale").length,
|
|
141
144
|
claims: claims.length,
|
|
142
|
-
tasks: snapshot.tasks.length,
|
|
143
145
|
messages: snapshot.messages.length,
|
|
144
146
|
},
|
|
145
147
|
};
|
|
@@ -1,19 +1,10 @@
|
|
|
1
1
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
2
2
|
|
|
3
|
+
import { computeAttention } from "./attention.mjs";
|
|
3
4
|
import { classifySessionPresence } from "./sessions.mjs";
|
|
4
|
-
import { overlaps } from "./claims.mjs";
|
|
5
5
|
|
|
6
6
|
const DEFAULT_LIMIT = 100;
|
|
7
|
-
|
|
8
|
-
// The shape every event carries, and the only thing a caller may ask to resume
|
|
9
|
-
// from. `null` means the beginning, which is what a session with no cursor yet
|
|
10
|
-
// has.
|
|
11
7
|
const CURSOR = /^[0-9]{16}$/;
|
|
12
|
-
|
|
13
|
-
// The two a caller may ask for. An unknown one used to become `delta`, so
|
|
14
|
-
// `--scope ful` answered the one question the full scope exists for - "show me
|
|
15
|
-
// everything, I cannot see the rest of the system" - with a delta carrying no
|
|
16
|
-
// snapshot at all, and the agent concluded there was nothing to see.
|
|
17
8
|
const SCOPES = Object.freeze(["delta", "full"]);
|
|
18
9
|
|
|
19
10
|
function assertScope(scope) {
|
|
@@ -27,263 +18,22 @@ function assertCursor(cursor) {
|
|
|
27
18
|
if (typeof cursor !== "string" || !CURSOR.test(cursor)) {
|
|
28
19
|
throw new AccError(EXIT.USAGE,
|
|
29
20
|
"a cursor is the 16-digit sequence a previous sync returned; "
|
|
30
|
-
+ "leave it out to start from the beginning",
|
|
31
|
-
{ cursor });
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Attention is computed from explicit rules, never from a hidden classifier.
|
|
36
|
-
// Lower priority sorts first.
|
|
37
|
-
//
|
|
38
|
-
// Exported so a test can prove every kind listed here is reachable. A fifth
|
|
39
|
-
// entry once sat here with no rule behind it, which read as a feature in review
|
|
40
|
-
// and produced nothing at runtime.
|
|
41
|
-
export const ATTENTION_PRIORITY = Object.freeze({
|
|
42
|
-
direct_request: 1,
|
|
43
|
-
claim_conflict: 2,
|
|
44
|
-
task_unblocked: 3,
|
|
45
|
-
coordinator_missing: 4,
|
|
46
|
-
request_stalled: 5,
|
|
47
|
-
claim_expired: 6,
|
|
48
|
-
claim_contended: 7,
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
function directRequests(snapshot, participantId) {
|
|
52
|
-
const items = [];
|
|
53
|
-
for (const receipt of snapshot.receipts ?? []) {
|
|
54
|
-
if (receipt.recipientParticipantId !== participantId) continue;
|
|
55
|
-
if (receipt.state === "acknowledged" || receipt.state === "failed") continue;
|
|
56
|
-
const message = (snapshot.messages ?? []).find(item => item.messageId === receipt.messageId);
|
|
57
|
-
if (message === undefined || !message.requiresAck) continue;
|
|
58
|
-
items.push({ kind: "direct_request", priority: ATTENTION_PRIORITY.direct_request,
|
|
59
|
-
sourceId: message.messageId, summary: message.subject });
|
|
60
|
-
}
|
|
61
|
-
return items;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* A claim of yours that has run out.
|
|
66
|
-
*
|
|
67
|
-
* A lease lapses on the clock, and nothing said so. Measured: while it held, a
|
|
68
|
-
* peer's write into the file was refused; three seconds later the same write
|
|
69
|
-
* went through, and the holder's turn was identical before and after. It went on
|
|
70
|
-
* working on a file it believed it had reserved, and everyone else was free to
|
|
71
|
-
* change it.
|
|
72
|
-
*
|
|
73
|
-
* Only for the session that took it, and only while that session is the one
|
|
74
|
-
* asking: a lapsed claim is news to its owner and nobody else's business.
|
|
75
|
-
* Re-claiming refreshes the lease and clears this; releasing it clears it too.
|
|
76
|
-
*/
|
|
77
|
-
function expiredClaims(snapshot, session, now) {
|
|
78
|
-
if (session == null) return [];
|
|
79
|
-
return (snapshot.claims ?? [])
|
|
80
|
-
.filter(claim => claim.ownerSessionId === session.sessionId
|
|
81
|
-
&& Date.parse(claim.expiresAt) <= Date.parse(now))
|
|
82
|
-
.map(claim => ({ kind: "claim_expired", priority: ATTENTION_PRIORITY.claim_expired,
|
|
83
|
-
sourceId: claim.claimId,
|
|
84
|
-
summary: `${claim.resource} - your claim has run out, and peers can write to it` }));
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function claimConflicts(snapshot, session, now) {
|
|
88
|
-
const mine = (snapshot.intents ?? []).find(intent => intent.sessionId === session?.sessionId);
|
|
89
|
-
if (mine === undefined) return [];
|
|
90
|
-
return (snapshot.claims ?? [])
|
|
91
|
-
.filter(claim => claim.ownerSessionId !== session.sessionId
|
|
92
|
-
&& Date.parse(claim.expiresAt) > Date.parse(now)
|
|
93
|
-
&& mine.resourceHints.some(hint => overlaps(hint, claim.resource)))
|
|
94
|
-
.map(claim => ({ kind: "claim_conflict", priority: ATTENTION_PRIORITY.claim_conflict,
|
|
95
|
-
sourceId: claim.claimId,
|
|
96
|
-
summary: `${claim.resource} is claimed by ${claim.ownerSessionId}` }));
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* A resource I hold that a peer has said they intend to touch.
|
|
101
|
-
*
|
|
102
|
-
* The mirror of `claimConflicts`. That one reads my own intent and warns me when
|
|
103
|
-
* what I mean to touch is already claimed. This one reads a peer's intent and
|
|
104
|
-
* warns me, the holder, that someone is heading for what I claimed. Without it
|
|
105
|
-
* intent's only wired reader faced inward: it protected the one declaring intent
|
|
106
|
-
* and told the claim holder nothing, so a claim was a wall nobody was told they
|
|
107
|
-
* were walking into. A claim is advisory - it does not stop the write - so being
|
|
108
|
-
* told early is the whole of the protection it offers.
|
|
109
|
-
*
|
|
110
|
-
* Only my own claims, and never my own intent against them: declaring intent on
|
|
111
|
-
* what you already hold is not someone reaching for it. The peer is named by
|
|
112
|
-
* participant where the roster knows it, because a session id cannot be used
|
|
113
|
-
* with `--to` and an id a reader cannot act on is the trap the projector warns of.
|
|
114
|
-
*/
|
|
115
|
-
function claimContended(snapshot, session, now) {
|
|
116
|
-
if (session === null || session === undefined) return [];
|
|
117
|
-
const theirs = (snapshot.intents ?? [])
|
|
118
|
-
.filter(intent => intent.sessionId !== session.sessionId);
|
|
119
|
-
const nameOf = sessionId => (snapshot.sessions ?? [])
|
|
120
|
-
.find(item => item.sessionId === sessionId)?.participantId ?? "a peer";
|
|
121
|
-
return (snapshot.claims ?? [])
|
|
122
|
-
.filter(claim => claim.ownerSessionId === session.sessionId
|
|
123
|
-
&& Date.parse(claim.expiresAt) > Date.parse(now))
|
|
124
|
-
.flatMap(claim => {
|
|
125
|
-
const eyeing = theirs.find(intent =>
|
|
126
|
-
(intent.resourceHints ?? []).some(hint => overlaps(hint, claim.resource)));
|
|
127
|
-
return eyeing === undefined ? [] : [{
|
|
128
|
-
kind: "claim_contended", priority: ATTENTION_PRIORITY.claim_contended,
|
|
129
|
-
sourceId: claim.claimId,
|
|
130
|
-
summary: `${claim.resource} - ${nameOf(eyeing.sessionId)} means to work on what you hold` }];
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Work waiting on me.
|
|
136
|
-
*
|
|
137
|
-
* Addressed by participant, so a request survives the recipient restarting -
|
|
138
|
-
* the next session of that agent is told about it. A task already taken by one
|
|
139
|
-
* of my sessions matches too, since that session may have been replaced.
|
|
140
|
-
*
|
|
141
|
-
* Unaddressed tasks are deliberately absent. Anyone may take one, but pushing
|
|
142
|
-
* every open task into every turn is how a coordination layer becomes noise.
|
|
143
|
-
*/
|
|
144
|
-
function unblockedTasks(snapshot, session, participantId) {
|
|
145
|
-
const mine = task => (task.assigneeParticipantId !== null
|
|
146
|
-
&& task.assigneeParticipantId === participantId)
|
|
147
|
-
|| (task.assigneeSessionId !== null && task.assigneeSessionId === session?.sessionId);
|
|
148
|
-
return (snapshot.tasks ?? [])
|
|
149
|
-
.filter(task => task.state === "pending" && mine(task))
|
|
150
|
-
.map(task => ({ kind: "task_unblocked", priority: ATTENTION_PRIORITY.task_unblocked,
|
|
151
|
-
sourceId: task.taskId, summary: task.title }));
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Work you asked for that nobody is doing any more.
|
|
156
|
-
*
|
|
157
|
-
* A task taken by a session that then crashed stayed `in_progress` for good:
|
|
158
|
-
* the requester was told nothing and nobody else could take it. Unlike the
|
|
159
|
-
* one-shot answers a request produces, this repeats until it is resolved,
|
|
160
|
-
* because it stays true until someone picks the work back up.
|
|
161
|
-
*/
|
|
162
|
-
/**
|
|
163
|
-
* A question nobody is left to answer.
|
|
164
|
-
*
|
|
165
|
-
* The task rule below tells a requester when work they asked for is going
|
|
166
|
-
* nowhere. A `requiresAck` message had no such rule, and a message is the other
|
|
167
|
-
* half of the same act: an agent asked a peer a direct question, the peer's
|
|
168
|
-
* session ended without answering, and the asker's next turn was empty. Not
|
|
169
|
-
* "still waiting" - empty. Measured, with the only other agent gone and an
|
|
170
|
-
* unanswered question standing between them.
|
|
171
|
-
*
|
|
172
|
-
* The same kind as the task case, because it is the same fact about the world:
|
|
173
|
-
* you asked, and there is nobody there.
|
|
174
|
-
*/
|
|
175
|
-
function unansweredQuestions(snapshot, participantId, onlineParticipants) {
|
|
176
|
-
const items = [];
|
|
177
|
-
for (const receipt of snapshot.receipts ?? []) {
|
|
178
|
-
if (receipt.state === "acknowledged" || receipt.state === "failed") continue;
|
|
179
|
-
const message = (snapshot.messages ?? [])
|
|
180
|
-
.find(item => item.messageId === receipt.messageId);
|
|
181
|
-
if (message === undefined || !message.requiresAck) continue;
|
|
182
|
-
if (message.fromParticipantId !== participantId) continue;
|
|
183
|
-
// Not answered yet by someone who is here is ordinary waiting, and saying so
|
|
184
|
-
// every turn would be noise the reader learns to skip.
|
|
185
|
-
if (onlineParticipants.has(receipt.recipientParticipantId)) continue;
|
|
186
|
-
items.push({ kind: "request_stalled", priority: ATTENTION_PRIORITY.request_stalled,
|
|
187
|
-
sourceId: message.messageId,
|
|
188
|
-
summary: `${message.subject} - ${receipt.recipientParticipantId} is not here to answer` });
|
|
189
|
-
}
|
|
190
|
-
return items;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function stalledRequests(snapshot, participantId, now, pidIsAlive) {
|
|
194
|
-
// Classified once per session and reused below, for the same reason
|
|
195
|
-
// collectStatus takes one reading: classifySessionPresence calls pidIsAlive,
|
|
196
|
-
// a real process.kill(pid, 0) syscall for a session with a recorded pid, so
|
|
197
|
-
// it is not pure given `now` any more. A second classifying pass here could
|
|
198
|
-
// disagree with the first - a client exiting between them would leave `live`
|
|
199
|
-
// saying "online" while a freshly-computed `onlineParticipants` had already
|
|
200
|
-
// dropped it, inside one attention computation.
|
|
201
|
-
const live = new Map((snapshot.sessions ?? [])
|
|
202
|
-
.map(session => [session.sessionId, classifySessionPresence(session, now, pidIsAlive)]));
|
|
203
|
-
const onlineParticipants = new Set((snapshot.sessions ?? [])
|
|
204
|
-
.filter(session => live.get(session.sessionId) === "online")
|
|
205
|
-
.map(session => session.participantId));
|
|
206
|
-
const goingNowhere = task => {
|
|
207
|
-
// Taken by someone who has gone quiet.
|
|
208
|
-
if (task.state === "in_progress") {
|
|
209
|
-
return task.assigneeSessionId !== null
|
|
210
|
-
&& live.get(task.assigneeSessionId) !== "online";
|
|
211
|
-
}
|
|
212
|
-
// Or waiting on an agent that is not here - including one that closed and
|
|
213
|
-
// never came back, which leaves the work addressed to nobody at all.
|
|
214
|
-
return task.state === "pending" && task.assigneeParticipantId !== null
|
|
215
|
-
&& !onlineParticipants.has(task.assigneeParticipantId);
|
|
216
|
-
};
|
|
217
|
-
return [
|
|
218
|
-
...(snapshot.tasks ?? [])
|
|
219
|
-
.filter(task => task.requestedByParticipantId === participantId
|
|
220
|
-
&& goingNowhere(task))
|
|
221
|
-
.map(task => ({ kind: "request_stalled", priority: ATTENTION_PRIORITY.request_stalled,
|
|
222
|
-
sourceId: task.taskId,
|
|
223
|
-
summary: `${task.title} - nobody is working on it` })),
|
|
224
|
-
...unansweredQuestions(snapshot, participantId, onlineParticipants),
|
|
225
|
-
];
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
function coordinatorGaps(snapshot) {
|
|
229
|
-
return (snapshot.workstreams ?? [])
|
|
230
|
-
.filter(workstream => workstream.state === "open"
|
|
231
|
-
&& workstream.coordinatorSessionId === null)
|
|
232
|
-
.map(workstream => ({ kind: "coordinator_missing",
|
|
233
|
-
priority: ATTENTION_PRIORITY.coordinator_missing,
|
|
234
|
-
sourceId: workstream.workstreamId, summary: workstream.title }));
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
export function computeAttention(snapshot, { session, participantId, now, pidIsAlive }) {
|
|
238
|
-
// Required unconditionally, not only when there happen to be sessions to
|
|
239
|
-
// classify: `stalledRequests` reaches `classifySessionPresence` only inside a
|
|
240
|
-
// map/filter over `snapshot.sessions`, so an empty or absent roster let a
|
|
241
|
-
// missing probe through with nothing to trip over it - the same silent pass
|
|
242
|
-
// the classifier's own required parameter exists to close, one layer up.
|
|
243
|
-
if (typeof pidIsAlive !== "function") {
|
|
244
|
-
throw new AccError(EXIT.USAGE, "computeAttention requires a pidIsAlive probe", {});
|
|
21
|
+
+ "leave it out to start from the beginning", { cursor });
|
|
245
22
|
}
|
|
246
|
-
return [
|
|
247
|
-
...directRequests(snapshot, participantId),
|
|
248
|
-
...claimConflicts(snapshot, session, now),
|
|
249
|
-
...claimContended(snapshot, session, now),
|
|
250
|
-
...expiredClaims(snapshot, session, now),
|
|
251
|
-
...unblockedTasks(snapshot, session, participantId),
|
|
252
|
-
...coordinatorGaps(snapshot),
|
|
253
|
-
...stalledRequests(snapshot, participantId, now, pidIsAlive),
|
|
254
|
-
].sort((left, right) => left.priority - right.priority
|
|
255
|
-
|| left.sourceId.localeCompare(right.sourceId));
|
|
256
23
|
}
|
|
257
24
|
|
|
258
25
|
export function createSyncService(ports, sessions) {
|
|
259
26
|
const { store, clock, pidIsAlive } = ports;
|
|
260
27
|
|
|
261
|
-
/**
|
|
262
|
-
* Any session may request the full Workspace scope. Peer equality is a
|
|
263
|
-
* knowledge property: no session receives a reduced
|
|
264
|
-
* view because of its role. The bounded delta is only the ambient default.
|
|
265
|
-
*/
|
|
266
28
|
async function sync(input = {}) {
|
|
267
|
-
// A cursor that is not a cursor answered "nothing new", every time, for as
|
|
268
|
-
// long as it was held. `eventsSince` compares sequences as strings, so
|
|
269
|
-
// `not-a-cursor` sorts after every event there has ever been - and an
|
|
270
|
-
// adapter holding a corrupt one, or an agent that invented one, saw a quiet
|
|
271
|
-
// workspace rather than a mistake. `"0000000000000001; DROP"` was quietly
|
|
272
|
-
// taken as the sequence it starts with.
|
|
273
29
|
if (input.cursor != null) assertCursor(input.cursor);
|
|
274
30
|
assertScope(input.scope);
|
|
275
31
|
const workspaceId = input.workspaceId ?? store.workspaceId;
|
|
276
32
|
const now = clock.now();
|
|
277
33
|
const located = input.sessionId === undefined
|
|
278
|
-
? null
|
|
279
|
-
: await sessions.locateSession(input.sessionId, workspaceId);
|
|
34
|
+
? null : await sessions.locateSession(input.sessionId, workspaceId);
|
|
280
35
|
const session = located?.record ?? null;
|
|
281
|
-
|
|
282
36
|
const durable = await store.snapshot(workspaceId);
|
|
283
|
-
// A workspace that has not materialised still has a truthful roster: its
|
|
284
|
-
// sessions live in the ephemeral area. Reading only the durable snapshot
|
|
285
|
-
// would make a lone session invisible to itself, and would disagree with
|
|
286
|
-
// what `status` reports from the same state.
|
|
287
37
|
const snapshot = durable.workspace !== null
|
|
288
38
|
? durable
|
|
289
39
|
: { ...durable,
|
|
@@ -293,7 +43,6 @@ export function createSyncService(ports, sessions) {
|
|
|
293
43
|
input.limit ?? DEFAULT_LIMIT);
|
|
294
44
|
const attention = computeAttention(snapshot, { session,
|
|
295
45
|
participantId: session?.participantId ?? input.participantId, now, pidIsAlive });
|
|
296
|
-
|
|
297
46
|
const roster = snapshot.sessions.map(item => ({
|
|
298
47
|
sessionId: item.sessionId,
|
|
299
48
|
participantId: item.participantId,
|
|
@@ -302,14 +51,10 @@ export function createSyncService(ports, sessions) {
|
|
|
302
51
|
branch: item.branch ?? null,
|
|
303
52
|
presence: classifySessionPresence(item, now, pidIsAlive),
|
|
304
53
|
}));
|
|
305
|
-
|
|
306
|
-
// Solo zero-overhead: one live session, no claims and
|
|
307
|
-
// no attention means an empty result, not a "nothing to report" banner.
|
|
308
54
|
const peers = roster.filter(item => item.sessionId !== session?.sessionId
|
|
309
55
|
&& item.presence !== "offline");
|
|
310
56
|
const solo = peers.length === 0 && attention.length === 0
|
|
311
57
|
&& snapshot.claims.length === 0;
|
|
312
|
-
|
|
313
58
|
return {
|
|
314
59
|
cursor: page.cursor,
|
|
315
60
|
scope: input.scope === "full" ? "full" : "delta",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createDeliveryRouter } from "./router.mjs";
|