tickmarkr 2.3.0 → 2.4.0
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/adapters/catalog-remote.d.ts +1 -4
- package/dist/adapters/catalog-remote.js +52 -42
- package/dist/adapters/catalog.js +5 -3
- package/dist/adapters/claude-code.d.ts +1 -1
- package/dist/adapters/claude-code.js +8 -5
- package/dist/adapters/model-lints.d.ts +9 -5
- package/dist/adapters/model-lints.js +56 -15
- package/dist/adapters/model-windows.js +11 -0
- package/dist/adapters/prompt.js +1 -0
- package/dist/adapters/qwen.d.ts +5 -0
- package/dist/adapters/qwen.js +153 -0
- package/dist/adapters/types.d.ts +1 -0
- package/dist/adapters/types.js +1 -0
- package/dist/cli/commands/compile.js +32 -6
- package/dist/cli/commands/doctor.d.ts +4 -3
- package/dist/cli/commands/doctor.js +20 -6
- package/dist/cli/commands/fleet.d.ts +4 -0
- package/dist/cli/commands/fleet.js +53 -14
- package/dist/cli/commands/plan.js +29 -5
- package/dist/cli/commands/status.d.ts +1 -0
- package/dist/cli/commands/status.js +45 -1
- package/dist/cli/commands/verify.d.ts +1 -0
- package/dist/cli/commands/verify.js +5 -0
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +2 -2
- package/dist/compile/collateral.d.ts +2 -9
- package/dist/compile/collateral.js +2 -9
- package/dist/compile/index.d.ts +4 -1
- package/dist/compile/index.js +41 -7
- package/dist/compile/native.d.ts +4 -2
- package/dist/compile/native.js +55 -6
- package/dist/compile/ownership.js +34 -9
- package/dist/config/config.d.ts +1 -0
- package/dist/config/config.js +51 -5
- package/dist/drivers/herdr.d.ts +1 -0
- package/dist/drivers/herdr.js +11 -1
- package/dist/drivers/orca.d.ts +14 -1
- package/dist/drivers/orca.js +114 -15
- package/dist/drivers/types.d.ts +10 -0
- package/dist/gates/baseline.js +26 -7
- package/dist/gates/review.d.ts +4 -2
- package/dist/gates/review.js +24 -13
- package/dist/gates/run-gates.d.ts +5 -2
- package/dist/gates/run-gates.js +34 -17
- package/dist/route/preference.d.ts +4 -0
- package/dist/route/preference.js +40 -0
- package/dist/route/router.js +15 -2
- package/dist/run/consult.d.ts +1 -0
- package/dist/run/consult.js +34 -7
- package/dist/run/daemon.d.ts +4 -0
- package/dist/run/daemon.js +128 -50
- package/dist/run/git.d.ts +2 -0
- package/dist/run/git.js +36 -5
- package/dist/run/journal.js +4 -1
- package/dist/tui/ink/fleet-app.d.ts +4 -0
- package/dist/tui/ink/fleet-app.js +45 -16
- package/package.json +59 -1
package/dist/run/daemon.js
CHANGED
|
@@ -4,6 +4,7 @@ import { shq } from "../adapters/types.js";
|
|
|
4
4
|
import { appendFileSync, closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, readSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
7
8
|
import { stringify } from "yaml";
|
|
8
9
|
import { classifyDeadChannel, NO_TRAILER_SUMMARY, trailerPattern, UNPARSEABLE_TRAILER_SUMMARY, writePrompt } from "../adapters/prompt.js";
|
|
9
10
|
import { allAdapters, getAdapter, probeAll, readDoctor, rolePools } from "../adapters/registry.js";
|
|
@@ -74,7 +75,7 @@ export function resolveRunMode(repoRoot, opts = {}) {
|
|
|
74
75
|
// task can fail in routing before task-dispatch, and the catch appends task-failed — treating that
|
|
75
76
|
// as enactment reports "complete" over an approval that never ran (the exact silent-completion this
|
|
76
77
|
// task exists to kill).
|
|
77
|
-
const DISPATCH_ENACTMENT = new Set(["task-dispatch", "repair-dispatch"]);
|
|
78
|
+
const DISPATCH_ENACTMENT = new Set(["task-dispatch", "repair-dispatch", "resume-restore"]);
|
|
78
79
|
const RECHECK_ENACTMENT = "recheck-battery";
|
|
79
80
|
// The ONE approval that enacts without buying a worker: GATE_SATISFIED_RELEASE resumes from the
|
|
80
81
|
// persisted task branch after the approved gate (execTask's satisfiedGate branch), whose first act
|
|
@@ -137,7 +138,8 @@ export function formatSummary(s) {
|
|
|
137
138
|
* run id (cli/commands/status.ts positionalRunId), so naming it here is what stops the board from
|
|
138
139
|
* following the newest journal in a repo that already carries a second, newer run — a board showing
|
|
139
140
|
* the wrong run is a recorded incident (skills/tickmarkr-overseer/SKILL.md). */
|
|
140
|
-
export const
|
|
141
|
+
export const daemonEntrypoint = fileURLToPath(new URL("../cli/index.js", import.meta.url));
|
|
142
|
+
export const watchCommand = (runId) => `${shq(process.execPath)} ${shq(daemonEntrypoint)} status --watch ${shq(runId)}`;
|
|
141
143
|
const MAX_ATTEMPTS = 10; // ponytail: hard cap so a pathological ladder can never loop forever
|
|
142
144
|
// v1.85 T3 (retry economics): two repairs per engagement, then the fresh ladder. A repair re-uses the
|
|
143
145
|
// findings and the landed diff instead of re-buying onboarding; when two of them have not closed the
|
|
@@ -213,7 +215,7 @@ function narrowRepairBattery(failing) {
|
|
|
213
215
|
}
|
|
214
216
|
// v2.0 T2 (OBS-554): the measurement keys run-gates stamps on a GateResult, lifted verbatim onto the
|
|
215
217
|
// gate row. One list, one lift — both onGate sites record through the same helper.
|
|
216
|
-
const GATE_TELEMETRY_KEYS = ["durationMs", "load1Start", "load1End", "selectedDurationMs", "fullDurationMs", "invocations"];
|
|
218
|
+
const GATE_TELEMETRY_KEYS = ["durationMs", "load1Start", "load1End", "load1Max", "load1Mean", "selectedDurationMs", "fullDurationMs", "invocations"];
|
|
217
219
|
const gateMeasurement = (meta = {}) => Object.fromEntries(GATE_TELEMETRY_KEYS.filter((k) => meta[k] !== undefined).map((k) => [k, meta[k]]));
|
|
218
220
|
/**
|
|
219
221
|
* T4 (OBS-265): the journal with the review objections a round did NOT hinge on removed. Judge and
|
|
@@ -289,6 +291,11 @@ function approvedReviewRoundCeiling(events, taskId) {
|
|
|
289
291
|
}
|
|
290
292
|
const BLOCKED_POLL_MS = 30_000; // between trailer-wait slices, check whether the pane is blocked on a prompt
|
|
291
293
|
export const SUITE_POLL_MS = 250;
|
|
294
|
+
// ponytail: one fixed ceiling on the live-suite wait; the baseline's test ceilingMs is the upgrade path
|
|
295
|
+
export const SUITE_WAIT_CEILING_MS = 600_000;
|
|
296
|
+
let suiteWaitCeilingMs = SUITE_WAIT_CEILING_MS;
|
|
297
|
+
export const setSuiteWaitCeilingForTests = (ms) => { suiteWaitCeilingMs = ms; };
|
|
298
|
+
export const resetSuiteWaitCeilingForTests = () => { suiteWaitCeilingMs = SUITE_WAIT_CEILING_MS; };
|
|
292
299
|
export const APPROVAL_POLL_MS = 250;
|
|
293
300
|
const PROVIDER_DEATH_REQUEUE_CAP = 2; // v1.46 T1: requeue same assignment twice, then fall through to the normal ladder
|
|
294
301
|
const PROVIDER_DEATH_BACKOFF_MS = 500; // short backoff before provider-death requeue
|
|
@@ -339,6 +346,7 @@ export function resetNudgeTimingForTests() {
|
|
|
339
346
|
// diff merely quotes "rate limit" keeps working undisturbed).
|
|
340
347
|
const QUOTA_BANNER_SILENT_MS = 3 * 60_000;
|
|
341
348
|
let quotaBannerSilentMs = QUOTA_BANNER_SILENT_MS;
|
|
349
|
+
const WORKER_STARTUP_FAILURE_RE = /(?:not logged in|authentication(?:[ _-](?:required|failed|error))|401 unauthorized|model[_ -]?not[_ -]?found|(?:model|deployment)[^\n]{0,80}(?:not found|does not exist|is unavailable)|(?:404|not[_ -]?found)[^\n]{0,80}(?:model|deployment))/i;
|
|
342
350
|
/** Test seam — shrink the quota-banner silence gate without minute-long sleeps. */
|
|
343
351
|
export function setQuotaBannerSilentMsForTests(ms) {
|
|
344
352
|
quotaBannerSilentMs = ms;
|
|
@@ -642,7 +650,11 @@ export function countLiveSuites(snapshot, repoRoot, daemonPid = process.pid, cwd
|
|
|
642
650
|
}
|
|
643
651
|
}
|
|
644
652
|
const root = realpathSync(repoRoot);
|
|
645
|
-
|
|
653
|
+
// OBS-889 (run 3372): a bare-codex worker carries its whole prompt in argv and the prompt names the
|
|
654
|
+
// runner ("…as a vitest test whose…"), so a finished interactive worker counted as a live suite and
|
|
655
|
+
// held a task's gates for 9 min 46 s. A runner is named in a command's HEAD — `node <bin>`,
|
|
656
|
+
// `npm test`, `npx vitest`, `sh -c npm test` — never 140 KB into it: read the first four tokens only.
|
|
657
|
+
const candidates = rows.filter((row) => !ancestors.has(row.pid) && SUITE_COMMAND_RE.test(row.command.split(/\s+/, 4).join(" ")));
|
|
646
658
|
const attributable = new Set(candidates.filter((row) => {
|
|
647
659
|
if (descendants.has(row.pid))
|
|
648
660
|
return true;
|
|
@@ -1219,6 +1231,8 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1219
1231
|
const trackedDriver = {
|
|
1220
1232
|
id: driver.id,
|
|
1221
1233
|
interactive: driver.interactive,
|
|
1234
|
+
...(driver.readSource ? { readSource: driver.readSource } : {}),
|
|
1235
|
+
...(driver.describe ? { describe: driver.describe.bind(driver) } : {}),
|
|
1222
1236
|
slot: async (cwd, name, o) => { const s = await driver.slot(cwd, name, o); liveSlots.add(s); return s; },
|
|
1223
1237
|
run: (s, cmd) => driver.run(s, cmd),
|
|
1224
1238
|
waitOutput: (s, p, ms, o) => driver.waitOutput(s, p, ms, o),
|
|
@@ -1227,10 +1241,25 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1227
1241
|
read: (s, n) => driver.read(s, n),
|
|
1228
1242
|
...(driver.sendKey ? { sendKey: driver.sendKey.bind(driver) } : {}),
|
|
1229
1243
|
...(driver.nudge ? { nudge: driver.nudge.bind(driver) } : {}),
|
|
1244
|
+
...(driver.narrator ? { narrator: async (cwd, command, id) => {
|
|
1245
|
+
const s = await driver.narrator(cwd, command, id);
|
|
1246
|
+
liveSlots.add(s);
|
|
1247
|
+
return s;
|
|
1248
|
+
} } : {}),
|
|
1249
|
+
...(driver.project ? { project: driver.project.bind(driver) } : {}),
|
|
1250
|
+
...(driver.reconcile ? { reconcile: driver.reconcile.bind(driver) } : {}),
|
|
1230
1251
|
notify: (m, o) => driver.notify(m, o),
|
|
1231
1252
|
close: closeSlot,
|
|
1232
1253
|
worktree: (r, b, base) => driver.worktree(r, b, base),
|
|
1233
1254
|
};
|
|
1255
|
+
const absentCapabilities = new Set();
|
|
1256
|
+
const noteCapabilityAbsent = (capability) => {
|
|
1257
|
+
if (absentCapabilities.has(capability)
|
|
1258
|
+
|| journal.read().some((event) => event.event === "driver-capability-absent" && event.data.capability === capability))
|
|
1259
|
+
return;
|
|
1260
|
+
absentCapabilities.add(capability);
|
|
1261
|
+
journal.append("driver-capability-absent", undefined, { driver: driver.id, capability });
|
|
1262
|
+
};
|
|
1234
1263
|
// Termination (SIGINT/SIGTERM): record the daemon-controlled exit before closing every live slot,
|
|
1235
1264
|
// reconcile owned panes against an EMPTY
|
|
1236
1265
|
// desired set (herdr panes not in memory; panesToClose spares foreign names, watch panes, and
|
|
@@ -1255,6 +1284,9 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1255
1284
|
catch (error) {
|
|
1256
1285
|
console.error(`tickmarkr ${runId}: deliberate exit cause could not be journalled (${error instanceof Error ? error.message : String(error)})`);
|
|
1257
1286
|
}
|
|
1287
|
+
// Stop the scheduler before the first awaited retirement. Otherwise a freed slot can fund a
|
|
1288
|
+
// new attempt while this reaper is still closing the old ones.
|
|
1289
|
+
abortRun(new Error(`terminated by ${sig}`));
|
|
1258
1290
|
if (cfg.visibility.keepPanes !== "forever") {
|
|
1259
1291
|
for (const s of liveSlots) { // closeSlot only deletes the element being visited — safe during Set iteration
|
|
1260
1292
|
try {
|
|
@@ -1269,13 +1301,15 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1269
1301
|
}
|
|
1270
1302
|
releaseRunLock(repoRoot); // the process dies at exit() below — the finally never runs on this path
|
|
1271
1303
|
}
|
|
1272
|
-
abortRun(new Error(`terminated by ${sig}`));
|
|
1273
1304
|
exit(sig === "SIGINT" ? 130 : 143);
|
|
1274
1305
|
})();
|
|
1275
1306
|
};
|
|
1276
1307
|
process.on("SIGINT", onTermination);
|
|
1277
1308
|
process.on("SIGTERM", onTermination);
|
|
1278
1309
|
journal = opts.resume ? Journal.open(repoRoot, runId, opts.narrate) : Journal.create(repoRoot, runId, opts.narrate);
|
|
1310
|
+
const reviewHistory = journal.read()
|
|
1311
|
+
.filter((event) => event.event === "gate-result" && event.data.gate === "review" && typeof event.data.reviewer === "string")
|
|
1312
|
+
.map((event) => event.data.reviewer);
|
|
1279
1313
|
// Capture this before this observer appends run-resume. A prior run-end belongs to a completed
|
|
1280
1314
|
// lifecycle and must use the ordinary resume/redispatch rules; only an interrupted live attempt
|
|
1281
1315
|
// owns reusable measurements or unclean-death residue.
|
|
@@ -1305,6 +1339,20 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1305
1339
|
const approved = new Set(approvalStartupEvents.filter((e) => e.event === "task-approved" && e.taskId).map((e) => e.taskId));
|
|
1306
1340
|
let approvalSweepCursor = approvalStartupEvents.length;
|
|
1307
1341
|
const commands = detectGateCommands(repoRoot, cfg);
|
|
1342
|
+
const watchName = formatOwnedName({ role: "watch", taskId: "run", attempt: 0, runId });
|
|
1343
|
+
let watchSlot;
|
|
1344
|
+
const openBoard = async () => {
|
|
1345
|
+
if (!trackedDriver.narrator) {
|
|
1346
|
+
noteCapabilityAbsent("narrator");
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
try {
|
|
1350
|
+
watchSlot = await trackedDriver.narrator(repoRoot, watchCommand(runId), runId);
|
|
1351
|
+
}
|
|
1352
|
+
catch (error) {
|
|
1353
|
+
journal.append("watch-placement-failed", undefined, { error: error instanceof Error ? error.message : String(error) });
|
|
1354
|
+
}
|
|
1355
|
+
};
|
|
1308
1356
|
let baseRef;
|
|
1309
1357
|
let baseline;
|
|
1310
1358
|
// Phase 46 (RES-01/RES-02): the resume-state map is built ONCE here so execTask closes over it.
|
|
@@ -1372,9 +1420,12 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1372
1420
|
...(replayedExclusions.size > 0 ? { excludedChannels: [...replayedExclusions].sort() } : {}),
|
|
1373
1421
|
...(opts.retryFailed ? { retryFailed: true } : {}),
|
|
1374
1422
|
});
|
|
1423
|
+
await openBoard();
|
|
1375
1424
|
}
|
|
1376
1425
|
else {
|
|
1377
1426
|
baseRef = await gitHead(repoRoot);
|
|
1427
|
+
journal.append("baseline-start", undefined, { baseRef, commands });
|
|
1428
|
+
await openBoard();
|
|
1378
1429
|
baseline = await captureBaseline(repoRoot, commands);
|
|
1379
1430
|
writeFileSync(join(journal.dir, "baseline.json"), JSON.stringify(baseline, null, 2));
|
|
1380
1431
|
writeFileSync(join(journal.dir, "graph.json"), readFileSync(join(tickmarkrDir(repoRoot), "graph.json")));
|
|
@@ -1420,21 +1471,6 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1420
1471
|
const collateral = pinnedCollateral ?? collateralHits(graph.tasks, repoRoot);
|
|
1421
1472
|
if (!pinnedCollateral)
|
|
1422
1473
|
writeFileSync(collateralPath, JSON.stringify(Object.fromEntries(collateral), null, 2));
|
|
1423
|
-
// T6: open the narrator AFTER run-start/run-resume is journaled so the watch surface has a run to
|
|
1424
|
-
// show. driver.narrator is undefined on subprocess → no-op (subprocess spawns nothing). Swallowed:
|
|
1425
|
-
// a failed-to-open or later-dead watch pane never affects the run.
|
|
1426
|
-
// OBS-103: hold the returned slot — narrator() returns the run's board under its canonical owned
|
|
1427
|
-
// name whichever way it got there (the herdr driver retires a survivor it finds and re-splits, so
|
|
1428
|
-
// the live command is run-bound), and the run-end sweep below retires it by that name regardless
|
|
1429
|
-
// of which daemon instance split the pane.
|
|
1430
|
-
const watchName = formatOwnedName({ role: "watch", taskId: "run", attempt: 0, runId });
|
|
1431
|
-
let watchSlot;
|
|
1432
|
-
try {
|
|
1433
|
-
watchSlot = await driver.narrator?.(repoRoot, watchCommand(runId), runId);
|
|
1434
|
-
}
|
|
1435
|
-
catch (error) {
|
|
1436
|
-
journal.append("watch-placement-failed", undefined, { error: error instanceof Error ? error.message : String(error) });
|
|
1437
|
-
}
|
|
1438
1474
|
const intWt = await ensureIntegration(repoRoot, branch, baseRef);
|
|
1439
1475
|
// v1.1 visibility: role-named slots; panes persist per keepPanes (attempt = v1 close-after-harvest)
|
|
1440
1476
|
const keepOpen = cfg.visibility.keepPanes !== "attempt";
|
|
@@ -1473,7 +1509,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1473
1509
|
if (watchSlot && watchSlot.name === watchName && !desired.has(watchName)) {
|
|
1474
1510
|
const w = watchSlot;
|
|
1475
1511
|
watchSlot = undefined;
|
|
1476
|
-
await
|
|
1512
|
+
await trackedDriver.close(w);
|
|
1477
1513
|
}
|
|
1478
1514
|
}
|
|
1479
1515
|
catch {
|
|
@@ -1525,10 +1561,17 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1525
1561
|
await previous;
|
|
1526
1562
|
try {
|
|
1527
1563
|
let lastCount = -1;
|
|
1564
|
+
const startedAt = Date.now();
|
|
1528
1565
|
for (;;) {
|
|
1529
1566
|
const count = await liveSuiteCount(repoRoot);
|
|
1530
1567
|
if (count === 0)
|
|
1531
1568
|
break;
|
|
1569
|
+
// OBS-889: a census that never reaches zero held T5's gates with no row and no end. Proceed at
|
|
1570
|
+
// the ceiling, flagged: the verdict that follows was produced beside whatever is still counted.
|
|
1571
|
+
if (Date.now() - startedAt >= suiteWaitCeilingMs) {
|
|
1572
|
+
journal.append("suite-wait-ceiling", taskId, { count, waitedMs: Date.now() - startedAt });
|
|
1573
|
+
break;
|
|
1574
|
+
}
|
|
1532
1575
|
if (count !== lastCount)
|
|
1533
1576
|
journal.append("suite-wait", taskId, { count });
|
|
1534
1577
|
lastCount = count;
|
|
@@ -1799,6 +1842,14 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1799
1842
|
// `test` row whose scope no consumer could recover.
|
|
1800
1843
|
...(Array.isArray(g.meta?.selectedTests) ? { selectedTests: g.meta.selectedTests } : {}),
|
|
1801
1844
|
...(g.meta?.fullSuite === true ? { fullSuite: true } : {}),
|
|
1845
|
+
...(g.meta?.reapedGroup === true ? { reapedGroup: true } : {}),
|
|
1846
|
+
...(g.gate === "review" && typeof g.meta?.reviewer === "string" ? {
|
|
1847
|
+
reviewer: g.meta.reviewer,
|
|
1848
|
+
...(typeof g.meta.vendor === "string" ? { vendor: g.meta.vendor } : {}),
|
|
1849
|
+
...(typeof g.meta.provider === "string" ? { provider: g.meta.provider } : {}),
|
|
1850
|
+
...(typeof g.meta.rotationSeat === "number" ? { rotationSeat: g.meta.rotationSeat } : {}),
|
|
1851
|
+
...(typeof g.meta.timeoutMs === "number" ? { timeoutMs: g.meta.timeoutMs } : {}),
|
|
1852
|
+
} : {}),
|
|
1802
1853
|
// A finding's path is its own evidence path. Do not pass task scope here: a declaration says
|
|
1803
1854
|
// where work is allowed, not where this verdict found the defect.
|
|
1804
1855
|
...(blocking ? {
|
|
@@ -1820,9 +1871,9 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1820
1871
|
// T7: the capacity the gate's own command child ran under, lifted verbatim from the result
|
|
1821
1872
|
// the battery produced — read where the shell built that child's environment, never
|
|
1822
1873
|
// re-derived from the run's own budget, which would answer a different number than the
|
|
1823
|
-
// operator's export did. It is this row's only COMPARABLE identity: the
|
|
1824
|
-
//
|
|
1825
|
-
//
|
|
1874
|
+
// operator's export did. It is this row's only COMPARABLE identity: the load samples describe
|
|
1875
|
+
// contention while capacity describes how the command divided the machine, and matching
|
|
1876
|
+
// capacity never claims the machine was calm. A gate that ran
|
|
1826
1877
|
// no command carries nothing — it divided nothing.
|
|
1827
1878
|
//
|
|
1828
1879
|
// `dirtiedBy` is the one hole in that lift: run-gates REPLACES a green battery verdict with
|
|
@@ -2167,6 +2218,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2167
2218
|
// deterministic-fingerprint occurrence/review round for budget accounting.
|
|
2168
2219
|
...(!satisfiedGate && !recheck ? { replayMeasurement: true } : {}),
|
|
2169
2220
|
};
|
|
2221
|
+
await trackedDriver.project?.(t.id, "in-review");
|
|
2170
2222
|
journal.phaseStart(t.id, "gates");
|
|
2171
2223
|
const { results } = await withSuiteWindow(t.id, resumedTask.gates.includes("test") && commands.test !== undefined, () => runGates(resumedTask, {
|
|
2172
2224
|
worktree: wt, baseRef: taskBase, result: priorResult, author: gateAuthor,
|
|
@@ -2184,6 +2236,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2184
2236
|
}
|
|
2185
2237
|
: undefined,
|
|
2186
2238
|
excludeReviewers: badReviewers,
|
|
2239
|
+
reviewHistory,
|
|
2187
2240
|
onGate: async (e) => {
|
|
2188
2241
|
if (e.phase === "start") {
|
|
2189
2242
|
notePhaseStart(e);
|
|
@@ -2260,6 +2313,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2260
2313
|
attempts: rs?.attempts ?? 0, assignment: gateAuthor, taskContentDigest: contentDigest,
|
|
2261
2314
|
});
|
|
2262
2315
|
journal.append("merge", t.id, { branch: taskBranch, commit: await integrationHead(intWt) });
|
|
2316
|
+
await trackedDriver.project?.(t.id, "completed");
|
|
2263
2317
|
journal.telemetry({
|
|
2264
2318
|
taskId: t.id, shape: t.shape, adapter: gateAuthor.adapter, model: gateAuthor.model,
|
|
2265
2319
|
channel: gateAuthor.channel, attempts: rs?.attempts ?? 0, outcome: "done",
|
|
@@ -2430,11 +2484,13 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2430
2484
|
lastContextTokens = undefined;
|
|
2431
2485
|
graph = setStatus(graph, t.id, "running");
|
|
2432
2486
|
saveGraph(repoRoot, graph);
|
|
2487
|
+
await trackedDriver.project?.(t.id, "in-progress");
|
|
2433
2488
|
// T6: a dispatch that carries an outstanding finding says so, and names it. Without this the
|
|
2434
2489
|
// ledger cannot tell a carried dispatch from an amnesiac one — the exact question a run that
|
|
2435
2490
|
// spends two frontier attempts re-deriving a known defect has to be able to answer afterwards.
|
|
2436
2491
|
journal.append("task-dispatch", t.id, {
|
|
2437
2492
|
assignment, attempt, provenance: dispatchProvenance(r.provenance), retryMode,
|
|
2493
|
+
excludedChannels: [...demotedChannels].sort(),
|
|
2438
2494
|
...(outstandingFindings.length > 0 ? { carriedFindings: outstandingFindings } : {}),
|
|
2439
2495
|
...(carriedConsultGuidance ? { carriedConsultGuidance } : {}),
|
|
2440
2496
|
});
|
|
@@ -2546,6 +2602,8 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2546
2602
|
// itself before gates regardless of what the worker does with it.
|
|
2547
2603
|
writeFileSync(promptFile, `${WORKTREE_LAYOUT_CONTRACT}\n\n${workerContract}\n\n${readFileSync(promptFile, "utf8")}`);
|
|
2548
2604
|
const adapter = getAdapter(assignment.adapter, adapters);
|
|
2605
|
+
if (adapter.trustDialog && !trackedDriver.sendKey)
|
|
2606
|
+
noteCapabilityAbsent("sendKey");
|
|
2549
2607
|
// VIS-04: workers share one role tab. T2: `owned` names the pane canonically (ownership contract);
|
|
2550
2608
|
// the legacy name stays the fallback for drivers without owned handling (subprocess spies).
|
|
2551
2609
|
const workerSlotOpts = {
|
|
@@ -2599,7 +2657,12 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2599
2657
|
// between task-dispatch and this line — worktree recreation, setup, prompt write, slot allocation,
|
|
2600
2658
|
// the launch itself — can still die with no worker having seen the brief, and `--retry-failed`
|
|
2601
2659
|
// must then re-send that same brief rather than a fresh prompt on a possibly banned channel.
|
|
2602
|
-
const noteLaunched = () => journal.append("worker-launch", t.id, {
|
|
2660
|
+
const noteLaunched = async () => journal.append("worker-launch", t.id, {
|
|
2661
|
+
attempt,
|
|
2662
|
+
retryMode,
|
|
2663
|
+
...(trackedDriver.describe ? await trackedDriver.describe(slot) : {}),
|
|
2664
|
+
});
|
|
2665
|
+
const noteWorkerLiveness = (event, data) => journal.append(event, t.id, { ...data, source: trackedDriver.readSource ?? "driver.read" });
|
|
2603
2666
|
let cpuFlat;
|
|
2604
2667
|
let cpuAccountant;
|
|
2605
2668
|
let cpuGapCount = 0;
|
|
@@ -2731,6 +2794,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2731
2794
|
// subprocess tree that REACHED its exit marker, not about what the worker claimed.
|
|
2732
2795
|
let processExited = false;
|
|
2733
2796
|
let earlyLaunchDead = false;
|
|
2797
|
+
let startupFailure = false;
|
|
2734
2798
|
let deadWorkerPark;
|
|
2735
2799
|
let settleParsed;
|
|
2736
2800
|
let seedResult;
|
|
@@ -2813,7 +2877,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2813
2877
|
continue attempts;
|
|
2814
2878
|
return;
|
|
2815
2879
|
}
|
|
2816
|
-
noteLaunched();
|
|
2880
|
+
await noteLaunched();
|
|
2817
2881
|
output = seedResult.output;
|
|
2818
2882
|
}
|
|
2819
2883
|
else {
|
|
@@ -2827,14 +2891,15 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2827
2891
|
continue attempts;
|
|
2828
2892
|
return;
|
|
2829
2893
|
}
|
|
2830
|
-
noteLaunched();
|
|
2894
|
+
await noteLaunched();
|
|
2831
2895
|
output = await driver.read(slot, PANE_READ_ROWS);
|
|
2832
2896
|
}
|
|
2833
2897
|
// The returning paths report the same fact on the result; both callbacks land on the one
|
|
2834
2898
|
// latch, and the second is a no-op. A seed that answered is never re-answered by the loop.
|
|
2835
2899
|
if (seedResult?.trustAnswered)
|
|
2836
2900
|
noteSeedTrustAnswered();
|
|
2837
|
-
|
|
2901
|
+
startupFailure = WORKER_STARTUP_FAILURE_RE.test(stallSnapshotBannerRows(output));
|
|
2902
|
+
if (seedResult?.seedFailed || startupFailure) {
|
|
2838
2903
|
finished = false;
|
|
2839
2904
|
}
|
|
2840
2905
|
else {
|
|
@@ -2922,7 +2987,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2922
2987
|
throw error;
|
|
2923
2988
|
if (!paneReadHeld) {
|
|
2924
2989
|
paneReadHeld = true;
|
|
2925
|
-
|
|
2990
|
+
noteWorkerLiveness("worker-dead-held", {
|
|
2926
2991
|
slot: slot.name, attempt, reason: "pane-read-unreadable",
|
|
2927
2992
|
error: error instanceof Error ? error.message : String(error),
|
|
2928
2993
|
});
|
|
@@ -2935,6 +3000,11 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2935
3000
|
}
|
|
2936
3001
|
if (paneText.length > 0)
|
|
2937
3002
|
everHadOutput = true;
|
|
3003
|
+
if (WORKER_STARTUP_FAILURE_RE.test(stallSnapshotBannerRows(paneText))) {
|
|
3004
|
+
startupFailure = true;
|
|
3005
|
+
output = paneText;
|
|
3006
|
+
break;
|
|
3007
|
+
}
|
|
2938
3008
|
// OBS-117 (v1.71 T6): zero raw output by the early-launch deadline is a dead channel now.
|
|
2939
3009
|
if (!everHadOutput && Date.now() - attemptStart >= earlyLaunchLivenessMs) {
|
|
2940
3010
|
earlyLaunchDead = true;
|
|
@@ -3008,7 +3078,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3008
3078
|
catch (error) {
|
|
3009
3079
|
if (!paneStatusHeld) {
|
|
3010
3080
|
paneStatusHeld = true;
|
|
3011
|
-
|
|
3081
|
+
noteWorkerLiveness("worker-dead-held", {
|
|
3012
3082
|
slot: slot.name, attempt, reason: "pane-status-unreadable",
|
|
3013
3083
|
error: error instanceof Error ? error.message : String(error),
|
|
3014
3084
|
});
|
|
@@ -3085,7 +3155,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3085
3155
|
paneAbsent = false;
|
|
3086
3156
|
if (!paneReadHeld) {
|
|
3087
3157
|
paneReadHeld = true;
|
|
3088
|
-
|
|
3158
|
+
noteWorkerLiveness("worker-dead-held", {
|
|
3089
3159
|
slot: slot.name, attempt, reason: "pane-read-unreadable",
|
|
3090
3160
|
error: error instanceof Error ? error.message : String(error),
|
|
3091
3161
|
});
|
|
@@ -3109,7 +3179,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3109
3179
|
paneAbsent = false;
|
|
3110
3180
|
if (!paneReadHeld) {
|
|
3111
3181
|
paneReadHeld = true;
|
|
3112
|
-
|
|
3182
|
+
noteWorkerLiveness("worker-dead-held", {
|
|
3113
3183
|
slot: slot.name, attempt, reason: "pane-read-unreadable",
|
|
3114
3184
|
error: error instanceof Error ? error.message : String(error),
|
|
3115
3185
|
});
|
|
@@ -3133,7 +3203,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3133
3203
|
if (deathCertain) {
|
|
3134
3204
|
const preservation = await preserveDeadWorker(wt, taskBase);
|
|
3135
3205
|
if (!preservation.ref) {
|
|
3136
|
-
|
|
3206
|
+
noteWorkerLiveness("worker-dead-held", {
|
|
3137
3207
|
slot: slot.name, attempt, reason: `worktree-${preservation.state}`,
|
|
3138
3208
|
});
|
|
3139
3209
|
continue;
|
|
@@ -3142,7 +3212,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3142
3212
|
const reason = `worker is unambiguously dead: pane absent, process tree empty, and worktree unchanged; preserved at ${ref}`;
|
|
3143
3213
|
deadWorkerPark = { ref, reason };
|
|
3144
3214
|
journal.append("worktree-preserved", t.id, { ref });
|
|
3145
|
-
|
|
3215
|
+
noteWorkerLiveness("worker-dead-held", {
|
|
3146
3216
|
slot: slot.name, attempt, reason: "unambiguous-worker-death", ref,
|
|
3147
3217
|
});
|
|
3148
3218
|
break;
|
|
@@ -3158,8 +3228,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3158
3228
|
// Everything the fast-kill can decide from the CHANNEL, evaluated before the CPU leg is
|
|
3159
3229
|
// asked for anything: the kill's own probe costs a `ps` every 100ms, so an ineligible
|
|
3160
3230
|
// slice must not pay for it. The CPU reading is consulted at the kill itself, below.
|
|
3161
|
-
const fastKillEligible = !
|
|
3162
|
-
&& !nudgePending && !nudgeFailed
|
|
3231
|
+
const fastKillEligible = !nudgePending && !nudgeFailed
|
|
3163
3232
|
&& sliceNow - lastOutputGrowthAt >= deadChannelFastKillMs
|
|
3164
3233
|
&& worktreeSinceLaunch === "unchanged";
|
|
3165
3234
|
const harvestCpuEligible = !nudgePending && !nudgeFailed
|
|
@@ -3200,14 +3269,10 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3200
3269
|
// (an elapsed "9s"→"10s" lengthens the read and would hide a frozen pane). But the
|
|
3201
3270
|
// tracker SHARES the read window's ceiling: its row signal saturates once a sample
|
|
3202
3271
|
// FILLS a PANE_READ_ROWS read on raw lines (blanks/chrome included — see stall.ts),
|
|
3203
|
-
// and past that point a flat tracker means "unmeasurable", not "dead".
|
|
3204
|
-
//
|
|
3205
|
-
//
|
|
3206
|
-
//
|
|
3207
|
-
// worker past the ceiling would be concluded dead mid-work. So the kill STANDS DOWN on
|
|
3208
|
-
// a saturated row signal (journaled once per attempt); the rolling window still owns
|
|
3209
|
-
// that pane, exactly as pre-T1. Token growth counts as life either way, so a metered
|
|
3210
|
-
// worker thinking through a long tool run survives.
|
|
3272
|
+
// and past that point a flat tracker means "unmeasurable", not "dead". Journal that
|
|
3273
|
+
// unreadability once, then let the independent CPU and worktree legs decide: accruing or
|
|
3274
|
+
// unreadable CPU preserves the worker; flat CPU beside an unchanged tree may conclude.
|
|
3275
|
+
// A saturated row alone is never a death verdict.
|
|
3211
3276
|
// The triad has NO status exemption: a pane that herdr reports as blocked, idle, working
|
|
3212
3277
|
// or unknown dies alike once it holds no trailer, an unchanged tree and no growth — waiting the
|
|
3213
3278
|
// rolling window out on a status reading is exactly the blindness T1 removes. A matched
|
|
@@ -3221,7 +3286,9 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3221
3286
|
// filesystem/git observation answer that clause.
|
|
3222
3287
|
if (stallProgress.rowSignalSaturated && !rowSaturationHeld) {
|
|
3223
3288
|
rowSaturationHeld = true;
|
|
3224
|
-
journal.append("
|
|
3289
|
+
journal.append("contact-unreadable", t.id, {
|
|
3290
|
+
slot: slot.name, attempt, source: "pane-rows", reason: "row-signal-saturated", concludes: false,
|
|
3291
|
+
});
|
|
3225
3292
|
}
|
|
3226
3293
|
// OBS-548: the FOURTH leg, and the one the daemon already measured. The three legs above
|
|
3227
3294
|
// are all channel-side: they say a pane stopped talking. The tree's CPU says whether
|
|
@@ -3238,12 +3305,13 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3238
3305
|
journal.append("worker-dead", t.id, {
|
|
3239
3306
|
slot: slot.name, attempt, silentMs: sliceNow - lastOutputGrowthAt,
|
|
3240
3307
|
cpuMs: leg.cpu.ms, cpuResolutionMs: leg.cpu.resolutionMs,
|
|
3308
|
+
source: trackedDriver.readSource ?? "driver.read",
|
|
3241
3309
|
});
|
|
3242
3310
|
break;
|
|
3243
3311
|
}
|
|
3244
3312
|
if (!cpuHeld) {
|
|
3245
3313
|
cpuHeld = true;
|
|
3246
|
-
|
|
3314
|
+
noteWorkerLiveness("worker-dead-held", {
|
|
3247
3315
|
slot: slot.name, attempt, reason: leg.state === "accruing" ? "cpu-accruing" : "cpu-unmeasurable",
|
|
3248
3316
|
});
|
|
3249
3317
|
}
|
|
@@ -3371,11 +3439,12 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3371
3439
|
continue attempts;
|
|
3372
3440
|
return;
|
|
3373
3441
|
}
|
|
3374
|
-
noteLaunched();
|
|
3442
|
+
await noteLaunched();
|
|
3375
3443
|
// OBS-54: headless workers have the same output-inactivity budget as visible panes.
|
|
3376
3444
|
// v1.76: same monotonic-progress measure as the interactive site; harvest stays raw.
|
|
3377
3445
|
const stallWindowMs = taskTimeoutMinutes * 60_000;
|
|
3378
3446
|
const initialPane = await driver.read(slot, 500);
|
|
3447
|
+
startupFailure = WORKER_STARTUP_FAILURE_RE.test(stallSnapshotBannerRows(initialPane));
|
|
3379
3448
|
let everHadOutput = initialPane.length > 0;
|
|
3380
3449
|
const stallProgress = new StallProgressTracker();
|
|
3381
3450
|
stallProgress.observe({ paneText: initialPane, seedSubmitted: true, contextTokens });
|
|
@@ -3388,7 +3457,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3388
3457
|
// worker-result-harvested row. The interactive site has always kept them apart (`finished`
|
|
3389
3458
|
// there is the trailer regex; the exit marker only sets exitCode), and the cause taxonomy
|
|
3390
3459
|
// already names this shape "clean-exit-no-trailer" — unreachable in print mode until now.
|
|
3391
|
-
while (Date.now() - lastProgressAt < stallWindowMs) {
|
|
3460
|
+
while (!startupFailure && Date.now() - lastProgressAt < stallWindowMs) {
|
|
3392
3461
|
const remaining = stallWindowMs - (Date.now() - lastProgressAt);
|
|
3393
3462
|
let slice = Math.min(BLOCKED_POLL_MS, Math.max(100, Math.min(stallWindowMs / 2, remaining)));
|
|
3394
3463
|
if (!everHadOutput) {
|
|
@@ -3405,6 +3474,10 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3405
3474
|
const paneText = await driver.read(slot, 500);
|
|
3406
3475
|
if (paneText.length > 0)
|
|
3407
3476
|
everHadOutput = true;
|
|
3477
|
+
if (WORKER_STARTUP_FAILURE_RE.test(stallSnapshotBannerRows(paneText))) {
|
|
3478
|
+
startupFailure = true;
|
|
3479
|
+
break;
|
|
3480
|
+
}
|
|
3408
3481
|
if (!everHadOutput && Date.now() - attemptStart >= earlyLaunchLivenessMs) {
|
|
3409
3482
|
earlyLaunchDead = true;
|
|
3410
3483
|
break;
|
|
@@ -3466,7 +3539,9 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3466
3539
|
}
|
|
3467
3540
|
let result = settleParsed ?? adapter.parse(output, nonce);
|
|
3468
3541
|
const workerFinished = finished;
|
|
3469
|
-
const workerCause =
|
|
3542
|
+
const workerCause = startupFailure
|
|
3543
|
+
? "startup-failure"
|
|
3544
|
+
: classifyWorkerResultCause({ output, ok: result.ok, finished, exitCode, summary: result.summary, timedOut, deadChannel: deadChannelKilled });
|
|
3470
3545
|
journal.append("worker-result", t.id, {
|
|
3471
3546
|
ok: result.ok, summary: result.summary, deviations: result.deviations, finished: workerFinished, exitCode,
|
|
3472
3547
|
mode: interactive ? "interactive" : "print", ...(workerCause ? { cause: workerCause } : {}),
|
|
@@ -3583,7 +3658,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3583
3658
|
// T2 review: classify the PRE-HARVEST parse — classifyDeadChannel bails on any ok:true
|
|
3584
3659
|
// result, so reading the synthesized harvest result would swallow auth-required /
|
|
3585
3660
|
// setup-required / provider-outage for every committed-but-walled attempt (in both modes).
|
|
3586
|
-
const dead = classifyDeadChannel(preHarvestResult) ?? (earlyLaunchDead ? "setup-required" : undefined);
|
|
3661
|
+
const dead = classifyDeadChannel(preHarvestResult) ?? (earlyLaunchDead || startupFailure ? "setup-required" : undefined);
|
|
3587
3662
|
if (dead) {
|
|
3588
3663
|
const from = channelKey(assignment);
|
|
3589
3664
|
demotedChannels.add(from); // excluded for later attempts AND later tasks in this run
|
|
@@ -3690,6 +3765,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3690
3765
|
gateLoop: while (true) {
|
|
3691
3766
|
const gated = await gitHead(wt);
|
|
3692
3767
|
gateSubject = { commit: await gateCommitSubject(taskBase, gated, wt), attempt };
|
|
3768
|
+
await trackedDriver.project?.(t.id, "in-review");
|
|
3693
3769
|
journal.phaseStart(t.id, "gates");
|
|
3694
3770
|
({ results, commits } = await withSuiteWindow(t.id, t.gates.includes("test") && commands.test !== undefined, () => runGates(t, {
|
|
3695
3771
|
worktree: wt, baseRef: taskBase, result, author: assignment,
|
|
@@ -3713,6 +3789,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3713
3789
|
}
|
|
3714
3790
|
: undefined,
|
|
3715
3791
|
excludeReviewers: badReviewers,
|
|
3792
|
+
reviewHistory,
|
|
3716
3793
|
onGate,
|
|
3717
3794
|
})));
|
|
3718
3795
|
results.forEach(classifySignalOnlyTest);
|
|
@@ -3751,6 +3828,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3751
3828
|
attempts: attempt + 1, assignment, taskContentDigest: contentDigest,
|
|
3752
3829
|
});
|
|
3753
3830
|
journal.append("merge", t.id, { branch: taskBranch, commit: await integrationHead(intWt) });
|
|
3831
|
+
await trackedDriver.project?.(t.id, "completed");
|
|
3754
3832
|
// firstAttemptOk/gateFails/consults are recorded FACTS, not policy — a parkKind:"stall" row is
|
|
3755
3833
|
// recorded but NOT quality-negative in v1.6; Phase 12 owns reward policy, so flipping it later needs zero data migration.
|
|
3756
3834
|
journal.telemetry({ taskId: t.id, shape: t.shape, adapter: assignment.adapter, model: assignment.model, channel: assignment.channel, attempts: attempt + 1, outcome: "done", durationMs: Date.now() - startMs, firstAttemptOk: attempt === 0, gateFails, consults, tokens, meteredAttempts: tokens ? metered : undefined, retryMode });
|
package/dist/run/git.d.ts
CHANGED
|
@@ -90,6 +90,8 @@ export interface ShResult {
|
|
|
90
90
|
stdout: string;
|
|
91
91
|
stderr: string;
|
|
92
92
|
timedOut?: boolean;
|
|
93
|
+
/** The shell exited, but descendants still held its process group and pipes past the grace. */
|
|
94
|
+
reapedGroup?: boolean;
|
|
93
95
|
durationMs?: number;
|
|
94
96
|
/** T7: the capacity THIS child ran under, stamped where its environment was built (see `shell`). */
|
|
95
97
|
capacity?: RunCapacity;
|
package/dist/run/git.js
CHANGED
|
@@ -138,6 +138,9 @@ export const resetSpawnForTests = () => { spawnChild = undefined; };
|
|
|
138
138
|
const RETRYABLE_SPAWN_CODE = "EAGAIN";
|
|
139
139
|
export const SPAWN_ATTEMPT_LIMIT = 4;
|
|
140
140
|
export const SPAWN_RETRY_BACKOFF_MS = 50;
|
|
141
|
+
// RULING-P99-28: SHIP — every gate user can hit the inherited-pipe hang at this product seam.
|
|
142
|
+
/** Give a normally-exited shell's descendants this long to close their inherited pipes themselves. */
|
|
143
|
+
const SHELL_REAP_GRACE_MS = 2000;
|
|
141
144
|
// stdin "ignore": same class as HARD-05 / SubprocessDriver — never leave an open pipe a child can block on
|
|
142
145
|
// (pi -p / codex exec wait for stdin EOF). timedOut distinguishes SIGKILL-timeout from a real nonzero exit.
|
|
143
146
|
function shell(cmd, cwd, timeoutMs, login) {
|
|
@@ -170,15 +173,25 @@ function shell(cmd, cwd, timeoutMs, login) {
|
|
|
170
173
|
let stdout = "", stderr = "";
|
|
171
174
|
const stdoutDecoder = new StringDecoder("utf8");
|
|
172
175
|
const stderrDecoder = new StringDecoder("utf8");
|
|
173
|
-
let timedOut = false, done = false, started = false, outputSeen = false;
|
|
176
|
+
let timedOut = false, reapedGroup = false, done = false, started = false, outputSeen = false;
|
|
177
|
+
let reapTimer;
|
|
174
178
|
const finish = (code, err) => {
|
|
175
179
|
if (done)
|
|
176
180
|
return;
|
|
177
181
|
done = true;
|
|
178
182
|
clearTimeout(timer);
|
|
183
|
+
clearTimeout(reapTimer);
|
|
179
184
|
stdout += stdoutDecoder.end();
|
|
180
185
|
stderr += stderrDecoder.end();
|
|
181
|
-
resolve({
|
|
186
|
+
resolve({
|
|
187
|
+
code,
|
|
188
|
+
stdout,
|
|
189
|
+
stderr: err ?? stderr,
|
|
190
|
+
timedOut,
|
|
191
|
+
durationMs: Date.now() - startedAt,
|
|
192
|
+
capacity,
|
|
193
|
+
...(reapedGroup ? { reapedGroup: true } : {}),
|
|
194
|
+
});
|
|
182
195
|
};
|
|
183
196
|
const timer = setTimeout(() => {
|
|
184
197
|
timedOut = true;
|
|
@@ -215,9 +228,27 @@ function shell(cmd, cwd, timeoutMs, login) {
|
|
|
215
228
|
finish(127, String(e));
|
|
216
229
|
});
|
|
217
230
|
p.on("close", (code) => finish(code ?? 1));
|
|
218
|
-
// "close" waits for stdio to drain
|
|
219
|
-
|
|
220
|
-
|
|
231
|
+
// "close" waits for stdio to drain. Once bash exits normally, give descendants a bounded grace
|
|
232
|
+
// to exit with it; a survivor still in bash's detached group is then reaped so its inherited pipe
|
|
233
|
+
// cannot hold this promise until the command ceiling. A real timeout wins first and is never
|
|
234
|
+
// reclassified as a grace reap.
|
|
235
|
+
p.on("exit", (code) => {
|
|
236
|
+
if (timedOut) {
|
|
237
|
+
finish(code ?? 1);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
reapTimer = setTimeout(() => {
|
|
241
|
+
if (done)
|
|
242
|
+
return;
|
|
243
|
+
try {
|
|
244
|
+
process.kill(-p.pid, "SIGKILL");
|
|
245
|
+
reapedGroup = true;
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// The group closed between the shell's exit and the grace boundary; "close" owns finish.
|
|
249
|
+
}
|
|
250
|
+
}, SHELL_REAP_GRACE_MS);
|
|
251
|
+
});
|
|
221
252
|
});
|
|
222
253
|
return (async () => {
|
|
223
254
|
const startedAt = Date.now();
|
package/dist/run/journal.js
CHANGED
|
@@ -5,6 +5,7 @@ import { z } from "zod";
|
|
|
5
5
|
import { channelKey, shq, TokenUsageSchema } from "../adapters/types.js";
|
|
6
6
|
import { stateDirName, taskContentDigest, tickmarkrDir } from "../graph/graph.js";
|
|
7
7
|
import { GATE_NAMES, TIERS } from "../graph/schema.js";
|
|
8
|
+
import { channelRouteIdentity } from "../route/preference.js";
|
|
8
9
|
import { buildProfile, classify } from "../route/profile.js";
|
|
9
10
|
import { DecisionEventSchema, trackJournalRows, } from "./protocol.js";
|
|
10
11
|
import { normalizeGateOutcome } from "./outcome.js";
|
|
@@ -752,7 +753,9 @@ export function activeRetryBan(events, taskId, channel) {
|
|
|
752
753
|
|| (e.event === "task-approved" && e.data.release === RECHECK_RELEASE))
|
|
753
754
|
pending = undefined;
|
|
754
755
|
}
|
|
755
|
-
return pending &&
|
|
756
|
+
return pending && typeof pending.data.channel === "string"
|
|
757
|
+
&& channelRouteIdentity(pending.data.channel) === channelRouteIdentity(channel)
|
|
758
|
+
&& typeof pending.data.gate === "string"
|
|
756
759
|
? pending.data.gate
|
|
757
760
|
: undefined;
|
|
758
761
|
}
|