@mjasnikovs/pi-task 0.18.35 → 0.18.36
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/dist/task/final-gate.d.ts +69 -3
- package/dist/task/final-gate.js +204 -45
- package/package.json +1 -1
|
@@ -103,6 +103,20 @@ export interface BootDeps {
|
|
|
103
103
|
* the gate wires runRenderCheck by default for served apps.
|
|
104
104
|
*/
|
|
105
105
|
renderProbe?: (url: string) => RenderOutcome;
|
|
106
|
+
/**
|
|
107
|
+
* Can this box enumerate listeners with pids AT ALL (ss/netstat/lsof)? False
|
|
108
|
+
* means the served-app requirement is UNOBSERVABLE here and must degrade to the
|
|
109
|
+
* survival rule rather than fail — see canEnumerateListeners.
|
|
110
|
+
*/
|
|
111
|
+
enumerationCapable?: () => boolean;
|
|
112
|
+
/**
|
|
113
|
+
* Reserve a free port to hand the boot child as PORT, so an HTTP answer on it is
|
|
114
|
+
* ownership evidence. null → no port could be reserved (the check then relies on
|
|
115
|
+
* pgid attribution alone). Injected for tests.
|
|
116
|
+
*/
|
|
117
|
+
pickPort?: () => Promise<number | null>;
|
|
118
|
+
/** Does anything answer HTTP on 127.0.0.1:`port`? Injected for tests. */
|
|
119
|
+
httpProbe?: (port: number) => boolean;
|
|
106
120
|
}
|
|
107
121
|
/**
|
|
108
122
|
* Does the finished run stand up a listening HTTP server? Deterministic, from the
|
|
@@ -111,6 +125,39 @@ export interface BootDeps {
|
|
|
111
125
|
* LISTENER (served app) or may pass on mere survival / quick exit (CLI project).
|
|
112
126
|
*/
|
|
113
127
|
export declare function detectsServedApp(cwd: string, planText?: string): boolean;
|
|
128
|
+
/** `ss -tlnpH` rows → {pid, port}. Column 4 (0-based 3) is the local address; the
|
|
129
|
+
* port is its last `:`-suffixed number ("0.0.0.0:3000", "[::]:3000"). */
|
|
130
|
+
export declare function parseSsListeners(stdout: string): Array<{
|
|
131
|
+
pid: number;
|
|
132
|
+
port: number;
|
|
133
|
+
}>;
|
|
134
|
+
/**
|
|
135
|
+
* `netstat -tlnp` rows → {pid, port} (mx5 run 14, validated: the agent-sandbox
|
|
136
|
+
* image ships NEITHER ss NOR lsof — only ps and netstat — so the served-app boot
|
|
137
|
+
* check could never observe a listener and failed unfalsifiably). The pid rides
|
|
138
|
+
* in the trailing "PID/Program name" column ("1234/bun"); rows the kernel will
|
|
139
|
+
* not attribute to us print "-" there and are skipped.
|
|
140
|
+
*/
|
|
141
|
+
export declare function parseNetstatListeners(stdout: string): Array<{
|
|
142
|
+
pid: number;
|
|
143
|
+
port: number;
|
|
144
|
+
}>;
|
|
145
|
+
/** `lsof -iTCP -sTCP:LISTEN -n -P` rows → {pid, port}. */
|
|
146
|
+
export declare function parseLsofListeners(stdout: string): Array<{
|
|
147
|
+
pid: number;
|
|
148
|
+
port: number;
|
|
149
|
+
}>;
|
|
150
|
+
export declare function canEnumerateListeners(): boolean;
|
|
151
|
+
/** Test seam: forget the memoised capability answer. */
|
|
152
|
+
export declare function resetListenerToolCapability(): void;
|
|
153
|
+
/**
|
|
154
|
+
* A free TCP port on the loopback interface, or null if one cannot be reserved.
|
|
155
|
+
* The boot check hands this to the child as PORT so that a successful HTTP
|
|
156
|
+
* request to it is OWNERSHIP evidence: nobody else knows the number (mx5 runs
|
|
157
|
+
* 8/10/11 — orphaned servers from earlier checks answered curl on the
|
|
158
|
+
* conventional :3000 and passed checks the app had not earned).
|
|
159
|
+
*/
|
|
160
|
+
export declare function pickFreePort(): Promise<number | null>;
|
|
114
161
|
/**
|
|
115
162
|
* Exercise the start command ONCE. For a CLI project (`expectServer` false) the
|
|
116
163
|
* command's own fate within the grace window decides:
|
|
@@ -126,9 +173,28 @@ export declare function detectsServedApp(cwd: string, planText?: string): boolea
|
|
|
126
173
|
* served nothing (mx5 run 10 — both were blessed by the survival rule). The boot then
|
|
127
174
|
* PASSes only once a LISTENing socket owned by our process group is observed; if the
|
|
128
175
|
* command exits, or the grace window closes, with no listener ever seen → FAIL naming
|
|
129
|
-
* that a listening server was expected.
|
|
130
|
-
*
|
|
131
|
-
*
|
|
176
|
+
* that a listening server was expected.
|
|
177
|
+
*
|
|
178
|
+
* OBSERVABILITY is a precondition of that FAIL (mx5 run 14, validated). The listener
|
|
179
|
+
* requirement needs pgid-attributed socket enumeration; win32 has none, and neither
|
|
180
|
+
* does a Linux image shipping no ss/netstat/lsof — run 14's sandbox was exactly that,
|
|
181
|
+
* so the check emitted "never opened a listening socket" against an app that
|
|
182
|
+
* demonstrably served, three autofix passes could not falsify it, and the run was
|
|
183
|
+
* recorded failed. Two defences, in order:
|
|
184
|
+
*
|
|
185
|
+
* - the child is spawned with a freshly reserved, otherwise-unused PORT, and an
|
|
186
|
+
* HTTP answer on THAT port proves a listener regardless of tooling. The private
|
|
187
|
+
* port is what makes the HTTP probe trustworthy: an orphaned server from an
|
|
188
|
+
* earlier check answers on :3000, but nobody else knows this number.
|
|
189
|
+
* - if nothing can enumerate listeners AND the assigned port never answered, the
|
|
190
|
+
* served-app requirement is unobservable here, so `expectServer` collapses to
|
|
191
|
+
* the survival rule and the PASS is stamped UNOBSERVED. An app that ignores PORT
|
|
192
|
+
* is indistinguishable from one that never listened — an observer limitation,
|
|
193
|
+
* not an app defect, and it may not be reported as one.
|
|
194
|
+
*
|
|
195
|
+
* A child that EXITS non-zero still FAILs in every environment: "the process died"
|
|
196
|
+
* needs no socket probe, so run 14's original true positive (a `--hot` runtime
|
|
197
|
+
* pinning a crashed app) stays reportable wherever the tooling exists.
|
|
132
198
|
*
|
|
133
199
|
* Env-gap contract as everywhere: spawn error (ENOENT) or exit 127 → skip.
|
|
134
200
|
*/
|
package/dist/task/final-gate.js
CHANGED
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
*/
|
|
41
41
|
import { spawn, spawnSync } from 'node:child_process';
|
|
42
42
|
import { existsSync, readFileSync } from 'node:fs';
|
|
43
|
+
import * as net from 'node:net';
|
|
43
44
|
import * as path from 'node:path';
|
|
44
45
|
import { runRepoHealthCheck, discoverHealthCommands } from './repo-health-check.js';
|
|
45
46
|
import { readAcceptDebts, recheckAcceptDebts, writeAcceptDebts, buildAcceptDebtNote, annotateDebtConflicts } from './accept-debt.js';
|
|
@@ -198,6 +199,11 @@ function extractPort(text) {
|
|
|
198
199
|
const n = Number(m[1]);
|
|
199
200
|
return n > 0 && n < 65536 ? n : null;
|
|
200
201
|
}
|
|
202
|
+
/** Stamped on a PASS the boot check could not actually observe, so the trail says
|
|
203
|
+
* so out loud instead of implying the listener requirement was met. */
|
|
204
|
+
const UNOBSERVED_LISTENER_NOTE = 'listener check UNOBSERVED: no socket-enumeration tool (ss/netstat/lsof) in this '
|
|
205
|
+
+ 'environment and the app never answered on the port it was given — passed on the '
|
|
206
|
+
+ 'survival rule (the process stayed up), NOT on observed serving';
|
|
201
207
|
/** Package deps that mean "this project stands up an HTTP server" — the deterministic
|
|
202
208
|
* proxy for "the plan/spec promised a served app". Bare framework names plus the
|
|
203
209
|
* scoped families whose presence implies a listener at runtime. */
|
|
@@ -224,54 +230,163 @@ export function detectsServedApp(cwd, planText) {
|
|
|
224
230
|
}
|
|
225
231
|
return planText !== undefined && SERVE_TEXT_RE.test(planText);
|
|
226
232
|
}
|
|
227
|
-
/**
|
|
228
|
-
*
|
|
229
|
-
|
|
230
|
-
function listeningSockets() {
|
|
233
|
+
/** `ss -tlnpH` rows → {pid, port}. Column 4 (0-based 3) is the local address; the
|
|
234
|
+
* port is its last `:`-suffixed number ("0.0.0.0:3000", "[::]:3000"). */
|
|
235
|
+
export function parseSsListeners(stdout) {
|
|
231
236
|
const out = [];
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
if (!
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
237
|
+
for (const line of stdout.split('\n')) {
|
|
238
|
+
const pm = /pid=(\d+)/.exec(line);
|
|
239
|
+
if (!pm)
|
|
240
|
+
continue;
|
|
241
|
+
const local = line.trim().split(/\s+/)[3] ?? '';
|
|
242
|
+
const portm = /:(\d+)$/.exec(local);
|
|
243
|
+
if (!portm)
|
|
244
|
+
continue;
|
|
245
|
+
out.push({ pid: Number(pm[1]), port: Number(portm[1]) });
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* `netstat -tlnp` rows → {pid, port} (mx5 run 14, validated: the agent-sandbox
|
|
251
|
+
* image ships NEITHER ss NOR lsof — only ps and netstat — so the served-app boot
|
|
252
|
+
* check could never observe a listener and failed unfalsifiably). The pid rides
|
|
253
|
+
* in the trailing "PID/Program name" column ("1234/bun"); rows the kernel will
|
|
254
|
+
* not attribute to us print "-" there and are skipped.
|
|
255
|
+
*/
|
|
256
|
+
export function parseNetstatListeners(stdout) {
|
|
257
|
+
const out = [];
|
|
258
|
+
for (const line of stdout.split('\n')) {
|
|
259
|
+
if (!/^\s*tcp/i.test(line))
|
|
260
|
+
continue;
|
|
261
|
+
const cols = line.trim().split(/\s+/);
|
|
262
|
+
const local = cols[3] ?? '';
|
|
263
|
+
const portm = /:(\d+)$/.exec(local);
|
|
264
|
+
if (!portm)
|
|
265
|
+
continue;
|
|
266
|
+
const pidm = /^(\d+)\//.exec(cols[cols.length - 1] ?? '');
|
|
267
|
+
if (!pidm)
|
|
268
|
+
continue;
|
|
269
|
+
out.push({ pid: Number(pidm[1]), port: Number(portm[1]) });
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
/** `lsof -iTCP -sTCP:LISTEN -n -P` rows → {pid, port}. */
|
|
274
|
+
export function parseLsofListeners(stdout) {
|
|
275
|
+
const out = [];
|
|
276
|
+
for (const line of stdout.split('\n').slice(1)) {
|
|
277
|
+
const cols = line.trim().split(/\s+/);
|
|
278
|
+
const pid = Number(cols[1]);
|
|
279
|
+
const name = cols.find(c => /:\d+$/.test(c)) ?? '';
|
|
280
|
+
const portm = /:(\d+)$/.exec(name);
|
|
281
|
+
if (Number.isInteger(pid) && pid > 0 && portm) {
|
|
282
|
+
out.push({ pid, port: Number(portm[1]) });
|
|
247
283
|
}
|
|
248
284
|
}
|
|
249
|
-
|
|
250
|
-
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
/** The socket-enumeration tools we can attribute listeners with, in preference
|
|
288
|
+
* order: ss (richest), netstat (present where ss is not), lsof (BSD/macOS). */
|
|
289
|
+
const LISTENER_TOOLS = [
|
|
290
|
+
{ bin: 'ss', args: ['-tlnpH'], parse: parseSsListeners },
|
|
291
|
+
{ bin: 'netstat', args: ['-tlnp'], parse: parseNetstatListeners },
|
|
292
|
+
{ bin: 'lsof', args: ['-iTCP', '-sTCP:LISTEN', '-n', '-P'], parse: parseLsofListeners }
|
|
293
|
+
];
|
|
294
|
+
/** Listening TCP sockets as {pid, port} pairs (best-effort; ss, then netstat, then
|
|
295
|
+
* lsof). Empty on any failure — the caller then cannot attribute a listener to our
|
|
296
|
+
* group and the served-app check degrades to survival (never a false FAIL). */
|
|
297
|
+
function listeningSockets() {
|
|
298
|
+
for (const { bin, args, parse } of LISTENER_TOOLS) {
|
|
299
|
+
try {
|
|
300
|
+
const t = spawnSync(bin, args, { encoding: 'utf8', timeout: 4000 });
|
|
301
|
+
if (t.error || !t.stdout)
|
|
302
|
+
continue;
|
|
303
|
+
const rows = parse(t.stdout);
|
|
304
|
+
if (rows.length > 0)
|
|
305
|
+
return rows;
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
// tool missing/unusable — try the next one
|
|
309
|
+
}
|
|
251
310
|
}
|
|
252
|
-
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Can ANY socket-enumeration tool run here at all? (mx5 run 14: the sandbox had
|
|
315
|
+
* none, so `groupHasListener` returned false forever and the boot check emitted
|
|
316
|
+
* "never opened a listening socket" no matter what the app did — an unfalsifiable
|
|
317
|
+
* FAIL that failed a run whose app demonstrably served.) This is a CAPABILITY
|
|
318
|
+
* question, deliberately separate from "did we see a listener": a tool that ran
|
|
319
|
+
* and found nothing is an observation; no tool at all is blindness, and blindness
|
|
320
|
+
* must degrade to the survival rule exactly like win32 — never a false FAIL on a
|
|
321
|
+
* platform we cannot probe.
|
|
322
|
+
*
|
|
323
|
+
* "Ran" = spawned without ENOENT and either exited 0 or printed something (lsof
|
|
324
|
+
* exits 1 on an empty match set; a netstat that rejects `-p` prints nothing).
|
|
325
|
+
* Memoised: the answer is a property of the box, not of the run.
|
|
326
|
+
*/
|
|
327
|
+
let listenerToolCapability = null;
|
|
328
|
+
export function canEnumerateListeners() {
|
|
329
|
+
if (listenerToolCapability !== null)
|
|
330
|
+
return listenerToolCapability;
|
|
331
|
+
listenerToolCapability = LISTENER_TOOLS.some(({ bin, args }) => {
|
|
253
332
|
try {
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
333
|
+
const r = spawnSync(bin, args, { encoding: 'utf8', timeout: 4000 });
|
|
334
|
+
if (r.error)
|
|
335
|
+
return false;
|
|
336
|
+
return r.status === 0 || (r.stdout ?? '').trim().length > 0;
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
return listenerToolCapability;
|
|
343
|
+
}
|
|
344
|
+
/** Test seam: forget the memoised capability answer. */
|
|
345
|
+
export function resetListenerToolCapability() {
|
|
346
|
+
listenerToolCapability = null;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* A free TCP port on the loopback interface, or null if one cannot be reserved.
|
|
350
|
+
* The boot check hands this to the child as PORT so that a successful HTTP
|
|
351
|
+
* request to it is OWNERSHIP evidence: nobody else knows the number (mx5 runs
|
|
352
|
+
* 8/10/11 — orphaned servers from earlier checks answered curl on the
|
|
353
|
+
* conventional :3000 and passed checks the app had not earned).
|
|
354
|
+
*/
|
|
355
|
+
export function pickFreePort() {
|
|
356
|
+
return new Promise(resolve => {
|
|
357
|
+
try {
|
|
358
|
+
const srv = net.createServer();
|
|
359
|
+
srv.once('error', () => resolve(null));
|
|
360
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
361
|
+
const a = srv.address();
|
|
362
|
+
const port = typeof a === 'object' && a !== null ? a.port : null;
|
|
363
|
+
srv.close(() => resolve(port));
|
|
257
364
|
});
|
|
258
|
-
if (!t.error && t.stdout) {
|
|
259
|
-
for (const line of t.stdout.split('\n').slice(1)) {
|
|
260
|
-
const cols = line.trim().split(/\s+/);
|
|
261
|
-
const pid = Number(cols[1]);
|
|
262
|
-
const name = cols.find(c => /:\d+$/.test(c)) ?? '';
|
|
263
|
-
const portm = /:(\d+)$/.exec(name);
|
|
264
|
-
if (Number.isInteger(pid) && pid > 0 && portm) {
|
|
265
|
-
out.push({ pid, port: Number(portm[1]) });
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
365
|
}
|
|
270
366
|
catch {
|
|
271
|
-
|
|
367
|
+
resolve(null);
|
|
272
368
|
}
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Does anything answer HTTP on 127.0.0.1:`port`? Any response at all (404, 500 —
|
|
373
|
+
* a status is a listener) counts; only a connection error or timeout is a no.
|
|
374
|
+
* Runs in a throwaway child of our own runtime so it needs no curl on PATH and
|
|
375
|
+
* stays synchronous inside the boot poll.
|
|
376
|
+
*/
|
|
377
|
+
function defaultHttpProbe(port) {
|
|
378
|
+
const script = `fetch('http://127.0.0.1:${port}/').then(()=>process.exit(0),()=>process.exit(1));`
|
|
379
|
+
+ `setTimeout(()=>process.exit(1),2000)`;
|
|
380
|
+
try {
|
|
381
|
+
const r = spawnSync(process.execPath, ['-e', script], {
|
|
382
|
+
encoding: 'utf8',
|
|
383
|
+
timeout: 5000
|
|
384
|
+
});
|
|
385
|
+
return !r.error && r.status === 0;
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return false;
|
|
273
389
|
}
|
|
274
|
-
return out;
|
|
275
390
|
}
|
|
276
391
|
/** Process-group id of `pid`, or null if it cannot be read. */
|
|
277
392
|
function pgidOf(pid) {
|
|
@@ -367,21 +482,48 @@ function holderIsOurs(command, boot) {
|
|
|
367
482
|
* served nothing (mx5 run 10 — both were blessed by the survival rule). The boot then
|
|
368
483
|
* PASSes only once a LISTENing socket owned by our process group is observed; if the
|
|
369
484
|
* command exits, or the grace window closes, with no listener ever seen → FAIL naming
|
|
370
|
-
* that a listening server was expected.
|
|
371
|
-
*
|
|
372
|
-
*
|
|
485
|
+
* that a listening server was expected.
|
|
486
|
+
*
|
|
487
|
+
* OBSERVABILITY is a precondition of that FAIL (mx5 run 14, validated). The listener
|
|
488
|
+
* requirement needs pgid-attributed socket enumeration; win32 has none, and neither
|
|
489
|
+
* does a Linux image shipping no ss/netstat/lsof — run 14's sandbox was exactly that,
|
|
490
|
+
* so the check emitted "never opened a listening socket" against an app that
|
|
491
|
+
* demonstrably served, three autofix passes could not falsify it, and the run was
|
|
492
|
+
* recorded failed. Two defences, in order:
|
|
493
|
+
*
|
|
494
|
+
* - the child is spawned with a freshly reserved, otherwise-unused PORT, and an
|
|
495
|
+
* HTTP answer on THAT port proves a listener regardless of tooling. The private
|
|
496
|
+
* port is what makes the HTTP probe trustworthy: an orphaned server from an
|
|
497
|
+
* earlier check answers on :3000, but nobody else knows this number.
|
|
498
|
+
* - if nothing can enumerate listeners AND the assigned port never answered, the
|
|
499
|
+
* served-app requirement is unobservable here, so `expectServer` collapses to
|
|
500
|
+
* the survival rule and the PASS is stamped UNOBSERVED. An app that ignores PORT
|
|
501
|
+
* is indistinguishable from one that never listened — an observer limitation,
|
|
502
|
+
* not an app defect, and it may not be reported as one.
|
|
503
|
+
*
|
|
504
|
+
* A child that EXITS non-zero still FAILs in every environment: "the process died"
|
|
505
|
+
* needs no socket probe, so run 14's original true positive (a `--hot` runtime
|
|
506
|
+
* pinning a crashed app) stays reportable wherever the tooling exists.
|
|
373
507
|
*
|
|
374
508
|
* Env-gap contract as everywhere: spawn error (ENOENT) or exit 127 → skip.
|
|
375
509
|
*/
|
|
376
|
-
export function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}) {
|
|
510
|
+
export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}) {
|
|
377
511
|
const expectServer = (opts.expectServer ?? false) && process.platform !== 'win32';
|
|
378
512
|
const groupHasListener = opts.deps?.groupHasListener ?? defaultGroupHasListener;
|
|
513
|
+
const httpProbe = opts.deps?.httpProbe ?? defaultHttpProbe;
|
|
514
|
+
const canEnumerate = expectServer ? (opts.deps?.enumerationCapable ?? canEnumerateListeners)() : true;
|
|
515
|
+
// Only served apps get an assigned port: a CLI project has nothing to bind, and
|
|
516
|
+
// an unexpected PORT in its env is noise.
|
|
517
|
+
const assignedPort = expectServer ? await (opts.deps?.pickPort ?? pickFreePort)() : null;
|
|
379
518
|
return new Promise(resolve => {
|
|
380
519
|
const child = spawn(bin, args, {
|
|
381
520
|
cwd,
|
|
382
521
|
detached: true,
|
|
383
522
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
384
|
-
env: {
|
|
523
|
+
env: {
|
|
524
|
+
...process.env,
|
|
525
|
+
...(assignedPort !== null ? { PORT: String(assignedPort) } : {})
|
|
526
|
+
}
|
|
385
527
|
});
|
|
386
528
|
// Best-effort cleanup only: killGroup below can silently fail to reap the
|
|
387
529
|
// process (platform/sandbox-specific — observed on a GH Actions Linux
|
|
@@ -443,13 +585,21 @@ export function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}) {
|
|
|
443
585
|
setInterval(() => {
|
|
444
586
|
if (settled || !child.pid)
|
|
445
587
|
return;
|
|
446
|
-
|
|
588
|
+
// pgid attribution first (precise, cheap). If it saw nothing — or
|
|
589
|
+
// cannot see anything here — fall back to the private assigned
|
|
590
|
+
// port: an HTTP answer on a number only this child was told is
|
|
591
|
+
// proof of OUR listener, not of some orphan on :3000.
|
|
592
|
+
const byGroup = canEnumerate && groupHasListener(child.pid);
|
|
593
|
+
const byPort = !byGroup && assignedPort !== null && httpProbe(assignedPort);
|
|
594
|
+
if (!byGroup && !byPort)
|
|
447
595
|
return;
|
|
448
596
|
listenerSeen = true;
|
|
449
597
|
const probe = opts.deps?.renderProbe;
|
|
450
598
|
if (!probe)
|
|
451
599
|
return passAndKill();
|
|
452
|
-
const port =
|
|
600
|
+
const port = byGroup ?
|
|
601
|
+
(opts.deps?.groupListeningPort ?? defaultGroupListeningPort)(child.pid)
|
|
602
|
+
: assignedPort;
|
|
453
603
|
if (port === null) {
|
|
454
604
|
return passAndKill('render check UNOBSERVED: a listener was seen but its port could not be determined');
|
|
455
605
|
}
|
|
@@ -462,6 +612,12 @@ export function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}) {
|
|
|
462
612
|
: null;
|
|
463
613
|
const timer = setTimeout(() => {
|
|
464
614
|
if (expectServer && !listenerSeen) {
|
|
615
|
+
// Blind here (no enumeration tool, and the assigned port never
|
|
616
|
+
// answered) ⇒ we cannot tell "never listened" from "ignores PORT".
|
|
617
|
+
// Survival rule, stamped UNOBSERVED — an observer limitation is not
|
|
618
|
+
// an app defect (mx5 run 14).
|
|
619
|
+
if (!canEnumerate)
|
|
620
|
+
return passAndKill(UNOBSERVED_LISTENER_NOTE);
|
|
465
621
|
settle({
|
|
466
622
|
outcome: 'fail',
|
|
467
623
|
detail: `still running after ${graceMs}ms but never opened a listening socket — the spec/dependencies promise an HTTP server`
|
|
@@ -476,6 +632,9 @@ export function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}) {
|
|
|
476
632
|
child.on('exit', (status, signal) => {
|
|
477
633
|
if (status === 0) {
|
|
478
634
|
if (expectServer && !listenerSeen) {
|
|
635
|
+
if (!canEnumerate) {
|
|
636
|
+
return settle({ outcome: 'pass', renderNote: UNOBSERVED_LISTENER_NOTE });
|
|
637
|
+
}
|
|
479
638
|
return settle({
|
|
480
639
|
outcome: 'fail',
|
|
481
640
|
detail: 'exited 0 without ever opening a listening socket — the spec/dependencies '
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.36",
|
|
4
4
|
"description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|