@mjasnikovs/pi-task 0.18.35 → 0.18.37
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/accept-debt.d.ts +17 -1
- package/dist/task/accept-debt.js +20 -1
- package/dist/task/auto-orchestrator.js +116 -28
- package/dist/task/final-gate-fix.d.ts +18 -2
- package/dist/task/final-gate-fix.js +19 -6
- package/dist/task/final-gate-progress.d.ts +67 -0
- package/dist/task/final-gate-progress.js +106 -0
- package/dist/task/final-gate.d.ts +69 -3
- package/dist/task/final-gate.js +204 -45
- package/package.json +1 -1
|
@@ -22,8 +22,15 @@
|
|
|
22
22
|
* deletion ships in the next commit. Recorded so the final gate re-checks it:
|
|
23
23
|
* resolved iff the named file is back in the tree (a later task restored it),
|
|
24
24
|
* otherwise surfaced.
|
|
25
|
+
* - 'final-gate' — the final integration gate DEMOTED one of its own checks to
|
|
26
|
+
* UNOBSERVED (mx5 run 14): two fix attempts that both changed the tree and
|
|
27
|
+
* re-ran the gate produced a byte-identical ranked-first failure, so the check
|
|
28
|
+
* is unfalsifiable in this environment (run 14: the boot probe needed `ss` or
|
|
29
|
+
* `lsof` and the sandbox had neither) and no further attempt can move it. The
|
|
30
|
+
* run is allowed to converge on the REMAINING checks, carrying this one here
|
|
31
|
+
* so the next run's gate re-checks and re-surfaces it rather than losing it.
|
|
25
32
|
*/
|
|
26
|
-
export type DebtOrigin = 'accepted' | 'enforce-revert' | 'frozen-blocked' | 'cross-task-deletion' | 'yolo-accepted';
|
|
33
|
+
export type DebtOrigin = 'accepted' | 'enforce-revert' | 'frozen-blocked' | 'cross-task-deletion' | 'yolo-accepted' | 'final-gate';
|
|
27
34
|
/** One recorded defect: the task, why its VERIFY failed, and how it was recorded. */
|
|
28
35
|
export interface AcceptDebt {
|
|
29
36
|
taskId: string;
|
|
@@ -89,6 +96,15 @@ export declare function recordCrossTaskDeletionDebt(cwd: string, taskId: string,
|
|
|
89
96
|
* reading only the artifacts can never read it as "a human decided this".
|
|
90
97
|
*/
|
|
91
98
|
export declare function recordYoloAcceptDebt(cwd: string, taskId: string, reason: string): Promise<void>;
|
|
99
|
+
/**
|
|
100
|
+
* Record a FINAL-GATE UNOBSERVED debt (mx5 run 14): the final gate demoted one of its
|
|
101
|
+
* OWN checks after two tree-changing fix attempts returned an identical ranked-first
|
|
102
|
+
* failure — unfalsifiable in this environment, so the gate stopped paying for it and
|
|
103
|
+
* converged on the remaining checks. Durable so the NEXT run's gate re-checks it: it
|
|
104
|
+
* is model-/environment-judged, so the re-check surfaces it (never auto-closes it).
|
|
105
|
+
* The taskId is the run's parent id — the demotion is a run-level decision.
|
|
106
|
+
*/
|
|
107
|
+
export declare function recordFinalGateUnobservedDebt(cwd: string, taskId: string, reason: string): Promise<void>;
|
|
92
108
|
/**
|
|
93
109
|
* The deleted path a cross-task-deletion debt names (the fixed shape
|
|
94
110
|
* recordCrossTaskDeletionDebt writes). Null on any other reason text — an
|
package/dist/task/accept-debt.js
CHANGED
|
@@ -77,7 +77,8 @@ export function parseAcceptDebts(raw) {
|
|
|
77
77
|
...((origin === 'enforce-revert'
|
|
78
78
|
|| origin === 'frozen-blocked'
|
|
79
79
|
|| origin === 'cross-task-deletion'
|
|
80
|
-
|| origin === 'yolo-accepted'
|
|
80
|
+
|| origin === 'yolo-accepted'
|
|
81
|
+
|| origin === 'final-gate') ?
|
|
81
82
|
{ origin: origin }
|
|
82
83
|
: {})
|
|
83
84
|
});
|
|
@@ -187,6 +188,21 @@ export async function recordYoloAcceptDebt(cwd, taskId, reason) {
|
|
|
187
188
|
origin: 'yolo-accepted'
|
|
188
189
|
});
|
|
189
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* Record a FINAL-GATE UNOBSERVED debt (mx5 run 14): the final gate demoted one of its
|
|
193
|
+
* OWN checks after two tree-changing fix attempts returned an identical ranked-first
|
|
194
|
+
* failure — unfalsifiable in this environment, so the gate stopped paying for it and
|
|
195
|
+
* converged on the remaining checks. Durable so the NEXT run's gate re-checks it: it
|
|
196
|
+
* is model-/environment-judged, so the re-check surfaces it (never auto-closes it).
|
|
197
|
+
* The taskId is the run's parent id — the demotion is a run-level decision.
|
|
198
|
+
*/
|
|
199
|
+
export async function recordFinalGateUnobservedDebt(cwd, taskId, reason) {
|
|
200
|
+
await appendDebt(cwd, {
|
|
201
|
+
taskId: taskId.trim(),
|
|
202
|
+
reason: normaliseReason(reason),
|
|
203
|
+
origin: 'final-gate'
|
|
204
|
+
});
|
|
205
|
+
}
|
|
190
206
|
/**
|
|
191
207
|
* The deleted path a cross-task-deletion debt names (the fixed shape
|
|
192
208
|
* recordCrossTaskDeletionDebt writes). Null on any other reason text — an
|
|
@@ -341,5 +357,8 @@ export function describeDebt(d) {
|
|
|
341
357
|
if (d.origin === 'yolo-accepted') {
|
|
342
358
|
return 'auto-ACCEPTED by YOLO mode despite verify-FAIL (unattended — no human weighed this)';
|
|
343
359
|
}
|
|
360
|
+
if (d.origin === 'final-gate') {
|
|
361
|
+
return 'final-gate check DEMOTED to UNOBSERVED (identical failure across two tree-changing fix attempts — unfalsifiable in that environment, never proven passing)';
|
|
362
|
+
}
|
|
344
363
|
return 'accepted despite verify-FAIL';
|
|
345
364
|
}
|
|
@@ -30,7 +30,8 @@ import { buildGateDeps, collectTreeChanges } from './gate-deps.js';
|
|
|
30
30
|
import { runGatesForTask } from './task-gates.js';
|
|
31
31
|
import { gitUnmergedPaths, gitStashRef } from './auto-commit.js';
|
|
32
32
|
import { runFinalIntegrationGate } from './final-gate.js';
|
|
33
|
-
import { describeDebt } from './accept-debt.js';
|
|
33
|
+
import { describeDebt, recordFinalGateUnobservedDebt } from './accept-debt.js';
|
|
34
|
+
import { applyDemotions, isNonProgress, normalizeFailureDetail, rankedFirstFailure, unobservedDebtReason } from './final-gate-progress.js';
|
|
34
35
|
import { classifyFinalGateAnswer, MAX_FINAL_GATE_AUTOFIX, FINAL_LEAVE_LABEL, FINAL_LEAVE_VALUE, FINAL_ACCEPT_LABEL, FINAL_ACCEPT_VALUE, FINAL_AUTOFIX_LABEL, FINAL_AUTOFIX_VALUE, STRANDED_FIX_COMMIT, strandedFixNote } from './final-gate-fix.js';
|
|
35
36
|
import { getConfig } from '../config/config.js';
|
|
36
37
|
import { isYoloMode, yoloPickAnswer, yoloFinalGateChoice, YOLO_STAMP } from './yolo.js';
|
|
@@ -1027,7 +1028,7 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
1027
1028
|
let fixAttempts = 0;
|
|
1028
1029
|
// Sub-fixes a non-converging autofix attempt left uncommitted.
|
|
1029
1030
|
// Refreshed after every attempt; drives the picker note and the
|
|
1030
|
-
//
|
|
1031
|
+
// terminal commit (mx5 run 13 PROMPT 4 item 3, run 14 item 2b).
|
|
1031
1032
|
let stranded = [];
|
|
1032
1033
|
const refreshStranded = async () => {
|
|
1033
1034
|
if (!deps.pendingChanges)
|
|
@@ -1040,6 +1041,42 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
1040
1041
|
stranded = [];
|
|
1041
1042
|
}
|
|
1042
1043
|
};
|
|
1044
|
+
// NON-PROGRESS / UNFALSIFIABLE-CHECK state (mx5 run 14 item 2a).
|
|
1045
|
+
// `prevFailSig` is the previous attempt's normalized ranked-first
|
|
1046
|
+
// failure; `demoted` holds the signatures already carried as debt,
|
|
1047
|
+
// so a re-run that still reports them does not re-fail the gate.
|
|
1048
|
+
let prevFailSig = null;
|
|
1049
|
+
const demoted = new Set();
|
|
1050
|
+
// Set when a write-guard rejected an attempt whose edits could NOT
|
|
1051
|
+
// be discarded: REJECTED edits are then sitting in the tree and
|
|
1052
|
+
// must never be committed by the terminal paths below.
|
|
1053
|
+
let rejectedEditsInTree = false;
|
|
1054
|
+
// Commit whatever guard-clean repairs the fix passes left, on ANY
|
|
1055
|
+
// terminal non-converged outcome. Run 14 ended on LEAVE with 13
|
|
1056
|
+
// real repairs dirty in the tree after an unattended run — the
|
|
1057
|
+
// next checkout would have destroyed them silently.
|
|
1058
|
+
const commitStranded = async (outcome) => {
|
|
1059
|
+
if (stranded.length === 0)
|
|
1060
|
+
return;
|
|
1061
|
+
if (rejectedEditsInTree) {
|
|
1062
|
+
await recGate(`final-gate: NOT committing ${stranded.length} working-tree change(s) — a `
|
|
1063
|
+
+ `write-guard rejected an attempt and its edits could not be discarded, `
|
|
1064
|
+
+ `so the tree holds REJECTED edits: ${stranded.slice(0, 8).join(', ')}`);
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
try {
|
|
1068
|
+
const sha = await deps.commit(cwd, STRANDED_FIX_COMMIT(id, outcome));
|
|
1069
|
+
await recGate(`final-gate: committed ${stranded.length} stranded fix-pass change(s)`
|
|
1070
|
+
+ `${sha ? ` as ${sha}` : ''} — ${stranded.slice(0, 8).join(', ')}`);
|
|
1071
|
+
}
|
|
1072
|
+
catch (err) {
|
|
1073
|
+
// Never break the terminal path over this — but say so, so
|
|
1074
|
+
// the changes are not silently lost.
|
|
1075
|
+
await recGate(`final-gate: could NOT commit ${stranded.length} stranded fix-pass `
|
|
1076
|
+
+ `change(s) (${err instanceof Error ? err.message : String(err)}) — `
|
|
1077
|
+
+ `they remain UNCOMMITTED in the working tree: ${stranded.slice(0, 8).join(', ')}`);
|
|
1078
|
+
}
|
|
1079
|
+
};
|
|
1043
1080
|
while (!fin.ok) {
|
|
1044
1081
|
const canAutofix = deps.finalGateFix !== undefined && fixAttempts < MAX_FINAL_GATE_AUTOFIX;
|
|
1045
1082
|
// The picker question shows the debts (the HUMAN weighs them);
|
|
@@ -1099,20 +1136,7 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
1099
1136
|
// it fixed. Commit it as its own, named commit — the
|
|
1100
1137
|
// ACCEPT is a decision about the FAILING gate, never an
|
|
1101
1138
|
// instruction to throw away work (mx5 run 13 item 3).
|
|
1102
|
-
|
|
1103
|
-
try {
|
|
1104
|
-
const sha = await deps.commit(cwd, STRANDED_FIX_COMMIT(id));
|
|
1105
|
-
await recGate(`final-gate: committed ${stranded.length} stranded fix-pass change(s)`
|
|
1106
|
-
+ `${sha ? ` as ${sha}` : ''} — ${stranded.slice(0, 8).join(', ')}`);
|
|
1107
|
-
}
|
|
1108
|
-
catch (err) {
|
|
1109
|
-
// Never break the completion path over this — but
|
|
1110
|
-
// say so, so the changes are not silently lost.
|
|
1111
|
-
await recGate(`final-gate: could NOT commit ${stranded.length} stranded fix-pass `
|
|
1112
|
-
+ `change(s) (${err instanceof Error ? err.message : String(err)}) — `
|
|
1113
|
-
+ `they remain UNCOMMITTED in the working tree: ${stranded.slice(0, 8).join(', ')}`);
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1139
|
+
await commitStranded('accepted');
|
|
1116
1140
|
active.ui.notify(`${id}: final integration gate FAIL accepted by user — completing.`
|
|
1117
1141
|
+ (stranded.length > 0 ?
|
|
1118
1142
|
` ${stranded.length} uncommitted fix-pass change(s) committed separately.`
|
|
@@ -1135,15 +1159,69 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
1135
1159
|
break;
|
|
1136
1160
|
}
|
|
1137
1161
|
await recGate(`final-gate: autofix attempt ${fixAttempts} failed — ${fix.reason.slice(0, 200)}`);
|
|
1162
|
+
// A guard that rejected an attempt WITHOUT discarding leaves
|
|
1163
|
+
// rejected edits behind: the terminal paths must not commit
|
|
1164
|
+
// the tree after that (the cheat guard stays intact).
|
|
1165
|
+
if (fix.guardTripped === true && fix.editsDiscarded !== true) {
|
|
1166
|
+
rejectedEditsInTree = true;
|
|
1167
|
+
}
|
|
1138
1168
|
// The attempt's edits survive a non-convergence (only a
|
|
1139
1169
|
// guard trip discards). Find out what they are NOW, so
|
|
1140
|
-
// the next picker shows them and
|
|
1170
|
+
// the next picker shows them and a terminal outcome commits them.
|
|
1141
1171
|
await refreshStranded();
|
|
1142
1172
|
if (stranded.length > 0) {
|
|
1143
1173
|
await recGate(`final-gate: autofix attempt ${fixAttempts} left ${stranded.length} `
|
|
1144
1174
|
+ `uncommitted change(s) — ${stranded.slice(0, 8).join(', ')}`);
|
|
1145
1175
|
}
|
|
1146
1176
|
active.ui.notify(`${id}: final-gate autofix did not converge — ${fix.reason.slice(0, 140)}`, 'warning');
|
|
1177
|
+
// NON-PROGRESS CLASSIFIER (mx5 run 14 item 2a). An attempt
|
|
1178
|
+
// that changed the tree, re-ran the gate, and got back the
|
|
1179
|
+
// SAME ranked-first failure as the previous such attempt is
|
|
1180
|
+
// evidence about the CHECK, not the fix: run 14 burned all
|
|
1181
|
+
// three attempts on a boot probe that could not observe a
|
|
1182
|
+
// listener in that sandbox at all. Demote that one check to
|
|
1183
|
+
// UNOBSERVED-with-debt and let the REMAINING checks decide.
|
|
1184
|
+
const detail = rankedFirstFailure({
|
|
1185
|
+
reason: fix.gateReason,
|
|
1186
|
+
failures: fix.gateFailures
|
|
1187
|
+
});
|
|
1188
|
+
const edited = fix.gateReason !== undefined && stranded.length > 0;
|
|
1189
|
+
if (detail !== null
|
|
1190
|
+
&& isNonProgress({
|
|
1191
|
+
previousSignature: prevFailSig,
|
|
1192
|
+
currentDetail: detail,
|
|
1193
|
+
edited
|
|
1194
|
+
})) {
|
|
1195
|
+
demoted.add(normalizeFailureDetail(detail));
|
|
1196
|
+
prevFailSig = null;
|
|
1197
|
+
const debtReason = unobservedDebtReason(detail);
|
|
1198
|
+
await recordFinalGateUnobservedDebt(cwd, id, debtReason);
|
|
1199
|
+
await recGate(`final-gate: check DEMOTED to UNOBSERVED after ${fixAttempts} tree-changing `
|
|
1200
|
+
+ `attempts returned an identical failure — carried as debt (origin final-gate) `
|
|
1201
|
+
+ `and re-checked by the next run's gate: ${detail.slice(0, 240)}`);
|
|
1202
|
+
active.ui.notify(`${id}: final-gate check is unfalsifiable in this environment — carried as debt; `
|
|
1203
|
+
+ 'the remaining checks decide convergence.', 'warning');
|
|
1204
|
+
}
|
|
1205
|
+
else {
|
|
1206
|
+
prevFailSig =
|
|
1207
|
+
detail !== null ? normalizeFailureDetail(detail) : null;
|
|
1208
|
+
}
|
|
1209
|
+
// Convergence on the REMAINING checks: a demoted signature no
|
|
1210
|
+
// longer counts against the gate. Nothing left ⇒ the run
|
|
1211
|
+
// converges carrying the demotion as debt, and the fix passes'
|
|
1212
|
+
// repairs are committed rather than stranded.
|
|
1213
|
+
if (demoted.size > 0 && fix.gateReason !== undefined) {
|
|
1214
|
+
const remaining = applyDemotions(fix.gateFailures ?? [fix.gateReason], demoted);
|
|
1215
|
+
if (remaining.length === 0) {
|
|
1216
|
+
await deps.commit(cwd, `FINAL GATE AUTOFIX (${id})`);
|
|
1217
|
+
const converged = `converged on all remaining checks; ${demoted.size} check(s) `
|
|
1218
|
+
+ 'carried as UNOBSERVED debt (unfalsifiable in this environment)';
|
|
1219
|
+
await recGate(`final-gate: ${converged}`);
|
|
1220
|
+
active.ui.notify(`${id}: final integration gate converged — ${converged}.`, 'warning');
|
|
1221
|
+
fin = { ok: true, reason: converged };
|
|
1222
|
+
break;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1147
1225
|
// Work from the FRESH gate failure when the fix pass got
|
|
1148
1226
|
// as far as re-running the gate; otherwise keep the last.
|
|
1149
1227
|
// The full ranked list rides along (and is re-trailed
|
|
@@ -1151,10 +1229,22 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
1151
1229
|
// still carry every entry, not just the first. The debt
|
|
1152
1230
|
// note is carried so the next picker still shows the
|
|
1153
1231
|
// open claims (the seed never includes it).
|
|
1232
|
+
// Demoted checks are stripped from what rides forward, so the
|
|
1233
|
+
// next picker and the next fix seed target only what is still
|
|
1234
|
+
// falsifiable — never re-aiming the child at the check the
|
|
1235
|
+
// classifier just proved it cannot move.
|
|
1236
|
+
const freshFailures = fix.gateReason !== undefined ? fix.gateFailures : fin.failures;
|
|
1237
|
+
const carried = freshFailures !== undefined ?
|
|
1238
|
+
applyDemotions(freshFailures, demoted)
|
|
1239
|
+
: undefined;
|
|
1154
1240
|
fin = {
|
|
1155
1241
|
ok: false,
|
|
1156
|
-
reason:
|
|
1157
|
-
|
|
1242
|
+
reason: (demoted.size > 0
|
|
1243
|
+
&& carried !== undefined
|
|
1244
|
+
&& carried.length > 0) ?
|
|
1245
|
+
carried[0]
|
|
1246
|
+
: (fix.gateReason ?? fin.reason),
|
|
1247
|
+
failures: carried,
|
|
1158
1248
|
debtNote: fin.debtNote
|
|
1159
1249
|
};
|
|
1160
1250
|
if (fix.gateReason !== undefined
|
|
@@ -1168,18 +1258,16 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
1168
1258
|
await recGate(yoloFinal !== null ?
|
|
1169
1259
|
`final-gate: left failed — autofix budget spent, nobody to ask ${YOLO_STAMP}`
|
|
1170
1260
|
: 'final-gate: left failed (user)');
|
|
1171
|
-
// Leaving the run failed
|
|
1172
|
-
//
|
|
1173
|
-
//
|
|
1174
|
-
//
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
+ `working tree — ${stranded.slice(0, 8).join(', ')}`);
|
|
1178
|
-
}
|
|
1261
|
+
// Leaving the run failed is TERMINAL for an unattended run, so
|
|
1262
|
+
// the fix passes' guard-clean repairs are committed here too —
|
|
1263
|
+
// run 14 left 13 of them dirty for a `git checkout` to destroy
|
|
1264
|
+
// (mx5 run 13 item 3, run 14 item 2b). The user still owns the
|
|
1265
|
+
// outcome; they own it with the work in HEAD, named in the trail.
|
|
1266
|
+
await commitStranded('left-failed');
|
|
1179
1267
|
await updateTaskFrontMatter(cwd, id, { state: 'failed' });
|
|
1180
1268
|
announceDone(active, `${id} finished all tasks but FAILED the final integration gate — ${fin.reason.slice(0, 200)} — fix and /task-auto-resume (the gate re-runs).`
|
|
1181
1269
|
+ (stranded.length > 0 ?
|
|
1182
|
-
` NOTE: ${stranded.length}
|
|
1270
|
+
` NOTE: ${stranded.length} fix-pass change(s) were committed separately (${stranded.slice(0, 4).join(', ')}).`
|
|
1183
1271
|
: ''), 'error');
|
|
1184
1272
|
return;
|
|
1185
1273
|
}
|
|
@@ -75,9 +75,18 @@ export declare function parseFinalFixMarker(text: string): {
|
|
|
75
75
|
*
|
|
76
76
|
* The rule: a partial fix is either committed (its own commit, named in the trail) or
|
|
77
77
|
* explicitly surfaced — never silently stranded.
|
|
78
|
+
*
|
|
79
|
+
* mx5 run 14 proved the committing half only ever ran on the ACCEPT path. The run
|
|
80
|
+
* ended on LEAVE (YOLO, budget spent) and 13 real repairs — the dev script, the
|
|
81
|
+
* teardown bug, test serialization, the migrate script: the changes that make that
|
|
82
|
+
* app work today — were left dirty in the tree, one `git checkout` from gone, after
|
|
83
|
+
* an UNATTENDED run nobody was watching. So EVERY terminal non-converged outcome
|
|
84
|
+
* commits them now, not just ACCEPT. Only guard-CLEAN edits qualify: an attempt a
|
|
85
|
+
* write-guard rejected without discarding leaves REJECTED edits in the tree, and
|
|
86
|
+
* those are never committed (the guard is not weakened to make committing easier).
|
|
78
87
|
*/
|
|
79
|
-
/** Commit subject for partial fixes committed alongside
|
|
80
|
-
export declare const STRANDED_FIX_COMMIT: (runId: string) => string;
|
|
88
|
+
/** Commit subject for partial fixes committed alongside a terminal gate FAIL. */
|
|
89
|
+
export declare const STRANDED_FIX_COMMIT: (runId: string, outcome?: "accepted" | "left-failed") => string;
|
|
81
90
|
/**
|
|
82
91
|
* The picker/trail line describing what a non-converging fix pass left behind.
|
|
83
92
|
* Empty string when the tree is clean — the caller then says nothing at all.
|
|
@@ -94,6 +103,13 @@ export interface FinalFixResult {
|
|
|
94
103
|
/** The fresh gate's individual ranked failures (see FinalGateOutcome.failures),
|
|
95
104
|
* so the caller can trail each entry — never just the first. */
|
|
96
105
|
gateFailures?: string[];
|
|
106
|
+
/** A write-guard rejected this attempt (deletion / shrink / probe-gaming). */
|
|
107
|
+
guardTripped?: boolean;
|
|
108
|
+
/** …and its edits were discarded. When a guard tripped and this is false, the
|
|
109
|
+
* working tree still holds REJECTED edits, so the caller must NOT commit what
|
|
110
|
+
* it finds there (mx5 run 14 item 2b: commit surviving fix-pass edits — but
|
|
111
|
+
* only guard-CLEAN ones; the cheat guard is never weakened to ease committing). */
|
|
112
|
+
editsDiscarded?: boolean;
|
|
97
113
|
}
|
|
98
114
|
export interface FinalFixDeps {
|
|
99
115
|
cwd: string;
|
|
@@ -178,9 +178,20 @@ export function parseFinalFixMarker(text) {
|
|
|
178
178
|
*
|
|
179
179
|
* The rule: a partial fix is either committed (its own commit, named in the trail) or
|
|
180
180
|
* explicitly surfaced — never silently stranded.
|
|
181
|
+
*
|
|
182
|
+
* mx5 run 14 proved the committing half only ever ran on the ACCEPT path. The run
|
|
183
|
+
* ended on LEAVE (YOLO, budget spent) and 13 real repairs — the dev script, the
|
|
184
|
+
* teardown bug, test serialization, the migrate script: the changes that make that
|
|
185
|
+
* app work today — were left dirty in the tree, one `git checkout` from gone, after
|
|
186
|
+
* an UNATTENDED run nobody was watching. So EVERY terminal non-converged outcome
|
|
187
|
+
* commits them now, not just ACCEPT. Only guard-CLEAN edits qualify: an attempt a
|
|
188
|
+
* write-guard rejected without discarding leaves REJECTED edits in the tree, and
|
|
189
|
+
* those are never committed (the guard is not weakened to make committing easier).
|
|
181
190
|
*/
|
|
182
|
-
/** Commit subject for partial fixes committed alongside
|
|
183
|
-
export const STRANDED_FIX_COMMIT = (runId) => `FINAL GATE PARTIAL FIX (${runId}) —
|
|
191
|
+
/** Commit subject for partial fixes committed alongside a terminal gate FAIL. */
|
|
192
|
+
export const STRANDED_FIX_COMMIT = (runId, outcome = 'accepted') => `FINAL GATE PARTIAL FIX (${runId}) — ${outcome === 'accepted' ?
|
|
193
|
+
'accepted with gate still failing'
|
|
194
|
+
: 'run left failed, repairs preserved'}`;
|
|
184
195
|
/**
|
|
185
196
|
* The picker/trail line describing what a non-converging fix pass left behind.
|
|
186
197
|
* Empty string when the tree is clean — the caller then says nothing at all.
|
|
@@ -190,9 +201,9 @@ export function strandedFixNote(paths) {
|
|
|
190
201
|
return '';
|
|
191
202
|
const shown = paths.slice(0, 8).join(', ');
|
|
192
203
|
return (`\n\nUNCOMMITTED: the fix pass left ${paths.length} change(s) in the working tree `
|
|
193
|
-
+ `(${shown}${paths.length > 8 ? ', …' : ''}). These are NOT in HEAD.
|
|
194
|
-
+ `
|
|
195
|
-
+ `
|
|
204
|
+
+ `(${shown}${paths.length > 8 ? ', …' : ''}). These are NOT in HEAD. Either terminal `
|
|
205
|
+
+ `choice commits them as their own, named commit so they cannot be lost to a later `
|
|
206
|
+
+ `checkout — the run's outcome is recorded in the gate trail either way.`);
|
|
196
207
|
}
|
|
197
208
|
/**
|
|
198
209
|
* Run one bounded final-gate fix attempt: snapshot discovery → child → write-guard
|
|
@@ -214,7 +225,9 @@ export async function runFinalGateAutofix(deps) {
|
|
|
214
225
|
}
|
|
215
226
|
const rejected = (what) => ({
|
|
216
227
|
ok: false,
|
|
217
|
-
reason: `${what} — edits ${deps.discard ? 'discarded' : 'REJECTED but left in the tree (no discard available)'}
|
|
228
|
+
reason: `${what} — edits ${deps.discard ? 'discarded' : 'REJECTED but left in the tree (no discard available)'}`,
|
|
229
|
+
guardTripped: true,
|
|
230
|
+
editsDiscarded: deps.discard !== undefined
|
|
218
231
|
});
|
|
219
232
|
// (Diff capture — what the pass changed, durably — happens at the gate-deps
|
|
220
233
|
// seam for every write-capable child; here only the guards act on it.)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* final-gate-progress — the non-progress classifier for the final-gate autofix loop.
|
|
3
|
+
*
|
|
4
|
+
* The failure this closes (mx5 run 14, validated from TASK_AUTO_0001.md's gates
|
|
5
|
+
* trail): the gate's boot check asserted "the app never opened a listening socket"
|
|
6
|
+
* in a sandbox where NO tool the probe knows (`ss`, `lsof`) exists — the check was
|
|
7
|
+
* UNFALSIFIABLE there. Three autofix attempts each edited real files, re-ran, and
|
|
8
|
+
* came back with a byte-identical ranked-first failure; the budget was spent on a
|
|
9
|
+
* check no edit could ever move, the two checks that WERE fixable had converged by
|
|
10
|
+
* attempt 2, and the run ended `failed` with 13 genuinely repaired files sitting
|
|
11
|
+
* uncommitted in the working tree.
|
|
12
|
+
*
|
|
13
|
+
* The rule this encodes: an attempt that CHANGED the tree and re-ran the gate, and
|
|
14
|
+
* got the same first failure back as the previous attempt, is evidence about the
|
|
15
|
+
* CHECK, not about the fix. Two identical post-fix results ⇒ the check is
|
|
16
|
+
* env-shaped/unfalsifiable in this environment ⇒ stop paying for it: demote that
|
|
17
|
+
* one check to UNOBSERVED-with-debt (durable, so the NEXT run's gate re-checks it)
|
|
18
|
+
* and let the REMAINING checks decide whether the gate converged. Run 14 replay:
|
|
19
|
+
* checks 2 and 3 were fixed by attempts 1–2, so the run converges with the boot
|
|
20
|
+
* check carried as debt instead of failing with the repairs stranded.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately conservative:
|
|
23
|
+
* - It never fires on attempt 1. The first repeat can be an ordinary
|
|
24
|
+
* didn't-fix-it-yet; only a SECOND identical post-fix result is evidence.
|
|
25
|
+
* - It never fires when the attempt changed nothing (a BLOCKED child, a
|
|
26
|
+
* guard-discarded attempt): with no edit there is nothing to conclude about
|
|
27
|
+
* falsifiability.
|
|
28
|
+
* - It demotes exactly the one repeated check. Every other failure still has to
|
|
29
|
+
* pass for real; the debt keeps the demoted one visible at run end and in the
|
|
30
|
+
* next run's gate.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Comparison key for a gate failure entry: lowercased, volatile substrings erased,
|
|
34
|
+
* whitespace collapsed. Two entries with the same key are "the same failure" for
|
|
35
|
+
* non-progress purposes.
|
|
36
|
+
*/
|
|
37
|
+
export declare function normalizeFailureDetail(detail: string): string;
|
|
38
|
+
/** The ranked-first failure of a gate outcome (the list is ranked most load-bearing
|
|
39
|
+
* first; a wiring without a list degrades to the single reason). */
|
|
40
|
+
export declare function rankedFirstFailure(outcome: {
|
|
41
|
+
reason?: string;
|
|
42
|
+
failures?: string[];
|
|
43
|
+
}): string | null;
|
|
44
|
+
export interface NonProgressInput {
|
|
45
|
+
/** Normalized ranked-first failure of the PREVIOUS attempt's gate re-run
|
|
46
|
+
* (null before any attempt has produced one — the classifier cannot fire). */
|
|
47
|
+
previousSignature: string | null;
|
|
48
|
+
/** This attempt's ranked-first failure, raw. */
|
|
49
|
+
currentDetail: string | null;
|
|
50
|
+
/** Did this attempt actually change the tree AND survive the guards? Only an
|
|
51
|
+
* attempt that edited and re-tested says anything about falsifiability. */
|
|
52
|
+
edited: boolean;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* True when this attempt is evidence that the ranked-first check is unfalsifiable
|
|
56
|
+
* here: it edited the tree, the gate re-ran, and returned the same first failure
|
|
57
|
+
* as the previous attempt.
|
|
58
|
+
*/
|
|
59
|
+
export declare function isNonProgress(input: NonProgressInput): boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Drop every failure entry matching an already-demoted signature. What survives is
|
|
62
|
+
* what still has to pass for the gate to converge; empty ⇒ converged-with-debt.
|
|
63
|
+
*/
|
|
64
|
+
export declare function applyDemotions(failures: string[], demoted: ReadonlySet<string>): string[];
|
|
65
|
+
/** The debt reason recorded for a demoted check, and the gate-trail wording. Kept
|
|
66
|
+
* in one place so the ledger line and the trail line cannot drift apart. */
|
|
67
|
+
export declare function unobservedDebtReason(detail: string): string;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* final-gate-progress — the non-progress classifier for the final-gate autofix loop.
|
|
3
|
+
*
|
|
4
|
+
* The failure this closes (mx5 run 14, validated from TASK_AUTO_0001.md's gates
|
|
5
|
+
* trail): the gate's boot check asserted "the app never opened a listening socket"
|
|
6
|
+
* in a sandbox where NO tool the probe knows (`ss`, `lsof`) exists — the check was
|
|
7
|
+
* UNFALSIFIABLE there. Three autofix attempts each edited real files, re-ran, and
|
|
8
|
+
* came back with a byte-identical ranked-first failure; the budget was spent on a
|
|
9
|
+
* check no edit could ever move, the two checks that WERE fixable had converged by
|
|
10
|
+
* attempt 2, and the run ended `failed` with 13 genuinely repaired files sitting
|
|
11
|
+
* uncommitted in the working tree.
|
|
12
|
+
*
|
|
13
|
+
* The rule this encodes: an attempt that CHANGED the tree and re-ran the gate, and
|
|
14
|
+
* got the same first failure back as the previous attempt, is evidence about the
|
|
15
|
+
* CHECK, not about the fix. Two identical post-fix results ⇒ the check is
|
|
16
|
+
* env-shaped/unfalsifiable in this environment ⇒ stop paying for it: demote that
|
|
17
|
+
* one check to UNOBSERVED-with-debt (durable, so the NEXT run's gate re-checks it)
|
|
18
|
+
* and let the REMAINING checks decide whether the gate converged. Run 14 replay:
|
|
19
|
+
* checks 2 and 3 were fixed by attempts 1–2, so the run converges with the boot
|
|
20
|
+
* check carried as debt instead of failing with the repairs stranded.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately conservative:
|
|
23
|
+
* - It never fires on attempt 1. The first repeat can be an ordinary
|
|
24
|
+
* didn't-fix-it-yet; only a SECOND identical post-fix result is evidence.
|
|
25
|
+
* - It never fires when the attempt changed nothing (a BLOCKED child, a
|
|
26
|
+
* guard-discarded attempt): with no edit there is nothing to conclude about
|
|
27
|
+
* falsifiability.
|
|
28
|
+
* - It demotes exactly the one repeated check. Every other failure still has to
|
|
29
|
+
* pass for real; the debt keeps the demoted one visible at run end and in the
|
|
30
|
+
* next run's gate.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Volatile substrings that differ between two runs of the SAME failing check —
|
|
34
|
+
* ports, timings, pids, temp paths, timestamps, hex ids. They must be erased
|
|
35
|
+
* before comparing, or the classifier never fires on a real repeat (a boot probe
|
|
36
|
+
* that prints the port it tried, a test runner that prints elapsed ms).
|
|
37
|
+
*
|
|
38
|
+
* Ordinary integers are NOT collapsed: "3 tests failed" → "1 test failed" is real
|
|
39
|
+
* progress and must stay visible as a difference.
|
|
40
|
+
*/
|
|
41
|
+
const VOLATILE = [
|
|
42
|
+
// ISO-ish timestamps, then clock times.
|
|
43
|
+
[/\d{4}-\d{2}-\d{2}[t ]\d{2}:\d{2}:\d{2}(?:\.\d+)?z?/g, '<ts>'],
|
|
44
|
+
[/\b\d{1,2}:\d{2}:\d{2}(?:\.\d+)?\b/g, '<time>'],
|
|
45
|
+
// Temp dirs (posix + macOS + windows) up to the next whitespace/quote.
|
|
46
|
+
[/(?:\/tmp|\/private\/var\/folders|\/var\/folders)\/[^\s'"`)]+/g, '<tmp>'],
|
|
47
|
+
[/[a-z]:\\+(?:users\\+[^\s'"`)\\]+\\+)?appdata\\+local\\+temp\\+[^\s'"`)]+/g, '<tmp>'],
|
|
48
|
+
// Durations with a unit.
|
|
49
|
+
[/\b\d+(?:\.\d+)?\s?(?:ms|µs|us|ns|s|m|h|sec|secs|seconds?|minutes?|hours?)\b/g, '<dur>'],
|
|
50
|
+
// pids, addresses and ports (address first — it subsumes the port form).
|
|
51
|
+
[/\bpids?\s*[:=]?\s*\d+/g, '<pid>'],
|
|
52
|
+
[/(?:127\.0\.0\.1|localhost|0\.0\.0\.0|\[::1\]):\d+/g, '<addr>'],
|
|
53
|
+
[/\bport\s*[:=]?\s*\d{2,5}\b/g, '<port>'],
|
|
54
|
+
[/:\d{2,5}\b/g, ':<port>'],
|
|
55
|
+
// Long hex / uuid-ish ids (sha, container id, request id).
|
|
56
|
+
[/\b0x[0-9a-f]+\b/g, '<hex>'],
|
|
57
|
+
[/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/g, '<uuid>'],
|
|
58
|
+
[/\b[0-9a-f]{7,40}\b/g, '<hex>']
|
|
59
|
+
];
|
|
60
|
+
/**
|
|
61
|
+
* Comparison key for a gate failure entry: lowercased, volatile substrings erased,
|
|
62
|
+
* whitespace collapsed. Two entries with the same key are "the same failure" for
|
|
63
|
+
* non-progress purposes.
|
|
64
|
+
*/
|
|
65
|
+
export function normalizeFailureDetail(detail) {
|
|
66
|
+
let s = detail.toLowerCase();
|
|
67
|
+
for (const [re, repl] of VOLATILE)
|
|
68
|
+
s = s.replace(re, repl);
|
|
69
|
+
return s.replace(/\s+/g, ' ').trim();
|
|
70
|
+
}
|
|
71
|
+
/** The ranked-first failure of a gate outcome (the list is ranked most load-bearing
|
|
72
|
+
* first; a wiring without a list degrades to the single reason). */
|
|
73
|
+
export function rankedFirstFailure(outcome) {
|
|
74
|
+
const first = outcome.failures?.[0] ?? outcome.reason;
|
|
75
|
+
const t = first?.trim();
|
|
76
|
+
return t && t.length > 0 ? t : null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* True when this attempt is evidence that the ranked-first check is unfalsifiable
|
|
80
|
+
* here: it edited the tree, the gate re-ran, and returned the same first failure
|
|
81
|
+
* as the previous attempt.
|
|
82
|
+
*/
|
|
83
|
+
export function isNonProgress(input) {
|
|
84
|
+
if (!input.edited)
|
|
85
|
+
return false;
|
|
86
|
+
if (input.previousSignature === null || input.currentDetail === null)
|
|
87
|
+
return false;
|
|
88
|
+
return normalizeFailureDetail(input.currentDetail) === input.previousSignature;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Drop every failure entry matching an already-demoted signature. What survives is
|
|
92
|
+
* what still has to pass for the gate to converge; empty ⇒ converged-with-debt.
|
|
93
|
+
*/
|
|
94
|
+
export function applyDemotions(failures, demoted) {
|
|
95
|
+
if (demoted.size === 0)
|
|
96
|
+
return [...failures];
|
|
97
|
+
return failures.filter(f => !demoted.has(normalizeFailureDetail(f)));
|
|
98
|
+
}
|
|
99
|
+
/** The debt reason recorded for a demoted check, and the gate-trail wording. Kept
|
|
100
|
+
* in one place so the ledger line and the trail line cannot drift apart. */
|
|
101
|
+
export function unobservedDebtReason(detail) {
|
|
102
|
+
return (`final gate check UNOBSERVED — ${detail.trim().slice(0, 200)} — `
|
|
103
|
+
+ 'repeated identically across two fix attempts that both changed the tree and '
|
|
104
|
+
+ 're-ran the gate; treated as unfalsifiable in this environment and carried as '
|
|
105
|
+
+ 'debt (re-checked by the next run’s gate), not as a proven defect');
|
|
106
|
+
}
|
|
@@ -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.37",
|
|
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",
|