@phnx-labs/agents-cli 1.22.115 → 1.22.116
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 +149 -14
- package/README.md +1 -1
- package/dist/commands/browser.js +11 -0
- package/dist/commands/exec.js +212 -141
- 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 +24 -0
- package/dist/commands/setup-term.js +70 -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 +26 -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/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 +6 -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/feed.d.ts
CHANGED
|
@@ -23,7 +23,38 @@ export interface MessageReceipt {
|
|
|
23
23
|
at: string;
|
|
24
24
|
/** Optional sender label for the message. */
|
|
25
25
|
from?: string;
|
|
26
|
+
/**
|
|
27
|
+
* The ask this receipt is ABOUT — the block generation live when the answer
|
|
28
|
+
* was sent. A block id is per SESSION, so it is reused by every generation of
|
|
29
|
+
* that session's questions; without this a late acknowledgement for question N
|
|
30
|
+
* is indistinguishable from one for question N+1 and would resolve the wrong
|
|
31
|
+
* ask (PHNX-3999). Carried durably on the queued message so it survives the
|
|
32
|
+
* process that sent it.
|
|
33
|
+
*/
|
|
34
|
+
generation?: string;
|
|
35
|
+
/** The claim (attempt) this receipt is about — `AnswerRecord.answeredAt`. */
|
|
36
|
+
attempt?: string;
|
|
37
|
+
}
|
|
38
|
+
/** The ask a receipt or queued message belongs to, plus the attempt that sent it. */
|
|
39
|
+
export interface ReceiptOrigin {
|
|
40
|
+
generation: string;
|
|
41
|
+
attempt: string;
|
|
26
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Whether a receipt describes THIS ask.
|
|
45
|
+
*
|
|
46
|
+
* Identity is the GENERATION alone, never the attempt. The question is "does
|
|
47
|
+
* this receipt answer this ask?", and a second attempt on the same ask carries
|
|
48
|
+
* the same answer -- so a stranded claim that is adopted (which necessarily
|
|
49
|
+
* mints a new attempt) must still recognise the message its predecessor queued,
|
|
50
|
+
* or it enqueues a duplicate. `attempt` rides along as provenance for the
|
|
51
|
+
* delivery check, not as part of identity.
|
|
52
|
+
*
|
|
53
|
+
* An UNBOUND receipt (written before these fields existed) matches NOTHING: it
|
|
54
|
+
* cannot name an ask, so attributing it to one would let a message queued for an
|
|
55
|
+
* earlier question resolve whichever question is current (PHNX-3999).
|
|
56
|
+
*/
|
|
57
|
+
export declare function receiptMatchesOrigin(receipt: MessageReceipt, origin: ReceiptOrigin): boolean;
|
|
27
58
|
export interface AnswerRecord {
|
|
28
59
|
/** ISO-8601 timestamp of when the answer was recorded. */
|
|
29
60
|
answeredAt: string;
|
|
@@ -267,7 +298,22 @@ export declare function recordAnswer(blockId: string, answer: {
|
|
|
267
298
|
answeredFrom: string;
|
|
268
299
|
operatorId?: string;
|
|
269
300
|
verified?: boolean;
|
|
270
|
-
}, root?: string
|
|
301
|
+
}, root?: string, options?: {
|
|
302
|
+
pending?: boolean;
|
|
303
|
+
}): RecordAnswerResult;
|
|
304
|
+
/**
|
|
305
|
+
* Promote a pending claim to a resolved answer — the second half of the
|
|
306
|
+
* two-phase answer protocol (see `recordAnswer`'s `pending` option).
|
|
307
|
+
*
|
|
308
|
+
* Called only once a rail has reported a real {@link MessageReceipt}: that is
|
|
309
|
+
* the point the item stops needing a human, so that is the point the tombstone
|
|
310
|
+
* is written and the card may leave the feed. An unconfirmed delivery never
|
|
311
|
+
* reaches here, so its card stays up.
|
|
312
|
+
*/
|
|
313
|
+
export declare function confirmAnswerResolution(blockId: string, root?: string, expected?: {
|
|
314
|
+
generation: string;
|
|
315
|
+
answeredAt: string;
|
|
316
|
+
}): boolean;
|
|
271
317
|
/** Read the answer record for a block, if one exists. */
|
|
272
318
|
export declare function getAnswerRecord(blockId: string, root?: string): AnswerRecord | undefined;
|
|
273
319
|
/**
|
|
@@ -277,6 +323,12 @@ export declare function getAnswerRecord(blockId: string, root?: string): AnswerR
|
|
|
277
323
|
* restoring the attention lifecycle to the state another surface observed.
|
|
278
324
|
*/
|
|
279
325
|
export declare function rollbackAnswerClaim(blockId: string, answeredAt: string, previousBlock: OpenBlock, previousResolution: AttentionResolution | undefined, root?: string): boolean;
|
|
326
|
+
/**
|
|
327
|
+
* How long a release token may sit before it is treated as abandoned. A release
|
|
328
|
+
* is a handful of synchronous file operations, so anything this old belongs to a
|
|
329
|
+
* process that died holding it.
|
|
330
|
+
*/
|
|
331
|
+
export declare const RELEASE_TOKEN_STALE_MS = 60000;
|
|
280
332
|
/** True when the block has already been answered. */
|
|
281
333
|
export declare function isBlockAnswered(blockId: string, root?: string): boolean;
|
|
282
334
|
/**
|
|
@@ -288,6 +340,14 @@ export declare function isBlockAnswered(blockId: string, root?: string): boolean
|
|
|
288
340
|
export declare function recordMessageReceipt(blockId: string, receipt: MessageReceipt, root?: string): void;
|
|
289
341
|
/** Read the receipt list for a block. */
|
|
290
342
|
export declare function getBlockReceipts(blockId: string, root?: string): MessageReceipt[];
|
|
343
|
+
/**
|
|
344
|
+
* The furthest-along receipt recorded for a block, or undefined when no rail
|
|
345
|
+
* ever reported one. This is the ONLY truthful evidence that an answer reached
|
|
346
|
+
* a delivery rail: an answer marker alone proves a claim was taken, not that
|
|
347
|
+
* anything was delivered, so a caller reporting on a block it did not deliver
|
|
348
|
+
* must read this rather than synthesize a receipt (PHNX-3999).
|
|
349
|
+
*/
|
|
350
|
+
export declare function latestMessageReceipt(blockId: string, root?: string, origin?: ReceiptOrigin): MessageReceipt | undefined;
|
|
291
351
|
/** Mark a block as "continued" -- the agent consumed the answer and moved on. */
|
|
292
352
|
export declare function recordContinued(blockId: string, root?: string): void;
|
|
293
353
|
/** Mark a decision-class block as hard-parked (no safe default existed). */
|
package/dist/lib/feed/feed.js
CHANGED
|
@@ -23,12 +23,32 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import * as fs from 'fs';
|
|
25
25
|
import * as path from 'path';
|
|
26
|
+
import * as os from 'os';
|
|
26
27
|
import * as yaml from 'yaml';
|
|
27
28
|
import { stringifyDoc } from '../yaml-io.js';
|
|
28
29
|
import { getFeedDir, getUserAgentsDir } from '../state.js';
|
|
29
30
|
import { isAdmin, isHighConsequenceAllowed, isKnownOperator } from '../operator.js';
|
|
30
31
|
import { projectKeyFromCwd } from '../project-key.js';
|
|
31
32
|
import { atomicWriteJsonSync } from '../fs-atomic.js';
|
|
33
|
+
/**
|
|
34
|
+
* Whether a receipt describes THIS ask.
|
|
35
|
+
*
|
|
36
|
+
* Identity is the GENERATION alone, never the attempt. The question is "does
|
|
37
|
+
* this receipt answer this ask?", and a second attempt on the same ask carries
|
|
38
|
+
* the same answer -- so a stranded claim that is adopted (which necessarily
|
|
39
|
+
* mints a new attempt) must still recognise the message its predecessor queued,
|
|
40
|
+
* or it enqueues a duplicate. `attempt` rides along as provenance for the
|
|
41
|
+
* delivery check, not as part of identity.
|
|
42
|
+
*
|
|
43
|
+
* An UNBOUND receipt (written before these fields existed) matches NOTHING: it
|
|
44
|
+
* cannot name an ask, so attributing it to one would let a message queued for an
|
|
45
|
+
* earlier question resolve whichever question is current (PHNX-3999).
|
|
46
|
+
*/
|
|
47
|
+
export function receiptMatchesOrigin(receipt, origin) {
|
|
48
|
+
if (receipt.generation === undefined)
|
|
49
|
+
return false;
|
|
50
|
+
return receipt.generation === origin.generation;
|
|
51
|
+
}
|
|
32
52
|
function resolutionDir(root) { return path.join(root, 'resolutions'); }
|
|
33
53
|
/**
|
|
34
54
|
* Canonical generation for a block. A writer that stamped `generation` wins;
|
|
@@ -129,7 +149,7 @@ export function readBlock(blockId, root) {
|
|
|
129
149
|
* High-consequence blocks require a verified operator identity. Unverified
|
|
130
150
|
* answers (no operatorId or not in the registry/allowed list) are refused.
|
|
131
151
|
*/
|
|
132
|
-
export function recordAnswer(blockId, answer, root) {
|
|
152
|
+
export function recordAnswer(blockId, answer, root, options = {}) {
|
|
133
153
|
const dir = root ?? getFeedDir();
|
|
134
154
|
const block = readBlock(blockId, dir);
|
|
135
155
|
const operatorId = answer.operatorId;
|
|
@@ -187,24 +207,74 @@ export function recordAnswer(blockId, answer, root) {
|
|
|
187
207
|
}
|
|
188
208
|
throw err;
|
|
189
209
|
}
|
|
190
|
-
// Marker created successfully -- mirror the answer into the block file
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
210
|
+
// Marker created successfully -- mirror the answer into the block file.
|
|
211
|
+
//
|
|
212
|
+
// A `pending` claim stops there: the claim is recorded so no second surface
|
|
213
|
+
// can take it, but the generation is NOT resolved and the lifecycle stays
|
|
214
|
+
// `open`, so the card remains in the operator's feed until a rail reports a
|
|
215
|
+
// real receipt (`confirmAnswerResolution`). A claim is not a delivery, and a
|
|
216
|
+
// claim whose delivery is never confirmed must not silently remove the item
|
|
217
|
+
// (PHNX-3999). `state` wins over `answer` in `deriveBlockState`, so the
|
|
218
|
+
// explicit `open` is what keeps the claimed block visible.
|
|
219
|
+
//
|
|
220
|
+
// The default one-phase path advances straight to `answered` for surfaces
|
|
221
|
+
// that resolve atomically (a policy default, a synchronous enqueue). The
|
|
222
|
+
// resolution tombstone is written first, so if a stale lifecycle re-read races
|
|
223
|
+
// the block-file update the reconciler already refuses to resurrect this
|
|
224
|
+
// generation.
|
|
194
225
|
if (block) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
226
|
+
if (!options.pending) {
|
|
227
|
+
recordResolution({
|
|
228
|
+
blockId,
|
|
229
|
+
generation: blockGeneration(block),
|
|
230
|
+
resolvedAt: record.answeredAt,
|
|
231
|
+
sourceCursor: block.sourceCursor,
|
|
232
|
+
reason: 'answered',
|
|
233
|
+
}, dir);
|
|
234
|
+
}
|
|
202
235
|
block.answer = record;
|
|
203
|
-
block.state = 'answered';
|
|
236
|
+
block.state = options.pending ? 'open' : 'answered';
|
|
204
237
|
publishBlock(block, dir);
|
|
205
238
|
}
|
|
206
239
|
return { ok: true };
|
|
207
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* Promote a pending claim to a resolved answer — the second half of the
|
|
243
|
+
* two-phase answer protocol (see `recordAnswer`'s `pending` option).
|
|
244
|
+
*
|
|
245
|
+
* Called only once a rail has reported a real {@link MessageReceipt}: that is
|
|
246
|
+
* the point the item stops needing a human, so that is the point the tombstone
|
|
247
|
+
* is written and the card may leave the feed. An unconfirmed delivery never
|
|
248
|
+
* reaches here, so its card stays up.
|
|
249
|
+
*/
|
|
250
|
+
export function confirmAnswerResolution(blockId, root, expected) {
|
|
251
|
+
const dir = root ?? getFeedDir();
|
|
252
|
+
const block = readBlock(blockId, dir);
|
|
253
|
+
const record = getAnswerRecord(blockId, dir);
|
|
254
|
+
if (!block || !record)
|
|
255
|
+
return false;
|
|
256
|
+
// One block id serves every generation of a session's asks, so a slow
|
|
257
|
+
// delivery for the PREVIOUS question must not resolve the one the agent has
|
|
258
|
+
// moved on to. A caller that knows which ask and which attempt it is
|
|
259
|
+
// confirming says so, and a mismatch is a no-op rather than a wrong tombstone.
|
|
260
|
+
// Bound to the ASK. The attempt is deliberately NOT compared: adopting a
|
|
261
|
+
// stranded claim mints a new attempt for the same question, and that adoption
|
|
262
|
+
// must still be able to resolve the ask it completed. The generation is what
|
|
263
|
+
// distinguishes one question from the next, which is the actual hazard.
|
|
264
|
+
if (expected && blockGeneration(block) !== expected.generation)
|
|
265
|
+
return false;
|
|
266
|
+
recordResolution({
|
|
267
|
+
blockId,
|
|
268
|
+
generation: blockGeneration(block),
|
|
269
|
+
resolvedAt: record.answeredAt,
|
|
270
|
+
sourceCursor: block.sourceCursor,
|
|
271
|
+
reason: 'answered',
|
|
272
|
+
}, dir);
|
|
273
|
+
block.answer = record;
|
|
274
|
+
block.state = 'answered';
|
|
275
|
+
publishBlock(block, dir);
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
208
278
|
/** Read the answer record for a block, if one exists. */
|
|
209
279
|
export function getAnswerRecord(blockId, root) {
|
|
210
280
|
return safeReadJson(path.join(answeredDir(root ?? getFeedDir()), `${blockId}.json`));
|
|
@@ -221,6 +291,31 @@ export function rollbackAnswerClaim(blockId, answeredAt, previousBlock, previous
|
|
|
221
291
|
const current = safeReadJson(marker);
|
|
222
292
|
if (!current || current.answeredAt !== answeredAt)
|
|
223
293
|
return false;
|
|
294
|
+
// Read-compare-then-unlink is NOT atomic, and two callers releasing the SAME
|
|
295
|
+
// claim is a real interleaving: both pass the compare, the first unlinks and
|
|
296
|
+
// re-claims, then the second unlinks the FIRST'S fresh marker and both end up
|
|
297
|
+
// holding a claim. The release is therefore gated on an O_EXCL token keyed by
|
|
298
|
+
// the exact claim being released -- the same primitive `recordAnswer` uses, so
|
|
299
|
+
// exactly one caller can ever release a given `answeredAt` (PHNX-3999).
|
|
300
|
+
const release = path.join(answeredDir(dir), `${blockId}.${answeredAt.replace(/[^0-9A-Za-z]/g, '')}.release`);
|
|
301
|
+
if (!acquireReleaseToken(release))
|
|
302
|
+
return false;
|
|
303
|
+
const dropToken = () => {
|
|
304
|
+
try {
|
|
305
|
+
fs.unlinkSync(release);
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
if (error.code !== 'ENOENT')
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
// Re-read INSIDE the token: a racer that released-and-re-claimed between our
|
|
313
|
+
// first read and the token acquisition would otherwise be clobbered.
|
|
314
|
+
const held = safeReadJson(marker);
|
|
315
|
+
if (!held || held.answeredAt !== answeredAt) {
|
|
316
|
+
dropToken();
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
224
319
|
// Restore while the O_EXCL marker still excludes every other claimant. The
|
|
225
320
|
// marker is removed LAST; once another writer can win recordAnswer, this
|
|
226
321
|
// rollback has no state left to overwrite.
|
|
@@ -237,9 +332,79 @@ export function rollbackAnswerClaim(blockId, answeredAt, previousBlock, previous
|
|
|
237
332
|
throw error;
|
|
238
333
|
}
|
|
239
334
|
}
|
|
240
|
-
|
|
335
|
+
try {
|
|
336
|
+
fs.unlinkSync(marker);
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
if (error.code !== 'ENOENT')
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
342
|
+
// The token has done its job: this exact claim can never be released again,
|
|
343
|
+
// because the claim it names no longer exists. Leaving it would accumulate one
|
|
344
|
+
// dead file per released claim forever.
|
|
345
|
+
dropToken();
|
|
241
346
|
return true;
|
|
242
347
|
}
|
|
348
|
+
/**
|
|
349
|
+
* How long a release token may sit before it is treated as abandoned. A release
|
|
350
|
+
* is a handful of synchronous file operations, so anything this old belongs to a
|
|
351
|
+
* process that died holding it.
|
|
352
|
+
*/
|
|
353
|
+
export const RELEASE_TOKEN_STALE_MS = 60_000;
|
|
354
|
+
/**
|
|
355
|
+
* Take the O_EXCL token that serialises releasing one specific claim.
|
|
356
|
+
*
|
|
357
|
+
* The token MUST be recoverable: a process killed between creating it and
|
|
358
|
+
* finishing would otherwise wedge that claim forever, and "the answer can never
|
|
359
|
+
* be released again" is a worse failure than the race the token prevents. So the
|
|
360
|
+
* token records its owner and its age, and a token whose owner is provably gone
|
|
361
|
+
* (same host, no such pid) or which is simply stale is reclaimed once.
|
|
362
|
+
*/
|
|
363
|
+
function acquireReleaseToken(release) {
|
|
364
|
+
const mine = { pid: process.pid, host: os.hostname(), at: Date.now() };
|
|
365
|
+
const create = () => {
|
|
366
|
+
try {
|
|
367
|
+
const fd = fs.openSync(release, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o644);
|
|
368
|
+
try {
|
|
369
|
+
fs.writeSync(fd, Buffer.from(JSON.stringify(mine), 'utf-8'));
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
fs.closeSync(fd);
|
|
373
|
+
}
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
if (error.code === 'EEXIST')
|
|
378
|
+
return false;
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
if (create())
|
|
383
|
+
return true;
|
|
384
|
+
const held = safeReadJson(release);
|
|
385
|
+
const ageMs = held?.at ? Date.now() - held.at : Number.POSITIVE_INFINITY;
|
|
386
|
+
let ownerGone = false;
|
|
387
|
+
if (held?.host === mine.host && typeof held.pid === 'number') {
|
|
388
|
+
// Signal 0 probes liveness without delivering anything.
|
|
389
|
+
try {
|
|
390
|
+
process.kill(held.pid, 0);
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
ownerGone = true;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (!ownerGone && ageMs < RELEASE_TOKEN_STALE_MS)
|
|
397
|
+
return false; // a live peer owns it
|
|
398
|
+
try {
|
|
399
|
+
fs.unlinkSync(release);
|
|
400
|
+
}
|
|
401
|
+
catch (error) {
|
|
402
|
+
if (error.code !== 'ENOENT')
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
405
|
+
// Exactly one reclaimer wins the re-create; the rest see EEXIST and back off.
|
|
406
|
+
return create();
|
|
407
|
+
}
|
|
243
408
|
/** True when the block has already been answered. */
|
|
244
409
|
export function isBlockAnswered(blockId, root) {
|
|
245
410
|
return fs.existsSync(path.join(answeredDir(root ?? getFeedDir()), `${blockId}.json`));
|
|
@@ -277,11 +442,58 @@ export function recordMessageReceipt(blockId, receipt, root) {
|
|
|
277
442
|
}
|
|
278
443
|
block.receipts = receipts;
|
|
279
444
|
publishBlock(block, dir);
|
|
445
|
+
// The AGENT's own acknowledgement is what resolves a pending claim: `queued`
|
|
446
|
+
// only says a rail took the answer, so it must never remove the card
|
|
447
|
+
// (PHNX-3999).
|
|
448
|
+
//
|
|
449
|
+
// The promotion is bound to the RECEIPT's own origin, not to whatever the
|
|
450
|
+
// block happens to hold now. Checking `block.answer` alone was not enough: if
|
|
451
|
+
// the agent moved to question N+1 AND that ask was itself claimed, a late
|
|
452
|
+
// acknowledgement for question N found a live claim and resolved the wrong
|
|
453
|
+
// question. `confirmAnswerResolution`'s own compare is what rejects it.
|
|
454
|
+
//
|
|
455
|
+
// An UNBOUND receipt never resolves. It cannot say which ask it acknowledges,
|
|
456
|
+
// so promoting it against whatever claim happens to be live would resolve the
|
|
457
|
+
// wrong question -- the exact failure this binding exists to prevent. Its
|
|
458
|
+
// delivery evidence is still recorded above; the card then clears through the
|
|
459
|
+
// ordinary session-advance path instead.
|
|
460
|
+
if ((receipt.status === 'consumed' || receipt.status === 'continued')
|
|
461
|
+
&& receipt.generation !== undefined && block.answer) {
|
|
462
|
+
confirmAnswerResolution(blockId, dir, {
|
|
463
|
+
generation: receipt.generation, answeredAt: block.answer.answeredAt,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
280
466
|
}
|
|
281
467
|
/** Read the receipt list for a block. */
|
|
282
468
|
export function getBlockReceipts(blockId, root) {
|
|
283
469
|
return readBlock(blockId, root)?.receipts ?? [];
|
|
284
470
|
}
|
|
471
|
+
/**
|
|
472
|
+
* The furthest-along receipt recorded for a block, or undefined when no rail
|
|
473
|
+
* ever reported one. This is the ONLY truthful evidence that an answer reached
|
|
474
|
+
* a delivery rail: an answer marker alone proves a claim was taken, not that
|
|
475
|
+
* anything was delivered, so a caller reporting on a block it did not deliver
|
|
476
|
+
* must read this rather than synthesize a receipt (PHNX-3999).
|
|
477
|
+
*/
|
|
478
|
+
export function latestMessageReceipt(blockId, root, origin) {
|
|
479
|
+
const all = getBlockReceipts(blockId, root);
|
|
480
|
+
// A block id is per SESSION, so its receipt list accumulates across every
|
|
481
|
+
// generation of that session's asks. Reading it unfiltered lets question N's
|
|
482
|
+
// receipt answer for question N+1 -- so a caller that knows which ask it is
|
|
483
|
+
// asking about passes the origin and sees only that ask's evidence.
|
|
484
|
+
const receipts = origin ? all.filter((receipt) => receiptMatchesOrigin(receipt, origin)) : all;
|
|
485
|
+
let best;
|
|
486
|
+
for (const receipt of receipts) {
|
|
487
|
+
if (!best) {
|
|
488
|
+
best = receipt;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
const rank = RECEIPT_STATUS_RANK[receipt.status] - RECEIPT_STATUS_RANK[best.status];
|
|
492
|
+
if (rank > 0 || (rank === 0 && receipt.at >= best.at))
|
|
493
|
+
best = receipt;
|
|
494
|
+
}
|
|
495
|
+
return best;
|
|
496
|
+
}
|
|
285
497
|
/** Mark a block as "continued" -- the agent consumed the answer and moved on. */
|
|
286
498
|
export function recordContinued(blockId, root) {
|
|
287
499
|
const dir = root ?? getFeedDir();
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { FeedHub } from './hub.js';
|
|
2
2
|
import type { FeedWatchEnvelope } from './envelope.js';
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Live bytes a reader may leave queued, sustained past {@link HUB_BACKLOG_GRACE_MS},
|
|
5
|
+
* before it is dropped.
|
|
5
6
|
*
|
|
6
7
|
* `socket.write()` never blocks: when a reader stops draining — a stopped
|
|
7
8
|
* process, a suspended laptop, a debugger paused on a breakpoint — node buffers
|
|
@@ -10,8 +11,49 @@ import type { FeedWatchEnvelope } from './envelope.js';
|
|
|
10
11
|
* can never reclaim, and the daemon is the process every other surface depends
|
|
11
12
|
* on. A reader that cannot keep up is dropped loudly instead: it can reconnect
|
|
12
13
|
* and be caught up from held state, which is cheaper than the backlog.
|
|
14
|
+
*
|
|
15
|
+
* The budget is a SUSTAINED condition on queued live events, never a verdict on
|
|
16
|
+
* one envelope or one burst. A cold collector delivers every peer's reset as a
|
|
17
|
+
* live event, thirteen of them inside one tick, and a healthy reader drains
|
|
18
|
+
* that in milliseconds; judging the budget the instant an envelope was written
|
|
19
|
+
* is how a 5 MB fleet reset was cut off after 8 KiB and delivered as one
|
|
20
|
+
* unterminated line (the Menu activation failure this module's writer fixes).
|
|
21
|
+
* The catch-up snapshot is never counted: it is bounded by the held state and is
|
|
22
|
+
* exactly what a fresh reader is waiting for.
|
|
13
23
|
*/
|
|
14
24
|
export declare const HUB_CLIENT_BACKLOG_LIMIT: number;
|
|
25
|
+
/**
|
|
26
|
+
* How long a reader may stay past {@link HUB_CLIENT_BACKLOG_LIMIT} before it is
|
|
27
|
+
* dropped. A healthy reader on a unix socket clears the whole budget in well
|
|
28
|
+
* under this; one still over it after this long is not keeping up. The live
|
|
29
|
+
* bytes queued for a reader are therefore bounded by the budget plus what the
|
|
30
|
+
* stream produces in this window; the snapshot and the frame in flight sit
|
|
31
|
+
* outside that figure.
|
|
32
|
+
*/
|
|
33
|
+
export declare const HUB_BACKLOG_GRACE_MS = 2000;
|
|
34
|
+
/**
|
|
35
|
+
* How long a reader may leave one chunk unaccepted before it is dropped.
|
|
36
|
+
*
|
|
37
|
+
* A write that returned `false` is a kernel buffer full of bytes the reader has
|
|
38
|
+
* not read. A healthy reader — even one on a busy laptop — clears it in
|
|
39
|
+
* milliseconds; one that has not in this long is not reading at all, and the
|
|
40
|
+
* live-bytes budget alone would let a paused reader hold a large snapshot's
|
|
41
|
+
* remainder in the daemon's heap forever.
|
|
42
|
+
*/
|
|
43
|
+
export declare const HUB_DRAIN_STALL_MS = 30000;
|
|
44
|
+
/**
|
|
45
|
+
* Bytes handed to the socket per write. Small enough that a reader's own
|
|
46
|
+
* backpressure (`write()` returning `false`, then `'drain'`) paces a multi-MB
|
|
47
|
+
* snapshot instead of dumping it into the daemon's heap in one copy.
|
|
48
|
+
*/
|
|
49
|
+
export declare const HUB_WRITE_CHUNK_BYTES: number;
|
|
50
|
+
/** Optional overrides for the transport bounds. Tests exercise both bounds fast. */
|
|
51
|
+
export type FeedHubLimits = {
|
|
52
|
+
backlogBytes?: number;
|
|
53
|
+
backlogGraceMs?: number;
|
|
54
|
+
drainStallMs?: number;
|
|
55
|
+
chunkBytes?: number;
|
|
56
|
+
};
|
|
15
57
|
/**
|
|
16
58
|
* How long a reader gets to send its scope line before it is REJECTED.
|
|
17
59
|
*
|
|
@@ -40,8 +82,12 @@ export declare class FeedHubServer {
|
|
|
40
82
|
private readonly detachers;
|
|
41
83
|
/** Which collector each reader is attached to, so a failure reaches only its own. */
|
|
42
84
|
private readonly attachedTo;
|
|
43
|
-
|
|
85
|
+
private readonly writers;
|
|
86
|
+
private readonly limits;
|
|
87
|
+
/** Readers dropped for queueing live bytes past the budget. Observability. */
|
|
44
88
|
droppedForBacklog: number;
|
|
89
|
+
/** Readers dropped for not draining a chunk within the stall deadline. Observability. */
|
|
90
|
+
droppedForStall: number;
|
|
45
91
|
/** Readers refused for a missing, invalid, or late scope line. Observability. */
|
|
46
92
|
rejectedHandshakes: number;
|
|
47
93
|
/**
|
|
@@ -51,11 +97,13 @@ export declare class FeedHubServer {
|
|
|
51
97
|
* every reader from the fleet hub, which is what the fleet
|
|
52
98
|
* stream already contained.
|
|
53
99
|
*/
|
|
54
|
-
constructor(hub: FeedHub, socketPathOverride?: string | undefined, localHub?: FeedHub | undefined);
|
|
100
|
+
constructor(hub: FeedHub, socketPathOverride?: string | undefined, localHub?: FeedHub | undefined, limits?: FeedHubLimits);
|
|
55
101
|
/** Report a collector failure to its readers and end those connections. */
|
|
56
102
|
private failReaders;
|
|
57
103
|
/** Subscribers currently connected. Observability + tests. */
|
|
58
104
|
get clientCount(): number;
|
|
105
|
+
/** Bytes queued for every reader and not yet handed to a socket. Observability + tests. */
|
|
106
|
+
get pendingBytes(): number;
|
|
59
107
|
start(): Promise<void>;
|
|
60
108
|
stop(): Promise<void>;
|
|
61
109
|
}
|
|
@@ -76,6 +124,13 @@ export declare function waitForHub(endpoint?: string, deadlineMs?: number, inter
|
|
|
76
124
|
* quietly ran its own `watchFleetFeed` instead would restore the per-caller
|
|
77
125
|
* ssh fan-out, so the caller starts the daemon and retries rather than
|
|
78
126
|
* degrading into the thing this replaced.
|
|
127
|
+
*
|
|
128
|
+
* Also rejects on any close the caller did not ask for. The stream has no end
|
|
129
|
+
* of its own — the hub serves it until the reader leaves — so a FIN that
|
|
130
|
+
* arrives before `signal` aborts is the hub refusing, failing, or dropping this
|
|
131
|
+
* reader, and a FIN inside a line is a frame the hub never finished. Resolving
|
|
132
|
+
* there let `agents feed watch --json` exit 0 after 8 KiB of a 5 MB reset,
|
|
133
|
+
* which is indistinguishable from an empty fleet.
|
|
79
134
|
*/
|
|
80
135
|
export declare function streamFeedFromHub(options: {
|
|
81
136
|
signal: AbortSignal;
|