agents-can-communicate 0.6.2 → 0.7.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 +3 -1
- package/bin/entrypoints/hook-output.mjs +12 -0
- package/docs/CAPABILITIES.md +1 -1
- package/docs/CLI.md +37 -0
- package/docs/GETTING_STARTED.md +2 -2
- 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/src/adapter.mjs +3 -3
- 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-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +2 -2
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +2 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- 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 +18 -0
- 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/intents.mjs +16 -0
- package/node_modules/@agents-can-communicate/core/src/prune.mjs +241 -0
- package/node_modules/@agents-can-communicate/core/src/service.mjs +3 -0
- package/node_modules/@agents-can-communicate/core/src/status.mjs +6 -1
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +4 -0
- 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 +42 -16
- 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/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
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
2
|
+
|
|
3
|
+
import { classifySessionPresence } from "./sessions.mjs";
|
|
4
|
+
|
|
5
|
+
// What an operator can ask to reclaim. The workspace record is deliberately
|
|
6
|
+
// absent: removing it would not clean a workspace, it would erase one.
|
|
7
|
+
export const PRUNE_CLASSES = Object.freeze(["sessions", "intents", "claims", "participants"]);
|
|
8
|
+
|
|
9
|
+
function assertClasses(classes) {
|
|
10
|
+
for (const name of classes) {
|
|
11
|
+
if (!PRUNE_CLASSES.includes(name)) {
|
|
12
|
+
throw new AccError(EXIT.USAGE,
|
|
13
|
+
`prune class is one of ${PRUNE_CLASSES.join(", ")}`, { class: name });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const of = (envelopes, kind) => envelopes.filter(envelope => envelope.kind === kind);
|
|
19
|
+
|
|
20
|
+
const CURSOR = /^[0-9]{16}$/;
|
|
21
|
+
const EVENT_PAGE = 500;
|
|
22
|
+
|
|
23
|
+
function assertBefore(before) {
|
|
24
|
+
if (typeof before !== "string" || !CURSOR.test(before)) {
|
|
25
|
+
throw new AccError(EXIT.USAGE,
|
|
26
|
+
"before is the 16-digit cursor a previous sync returned", { before });
|
|
27
|
+
}
|
|
28
|
+
return before;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read the log up to the boundary, counting it and naming what it recorded.
|
|
33
|
+
*
|
|
34
|
+
* The boundary is a cursor rather than a date on purpose: a cursor is what
|
|
35
|
+
* `sync` already returns and what a peer already holds, so an operator trimming
|
|
36
|
+
* to one names the same point its readers name. A duration would have to be
|
|
37
|
+
* resolved to a sequence anyway, and the resolution could move between the
|
|
38
|
+
* report and the apply.
|
|
39
|
+
*/
|
|
40
|
+
async function readBelow(store, workspaceId, before) {
|
|
41
|
+
const messageIds = new Set();
|
|
42
|
+
let events = 0;
|
|
43
|
+
let cursor = null;
|
|
44
|
+
for (;;) {
|
|
45
|
+
const page = await store.eventsSince(workspaceId, cursor, EVENT_PAGE);
|
|
46
|
+
if (page.events.length === 0) break;
|
|
47
|
+
let past = false;
|
|
48
|
+
for (const event of page.events) {
|
|
49
|
+
if (event.sequence > before) {
|
|
50
|
+
past = true;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
events += 1;
|
|
54
|
+
if (event.type === "message.recorded") messageIds.add(event.payload.messageId);
|
|
55
|
+
}
|
|
56
|
+
if (past) break;
|
|
57
|
+
cursor = page.cursor;
|
|
58
|
+
}
|
|
59
|
+
return { events, messageIds };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A message is resolved when nobody is still owed it.
|
|
64
|
+
*
|
|
65
|
+
* Offered is not read and retrieved is not model attention, so only an
|
|
66
|
+
* acknowledged receipt settles the obligation. A recipient that no longer
|
|
67
|
+
* exists cannot acknowledge anything, and holding the message for it would
|
|
68
|
+
* keep the whole thread forever.
|
|
69
|
+
*/
|
|
70
|
+
function resolvedMessages(envelopes, messageIds) {
|
|
71
|
+
const participants = new Set(of(envelopes, "participant").map(envelope => envelope.id));
|
|
72
|
+
const open = new Set(of(envelopes, "receipt").map(envelope => envelope.record)
|
|
73
|
+
.filter(receipt => receipt.state !== "acknowledged"
|
|
74
|
+
&& participants.has(receipt.recipientParticipantId))
|
|
75
|
+
.map(receipt => receipt.messageId));
|
|
76
|
+
return of(envelopes, "message")
|
|
77
|
+
.filter(envelope => messageIds.has(envelope.id) && !open.has(envelope.id));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Which records describe work that has stopped for good.
|
|
82
|
+
*
|
|
83
|
+
* Only `offline` qualifies for a session, which is a confirmed dead pid or a
|
|
84
|
+
* full day without a heartbeat. `stale` is a session that has gone quiet, and a
|
|
85
|
+
* quiet session is still someone's.
|
|
86
|
+
*/
|
|
87
|
+
function eligibleSessions(envelopes, now, pidIsAlive) {
|
|
88
|
+
// A live claim names the session that holds it, and that name is how a peer
|
|
89
|
+
// blocked by the claim finds someone to ask. Removing the session leaves the
|
|
90
|
+
// claim reading as owned by nobody, so a session keeping a live lease stays
|
|
91
|
+
// however long it has been quiet. Its claim expiring makes both eligible.
|
|
92
|
+
const holding = new Set(of(envelopes, "claim")
|
|
93
|
+
.filter(envelope => Date.parse(envelope.record.expiresAt) > Date.parse(now))
|
|
94
|
+
.map(envelope => envelope.record.ownerSessionId));
|
|
95
|
+
return of(envelopes, "session")
|
|
96
|
+
.filter(envelope => !holding.has(envelope.id)
|
|
97
|
+
&& classifySessionPresence(envelope.record, now, pidIsAlive) === "offline");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A participant is kept while anything still points at it.
|
|
102
|
+
*
|
|
103
|
+
* `removedSessions` is what this pass actually takes, not what it could take.
|
|
104
|
+
* The difference matters when an operator names classes: asked for participants
|
|
105
|
+
* alone, every session stays, so every participant is still pointed at and none
|
|
106
|
+
* is eligible. Judging by what is merely *eligible* would remove a participant
|
|
107
|
+
* and leave its sessions naming a roster entry that is gone.
|
|
108
|
+
*
|
|
109
|
+
* Messages are not pruned unless a boundary says so, and the roster is where
|
|
110
|
+
* "who sent this" is answered, so a name on a surviving message stays too.
|
|
111
|
+
*/
|
|
112
|
+
function eligibleParticipants(envelopes, removedSessions, messages) {
|
|
113
|
+
const surviving = new Set(of(envelopes, "session")
|
|
114
|
+
.filter(envelope => !removedSessions.has(envelope.id))
|
|
115
|
+
.map(envelope => envelope.record.participantId));
|
|
116
|
+
const named = new Set(messages.flatMap(message =>
|
|
117
|
+
[message.fromParticipantId, ...message.toParticipantIds]));
|
|
118
|
+
return of(envelopes, "participant")
|
|
119
|
+
.filter(envelope => !surviving.has(envelope.id) && !named.has(envelope.id));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createPruneService(ports) {
|
|
123
|
+
const { store, clock, pidIsAlive } = ports;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* What `prune` would reclaim, as records rather than as counts.
|
|
127
|
+
*
|
|
128
|
+
* The plan is computed outside the writer mutex and applied inside it, so
|
|
129
|
+
* every entry carries the generation that proves it is still the record this
|
|
130
|
+
* judged. A claim renewed in between is skipped by the store rather than
|
|
131
|
+
* removed.
|
|
132
|
+
*/
|
|
133
|
+
async function planPrune(input = {}) {
|
|
134
|
+
const classes = input.classes ?? PRUNE_CLASSES;
|
|
135
|
+
assertClasses(classes);
|
|
136
|
+
const workspaceId = input.workspaceId ?? store.workspaceId;
|
|
137
|
+
const now = clock.now();
|
|
138
|
+
const envelopes = await store.stateEnvelopes(workspaceId);
|
|
139
|
+
const wants = name => classes.includes(name);
|
|
140
|
+
|
|
141
|
+
const eligible = wants("sessions") || wants("intents") || wants("participants")
|
|
142
|
+
? eligibleSessions(envelopes, now, pidIsAlive)
|
|
143
|
+
: [];
|
|
144
|
+
const sessions = wants("sessions") ? eligible : [];
|
|
145
|
+
const removedSessions = new Set(sessions.map(envelope => envelope.id));
|
|
146
|
+
const eligibleSessionIds = new Set(eligible.map(envelope => envelope.id));
|
|
147
|
+
|
|
148
|
+
// An intent belongs to one session and outlives nothing, so a session
|
|
149
|
+
// leaving takes its intent with it whether or not the operator named the
|
|
150
|
+
// class. Asking for intents alone reaches further: every intent whose
|
|
151
|
+
// session has stopped, including sessions this pass is keeping. Nothing
|
|
152
|
+
// points at an intent, so removing one leaves nothing dangling - but
|
|
153
|
+
// leaving one behind when its session goes would.
|
|
154
|
+
const owning = wants("intents") ? eligibleSessionIds : removedSessions;
|
|
155
|
+
const intents = of(envelopes, "intent")
|
|
156
|
+
.filter(envelope => owning.has(envelope.record.sessionId));
|
|
157
|
+
const claims = wants("claims")
|
|
158
|
+
? of(envelopes, "claim")
|
|
159
|
+
.filter(envelope => Date.parse(envelope.record.expiresAt) <= Date.parse(now))
|
|
160
|
+
: [];
|
|
161
|
+
|
|
162
|
+
// History is named by a boundary rather than by a class, because there is
|
|
163
|
+
// no such thing as an event that has stopped being true - only one the
|
|
164
|
+
// operator has decided nobody will read again.
|
|
165
|
+
const before = input.before === undefined ? null : assertBefore(input.before);
|
|
166
|
+
const below = before === null ? { events: 0, messageIds: new Set() }
|
|
167
|
+
: await readBelow(store, workspaceId, before);
|
|
168
|
+
const messages = before === null ? [] : resolvedMessages(envelopes, below.messageIds);
|
|
169
|
+
const doomedMessages = new Set(messages.map(envelope => envelope.id));
|
|
170
|
+
const receipts = of(envelopes, "receipt")
|
|
171
|
+
.filter(envelope => doomedMessages.has(envelope.record.messageId));
|
|
172
|
+
|
|
173
|
+
// Decided after the messages, and against the ones that will survive this
|
|
174
|
+
// pass. A participant named only by a message the same run removes is a
|
|
175
|
+
// participant nothing will point at once the run finishes; judging it
|
|
176
|
+
// against every message that exists now would keep it, report no work
|
|
177
|
+
// remaining, and need a second identical run to reclaim it.
|
|
178
|
+
const participants = wants("participants")
|
|
179
|
+
? eligibleParticipants(envelopes, removedSessions, of(envelopes, "message")
|
|
180
|
+
.map(envelope => envelope.record).filter(record => !doomedMessages.has(record.messageId)))
|
|
181
|
+
: [];
|
|
182
|
+
|
|
183
|
+
const named = { sessions, intents, claims, participants, messages, receipts };
|
|
184
|
+
// Ordered so that anything holding a reference goes before what it refers
|
|
185
|
+
// to. A pass can stop at its budget or its deadline, and what it has done
|
|
186
|
+
// by then is a prefix of this list - so the prefix has to be a state the
|
|
187
|
+
// store can be left in. A receipt names a message, a message and an intent
|
|
188
|
+
// and a claim each name a session, and a session names a participant.
|
|
189
|
+
// Removing them in that order means an interrupted pass never leaves a
|
|
190
|
+
// record pointing at one that is already gone.
|
|
191
|
+
const order = ["receipts", "messages", "intents", "claims", "sessions", "participants"];
|
|
192
|
+
const entries = order.flatMap(name => named[name])
|
|
193
|
+
.map(({ kind, id, generation }) => ({ kind, id, generation }));
|
|
194
|
+
return { workspaceId,
|
|
195
|
+
before,
|
|
196
|
+
counts: { ...Object.fromEntries(Object.entries(named)
|
|
197
|
+
.map(([name, list]) => [name, list.length])), events: below.events },
|
|
198
|
+
entries };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Reclaim what the plan named, or report it and change nothing.
|
|
203
|
+
*
|
|
204
|
+
* The dry run is the default because this is the one command that takes
|
|
205
|
+
* records out of the store. A caller that means it says so.
|
|
206
|
+
*/
|
|
207
|
+
async function prune(input = {}) {
|
|
208
|
+
let plan = await planPrune(input);
|
|
209
|
+
if (input.apply !== true) {
|
|
210
|
+
return { ...plan, applied: false, reclaimed: 0, skipped: 0, remaining: false,
|
|
211
|
+
trimmedThrough: null };
|
|
212
|
+
}
|
|
213
|
+
// Decided again inside the writer mutex, and that second reading is the one
|
|
214
|
+
// acted on. Eligibility here is relational - a participant is eligible
|
|
215
|
+
// because no session of theirs survives - and a generation proves only that
|
|
216
|
+
// one record did not change. A session opening between the report and the
|
|
217
|
+
// apply changes the answer without changing any record this had named.
|
|
218
|
+
let decided = plan;
|
|
219
|
+
const result = await store.reclaimRecords(async () => {
|
|
220
|
+
decided = await planPrune(input);
|
|
221
|
+
return decided.entries;
|
|
222
|
+
}, { limit: input.limit, deadlineAt: input.deadlineAt });
|
|
223
|
+
if (decided.before === null) {
|
|
224
|
+
return { ...decided, applied: true, ...result, trimmedThrough: null };
|
|
225
|
+
}
|
|
226
|
+
plan = decided;
|
|
227
|
+
// Records first, then the log. A message removed while its recording event
|
|
228
|
+
// survives reads as history describing a record that is gone, which is
|
|
229
|
+
// ordinary. The reverse - an event log that starts after a message it never
|
|
230
|
+
// mentions - is the gap the floor is there to report.
|
|
231
|
+
const trimmed = await store.trimHistory(plan.before,
|
|
232
|
+
{ limit: input.limit, deadlineAt: input.deadlineAt });
|
|
233
|
+
return { ...plan, applied: true,
|
|
234
|
+
reclaimed: result.reclaimed + trimmed.reclaimed,
|
|
235
|
+
skipped: result.skipped,
|
|
236
|
+
trimmedThrough: trimmed.trimmedThrough,
|
|
237
|
+
remaining: result.remaining || trimmed.remaining };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { planPrune, prune };
|
|
241
|
+
}
|
|
@@ -5,6 +5,7 @@ import { createIntentService } from "./intents.mjs";
|
|
|
5
5
|
import { createInboxService } from "./inbox.mjs";
|
|
6
6
|
import { defaultPidIsAlive } from "./pid.mjs";
|
|
7
7
|
import { assertPorts } from "./ports.mjs";
|
|
8
|
+
import { createPruneService } from "./prune.mjs";
|
|
8
9
|
import { createReceiptService } from "./receipts.mjs";
|
|
9
10
|
import { createSessionService } from "./sessions.mjs";
|
|
10
11
|
import { createGuardStateService, createStatusService } from "./status.mjs";
|
|
@@ -37,6 +38,7 @@ export function createCoordinationService({ store, clock, ids,
|
|
|
37
38
|
const sync = createSyncService(ports, sessions);
|
|
38
39
|
const status = createStatusService(ports, sessions, deliveryBindings);
|
|
39
40
|
const guardState = createGuardStateService(ports);
|
|
41
|
+
const prune = createPruneService(ports);
|
|
40
42
|
return Object.freeze({
|
|
41
43
|
store,
|
|
42
44
|
clock,
|
|
@@ -62,6 +64,7 @@ export function createCoordinationService({ store, clock, ids,
|
|
|
62
64
|
...receipts,
|
|
63
65
|
...sync,
|
|
64
66
|
...status,
|
|
67
|
+
...prune,
|
|
65
68
|
guardState,
|
|
66
69
|
});
|
|
67
70
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { classifySessionPresence } from "./sessions.mjs";
|
|
2
2
|
import { computeAttention } from "./attention.mjs";
|
|
3
|
+
import { isCurrentIntent } from "./intents.mjs";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Protection level, reported from what is actually enforceable.
|
|
@@ -108,7 +109,11 @@ export function createStatusService(ports, sessions, deliveryBindings) {
|
|
|
108
109
|
enforcement: session.enforcement ?? "advisory",
|
|
109
110
|
lifecycle: session.lifecycle ?? "manual",
|
|
110
111
|
presence,
|
|
111
|
-
|
|
112
|
+
// A finished intent reads as no intent at all. `--clear` exists so a
|
|
113
|
+
// peer stops seeing work that has stopped, and reporting the summary of
|
|
114
|
+
// a done record made the command and this line contradict each other.
|
|
115
|
+
intent: intents.find(intent => intent.sessionId === session.sessionId
|
|
116
|
+
&& isCurrentIntent(intent))?.summary ?? null,
|
|
112
117
|
})),
|
|
113
118
|
// The owner is named twice on purpose. Every command that reaches a peer
|
|
114
119
|
// takes a participant id, so a claim that gave only a session id sent the
|
|
@@ -86,6 +86,10 @@ export function createSyncService(ports, sessions) {
|
|
|
86
86
|
&& snapshot.claims.length === 0;
|
|
87
87
|
return {
|
|
88
88
|
cursor: page.cursor,
|
|
89
|
+
// Where the log starts, when it no longer starts at the beginning. A
|
|
90
|
+
// caller whose cursor precedes this was served a short page, and saying
|
|
91
|
+
// so is the difference between a trim and a silent gap.
|
|
92
|
+
trimmedThrough: page.trimmedThrough ?? null,
|
|
89
93
|
scope: input.scope === "full" ? "full" : "delta",
|
|
90
94
|
solo,
|
|
91
95
|
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";
|
|
@@ -264,14 +265,17 @@ async function projectTurn({ binding, context, adapter, adapterId,
|
|
|
264
265
|
// turn carries - and only when both fit in half the budget, so peer bodies
|
|
265
266
|
// always keep the rest.
|
|
266
267
|
const header = ownerHeader(binding, context.workspaceCwd, context.workspaceRef);
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
268
|
+
const hintFits = activationHint !== null
|
|
269
|
+
&& byteLength(header) + 1 + byteLength(activationHint) <= totalBudget / 2;
|
|
270
|
+
const owner = hintFits ? `${header}\n${activationHint}` : header;
|
|
271
|
+
const withHint = (outcome, attached) => ({ ...outcome,
|
|
272
|
+
activationHintIncluded: attached === true && outcome.stdout !== "" });
|
|
270
273
|
// A peer can join after this prompt has begun. The current turn must already
|
|
271
274
|
// have its own arguments when it needs inbox/reply, without reattaching or
|
|
272
275
|
// waiting for another user prompt. Solo emits identity, not a peer notice.
|
|
273
276
|
if (sync.solo && messages.length === 0) {
|
|
274
|
-
return ownerOnlyOutcome(text => adapter.injectOutcome?.(text), owner, totalBudget)
|
|
277
|
+
return withHint(ownerOnlyOutcome(text => adapter.injectOutcome?.(text), owner, totalBudget),
|
|
278
|
+
hintFits);
|
|
275
279
|
}
|
|
276
280
|
|
|
277
281
|
// The ceiling a team agreed on in `acc.workspace.json`, or the default when
|
|
@@ -324,12 +328,13 @@ async function projectTurn({ binding, context, adapter, adapterId,
|
|
|
324
328
|
// Own claims make sync non-solo but produce no peer context. They cannot
|
|
325
329
|
// remove this turn's identity or consume a nonexistent body separator.
|
|
326
330
|
if (body === "" && messages.length === 0) {
|
|
327
|
-
return ownerOnlyOutcome(text => adapter.injectOutcome?.(text), owner, totalBudget)
|
|
331
|
+
return withHint(ownerOnlyOutcome(text => adapter.injectOutcome?.(text), owner, totalBudget),
|
|
332
|
+
hintFits);
|
|
328
333
|
}
|
|
329
334
|
const projected = body === "" ? "" : ownerFits ? `${owner}\n${body}` : body;
|
|
330
335
|
if (projected === "") {
|
|
331
|
-
return { stdout: "", stderr: messages.length === 0 ? ""
|
|
332
|
-
: [ownerWarning, degradation].filter(Boolean).join("\n") };
|
|
336
|
+
return withHint({ stdout: "", stderr: messages.length === 0 ? ""
|
|
337
|
+
: [ownerWarning, degradation].filter(Boolean).join("\n") }, false);
|
|
333
338
|
}
|
|
334
339
|
|
|
335
340
|
// The renderer returns ids as metadata, never as text to parse. A peer body
|
|
@@ -349,9 +354,30 @@ async function projectTurn({ binding, context, adapter, adapterId,
|
|
|
349
354
|
// callback proves that the bytes crossed.
|
|
350
355
|
const outcome = { stdout: "", ...render(projected) };
|
|
351
356
|
const writableOffers = outcome.stdout === "" ? [] : offerInputs;
|
|
352
|
-
return { ...outcome,
|
|
357
|
+
return withHint({ ...outcome,
|
|
353
358
|
stderr: [outcome.stderr, ownerWarning, degradation].filter(Boolean).join("\n"),
|
|
354
|
-
offerInputs: writableOffers };
|
|
359
|
+
offerInputs: writableOffers }, hintFits && ownerFits);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// The adapter may already have reserved an ask. That reservation is an ask
|
|
363
|
+
// only when this turn's stdout carries the line. A throw discards the turn,
|
|
364
|
+
// so the reservation goes with it.
|
|
365
|
+
async function projectActivation(input, offered) {
|
|
366
|
+
try {
|
|
367
|
+
const turn = await projectTurn({ ...input, activationHint: offered?.line ?? null });
|
|
368
|
+
const included = turn.activationHintIncluded === true;
|
|
369
|
+
delete turn.activationHintIncluded;
|
|
370
|
+
if (!offered) return turn;
|
|
371
|
+
if (!included) await callRelease(offered.release);
|
|
372
|
+
else if (typeof offered.release === "function") {
|
|
373
|
+
const release = offered.release;
|
|
374
|
+
turn.releaseActivationAsk = () => callRelease(release);
|
|
375
|
+
}
|
|
376
|
+
return turn;
|
|
377
|
+
} catch (error) {
|
|
378
|
+
await callRelease(offered?.release);
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
355
381
|
}
|
|
356
382
|
|
|
357
383
|
const HANDLERS = {
|
|
@@ -500,9 +526,9 @@ const HANDLERS = {
|
|
|
500
526
|
const started = await HANDLERS.sessionStart(input);
|
|
501
527
|
const fresh = await loadSessionBinding({ runtimeDir: paths.root,
|
|
502
528
|
harnessSessionId: event.sessionId });
|
|
503
|
-
const
|
|
529
|
+
const offered = await nativeActivationHintFor({ adapter, event,
|
|
504
530
|
nativeBinding: started.nativeBinding, binding: fresh, context, paths, deadline });
|
|
505
|
-
const turn = await
|
|
531
|
+
const turn = await projectActivation({ ...input, binding: fresh }, offered);
|
|
506
532
|
return { ...turn, nativeBinding: started.nativeBinding };
|
|
507
533
|
}
|
|
508
534
|
const current = await context.service.locateSession(binding.accSessionId);
|
|
@@ -516,9 +542,9 @@ const HANDLERS = {
|
|
|
516
542
|
const started = await HANDLERS.sessionStart(input);
|
|
517
543
|
const fresh = await loadSessionBinding({ runtimeDir: paths.root,
|
|
518
544
|
harnessSessionId: event.sessionId });
|
|
519
|
-
const
|
|
545
|
+
const offered = await nativeActivationHintFor({ adapter, event,
|
|
520
546
|
nativeBinding: started.nativeBinding, binding: fresh, context, paths, deadline });
|
|
521
|
-
const turn = await
|
|
547
|
+
const turn = await projectActivation({ ...input, binding: fresh }, offered);
|
|
522
548
|
return { ...turn, nativeBinding: started.nativeBinding };
|
|
523
549
|
}
|
|
524
550
|
// A turn is the clearest sign a session is alive. Never a reason to fail:
|
|
@@ -530,9 +556,9 @@ const HANDLERS = {
|
|
|
530
556
|
const nativeBinding = await bindNative({ adapter, event, hookBinding: binding,
|
|
531
557
|
clientVersion: binding.clientVersion, platform: binding.platform, context, paths,
|
|
532
558
|
deadline });
|
|
533
|
-
const
|
|
559
|
+
const offered = await nativeActivationHintFor({ adapter, event, nativeBinding,
|
|
534
560
|
binding, context, paths, deadline });
|
|
535
|
-
const turn = await
|
|
561
|
+
const turn = await projectActivation(input, offered);
|
|
536
562
|
return nativeBinding === undefined ? turn : { ...turn, nativeBinding };
|
|
537
563
|
},
|
|
538
564
|
|
|
@@ -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
|