flowviant 0.70.0 → 0.71.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/lib/fleet.mjs +6 -0
- package/bin/lib/listeners.mjs +189 -12
- package/bin/lib/processes.mjs +48 -7
- package/bin/lib/repoState.mjs +9 -2
- package/bin/lib/work.mjs +290 -6
- package/package.json +1 -1
package/bin/lib/fleet.mjs
CHANGED
|
@@ -815,6 +815,7 @@ export async function runFleetDaemon() {
|
|
|
815
815
|
processWorkTurns,
|
|
816
816
|
processShipJobs,
|
|
817
817
|
processDiffJobs,
|
|
818
|
+
processKillJobs,
|
|
818
819
|
heldSessionIds,
|
|
819
820
|
processPreviewJobs,
|
|
820
821
|
livePreviewIds,
|
|
@@ -1586,6 +1587,11 @@ export async function runFleetDaemon() {
|
|
|
1586
1587
|
// credential are both handed this array, and both opening a tunnel strands
|
|
1587
1588
|
// a public hostname nobody can settle.
|
|
1588
1589
|
processPreviewJobs(roster.previewJobs);
|
|
1590
|
+
// One measured process a human asked to stop. Claimed before acted on for
|
|
1591
|
+
// the same reason a share is, and re-verified against the kernel inside —
|
|
1592
|
+
// the pid on this job is a request, never an authority, because pids are
|
|
1593
|
+
// recycled and the row the browser clicked is up to a sweep old.
|
|
1594
|
+
processKillJobs(roster.killJobs);
|
|
1589
1595
|
// …and what the SURVIVING ones hold: branch, ahead-of-base, diffstat.
|
|
1590
1596
|
// Throttled inside, never awaited — a `git status` the human cannot run
|
|
1591
1597
|
// themselves from a browser, relayed. After retirement so a directory that
|
package/bin/lib/listeners.mjs
CHANGED
|
@@ -24,21 +24,59 @@
|
|
|
24
24
|
* reports NOTHING and says so through the empty array — the same answer
|
|
25
25
|
* `stillOurs` gives, and the same rule the rest of the product keeps: an
|
|
26
26
|
* unmeasured thing renders nothing rather than rendering "none".
|
|
27
|
+
*
|
|
28
|
+
* CHOSEN vs KERNEL-ASSIGNED, and why this is a measurement rather than a guess.
|
|
29
|
+
* `wrangler dev` opens NINE listening sockets; exactly one of them is the URL a
|
|
30
|
+
* person opens. The other eight were opened on port 0 and given whatever the
|
|
31
|
+
* kernel had free, and nobody will ever type one into a browser. The box itself
|
|
32
|
+
* says where that range is (`/proc/sys/net/ipv4/ip_local_port_range`, or
|
|
33
|
+
* `sysctl net.inet.ip.portrange.*`), so "was this port CHOSEN" is a fact we can
|
|
34
|
+
* read rather than a heuristic about anybody's stack — the same character as
|
|
35
|
+
* `bind`, and it needs no allowlist, no framework detection and no probe.
|
|
36
|
+
*
|
|
37
|
+
* IT IS THE ONE DISCRIMINATOR A PROBE COULD NOT PROVIDE. Connecting to each
|
|
38
|
+
* port and keeping the ones that answer HTTP is the obvious alternative and it
|
|
39
|
+
* is WRONG, not merely rude: under `wrangler dev` the app's internal entry
|
|
40
|
+
* socket returns byte-for-byte what the dev URL returns, because the dev URL
|
|
41
|
+
* proxies straight to it. A probe promotes the wrong port. The free fact beats
|
|
42
|
+
* the expensive one on accuracy.
|
|
43
|
+
*
|
|
44
|
+
* WHAT THE ROW MAY CARRY, and the line that must not be crossed. `pid` and
|
|
45
|
+
* `rss` are numbers about a process; the LABEL is still only the basenames of
|
|
46
|
+
* argv[0] and argv[1], and argv[1] is skipped outright when it starts with `-`.
|
|
47
|
+
* That is why no row here is ever passed through `envScrub`: it cannot carry a
|
|
48
|
+
* secret, by construction rather than by filtering. Widening the label to "the
|
|
49
|
+
* first argument that is not a flag" would break exactly that — it reads a
|
|
50
|
+
* flag's VALUE, so `node --require hunter2 app.js` would relay `hunter2`. Do
|
|
51
|
+
* not widen it. Add fields that are numbers; never add argv.
|
|
27
52
|
*/
|
|
28
53
|
|
|
29
54
|
import { execFileSync } from 'node:child_process';
|
|
30
55
|
import { createConnection } from 'node:net';
|
|
31
56
|
import { readFileSync, readdirSync, readlinkSync, realpathSync } from 'node:fs';
|
|
32
57
|
import { platform } from 'node:os';
|
|
58
|
+
import { rssBytes } from './processes.mjs';
|
|
33
59
|
import { sep } from 'node:path';
|
|
34
60
|
|
|
35
61
|
/** A box with more processes than this is not one we walk per sweep. The scan
|
|
36
62
|
* is one readlink per pid and runs every reconcile; this is the runaway
|
|
37
63
|
* bound, not a capacity statement. */
|
|
38
64
|
const MAX_PIDS = 4000;
|
|
39
|
-
/**
|
|
40
|
-
*
|
|
41
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Rows reported per session.
|
|
67
|
+
*
|
|
68
|
+
* It was 8, and `wrangler dev` alone opens NINE — so the cap was silently
|
|
69
|
+
* eating a row on an ordinary stack while the wire carried no flag saying it
|
|
70
|
+
* had. Both halves of that are fixed: the number is 12, and `measureListeners`
|
|
71
|
+
* reports the TOTAL so a surface can say it is not showing everything.
|
|
72
|
+
*
|
|
73
|
+
* The cap is also no longer allowed to eat the row that matters. Sorting was by
|
|
74
|
+
* port ascending, which is arbitrary with respect to importance — a dev server
|
|
75
|
+
* on :8080 beside eight kernel-assigned sockets in the 30000s survives by luck,
|
|
76
|
+
* and one on :9000 would not. CHOSEN ports sort first now, so the cut falls on
|
|
77
|
+
* the ephemeral tail, which is the half nobody opens.
|
|
78
|
+
*/
|
|
79
|
+
const MAX_ROWS = 12;
|
|
42
80
|
/** Longest process label we relay. */
|
|
43
81
|
const MAX_LABEL = 40;
|
|
44
82
|
|
|
@@ -120,6 +158,68 @@ export function labelFromArgv(argv) {
|
|
|
120
158
|
return label.slice(0, MAX_LABEL) || null;
|
|
121
159
|
}
|
|
122
160
|
|
|
161
|
+
/**
|
|
162
|
+
* THE KERNEL'S OWN EPHEMERAL PORT RANGE, or null where we cannot read it.
|
|
163
|
+
*
|
|
164
|
+
* A socket opened on port 0 is given a port out of this range; a port outside
|
|
165
|
+
* it was named by a person or their config. That is the whole of the CHOSEN
|
|
166
|
+
* test, and the reason it is honest: the range is READ FROM THIS BOX, never
|
|
167
|
+
* assumed. Linux's default is 32768-60999 and macOS's is 49152-65535, and both
|
|
168
|
+
* are tunable — hardcoding either would turn a measurement into a guess that is
|
|
169
|
+
* wrong on exactly the machines somebody bothered to tune.
|
|
170
|
+
*
|
|
171
|
+
* THREE STATES, as everywhere else here: `undefined` before we look, `null` for
|
|
172
|
+
* "this box would not say" (which must leave `chosen` OFF every row rather than
|
|
173
|
+
* defaulting it), and a pair once measured. Cached for the life of the process
|
|
174
|
+
* because the range does not move under a running kernel.
|
|
175
|
+
*/
|
|
176
|
+
let EPHEMERAL;
|
|
177
|
+
function ephemeralRange() {
|
|
178
|
+
if (EPHEMERAL !== undefined) return EPHEMERAL;
|
|
179
|
+
EPHEMERAL = null;
|
|
180
|
+
try {
|
|
181
|
+
if (platform() === 'linux') {
|
|
182
|
+
const [lo, hi] = readFileSync('/proc/sys/net/ipv4/ip_local_port_range', 'utf8')
|
|
183
|
+
.trim()
|
|
184
|
+
.split(/\s+/)
|
|
185
|
+
.map(Number);
|
|
186
|
+
if (Number.isInteger(lo) && Number.isInteger(hi) && lo > 0 && hi >= lo) EPHEMERAL = [lo, hi];
|
|
187
|
+
} else if (platform() === 'darwin') {
|
|
188
|
+
const one = (k) =>
|
|
189
|
+
Number(
|
|
190
|
+
execFileSync('sysctl', ['-n', k], {
|
|
191
|
+
encoding: 'utf8',
|
|
192
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
193
|
+
timeout: 3000,
|
|
194
|
+
}).trim()
|
|
195
|
+
);
|
|
196
|
+
const lo = one('net.inet.ip.portrange.first');
|
|
197
|
+
const hi = one('net.inet.ip.portrange.last');
|
|
198
|
+
if (Number.isInteger(lo) && Number.isInteger(hi) && lo > 0 && hi >= lo) EPHEMERAL = [lo, hi];
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
/* unreadable — stays null, and `chosen` is then absent rather than guessed */
|
|
202
|
+
}
|
|
203
|
+
return EPHEMERAL;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* True/false once the range is known, `undefined` when it is not — so the
|
|
208
|
+
* caller can omit the key entirely rather than assert a default.
|
|
209
|
+
*
|
|
210
|
+
* Split from the cached reader so the RULE is testable without a kernel: the
|
|
211
|
+
* three-state answer is the part worth pinning, and it is the part a later
|
|
212
|
+
* refactor is most likely to flatten into a boolean.
|
|
213
|
+
*/
|
|
214
|
+
export function isChosenPort(port, range) {
|
|
215
|
+
if (!range) return undefined;
|
|
216
|
+
return port < range[0] || port > range[1];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function chosenPort(port) {
|
|
220
|
+
return isChosenPort(port, ephemeralRange());
|
|
221
|
+
}
|
|
222
|
+
|
|
123
223
|
function labelFor(pid) {
|
|
124
224
|
try {
|
|
125
225
|
const raw = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
|
@@ -180,7 +280,20 @@ function scanLinux(worktree) {
|
|
|
180
280
|
const hit = inodes.get(m[1]);
|
|
181
281
|
if (!hit) continue;
|
|
182
282
|
if (found.has(hit.port)) continue;
|
|
183
|
-
|
|
283
|
+
const chosen = chosenPort(hit.port);
|
|
284
|
+
const rss = rssBytes(pid);
|
|
285
|
+
found.set(hit.port, {
|
|
286
|
+
port: hit.port,
|
|
287
|
+
bind: hit.bind,
|
|
288
|
+
label: labelFor(pid),
|
|
289
|
+
// The pid was always in hand here — it is what resolves the cwd — and
|
|
290
|
+
// was thrown away the moment the row was built. Keeping it is what lets
|
|
291
|
+
// a surface group nine sockets under the ONE program that opened them,
|
|
292
|
+
// and it is the only thing a kill could ever be aimed at.
|
|
293
|
+
pid: Number(pid),
|
|
294
|
+
...(rss != null ? { rss } : {}),
|
|
295
|
+
...(chosen === undefined ? {} : { chosen }),
|
|
296
|
+
});
|
|
184
297
|
}
|
|
185
298
|
}
|
|
186
299
|
return [...found.values()];
|
|
@@ -205,6 +318,22 @@ function scanDarwin(worktree) {
|
|
|
205
318
|
// Pass 1: every listening socket, as pid → ports.
|
|
206
319
|
const byPid = new Map();
|
|
207
320
|
let pid = null;
|
|
321
|
+
// RSS for every pid on the box, in one call. `lsof` cannot report memory and
|
|
322
|
+
// a per-pid `ps` would be one fork per row; this is one fork per sweep.
|
|
323
|
+
const rssByPid = new Map();
|
|
324
|
+
try {
|
|
325
|
+
for (const line of execFileSync('ps', ['-axo', 'pid=,rss='], {
|
|
326
|
+
encoding: 'utf8',
|
|
327
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
328
|
+
timeout: 5000,
|
|
329
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
330
|
+
}).split('\n')) {
|
|
331
|
+
const m = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
|
|
332
|
+
if (m) rssByPid.set(m[1], Number(m[2]) * 1024); // ps reports KB
|
|
333
|
+
}
|
|
334
|
+
} catch {
|
|
335
|
+
/* no memory on this box — rows simply carry no rss */
|
|
336
|
+
}
|
|
208
337
|
for (const line of lsof(['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pn']).split('\n')) {
|
|
209
338
|
if (line.startsWith('p')) pid = line.slice(1);
|
|
210
339
|
else if (line.startsWith('n') && pid) {
|
|
@@ -235,7 +364,20 @@ function scanDarwin(worktree) {
|
|
|
235
364
|
const cwd = line.slice(1);
|
|
236
365
|
if (cwd !== root && !cwd.startsWith(prefix)) continue;
|
|
237
366
|
for (const row of byPid.get(cur) || []) {
|
|
238
|
-
if (
|
|
367
|
+
if (out.has(row.port)) continue;
|
|
368
|
+
const chosen = chosenPort(row.port);
|
|
369
|
+
out.set(row.port, {
|
|
370
|
+
...row,
|
|
371
|
+
// Still null on darwin: `lsof -F pn` is asked for name and pid, never
|
|
372
|
+
// a command, and adding `-F c` would relay a command name we have no
|
|
373
|
+
// scrub for. The CHOSEN test needs no label, which is the other
|
|
374
|
+
// reason it is the right discriminator — it is the only one that
|
|
375
|
+
// works identically on a Mac.
|
|
376
|
+
label: null,
|
|
377
|
+
pid: Number(cur),
|
|
378
|
+
...(rssByPid.has(cur) ? { rss: rssByPid.get(cur) } : {}),
|
|
379
|
+
...(chosen === undefined ? {} : { chosen }),
|
|
380
|
+
});
|
|
239
381
|
}
|
|
240
382
|
}
|
|
241
383
|
}
|
|
@@ -245,19 +387,54 @@ function scanDarwin(worktree) {
|
|
|
245
387
|
// ── public ─────────────────────────────────────────────────────────────────
|
|
246
388
|
|
|
247
389
|
/**
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
390
|
+
* ORDER, and why it is not the port number any more.
|
|
391
|
+
*
|
|
392
|
+
* CHOSEN ports first, each group by port ascending. Two reasons, and the second
|
|
393
|
+
* is the load-bearing one:
|
|
394
|
+
*
|
|
395
|
+
* · A person scanning this list is looking for the URL they are about to open,
|
|
396
|
+
* and that is always a port somebody named. The seven sockets `wrangler dev`
|
|
397
|
+
* was handed by the kernel are not candidates and should not be read past.
|
|
398
|
+
* · The list is CAPPED. Ordering by port number let the cap fall wherever the
|
|
399
|
+
* numbers happened to land, so a dev server on a high port could be the row
|
|
400
|
+
* that got dropped. Sorting by chosen-ness puts the cut on the tail nobody
|
|
401
|
+
* opens.
|
|
402
|
+
*
|
|
403
|
+
* Where the range could not be read `chosen` is absent on every row, `Number()`
|
|
404
|
+
* of undefined is NaN, and the comparison is false both ways — so the order
|
|
405
|
+
* degrades to the old port sort rather than to something arbitrary.
|
|
406
|
+
*/
|
|
407
|
+
export function byChosenThenPort(a, b) {
|
|
408
|
+
const ac = a.chosen === true ? 0 : 1;
|
|
409
|
+
const bc = b.chosen === true ? 0 : 1;
|
|
410
|
+
return ac !== bc ? ac - bc : a.port - b.port;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Every TCP port in LISTEN held by a process whose cwd is inside `worktree`,
|
|
415
|
+
* with the TOTAL the cap was applied to.
|
|
416
|
+
*
|
|
417
|
+
* The total is the whole point of the pair. A list silently cut at twelve
|
|
418
|
+
* answers "what is running in here" with a number that is not true, and the
|
|
419
|
+
* Repository block already settled that trade for branches and worktrees: the
|
|
420
|
+
* capped list rides beside the count it was cut from. An empty array on an
|
|
421
|
+
* unsupported platform means "we did not look" — callers must not turn it into
|
|
422
|
+
* "nothing is running".
|
|
251
423
|
*/
|
|
252
|
-
export function
|
|
253
|
-
if (!worktree) return [];
|
|
424
|
+
export function measureListeners(worktree) {
|
|
425
|
+
if (!worktree) return { rows: [], total: 0 };
|
|
254
426
|
let rows;
|
|
255
427
|
try {
|
|
256
428
|
rows = platform() === 'linux' ? scanLinux(worktree) : platform() === 'darwin' ? scanDarwin(worktree) : [];
|
|
257
429
|
} catch {
|
|
258
|
-
return [];
|
|
430
|
+
return { rows: [], total: 0 };
|
|
259
431
|
}
|
|
260
|
-
return rows.sort(
|
|
432
|
+
return { rows: rows.sort(byChosenThenPort).slice(0, MAX_ROWS), total: rows.length };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** The capped rows alone, for callers with nowhere to put a total. */
|
|
436
|
+
export function listenersIn(worktree) {
|
|
437
|
+
return measureListeners(worktree).rows;
|
|
261
438
|
}
|
|
262
439
|
|
|
263
440
|
/** Does this platform measure listeners at all? The web must render no preview
|
package/bin/lib/processes.mjs
CHANGED
|
@@ -80,6 +80,28 @@ function pgrpOf(pid) {
|
|
|
80
80
|
return Number.isInteger(pgrp) && pgrp > 0 ? pgrp : null;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Resident set size in BYTES for one pid, or null.
|
|
85
|
+
*
|
|
86
|
+
* Read from `VmRSS` in /proc/<pid>/status rather than from `statm`, whose
|
|
87
|
+
* second field is resident PAGES and would need a page size we would have to
|
|
88
|
+
* assume. 4096 is not universal — arm64 boxes run 16K pages — and a memory
|
|
89
|
+
* readout that is silently four times wrong on somebody's machine is worse than
|
|
90
|
+
* no readout at all. `status` states its own unit.
|
|
91
|
+
*
|
|
92
|
+
* Lives here rather than in `listeners.mjs` because it is a fact about a
|
|
93
|
+
* PROCESS; both scanners import it, so there is one implementation of the unit
|
|
94
|
+
* question and not two that can drift.
|
|
95
|
+
*/
|
|
96
|
+
export function rssBytes(pid) {
|
|
97
|
+
try {
|
|
98
|
+
const m = /^VmRSS:\s+(\d+)\s+kB$/m.exec(readFileSync(`/proc/${pid}/status`, 'utf8'));
|
|
99
|
+
return m ? Number(m[1]) * 1024 : null;
|
|
100
|
+
} catch {
|
|
101
|
+
return null; // gone between the scan and the read — ordinary
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
83
105
|
function cmdlineOf(pid) {
|
|
84
106
|
try {
|
|
85
107
|
const raw = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
|
@@ -109,7 +131,8 @@ function scanLinux(pgids) {
|
|
|
109
131
|
if (pid === pgrp) continue;
|
|
110
132
|
const cmd = cmdlineOf(raw);
|
|
111
133
|
if (!cmd) continue;
|
|
112
|
-
|
|
134
|
+
const rss = rssBytes(raw);
|
|
135
|
+
out.push({ pid, pgid: pgrp, cmd, ...(rss != null ? { rss } : {}) });
|
|
113
136
|
}
|
|
114
137
|
return out;
|
|
115
138
|
}
|
|
@@ -117,7 +140,7 @@ function scanLinux(pgids) {
|
|
|
117
140
|
function scanDarwin(pgids) {
|
|
118
141
|
let text;
|
|
119
142
|
try {
|
|
120
|
-
text = execFileSync('ps', ['-axo', 'pid=,pgid=,command='], {
|
|
143
|
+
text = execFileSync('ps', ['-axo', 'pid=,pgid=,rss=,command='], {
|
|
121
144
|
encoding: 'utf8',
|
|
122
145
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
123
146
|
timeout: 5000,
|
|
@@ -128,13 +151,13 @@ function scanDarwin(pgids) {
|
|
|
128
151
|
}
|
|
129
152
|
const out = [];
|
|
130
153
|
for (const line of text.split('\n')) {
|
|
131
|
-
const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
|
|
154
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/.exec(line);
|
|
132
155
|
if (!m) continue;
|
|
133
156
|
const pid = Number(m[1]);
|
|
134
157
|
const pgid = Number(m[2]);
|
|
135
158
|
if (!pgids.has(pgid) || pid === pgid) continue;
|
|
136
|
-
const cmd = m[
|
|
137
|
-
if (cmd) out.push({ pid, pgid, cmd });
|
|
159
|
+
const cmd = m[4].trim();
|
|
160
|
+
if (cmd) out.push({ pid, pgid, cmd, rss: Number(m[3]) * 1024 }); // ps reports KB
|
|
138
161
|
}
|
|
139
162
|
return out;
|
|
140
163
|
}
|
|
@@ -146,18 +169,36 @@ function scanDarwin(pgids) {
|
|
|
146
169
|
* "looked, found none" and is a different fact a surface renders differently.
|
|
147
170
|
*/
|
|
148
171
|
export function processesInGroups(pgids, { scrub } = {}) {
|
|
172
|
+
const m = measureProcesses(pgids, { scrub });
|
|
173
|
+
return m === null ? null : m.rows;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* …and the TOTAL the cap was applied to.
|
|
178
|
+
*
|
|
179
|
+
* The rows alone are a lie about scale the moment a group has more than twelve
|
|
180
|
+
* members: a dashboard summing them states an incomplete figure as a complete
|
|
181
|
+
* one. Same rule the listener list and the Repository block's worktree counts
|
|
182
|
+
* keep — the capped list rides beside the count it was cut from, or it answers
|
|
183
|
+
* "how much is in here" with a number that is not true.
|
|
184
|
+
*/
|
|
185
|
+
export function measureProcesses(pgids, { scrub } = {}) {
|
|
149
186
|
if (!processesSupported()) return null;
|
|
150
187
|
const want = pgids instanceof Set ? pgids : new Set(pgids ?? []);
|
|
151
|
-
if (want.size === 0) return [];
|
|
188
|
+
if (want.size === 0) return { rows: [], total: 0 };
|
|
152
189
|
const rows = platform() === 'darwin' ? scanDarwin(want) : scanLinux(want);
|
|
153
190
|
// Oldest first: a pid is monotonic, so the long-running watcher you started
|
|
154
191
|
// an hour ago sorts above the `sh -c` spawned two seconds ago. Cutting from
|
|
155
192
|
// the END keeps the durable processes and drops the churn.
|
|
156
193
|
rows.sort((a, b) => a.pid - b.pid);
|
|
157
|
-
|
|
194
|
+
const capped = rows.slice(0, MAX_PROCS).map((r) => ({
|
|
158
195
|
pid: r.pid,
|
|
159
196
|
cmd: String(scrub ? scrub(r.cmd) : r.cmd).slice(0, MAX_CMD),
|
|
197
|
+
// Absent rather than zero where it could not be read: a watcher using no
|
|
198
|
+
// memory is not a thing, so a 0 here would only ever mean "we failed".
|
|
199
|
+
...(r.rss != null ? { rss: r.rss } : {}),
|
|
160
200
|
}));
|
|
201
|
+
return { rows: capped, total: rows.length };
|
|
161
202
|
}
|
|
162
203
|
|
|
163
204
|
/**
|
package/bin/lib/repoState.mjs
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import { execFileSync } from 'node:child_process';
|
|
35
|
-
import {
|
|
35
|
+
import { measureListeners, listenersSupported } from './listeners.mjs';
|
|
36
36
|
|
|
37
37
|
/** Same cap the session diffstat uses: enough to see the shape, small enough
|
|
38
38
|
* that one machine cannot flood a row. */
|
|
@@ -162,7 +162,13 @@ export function repoState(repoRoot, baseRef) {
|
|
|
162
162
|
* Reported, not offered: this is a readout of what is up. Sharing one is a
|
|
163
163
|
* separate act with its own gates (see previewJobs).
|
|
164
164
|
*/
|
|
165
|
-
|
|
165
|
+
// The TOTAL beside the capped rows, for the reason every other capped list
|
|
166
|
+
// here carries one: a list silently cut at twelve answers "what is running in
|
|
167
|
+
// the checkout" with a number that is not true, and `wrangler dev` alone
|
|
168
|
+
// opens nine.
|
|
169
|
+
const lis = measureListeners(repoRoot);
|
|
170
|
+
const listening = lis.rows;
|
|
171
|
+
const listeningTotal = lis.total;
|
|
166
172
|
const wt = worktrees ?? [];
|
|
167
173
|
const br = branches ?? [];
|
|
168
174
|
const sessionRows = br.filter((b) => b.session);
|
|
@@ -194,6 +200,7 @@ export function repoState(repoRoot, baseRef) {
|
|
|
194
200
|
? { sessionBranchesUnshipped: sessionRows.filter((b) => b.ahead > 0).length }
|
|
195
201
|
: {}),
|
|
196
202
|
listening,
|
|
203
|
+
listeningTotal,
|
|
197
204
|
// "Nothing is listening" and "this machine cannot look" (Windows, a failed
|
|
198
205
|
// scan) are the same empty array without this — and the second must never
|
|
199
206
|
// render as the first. Same field, same reason, as the session report.
|
package/bin/lib/work.mjs
CHANGED
|
@@ -39,8 +39,9 @@ import {
|
|
|
39
39
|
MODEL,
|
|
40
40
|
} from './config.mjs';
|
|
41
41
|
import { git, gitRaw, splitNul, baseBranchName, isSafePathSegment } from './git.mjs';
|
|
42
|
-
import { listenersIn, listenersSupported } from './listeners.mjs';
|
|
43
|
-
import {
|
|
42
|
+
import { listenersIn, measureListeners, listenersSupported } from './listeners.mjs';
|
|
43
|
+
import { measureProcesses, liveGroups, processesSupported } from './processes.mjs';
|
|
44
|
+
import { mutateRegistry, processAlive, readRegistry } from './procRegistry.mjs';
|
|
44
45
|
import { createPlaceLock } from './placeLock.mjs';
|
|
45
46
|
import { sweepMergedBranch } from './shipSweep.mjs';
|
|
46
47
|
import { mergeOutward as shipMergeOutward } from './shipMerge.mjs';
|
|
@@ -125,6 +126,8 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
|
|
|
125
126
|
const WORKTREES_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-worktrees');
|
|
126
127
|
const DIFF_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/diff-done');
|
|
127
128
|
const PREVIEW_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-claim');
|
|
129
|
+
const KILL_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/kill-done');
|
|
130
|
+
const KILL_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/kill-claim');
|
|
128
131
|
const PREVIEW_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-done');
|
|
129
132
|
const SESSION_COMMANDS_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-commands');
|
|
130
133
|
const ATTACHMENT_URL = FLEET_URL.replace(/\/agents\/?$/, '/attachment');
|
|
@@ -149,11 +152,65 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
|
|
|
149
152
|
*/
|
|
150
153
|
const sessionGroups = new Map(); // sessionId -> Set<pgid>
|
|
151
154
|
|
|
155
|
+
/**
|
|
156
|
+
* …AND IT SURVIVES A RESTART, which it did not until 2026-08-27.
|
|
157
|
+
*
|
|
158
|
+
* This map used to live only in this closure. The processes it tracks
|
|
159
|
+
* OUTLIVE the daemon on purpose — `shutdownWork` SIGTERMs the CLI child and
|
|
160
|
+
* never the group, precisely so an unattended auto-update does not kill the
|
|
161
|
+
* driver's dev server — so every daemon restart left a live watcher running
|
|
162
|
+
* with nothing left that knew whose it was. The tab reported `[]`, the web
|
|
163
|
+
* read that as "looked, found none", and the Running section went dark until
|
|
164
|
+
* some later turn happened to open a new group.
|
|
165
|
+
*
|
|
166
|
+
* That is not a small bug: AUTO_UPDATE is on by default, so it fired on every
|
|
167
|
+
* release, on every machine. And it broke the three-state rule this file
|
|
168
|
+
* states in its own header — the honest answer after a restart was "we have
|
|
169
|
+
* forgotten", and `[]` is not that. Persisting is what makes `[]` true again,
|
|
170
|
+
* which is why the fix is a disk write and not a fourth state.
|
|
171
|
+
*
|
|
172
|
+
* `procRegistry` is the right home and was sitting unused: it was built for
|
|
173
|
+
* exactly this ("the daemon spawns things that outlive it… the successor has
|
|
174
|
+
* to find them"), was orphaned when the dev-run system was deleted, and
|
|
175
|
+
* already does the atomic write, the stale-lock recovery, the entry cap and
|
|
176
|
+
* the dead-pid TTL. Its prune is deliberately LOOSE here — it keeps an entry
|
|
177
|
+
* whose leader is gone, because the leader is the CLI and it exits at the end
|
|
178
|
+
* of every turn while the watcher it started keeps running. `liveGroups` is
|
|
179
|
+
* the real prune, on every read, against the kernel.
|
|
180
|
+
*/
|
|
181
|
+
const GROUPS_DIR = join(homedir(), '.flowviant');
|
|
182
|
+
const GROUPS_FILE = join(GROUPS_DIR, 'session-groups.json');
|
|
183
|
+
const GROUPS_LOCK = join(GROUPS_DIR, 'session-groups.lock');
|
|
184
|
+
|
|
185
|
+
const persistGroups = () => {
|
|
186
|
+
const flat = [];
|
|
187
|
+
for (const [sid, set] of sessionGroups)
|
|
188
|
+
for (const pgid of set) flat.push({ sessionId: sid, pid: pgid, startedAt: Date.now() });
|
|
189
|
+
try {
|
|
190
|
+
mutateRegistry(GROUPS_DIR, GROUPS_FILE, GROUPS_LOCK, () => flat);
|
|
191
|
+
} catch {
|
|
192
|
+
/* best-effort: losing the file costs a restart's visibility, never a turn */
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
for (const e of readRegistry(GROUPS_FILE)) {
|
|
198
|
+
if (!e?.sessionId || !Number.isInteger(e?.pid)) continue;
|
|
199
|
+
const set = sessionGroups.get(e.sessionId) ?? new Set();
|
|
200
|
+
set.add(e.pid);
|
|
201
|
+
sessionGroups.set(e.sessionId, set);
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
/* no registry yet — the ordinary first run */
|
|
205
|
+
}
|
|
206
|
+
|
|
152
207
|
const noteSessionGroup = (sessionId, pgid) => {
|
|
153
208
|
if (!sessionId || !pgid) return;
|
|
154
209
|
const set = sessionGroups.get(sessionId) ?? new Set();
|
|
210
|
+
if (set.has(pgid)) return;
|
|
155
211
|
set.add(pgid);
|
|
156
212
|
sessionGroups.set(sessionId, set);
|
|
213
|
+
persistGroups();
|
|
157
214
|
};
|
|
158
215
|
|
|
159
216
|
/** This tab's live processes, or null where the machine cannot look. */
|
|
@@ -162,9 +219,24 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
|
|
|
162
219
|
const known = sessionGroups.get(sessionId);
|
|
163
220
|
if (!known || known.size === 0) return [];
|
|
164
221
|
const alive = liveGroups(known);
|
|
222
|
+
// Only touch the disk when the set actually MOVED. This runs on every
|
|
223
|
+
// sweep, for every live tab, forever; an unconditional write would be a
|
|
224
|
+
// file rewrite a minute for the life of the daemon to restate what is
|
|
225
|
+
// already there — the same reasoning the auto-name relay uses for its
|
|
226
|
+
// unchanged-title check.
|
|
227
|
+
const changed = alive.size !== known.size;
|
|
165
228
|
if (alive.size === 0) sessionGroups.delete(sessionId);
|
|
166
229
|
else sessionGroups.set(sessionId, alive);
|
|
167
|
-
|
|
230
|
+
if (changed) persistGroups();
|
|
231
|
+
return measureProcesses(alive, { scrub: envScrub });
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/** Every process group this machine is tracking, across every tab — what a
|
|
235
|
+
* kill request is checked against before anything is signalled. */
|
|
236
|
+
const allKnownGroups = () => {
|
|
237
|
+
const all = new Set();
|
|
238
|
+
for (const set of sessionGroups.values()) for (const g of set) all.add(g);
|
|
239
|
+
return all;
|
|
168
240
|
};
|
|
169
241
|
|
|
170
242
|
|
|
@@ -479,7 +551,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
|
|
|
479
551
|
// report on an endpoint that already exists, so NO version floor, and
|
|
480
552
|
// `processesSupported` keeps "cannot look" (Windows) apart from "looked and
|
|
481
553
|
// found none", which renders differently.
|
|
482
|
-
const
|
|
554
|
+
const proc = sessionProcesses(sessionId);
|
|
483
555
|
// THE NAME CLAUDE ALREADY GAVE THIS CONVERSATION, relayed.
|
|
484
556
|
//
|
|
485
557
|
// Claude Code titles its own sessions; a Flowviant tab was born "session 3"
|
|
@@ -503,12 +575,18 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
|
|
|
503
575
|
} catch {
|
|
504
576
|
/* no marker yet — this tab has not spoken, or is not Claude */
|
|
505
577
|
}
|
|
578
|
+
// The TOTAL rides beside the capped rows, because a list silently cut at
|
|
579
|
+
// twelve answers "what is running in here" with a number that is not true.
|
|
580
|
+
// `wrangler dev` alone opens nine, so the old cap of eight was already
|
|
581
|
+
// dropping a row on an ordinary stack with nothing on the wire to say so.
|
|
582
|
+
const lis = measureListeners(wt);
|
|
506
583
|
return {
|
|
507
584
|
sessionId,
|
|
508
585
|
...d,
|
|
509
|
-
listening:
|
|
586
|
+
listening: lis.rows,
|
|
587
|
+
listeningTotal: lis.total,
|
|
510
588
|
listeningSupported: listenersSupported(),
|
|
511
|
-
...(
|
|
589
|
+
...(proc === null ? {} : { processes: proc.rows, processesTotal: proc.total }),
|
|
512
590
|
processesSupported: processesSupported(),
|
|
513
591
|
...(title ? { title } : {}),
|
|
514
592
|
};
|
|
@@ -893,6 +971,211 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
|
|
|
893
971
|
livePreviews.clear();
|
|
894
972
|
};
|
|
895
973
|
|
|
974
|
+
// ── stopping one measured process ────────────────────────────────────────
|
|
975
|
+
//
|
|
976
|
+
// The driver points at a row the MACHINE reported and says stop. Flowviant
|
|
977
|
+
// never picks the target, never sweeps, never signals anything on its own
|
|
978
|
+
// initiative, and never signals a GROUP — teardown deliberately SIGTERMs the
|
|
979
|
+
// CLI child and not its group precisely so an unattended auto-update cannot
|
|
980
|
+
// take the driver's dev server with it, and a control that signalled groups
|
|
981
|
+
// would hand that outcome back one click at a time.
|
|
982
|
+
//
|
|
983
|
+
// WHY THIS EXISTS AT ALL, since "ask your Claude to kill it" looks like it
|
|
984
|
+
// already covers it. It does not, and it fails hardest in the case that
|
|
985
|
+
// motivates it: every tab one person owns shares ONE place, the cross-process
|
|
986
|
+
// turn lock is per-place and deliberately un-scoped, so while any other tab
|
|
987
|
+
// of yours is mid-turn a new turn does not spawn — it warns and waits. If the
|
|
988
|
+
// runaway process you want stopped is being held by a turn that is hung, the
|
|
989
|
+
// turn that would kill it never runs. (Two more: FLOWVIANT_SAFE=1 — which the
|
|
990
|
+
// README recommends on a shared box — has no kill, pkill, lsof or ss in its
|
|
991
|
+
// allowlist; and under the README's own top hardening tip, a daemon on its
|
|
992
|
+
// own OS user, the operator's dev server is EPERM to the agent.)
|
|
993
|
+
//
|
|
994
|
+
// A PID IS NOT AN IDENTITY, and this is the whole safety argument. Pids are
|
|
995
|
+
// recycled, the row the browser is looking at is up to a sweep old, and the
|
|
996
|
+
// instance lock already learned this the expensive way — its own comment says
|
|
997
|
+
// "a looser version of this check SIGTERMed one". So the pid the server sends
|
|
998
|
+
// is a REQUEST, never an authority: this re-derives attribution from the
|
|
999
|
+
// kernel immediately before signalling, and refuses unless the pid is STILL
|
|
1000
|
+
// in one of this tab's process groups or STILL holding a socket in this
|
|
1001
|
+
// tab's place. A recycled pid belonging to something else fails that, which
|
|
1002
|
+
// is the property a start-time witness would have bought at the cost of
|
|
1003
|
+
// another wire field.
|
|
1004
|
+
//
|
|
1005
|
+
// CLAIMED, not read, for the reason `processPreviewJobs` states: two daemons
|
|
1006
|
+
// legitimately share one credential and both are handed the same array.
|
|
1007
|
+
// Signalling twice is survivable; signalling twice with a recycle in between
|
|
1008
|
+
// is the failure this whole comment is about.
|
|
1009
|
+
const killing = new Set(); // job ids in flight on this tick
|
|
1010
|
+
|
|
1011
|
+
const postKill = async (body) => {
|
|
1012
|
+
try {
|
|
1013
|
+
await fetch(KILL_DONE_URL, {
|
|
1014
|
+
method: 'POST',
|
|
1015
|
+
headers: {
|
|
1016
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
1017
|
+
'User-Agent': USER_AGENT,
|
|
1018
|
+
'Content-Type': 'application/json',
|
|
1019
|
+
},
|
|
1020
|
+
signal: AbortSignal.timeout(30_000),
|
|
1021
|
+
body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
|
|
1022
|
+
});
|
|
1023
|
+
} catch {
|
|
1024
|
+
/* unsettled, and the server expires it — the asker is told, never spun */
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
|
|
1028
|
+
const claimKill = async (id) => {
|
|
1029
|
+
try {
|
|
1030
|
+
const res = await fetch(KILL_CLAIM_URL, {
|
|
1031
|
+
method: 'POST',
|
|
1032
|
+
headers: {
|
|
1033
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
1034
|
+
'User-Agent': USER_AGENT,
|
|
1035
|
+
'Content-Type': 'application/json',
|
|
1036
|
+
},
|
|
1037
|
+
signal: AbortSignal.timeout(15_000),
|
|
1038
|
+
body: JSON.stringify({ id, instance: DAEMON_INSTANCE }),
|
|
1039
|
+
});
|
|
1040
|
+
const j = await res.json().catch(() => null);
|
|
1041
|
+
return j?.data?.claimed === true;
|
|
1042
|
+
} catch {
|
|
1043
|
+
return false; // the peer may hold it; doing nothing is the safe answer
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Is this pid, RIGHT NOW, one of the things we told the browser about for
|
|
1049
|
+
* this session? Two lanes, matching the two lists a row can come from.
|
|
1050
|
+
*
|
|
1051
|
+
* Deliberately re-measured rather than read from anything cached: a cache is
|
|
1052
|
+
* exactly as old as the report the browser is acting on, and staleness is the
|
|
1053
|
+
* hazard.
|
|
1054
|
+
*/
|
|
1055
|
+
const killTargetOk = (sessionId, pid) => {
|
|
1056
|
+
const groups = sessionGroups.get(sessionId);
|
|
1057
|
+
if (groups && groups.size) {
|
|
1058
|
+
const alive = liveGroups(groups);
|
|
1059
|
+
const rows = measureProcesses(alive)?.rows ?? [];
|
|
1060
|
+
if (rows.some((r) => r.pid === pid)) return true;
|
|
1061
|
+
}
|
|
1062
|
+
const wt = placeDir(sessionId);
|
|
1063
|
+
if (wt) {
|
|
1064
|
+
try {
|
|
1065
|
+
if (measureListeners(wt).rows.some((r) => r.pid === pid)) return true;
|
|
1066
|
+
} catch {
|
|
1067
|
+
/* unmeasurable → not verified → refused, which is the safe direction */
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
return false;
|
|
1071
|
+
};
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* How long to watch for the process to actually go before answering.
|
|
1075
|
+
*
|
|
1076
|
+
* SIGTERM is a REQUEST, not an event: a dev server traps it and tears down
|
|
1077
|
+
* its children, which takes a beat. Answering the instant the signal returns
|
|
1078
|
+
* would report "signalled" over a process that is about to die, and the
|
|
1079
|
+
* surface would then offer Force stop on something already on its way out.
|
|
1080
|
+
*
|
|
1081
|
+
* Four seconds is long enough for the ordinary teardown and short enough that
|
|
1082
|
+
* a person is still looking at the row. Past it the honest answer is that the
|
|
1083
|
+
* signal landed and the thing is still there — which is a real state, and the
|
|
1084
|
+
* one where escalating actually means something.
|
|
1085
|
+
*/
|
|
1086
|
+
const KILL_GRACE_MS = 4000;
|
|
1087
|
+
|
|
1088
|
+
const waitForExit = async (pid) => {
|
|
1089
|
+
const until = Date.now() + KILL_GRACE_MS;
|
|
1090
|
+
while (Date.now() < until) {
|
|
1091
|
+
if (!processAlive(pid)) return true;
|
|
1092
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1093
|
+
}
|
|
1094
|
+
return !processAlive(pid);
|
|
1095
|
+
};
|
|
1096
|
+
|
|
1097
|
+
const runKill = async (job) => {
|
|
1098
|
+
const id = String(job.id);
|
|
1099
|
+
const sessionId = String(job.sessionId || '');
|
|
1100
|
+
const pid = Number(job.pid);
|
|
1101
|
+
const signal = job.signal === 'KILL' ? 'SIGKILL' : 'SIGTERM';
|
|
1102
|
+
|
|
1103
|
+
if (!processesSupported()) {
|
|
1104
|
+
await postKill({ id, outcome: 'unsupported' });
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
if (!killTargetOk(sessionId, pid)) {
|
|
1108
|
+
// Not a lie and not a failure: the process is genuinely no longer one of
|
|
1109
|
+
// this tab's, which is the common case when somebody clicks a row that
|
|
1110
|
+
// has since exited. The asker gets that sentence rather than a spinner —
|
|
1111
|
+
// and the RE-MEASURE below is what takes the stale row off their screen,
|
|
1112
|
+
// since a row you can click for something already gone is the readout
|
|
1113
|
+
// being behind, not the person being wrong.
|
|
1114
|
+
await postKill({ id, outcome: 'not_found' });
|
|
1115
|
+
await remeasureAfterKill(sessionId);
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
if (!(await claimKill(id))) return;
|
|
1119
|
+
try {
|
|
1120
|
+
process.kill(pid, signal);
|
|
1121
|
+
// WHAT HAPPENED, not what we did. "We sent a signal" is a fact about us;
|
|
1122
|
+
// "it stopped" is a fact about the machine, and the machine is standing
|
|
1123
|
+
// right here able to check. Reporting the weaker word would also make the
|
|
1124
|
+
// Force stop offer wrong for the whole window, since escalating only
|
|
1125
|
+
// means something while the process is genuinely still there.
|
|
1126
|
+
const gone = await waitForExit(pid);
|
|
1127
|
+
await postKill({ id, outcome: gone ? 'stopped' : 'signalled', signal });
|
|
1128
|
+
} catch (e) {
|
|
1129
|
+
// EPERM is the daemon-on-its-own-user posture doing exactly what it is
|
|
1130
|
+
// for. Report it as its own word: "we may not" and "it was gone" are
|
|
1131
|
+
// different sentences and the surface says which.
|
|
1132
|
+
await postKill({ id, outcome: e?.code === 'ESRCH' ? 'not_found' : 'error', detail: String(e?.code || e) });
|
|
1133
|
+
}
|
|
1134
|
+
await remeasureAfterKill(sessionId);
|
|
1135
|
+
};
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* THE LIST THE PERSON IS LOOKING AT WAS MEASURED BEFORE ANY OF THIS.
|
|
1139
|
+
*
|
|
1140
|
+
* Without this the row survives the thing it describes: the panel renders the
|
|
1141
|
+
* last sweep's `listening`, the sweep is on a SIXTY-SECOND beat, and the
|
|
1142
|
+
* reported outcome sits next to a port row still claiming to be live. The
|
|
1143
|
+
* first person to use it said exactly that — "i clicked stop on the listening
|
|
1144
|
+
* but its still running… then it finally disappears".
|
|
1145
|
+
*
|
|
1146
|
+
* The rule it was missing is one this file already keeps everywhere else: an
|
|
1147
|
+
* action that changes what the machine would measure must cause a new
|
|
1148
|
+
* measurement. A turn settling does it; a kill did not. `reportSessionWorktree`
|
|
1149
|
+
* is the un-throttled per-session path built for precisely this and it was
|
|
1150
|
+
* being called from exactly one place.
|
|
1151
|
+
*
|
|
1152
|
+
* Never awaited by the caller's answer path: the outcome is posted first, so
|
|
1153
|
+
* a slow re-measure can delay the list but never the sentence.
|
|
1154
|
+
*/
|
|
1155
|
+
const remeasureAfterKill = async (sessionId) => {
|
|
1156
|
+
try {
|
|
1157
|
+
await reportSessionWorktree(sessionId);
|
|
1158
|
+
} catch {
|
|
1159
|
+
/* the 60s sweep still carries it — this only makes it prompt */
|
|
1160
|
+
}
|
|
1161
|
+
};
|
|
1162
|
+
|
|
1163
|
+
const processKillJobs = (jobs) => {
|
|
1164
|
+
if (!Array.isArray(jobs) || jobs.length === 0) return;
|
|
1165
|
+
for (const job of jobs.slice(0, 5)) {
|
|
1166
|
+
const id = String(job?.id || '');
|
|
1167
|
+
const pid = Number(job?.pid);
|
|
1168
|
+
// Bounded at the boundary the same way sessionId and port already are.
|
|
1169
|
+
// pid 1 is init and is never something a tab started; a signal there
|
|
1170
|
+
// would ask the kernel to shut the box down.
|
|
1171
|
+
if (!id || killing.has(id)) continue;
|
|
1172
|
+
if (!Number.isInteger(pid) || pid <= 1 || pid > 4_294_967_295) continue;
|
|
1173
|
+
if (!isSafePathSegment(String(job?.sessionId || ''))) continue;
|
|
1174
|
+
killing.add(id);
|
|
1175
|
+
void runKill(job).finally(() => killing.delete(id));
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
|
|
896
1179
|
const processDiffJobs = (jobs) => {
|
|
897
1180
|
if (!Array.isArray(jobs) || jobs.length === 0) return;
|
|
898
1181
|
for (const job of jobs.slice(0, 5)) {
|
|
@@ -2641,6 +2924,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
|
|
|
2641
2924
|
processWorkTurns,
|
|
2642
2925
|
processShipJobs,
|
|
2643
2926
|
processDiffJobs,
|
|
2927
|
+
processKillJobs,
|
|
2644
2928
|
heldSessionIds,
|
|
2645
2929
|
processPreviewJobs,
|
|
2646
2930
|
livePreviewIds,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.71.1",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant — Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|