@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
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* This is that boundary, and it is deliberately the thinnest possible one: a
|
|
11
11
|
* UNIX socket (named pipe on Windows) that writes the SAME NDJSON envelopes
|
|
12
12
|
* `agents feed watch --json` has always written, one per line. A client is a
|
|
13
|
-
*
|
|
13
|
+
* line splitter over the socket; there is no request/response protocol, no framing
|
|
14
14
|
* of its own, and no second schema to keep in sync.
|
|
15
15
|
*
|
|
16
16
|
* The daemon owns the server (`FeedStreamService`), which is what makes it one
|
|
@@ -22,13 +22,13 @@
|
|
|
22
22
|
import * as fs from 'node:fs';
|
|
23
23
|
import * as net from 'node:net';
|
|
24
24
|
import * as path from 'node:path';
|
|
25
|
-
import { createInterface } from 'node:readline';
|
|
26
25
|
import { getHelpersDir } from '../state.js';
|
|
27
26
|
import { ipcEndpoint } from '../platform/ipc.js';
|
|
28
27
|
const IS_WINDOWS = process.platform === 'win32';
|
|
29
28
|
const SOCKET_NAME = 'feed-stream.sock';
|
|
30
29
|
/**
|
|
31
|
-
*
|
|
30
|
+
* Live bytes a reader may leave queued, sustained past {@link HUB_BACKLOG_GRACE_MS},
|
|
31
|
+
* before it is dropped.
|
|
32
32
|
*
|
|
33
33
|
* `socket.write()` never blocks: when a reader stops draining — a stopped
|
|
34
34
|
* process, a suspended laptop, a debugger paused on a breakpoint — node buffers
|
|
@@ -37,8 +37,42 @@ const SOCKET_NAME = 'feed-stream.sock';
|
|
|
37
37
|
* can never reclaim, and the daemon is the process every other surface depends
|
|
38
38
|
* on. A reader that cannot keep up is dropped loudly instead: it can reconnect
|
|
39
39
|
* and be caught up from held state, which is cheaper than the backlog.
|
|
40
|
+
*
|
|
41
|
+
* The budget is a SUSTAINED condition on queued live events, never a verdict on
|
|
42
|
+
* one envelope or one burst. A cold collector delivers every peer's reset as a
|
|
43
|
+
* live event, thirteen of them inside one tick, and a healthy reader drains
|
|
44
|
+
* that in milliseconds; judging the budget the instant an envelope was written
|
|
45
|
+
* is how a 5 MB fleet reset was cut off after 8 KiB and delivered as one
|
|
46
|
+
* unterminated line (the Menu activation failure this module's writer fixes).
|
|
47
|
+
* The catch-up snapshot is never counted: it is bounded by the held state and is
|
|
48
|
+
* exactly what a fresh reader is waiting for.
|
|
40
49
|
*/
|
|
41
50
|
export const HUB_CLIENT_BACKLOG_LIMIT = 4 * 1024 * 1024;
|
|
51
|
+
/**
|
|
52
|
+
* How long a reader may stay past {@link HUB_CLIENT_BACKLOG_LIMIT} before it is
|
|
53
|
+
* dropped. A healthy reader on a unix socket clears the whole budget in well
|
|
54
|
+
* under this; one still over it after this long is not keeping up. The live
|
|
55
|
+
* bytes queued for a reader are therefore bounded by the budget plus what the
|
|
56
|
+
* stream produces in this window; the snapshot and the frame in flight sit
|
|
57
|
+
* outside that figure.
|
|
58
|
+
*/
|
|
59
|
+
export const HUB_BACKLOG_GRACE_MS = 2_000;
|
|
60
|
+
/**
|
|
61
|
+
* How long a reader may leave one chunk unaccepted before it is dropped.
|
|
62
|
+
*
|
|
63
|
+
* A write that returned `false` is a kernel buffer full of bytes the reader has
|
|
64
|
+
* not read. A healthy reader — even one on a busy laptop — clears it in
|
|
65
|
+
* milliseconds; one that has not in this long is not reading at all, and the
|
|
66
|
+
* live-bytes budget alone would let a paused reader hold a large snapshot's
|
|
67
|
+
* remainder in the daemon's heap forever.
|
|
68
|
+
*/
|
|
69
|
+
export const HUB_DRAIN_STALL_MS = 30_000;
|
|
70
|
+
/**
|
|
71
|
+
* Bytes handed to the socket per write. Small enough that a reader's own
|
|
72
|
+
* backpressure (`write()` returning `false`, then `'drain'`) paces a multi-MB
|
|
73
|
+
* snapshot instead of dumping it into the daemon's heap in one copy.
|
|
74
|
+
*/
|
|
75
|
+
export const HUB_WRITE_CHUNK_BYTES = 64 * 1024;
|
|
42
76
|
/**
|
|
43
77
|
* How long a reader gets to send its scope line before it is REJECTED.
|
|
44
78
|
*
|
|
@@ -63,6 +97,146 @@ export function feedHubSocketPath() {
|
|
|
63
97
|
export function feedHubEndpoint(socketPath = feedHubSocketPath()) {
|
|
64
98
|
return ipcEndpoint(socketPath);
|
|
65
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* One reader's ordered outbound queue.
|
|
102
|
+
*
|
|
103
|
+
* Every line to one socket goes through this — snapshot, live events, and the
|
|
104
|
+
* error envelope that precedes a refusal — so nothing can land inside another
|
|
105
|
+
* line. Lines are written in {@link HUB_WRITE_CHUNK_BYTES} chunks and the pump
|
|
106
|
+
* waits for `'drain'` after every write the socket did not accept, so the socket
|
|
107
|
+
* never holds more than one chunk past its high-water mark. The frame's own
|
|
108
|
+
* Buffer stays allocated until its last chunk is written (`subarray` is a view),
|
|
109
|
+
* so `pendingBytes` is a gauge of UNFLUSHED bytes, not of retained heap. Two
|
|
110
|
+
* bounds drop the reader: a chunk left unaccepted for `drainStallMs`, and live
|
|
111
|
+
* bytes queued past `backlogBytes` for longer than `backlogGraceMs`. The bound
|
|
112
|
+
* on what a reader can make the daemon hold is therefore time- and
|
|
113
|
+
* rate-dependent — the budget, plus the stream's ingress during the grace,
|
|
114
|
+
* plus the snapshot and the frame in flight — not a hard allocation cap.
|
|
115
|
+
*/
|
|
116
|
+
class ReaderWriter {
|
|
117
|
+
socket;
|
|
118
|
+
limits;
|
|
119
|
+
drop;
|
|
120
|
+
/** Lines not yet handed to the socket; `live` marks the ones the budget counts. */
|
|
121
|
+
queue = [];
|
|
122
|
+
/** Bytes queued since the snapshot finished enqueuing; the budgeted part. */
|
|
123
|
+
liveBytes = 0;
|
|
124
|
+
live = false;
|
|
125
|
+
running = false;
|
|
126
|
+
closeAfterFlush = false;
|
|
127
|
+
/** Armed while the live queue is over budget; fires the drop if it still is. */
|
|
128
|
+
overBudget = null;
|
|
129
|
+
/** Bytes of the line being pumped that have not yet been handed to the socket. */
|
|
130
|
+
activeRemaining = 0;
|
|
131
|
+
constructor(socket, limits, drop) {
|
|
132
|
+
this.socket = socket;
|
|
133
|
+
this.limits = limits;
|
|
134
|
+
this.drop = drop;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Unflushed bytes for this reader: queued lines, the unwritten rest of the
|
|
138
|
+
* line being pumped, and the socket's own buffer. A gauge of what is still
|
|
139
|
+
* owed to the socket, not of retained heap. A 5 MiB frame to a paused reader
|
|
140
|
+
* leaves the queue on its first chunk, so a queue-only gauge read 0 while
|
|
141
|
+
* almost all of it was still unflushed.
|
|
142
|
+
*/
|
|
143
|
+
get pendingBytes() {
|
|
144
|
+
return this.queue.reduce((sum, line) => sum + line.bytes.length, 0) + this.activeRemaining + this.socket.writableLength;
|
|
145
|
+
}
|
|
146
|
+
/** Everything enqueued from now on is a live event and counts against the budget. */
|
|
147
|
+
startLive() { this.live = true; }
|
|
148
|
+
write(line) {
|
|
149
|
+
if (this.socket.destroyed)
|
|
150
|
+
return;
|
|
151
|
+
const bytes = Buffer.from(`${line}\n`, 'utf-8');
|
|
152
|
+
this.queue.push({ bytes, live: this.live });
|
|
153
|
+
if (this.live) {
|
|
154
|
+
this.liveBytes += bytes.length;
|
|
155
|
+
this.judgeBacklog();
|
|
156
|
+
}
|
|
157
|
+
void this.pump();
|
|
158
|
+
}
|
|
159
|
+
/** FIN once everything queued has been handed to the socket. */
|
|
160
|
+
end() {
|
|
161
|
+
this.closeAfterFlush = true;
|
|
162
|
+
void this.pump();
|
|
163
|
+
}
|
|
164
|
+
/** Release the timers; the socket is gone. */
|
|
165
|
+
dispose() {
|
|
166
|
+
if (this.overBudget) {
|
|
167
|
+
clearTimeout(this.overBudget);
|
|
168
|
+
this.overBudget = null;
|
|
169
|
+
}
|
|
170
|
+
this.queue.length = 0;
|
|
171
|
+
this.liveBytes = 0;
|
|
172
|
+
this.activeRemaining = 0;
|
|
173
|
+
}
|
|
174
|
+
judgeBacklog() {
|
|
175
|
+
if (this.liveBytes <= this.limits.backlogBytes) {
|
|
176
|
+
if (this.overBudget) {
|
|
177
|
+
clearTimeout(this.overBudget);
|
|
178
|
+
this.overBudget = null;
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (this.overBudget)
|
|
183
|
+
return;
|
|
184
|
+
this.overBudget = setTimeout(() => {
|
|
185
|
+
this.overBudget = null;
|
|
186
|
+
if (this.liveBytes > this.limits.backlogBytes) {
|
|
187
|
+
this.drop('backlog', `feed reader dropped: ${this.liveBytes} live bytes queued exceeded the ${this.limits.backlogBytes}-byte budget for ${this.limits.backlogGraceMs}ms`);
|
|
188
|
+
}
|
|
189
|
+
}, this.limits.backlogGraceMs);
|
|
190
|
+
this.overBudget.unref();
|
|
191
|
+
}
|
|
192
|
+
async pump() {
|
|
193
|
+
if (this.running)
|
|
194
|
+
return;
|
|
195
|
+
this.running = true;
|
|
196
|
+
try {
|
|
197
|
+
while (this.queue.length > 0 && !this.socket.destroyed) {
|
|
198
|
+
const { bytes, live } = this.queue.shift();
|
|
199
|
+
if (live) {
|
|
200
|
+
this.liveBytes -= bytes.length;
|
|
201
|
+
this.judgeBacklog();
|
|
202
|
+
}
|
|
203
|
+
for (let offset = 0; offset < bytes.length && !this.socket.destroyed; offset += this.limits.chunkBytes) {
|
|
204
|
+
const end = Math.min(offset + this.limits.chunkBytes, bytes.length);
|
|
205
|
+
this.activeRemaining = bytes.length - end;
|
|
206
|
+
const accepted = this.socket.write(bytes.subarray(offset, end));
|
|
207
|
+
if (!accepted && !await this.drained())
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
this.activeRemaining = 0;
|
|
211
|
+
}
|
|
212
|
+
if (this.closeAfterFlush && !this.socket.destroyed)
|
|
213
|
+
this.socket.end();
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
this.running = false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/** Resolve true on `'drain'`; false when the socket closed or the stall deadline passed. */
|
|
220
|
+
drained() {
|
|
221
|
+
return new Promise((resolve) => {
|
|
222
|
+
const settle = (ok) => {
|
|
223
|
+
clearTimeout(stall);
|
|
224
|
+
this.socket.off('drain', onDrain);
|
|
225
|
+
this.socket.off('close', onClose);
|
|
226
|
+
resolve(ok);
|
|
227
|
+
};
|
|
228
|
+
const onDrain = () => settle(true);
|
|
229
|
+
const onClose = () => settle(false);
|
|
230
|
+
const stall = setTimeout(() => {
|
|
231
|
+
this.drop('stall', `feed reader dropped: no drain within ${this.limits.drainStallMs}ms with ${this.socket.writableLength} bytes unaccepted`);
|
|
232
|
+
settle(false);
|
|
233
|
+
}, this.limits.drainStallMs);
|
|
234
|
+
stall.unref();
|
|
235
|
+
this.socket.once('drain', onDrain);
|
|
236
|
+
this.socket.once('close', onClose);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
66
240
|
/**
|
|
67
241
|
* Serve one {@link FeedHub} to other processes. Each accepted connection is one
|
|
68
242
|
* subscriber; closing it detaches, and the last detach stops the fan-out.
|
|
@@ -75,8 +249,12 @@ export class FeedHubServer {
|
|
|
75
249
|
detachers = new Map();
|
|
76
250
|
/** Which collector each reader is attached to, so a failure reaches only its own. */
|
|
77
251
|
attachedTo = new Map();
|
|
78
|
-
|
|
252
|
+
writers = new Map();
|
|
253
|
+
limits;
|
|
254
|
+
/** Readers dropped for queueing live bytes past the budget. Observability. */
|
|
79
255
|
droppedForBacklog = 0;
|
|
256
|
+
/** Readers dropped for not draining a chunk within the stall deadline. Observability. */
|
|
257
|
+
droppedForStall = 0;
|
|
80
258
|
/** Readers refused for a missing, invalid, or late scope line. Observability. */
|
|
81
259
|
rejectedHandshakes = 0;
|
|
82
260
|
/**
|
|
@@ -86,10 +264,16 @@ export class FeedHubServer {
|
|
|
86
264
|
* every reader from the fleet hub, which is what the fleet
|
|
87
265
|
* stream already contained.
|
|
88
266
|
*/
|
|
89
|
-
constructor(hub, socketPathOverride, localHub) {
|
|
267
|
+
constructor(hub, socketPathOverride, localHub, limits = {}) {
|
|
90
268
|
this.hub = hub;
|
|
91
269
|
this.socketPathOverride = socketPathOverride;
|
|
92
270
|
this.localHub = localHub;
|
|
271
|
+
this.limits = {
|
|
272
|
+
backlogBytes: limits.backlogBytes ?? HUB_CLIENT_BACKLOG_LIMIT,
|
|
273
|
+
backlogGraceMs: limits.backlogGraceMs ?? HUB_BACKLOG_GRACE_MS,
|
|
274
|
+
drainStallMs: limits.drainStallMs ?? HUB_DRAIN_STALL_MS,
|
|
275
|
+
chunkBytes: limits.chunkBytes ?? HUB_WRITE_CHUNK_BYTES,
|
|
276
|
+
};
|
|
93
277
|
// A collector that cannot start is reported to every reader attached to it
|
|
94
278
|
// and the connection is ended, so a consumer sees a failure instead of an
|
|
95
279
|
// indefinitely silent stream it cannot distinguish from an idle fleet.
|
|
@@ -103,12 +287,20 @@ export class FeedHubServer {
|
|
|
103
287
|
for (const [socket, attached] of this.attachedTo) {
|
|
104
288
|
if (attached !== collector || socket.destroyed)
|
|
105
289
|
continue;
|
|
106
|
-
|
|
107
|
-
|
|
290
|
+
const writer = this.writers.get(socket);
|
|
291
|
+
writer.write(JSON.stringify({ v: 1, type: 'error', scope: '', error: error.message }));
|
|
292
|
+
writer.end();
|
|
108
293
|
}
|
|
109
294
|
}
|
|
110
295
|
/** Subscribers currently connected. Observability + tests. */
|
|
111
296
|
get clientCount() { return this.detachers.size; }
|
|
297
|
+
/** Bytes queued for every reader and not yet handed to a socket. Observability + tests. */
|
|
298
|
+
get pendingBytes() {
|
|
299
|
+
let total = 0;
|
|
300
|
+
for (const writer of this.writers.values())
|
|
301
|
+
total += writer.pendingBytes;
|
|
302
|
+
return total;
|
|
303
|
+
}
|
|
112
304
|
async start() {
|
|
113
305
|
const socketPath = this.socketPathOverride ?? feedHubSocketPath();
|
|
114
306
|
const endpoint = this.socketPathOverride ?? feedHubEndpoint();
|
|
@@ -124,41 +316,51 @@ export class FeedHubServer {
|
|
|
124
316
|
catch { /* nothing stale to remove */ }
|
|
125
317
|
}
|
|
126
318
|
this.server = net.createServer((socket) => {
|
|
319
|
+
// The scope line is REQUIRED and must arrive within the grace window.
|
|
320
|
+
const grace = setTimeout(() => reject(`no scope line within ${HUB_HANDSHAKE_GRACE_MS}ms`), HUB_HANDSHAKE_GRACE_MS);
|
|
321
|
+
grace.unref();
|
|
127
322
|
const end = () => {
|
|
323
|
+
clearTimeout(grace);
|
|
128
324
|
this.detachers.get(socket)?.();
|
|
129
325
|
this.detachers.delete(socket);
|
|
130
326
|
this.attachedTo.delete(socket);
|
|
327
|
+
this.writers.get(socket)?.dispose();
|
|
328
|
+
this.writers.delete(socket);
|
|
131
329
|
};
|
|
132
330
|
socket.on('close', end);
|
|
133
331
|
// A reader that dies mid-write surfaces as an error, not a close, and
|
|
134
332
|
// leaving it subscribed would hold every peer connection open forever.
|
|
135
333
|
socket.on('error', end);
|
|
334
|
+
const writer = new ReaderWriter(socket, this.limits, (reason, message) => {
|
|
335
|
+
if (socket.destroyed)
|
|
336
|
+
return;
|
|
337
|
+
if (reason === 'backlog')
|
|
338
|
+
this.droppedForBacklog += 1;
|
|
339
|
+
else
|
|
340
|
+
this.droppedForStall += 1;
|
|
341
|
+
// `destroy` rather than `end`: a reader this far behind is not going
|
|
342
|
+
// to drain a graceful FIN either, and the point is to release the
|
|
343
|
+
// queued bytes now. 'close' fires and detaches it.
|
|
344
|
+
socket.destroy(new Error(message));
|
|
345
|
+
});
|
|
346
|
+
this.writers.set(socket, writer);
|
|
136
347
|
/** Refuse this reader, telling it why rather than hanging up silently. */
|
|
137
348
|
const reject = (reason) => {
|
|
138
349
|
clearTimeout(grace);
|
|
139
350
|
if (socket.destroyed)
|
|
140
351
|
return;
|
|
141
352
|
this.rejectedHandshakes += 1;
|
|
142
|
-
|
|
143
|
-
|
|
353
|
+
writer.write(JSON.stringify({ v: 1, type: 'error', scope: '', error: `feed handshake rejected: ${reason}` }));
|
|
354
|
+
writer.end();
|
|
144
355
|
};
|
|
145
356
|
const attach = (hub) => {
|
|
146
357
|
if (socket.destroyed || this.detachers.has(socket))
|
|
147
358
|
return;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
// rather than on one envelope's size.
|
|
154
|
-
if (socket.writableLength > HUB_CLIENT_BACKLOG_LIMIT) {
|
|
155
|
-
this.droppedForBacklog += 1;
|
|
156
|
-
// `destroy` rather than `end`: a reader this far behind is not going
|
|
157
|
-
// to drain a graceful FIN either, and the point is to release the
|
|
158
|
-
// buffered bytes now. 'close' fires and detaches it.
|
|
159
|
-
socket.destroy(new Error(`feed reader dropped: ${socket.writableLength} bytes unflushed exceeds the ${HUB_CLIENT_BACKLOG_LIMIT}-byte budget`));
|
|
160
|
-
}
|
|
161
|
-
});
|
|
359
|
+
// `subscribe` emits the catch-up snapshot synchronously before it
|
|
360
|
+
// returns, so every line enqueued until then is snapshot and the first
|
|
361
|
+
// live event can only ever queue behind it.
|
|
362
|
+
const detach = hub.subscribe((event) => writer.write(JSON.stringify(event)));
|
|
363
|
+
writer.startLive();
|
|
162
364
|
this.detachers.set(socket, detach);
|
|
163
365
|
this.attachedTo.set(socket, hub);
|
|
164
366
|
// A collector that ALREADY failed must not leave this reader waiting for
|
|
@@ -166,10 +368,7 @@ export class FeedHubServer {
|
|
|
166
368
|
if (hub.lastFailure)
|
|
167
369
|
this.failReaders(hub, hub.lastFailure);
|
|
168
370
|
};
|
|
169
|
-
// The scope line is REQUIRED and must arrive within the grace window.
|
|
170
371
|
let handshake = '';
|
|
171
|
-
const grace = setTimeout(() => reject(`no scope line within ${HUB_HANDSHAKE_GRACE_MS}ms`), HUB_HANDSHAKE_GRACE_MS);
|
|
172
|
-
grace.unref();
|
|
173
372
|
socket.on('data', (chunk) => {
|
|
174
373
|
if (socket.destroyed)
|
|
175
374
|
return;
|
|
@@ -207,7 +406,6 @@ export class FeedHubServer {
|
|
|
207
406
|
}
|
|
208
407
|
attach(scope === 'local' ? this.localHub : this.hub);
|
|
209
408
|
});
|
|
210
|
-
socket.once('end', () => clearTimeout(grace));
|
|
211
409
|
});
|
|
212
410
|
await new Promise((resolve, reject) => {
|
|
213
411
|
const listener = this.server;
|
|
@@ -243,12 +441,18 @@ export class FeedHubServer {
|
|
|
243
441
|
});
|
|
244
442
|
}
|
|
245
443
|
async stop() {
|
|
246
|
-
for (const
|
|
444
|
+
for (const detach of this.detachers.values())
|
|
247
445
|
detach();
|
|
446
|
+
// Every connection, including one still in its handshake: `server.close`
|
|
447
|
+
// waits for open sockets, and a reader that never sent its scope line
|
|
448
|
+
// would otherwise hold the daemon's shutdown for the whole grace window.
|
|
449
|
+
for (const [socket, writer] of this.writers) {
|
|
450
|
+
writer.dispose();
|
|
248
451
|
socket.destroy();
|
|
249
452
|
}
|
|
250
453
|
this.detachers.clear();
|
|
251
454
|
this.attachedTo.clear();
|
|
455
|
+
this.writers.clear();
|
|
252
456
|
const server = this.server;
|
|
253
457
|
this.server = null;
|
|
254
458
|
if (server)
|
|
@@ -289,46 +493,94 @@ export async function waitForHub(endpoint = feedHubEndpoint(), deadlineMs = 10_0
|
|
|
289
493
|
* quietly ran its own `watchFleetFeed` instead would restore the per-caller
|
|
290
494
|
* ssh fan-out, so the caller starts the daemon and retries rather than
|
|
291
495
|
* degrading into the thing this replaced.
|
|
496
|
+
*
|
|
497
|
+
* Also rejects on any close the caller did not ask for. The stream has no end
|
|
498
|
+
* of its own — the hub serves it until the reader leaves — so a FIN that
|
|
499
|
+
* arrives before `signal` aborts is the hub refusing, failing, or dropping this
|
|
500
|
+
* reader, and a FIN inside a line is a frame the hub never finished. Resolving
|
|
501
|
+
* there let `agents feed watch --json` exit 0 after 8 KiB of a 5 MB reset,
|
|
502
|
+
* which is indistinguishable from an empty fleet.
|
|
292
503
|
*/
|
|
293
504
|
export function streamFeedFromHub(options) {
|
|
294
505
|
return new Promise((resolve, reject) => {
|
|
295
506
|
const socket = net.createConnection(options.endpoint ?? feedHubEndpoint());
|
|
296
|
-
let
|
|
507
|
+
let aborted = false;
|
|
508
|
+
let failure;
|
|
509
|
+
const fail = (error) => { failure ??= error; socket.destroy(); };
|
|
297
510
|
// Registered before anything else touches the socket: a connect failure on
|
|
298
511
|
// a missing socket path is emitted as an 'error' with no listener attached
|
|
299
512
|
// yet, which node raises as an uncaught exception rather than rejecting.
|
|
300
|
-
socket.on('error', (error) =>
|
|
301
|
-
const stop = () => { socket.destroy(); };
|
|
513
|
+
socket.on('error', (error) => { failure ??= error; });
|
|
514
|
+
const stop = () => { aborted = true; socket.destroy(); };
|
|
302
515
|
options.signal.addEventListener('abort', stop, { once: true });
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const
|
|
516
|
+
if (options.signal.aborted)
|
|
517
|
+
stop();
|
|
518
|
+
// Pieces of the line in progress; only the newest chunk is searched for a
|
|
519
|
+
// newline, so an 8 MB reset arriving in 64 KiB chunks is not rescanned from
|
|
520
|
+
// its start on every chunk.
|
|
521
|
+
const partial = [];
|
|
522
|
+
let partialBytes = 0;
|
|
523
|
+
socket.setEncoding('utf-8');
|
|
524
|
+
socket.on('data', (chunk) => {
|
|
525
|
+
let rest = chunk;
|
|
526
|
+
for (;;) {
|
|
527
|
+
const newline = rest.indexOf('\n');
|
|
528
|
+
if (newline < 0) {
|
|
529
|
+
partial.push(rest);
|
|
530
|
+
partialBytes += rest.length;
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
partial.push(rest.slice(0, newline));
|
|
534
|
+
const line = partial.join('');
|
|
535
|
+
partial.length = 0;
|
|
536
|
+
partialBytes = 0;
|
|
537
|
+
rest = rest.slice(newline + 1);
|
|
538
|
+
if (!line)
|
|
539
|
+
continue;
|
|
540
|
+
let event;
|
|
541
|
+
try {
|
|
542
|
+
event = JSON.parse(line);
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
fail(new Error(`feed hub sent a line that is not JSON (${line.length} chars)`));
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (typeof event !== 'object' || event === null) {
|
|
549
|
+
fail(new Error(`feed hub sent a line that is not an envelope: ${line.slice(0, 80)}`));
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
322
552
|
// Protocol only: an unversioned line is not a feed envelope.
|
|
323
|
-
if (event.v
|
|
553
|
+
if (event.v !== 1)
|
|
554
|
+
continue;
|
|
555
|
+
// A consumer that throws ends the read as a failure of THIS promise; left
|
|
556
|
+
// to escape the socket's 'data' handler it is an uncaught exception.
|
|
557
|
+
try {
|
|
324
558
|
options.emit(event);
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
325
564
|
}
|
|
326
|
-
catch { /* a partial/foreign line is not state */ }
|
|
327
565
|
});
|
|
328
566
|
socket.on('connect', () => {
|
|
329
|
-
connected = true;
|
|
330
567
|
socket.write(`${JSON.stringify({ v: 1, scope: options.scope ?? 'fleet' })}\n`);
|
|
331
568
|
});
|
|
332
|
-
socket.on('close', () =>
|
|
569
|
+
socket.on('close', () => {
|
|
570
|
+
options.signal.removeEventListener('abort', stop);
|
|
571
|
+
if (aborted) {
|
|
572
|
+
resolve();
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (failure) {
|
|
576
|
+
reject(failure);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (partialBytes > 0) {
|
|
580
|
+
reject(new Error(`feed hub closed mid-frame: ${partialBytes} chars of an unterminated line`));
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
reject(new Error('feed hub closed the stream'));
|
|
584
|
+
});
|
|
333
585
|
});
|
|
334
586
|
}
|
|
@@ -7,9 +7,17 @@ export interface PullRequestStatus extends PullRequestAttentionSignal {
|
|
|
7
7
|
statusCheckRollup?: unknown[];
|
|
8
8
|
}
|
|
9
9
|
/** CLI-owned bounded-TTL source shared by feed attention and PR-board consumers. */
|
|
10
|
+
export declare const PR_STATUS_DEFAULT_TIMEOUT_MS = 15000;
|
|
11
|
+
/**
|
|
12
|
+
* @param options.timeoutMs bound for the underlying `gh` call. An operator-facing
|
|
13
|
+
* caller working against a deadline (the feed answer path) passes its remaining
|
|
14
|
+
* budget so one unreachable PR cannot consume the whole operation; a timed-out
|
|
15
|
+
* read is negative-cached for the TTL like any other failure.
|
|
16
|
+
*/
|
|
10
17
|
export declare function readPullRequestStatus(session: ActiveSession, options?: {
|
|
11
18
|
nowMs?: number;
|
|
12
19
|
ttlMs?: number;
|
|
20
|
+
timeoutMs?: number;
|
|
13
21
|
gh?: GhExec;
|
|
14
22
|
}): Promise<PullRequestStatus | undefined>;
|
|
15
23
|
export declare function resetPullRequestStatusCache(): void;
|
|
@@ -15,6 +15,13 @@ function needsHuman(value) {
|
|
|
15
15
|
return value.reviewDecision !== 'APPROVED' || (value.mergeable === 'MERGEABLE' && checksSettled);
|
|
16
16
|
}
|
|
17
17
|
/** CLI-owned bounded-TTL source shared by feed attention and PR-board consumers. */
|
|
18
|
+
export const PR_STATUS_DEFAULT_TIMEOUT_MS = 15_000;
|
|
19
|
+
/**
|
|
20
|
+
* @param options.timeoutMs bound for the underlying `gh` call. An operator-facing
|
|
21
|
+
* caller working against a deadline (the feed answer path) passes its remaining
|
|
22
|
+
* budget so one unreachable PR cannot consume the whole operation; a timed-out
|
|
23
|
+
* read is negative-cached for the TTL like any other failure.
|
|
24
|
+
*/
|
|
18
25
|
export async function readPullRequestStatus(session, options = {}) {
|
|
19
26
|
const ref = session.pr?.url ?? session.pr?.number;
|
|
20
27
|
if (!ref || !session.cwd)
|
|
@@ -25,7 +32,8 @@ export async function readPullRequestStatus(session, options = {}) {
|
|
|
25
32
|
if (hit && hit.expiresAt > now)
|
|
26
33
|
return hit.value;
|
|
27
34
|
const cwd = session.cwd;
|
|
28
|
-
const
|
|
35
|
+
const timeout = options.timeoutMs ?? PR_STATUS_DEFAULT_TIMEOUT_MS;
|
|
36
|
+
const gh = options.gh ?? (async (args) => (await execFileAsync('gh', args, { cwd, timeout, maxBuffer: 1024 * 1024 })).stdout);
|
|
29
37
|
try {
|
|
30
38
|
const stdout = await gh(['pr', 'view', String(ref), '--json', PR_STATUS_FIELDS]);
|
|
31
39
|
const raw = JSON.parse(stdout);
|
package/dist/lib/feed-outcome.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* and any UI that collapses blocks under deliverables.
|
|
18
18
|
*/
|
|
19
19
|
import { detectTicket, extractPrUrl } from './session/state.js';
|
|
20
|
+
import { deriveBlockState } from './feed/feed.js';
|
|
20
21
|
const UNASSIGNED = {
|
|
21
22
|
key: 'unassigned',
|
|
22
23
|
kind: 'unassigned',
|
|
@@ -132,8 +133,14 @@ export function outcomeForBlock(block) {
|
|
|
132
133
|
text: blockScanText(block),
|
|
133
134
|
});
|
|
134
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Openness is the canonical {@link deriveBlockState}, never a raw `block.answer`
|
|
138
|
+
* test: a PENDING claim carries an answer record while the block is still
|
|
139
|
+
* `open`, so reading the field directly showed a claimed-but-undelivered
|
|
140
|
+
* question as answered in the operator's own feed (PHNX-3999).
|
|
141
|
+
*/
|
|
135
142
|
function isOpen(block) {
|
|
136
|
-
return
|
|
143
|
+
return deriveBlockState(block) === 'open' && !block.parkedAt && !block.continuedAt && !block.defaultedAt;
|
|
137
144
|
}
|
|
138
145
|
/**
|
|
139
146
|
* Collapse blocks under their outcome. Order: needs-you outcomes first
|
|
@@ -156,7 +163,7 @@ export function groupBlocksByOutcome(blocks) {
|
|
|
156
163
|
for (const b of members) {
|
|
157
164
|
const next = b.parkedAt
|
|
158
165
|
? 'parked'
|
|
159
|
-
: b
|
|
166
|
+
: isOpen(b) ? 'open' : 'answered';
|
|
160
167
|
const current = states.get(b.mailboxId);
|
|
161
168
|
// One agent occupies one state. Its most actionable block wins.
|
|
162
169
|
if (!current || next === 'open' || (next === 'parked' && current === 'answered')) {
|
package/dist/lib/feed-policy.js
CHANGED
|
@@ -16,7 +16,7 @@ import * as fs from 'fs';
|
|
|
16
16
|
import * as path from 'path';
|
|
17
17
|
import * as yaml from 'yaml';
|
|
18
18
|
import { getUserAgentsDir } from './state.js';
|
|
19
|
-
import { recordAnswer, recordDefaulted, recordMessageReceipt, recordParked, } from './feed/feed.js';
|
|
19
|
+
import { deriveBlockState, recordAnswer, recordDefaulted, recordMessageReceipt, recordParked, } from './feed/feed.js';
|
|
20
20
|
import { enqueue, mailboxDir } from './mailbox.js';
|
|
21
21
|
const POLICY_FILE = 'feed-policy.yaml';
|
|
22
22
|
const COST_RANK = { low: 0, medium: 1, high: 2 };
|
|
@@ -60,7 +60,10 @@ export function blockClass(block) {
|
|
|
60
60
|
return block.blockClass === 'decision' ? 'decision' : 'approval';
|
|
61
61
|
}
|
|
62
62
|
export function isPhoneUrgent(block, policy) {
|
|
63
|
-
|
|
63
|
+
// Openness is the canonical deriveBlockState, not a raw `block.answer`: a
|
|
64
|
+
// pending, unconfirmed claim sets `answer` while state stays `open`, and such
|
|
65
|
+
// a block is still waiting on the operator, so it must not silence the alert.
|
|
66
|
+
if (deriveBlockState(block) !== 'open')
|
|
64
67
|
return false; // already answered
|
|
65
68
|
const cost = block.costOfDelay ?? 'low';
|
|
66
69
|
return COST_RANK[cost] >= COST_RANK[policy.phoneNotifyThreshold];
|
|
@@ -86,7 +89,10 @@ export function isTimedOut(block, policy, now) {
|
|
|
86
89
|
* Caller is responsible for persistence/logging side effects not owned by feed.ts.
|
|
87
90
|
*/
|
|
88
91
|
export function applyPolicyToBlock(block, policy, now, root, mailboxRoot) {
|
|
89
|
-
|
|
92
|
+
// A pending, unconfirmed claim sets `block.answer` but keeps state `open`
|
|
93
|
+
// (deriveBlockState), so it must still be able to time out and escalate;
|
|
94
|
+
// parked/defaulted are separate terminal markers deriveBlockState omits.
|
|
95
|
+
if (deriveBlockState(block) !== 'open' || block.parkedAt || block.defaultedAt) {
|
|
90
96
|
return { blockId: block.blockId, action: 'none' };
|
|
91
97
|
}
|
|
92
98
|
if (!isTimedOut(block, policy, now)) {
|