@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.
@@ -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. (The listener requirement needs pgid probing,
130
- * absent on win32, where `expectServer` collapses to the survival rule — best-effort,
131
- * never a false FAIL on a platform we cannot probe.)
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
  */
@@ -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
- /** Listening TCP sockets as {pid, port} pairs (best-effort; ss first, then lsof).
228
- * Empty on any failure — the caller then cannot attribute a listener to our group
229
- * and the served-app check degrades to survival (never a false FAIL). */
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
- try {
233
- const t = spawnSync('ss', ['-tlnpH'], { encoding: 'utf8', timeout: 4000 });
234
- if (!t.error && t.stdout) {
235
- for (const line of t.stdout.split('\n')) {
236
- const pm = /pid=(\d+)/.exec(line);
237
- if (!pm)
238
- continue;
239
- // Column 4 (0-based 3) is the local address; the port is its last
240
- // `:`-suffixed number ("0.0.0.0:3000", "[::]:3000").
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
- }
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
- catch {
250
- // ss missing — try lsof
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
- if (out.length === 0) {
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 t = spawnSync('lsof', ['-iTCP', '-sTCP:LISTEN', '-n', '-P'], {
255
- encoding: 'utf8',
256
- timeout: 4000
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
- // neither tool available
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. (The listener requirement needs pgid probing,
371
- * absent on win32, where `expectServer` collapses to the survival rule — best-effort,
372
- * never a false FAIL on a platform we cannot probe.)
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: { ...process.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
- if (!groupHasListener(child.pid))
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 = (opts.deps?.groupListeningPort ?? defaultGroupListeningPort)(child.pid);
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.35",
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",