agents-can-communicate 0.1.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/LICENSE +21 -0
- package/README.md +176 -0
- package/bin/acc-hook.mjs +53 -0
- package/bin/acc-mcp.mjs +46 -0
- package/bin/acc.mjs +24 -0
- package/docs/CAPABILITIES.md +153 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.claude-plugin/plugin.json +5 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/hooks/hooks.json +56 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +64 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +106 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +278 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +17 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/hooks.json +55 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +81 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +133 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +235 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +6 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/hooks/hooks.json +69 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +74 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/hooks.mjs +109 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +149 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/.kimi-plugin/plugin.json +9 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +73 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/hooks.mjs +125 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +216 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +101 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/config-merge.mjs +215 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +217 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/events.mjs +55 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +148 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +16 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +95 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-block.mjs +71 -0
- package/node_modules/@agents-can-communicate/cli/package.json +12 -0
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +163 -0
- package/node_modules/@agents-can-communicate/cli/src/claim-spelling.mjs +79 -0
- package/node_modules/@agents-can-communicate/cli/src/config-command.mjs +149 -0
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +85 -0
- package/node_modules/@agents-can-communicate/cli/src/git-probe.mjs +41 -0
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +78 -0
- package/node_modules/@agents-can-communicate/cli/src/index.mjs +10 -0
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +107 -0
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +344 -0
- package/node_modules/@agents-can-communicate/cli/src/platform-paths.mjs +109 -0
- package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +58 -0
- package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +126 -0
- package/node_modules/@agents-can-communicate/cli/src/workspace-discovery.mjs +151 -0
- package/node_modules/@agents-can-communicate/core/package.json +12 -0
- package/node_modules/@agents-can-communicate/core/src/claims.mjs +180 -0
- package/node_modules/@agents-can-communicate/core/src/communication.mjs +334 -0
- package/node_modules/@agents-can-communicate/core/src/index.mjs +7 -0
- package/node_modules/@agents-can-communicate/core/src/intents.mjs +75 -0
- package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +86 -0
- package/node_modules/@agents-can-communicate/core/src/notify.mjs +95 -0
- package/node_modules/@agents-can-communicate/core/src/participants.mjs +48 -0
- package/node_modules/@agents-can-communicate/core/src/ports.mjs +56 -0
- package/node_modules/@agents-can-communicate/core/src/service.mjs +44 -0
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +190 -0
- package/node_modules/@agents-can-communicate/core/src/status.mjs +132 -0
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +273 -0
- package/node_modules/@agents-can-communicate/core/src/tasks.mjs +238 -0
- package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +109 -0
- package/node_modules/@agents-can-communicate/hook-runner/package.json +12 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +379 -0
- package/node_modules/@agents-can-communicate/installer/package.json +10 -0
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +58 -0
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +79 -0
- package/node_modules/@agents-can-communicate/installer/src/index.mjs +6 -0
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +162 -0
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +62 -0
- package/node_modules/@agents-can-communicate/mcp-server/package.json +12 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +58 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +250 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +201 -0
- package/node_modules/@agents-can-communicate/protocol/package.json +12 -0
- package/node_modules/@agents-can-communicate/protocol/src/config.mjs +151 -0
- package/node_modules/@agents-can-communicate/protocol/src/envelopes.mjs +25 -0
- package/node_modules/@agents-can-communicate/protocol/src/errors.mjs +30 -0
- package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +103 -0
- package/node_modules/@agents-can-communicate/protocol/src/ids.mjs +25 -0
- package/node_modules/@agents-can-communicate/protocol/src/index.mjs +9 -0
- package/node_modules/@agents-can-communicate/protocol/src/resources.mjs +74 -0
- package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +175 -0
- package/node_modules/@agents-can-communicate/protocol/src/states.mjs +55 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +12 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +135 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +67 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +4 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +87 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/record-id.mjs +44 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +114 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +80 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +55 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +292 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +87 -0
- package/package.json +81 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
2
|
+
|
|
3
|
+
import { classifySessionPresence } from "./sessions.mjs";
|
|
4
|
+
import { overlaps } from "./claims.mjs";
|
|
5
|
+
|
|
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
|
+
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
|
+
const SCOPES = Object.freeze(["delta", "full"]);
|
|
18
|
+
|
|
19
|
+
function assertScope(scope) {
|
|
20
|
+
if (scope != null && !SCOPES.includes(scope)) {
|
|
21
|
+
throw new AccError(EXIT.USAGE,
|
|
22
|
+
`scope is one of ${SCOPES.join(", ")}; leave it out for ${SCOPES[0]}`, { scope });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function assertCursor(cursor) {
|
|
27
|
+
if (typeof cursor !== "string" || !CURSOR.test(cursor)) {
|
|
28
|
+
throw new AccError(EXIT.USAGE,
|
|
29
|
+
"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
|
+
});
|
|
49
|
+
|
|
50
|
+
function directRequests(snapshot, participantId) {
|
|
51
|
+
const items = [];
|
|
52
|
+
for (const receipt of snapshot.receipts ?? []) {
|
|
53
|
+
if (receipt.recipientParticipantId !== participantId) continue;
|
|
54
|
+
if (receipt.state === "acknowledged" || receipt.state === "failed") continue;
|
|
55
|
+
const message = (snapshot.messages ?? []).find(item => item.messageId === receipt.messageId);
|
|
56
|
+
if (message === undefined || !message.requiresAck) continue;
|
|
57
|
+
items.push({ kind: "direct_request", priority: ATTENTION_PRIORITY.direct_request,
|
|
58
|
+
sourceId: message.messageId, summary: message.subject });
|
|
59
|
+
}
|
|
60
|
+
return items;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A claim of yours that has run out.
|
|
65
|
+
*
|
|
66
|
+
* A lease lapses on the clock, and nothing said so. Measured: while it held, a
|
|
67
|
+
* peer's write into the file was refused; three seconds later the same write
|
|
68
|
+
* went through, and the holder's turn was identical before and after. It went on
|
|
69
|
+
* working on a file it believed it had reserved, and everyone else was free to
|
|
70
|
+
* change it.
|
|
71
|
+
*
|
|
72
|
+
* Only for the session that took it, and only while that session is the one
|
|
73
|
+
* asking: a lapsed claim is news to its owner and nobody else's business.
|
|
74
|
+
* Re-claiming refreshes the lease and clears this; releasing it clears it too.
|
|
75
|
+
*/
|
|
76
|
+
function expiredClaims(snapshot, session, now) {
|
|
77
|
+
if (session == null) return [];
|
|
78
|
+
return (snapshot.claims ?? [])
|
|
79
|
+
.filter(claim => claim.ownerSessionId === session.sessionId
|
|
80
|
+
&& Date.parse(claim.expiresAt) <= Date.parse(now))
|
|
81
|
+
.map(claim => ({ kind: "claim_expired", priority: ATTENTION_PRIORITY.claim_expired,
|
|
82
|
+
sourceId: claim.claimId,
|
|
83
|
+
summary: `${claim.resource} - your claim has run out, and peers can write to it` }));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function claimConflicts(snapshot, session, now) {
|
|
87
|
+
const mine = (snapshot.intents ?? []).find(intent => intent.sessionId === session?.sessionId);
|
|
88
|
+
if (mine === undefined) return [];
|
|
89
|
+
return (snapshot.claims ?? [])
|
|
90
|
+
.filter(claim => claim.ownerSessionId !== session.sessionId
|
|
91
|
+
&& Date.parse(claim.expiresAt) > Date.parse(now)
|
|
92
|
+
&& mine.resourceHints.some(hint => overlaps(hint, claim.resource)))
|
|
93
|
+
.map(claim => ({ kind: "claim_conflict", priority: ATTENTION_PRIORITY.claim_conflict,
|
|
94
|
+
sourceId: claim.claimId,
|
|
95
|
+
summary: `${claim.resource} is claimed by ${claim.ownerSessionId}` }));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Work waiting on me.
|
|
100
|
+
*
|
|
101
|
+
* Addressed by participant, so a request survives the recipient restarting -
|
|
102
|
+
* the next session of that agent is told about it. A task already taken by one
|
|
103
|
+
* of my sessions matches too, since that session may have been replaced.
|
|
104
|
+
*
|
|
105
|
+
* Unaddressed tasks are deliberately absent. Anyone may take one, but pushing
|
|
106
|
+
* every open task into every turn is how a coordination layer becomes noise.
|
|
107
|
+
*/
|
|
108
|
+
function unblockedTasks(snapshot, session, participantId) {
|
|
109
|
+
const mine = task => (task.assigneeParticipantId !== null
|
|
110
|
+
&& task.assigneeParticipantId === participantId)
|
|
111
|
+
|| (task.assigneeSessionId !== null && task.assigneeSessionId === session?.sessionId);
|
|
112
|
+
return (snapshot.tasks ?? [])
|
|
113
|
+
.filter(task => task.state === "pending" && mine(task))
|
|
114
|
+
.map(task => ({ kind: "task_unblocked", priority: ATTENTION_PRIORITY.task_unblocked,
|
|
115
|
+
sourceId: task.taskId, summary: task.title }));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Work you asked for that nobody is doing any more.
|
|
120
|
+
*
|
|
121
|
+
* A task taken by a session that then crashed stayed `in_progress` for good:
|
|
122
|
+
* the requester was told nothing and nobody else could take it. Unlike the
|
|
123
|
+
* one-shot answers a request produces, this repeats until it is resolved,
|
|
124
|
+
* because it stays true until someone picks the work back up.
|
|
125
|
+
*/
|
|
126
|
+
/**
|
|
127
|
+
* A question nobody is left to answer.
|
|
128
|
+
*
|
|
129
|
+
* The task rule below tells a requester when work they asked for is going
|
|
130
|
+
* nowhere. A `requiresAck` message had no such rule, and a message is the other
|
|
131
|
+
* half of the same act: an agent asked a peer a direct question, the peer's
|
|
132
|
+
* session ended without answering, and the asker's next turn was empty. Not
|
|
133
|
+
* "still waiting" - empty. Measured, with the only other agent gone and an
|
|
134
|
+
* unanswered question standing between them.
|
|
135
|
+
*
|
|
136
|
+
* The same kind as the task case, because it is the same fact about the world:
|
|
137
|
+
* you asked, and there is nobody there.
|
|
138
|
+
*/
|
|
139
|
+
function unansweredQuestions(snapshot, participantId, onlineParticipants) {
|
|
140
|
+
const items = [];
|
|
141
|
+
for (const receipt of snapshot.receipts ?? []) {
|
|
142
|
+
if (receipt.state === "acknowledged" || receipt.state === "failed") continue;
|
|
143
|
+
const message = (snapshot.messages ?? [])
|
|
144
|
+
.find(item => item.messageId === receipt.messageId);
|
|
145
|
+
if (message === undefined || !message.requiresAck) continue;
|
|
146
|
+
if (message.fromParticipantId !== participantId) continue;
|
|
147
|
+
// Not answered yet by someone who is here is ordinary waiting, and saying so
|
|
148
|
+
// every turn would be noise the reader learns to skip.
|
|
149
|
+
if (onlineParticipants.has(receipt.recipientParticipantId)) continue;
|
|
150
|
+
items.push({ kind: "request_stalled", priority: ATTENTION_PRIORITY.request_stalled,
|
|
151
|
+
sourceId: message.messageId,
|
|
152
|
+
summary: `${message.subject} - ${receipt.recipientParticipantId} is not here to answer` });
|
|
153
|
+
}
|
|
154
|
+
return items;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function stalledRequests(snapshot, participantId, now) {
|
|
158
|
+
const live = new Map((snapshot.sessions ?? [])
|
|
159
|
+
.map(session => [session.sessionId, classifySessionPresence(session, now)]));
|
|
160
|
+
const onlineParticipants = new Set((snapshot.sessions ?? [])
|
|
161
|
+
.filter(session => classifySessionPresence(session, now) === "online")
|
|
162
|
+
.map(session => session.participantId));
|
|
163
|
+
const goingNowhere = task => {
|
|
164
|
+
// Taken by someone who has gone quiet.
|
|
165
|
+
if (task.state === "in_progress") {
|
|
166
|
+
return task.assigneeSessionId !== null
|
|
167
|
+
&& live.get(task.assigneeSessionId) !== "online";
|
|
168
|
+
}
|
|
169
|
+
// Or waiting on an agent that is not here - including one that closed and
|
|
170
|
+
// never came back, which leaves the work addressed to nobody at all.
|
|
171
|
+
return task.state === "pending" && task.assigneeParticipantId !== null
|
|
172
|
+
&& !onlineParticipants.has(task.assigneeParticipantId);
|
|
173
|
+
};
|
|
174
|
+
return [
|
|
175
|
+
...(snapshot.tasks ?? [])
|
|
176
|
+
.filter(task => task.requestedByParticipantId === participantId
|
|
177
|
+
&& goingNowhere(task))
|
|
178
|
+
.map(task => ({ kind: "request_stalled", priority: ATTENTION_PRIORITY.request_stalled,
|
|
179
|
+
sourceId: task.taskId,
|
|
180
|
+
summary: `${task.title} - nobody is working on it` })),
|
|
181
|
+
...unansweredQuestions(snapshot, participantId, onlineParticipants),
|
|
182
|
+
];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function coordinatorGaps(snapshot) {
|
|
186
|
+
return (snapshot.workstreams ?? [])
|
|
187
|
+
.filter(workstream => workstream.state === "open"
|
|
188
|
+
&& workstream.coordinatorSessionId === null)
|
|
189
|
+
.map(workstream => ({ kind: "coordinator_missing",
|
|
190
|
+
priority: ATTENTION_PRIORITY.coordinator_missing,
|
|
191
|
+
sourceId: workstream.workstreamId, summary: workstream.title }));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function computeAttention(snapshot, { session, participantId, now }) {
|
|
195
|
+
return [
|
|
196
|
+
...directRequests(snapshot, participantId),
|
|
197
|
+
...claimConflicts(snapshot, session, now),
|
|
198
|
+
...expiredClaims(snapshot, session, now),
|
|
199
|
+
...unblockedTasks(snapshot, session, participantId),
|
|
200
|
+
...coordinatorGaps(snapshot),
|
|
201
|
+
...stalledRequests(snapshot, participantId, now),
|
|
202
|
+
].sort((left, right) => left.priority - right.priority
|
|
203
|
+
|| left.sourceId.localeCompare(right.sourceId));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function createSyncService(ports, sessions) {
|
|
207
|
+
const { store, clock } = ports;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Any session may request the full Workspace scope. Peer equality is a
|
|
211
|
+
* knowledge property: no session receives a reduced
|
|
212
|
+
* view because of its role. The bounded delta is only the ambient default.
|
|
213
|
+
*/
|
|
214
|
+
async function sync(input = {}) {
|
|
215
|
+
// A cursor that is not a cursor answered "nothing new", every time, for as
|
|
216
|
+
// long as it was held. `eventsSince` compares sequences as strings, so
|
|
217
|
+
// `not-a-cursor` sorts after every event there has ever been - and an
|
|
218
|
+
// adapter holding a corrupt one, or an agent that invented one, saw a quiet
|
|
219
|
+
// workspace rather than a mistake. `"0000000000000001; DROP"` was quietly
|
|
220
|
+
// taken as the sequence it starts with.
|
|
221
|
+
if (input.cursor != null) assertCursor(input.cursor);
|
|
222
|
+
assertScope(input.scope);
|
|
223
|
+
const workspaceId = input.workspaceId ?? store.workspaceId;
|
|
224
|
+
const now = clock.now();
|
|
225
|
+
const located = input.sessionId === undefined
|
|
226
|
+
? null
|
|
227
|
+
: await sessions.locateSession(input.sessionId, workspaceId);
|
|
228
|
+
const session = located?.record ?? null;
|
|
229
|
+
|
|
230
|
+
const durable = await store.snapshot(workspaceId);
|
|
231
|
+
// A workspace that has not materialised still has a truthful roster: its
|
|
232
|
+
// sessions live in the ephemeral area. Reading only the durable snapshot
|
|
233
|
+
// would make a lone session invisible to itself, and would disagree with
|
|
234
|
+
// what `status` reports from the same state.
|
|
235
|
+
const snapshot = durable.workspace !== null
|
|
236
|
+
? durable
|
|
237
|
+
: { ...durable,
|
|
238
|
+
sessions: await store.ephemeral.list("session"),
|
|
239
|
+
intents: await store.ephemeral.list("intent") };
|
|
240
|
+
const page = await store.eventsSince(workspaceId, input.cursor ?? null,
|
|
241
|
+
input.limit ?? DEFAULT_LIMIT);
|
|
242
|
+
const attention = computeAttention(snapshot, { session,
|
|
243
|
+
participantId: session?.participantId ?? input.participantId, now });
|
|
244
|
+
|
|
245
|
+
const roster = snapshot.sessions.map(item => ({
|
|
246
|
+
sessionId: item.sessionId,
|
|
247
|
+
participantId: item.participantId,
|
|
248
|
+
parentSessionId: item.parentSessionId,
|
|
249
|
+
harness: item.harness,
|
|
250
|
+
branch: item.branch ?? null,
|
|
251
|
+
presence: classifySessionPresence(item, now),
|
|
252
|
+
}));
|
|
253
|
+
|
|
254
|
+
// Solo zero-overhead: one live session, no claims and
|
|
255
|
+
// no attention means an empty result, not a "nothing to report" banner.
|
|
256
|
+
const peers = roster.filter(item => item.sessionId !== session?.sessionId
|
|
257
|
+
&& item.presence !== "offline");
|
|
258
|
+
const solo = peers.length === 0 && attention.length === 0
|
|
259
|
+
&& snapshot.claims.length === 0;
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
cursor: page.cursor,
|
|
263
|
+
scope: input.scope === "full" ? "full" : "delta",
|
|
264
|
+
solo,
|
|
265
|
+
attention,
|
|
266
|
+
roster,
|
|
267
|
+
events: page.events,
|
|
268
|
+
...(input.scope === "full" ? { snapshot } : {}),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return { sync };
|
|
273
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { AccError, EXIT, SCHEMA_VERSION, createId, transitionTask as stepTask, validateRecord }
|
|
2
|
+
from "@agents-can-communicate/protocol";
|
|
3
|
+
|
|
4
|
+
import { ensureMaterialised } from "./materialisation.mjs";
|
|
5
|
+
import { assertKnownParticipants } from "./participants.mjs";
|
|
6
|
+
import { classifySessionPresence } from "./sessions.mjs";
|
|
7
|
+
import { closeRequestReceipt, writeWorkResponse } from "./notify.mjs";
|
|
8
|
+
|
|
9
|
+
// Dependency completion unblocks tasks deterministically, inside the same
|
|
10
|
+
// transaction that completed the dependency. It must never depend on a model
|
|
11
|
+
// remembering to re-evaluate the graph.
|
|
12
|
+
//
|
|
13
|
+
// Exported because a caller may supply its own task id - an adapter mirroring
|
|
14
|
+
// an external tracker, for instance - and that is the only way a create can
|
|
15
|
+
// close a cycle. Without an explicit id the guard would be unreachable.
|
|
16
|
+
export function wouldCycle(tasks, taskId, dependsOn) {
|
|
17
|
+
const byId = new Map(tasks.map(task => [task.taskId, task]));
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
const stack = [...dependsOn];
|
|
20
|
+
while (stack.length > 0) {
|
|
21
|
+
const current = stack.pop();
|
|
22
|
+
if (current === taskId) return true;
|
|
23
|
+
if (seen.has(current)) continue;
|
|
24
|
+
seen.add(current);
|
|
25
|
+
stack.push(...(byId.get(current)?.dependsOn ?? []));
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const blockedBy = (task, byId) => task.dependsOn
|
|
31
|
+
.filter(id => (byId.get(id)?.state ?? "pending") !== "done");
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Write one task inside a transaction the caller already owns.
|
|
35
|
+
*
|
|
36
|
+
* Separated so that `acc request` - which creates the task and tells the
|
|
37
|
+
* recipient about it - can do both as one write. A request that produced a task
|
|
38
|
+
* and then failed to mention it would leave work addressed to an agent that was
|
|
39
|
+
* never told, which is worse than no request at all.
|
|
40
|
+
*/
|
|
41
|
+
export function writeTask(tx, { input, session, workspaceId, now, ids }) {
|
|
42
|
+
const taskId = input.taskId ?? createId("task");
|
|
43
|
+
const existing = tx.list("task");
|
|
44
|
+
const dependsOn = input.dependsOn ?? [];
|
|
45
|
+
for (const dependency of dependsOn) {
|
|
46
|
+
if (tx.get("task", dependency) === null) {
|
|
47
|
+
throw new AccError(EXIT.DATA, "a dependency does not exist", { dependency });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (wouldCycle(existing, taskId, dependsOn)) {
|
|
51
|
+
throw new AccError(EXIT.DATA, "the dependency graph would contain a cycle", { taskId });
|
|
52
|
+
}
|
|
53
|
+
// A workstream is optional - "finish these tests for me" should not require
|
|
54
|
+
// inventing a project first - but a named one has to exist, or the task hangs
|
|
55
|
+
// off nothing and nobody notices.
|
|
56
|
+
const workstreamId = input.workstreamId ?? null;
|
|
57
|
+
if (workstreamId !== null && tx.get("workstream", workstreamId) === null) {
|
|
58
|
+
throw new AccError(EXIT.DATA, "the workstream does not exist", { workstreamId });
|
|
59
|
+
}
|
|
60
|
+
const record = validateRecord("task", {
|
|
61
|
+
schemaVersion: SCHEMA_VERSION,
|
|
62
|
+
taskId,
|
|
63
|
+
workstreamId,
|
|
64
|
+
workspaceId,
|
|
65
|
+
title: input.title,
|
|
66
|
+
detail: input.detail ?? null,
|
|
67
|
+
state: blockedBy({ dependsOn }, new Map(existing.map(task => [task.taskId, task])))
|
|
68
|
+
.length > 0 ? "blocked" : "pending",
|
|
69
|
+
// Addressed to a participant, so the request survives that agent closing
|
|
70
|
+
// its terminal. Whoever picks it up is recorded separately.
|
|
71
|
+
assigneeParticipantId: input.assigneeParticipantId ?? null,
|
|
72
|
+
assigneeSessionId: null,
|
|
73
|
+
requestedByParticipantId: input.requestedByParticipantId ?? null,
|
|
74
|
+
dependsOn,
|
|
75
|
+
acceptance: input.acceptance ?? [],
|
|
76
|
+
createdAt: now,
|
|
77
|
+
});
|
|
78
|
+
tx.put("task", taskId, record);
|
|
79
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
|
|
80
|
+
actorSessionId: session.sessionId, type: "task.created", occurredAt: now,
|
|
81
|
+
payload: { taskId, state: record.state,
|
|
82
|
+
assigneeParticipantId: record.assigneeParticipantId } });
|
|
83
|
+
return record;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createTaskService(ports, workstreams) {
|
|
87
|
+
const { store, clock, ids } = ports;
|
|
88
|
+
|
|
89
|
+
async function createTask(input) {
|
|
90
|
+
const session = await workstreams.requireOpenSession(input, "create a task");
|
|
91
|
+
const workspaceId = session.workspaceId;
|
|
92
|
+
await ensureMaterialised(ports, { workspaceId, descriptor: input.descriptor,
|
|
93
|
+
reason: "durable_object" });
|
|
94
|
+
// The same rule as addressing a message. `acc task --assignee physcis` was
|
|
95
|
+
// accepted and left work `pending` for a participant nobody has ever been:
|
|
96
|
+
// invisible to every roster, raising `task_unblocked` for nobody, and not
|
|
97
|
+
// even stalled, since nothing was waiting on it that could be told.
|
|
98
|
+
if (input.assigneeParticipantId != null) {
|
|
99
|
+
await assertKnownParticipants(store, workspaceId, [input.assigneeParticipantId]);
|
|
100
|
+
}
|
|
101
|
+
const now = clock.now();
|
|
102
|
+
let record = null;
|
|
103
|
+
await store.transaction(async tx => {
|
|
104
|
+
record = writeTask(tx, { input, session, workspaceId, now, ids });
|
|
105
|
+
// `writeTask` reads and writes on this handle, so its kinds are ours.
|
|
106
|
+
}, { kinds: ["session", "task", "workstream"] });
|
|
107
|
+
return record;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function claimTask(input) {
|
|
111
|
+
const session = await workstreams.requireOpenSession(input, "claim a task");
|
|
112
|
+
const now = clock.now();
|
|
113
|
+
let record = null;
|
|
114
|
+
await store.transaction(async tx => {
|
|
115
|
+
const existing = tx.get("task", input.taskId);
|
|
116
|
+
if (existing === null) {
|
|
117
|
+
throw new AccError(EXIT.DATA, "the task does not exist", { taskId: input.taskId });
|
|
118
|
+
}
|
|
119
|
+
if (existing.assigneeSessionId !== null
|
|
120
|
+
&& existing.assigneeSessionId !== session.sessionId) {
|
|
121
|
+
// A holder that is gone is not a holder. Closing a session hands its
|
|
122
|
+
// work back, so this is the crash case: no session end ever arrived and
|
|
123
|
+
// presence has decayed. Staleness alone does not release it - that is
|
|
124
|
+
// the same rule claims follow, because an idle agent may be thinking
|
|
125
|
+
// rather than dead - but it can be taken over deliberately.
|
|
126
|
+
const holder = tx.get("session", existing.assigneeSessionId);
|
|
127
|
+
const presence = holder === null
|
|
128
|
+
? "offline"
|
|
129
|
+
: classifySessionPresence(holder, now);
|
|
130
|
+
if (presence === "online") {
|
|
131
|
+
throw new AccError(EXIT.CONFLICT, "the task already has an assignee",
|
|
132
|
+
{ taskId: input.taskId, assigneeSessionId: existing.assigneeSessionId });
|
|
133
|
+
}
|
|
134
|
+
if (presence === "stale" && input.force !== true) {
|
|
135
|
+
throw new AccError(EXIT.CONFLICT,
|
|
136
|
+
"the task is held by a session that has gone quiet; take it with force",
|
|
137
|
+
{ taskId: input.taskId, assigneeSessionId: existing.assigneeSessionId,
|
|
138
|
+
presence });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Work addressed to one participant is not picked up by another. Taking
|
|
142
|
+
// an unaddressed task is open to anyone, which is what makes a request
|
|
143
|
+
// with no named recipient a request to the room.
|
|
144
|
+
if (existing.assigneeParticipantId !== null
|
|
145
|
+
&& existing.assigneeParticipantId !== session.participantId) {
|
|
146
|
+
throw new AccError(EXIT.CONFLICT, "the task is addressed to another participant",
|
|
147
|
+
{ taskId: input.taskId,
|
|
148
|
+
assigneeParticipantId: existing.assigneeParticipantId });
|
|
149
|
+
}
|
|
150
|
+
record = { ...existing, assigneeSessionId: session.sessionId,
|
|
151
|
+
assigneeParticipantId: existing.assigneeParticipantId ?? session.participantId,
|
|
152
|
+
state: stepTask(existing.state, "in_progress") };
|
|
153
|
+
// Whoever asked is waiting on an answer, and "someone is on it" is one.
|
|
154
|
+
writeWorkResponse(tx, { task: record, actor: session,
|
|
155
|
+
workspaceId: session.workspaceId, now, ids, outcome: "accepted" });
|
|
156
|
+
tx.put("task", input.taskId, record, tx.generationOf("task", input.taskId));
|
|
157
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
|
|
158
|
+
workspaceId: session.workspaceId, actorSessionId: session.sessionId,
|
|
159
|
+
type: "task.claimed", occurredAt: now, payload: { taskId: input.taskId } });
|
|
160
|
+
}, { kinds: ["message", "receipt", "session", "task"] });
|
|
161
|
+
return record;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function transitionTask(input) {
|
|
165
|
+
const session = await workstreams.requireOpenSession(input, "transition a task");
|
|
166
|
+
const now = clock.now();
|
|
167
|
+
let record = null;
|
|
168
|
+
await store.transaction(async tx => {
|
|
169
|
+
const existing = tx.get("task", input.taskId);
|
|
170
|
+
if (existing === null) {
|
|
171
|
+
throw new AccError(EXIT.DATA, "the task does not exist", { taskId: input.taskId });
|
|
172
|
+
}
|
|
173
|
+
record = { ...existing, state: stepTask(existing.state, input.state) };
|
|
174
|
+
tx.put("task", input.taskId, record, tx.generationOf("task", input.taskId));
|
|
175
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
|
|
176
|
+
workspaceId: session.workspaceId, actorSessionId: session.sessionId,
|
|
177
|
+
type: "task.transitioned", occurredAt: now,
|
|
178
|
+
payload: { taskId: input.taskId, state: record.state } });
|
|
179
|
+
|
|
180
|
+
if (record.state === "done" || record.state === "review") {
|
|
181
|
+
writeWorkResponse(tx, { task: record, actor: session,
|
|
182
|
+
workspaceId: session.workspaceId, now, ids, outcome: record.state });
|
|
183
|
+
}
|
|
184
|
+
// Doing the work answers the request that asked for it.
|
|
185
|
+
if (record.state === "done") closeRequestReceipt(tx, { task: record, actor: session, now, ids });
|
|
186
|
+
if (record.state !== "done") return;
|
|
187
|
+
// Unblock dependents here, in the same transaction, so the graph is
|
|
188
|
+
// never left in a state that needs someone to notice it later.
|
|
189
|
+
const byId = new Map(tx.list("task").map(task => [task.taskId, task]));
|
|
190
|
+
byId.set(record.taskId, record);
|
|
191
|
+
for (const dependent of byId.values()) {
|
|
192
|
+
if (dependent.state !== "blocked") continue;
|
|
193
|
+
if (blockedBy(dependent, byId).length > 0) continue;
|
|
194
|
+
const unblocked = { ...dependent, state: "pending" };
|
|
195
|
+
tx.put("task", dependent.taskId, unblocked,
|
|
196
|
+
tx.generationOf("task", dependent.taskId));
|
|
197
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
|
|
198
|
+
workspaceId: session.workspaceId, actorSessionId: session.sessionId,
|
|
199
|
+
type: "task.unblocked", occurredAt: now, payload: { taskId: dependent.taskId } });
|
|
200
|
+
}
|
|
201
|
+
}, { kinds: ["message", "receipt", "task"] });
|
|
202
|
+
return record;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Refuse a request, with a reason.
|
|
207
|
+
*
|
|
208
|
+
* The task returns to unclaimed rather than being deleted: the work is still
|
|
209
|
+
* wanted, it is just not this agent's. Leaving a request pending forever was
|
|
210
|
+
* the only way to say no, and it looks identical to not having read it.
|
|
211
|
+
*/
|
|
212
|
+
async function declineTask(input) {
|
|
213
|
+
const session = await workstreams.requireOpenSession(input, "decline a task");
|
|
214
|
+
const now = clock.now();
|
|
215
|
+
let record = null;
|
|
216
|
+
await store.transaction(async tx => {
|
|
217
|
+
const existing = tx.get("task", input.taskId);
|
|
218
|
+
if (existing === null) {
|
|
219
|
+
throw new AccError(EXIT.DATA, "the task does not exist", { taskId: input.taskId });
|
|
220
|
+
}
|
|
221
|
+
record = { ...existing, assigneeParticipantId: null, assigneeSessionId: null,
|
|
222
|
+
state: "pending" };
|
|
223
|
+
tx.put("task", input.taskId, record, tx.generationOf("task", input.taskId));
|
|
224
|
+
writeWorkResponse(tx, { task: existing, actor: session,
|
|
225
|
+
workspaceId: session.workspaceId, now, ids, outcome: "declined",
|
|
226
|
+
reason: input.reason ?? null });
|
|
227
|
+
// Refusing is also an answer, so the request stops demanding one.
|
|
228
|
+
closeRequestReceipt(tx, { task: existing, actor: session, now, ids });
|
|
229
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
|
|
230
|
+
workspaceId: session.workspaceId, actorSessionId: session.sessionId,
|
|
231
|
+
type: "task.declined", occurredAt: now,
|
|
232
|
+
payload: { taskId: input.taskId, reason: input.reason ?? null } });
|
|
233
|
+
}, { kinds: ["message", "receipt", "task"] });
|
|
234
|
+
return record;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { createTask, claimTask, transitionTask, declineTask };
|
|
238
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { AccError, EXIT, SCHEMA_VERSION, createId, validateRecord }
|
|
2
|
+
from "@agents-can-communicate/protocol";
|
|
3
|
+
|
|
4
|
+
import { ensureMaterialised } from "./materialisation.mjs";
|
|
5
|
+
import { classifySessionPresence } from "./sessions.mjs";
|
|
6
|
+
|
|
7
|
+
// A workstream groups related collaboration. It may have zero or one
|
|
8
|
+
// coordinator lease, and the coordinator plans - it is never the transport,
|
|
9
|
+
// the durable owner, or an information gatekeeper.
|
|
10
|
+
export function createWorkstreamService(ports, sessions) {
|
|
11
|
+
const { store, clock, ids } = ports;
|
|
12
|
+
|
|
13
|
+
async function requireOpenSession(input, action) {
|
|
14
|
+
const existing = await sessions.locateSession(input.sessionId, input.workspaceId);
|
|
15
|
+
if (existing === null || existing.record.state !== "open"
|
|
16
|
+
|| existing.record.generation !== input.generation) {
|
|
17
|
+
throw new AccError(EXIT.CONFLICT, `cannot ${action} from this session generation`,
|
|
18
|
+
{ sessionId: input.sessionId });
|
|
19
|
+
}
|
|
20
|
+
return existing.record;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function createWorkstream(input) {
|
|
24
|
+
const session = await requireOpenSession(input, "create a workstream");
|
|
25
|
+
const workspaceId = session.workspaceId;
|
|
26
|
+
await ensureMaterialised(ports, { workspaceId, descriptor: input.descriptor,
|
|
27
|
+
reason: "durable_object" });
|
|
28
|
+
const now = clock.now();
|
|
29
|
+
const workstreamId = createId("workstream");
|
|
30
|
+
const record = validateRecord("workstream", {
|
|
31
|
+
schemaVersion: SCHEMA_VERSION,
|
|
32
|
+
workstreamId,
|
|
33
|
+
workspaceId,
|
|
34
|
+
title: input.title,
|
|
35
|
+
objective: input.objective,
|
|
36
|
+
// A workstream never acquires a coordinator merely by being created.
|
|
37
|
+
coordinatorSessionId: null,
|
|
38
|
+
state: "open",
|
|
39
|
+
createdAt: now,
|
|
40
|
+
});
|
|
41
|
+
await store.transaction(async tx => {
|
|
42
|
+
tx.put("workstream", workstreamId, record);
|
|
43
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
|
|
44
|
+
actorSessionId: session.sessionId, type: "workstream.created", occurredAt: now,
|
|
45
|
+
payload: { workstreamId } });
|
|
46
|
+
}, { kinds: ["workstream"] });
|
|
47
|
+
return record;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function acquireCoordinator(input) {
|
|
51
|
+
const session = await requireOpenSession(input, "coordinate");
|
|
52
|
+
const now = clock.now();
|
|
53
|
+
const snapshot = await store.snapshot(session.workspaceId,
|
|
54
|
+
{ kinds: ["session", "workstream"] });
|
|
55
|
+
let record = null;
|
|
56
|
+
await store.transaction(async tx => {
|
|
57
|
+
const existing = tx.get("workstream", input.workstreamId);
|
|
58
|
+
if (existing === null) {
|
|
59
|
+
throw new AccError(EXIT.CONFLICT, "the workstream does not exist",
|
|
60
|
+
{ workstreamId: input.workstreamId });
|
|
61
|
+
}
|
|
62
|
+
const held = existing.coordinatorSessionId;
|
|
63
|
+
if (held !== null && held !== session.sessionId) {
|
|
64
|
+
const holder = snapshot.sessions.find(item => item.sessionId === held);
|
|
65
|
+
const presence = holder === undefined ? "offline"
|
|
66
|
+
: classifySessionPresence(holder, now);
|
|
67
|
+
// A coordinator lease is replaced only when the holder is genuinely
|
|
68
|
+
// gone or policy says so - not because a peer would like the role.
|
|
69
|
+
if (presence !== "offline" && input.authority !== "human"
|
|
70
|
+
&& input.authority !== "policy") {
|
|
71
|
+
throw new AccError(EXIT.CONFLICT, "the workstream already has a coordinator",
|
|
72
|
+
{ workstreamId: input.workstreamId, coordinatorSessionId: held,
|
|
73
|
+
coordinatorPresence: presence });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
record = { ...existing, coordinatorSessionId: session.sessionId };
|
|
77
|
+
tx.put("workstream", input.workstreamId, record,
|
|
78
|
+
tx.generationOf("workstream", input.workstreamId));
|
|
79
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
|
|
80
|
+
workspaceId: session.workspaceId, actorSessionId: session.sessionId,
|
|
81
|
+
type: "workstream.coordinator_acquired", occurredAt: now,
|
|
82
|
+
payload: { workstreamId: input.workstreamId, replaced: held } });
|
|
83
|
+
}, { kinds: ["workstream"] });
|
|
84
|
+
return record;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function releaseCoordinator(input) {
|
|
88
|
+
const session = await requireOpenSession(input, "release coordination");
|
|
89
|
+
const now = clock.now();
|
|
90
|
+
let record = null;
|
|
91
|
+
await store.transaction(async tx => {
|
|
92
|
+
const existing = tx.get("workstream", input.workstreamId);
|
|
93
|
+
if (existing === null || existing.coordinatorSessionId !== session.sessionId) {
|
|
94
|
+
throw new AccError(EXIT.CONFLICT, "only the coordinator may release the lease",
|
|
95
|
+
{ workstreamId: input.workstreamId });
|
|
96
|
+
}
|
|
97
|
+
record = { ...existing, coordinatorSessionId: null };
|
|
98
|
+
tx.put("workstream", input.workstreamId, record,
|
|
99
|
+
tx.generationOf("workstream", input.workstreamId));
|
|
100
|
+
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
|
|
101
|
+
workspaceId: session.workspaceId, actorSessionId: session.sessionId,
|
|
102
|
+
type: "workstream.coordinator_released", occurredAt: now,
|
|
103
|
+
payload: { workstreamId: input.workstreamId } });
|
|
104
|
+
}, { kinds: ["workstream"] });
|
|
105
|
+
return record;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { createWorkstream, acquireCoordinator, releaseCoordinator, requireOpenSession };
|
|
109
|
+
}
|