@phnx-labs/agents-cli 1.22.115 → 1.22.117
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/CHANGELOG.md +161 -14
- package/README.md +1 -1
- package/dist/commands/browser.js +11 -0
- package/dist/commands/exec.d.ts +18 -0
- package/dist/commands/exec.js +263 -152
- package/dist/commands/feed.js +65 -14
- package/dist/commands/sessions-picker.d.ts +11 -0
- package/dist/commands/sessions-picker.js +88 -7
- package/dist/commands/sessions.d.ts +21 -2
- package/dist/commands/sessions.js +157 -3
- package/dist/commands/setup-secrets.d.ts +2 -2
- package/dist/commands/setup-term.d.ts +25 -0
- package/dist/commands/setup-term.js +71 -0
- package/dist/commands/setup.d.ts +1 -1
- package/dist/commands/setup.js +12 -4
- package/dist/commands/ssh.js +1 -69
- package/dist/lib/accounting/rotate.d.ts +63 -1
- package/dist/lib/accounting/rotate.js +56 -0
- package/dist/lib/accounts/add.js +2 -2
- package/dist/lib/accounts/slots.js +32 -2
- package/dist/lib/answer-router.d.ts +11 -2
- package/dist/lib/answer-router.js +26 -2
- package/dist/lib/auth-mint.d.ts +5 -4
- package/dist/lib/auth-mint.js +4 -3
- package/dist/lib/browser/drivers/arc.d.ts +1 -1
- package/dist/lib/browser/service.d.ts +10 -0
- package/dist/lib/browser/service.js +208 -36
- package/dist/lib/browser/types.d.ts +18 -0
- package/dist/lib/config-keys.d.ts +1 -1
- package/dist/lib/config-keys.js +5 -0
- package/dist/lib/device-config.js +61 -0
- package/dist/lib/devices/doctor-findings.js +2 -6
- package/dist/lib/feed/answer.d.ts +153 -4
- package/dist/lib/feed/answer.js +716 -105
- package/dist/lib/feed/feed.d.ts +61 -1
- package/dist/lib/feed/feed.js +226 -14
- package/dist/lib/feed/hub-server.d.ts +58 -3
- package/dist/lib/feed/hub-server.js +306 -54
- package/dist/lib/feed/pr-status.d.ts +8 -0
- package/dist/lib/feed/pr-status.js +9 -1
- package/dist/lib/feed-outcome.d.ts +1 -1
- package/dist/lib/feed-outcome.js +9 -2
- package/dist/lib/feed-policy.js +9 -3
- package/dist/lib/fleet/auth-sync.d.ts +2 -55
- package/dist/lib/fleet/auth-sync.js +2 -89
- package/dist/lib/harness-auth-capabilities.js +7 -2
- package/dist/lib/hosts/dispatch.d.ts +20 -1
- package/dist/lib/hosts/dispatch.js +52 -30
- package/dist/lib/hosts/remote-cmd.d.ts +21 -0
- package/dist/lib/hosts/remote-cmd.js +27 -2
- package/dist/lib/mailbox.d.ts +12 -0
- package/dist/lib/mailbox.js +16 -2
- package/dist/lib/menubar/snapshot.d.ts +51 -0
- package/dist/lib/menubar/snapshot.js +42 -3
- package/dist/lib/open-url.js +2 -2
- package/dist/lib/placement.d.ts +2 -0
- package/dist/lib/placement.js +5 -0
- package/dist/lib/projects.d.ts +23 -0
- package/dist/lib/projects.js +78 -0
- package/dist/lib/secrets-cli.d.ts +3 -3
- package/dist/lib/secrets-cli.js +1 -1
- package/dist/lib/session/active.d.ts +1 -0
- package/dist/lib/session/active.js +8 -0
- package/dist/lib/session/db.d.ts +67 -3
- package/dist/lib/session/db.js +381 -126
- package/dist/lib/session/prompt.d.ts +23 -7
- package/dist/lib/session/prompt.js +46 -8
- package/dist/lib/session/remote/remote-list.d.ts +20 -0
- package/dist/lib/session/remote/remote-list.js +22 -6
- package/dist/lib/session/remote/watch.d.ts +12 -0
- package/dist/lib/session/remote/watch.js +9 -0
- package/dist/lib/session/remote-preview-cache.d.ts +29 -0
- package/dist/lib/session/remote-preview-cache.js +373 -0
- package/dist/lib/session/tail.d.ts +50 -0
- package/dist/lib/session/tail.js +219 -0
- package/dist/lib/setup-tool-install.js +2 -1
- package/dist/lib/setup-tool-status.d.ts +1 -1
- package/dist/lib/setup-tool-status.js +7 -1
- package/dist/lib/signin-badge.d.ts +19 -4
- package/dist/lib/signin-badge.js +29 -11
- package/dist/lib/term-driver.d.ts +24 -0
- package/dist/lib/term-driver.js +36 -0
- package/dist/lib/terminal/index.d.ts +1 -1
- package/dist/lib/terminal/index.js +1 -1
- package/dist/lib/terminal/inject.d.ts +38 -0
- package/dist/lib/terminal/inject.js +55 -9
- package/dist/lib/terminal/transport.d.ts +15 -5
- package/dist/lib/terminal/transport.js +61 -11
- package/package.json +1 -1
- package/dist/lib/fleet/remote-login.d.ts +0 -170
- package/dist/lib/fleet/remote-login.js +0 -568
package/dist/lib/feed/answer.js
CHANGED
|
@@ -1,16 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deliver one operator answer to one open attention item (PHNX-3999).
|
|
3
|
+
*
|
|
4
|
+
* The AGI Menu abandons a reply at 30s, so every path here is bounded and every
|
|
5
|
+
* outcome is reported from real evidence:
|
|
6
|
+
*
|
|
7
|
+
* - **Match before enrichment.** The requested key names its session, so the
|
|
8
|
+
* candidate set is that session alone and the optional `gh pr view` read
|
|
9
|
+
* runs only for a key whose generation is a PR review. Enriching every
|
|
10
|
+
* active session first spent one 15s-bounded `gh` call per session on a
|
|
11
|
+
* question that never needed one.
|
|
12
|
+
* - **Unknown stays unknown.** Three outcomes are distinct and never merged: a
|
|
13
|
+
* confirmed failure (nothing was sent, the claim is released, retry is safe),
|
|
14
|
+
* an unconfirmed delivery (something may have landed, the claim is KEPT), and
|
|
15
|
+
* a real receipt. A timeout is unconfirmed, never a failure.
|
|
16
|
+
* - **Receipts are read, never synthesized, and never over-read.** `queued`
|
|
17
|
+
* means a rail took the answer and nothing more; only the agent's own
|
|
18
|
+
* `consumed`/`continued` resolves the item; `dropped`/`expired` are failures.
|
|
19
|
+
* - **Replay is de-duplicable or refused.** A claim stranded by a kill is
|
|
20
|
+
* adopted only on the mailbox rail, where the queued message's block id makes
|
|
21
|
+
* re-delivery detectable, and only through a compare-and-swap on the claim so
|
|
22
|
+
* two retries cannot both adopt it. A keystroke or resume rail cannot be
|
|
23
|
+
* replayed safely, so it reports unknown and points at the session.
|
|
24
|
+
* - **Exact tokens across the hop.** A remote answer is quoted with the
|
|
25
|
+
* canonical `shellQuote` and run through the canonical bounded `sshExecAsync`,
|
|
26
|
+
* so newlines and shell metacharacters arrive byte-identical, and the returned
|
|
27
|
+
* receipt is verified to belong to the key that was asked about.
|
|
28
|
+
*
|
|
29
|
+
* - **A claim is not a resolution.** The claim is taken `pending`
|
|
30
|
+
* (`recordAnswer`'s two-phase option): it locks the item against a second
|
|
31
|
+
* surface but leaves the block `open` and writes no tombstone, so the card
|
|
32
|
+
* stays in the operator's feed. Only a real receipt calls
|
|
33
|
+
* `confirmAnswerResolution` and lets the item leave. A delivery that never
|
|
34
|
+
* confirms can no longer make the request silently disappear.
|
|
35
|
+
*/
|
|
1
36
|
import { spawn } from 'node:child_process';
|
|
2
37
|
import { getActiveSessions } from '../session/active.js';
|
|
3
38
|
import { resolveAnswerRoute, resumeArgv } from '../answer-router.js';
|
|
4
|
-
import { enqueue, mailboxDir } from '../mailbox.js';
|
|
5
|
-
import { injectIntoTerminal } from '../terminal/index.js';
|
|
39
|
+
import { enqueue, mailboxDir, readBox } from '../mailbox.js';
|
|
40
|
+
import { backendCarriesPaste, injectIntoTerminal } from '../terminal/index.js';
|
|
6
41
|
import { verifyOperatorIdentity } from '../operator.js';
|
|
7
|
-
import {
|
|
42
|
+
import { machineId, normalizeHost } from '../machine-id.js';
|
|
43
|
+
import { shellQuote, sshExecAsync, SSH_CONN_FAILURE_CODE, SSH_TIMEOUT_KILL_GRACE_MS } from '../ssh-exec.js';
|
|
44
|
+
import { blockGeneration, blockIdForSession, confirmAnswerResolution, getAnswerRecord, latestMessageReceipt, publishBlock, readBlock, readResolution, recordAnswer, recordMessageReceipt, rollbackAnswerClaim, } from './feed.js';
|
|
8
45
|
import { reconcileAttention } from './attention.js';
|
|
9
46
|
import { readPullRequestStatus } from './pr-status.js';
|
|
10
47
|
import { getAgentsInvocation } from '../daemon/daemon.js';
|
|
11
|
-
|
|
12
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Total budget for one answer. Sits below the AGI Menu's 30s abandon so the
|
|
50
|
+
* operator always gets a typed verdict instead of a timeout.
|
|
51
|
+
*/
|
|
52
|
+
export const ANSWER_DEADLINE_MS = 20_000;
|
|
53
|
+
/** Slice of the budget the optional PR-review enrichment may spend. */
|
|
54
|
+
export const PR_ENRICHMENT_BUDGET_MS = 4_000;
|
|
55
|
+
/** Budget for the forwarded leg — the remote repeats the local work under its own deadline. */
|
|
56
|
+
export const REMOTE_ANSWER_TIMEOUT_MS = 25_000;
|
|
57
|
+
/**
|
|
58
|
+
* A claim older than this with no receipt was stranded by a kill, not left in
|
|
59
|
+
* flight: it exceeds a full local deadline plus the remote leg, so no live
|
|
60
|
+
* delivery can still be running behind it.
|
|
61
|
+
*/
|
|
62
|
+
export const STRANDED_CLAIM_MS = 60_000;
|
|
63
|
+
/**
|
|
64
|
+
* How long to watch a `resume` child before giving up on a verdict. A resume
|
|
65
|
+
* runs the agent's whole next turn — minutes — so waiting for exit would blow the
|
|
66
|
+
* operator deadline on every headless answer. Only a clean early exit is booked;
|
|
67
|
+
* a non-zero exit or a still-running child is unknown, because neither proves
|
|
68
|
+
* the agent did or did not accept the prompt.
|
|
69
|
+
*/
|
|
70
|
+
export const RESUME_SETTLE_MS = 2_000;
|
|
71
|
+
/**
|
|
72
|
+
* How long a caller that LOST the claim waits for the holder's receipt before
|
|
73
|
+
* reporting unknown. The holder is mid-delivery — a double-clicked answer is the
|
|
74
|
+
* common case — so a short wait turns "I can't tell" into the real receipt,
|
|
75
|
+
* without ever delivering a second copy.
|
|
76
|
+
*/
|
|
77
|
+
export const HOLDER_RECEIPT_WAIT_MS = 400;
|
|
78
|
+
const HOLDER_RECEIPT_POLL_MS = 20;
|
|
79
|
+
/** A CONFIRMED failure: nothing reached a rail, the claim is released, retry is safe. */
|
|
80
|
+
export class AnswerError extends Error {
|
|
81
|
+
code;
|
|
82
|
+
constructor(message, code) {
|
|
83
|
+
super(message);
|
|
84
|
+
this.code = code;
|
|
85
|
+
this.name = 'AnswerError';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* An UNCONFIRMED outcome: something may have been delivered. The claim is kept
|
|
90
|
+
* so a retry cannot double-send, and the operator is offered a delivery check
|
|
91
|
+
* rather than a resend.
|
|
92
|
+
*/
|
|
93
|
+
export class AnswerUnknownError extends Error {
|
|
94
|
+
constructor(message) {
|
|
95
|
+
super(message);
|
|
96
|
+
this.name = 'AnswerUnknownError';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Split `<host>/<session>/<generation>` (attention.ts `attentionKey`). Fails
|
|
101
|
+
* loud on a malformed key: the old `slice(0, indexOf('/'))` returned a truncated
|
|
102
|
+
* host for a key with no separator, which then routed the answer at random.
|
|
103
|
+
*/
|
|
104
|
+
export function parseAttentionKey(key) {
|
|
105
|
+
const first = key.indexOf('/');
|
|
106
|
+
const last = key.lastIndexOf('/');
|
|
107
|
+
if (first <= 0 || last <= first || last === key.length - 1) {
|
|
108
|
+
throw new AnswerError(`Malformed attention key '${key}' — expected '<host>/<session>/<generation>'.`, 'malformed_key');
|
|
109
|
+
}
|
|
110
|
+
return { host: key.slice(0, first), sessionId: key.slice(first + 1, last), generation: key.slice(last + 1) };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Whether THIS machine owns the item. The exact local/remote choice: the key's
|
|
114
|
+
* host is compared through the same {@link normalizeHost} the machine id is
|
|
115
|
+
* minted with, so `Yosemite-M4.local` and `yosemite-m4` are one machine.
|
|
116
|
+
*/
|
|
117
|
+
export function answerOwnerIsLocal(host, self = machineId()) {
|
|
118
|
+
return normalizeHost(host) === normalizeHost(self);
|
|
119
|
+
}
|
|
120
|
+
/** A PR-review generation (`pr<number>:<decision>`) — the only kind a `gh` read can produce. */
|
|
121
|
+
function isPullRequestGeneration(generation) {
|
|
122
|
+
return /^pr\d+:/.test(generation);
|
|
123
|
+
}
|
|
124
|
+
// --- receipt semantics ------------------------------------------------------
|
|
125
|
+
/**
|
|
126
|
+
* What a stored receipt proves. The lifecycle vocabulary is fixed by
|
|
127
|
+
* `MessageReceipt`, and each member means exactly one thing here:
|
|
128
|
+
* queued — a rail took the answer. Delivery, never resolution.
|
|
129
|
+
* consumed/continued— the agent itself acknowledged it. This resolves the item.
|
|
130
|
+
* dropped/expired — terminal failure; it is the newest truth for its message
|
|
131
|
+
* (the write rank puts it top precisely so it cannot be
|
|
132
|
+
* regressed), which is exactly why it must not read as success.
|
|
133
|
+
*/
|
|
134
|
+
export function classifyReceipt(receipt) {
|
|
135
|
+
if (receipt.status === 'consumed' || receipt.status === 'continued')
|
|
136
|
+
return { delivery: 'receipt', resolved: true };
|
|
137
|
+
if (receipt.status === 'queued')
|
|
138
|
+
return { delivery: 'receipt', resolved: false };
|
|
139
|
+
return { delivery: 'failed', resolved: false };
|
|
140
|
+
}
|
|
141
|
+
function startDeadline(totalMs) { return { endMs: Date.now() + totalMs }; }
|
|
142
|
+
function remainingMs(deadline) { return deadline.endMs - Date.now(); }
|
|
143
|
+
/**
|
|
144
|
+
* Bound one delivery await. Expiry is UNKNOWN, not failure: the rail's own child
|
|
145
|
+
* may still land the answer, so the claim stays and the operator is sent to a
|
|
146
|
+
* delivery check.
|
|
147
|
+
*/
|
|
148
|
+
async function withinDeadline(work, deadline, what) {
|
|
149
|
+
const budget = remainingMs(deadline);
|
|
150
|
+
if (budget <= 0)
|
|
151
|
+
throw new AnswerUnknownError(`${what} had no budget left; delivery is unknown.`);
|
|
152
|
+
let timer;
|
|
153
|
+
try {
|
|
154
|
+
return await Promise.race([
|
|
155
|
+
work,
|
|
156
|
+
new Promise((_, reject) => {
|
|
157
|
+
timer = setTimeout(() => reject(new AnswerUnknownError(`${what} did not finish in ${budget}ms; delivery is unknown.`)), budget);
|
|
158
|
+
timer.unref?.();
|
|
159
|
+
}),
|
|
160
|
+
]);
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
if (timer)
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
}
|
|
13
166
|
}
|
|
167
|
+
// --- resolution -------------------------------------------------------------
|
|
14
168
|
function blockFromAttention(attention, session) {
|
|
15
169
|
return {
|
|
16
170
|
blockId: blockIdForSession(attention.sessionId), sessionId: attention.sessionId,
|
|
@@ -26,129 +180,586 @@ function blockFromAttention(attention, session) {
|
|
|
26
180
|
safeDefault: attention.safeDefault,
|
|
27
181
|
};
|
|
28
182
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
183
|
+
/**
|
|
184
|
+
* Reconcile one session against the requested key. `pullRequest` is passed only
|
|
185
|
+
* on the enrichment pass — `reconcileAttention` consults it last
|
|
186
|
+
* (`attentionFromPullRequest`), so a block- or session-derived match never needed it.
|
|
187
|
+
*/
|
|
188
|
+
function matchSession(session, key, attentionKey, root, pullRequest) {
|
|
189
|
+
const projected = { ...session, host: key.host };
|
|
190
|
+
const blockId = blockIdForSession(session.sessionId);
|
|
191
|
+
const block = readBlock(blockId, root);
|
|
192
|
+
const attention = reconcileAttention({
|
|
193
|
+
block, session: projected, pullRequest,
|
|
194
|
+
resolution: readResolution(blockId, root), nowMs: Date.now(),
|
|
195
|
+
});
|
|
196
|
+
if (attention?.key === attentionKey) {
|
|
197
|
+
if (attention.kind === 'unverified') {
|
|
38
198
|
// No confirmed prompt to land a reply in: an answer typed into the session
|
|
39
199
|
// could hit an empty prompt line or a different dialog (PHNX-3999).
|
|
40
|
-
throw new
|
|
200
|
+
throw new AnswerError(`'${attentionKey}' could not be verified as a pending request — open the session and answer it there.`, 'unverified');
|
|
41
201
|
}
|
|
42
|
-
if (
|
|
43
|
-
return { block, attention };
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
202
|
+
if (block)
|
|
203
|
+
return { hit: { block, attention, session } };
|
|
204
|
+
const reconstructed = blockFromAttention(attention, session);
|
|
205
|
+
publishBlock(reconstructed, root);
|
|
206
|
+
return { hit: { block: reconstructed, attention, session } };
|
|
207
|
+
}
|
|
208
|
+
// The winning caller advances the block to answered before a concurrent loser
|
|
209
|
+
// resolves it. Reconstruct only this block's original generation so the loser
|
|
210
|
+
// reaches the claim check and reports the real outcome, without routing again.
|
|
211
|
+
if (block && getAnswerRecord(blockId, root)) {
|
|
212
|
+
const original = reconcileAttention({
|
|
213
|
+
block: { ...block, state: 'open', answer: undefined }, session: projected, nowMs: Date.now(),
|
|
214
|
+
});
|
|
215
|
+
if (original?.key === attentionKey)
|
|
216
|
+
return { hit: { block, attention: original, session } };
|
|
217
|
+
}
|
|
218
|
+
return { observed: attention };
|
|
219
|
+
}
|
|
220
|
+
async function resolveTarget(key, attentionKey, sessions, deadline, root) {
|
|
221
|
+
// The key names its session, so only that session can produce it. Narrowing
|
|
222
|
+
// here is also what keeps an answer off an unrelated operator's pending item.
|
|
223
|
+
const candidates = sessions.filter((session) => session.sessionId && session.sessionId === key.sessionId);
|
|
224
|
+
if (candidates.length === 0) {
|
|
225
|
+
throw new AnswerError(`No live session '${key.sessionId}' here to answer '${attentionKey}'.`, 'no_session');
|
|
226
|
+
}
|
|
227
|
+
let observed;
|
|
228
|
+
for (const session of candidates) {
|
|
229
|
+
const outcome = matchSession(session, key, attentionKey, root, undefined);
|
|
230
|
+
if (outcome.hit)
|
|
231
|
+
return outcome.hit;
|
|
232
|
+
observed ??= outcome.observed;
|
|
233
|
+
}
|
|
234
|
+
// Enrichment pass — only a review key can come from a PR read, and only the
|
|
235
|
+
// named session is fetched, under whatever budget is left.
|
|
236
|
+
if (isPullRequestGeneration(key.generation)) {
|
|
237
|
+
for (const session of candidates) {
|
|
238
|
+
const budget = Math.min(PR_ENRICHMENT_BUDGET_MS, remainingMs(deadline));
|
|
239
|
+
if (budget <= 0)
|
|
240
|
+
break;
|
|
241
|
+
const pullRequest = await readPullRequestStatus({ ...session, host: key.host }, { timeoutMs: budget });
|
|
242
|
+
if (!pullRequest)
|
|
243
|
+
continue;
|
|
244
|
+
const outcome = matchSession(session, key, attentionKey, root, pullRequest);
|
|
245
|
+
if (outcome.hit)
|
|
246
|
+
return outcome.hit;
|
|
247
|
+
observed ??= outcome.observed;
|
|
51
248
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
249
|
+
}
|
|
250
|
+
// The session is live but has moved on — a different generation, or none. That
|
|
251
|
+
// is a STALE request, not a missing one: the operator answered yesterday's card.
|
|
252
|
+
throw new AnswerError(observed
|
|
253
|
+
? `'${attentionKey}' is no longer the open request for '${key.sessionId}' — it is now '${observed.key}'.`
|
|
254
|
+
: `'${attentionKey}' is no longer open — session '${key.sessionId}' has no pending request.`, 'stale');
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Take over a claim a kill stranded, atomically.
|
|
258
|
+
*
|
|
259
|
+
* `rollbackAnswerClaim` compares `answeredAt` before releasing, so it IS the
|
|
260
|
+
* compare-and-swap: exactly one racer can release this exact claim, and it
|
|
261
|
+
* re-takes it immediately under `recordAnswer`'s `O_EXCL`. A racer that loses
|
|
262
|
+
* either the release or the re-take finds a live marker and reports
|
|
263
|
+
* `already_answered` instead of delivering a second copy.
|
|
264
|
+
*/
|
|
265
|
+
function adoptStrandedClaim(block, stranded, operator, verified, root) {
|
|
266
|
+
let released;
|
|
267
|
+
try {
|
|
268
|
+
released = rollbackAnswerClaim(block.blockId, stranded.answeredAt, { ...block, state: 'open', answer: undefined }, undefined, root);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
// The release is a read-compare-then-unlink, so a racing adopter that got
|
|
272
|
+
// there first leaves this one unlinking a marker that is already gone. That
|
|
273
|
+
// is losing the race, not an error — fall through and let the loser report.
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
if (!released)
|
|
277
|
+
return undefined;
|
|
278
|
+
const retaken = recordAnswer(block.blockId, {
|
|
279
|
+
answeredBy: operator.label, answeredFrom: 'feed', operatorId: operator.id, verified,
|
|
280
|
+
}, root, { pending: true });
|
|
281
|
+
if (!retaken.ok)
|
|
282
|
+
return undefined;
|
|
283
|
+
return getAnswerRecord(block.blockId, root);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Wait briefly for the claim holder to record its receipt. Read-only — it polls
|
|
287
|
+
* the block's own receipt list and never claims, routes or resends.
|
|
288
|
+
*/
|
|
289
|
+
async function awaitHolderReceipt(blockId, waitMs, origin, root) {
|
|
290
|
+
const until = Date.now() + waitMs;
|
|
291
|
+
for (;;) {
|
|
292
|
+
const receipt = latestMessageReceipt(blockId, root, origin);
|
|
293
|
+
if (receipt || Date.now() >= until)
|
|
294
|
+
return receipt;
|
|
295
|
+
await new Promise((resolve) => { const t = setTimeout(resolve, HOLDER_RECEIPT_POLL_MS); t.unref?.(); });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function heldClaimResult(block, attentionKey, host, existing, receipt) {
|
|
299
|
+
const base = { attentionKey, blockId: block.blockId, host, attempt: existing.answeredAt };
|
|
300
|
+
if (receipt) {
|
|
301
|
+
const { delivery, resolved } = classifyReceipt(receipt);
|
|
302
|
+
return {
|
|
303
|
+
status: delivery === 'failed' ? 'failed' : 'already_answered',
|
|
304
|
+
delivery, receipt, resolved,
|
|
305
|
+
reason: `Answered by ${existing.answeredBy ?? existing.answeredFrom} at ${existing.answeredAt}; the rail reports '${receipt.status}'.`,
|
|
306
|
+
...(delivery === 'failed' ? { code: 'rail_failed' } : {}),
|
|
307
|
+
...base,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
status: 'unknown', delivery: 'unconfirmed', resolved: false,
|
|
312
|
+
reason: `Claimed by ${existing.answeredBy ?? existing.answeredFrom} at ${existing.answeredAt}; no rail has reported a receipt. Check delivery rather than resending.`,
|
|
313
|
+
...base,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Take the claim, or reconcile the one already on disk against the block's real
|
|
318
|
+
* receipts. A stranded claim is adopted only when `replayable` — the mailbox rail,
|
|
319
|
+
* where the queued message's block id makes a second enqueue detectable. A
|
|
320
|
+
* keystroke or resume rail may already have landed before the kill and cannot be
|
|
321
|
+
* de-duplicated, so it stays unknown.
|
|
322
|
+
*/
|
|
323
|
+
async function claimOrReconcile(block, attentionKey, host, operator, verified, replayable, generation, nowMs, deadline, root) {
|
|
324
|
+
// PENDING: claimed, not resolved. The card stays in the operator's feed until
|
|
325
|
+
// a rail reports a receipt, so a claim whose delivery never lands cannot make
|
|
326
|
+
// the request silently disappear.
|
|
327
|
+
const claim = recordAnswer(block.blockId, {
|
|
328
|
+
answeredBy: operator.label, answeredFrom: 'feed', operatorId: operator.id, verified,
|
|
329
|
+
}, root, { pending: true });
|
|
330
|
+
if (claim.ok) {
|
|
331
|
+
const created = getAnswerRecord(block.blockId, root);
|
|
332
|
+
if (!created)
|
|
333
|
+
throw new AnswerError(`Answer claim for '${attentionKey}' was not persisted.`, 'rail_failed');
|
|
334
|
+
return { claim: created, adopted: false };
|
|
335
|
+
}
|
|
336
|
+
if ('unauthorized' in claim)
|
|
337
|
+
throw new AnswerError(claim.reason, 'unauthorized');
|
|
338
|
+
const existing = getAnswerRecord(block.blockId, root) ?? claim.existing;
|
|
339
|
+
// Scope every receipt read to THIS ask and THIS attempt, so a leftover receipt
|
|
340
|
+
// from an earlier question on the same session cannot answer for this one.
|
|
341
|
+
const origin = { generation, attempt: existing.answeredAt };
|
|
342
|
+
const claimedAtMs = Date.parse(existing.answeredAt);
|
|
343
|
+
const stranded = Number.isFinite(claimedAtMs) && nowMs - claimedAtMs >= STRANDED_CLAIM_MS;
|
|
344
|
+
// A fresh claim with no receipt is a delivery IN FLIGHT (the double-clicked
|
|
345
|
+
// answer), so give the holder a moment to record it rather than reporting a
|
|
346
|
+
// scary unknown for what is about to be a receipt.
|
|
347
|
+
const receipt = stranded
|
|
348
|
+
? latestMessageReceipt(block.blockId, root, origin)
|
|
349
|
+
: await awaitHolderReceipt(block.blockId, Math.max(0, Math.min(HOLDER_RECEIPT_WAIT_MS, remainingMs(deadline))), origin, root);
|
|
350
|
+
if (receipt)
|
|
351
|
+
return { adopted: false, lost: heldClaimResult(block, attentionKey, host, existing, receipt) };
|
|
352
|
+
// No receipt and the claim predates any possible live delivery: a kill cut it
|
|
353
|
+
// between the claim and the receipt.
|
|
354
|
+
if (stranded && replayable) {
|
|
355
|
+
const adopted = adoptStrandedClaim(block, existing, operator, verified, root);
|
|
356
|
+
if (adopted)
|
|
357
|
+
return { claim: adopted, adopted: true };
|
|
358
|
+
}
|
|
359
|
+
return { adopted: false, lost: heldClaimResult(block, attentionKey, host, existing, undefined) };
|
|
360
|
+
}
|
|
361
|
+
/** A multiline free-text answer needs a rail that inserts rather than submits per line. */
|
|
362
|
+
function isMultilineFreeText(route, answer) {
|
|
363
|
+
return (route.payload ?? '') === answer && answer.includes('\n');
|
|
364
|
+
}
|
|
365
|
+
async function deliverMailbox(block, answer, operator, adopted, origin, mailboxRoot) {
|
|
366
|
+
const dir = mailboxDir(block.mailboxId, mailboxRoot);
|
|
367
|
+
// An adopted claim may already have enqueued before it was killed. The whole
|
|
368
|
+
// spool is scanned — inbox, processing AND consumed (`readBox`, not `peek`) —
|
|
369
|
+
// because a kill AFTER the agent drained the message would otherwise look
|
|
370
|
+
// like nothing was ever sent and enqueue the answer a second time.
|
|
371
|
+
//
|
|
372
|
+
// The match is on the ASK (block id + generation), not just the block id: a
|
|
373
|
+
// block id is per SESSION, so an already-consumed message answering question N
|
|
374
|
+
// would otherwise suppress a genuine delivery for question N+1. It is NOT keyed
|
|
375
|
+
// on the attempt: adopting a stranded claim necessarily mints a NEW attempt, so
|
|
376
|
+
// requiring an attempt match would never find the message the killed run queued
|
|
377
|
+
// and would duplicate the answer.
|
|
378
|
+
const existing = adopted
|
|
379
|
+
? readBox(dir).find((msg) => msg.blockId === block.blockId && msg.generation === origin.generation)
|
|
380
|
+
: undefined;
|
|
381
|
+
const msgId = existing?.msgId ?? enqueue(dir, {
|
|
382
|
+
to: block.mailboxId, text: answer, from: operator.label, blockId: block.blockId,
|
|
383
|
+
generation: origin.generation, attempt: origin.attempt,
|
|
384
|
+
});
|
|
385
|
+
return {
|
|
386
|
+
receipt: { msgId, status: 'queued', at: new Date().toISOString(), from: operator.label, ...origin },
|
|
387
|
+
...(existing ? { reason: 'Re-used the message a stranded claim had already enqueued.' } : {}),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Re-enter a headless agent with the answer as its next user turn.
|
|
392
|
+
*
|
|
393
|
+
* Nothing here can prove non-delivery. A non-zero exit does NOT mean the prompt
|
|
394
|
+
* never ran — the agent may have acted on it and then crashed — and a process
|
|
395
|
+
* still running is not evidence the prompt was accepted either. Both are
|
|
396
|
+
* therefore UNKNOWN, which keeps the claim and sends the operator to a delivery
|
|
397
|
+
* check instead of a resend. Only a clean exit is booked, as `queued`: the
|
|
398
|
+
* resume command completed, which is the rail taking the answer — not the agent
|
|
399
|
+
* acknowledging it, which is what `consumed`/`continued` mean.
|
|
400
|
+
*/
|
|
401
|
+
async function deliverResume(route, block, claimedAt, operator, attentionKey, deadline, origin) {
|
|
402
|
+
const invocation = getAgentsInvocation(resumeArgv(route));
|
|
403
|
+
const settleMs = Math.max(0, Math.min(RESUME_SETTLE_MS, remainingMs(deadline)));
|
|
404
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
405
|
+
detached: true, stdio: ['ignore', 'ignore', 'pipe'], env: process.env,
|
|
406
|
+
});
|
|
407
|
+
let stderr = '';
|
|
408
|
+
child.stderr?.setEncoding('utf-8');
|
|
409
|
+
child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
|
|
410
|
+
const exit = await new Promise((resolve) => {
|
|
411
|
+
const timer = setTimeout(() => resolve(undefined), settleMs);
|
|
412
|
+
timer.unref?.();
|
|
413
|
+
child.once('error', () => { clearTimeout(timer); resolve(1); });
|
|
414
|
+
child.once('close', (code) => { clearTimeout(timer); resolve(code ?? 1); });
|
|
415
|
+
});
|
|
416
|
+
// Detach either way — the agent's turn outlives the operator's deadline.
|
|
417
|
+
child.stderr?.destroy();
|
|
418
|
+
child.unref();
|
|
419
|
+
if (exit !== undefined && exit !== 0) {
|
|
420
|
+
throw new AnswerUnknownError(`Resume for '${attentionKey}' exited ${exit}${stderr.trim() ? `: ${stderr.trim()}` : ''} — it may have taken the answer before failing.`);
|
|
421
|
+
}
|
|
422
|
+
if (exit === undefined) {
|
|
423
|
+
throw new AnswerUnknownError(`Resume for '${attentionKey}' is still running after ${settleMs}ms; it has the answer but has not acknowledged it.`);
|
|
424
|
+
}
|
|
425
|
+
return { receipt: { msgId: `resume-${block.blockId}-${claimedAt}`, status: 'queued', at: new Date().toISOString(), from: operator.label, ...origin } };
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Drive a keystroke rail.
|
|
429
|
+
*
|
|
430
|
+
* A failure here is UNKNOWN, not a confirmed failure: the tmux/iTerm path writes
|
|
431
|
+
* the text and its Enter as two separate sends (terminal/inject.ts, the Ink-safe
|
|
432
|
+
* split), so a reported error can mean the text already landed in the composer
|
|
433
|
+
* and only the submit failed. Every check that can prove nothing was sent —
|
|
434
|
+
* rail completeness, paste capability — runs BEFORE the claim instead.
|
|
435
|
+
*/
|
|
436
|
+
async function deliverInject(route, block, answer, claimedAt, operator, origin, deadline) {
|
|
437
|
+
// The rail gets the remaining budget, so it cancels its own process group and
|
|
438
|
+
// never starts a write the deadline can no longer cover.
|
|
439
|
+
const delivered = await injectIntoTerminal(route.inject, route.payload, {
|
|
440
|
+
enter: route.enter ?? true, combined: false, deadlineMs: Math.max(0, remainingMs(deadline)),
|
|
441
|
+
...(isMultilineFreeText(route, answer) ? { paste: true } : {}),
|
|
442
|
+
});
|
|
443
|
+
if (!delivered.ok) {
|
|
444
|
+
// `writes === 0` means no write was even issued, so nothing landed and the
|
|
445
|
+
// item is cleanly retryable. Anything past the first write is ambiguous: the
|
|
446
|
+
// text may sit in the composer with only its submit missing.
|
|
447
|
+
// `started === 0` is the ONLY proof nothing reached the terminal. A spec that
|
|
448
|
+
// was started and then failed or timed out may have written bytes first, so
|
|
449
|
+
// `writes` (which counts COMPLETED specs) cannot license a retry.
|
|
450
|
+
if (delivered.started === 0) {
|
|
451
|
+
throw new AnswerError(`${delivered.error ?? `Failed to deliver over ${route.kind}`} — no keystroke was sent.`, 'rail_failed');
|
|
56
452
|
}
|
|
453
|
+
throw new AnswerUnknownError(`${delivered.error ?? `Failed to deliver over ${route.kind}`} — ${delivered.started} of ${delivered.specs?.length ?? '?'} keystroke write(s) had already begun; open the session before resending.`);
|
|
57
454
|
}
|
|
58
|
-
|
|
455
|
+
if (!delivered.confirmed) {
|
|
456
|
+
throw new AnswerUnknownError(`${delivered.backend} accepted the hand-off but cannot confirm the agent received it.`);
|
|
457
|
+
}
|
|
458
|
+
return { receipt: { msgId: `inject-${block.blockId}-${claimedAt}`, status: 'queued', at: new Date().toISOString(), from: operator.label, ...origin } };
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Everything provable BEFORE a claim is taken. Each of these means nothing was
|
|
462
|
+
* sent, so raising here keeps the item cleanly retryable instead of parking it
|
|
463
|
+
* in an unknown state.
|
|
464
|
+
*/
|
|
465
|
+
function preflightRoute(route, answer, attentionKey) {
|
|
466
|
+
if (route.kind === 'refuse')
|
|
467
|
+
throw new AnswerError(route.reason, 'refused');
|
|
468
|
+
if (route.kind === 'mailbox' || route.kind === 'resume')
|
|
469
|
+
return;
|
|
470
|
+
if (!route.inject || route.payload == null)
|
|
471
|
+
throw new AnswerError(`Incomplete ${route.kind} reply rail.`, 'refused');
|
|
472
|
+
if (isMultilineFreeText(route, answer) && !backendCarriesPaste(route.inject.backend)) {
|
|
473
|
+
throw new AnswerError(`A multiline answer cannot be typed into the ${route.inject.backend} rail for '${attentionKey}' — only tmux carries bracketed paste. Open the session and paste it there.`, 'refused');
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
// --- entry points -----------------------------------------------------------
|
|
477
|
+
/**
|
|
478
|
+
* Read-only reconciliation of an attention item's delivery state — what powers
|
|
479
|
+
* "Check delivery" on an unconfirmed answer. It NEVER claims, routes, adopts or
|
|
480
|
+
* resends; it reports the stored claim and the block's real receipt so an
|
|
481
|
+
* operator can tell "still unconfirmed" from "the agent has it".
|
|
482
|
+
*/
|
|
483
|
+
export function checkAnswerDelivery(attentionKey, feedRoot, expectedAttempt) {
|
|
484
|
+
const key = parseAttentionKey(attentionKey);
|
|
485
|
+
const blockId = blockIdForSession(key.sessionId);
|
|
486
|
+
const base = { attentionKey, blockId, host: key.host };
|
|
487
|
+
const claim = getAnswerRecord(blockId, feedRoot);
|
|
488
|
+
// Scoped to the requested ask AND the attempt being checked, so a receipt left
|
|
489
|
+
// by a different question or a superseded attempt is never read as this one's.
|
|
490
|
+
const receipt = claim
|
|
491
|
+
? latestMessageReceipt(blockId, feedRoot, {
|
|
492
|
+
generation: key.generation, attempt: expectedAttempt ?? claim.answeredAt,
|
|
493
|
+
})
|
|
494
|
+
: undefined;
|
|
495
|
+
// A block id is per SESSION, so its claim and receipts belong to whatever
|
|
496
|
+
// generation the session is on NOW. Checking an older card against them would
|
|
497
|
+
// report the NEXT question's receipt as this question's answer, which is the
|
|
498
|
+
// one way a read-only check can still lie.
|
|
499
|
+
const block = readBlock(blockId, feedRoot);
|
|
500
|
+
const resolution = readResolution(blockId, feedRoot);
|
|
501
|
+
const liveGeneration = block ? blockGeneration(block) : resolution?.generation;
|
|
502
|
+
if (liveGeneration !== undefined && liveGeneration !== key.generation) {
|
|
503
|
+
return {
|
|
504
|
+
status: 'unknown', delivery: 'unconfirmed', resolved: false,
|
|
505
|
+
reason: `'${attentionKey}' is not the generation this session is on ('${liveGeneration}'), so its claim and receipts describe a different request.`,
|
|
506
|
+
...base,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
// An attempt the caller did not ask about is a DIFFERENT delivery — say so
|
|
510
|
+
// rather than answering about someone else's.
|
|
511
|
+
if (expectedAttempt !== undefined && claim && claim.answeredAt !== expectedAttempt) {
|
|
512
|
+
return {
|
|
513
|
+
status: 'unknown', delivery: 'unconfirmed', resolved: false,
|
|
514
|
+
reason: `Attempt '${expectedAttempt}' was replaced by one at ${claim.answeredAt}.`,
|
|
515
|
+
...base, attempt: claim.answeredAt,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
if (receipt) {
|
|
519
|
+
const { delivery, resolved } = classifyReceipt(receipt);
|
|
520
|
+
return {
|
|
521
|
+
status: delivery === 'failed' ? 'failed' : 'already_answered',
|
|
522
|
+
delivery, receipt, resolved,
|
|
523
|
+
reason: `The rail reports '${receipt.status}' for ${receipt.msgId}.`,
|
|
524
|
+
...(delivery === 'failed' ? { code: 'rail_failed' } : {}),
|
|
525
|
+
...base, ...(claim ? { attempt: claim.answeredAt } : {}),
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
if (claim) {
|
|
529
|
+
return {
|
|
530
|
+
status: 'unknown', delivery: 'unconfirmed', resolved: false,
|
|
531
|
+
reason: `Claimed by ${claim.answeredBy ?? claim.answeredFrom} at ${claim.answeredAt}; no rail has reported a receipt.`,
|
|
532
|
+
...base, attempt: claim.answeredAt,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
// No claim at all is the one state that IS a confirmed non-delivery: nothing
|
|
536
|
+
// ever took the item, so the operator can safely answer it again.
|
|
537
|
+
return {
|
|
538
|
+
status: 'failed', delivery: 'failed', resolved: false, code: 'rail_failed',
|
|
539
|
+
reason: `No answer has been claimed for '${attentionKey}'.`,
|
|
540
|
+
...base,
|
|
541
|
+
};
|
|
59
542
|
}
|
|
60
543
|
/** Atomically claim the first answer, then route it over the session's recorded reply rail. */
|
|
61
544
|
export async function claimAndRouteAttentionAnswer(input) {
|
|
62
|
-
if ((input.choiceId == null) === (input.text == null))
|
|
63
|
-
throw new
|
|
64
|
-
|
|
65
|
-
const
|
|
545
|
+
if ((input.choiceId == null) === (input.text == null)) {
|
|
546
|
+
throw new AnswerError('Exactly one of choiceId or text is required.', 'empty_answer');
|
|
547
|
+
}
|
|
548
|
+
const deadline = startDeadline(input.deadlineMs ?? ANSWER_DEADLINE_MS);
|
|
549
|
+
const key = parseAttentionKey(input.attentionKey);
|
|
550
|
+
const sessions = input.sessions ?? await withinDeadline(getActiveSessions(), deadline, 'Reading live sessions');
|
|
551
|
+
const { block, attention, session } = await resolveTarget(key, input.attentionKey, sessions, deadline, input.feedRoot);
|
|
66
552
|
const choice = input.choiceId == null ? undefined : attention.choices?.find((item) => item.id === input.choiceId);
|
|
67
|
-
if (input.choiceId != null && !choice)
|
|
68
|
-
throw new
|
|
553
|
+
if (input.choiceId != null && !choice) {
|
|
554
|
+
throw new AnswerError(`Unknown choice '${input.choiceId}' for '${input.attentionKey}'.`, 'unknown_choice');
|
|
555
|
+
}
|
|
69
556
|
const answer = input.text ?? choice?.deliveryKey ?? choice?.label;
|
|
70
557
|
if (!answer)
|
|
71
|
-
throw new
|
|
558
|
+
throw new AnswerError('Answer is empty.', 'empty_answer');
|
|
559
|
+
// The route is pure, so it is resolved BEFORE the claim: a refusal or an
|
|
560
|
+
// incapable rail then fails with nothing claimed and nothing delivered.
|
|
561
|
+
const route = resolveAnswerRoute({ mailboxId: block.mailboxId, answer, block, session });
|
|
562
|
+
preflightRoute(route, answer, input.attentionKey);
|
|
72
563
|
const verified = input.operator.verified && verifyOperatorIdentity(input.operator.id);
|
|
73
564
|
const previousResolution = readResolution(block.blockId, input.feedRoot);
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
565
|
+
const outcome = await claimOrReconcile(block, input.attentionKey, key.host, input.operator, verified, route.kind === 'mailbox', key.generation, Date.now(), deadline, input.feedRoot);
|
|
566
|
+
if (outcome.lost)
|
|
567
|
+
return outcome.lost;
|
|
568
|
+
const claim = outcome.claim;
|
|
569
|
+
// Every receipt this delivery writes names the ask and the attempt it belongs
|
|
570
|
+
// to, so a late acknowledgement can never be read against a different question.
|
|
571
|
+
const origin = { generation: key.generation, attempt: claim.answeredAt };
|
|
572
|
+
const base = { attentionKey: input.attentionKey, blockId: block.blockId, host: key.host, attempt: claim.answeredAt };
|
|
573
|
+
// An adopted claim was created by the killed run, so releasing it means
|
|
574
|
+
// restoring the pre-claim OPEN state, not the tombstone that claim wrote.
|
|
575
|
+
const restoreBlock = outcome.adopted ? { ...block, state: 'open', answer: undefined } : block;
|
|
576
|
+
const restoreResolution = outcome.adopted ? undefined : previousResolution;
|
|
577
|
+
let delivered;
|
|
578
|
+
try {
|
|
579
|
+
delivered = await withinDeadline(route.kind === 'mailbox'
|
|
580
|
+
? deliverMailbox(block, answer, input.operator, outcome.adopted, origin, input.mailboxRoot)
|
|
581
|
+
: route.kind === 'resume'
|
|
582
|
+
? deliverResume(route, block, claim.answeredAt, input.operator, input.attentionKey, deadline, origin)
|
|
583
|
+
: deliverInject(route, block, answer, claim.answeredAt, input.operator, origin, deadline), deadline, `Delivering '${input.attentionKey}' over ${route.kind}`);
|
|
584
|
+
}
|
|
585
|
+
catch (error) {
|
|
586
|
+
// Only a CONFIRMED failure releases the claim. Past this point the code is
|
|
587
|
+
// inside a rail that may already have written bytes, so anything unexpected
|
|
588
|
+
// is UNKNOWN too — keeping the claim is what stops a retry double-sending
|
|
589
|
+
// behind a delivery that may have landed.
|
|
590
|
+
if (error instanceof AnswerError) {
|
|
591
|
+
rollbackAnswerClaim(block.blockId, claim.answeredAt, restoreBlock, restoreResolution, input.feedRoot);
|
|
592
|
+
throw error;
|
|
593
|
+
}
|
|
594
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
595
|
+
return { status: 'unknown', delivery: 'unconfirmed', resolved: false, reason, ...base };
|
|
99
596
|
}
|
|
100
|
-
|
|
597
|
+
const { delivery, resolved } = classifyReceipt(delivered.receipt);
|
|
101
598
|
try {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
599
|
+
recordMessageReceipt(block.blockId, delivered.receipt, input.feedRoot);
|
|
600
|
+
// Only the AGENT's own acknowledgement resolves the item. A `queued` receipt
|
|
601
|
+
// says a rail took the answer and nothing more, so the card stays up until
|
|
602
|
+
// the mailbox drain records `consumed` (which promotes it through
|
|
603
|
+
// `recordMessageReceipt`) or the transcript moves past the block. The
|
|
604
|
+
// generation + attempt binding stops a slow delivery from resolving the ask
|
|
605
|
+
// the agent has since moved on to.
|
|
606
|
+
if (resolved) {
|
|
607
|
+
confirmAnswerResolution(block.blockId, input.feedRoot, {
|
|
608
|
+
generation: key.generation, answeredAt: claim.answeredAt,
|
|
105
609
|
});
|
|
106
610
|
}
|
|
107
|
-
else if (route.kind === 'resume') {
|
|
108
|
-
const invocation = getAgentsInvocation(resumeArgv(route));
|
|
109
|
-
const child = spawn(invocation.command, invocation.args, { stdio: 'inherit', env: process.env });
|
|
110
|
-
const code = await new Promise((resolve) => { child.once('error', () => resolve(1)); child.once('close', (value) => resolve(value ?? 1)); });
|
|
111
|
-
if (code !== 0)
|
|
112
|
-
throw new Error(`Resume delivery for '${input.attentionKey}' exited ${code}.`);
|
|
113
|
-
msgId = `resume-${Date.now()}`;
|
|
114
|
-
}
|
|
115
|
-
else {
|
|
116
|
-
if (!route.inject || route.payload == null)
|
|
117
|
-
throw new Error(`Incomplete ${route.kind} reply rail.`);
|
|
118
|
-
const delivered = await injectIntoTerminal(route.inject, route.payload, { enter: route.enter ?? true, combined: false });
|
|
119
|
-
if (!delivered.ok)
|
|
120
|
-
throw new Error(delivered.error ?? `Failed to deliver over ${route.kind}.`);
|
|
121
|
-
msgId = `inject-${Date.now()}`;
|
|
122
|
-
}
|
|
123
611
|
}
|
|
124
612
|
catch (error) {
|
|
125
|
-
|
|
126
|
-
|
|
613
|
+
// The answer IS on the rail; only the bookkeeping failed. Reporting a
|
|
614
|
+
// failure here would invite a resend of something already delivered.
|
|
615
|
+
return {
|
|
616
|
+
status: 'unknown', delivery: 'unconfirmed', resolved: false,
|
|
617
|
+
reason: `Delivered over ${route.kind}, but the receipt could not be recorded: ${error instanceof Error ? error.message : String(error)}.`,
|
|
618
|
+
...base,
|
|
619
|
+
};
|
|
127
620
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
621
|
+
return {
|
|
622
|
+
status: 'delivered', delivery, receipt: delivered.receipt, resolved,
|
|
623
|
+
...(delivered.reason ? { reason: delivered.reason } : {}),
|
|
624
|
+
...base,
|
|
625
|
+
};
|
|
131
626
|
}
|
|
132
|
-
/**
|
|
133
|
-
|
|
134
|
-
|
|
627
|
+
/**
|
|
628
|
+
* The remote `agents feed answer` argv for a forwarded answer. Exported so the
|
|
629
|
+
* exact tokens that cross the hop are asserted directly.
|
|
630
|
+
*/
|
|
631
|
+
export function remoteAnswerArgv(input) {
|
|
632
|
+
const argv = ['agents', 'feed', 'answer', input.attentionKey, '--json'];
|
|
633
|
+
if (input.check)
|
|
634
|
+
argv.push('--check');
|
|
635
|
+
if (input.attempt != null)
|
|
636
|
+
argv.push('--attempt', input.attempt);
|
|
135
637
|
if (input.choiceId != null)
|
|
136
|
-
|
|
638
|
+
argv.push('--choice', input.choiceId);
|
|
137
639
|
if (input.text != null)
|
|
138
|
-
|
|
640
|
+
argv.push('--text', input.text);
|
|
139
641
|
if (input.operatorId)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
642
|
+
argv.push('--as', input.operatorId);
|
|
643
|
+
return argv;
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Forward a fleet attention answer to the device that owns its scope.
|
|
647
|
+
*
|
|
648
|
+
* Every token is POSIX-quoted with the canonical {@link shellQuote} before the
|
|
649
|
+
* remote login shell parses it, so a multiline answer or one carrying `$`, `"`,
|
|
650
|
+
* `` ` ``, `;` or a newline arrives byte-identical. The transport is the
|
|
651
|
+
* canonical bounded {@link sshExecAsync} — it disables ControlMaster whenever a
|
|
652
|
+
* timeout is set, so the bound actually tears the remote command down instead of
|
|
653
|
+
* orphaning it behind a control socket.
|
|
654
|
+
*
|
|
655
|
+
* A timeout is reported UNKNOWN: the remote may well have delivered before the
|
|
656
|
+
* link was cut, so the operator is offered a delivery check, never an implicit
|
|
657
|
+
* resend. The returned receipt is verified to be about the key that was asked
|
|
658
|
+
* for, so a mismatched or truncated remote reply cannot be trusted as one.
|
|
659
|
+
*/
|
|
660
|
+
export async function forwardFeedAnswer(input) {
|
|
661
|
+
const timeoutMs = input.timeoutMs ?? REMOTE_ANSWER_TIMEOUT_MS;
|
|
662
|
+
const remoteCmd = remoteAnswerArgv(input).map(shellQuote).join(' ');
|
|
663
|
+
const unknown = (reason) => ({
|
|
664
|
+
status: 'unknown', delivery: 'unconfirmed', resolved: false,
|
|
665
|
+
reason: `${reason} Check delivery rather than resending.`,
|
|
666
|
+
attentionKey: input.attentionKey, host: input.host,
|
|
667
|
+
});
|
|
668
|
+
// The caller enforces the bound, not the transport. `sshExecAsync` resolves on
|
|
669
|
+
// the child's `close`, which a remote peer still holding the pipe can delay
|
|
670
|
+
// past the kill it already issued — so the operator's verdict is raced against
|
|
671
|
+
// the deadline directly and returns on time regardless.
|
|
672
|
+
const settled = await Promise.race([
|
|
673
|
+
sshExecAsync(input.host, remoteCmd, { timeoutMs }).then((value) => ({ value })),
|
|
674
|
+
new Promise((resolve) => {
|
|
675
|
+
const timer = setTimeout(() => resolve({}), timeoutMs + SSH_TIMEOUT_KILL_GRACE_MS + 500);
|
|
676
|
+
timer.unref?.();
|
|
677
|
+
}),
|
|
678
|
+
]);
|
|
679
|
+
const result = settled.value;
|
|
680
|
+
if (!result || result.timedOut) {
|
|
681
|
+
return unknown(`Answering on '${input.host}' did not finish in ${timeoutMs}ms — it may already have been delivered.`);
|
|
682
|
+
}
|
|
683
|
+
// Once ssh has been handed the command there is NO way to prove the remote did
|
|
684
|
+
// not run it. Exit 255 covers both "could not connect" and "connection dropped
|
|
685
|
+
// after the answer was delivered", and a dropped link can lose stdout, so even
|
|
686
|
+
// an echoed start marker is not proof of its own absence. Every post-dispatch
|
|
687
|
+
// remote outcome is therefore UNKNOWN — the operator gets "check delivery",
|
|
688
|
+
// never a retry that could double-send. (A locally-detectable fault, such as an
|
|
689
|
+
// invalid ssh target, throws from `sshExecAsync` before anything is dispatched.)
|
|
690
|
+
if (result.code === SSH_CONN_FAILURE_CODE) {
|
|
691
|
+
return unknown(`ssh to '${input.host}' failed (exit 255)${result.stderr.trim() ? `: ${result.stderr.trim()}` : ''}; whether the answer ran there is unknown.`);
|
|
692
|
+
}
|
|
693
|
+
const line = result.stdout.trim().split('\n').reverse().find((value) => value.startsWith('{'));
|
|
694
|
+
if (!line) {
|
|
695
|
+
return unknown(`Remote answer on '${input.host}' returned no JSON receipt (exit ${result.code}${result.stderr.trim() ? `: ${result.stderr.trim()}` : ''}).`);
|
|
696
|
+
}
|
|
697
|
+
let parsed;
|
|
698
|
+
try {
|
|
699
|
+
parsed = JSON.parse(line);
|
|
700
|
+
}
|
|
701
|
+
catch {
|
|
702
|
+
return unknown(`Remote answer on '${input.host}' returned unreadable JSON.`);
|
|
703
|
+
}
|
|
704
|
+
const shapeProblem = remoteResultProblem(parsed, input.attentionKey, input.attempt);
|
|
705
|
+
if (shapeProblem)
|
|
706
|
+
return unknown(`Remote answer on '${input.host}' ${shapeProblem}.`);
|
|
707
|
+
return { ...parsed, host: input.host };
|
|
708
|
+
}
|
|
709
|
+
const ANSWER_STATUSES = ['delivered', 'already_answered', 'unknown', 'failed'];
|
|
710
|
+
const ANSWER_DELIVERIES = ['receipt', 'unconfirmed', 'failed'];
|
|
711
|
+
const RECEIPT_STATUSES = ['queued', 'consumed', 'continued', 'dropped', 'expired'];
|
|
712
|
+
/**
|
|
713
|
+
* Validate a forwarded result as a whole, not just its key: an off-key, truncated
|
|
714
|
+
* or shape-invalid reply is not evidence about THIS request, and a caller that
|
|
715
|
+
* trusted one would report another item's outcome as this one's.
|
|
716
|
+
*/
|
|
717
|
+
function remoteResultProblem(parsed, attentionKey, requestedAttempt) {
|
|
718
|
+
if (parsed?.attentionKey !== attentionKey) {
|
|
719
|
+
return `reported '${parsed?.attentionKey ?? 'no key'}', not '${attentionKey}'`;
|
|
720
|
+
}
|
|
721
|
+
if (!ANSWER_STATUSES.includes(parsed.status))
|
|
722
|
+
return `reported an unknown status '${parsed.status}'`;
|
|
723
|
+
if (!ANSWER_DELIVERIES.includes(parsed.delivery))
|
|
724
|
+
return `reported an unknown delivery '${parsed.delivery}'`;
|
|
725
|
+
if (typeof parsed.resolved !== 'boolean')
|
|
726
|
+
return 'omitted the resolved flag';
|
|
727
|
+
if (parsed.blockId !== undefined && parsed.blockId !== blockIdForSession(parseAttentionKey(attentionKey).sessionId)) {
|
|
728
|
+
return `reported block '${parsed.blockId}', which is not this key's block`;
|
|
729
|
+
}
|
|
730
|
+
// An answer about a DIFFERENT attempt than the one asked about is not evidence
|
|
731
|
+
// for this one, even though the key matches.
|
|
732
|
+
if (requestedAttempt !== undefined && parsed.attempt !== undefined && parsed.attempt !== requestedAttempt) {
|
|
733
|
+
return `reported attempt '${parsed.attempt}', not the requested '${requestedAttempt}'`;
|
|
734
|
+
}
|
|
735
|
+
if (parsed.delivery === 'receipt') {
|
|
736
|
+
if (!parsed.receipt?.msgId)
|
|
737
|
+
return 'claimed a receipt without one';
|
|
738
|
+
if (!RECEIPT_STATUSES.includes(parsed.receipt.status)) {
|
|
739
|
+
return `reported an unknown receipt status '${parsed.receipt.status}'`;
|
|
740
|
+
}
|
|
741
|
+
// A dead message is a failure, not a delivery.
|
|
742
|
+
if (parsed.receipt.status === 'dropped' || parsed.receipt.status === 'expired') {
|
|
743
|
+
return `reported delivery 'receipt' for a '${parsed.receipt.status}' message`;
|
|
744
|
+
}
|
|
745
|
+
// The receipt must be about the ASK that was requested.
|
|
746
|
+
const generation = parseAttentionKey(attentionKey).generation;
|
|
747
|
+
if (parsed.receipt.generation !== undefined && parsed.receipt.generation !== generation) {
|
|
748
|
+
return `returned a receipt for ask '${parsed.receipt.generation}', not '${generation}'`;
|
|
749
|
+
}
|
|
750
|
+
if (parsed.attempt !== undefined && parsed.receipt.attempt !== undefined
|
|
751
|
+
&& parsed.receipt.attempt !== parsed.attempt) {
|
|
752
|
+
return `returned a receipt for attempt '${parsed.receipt.attempt}', not its own '${parsed.attempt}'`;
|
|
753
|
+
}
|
|
754
|
+
// `resolved` means the agent itself acknowledged — only consumed/continued
|
|
755
|
+
// can support it. A `queued` receipt claiming resolution is inconsistent.
|
|
756
|
+
const acknowledged = parsed.receipt.status === 'consumed' || parsed.receipt.status === 'continued';
|
|
757
|
+
if (parsed.resolved !== acknowledged) {
|
|
758
|
+
return `reported resolved=${parsed.resolved} for a '${parsed.receipt.status}' receipt`;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
else if (parsed.resolved) {
|
|
762
|
+
return `reported resolved=true with delivery '${parsed.delivery}'`;
|
|
763
|
+
}
|
|
764
|
+
return undefined;
|
|
154
765
|
}
|