agents-can-communicate 0.6.3 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/bin/entrypoints/hook-output.mjs +12 -0
- package/docs/ARCHITECTURE.md +5 -0
- package/docs/CAPABILITIES.md +1 -1
- package/docs/CLI.md +50 -2
- package/docs/CONCEPTS.md +7 -0
- package/docs/GETTING_STARTED.md +2 -2
- package/docs/PROTOCOL.md +31 -0
- package/docs/TROUBLESHOOTING.md +4 -3
- package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/skills/acc/SKILL.md +8 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/install.mjs +2 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/native-delivery.mjs +57 -11
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +8 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +8 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +8 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +8 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +8 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +15 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt-writer.mjs +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt.mjs +3 -1
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +6 -0
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +13 -1
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -1
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +33 -8
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/attention.mjs +7 -2
- package/node_modules/@agents-can-communicate/core/src/conversations.mjs +2 -2
- package/node_modules/@agents-can-communicate/core/src/intents.mjs +16 -0
- package/node_modules/@agents-can-communicate/core/src/next-turn-delivery.mjs +21 -4
- package/node_modules/@agents-can-communicate/core/src/prune.mjs +241 -0
- package/node_modules/@agents-can-communicate/core/src/receipts.mjs +66 -10
- package/node_modules/@agents-can-communicate/core/src/service.mjs +3 -0
- package/node_modules/@agents-can-communicate/core/src/status.mjs +25 -1
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +15 -2
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +60 -7
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +62 -27
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +5 -3
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +26 -7
- package/node_modules/@agents-can-communicate/storage-filesystem/src/doomed-directory.mjs +98 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/event-floor.mjs +52 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/reclaim.mjs +272 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +54 -7
- package/node_modules/@agents-can-communicate/storage-filesystem/src/stage-sweep.mjs +174 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +97 -5
- package/package.json +1 -1
|
@@ -4,6 +4,7 @@ import { computeAttention } from "./attention.mjs";
|
|
|
4
4
|
import { classifySessionPresence } from "./sessions.mjs";
|
|
5
5
|
import { assertMessageId, messagePage } from "./message-pages.mjs";
|
|
6
6
|
import { decisionView, isCurrentDecision } from "./decision-state.mjs";
|
|
7
|
+
import { offerFacts } from "./receipts.mjs";
|
|
7
8
|
|
|
8
9
|
const DEFAULT_LIMIT = 100;
|
|
9
10
|
const CURSOR = /^[0-9]{16}$/;
|
|
@@ -38,7 +39,7 @@ export function createSyncService(ports, sessions) {
|
|
|
38
39
|
}
|
|
39
40
|
if (exact) assertMessageId(input.messageId);
|
|
40
41
|
const snapshot = await store.snapshot(input.workspaceId ?? store.workspaceId,
|
|
41
|
-
{ kinds: ["message"] });
|
|
42
|
+
{ kinds: exact ? ["message", "receipt"] : ["message"] });
|
|
42
43
|
const messages = snapshot.messages.map(decisionView(snapshot.messages));
|
|
43
44
|
if (!exact) return messagePage(messages, input,
|
|
44
45
|
{ include: message => input.current !== true || isCurrentDecision(message),
|
|
@@ -47,7 +48,15 @@ export function createSyncService(ports, sessions) {
|
|
|
47
48
|
if (message === undefined) {
|
|
48
49
|
throw new AccError(EXIT.DATA, "message is not in this workspace's history");
|
|
49
50
|
}
|
|
50
|
-
|
|
51
|
+
// Beside the message rather than inside it: the router offers `items[0]`
|
|
52
|
+
// to a transport, and a sender asking "did they get it" wants every
|
|
53
|
+
// recipient's answer. `sync --scope full` already shows all of it.
|
|
54
|
+
const receipts = snapshot.receipts
|
|
55
|
+
.filter(receipt => receipt.messageId === message.messageId)
|
|
56
|
+
.sort((left, right) => left.recipientParticipantId.localeCompare(right.recipientParticipantId))
|
|
57
|
+
.map(receipt => ({ recipientParticipantId: receipt.recipientParticipantId,
|
|
58
|
+
state: receipt.state, updatedAt: receipt.updatedAt, offer: offerFacts(receipt) }));
|
|
59
|
+
return { scope: "history", view: "message", items: [message], receipts, nextCursor: null };
|
|
51
60
|
}
|
|
52
61
|
|
|
53
62
|
async function sync(input = {}) {
|
|
@@ -86,6 +95,10 @@ export function createSyncService(ports, sessions) {
|
|
|
86
95
|
&& snapshot.claims.length === 0;
|
|
87
96
|
return {
|
|
88
97
|
cursor: page.cursor,
|
|
98
|
+
// Where the log starts, when it no longer starts at the beginning. A
|
|
99
|
+
// caller whose cursor precedes this was served a short page, and saying
|
|
100
|
+
// so is the difference between a trim and a silent gap.
|
|
101
|
+
trimmedThrough: page.trimmedThrough ?? null,
|
|
89
102
|
scope: input.scope === "full" ? "full" : "delta",
|
|
90
103
|
solo,
|
|
91
104
|
attention,
|
|
@@ -3,10 +3,20 @@ import { readInstalledLivePolicyState } from "@agents-can-communicate/installer"
|
|
|
3
3
|
|
|
4
4
|
import { establishNativeBinding, LIVE_POLICIES, livePolicyFrom } from "./native-binding.mjs";
|
|
5
5
|
|
|
6
|
-
//
|
|
6
|
+
// A cold worker plus the write has to finish inside this window. 250 ms was
|
|
7
|
+
// not enough on a busy CI runner, so the attempt file never appeared and
|
|
8
|
+
// doctor reported no attempt. 1.5 s still abandons a stuck disk, and the
|
|
9
|
+
// extra 500 ms stays available for turn projection, status and output.
|
|
10
|
+
const DIAGNOSTIC_BUDGET_MS = 1_500;
|
|
11
|
+
const DIAGNOSTIC_FLOOR_MS = 250;
|
|
12
|
+
const AFTER_DIAGNOSTIC_MS = 500;
|
|
13
|
+
|
|
7
14
|
export function nativeDiagnosticDeadline(hookDeadline) {
|
|
8
15
|
const now = Date.now();
|
|
9
|
-
|
|
16
|
+
const remaining = hookDeadline - now;
|
|
17
|
+
// Below this, even the old quarter-second write would crowd out projection.
|
|
18
|
+
if (remaining < DIAGNOSTIC_FLOOR_MS + AFTER_DIAGNOSTIC_MS) return null;
|
|
19
|
+
return now + Math.min(DIAGNOSTIC_BUDGET_MS, remaining - AFTER_DIAGNOSTIC_MS);
|
|
10
20
|
}
|
|
11
21
|
|
|
12
22
|
// The diagnostic has its own bounded writer; it never rewrites hook ownership.
|
|
@@ -45,12 +55,44 @@ export async function bindNative({ adapter, event, hookBinding, clientVersion, p
|
|
|
45
55
|
const HINT_MAX_BYTES = 512;
|
|
46
56
|
const HINT_MAX_MS = 250;
|
|
47
57
|
|
|
58
|
+
function asHint(value) {
|
|
59
|
+
if (typeof value === "string") return { line: value, release: async () => {} };
|
|
60
|
+
if (value !== null && typeof value === "object" && typeof value.line === "string") {
|
|
61
|
+
return { line: value.line,
|
|
62
|
+
release: typeof value.release === "function" ? value.release : async () => {} };
|
|
63
|
+
}
|
|
64
|
+
if (value !== null && typeof value === "object" && typeof value.release === "function") {
|
|
65
|
+
return { line: "", release: value.release };
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function usableHint(line) {
|
|
71
|
+
return line !== "" && !/[\r\n]/.test(line) && Buffer.byteLength(line, "utf8") <= HINT_MAX_BYTES;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// An adapter may release with a synchronous function. Calling `.catch` on its
|
|
75
|
+
// return value throws when that return value is undefined, and a throw from
|
|
76
|
+
// the function itself must not become a failed hook.
|
|
77
|
+
export function callRelease(release) {
|
|
78
|
+
if (typeof release !== "function") return Promise.resolve();
|
|
79
|
+
try {
|
|
80
|
+
return Promise.resolve(release()).catch(() => {});
|
|
81
|
+
} catch {
|
|
82
|
+
return Promise.resolve();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
48
86
|
/**
|
|
49
87
|
* One line an adapter asks the agent to act on, when the agent itself can fix
|
|
50
88
|
* a degraded native binding - Antigravity's relay is started from the agent's
|
|
51
89
|
* own shell, and nothing else can start it. Bounded and fail-open: a slow or
|
|
52
90
|
* failing adapter costs the turn nothing, and anything but one short line is
|
|
53
91
|
* dropped rather than trusted into model context.
|
|
92
|
+
*
|
|
93
|
+
* A string has nothing reserved. `{ line, release }` has reserved an ask; this
|
|
94
|
+
* function calls `release` when it drops the line, including when the adapter
|
|
95
|
+
* answers after the budget. The caller releases a line it does not deliver.
|
|
54
96
|
*/
|
|
55
97
|
export async function nativeActivationHintFor({ adapter, event, nativeBinding, binding, context,
|
|
56
98
|
paths, deadline }) {
|
|
@@ -58,17 +100,28 @@ export async function nativeActivationHintFor({ adapter, event, nativeBinding, b
|
|
|
58
100
|
const budget = Math.min(HINT_MAX_MS, deadline - Date.now() - HINT_MAX_MS);
|
|
59
101
|
if (budget <= 0) return null;
|
|
60
102
|
let timer = null;
|
|
103
|
+
let keep = false;
|
|
104
|
+
// The call sits inside then so a synchronous throw becomes a rejection.
|
|
105
|
+
// Promise.resolve(adapter.nativeActivationHint()) evaluates the call first
|
|
106
|
+
// and lets that throw escape the hook.
|
|
107
|
+
const pending = Promise.resolve().then(() => adapter.nativeActivationHint({ event, nativeBinding,
|
|
108
|
+
runtimeDir: paths.root, clientPid: binding?.clientPid, env: context.env })).then(asHint, () => null);
|
|
61
109
|
try {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
clientPid: binding?.clientPid, env: context.env })),
|
|
110
|
+
const taken = await Promise.race([
|
|
111
|
+
pending,
|
|
65
112
|
new Promise(resolve => { timer = setTimeout(resolve, budget, null); }),
|
|
66
113
|
]);
|
|
67
|
-
|
|
68
|
-
|
|
114
|
+
if (taken !== null && usableHint(taken.line)) {
|
|
115
|
+
keep = true;
|
|
116
|
+
return taken;
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
69
119
|
} catch {
|
|
70
120
|
return null;
|
|
71
121
|
} finally {
|
|
72
122
|
if (timer !== null) clearTimeout(timer);
|
|
123
|
+
// A late or rejected answer still holds its reservation. Do not wait for
|
|
124
|
+
// it: an adapter that never settles must not hold the hook.
|
|
125
|
+
if (!keep) pending.then(taken => callRelease(taken?.release)).catch(() => {});
|
|
73
126
|
}
|
|
74
127
|
}
|
|
@@ -15,7 +15,8 @@ import { clearPin, createGitProbe, resolveHookWorkspace, platformDataHome, runti
|
|
|
15
15
|
|
|
16
16
|
import { resolveClientPid } from "./client-pid.mjs";
|
|
17
17
|
import { probeClientVersion as defaultProbeClientVersion } from "./client-version.mjs";
|
|
18
|
-
import { bindNative, nativeActivationHintFor, nativeDiagnosticDeadline }
|
|
18
|
+
import { bindNative, callRelease, nativeActivationHintFor, nativeDiagnosticDeadline }
|
|
19
|
+
from "./native-attempt.mjs";
|
|
19
20
|
import { readProcessTable as defaultReadProcessTable } from "./process-table.mjs";
|
|
20
21
|
import { withSessionLifecycle } from "./session-lifecycle.mjs";
|
|
21
22
|
import { appendStartOwner, appendToolOwner, ownerHeader, ownerOnlyOutcome } from "./owner-context.mjs";
|
|
@@ -236,7 +237,7 @@ async function openContext({ event, adapterId, dataHome, runtime, env, deadline
|
|
|
236
237
|
// `render` is how the projected text reaches this client: before a turn it is
|
|
237
238
|
// the adapter's context envelope, at the end of one it is a continuation.
|
|
238
239
|
async function projectTurn({ binding, context, adapter, adapterId,
|
|
239
|
-
render = text => adapter.injectOutcome?.(text), activationHint = null }) {
|
|
240
|
+
render = text => adapter.injectOutcome?.(text), activationHint = null, repeats = true }) {
|
|
240
241
|
const sync = await context.service.sync({ sessionId: binding.accSessionId,
|
|
241
242
|
cursor: null, scope: "delta" });
|
|
242
243
|
|
|
@@ -250,11 +251,22 @@ async function projectTurn({ binding, context, adapter, adapterId,
|
|
|
250
251
|
// Without this the projector's peer block never runs in production: an
|
|
251
252
|
// agent sees only the obligation attention line, not the durable body that
|
|
252
253
|
// may be offered after the stdout transport succeeds.
|
|
254
|
+
// Decided before selection: a repeat is taken out of the reminder lists, so
|
|
255
|
+
// asking for one this turn cannot show is how a message would go quiet.
|
|
256
|
+
// The ceiling a team agreed on in `acc.workspace.json`, or the default when
|
|
257
|
+
// there is no config. Validated by the protocol and, until now, never read:
|
|
258
|
+
// the projector was always called with its own default.
|
|
259
|
+
const effective = effectiveCapabilities(adapter, binding);
|
|
260
|
+
const hasStructuredRenderer = typeof adapter.renderContextResult === "function";
|
|
261
|
+
const canOfferNextTurn = effective.delivery.nextTurn === true && hasStructuredRenderer;
|
|
253
262
|
const delivery = await context.service.nextTurnDelivery({
|
|
254
263
|
workspaceId: context.descriptor.id,
|
|
255
264
|
participantId: mine?.participantId,
|
|
256
|
-
exceptSessionId: binding.accSessionId
|
|
257
|
-
|
|
265
|
+
exceptSessionId: binding.accSessionId,
|
|
266
|
+
repeats: repeats && canOfferNextTurn });
|
|
267
|
+
// New bodies first, so a repeat is what the byte budget drops.
|
|
268
|
+
const messages = [...delivery.queuedMessages, ...delivery.repeatMessages];
|
|
269
|
+
const repeating = new Set(delivery.repeatMessages.map(message => message.messageId));
|
|
258
270
|
|
|
259
271
|
// Only this hook's payload selected the binding. Supply its own pair as
|
|
260
272
|
// trusted context, outside peer bodies, rather than exporting inheritable
|
|
@@ -264,26 +276,24 @@ async function projectTurn({ binding, context, adapter, adapterId,
|
|
|
264
276
|
// turn carries - and only when both fit in half the budget, so peer bodies
|
|
265
277
|
// always keep the rest.
|
|
266
278
|
const header = ownerHeader(binding, context.workspaceCwd, context.workspaceRef);
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
279
|
+
const hintFits = activationHint !== null
|
|
280
|
+
&& byteLength(header) + 1 + byteLength(activationHint) <= totalBudget / 2;
|
|
281
|
+
const owner = hintFits ? `${header}\n${activationHint}` : header;
|
|
282
|
+
const withHint = (outcome, attached) => ({ ...outcome,
|
|
283
|
+
activationHintIncluded: attached === true && outcome.stdout !== "" });
|
|
270
284
|
// A peer can join after this prompt has begun. The current turn must already
|
|
271
285
|
// have its own arguments when it needs inbox/reply, without reattaching or
|
|
272
286
|
// waiting for another user prompt. Solo emits identity, not a peer notice.
|
|
273
287
|
if (sync.solo && messages.length === 0) {
|
|
274
|
-
return ownerOnlyOutcome(text => adapter.injectOutcome?.(text), owner, totalBudget)
|
|
288
|
+
return withHint(ownerOnlyOutcome(text => adapter.injectOutcome?.(text), owner, totalBudget),
|
|
289
|
+
hintFits);
|
|
275
290
|
}
|
|
276
291
|
|
|
277
|
-
// The ceiling a team agreed on in `acc.workspace.json`, or the default when
|
|
278
|
-
// there is no config. Validated by the protocol and, until now, never read:
|
|
279
|
-
// the projector was always called with its own default.
|
|
280
|
-
const effective = effectiveCapabilities(adapter, binding);
|
|
281
|
-
const hasStructuredRenderer = typeof adapter.renderContextResult === "function";
|
|
282
|
-
const canOfferNextTurn = effective.delivery.nextTurn === true && hasStructuredRenderer;
|
|
283
292
|
const projectionInput = { ...sync, messages: canOfferNextTurn ? messages : [],
|
|
284
293
|
liveOfferedMessageIds: delivery.liveOfferedMessageIds,
|
|
285
294
|
reminderMessageIds: delivery.reminderMessageIds,
|
|
286
295
|
roomMessageIds: delivery.roomMessageIds,
|
|
296
|
+
repeatOffers: delivery.repeatOffers,
|
|
287
297
|
currentParticipantId: mine?.participantId };
|
|
288
298
|
// Credentials alone cannot replace the command that reaches a queued body.
|
|
289
299
|
// If both cannot fit, preserve recovery and explain the missing owner pair.
|
|
@@ -324,12 +334,13 @@ async function projectTurn({ binding, context, adapter, adapterId,
|
|
|
324
334
|
// Own claims make sync non-solo but produce no peer context. They cannot
|
|
325
335
|
// remove this turn's identity or consume a nonexistent body separator.
|
|
326
336
|
if (body === "" && messages.length === 0) {
|
|
327
|
-
return ownerOnlyOutcome(text => adapter.injectOutcome?.(text), owner, totalBudget)
|
|
337
|
+
return withHint(ownerOnlyOutcome(text => adapter.injectOutcome?.(text), owner, totalBudget),
|
|
338
|
+
hintFits);
|
|
328
339
|
}
|
|
329
340
|
const projected = body === "" ? "" : ownerFits ? `${owner}\n${body}` : body;
|
|
330
341
|
if (projected === "") {
|
|
331
|
-
return { stdout: "", stderr: messages.length === 0 ? ""
|
|
332
|
-
: [ownerWarning, degradation].filter(Boolean).join("\n") };
|
|
342
|
+
return withHint({ stdout: "", stderr: messages.length === 0 ? ""
|
|
343
|
+
: [ownerWarning, degradation].filter(Boolean).join("\n") }, false);
|
|
333
344
|
}
|
|
334
345
|
|
|
335
346
|
// The renderer returns ids as metadata, never as text to parse. A peer body
|
|
@@ -341,7 +352,8 @@ async function projectTurn({ binding, context, adapter, adapterId,
|
|
|
341
352
|
recipientParticipantId: mine.participantId,
|
|
342
353
|
targetSessionId: binding.accSessionId, targetGeneration: binding.generation,
|
|
343
354
|
transport: "next-turn", adapterId,
|
|
344
|
-
clientVersion: binding.clientVersion
|
|
355
|
+
clientVersion: binding.clientVersion,
|
|
356
|
+
...(repeating.has(message.messageId) ? { repeat: true } : {}) }));
|
|
345
357
|
// Same again: Kimi Code shows the model a hook's raw stdout, while Gemini
|
|
346
358
|
// and Claude Code want an envelope and drop a bare string.
|
|
347
359
|
// The entry point owns the transport boundary. This handler only prepares
|
|
@@ -349,9 +361,30 @@ async function projectTurn({ binding, context, adapter, adapterId,
|
|
|
349
361
|
// callback proves that the bytes crossed.
|
|
350
362
|
const outcome = { stdout: "", ...render(projected) };
|
|
351
363
|
const writableOffers = outcome.stdout === "" ? [] : offerInputs;
|
|
352
|
-
return { ...outcome,
|
|
364
|
+
return withHint({ ...outcome,
|
|
353
365
|
stderr: [outcome.stderr, ownerWarning, degradation].filter(Boolean).join("\n"),
|
|
354
|
-
offerInputs: writableOffers };
|
|
366
|
+
offerInputs: writableOffers }, hintFits && ownerFits);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// The adapter may already have reserved an ask. That reservation is an ask
|
|
370
|
+
// only when this turn's stdout carries the line. A throw discards the turn,
|
|
371
|
+
// so the reservation goes with it.
|
|
372
|
+
async function projectActivation(input, offered) {
|
|
373
|
+
try {
|
|
374
|
+
const turn = await projectTurn({ ...input, activationHint: offered?.line ?? null });
|
|
375
|
+
const included = turn.activationHintIncluded === true;
|
|
376
|
+
delete turn.activationHintIncluded;
|
|
377
|
+
if (!offered) return turn;
|
|
378
|
+
if (!included) await callRelease(offered.release);
|
|
379
|
+
else if (typeof offered.release === "function") {
|
|
380
|
+
const release = offered.release;
|
|
381
|
+
turn.releaseActivationAsk = () => callRelease(release);
|
|
382
|
+
}
|
|
383
|
+
return turn;
|
|
384
|
+
} catch (error) {
|
|
385
|
+
await callRelease(offered?.release);
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
355
388
|
}
|
|
356
389
|
|
|
357
390
|
const HANDLERS = {
|
|
@@ -500,9 +533,9 @@ const HANDLERS = {
|
|
|
500
533
|
const started = await HANDLERS.sessionStart(input);
|
|
501
534
|
const fresh = await loadSessionBinding({ runtimeDir: paths.root,
|
|
502
535
|
harnessSessionId: event.sessionId });
|
|
503
|
-
const
|
|
536
|
+
const offered = await nativeActivationHintFor({ adapter, event,
|
|
504
537
|
nativeBinding: started.nativeBinding, binding: fresh, context, paths, deadline });
|
|
505
|
-
const turn = await
|
|
538
|
+
const turn = await projectActivation({ ...input, binding: fresh }, offered);
|
|
506
539
|
return { ...turn, nativeBinding: started.nativeBinding };
|
|
507
540
|
}
|
|
508
541
|
const current = await context.service.locateSession(binding.accSessionId);
|
|
@@ -516,9 +549,9 @@ const HANDLERS = {
|
|
|
516
549
|
const started = await HANDLERS.sessionStart(input);
|
|
517
550
|
const fresh = await loadSessionBinding({ runtimeDir: paths.root,
|
|
518
551
|
harnessSessionId: event.sessionId });
|
|
519
|
-
const
|
|
552
|
+
const offered = await nativeActivationHintFor({ adapter, event,
|
|
520
553
|
nativeBinding: started.nativeBinding, binding: fresh, context, paths, deadline });
|
|
521
|
-
const turn = await
|
|
554
|
+
const turn = await projectActivation({ ...input, binding: fresh }, offered);
|
|
522
555
|
return { ...turn, nativeBinding: started.nativeBinding };
|
|
523
556
|
}
|
|
524
557
|
// A turn is the clearest sign a session is alive. Never a reason to fail:
|
|
@@ -530,9 +563,9 @@ const HANDLERS = {
|
|
|
530
563
|
const nativeBinding = await bindNative({ adapter, event, hookBinding: binding,
|
|
531
564
|
clientVersion: binding.clientVersion, platform: binding.platform, context, paths,
|
|
532
565
|
deadline });
|
|
533
|
-
const
|
|
566
|
+
const offered = await nativeActivationHintFor({ adapter, event, nativeBinding,
|
|
534
567
|
binding, context, paths, deadline });
|
|
535
|
-
const turn = await
|
|
568
|
+
const turn = await projectActivation(input, offered);
|
|
536
569
|
return nativeBinding === undefined ? turn : { ...turn, nativeBinding };
|
|
537
570
|
},
|
|
538
571
|
|
|
@@ -557,7 +590,9 @@ const HANDLERS = {
|
|
|
557
590
|
const current = await context.service.locateSession(binding.accSessionId);
|
|
558
591
|
if (current === null || current.record.state !== "open"
|
|
559
592
|
|| current.record.generation !== binding.generation) return {};
|
|
560
|
-
|
|
593
|
+
// A repeat is a body the model may already have. It rides on the next
|
|
594
|
+
// turn that happens anyway and never buys a model call of its own.
|
|
595
|
+
const turn = await projectTurn({ ...input, repeats: false,
|
|
561
596
|
render: text => adapter.continueTurnOutcome({ reason: text, payload }) });
|
|
562
597
|
return (turn.offerInputs ?? []).length === 0 ? {} : turn;
|
|
563
598
|
},
|
|
@@ -23,15 +23,17 @@ const stringList = description => ({ type: "array", items: { type: "string" }, d
|
|
|
23
23
|
export const PUBLIC_TOOLS = Object.freeze([
|
|
24
24
|
{
|
|
25
25
|
name: "acc_status",
|
|
26
|
-
description: `Read who is here, current intents and claims,
|
|
27
|
-
+ `protection level
|
|
26
|
+
description: `Read who is here, current intents and claims, the workspace's real `
|
|
27
|
+
+ `protection level, and how many messages each participant has not fetched `
|
|
28
|
+
+ `(offered is not read). ${POLLED}`,
|
|
28
29
|
inputSchema: object({}),
|
|
29
30
|
},
|
|
30
31
|
{
|
|
31
32
|
name: "acc_sync",
|
|
32
33
|
description: `Read coordination state for this workspace: roster, attention items, and `
|
|
33
34
|
+ `events since a cursor. Use scope "history" for bounded message summaries, then `
|
|
34
|
-
+ `messageId to read one complete historical message
|
|
35
|
+
+ `messageId to read one complete historical message, with every recipient's `
|
|
36
|
+
+ `receipt state, without changing receipts. `
|
|
35
37
|
+ `Scope "full" is an unbounded forensic workspace snapshot. Use acc_inbox for `
|
|
36
38
|
+ `your addressed messages. ${POLLED}`,
|
|
37
39
|
inputSchema: object({
|
|
@@ -34,11 +34,11 @@ async function bytesIfPresent(filePath, root, openFile) {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function retainedStage(destination, root,
|
|
37
|
+
function retainedStage(destination, root, stageDir) {
|
|
38
38
|
const identity = createHash("sha256")
|
|
39
39
|
.update(path.relative(root, destination))
|
|
40
40
|
.digest("hex");
|
|
41
|
-
return path.join(
|
|
41
|
+
return path.join(stageDir, `${identity}.published`);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
async function replaceHandleBytes(handle, bytes) {
|
|
@@ -63,11 +63,18 @@ async function replaceHandleBytes(handle, bytes) {
|
|
|
63
63
|
*
|
|
64
64
|
* @returns {Promise<"published" | "already_published">}
|
|
65
65
|
*/
|
|
66
|
-
export async function publishAtomic(destination, bytes,
|
|
66
|
+
export async function publishAtomic(destination, bytes,
|
|
67
|
+
{ root, tmpDir, stageDir, replace = false, deadlineAt, afterAccepted }) {
|
|
67
68
|
assertPublicationDeadline(deadlineAt);
|
|
69
|
+
// The accepted stage lives apart from the partial a failed publication
|
|
70
|
+
// leaves, so what a file is follows from the directory it was created in
|
|
71
|
+
// rather than from how its name ends. Defaulted from the root the way
|
|
72
|
+
// active-journal.mjs defaults tmpDir, so every caller keeps working.
|
|
73
|
+
stageDir = stageDir ?? path.join(root, "stage");
|
|
68
74
|
const destinationDir = path.dirname(destination);
|
|
69
75
|
await Promise.all([
|
|
70
76
|
ensureManagedDirectory(root, tmpDir),
|
|
77
|
+
ensureManagedDirectory(root, stageDir),
|
|
71
78
|
ensureManagedDirectory(root, destinationDir),
|
|
72
79
|
]);
|
|
73
80
|
if (!replace) {
|
|
@@ -79,8 +86,11 @@ export async function publishAtomic(destination, bytes, { root, tmpDir, replace
|
|
|
79
86
|
}
|
|
80
87
|
}
|
|
81
88
|
|
|
82
|
-
const stage = retainedStage(destination, root,
|
|
83
|
-
|
|
89
|
+
const stage = retainedStage(destination, root, stageDir);
|
|
90
|
+
// The temporary stays in tmpDir: until the bytes are accepted it is a
|
|
91
|
+
// partial, and a partial is exactly what tmp holds.
|
|
92
|
+
const temporary = path.join(tmpDir,
|
|
93
|
+
`${path.basename(stage)}.${process.pid}.${randomUUID()}.tmp`);
|
|
84
94
|
let handle = await open(temporary, "wx");
|
|
85
95
|
let stageAcceptedBytes = false;
|
|
86
96
|
try {
|
|
@@ -121,9 +131,18 @@ export async function publishAtomic(destination, bytes, { root, tmpDir, replace
|
|
|
121
131
|
} finally {
|
|
122
132
|
await handle?.close();
|
|
123
133
|
if (stageAcceptedBytes) {
|
|
124
|
-
|
|
134
|
+
// `ensure`, not `assert`: the sweep empties this directory by renaming it
|
|
135
|
+
// aside and recreating it, and a publication that began before that swap
|
|
136
|
+
// arrives here to find the name gone. Both apply the same managed-root
|
|
137
|
+
// checks - the only difference is that this one recreates what the sweep
|
|
138
|
+
// has already taken away, instead of failing a write whose bytes are
|
|
139
|
+
// already published.
|
|
140
|
+
// The seam the race test uses, in the manner retainFile already
|
|
141
|
+
// establishes: the window this closes is invisible without one.
|
|
142
|
+
await afterAccepted?.();
|
|
143
|
+
await ensureManagedDirectory(root, stageDir);
|
|
125
144
|
await rename(temporary, stage);
|
|
126
|
-
await syncDirectory(
|
|
145
|
+
await syncDirectory(stageDir);
|
|
127
146
|
} else {
|
|
128
147
|
// A crash/error before immutable acceptance keeps its unique partial.
|
|
129
148
|
// Retention avoids the unsafe parent-check/unlink pathname window, while
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { open, rename, rm, rmdir } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { listDirectoryEntries } from "./atomic-json.mjs";
|
|
6
|
+
import { assertManagedDirectory, ensureManagedDirectory } from "./safe-directory.mjs";
|
|
7
|
+
|
|
8
|
+
// The store never unlinks a live name: Node exposes unlink only by pathname, so
|
|
9
|
+
// a checked parent can be replaced between the check and the call. What it does
|
|
10
|
+
// instead is move a doomed entry into a directory this process named with a
|
|
11
|
+
// value no other process knows, and then remove that directory whole. Every
|
|
12
|
+
// reclaimer in the store shares the primitive, so the rule is stated once.
|
|
13
|
+
//
|
|
14
|
+
// The prefix still reads "stage" because a store written by 0.6.3 leaves
|
|
15
|
+
// directories under this exact name when a pass is interrupted, and the resume
|
|
16
|
+
// below finds them by prefix. Renaming it would orphan them.
|
|
17
|
+
const DOOMED = "stage.sweeping-";
|
|
18
|
+
|
|
19
|
+
// Not assertPublicationDeadline: that one throws, and a maintenance pass on the
|
|
20
|
+
// path that opens the store must stop rather than fail the open. What is left
|
|
21
|
+
// is reported as remaining, and the next pass adopts it.
|
|
22
|
+
export const expired = deadlineAt => deadlineAt !== undefined && Date.now() >= deadlineAt;
|
|
23
|
+
|
|
24
|
+
export async function syncDirectory(directory) {
|
|
25
|
+
const handle = await open(directory, "r");
|
|
26
|
+
try {
|
|
27
|
+
await handle.sync();
|
|
28
|
+
} finally {
|
|
29
|
+
await handle.close();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function doomedDirectories(root) {
|
|
34
|
+
return (await listDirectoryEntries(root, { root }))
|
|
35
|
+
.filter(entry => entry.isDirectory() && entry.name.startsWith(DOOMED))
|
|
36
|
+
.map(entry => path.join(root, entry.name))
|
|
37
|
+
.sort((left, right) => left.localeCompare(right));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function doomedName(root) {
|
|
41
|
+
return path.join(root, `${DOOMED}${randomUUID()}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function detachDoomed(root) {
|
|
45
|
+
const directory = doomedName(root);
|
|
46
|
+
await ensureManagedDirectory(root, directory);
|
|
47
|
+
return directory;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Move one doomed entry out of the store's live names.
|
|
52
|
+
*
|
|
53
|
+
* The destination name is unique because the sources are not: two records of
|
|
54
|
+
* one kind each keep a marker named for its own sequence, and a journal entry
|
|
55
|
+
* and a stage file can collide on nothing at all. The doomed directory is
|
|
56
|
+
* discarded whole, so the names inside it carry no meaning.
|
|
57
|
+
*/
|
|
58
|
+
export async function condemn(filePath, doomed) {
|
|
59
|
+
await rename(filePath, path.join(doomed, `${randomUUID()}.doomed`));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Remove a doomed directory, one entry at a time, within a budget.
|
|
64
|
+
*
|
|
65
|
+
* @returns {Promise<{ spent: number, drained: boolean }>}
|
|
66
|
+
*/
|
|
67
|
+
export async function discard(directory, root, budget, deadlineAt) {
|
|
68
|
+
let spent = 0;
|
|
69
|
+
await assertManagedDirectory(root, directory);
|
|
70
|
+
for (const entry of await listDirectoryEntries(directory, { root })) {
|
|
71
|
+
if (spent >= budget || expired(deadlineAt)) return { spent, drained: false };
|
|
72
|
+
await rm(path.join(directory, entry.name), { recursive: true, force: true });
|
|
73
|
+
spent += 1;
|
|
74
|
+
}
|
|
75
|
+
await rmdir(directory).catch(error => {
|
|
76
|
+
if (error.code !== "ENOENT" && error.code !== "ENOTEMPTY") throw error;
|
|
77
|
+
});
|
|
78
|
+
return { spent, drained: true };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Finish what an interrupted pass left behind, within a budget.
|
|
83
|
+
*
|
|
84
|
+
* Every pass runs this first, and for a second reason: a pass that stops here
|
|
85
|
+
* never detaches a further directory, so an exhausted budget cannot make them
|
|
86
|
+
* accumulate.
|
|
87
|
+
*
|
|
88
|
+
* @returns {Promise<{ spent: number, drained: boolean }>}
|
|
89
|
+
*/
|
|
90
|
+
export async function discardLeftovers(root, budget, deadlineAt) {
|
|
91
|
+
let spent = 0;
|
|
92
|
+
for (const directory of await doomedDirectories(root)) {
|
|
93
|
+
const removed = await discard(directory, root, budget - spent, deadlineAt);
|
|
94
|
+
spent += removed.spent;
|
|
95
|
+
if (!removed.drained) return { spent, drained: false };
|
|
96
|
+
}
|
|
97
|
+
return { spent, drained: true };
|
|
98
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
4
|
+
|
|
5
|
+
import { encode, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
|
|
6
|
+
|
|
7
|
+
const SEQUENCE = /^[0-9]{16}$/;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The highest event sequence this store has trimmed away.
|
|
11
|
+
*
|
|
12
|
+
* Trimming the oldest events is safe on its own, because `nextSequence` reads
|
|
13
|
+
* the newest file. An emptied directory is what needs a floor: without one the
|
|
14
|
+
* sequence would restart at 1 and collide with a cursor a peer still holds,
|
|
15
|
+
* and a reader whose cursor precedes the boundary would be served a short page
|
|
16
|
+
* with no way to tell it was short.
|
|
17
|
+
*/
|
|
18
|
+
const floorPath = paths => path.join(paths.locks, "retention.json");
|
|
19
|
+
|
|
20
|
+
export function assertSequence(value, field) {
|
|
21
|
+
if (typeof value !== "string" || !SEQUENCE.test(value)) {
|
|
22
|
+
throw new AccError(EXIT.USAGE,
|
|
23
|
+
`${field} is the 16-digit sequence a previous sync returned`, { [field]: value });
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// An unreadable floor is treated as none. The alternative - refusing to serve
|
|
29
|
+
// events because the bookkeeping about their removal is damaged - would take a
|
|
30
|
+
// workspace offline over a file that only ever adds information.
|
|
31
|
+
export async function readEventFloor(paths, root) {
|
|
32
|
+
const found = await readJsonIfPresent(floorPath(paths), root).catch(() => null);
|
|
33
|
+
const value = found?.value?.trimmedThrough;
|
|
34
|
+
return typeof value === "string" && SEQUENCE.test(value) ? value : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Raise the floor, never lower it.
|
|
39
|
+
*
|
|
40
|
+
* Written before a single event file moves. A floor ahead of the trim only
|
|
41
|
+
* reports a boundary for events still present, which costs a reader nothing;
|
|
42
|
+
* a trim ahead of the floor is a silent gap, which is the failure this exists
|
|
43
|
+
* to prevent.
|
|
44
|
+
*/
|
|
45
|
+
export async function raiseEventFloor(paths, options, trimmedThrough) {
|
|
46
|
+
assertSequence(trimmedThrough, "trimmedThrough");
|
|
47
|
+
const current = await readEventFloor(paths, options.root);
|
|
48
|
+
if (current !== null && current >= trimmedThrough) return current;
|
|
49
|
+
await publishAtomic(floorPath(paths), encode({ trimmedThrough }),
|
|
50
|
+
{ ...options, replace: true });
|
|
51
|
+
return trimmedThrough;
|
|
52
|
+
}
|