tickmarkr 2.2.1 → 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/README.md +10 -9
- 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 +21 -1
- package/dist/adapters/types.js +43 -2
- package/dist/cli/commands/approve.js +5 -4
- package/dist/cli/commands/beat.js +7 -4
- package/dist/cli/commands/compile.js +32 -6
- package/dist/cli/commands/doctor.d.ts +9 -4
- package/dist/cli/commands/doctor.js +87 -13
- package/dist/cli/commands/fleet.d.ts +4 -0
- package/dist/cli/commands/fleet.js +53 -14
- package/dist/cli/commands/init.js +36 -21
- package/dist/cli/commands/plan.js +45 -7
- package/dist/cli/commands/report.js +37 -1
- package/dist/cli/commands/status.d.ts +1 -0
- package/dist/cli/commands/status.js +45 -1
- package/dist/cli/commands/verify.d.ts +6 -0
- package/dist/cli/commands/verify.js +145 -25
- 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 +17 -18
- 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 +58 -9
- package/dist/compile/ownership.js +34 -9
- package/dist/config/config.d.ts +1 -0
- package/dist/config/config.js +52 -6
- package/dist/drivers/herdr.d.ts +1 -0
- package/dist/drivers/herdr.js +11 -1
- package/dist/drivers/index.d.ts +6 -0
- package/dist/drivers/index.js +19 -4
- package/dist/drivers/orca.d.ts +35 -1
- package/dist/drivers/orca.js +260 -20
- package/dist/drivers/subprocess.d.ts +3 -3
- package/dist/drivers/subprocess.js +16 -9
- package/dist/drivers/types.d.ts +12 -0
- package/dist/gates/baseline.d.ts +2 -0
- package/dist/gates/baseline.js +47 -11
- package/dist/gates/llm.d.ts +6 -0
- package/dist/gates/llm.js +25 -9
- package/dist/gates/review.d.ts +7 -3
- package/dist/gates/review.js +61 -22
- package/dist/gates/run-gates.d.ts +5 -2
- package/dist/gates/run-gates.js +50 -26
- package/dist/gates/verdict-cause.d.ts +6 -2
- package/dist/gates/verdict-cause.js +8 -4
- 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 +39 -8
- package/dist/run/daemon.d.ts +16 -0
- package/dist/run/daemon.js +345 -74
- package/dist/run/git.d.ts +3 -0
- package/dist/run/git.js +40 -5
- package/dist/run/journal.d.ts +15 -2
- package/dist/run/journal.js +73 -12
- package/dist/run/supervision.d.ts +6 -0
- package/dist/run/supervision.js +29 -1
- package/dist/tui/ink/fleet-app.d.ts +4 -0
- package/dist/tui/ink/fleet-app.js +45 -16
- package/dist/tui/ink/init-app.js +4 -4
- package/package.json +59 -1
- package/skills/tickmarkr-overseer/SKILL.md +77 -18
- package/skills/tickmarkr-overseer/scripts/seat-send.sh +88 -18
- package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +36 -2
- package/skills/tickmarkr-overseer/scripts/watch-contamination.sh +36 -15
- package/skills/tickmarkr-overseer/scripts/watch-context.sh +33 -7
- package/skills/tickmarkr-overseer/scripts/watch-pending-input.sh +32 -8
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";
|
|
@@ -23,9 +24,9 @@ import { GATE_NAMES } from "../graph/schema.js";
|
|
|
23
24
|
import { distFingerprint } from "../cli/commands/version.js";
|
|
24
25
|
import { augmentRetryBrief, consult, renderRetryGuidance } from "./consult.js";
|
|
25
26
|
import { runEnvironment } from "./environment.js";
|
|
26
|
-
import { cleanupRunWorktrees, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, preserveWorktree, resolvedCapacity, runWithForkBudget, sameCapacity, sh, shGit, WORKTREE_LAYOUT_CONTRACT, worktreePath } from "./git.js";
|
|
27
|
+
import { cleanupRunWorktrees, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, preserveWorktree, resolvedCapacity, runWithForkBudget, sameCapacity, sh, shGit, SUITE_PARENT_ENV, WORKTREE_LAYOUT_CONTRACT, worktreePath } from "./git.js";
|
|
27
28
|
import { runInteractiveSeed } from "./interactive-seed.js";
|
|
28
|
-
import { activeRetryBan, classifyTaskFailure, classifyWorkerResultCause, deferredReviewFindings, engagementComparable, formatPriorFindingEvidence, GATE_FINGERPRINT_CAP, GATE_SATISFIED_RELEASE, identicalGateFailures, isDeferredFinding, journaledFailureBrief, Journal, loadRoutingProfile, newRunId, normalizeGateFailure, outstandingConsultGuidance, outstandingReviewFindings, pendingRepairFindings, phaseForGate, readPriorRunEvidence, recordedTaskFailureKind, renderStructuredReviewFinding, repairsSinceApproval, reviewRoundsSinceApproval, runHasEnded, structuredFindings, upheldFeedbackByTask } from "./journal.js";
|
|
29
|
+
import { activeRetryBan, classifyTaskFailure, classifyWorkerResultCause, deferredReviewFindings, engagementComparable, formatPriorFindingEvidence, GATE_FINGERPRINT_CAP, GATE_SATISFIED_RELEASE, identicalGateFailures, isDeferredFinding, journaledFailureBrief, Journal, loadRoutingProfile, newRunId, normalizeGateFailure, outstandingConsultGuidance, outstandingReviewFindings, pendingRechecks, pendingRepairFindings, phaseForGate, readPriorRunEvidence, recordedTaskFailureKind, RECHECK_RELEASE, renderStructuredReviewFinding, repairReachSinceApproval, repairsSinceApproval, reviewRoundsSinceApproval, runHasEnded, structuredFindings, upheldFeedbackByTask } from "./journal.js";
|
|
29
30
|
import { isDiffCapPark } from "../gates/review.js";
|
|
30
31
|
import { acquireApprovalSerialization, acquireRunLock, isPidLive, releaseRunLock } from "./lock.js";
|
|
31
32
|
import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
|
|
@@ -69,12 +70,13 @@ export function resolveRunMode(repoRoot, opts = {}) {
|
|
|
69
70
|
return { cfg: resolved.cfg, mode: resolved.mode, source, ...(conflict ? { conflict } : {}) };
|
|
70
71
|
}
|
|
71
72
|
// T14: the events that prove an approval was ENACTED — narrowly causal, never merely subsequent.
|
|
72
|
-
//
|
|
73
|
-
//
|
|
73
|
+
// Ordinary, attempt-cap and review-upheld approvals buy a WORKER, so their proof is a dispatch;
|
|
74
|
+
// recheck has its own battery enactment below. Generic terminal events are deliberately NOT proof: an approved human-gate
|
|
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"]);
|
|
79
|
+
const RECHECK_ENACTMENT = "recheck-battery";
|
|
78
80
|
// The ONE approval that enacts without buying a worker: GATE_SATISFIED_RELEASE resumes from the
|
|
79
81
|
// persisted task branch after the approved gate (execTask's satisfiedGate branch), whose first act
|
|
80
82
|
// for the task is worktree-recreation. That event is causal for this path, not incidental.
|
|
@@ -97,9 +99,13 @@ export function outstandingApprovals(events) {
|
|
|
97
99
|
newest.set(e.taskId, i); });
|
|
98
100
|
return [...newest]
|
|
99
101
|
.filter(([taskId, i]) => {
|
|
100
|
-
const
|
|
102
|
+
const release = events[i].data.release;
|
|
103
|
+
const noWorker = release === GATE_SATISFIED_RELEASE;
|
|
104
|
+
const recheck = release === RECHECK_RELEASE;
|
|
101
105
|
return !events.slice(i + 1).some((e) => e.taskId === taskId
|
|
102
|
-
&& (DISPATCH_ENACTMENT.has(e.event)
|
|
106
|
+
&& (DISPATCH_ENACTMENT.has(e.event)
|
|
107
|
+
|| (noWorker && e.event === GATE_SATISFIED_ENACTMENT)
|
|
108
|
+
|| (recheck && e.event === RECHECK_ENACTMENT)));
|
|
103
109
|
})
|
|
104
110
|
.map(([taskId]) => taskId)
|
|
105
111
|
.sort();
|
|
@@ -132,7 +138,8 @@ export function formatSummary(s) {
|
|
|
132
138
|
* run id (cli/commands/status.ts positionalRunId), so naming it here is what stops the board from
|
|
133
139
|
* following the newest journal in a repo that already carries a second, newer run — a board showing
|
|
134
140
|
* the wrong run is a recorded incident (skills/tickmarkr-overseer/SKILL.md). */
|
|
135
|
-
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)}`;
|
|
136
143
|
const MAX_ATTEMPTS = 10; // ponytail: hard cap so a pathological ladder can never loop forever
|
|
137
144
|
// v1.85 T3 (retry economics): two repairs per engagement, then the fresh ladder. A repair re-uses the
|
|
138
145
|
// findings and the landed diff instead of re-buying onboarding; when two of them have not closed the
|
|
@@ -165,6 +172,19 @@ const isOracleFailure = (g) => g.details.startsWith("oracle failed:");
|
|
|
165
172
|
*/
|
|
166
173
|
export const gateSatisfied = (g) => (g.pass || g.meta?.skipped === true) && g.meta?.infra !== true;
|
|
167
174
|
const gateFailed = (g) => !gateSatisfied(g);
|
|
175
|
+
const SIGNAL_EXIT_RE = /\b(?:SIGTERM|SIGKILL|signal\s+(?:9|15)|exit(?:s|ed|\s+code)?\s+(?:137|143))\b/i;
|
|
176
|
+
const FAILURE_IDENTITY_RE = /\b(?:AssertionError|FAIL\s+\S|Tests?\s+\d+\s+failed|expected\s+.+\s+to\s+)\b/i;
|
|
177
|
+
/** A signalled test runner with no failure identity produced no verdict about HEAD. Baseline owns the
|
|
178
|
+
* ordinary infra vocabulary; this daemon-only rider handles the signal-shaped non-verdict before its
|
|
179
|
+
* journal row and repair accounting are written. */
|
|
180
|
+
function classifySignalOnlyTest(g) {
|
|
181
|
+
if (g.gate !== "test" || g.pass || g.meta?.infra === true || !SIGNAL_EXIT_RE.test(g.details))
|
|
182
|
+
return;
|
|
183
|
+
const named = Array.isArray(g.meta?.failingTests) && g.meta.failingTests.length > 0;
|
|
184
|
+
if (named || FAILURE_IDENTITY_RE.test(g.details))
|
|
185
|
+
return;
|
|
186
|
+
g.meta = { ...g.meta, classification: "infra", infra: true, retryable: false, kind: "signal-exit" };
|
|
187
|
+
}
|
|
168
188
|
// v1.85 T3: the gates whose failure IS a deterministic measurement — a machine re-ran a command over a
|
|
169
189
|
// tree and printed the same bytes. Those are the failures the fingerprint cap governs (the ruling names
|
|
170
190
|
// it a "deterministic-gate" cap): a third identical answer to a question already answered twice is the
|
|
@@ -195,7 +215,7 @@ function narrowRepairBattery(failing) {
|
|
|
195
215
|
}
|
|
196
216
|
// v2.0 T2 (OBS-554): the measurement keys run-gates stamps on a GateResult, lifted verbatim onto the
|
|
197
217
|
// gate row. One list, one lift — both onGate sites record through the same helper.
|
|
198
|
-
const GATE_TELEMETRY_KEYS = ["durationMs", "load1Start", "load1End", "selectedDurationMs", "fullDurationMs", "invocations"];
|
|
218
|
+
const GATE_TELEMETRY_KEYS = ["durationMs", "load1Start", "load1End", "load1Max", "load1Mean", "selectedDurationMs", "fullDurationMs", "invocations"];
|
|
199
219
|
const gateMeasurement = (meta = {}) => Object.fromEntries(GATE_TELEMETRY_KEYS.filter((k) => meta[k] !== undefined).map((k) => [k, meta[k]]));
|
|
200
220
|
/**
|
|
201
221
|
* T4 (OBS-265): the journal with the review objections a round did NOT hinge on removed. Judge and
|
|
@@ -270,6 +290,13 @@ function approvedReviewRoundCeiling(events, taskId) {
|
|
|
270
290
|
return undefined;
|
|
271
291
|
}
|
|
272
292
|
const BLOCKED_POLL_MS = 30_000; // between trailer-wait slices, check whether the pane is blocked on a prompt
|
|
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; };
|
|
299
|
+
export const APPROVAL_POLL_MS = 250;
|
|
273
300
|
const PROVIDER_DEATH_REQUEUE_CAP = 2; // v1.46 T1: requeue same assignment twice, then fall through to the normal ladder
|
|
274
301
|
const PROVIDER_DEATH_BACKOFF_MS = 500; // short backoff before provider-death requeue
|
|
275
302
|
const NO_TRAILER_DEMOTION_STREAK = 2; // OBS-57: consecutive no-trailer windows demote a channel for the rest of the run
|
|
@@ -319,6 +346,7 @@ export function resetNudgeTimingForTests() {
|
|
|
319
346
|
// diff merely quotes "rate limit" keeps working undisturbed).
|
|
320
347
|
const QUOTA_BANNER_SILENT_MS = 3 * 60_000;
|
|
321
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;
|
|
322
350
|
/** Test seam — shrink the quota-banner silence gate without minute-long sleeps. */
|
|
323
351
|
export function setQuotaBannerSilentMsForTests(ms) {
|
|
324
352
|
quotaBannerSilentMs = ms;
|
|
@@ -558,6 +586,110 @@ async function observeWorkerProcessTree(marker, cwd) {
|
|
|
558
586
|
}
|
|
559
587
|
return tree.size === 0 ? "empty" : "running";
|
|
560
588
|
}
|
|
589
|
+
const SUITE_COMMAND_RE = /(?:^|[\s/])(vitest(?:\.mjs)?|jest|mocha)(?:[\s/]|$)|\bnpm(?:\s+run)?\s+test\b/i;
|
|
590
|
+
function processCwd(pid) {
|
|
591
|
+
try {
|
|
592
|
+
return realpathSync(readlinkSync(`/proc/${pid}/cwd`));
|
|
593
|
+
}
|
|
594
|
+
catch { /* Darwin has no /proc */ }
|
|
595
|
+
try {
|
|
596
|
+
const out = execFileSync("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], {
|
|
597
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000,
|
|
598
|
+
});
|
|
599
|
+
const path = out.split("\n").find((line) => line.startsWith("n"))?.slice(1);
|
|
600
|
+
return path ? realpathSync(path) : undefined;
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
return undefined;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
function processSuiteParent(pid) {
|
|
607
|
+
try {
|
|
608
|
+
const env = readFileSync(`/proc/${pid}/environ`, "utf8").split("\0");
|
|
609
|
+
const value = env.find((entry) => entry.startsWith(`${SUITE_PARENT_ENV}=`))?.slice(SUITE_PARENT_ENV.length + 1);
|
|
610
|
+
return value && /^\d+$/.test(value) ? Number(value) : undefined;
|
|
611
|
+
}
|
|
612
|
+
catch { /* Darwin has no /proc process environments */ }
|
|
613
|
+
try {
|
|
614
|
+
const out = execFileSync("ps", ["eww", "-p", String(pid), "-o", "command="], {
|
|
615
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000,
|
|
616
|
+
});
|
|
617
|
+
const value = new RegExp(`(?:^|\\s)${SUITE_PARENT_ENV}=(\\d+)(?:\\s|$)`).exec(out)?.[1];
|
|
618
|
+
return value ? Number(value) : undefined;
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
return undefined;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const pathAtOrBelow = (root, candidate) => {
|
|
625
|
+
const rel = relative(root, candidate);
|
|
626
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
627
|
+
};
|
|
628
|
+
/** Count full-suite roots in one process-table snapshot. The probes are arguments so the ownership
|
|
629
|
+
* rules remain testable on hosts that forbid process inspection; production supplies cwd and the
|
|
630
|
+
* inherited TICKMARKR_SUITE_PARENT marker from the process itself. */
|
|
631
|
+
export function countLiveSuites(snapshot, repoRoot, daemonPid = process.pid, cwdForPid = processCwd, suiteParentForPid = processSuiteParent) {
|
|
632
|
+
const rows = [];
|
|
633
|
+
for (const line of snapshot.split("\n")) {
|
|
634
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
|
|
635
|
+
if (match && !match[3].startsWith("Z")) {
|
|
636
|
+
rows.push({ pid: Number(match[1]), ppid: Number(match[2]), command: match[4] });
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
const byPid = new Map(rows.map((row) => [row.pid, row]));
|
|
640
|
+
const ancestors = new Set();
|
|
641
|
+
for (let pid = daemonPid; pid && !ancestors.has(pid); pid = byPid.get(pid)?.ppid ?? 0)
|
|
642
|
+
ancestors.add(pid);
|
|
643
|
+
const descendants = new Set([daemonPid]);
|
|
644
|
+
for (let grew = true; grew;) {
|
|
645
|
+
grew = false;
|
|
646
|
+
for (const row of rows)
|
|
647
|
+
if (!descendants.has(row.pid) && descendants.has(row.ppid)) {
|
|
648
|
+
descendants.add(row.pid);
|
|
649
|
+
grew = true;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
const root = realpathSync(repoRoot);
|
|
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(" ")));
|
|
658
|
+
const attributable = new Set(candidates.filter((row) => {
|
|
659
|
+
if (descendants.has(row.pid))
|
|
660
|
+
return true;
|
|
661
|
+
const cwd = cwdForPid(row.pid);
|
|
662
|
+
if (cwd !== undefined && pathAtOrBelow(root, cwd))
|
|
663
|
+
return true;
|
|
664
|
+
const suiteParent = suiteParentForPid(row.pid);
|
|
665
|
+
if (suiteParent === daemonPid)
|
|
666
|
+
return true;
|
|
667
|
+
const parentCwd = suiteParent === undefined ? undefined : cwdForPid(suiteParent);
|
|
668
|
+
return parentCwd !== undefined && pathAtOrBelow(root, parentCwd);
|
|
669
|
+
}).map((row) => row.pid));
|
|
670
|
+
// npm/npx + vitest + pool workers are one suite. Count only attributable suite processes with no
|
|
671
|
+
// attributable suite ancestor, while still following ordinary non-suite parents between them.
|
|
672
|
+
return [...attributable].filter((pid) => {
|
|
673
|
+
for (let parent = byPid.get(pid)?.ppid; parent; parent = byPid.get(parent)?.ppid) {
|
|
674
|
+
if (attributable.has(parent))
|
|
675
|
+
return false;
|
|
676
|
+
}
|
|
677
|
+
return true;
|
|
678
|
+
}).length;
|
|
679
|
+
}
|
|
680
|
+
let liveSuiteCountForTests;
|
|
681
|
+
export const setLiveSuiteCountForTests = (probe) => {
|
|
682
|
+
liveSuiteCountForTests = probe;
|
|
683
|
+
};
|
|
684
|
+
export const resetLiveSuiteCountForTests = () => { liveSuiteCountForTests = undefined; };
|
|
685
|
+
/** Live full-suite roots attributable to this repository or this daemon. Ancestors are excluded so
|
|
686
|
+
* a daemon invoked by vitest does not wait on its own test harness forever. */
|
|
687
|
+
export async function liveSuiteCount(repoRoot) {
|
|
688
|
+
if (liveSuiteCountForTests)
|
|
689
|
+
return liveSuiteCountForTests(repoRoot);
|
|
690
|
+
const snapshot = await shGit("ps -Aww -o pid=,ppid=,state=,command=", repoRoot, 15_000);
|
|
691
|
+
return snapshot.code === 0 ? countLiveSuites(snapshot.stdout, repoRoot) : 0;
|
|
692
|
+
}
|
|
561
693
|
const OBSERVE_CHUNK_BYTES = 64 * 1024;
|
|
562
694
|
const OBSERVE_BUDGET_BYTES = 256 * 1024 * 1024;
|
|
563
695
|
let observeBudgetBytes = OBSERVE_BUDGET_BYTES;
|
|
@@ -1099,6 +1231,8 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1099
1231
|
const trackedDriver = {
|
|
1100
1232
|
id: driver.id,
|
|
1101
1233
|
interactive: driver.interactive,
|
|
1234
|
+
...(driver.readSource ? { readSource: driver.readSource } : {}),
|
|
1235
|
+
...(driver.describe ? { describe: driver.describe.bind(driver) } : {}),
|
|
1102
1236
|
slot: async (cwd, name, o) => { const s = await driver.slot(cwd, name, o); liveSlots.add(s); return s; },
|
|
1103
1237
|
run: (s, cmd) => driver.run(s, cmd),
|
|
1104
1238
|
waitOutput: (s, p, ms, o) => driver.waitOutput(s, p, ms, o),
|
|
@@ -1107,10 +1241,25 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1107
1241
|
read: (s, n) => driver.read(s, n),
|
|
1108
1242
|
...(driver.sendKey ? { sendKey: driver.sendKey.bind(driver) } : {}),
|
|
1109
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) } : {}),
|
|
1110
1251
|
notify: (m, o) => driver.notify(m, o),
|
|
1111
1252
|
close: closeSlot,
|
|
1112
1253
|
worktree: (r, b, base) => driver.worktree(r, b, base),
|
|
1113
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
|
+
};
|
|
1114
1263
|
// Termination (SIGINT/SIGTERM): record the daemon-controlled exit before closing every live slot,
|
|
1115
1264
|
// reconcile owned panes against an EMPTY
|
|
1116
1265
|
// desired set (herdr panes not in memory; panesToClose spares foreign names, watch panes, and
|
|
@@ -1135,6 +1284,9 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1135
1284
|
catch (error) {
|
|
1136
1285
|
console.error(`tickmarkr ${runId}: deliberate exit cause could not be journalled (${error instanceof Error ? error.message : String(error)})`);
|
|
1137
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}`));
|
|
1138
1290
|
if (cfg.visibility.keepPanes !== "forever") {
|
|
1139
1291
|
for (const s of liveSlots) { // closeSlot only deletes the element being visited — safe during Set iteration
|
|
1140
1292
|
try {
|
|
@@ -1149,13 +1301,15 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1149
1301
|
}
|
|
1150
1302
|
releaseRunLock(repoRoot); // the process dies at exit() below — the finally never runs on this path
|
|
1151
1303
|
}
|
|
1152
|
-
abortRun(new Error(`terminated by ${sig}`));
|
|
1153
1304
|
exit(sig === "SIGINT" ? 130 : 143);
|
|
1154
1305
|
})();
|
|
1155
1306
|
};
|
|
1156
1307
|
process.on("SIGINT", onTermination);
|
|
1157
1308
|
process.on("SIGTERM", onTermination);
|
|
1158
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);
|
|
1159
1313
|
// Capture this before this observer appends run-resume. A prior run-end belongs to a completed
|
|
1160
1314
|
// lifecycle and must use the ordinary resume/redispatch rules; only an interrupted live attempt
|
|
1161
1315
|
// owns reusable measurements or unclean-death residue.
|
|
@@ -1185,6 +1339,20 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1185
1339
|
const approved = new Set(approvalStartupEvents.filter((e) => e.event === "task-approved" && e.taskId).map((e) => e.taskId));
|
|
1186
1340
|
let approvalSweepCursor = approvalStartupEvents.length;
|
|
1187
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
|
+
};
|
|
1188
1356
|
let baseRef;
|
|
1189
1357
|
let baseline;
|
|
1190
1358
|
// Phase 46 (RES-01/RES-02): the resume-state map is built ONCE here so execTask closes over it.
|
|
@@ -1252,9 +1420,12 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1252
1420
|
...(replayedExclusions.size > 0 ? { excludedChannels: [...replayedExclusions].sort() } : {}),
|
|
1253
1421
|
...(opts.retryFailed ? { retryFailed: true } : {}),
|
|
1254
1422
|
});
|
|
1423
|
+
await openBoard();
|
|
1255
1424
|
}
|
|
1256
1425
|
else {
|
|
1257
1426
|
baseRef = await gitHead(repoRoot);
|
|
1427
|
+
journal.append("baseline-start", undefined, { baseRef, commands });
|
|
1428
|
+
await openBoard();
|
|
1258
1429
|
baseline = await captureBaseline(repoRoot, commands);
|
|
1259
1430
|
writeFileSync(join(journal.dir, "baseline.json"), JSON.stringify(baseline, null, 2));
|
|
1260
1431
|
writeFileSync(join(journal.dir, "graph.json"), readFileSync(join(tickmarkrDir(repoRoot), "graph.json")));
|
|
@@ -1300,21 +1471,6 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1300
1471
|
const collateral = pinnedCollateral ?? collateralHits(graph.tasks, repoRoot);
|
|
1301
1472
|
if (!pinnedCollateral)
|
|
1302
1473
|
writeFileSync(collateralPath, JSON.stringify(Object.fromEntries(collateral), null, 2));
|
|
1303
|
-
// T6: open the narrator AFTER run-start/run-resume is journaled so the watch surface has a run to
|
|
1304
|
-
// show. driver.narrator is undefined on subprocess → no-op (subprocess spawns nothing). Swallowed:
|
|
1305
|
-
// a failed-to-open or later-dead watch pane never affects the run.
|
|
1306
|
-
// OBS-103: hold the returned slot — narrator() returns the run's board under its canonical owned
|
|
1307
|
-
// name whichever way it got there (the herdr driver retires a survivor it finds and re-splits, so
|
|
1308
|
-
// the live command is run-bound), and the run-end sweep below retires it by that name regardless
|
|
1309
|
-
// of which daemon instance split the pane.
|
|
1310
|
-
const watchName = formatOwnedName({ role: "watch", taskId: "run", attempt: 0, runId });
|
|
1311
|
-
let watchSlot;
|
|
1312
|
-
try {
|
|
1313
|
-
watchSlot = await driver.narrator?.(repoRoot, watchCommand(runId), runId);
|
|
1314
|
-
}
|
|
1315
|
-
catch (error) {
|
|
1316
|
-
journal.append("watch-placement-failed", undefined, { error: error instanceof Error ? error.message : String(error) });
|
|
1317
|
-
}
|
|
1318
1474
|
const intWt = await ensureIntegration(repoRoot, branch, baseRef);
|
|
1319
1475
|
// v1.1 visibility: role-named slots; panes persist per keepPanes (attempt = v1 close-after-harvest)
|
|
1320
1476
|
const keepOpen = cfg.visibility.keepPanes !== "attempt";
|
|
@@ -1353,7 +1509,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1353
1509
|
if (watchSlot && watchSlot.name === watchName && !desired.has(watchName)) {
|
|
1354
1510
|
const w = watchSlot;
|
|
1355
1511
|
watchSlot = undefined;
|
|
1356
|
-
await
|
|
1512
|
+
await trackedDriver.close(w);
|
|
1357
1513
|
}
|
|
1358
1514
|
}
|
|
1359
1515
|
catch {
|
|
@@ -1386,6 +1542,48 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1386
1542
|
mergeChain = next.catch(() => undefined);
|
|
1387
1543
|
return next;
|
|
1388
1544
|
};
|
|
1545
|
+
// OBS-829/OBS-854: one full-suite verdict round at a time in this run, and do not begin beside an
|
|
1546
|
+
// externally live suite attributable to this repository. The process scan catches nested scratch
|
|
1547
|
+
// suites through daemon parentage even after cwd stops naming a worktree.
|
|
1548
|
+
let suiteChain = Promise.resolve();
|
|
1549
|
+
let suitePending = 0;
|
|
1550
|
+
const withSuiteWindow = async (taskId, enabled, run) => {
|
|
1551
|
+
if (!enabled)
|
|
1552
|
+
return run();
|
|
1553
|
+
const previous = suiteChain;
|
|
1554
|
+
let release;
|
|
1555
|
+
suiteChain = new Promise((resolve) => { release = resolve; });
|
|
1556
|
+
const queued = suitePending++ > 0;
|
|
1557
|
+
if (queued) {
|
|
1558
|
+
const count = Math.max(1, await liveSuiteCount(repoRoot));
|
|
1559
|
+
journal.append("suite-wait", taskId, { count });
|
|
1560
|
+
}
|
|
1561
|
+
await previous;
|
|
1562
|
+
try {
|
|
1563
|
+
let lastCount = -1;
|
|
1564
|
+
const startedAt = Date.now();
|
|
1565
|
+
for (;;) {
|
|
1566
|
+
const count = await liveSuiteCount(repoRoot);
|
|
1567
|
+
if (count === 0)
|
|
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
|
+
}
|
|
1575
|
+
if (count !== lastCount)
|
|
1576
|
+
journal.append("suite-wait", taskId, { count });
|
|
1577
|
+
lastCount = count;
|
|
1578
|
+
await new Promise((wake) => setTimeout(wake, SUITE_POLL_MS));
|
|
1579
|
+
}
|
|
1580
|
+
return await run();
|
|
1581
|
+
}
|
|
1582
|
+
finally {
|
|
1583
|
+
suitePending--;
|
|
1584
|
+
release();
|
|
1585
|
+
}
|
|
1586
|
+
};
|
|
1389
1587
|
// gateFails/consults are execTask-scoped counters passed in so a park row is a rich verified-failure
|
|
1390
1588
|
// observation (e.g. ladder-exhausted + gateFails:4); every task-human row has a closed kind, never prose alone.
|
|
1391
1589
|
const gateFailApprovalReason = (taskId, identity, includeUphold = false) => {
|
|
@@ -1644,6 +1842,14 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1644
1842
|
// `test` row whose scope no consumer could recover.
|
|
1645
1843
|
...(Array.isArray(g.meta?.selectedTests) ? { selectedTests: g.meta.selectedTests } : {}),
|
|
1646
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
|
+
} : {}),
|
|
1647
1853
|
// A finding's path is its own evidence path. Do not pass task scope here: a declaration says
|
|
1648
1854
|
// where work is allowed, not where this verdict found the defect.
|
|
1649
1855
|
...(blocking ? {
|
|
@@ -1665,9 +1871,9 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1665
1871
|
// T7: the capacity the gate's own command child ran under, lifted verbatim from the result
|
|
1666
1872
|
// the battery produced — read where the shell built that child's environment, never
|
|
1667
1873
|
// re-derived from the run's own budget, which would answer a different number than the
|
|
1668
|
-
// operator's export did. It is this row's only COMPARABLE identity: the
|
|
1669
|
-
//
|
|
1670
|
-
//
|
|
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
|
|
1671
1877
|
// no command carries nothing — it divided nothing.
|
|
1672
1878
|
//
|
|
1673
1879
|
// `dirtiedBy` is the one hole in that lift: run-gates REPLACES a green battery verdict with
|
|
@@ -1853,21 +2059,24 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1853
2059
|
// contiguous green prefix whose recorded commit is still the task branch tip.
|
|
1854
2060
|
const satisfiedGate = satisfiedGates.get(t.id);
|
|
1855
2061
|
const replayedGates = replayedGateResults.get(t.id);
|
|
1856
|
-
|
|
2062
|
+
const recheck = pendingRechecks(journal.read()).has(t.id);
|
|
2063
|
+
resumeGateReplay: if (satisfiedGate || replayedGates || recheck) {
|
|
1857
2064
|
const taskBase = await integrationHead(intWt);
|
|
1858
2065
|
const taskBranch = `${branch}--${t.id}`;
|
|
1859
2066
|
const priorWt = worktreePath(repoRoot, taskBranch);
|
|
1860
2067
|
if (!existsSync(priorWt)) {
|
|
1861
|
-
if (satisfiedGate)
|
|
1862
|
-
throw new Error(`approved gate ${satisfiedGate} cannot resume: task worktree is missing`);
|
|
2068
|
+
if (satisfiedGate || recheck)
|
|
2069
|
+
throw new Error(`${recheck ? "recheck" : `approved gate ${satisfiedGate}`} cannot resume: task worktree is missing`);
|
|
1863
2070
|
// Observed passes are an optimization, never authority: without the task worktree there is no
|
|
1864
2071
|
// commit to compare and no landed work to gate, so fall through to the ordinary worker path.
|
|
1865
2072
|
replayedGateResults.delete(t.id);
|
|
1866
2073
|
break resumeGateReplay;
|
|
1867
2074
|
}
|
|
1868
|
-
const resumeReason =
|
|
1869
|
-
?
|
|
1870
|
-
:
|
|
2075
|
+
const resumeReason = recheck
|
|
2076
|
+
? "operator recheck"
|
|
2077
|
+
: satisfiedGate
|
|
2078
|
+
? `approved gate ${satisfiedGate}`
|
|
2079
|
+
: `recorded gates on ${replayedGates.commit.slice(0, 10)}`;
|
|
1871
2080
|
const priorTaskTip = await gitHead(priorWt);
|
|
1872
2081
|
const priorTaskSubject = await gateCommitSubject(taskBase, priorTaskTip, priorWt);
|
|
1873
2082
|
const commitsToCarry = await commitsAheadOf(taskBase, priorWt);
|
|
@@ -1919,7 +2128,10 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1919
2128
|
: [],
|
|
1920
2129
|
raw: "",
|
|
1921
2130
|
};
|
|
1922
|
-
const
|
|
2131
|
+
const parkedAuthor = recheck
|
|
2132
|
+
? [...journal.read()].reverse().find((e) => e.event === "task-dispatch" && e.taskId === t.id)?.data.assignment
|
|
2133
|
+
: undefined;
|
|
2134
|
+
const gateAuthor = parkedAuthor ?? rs?.lastAssignment ?? assignment;
|
|
1923
2135
|
const satisfiedIndex = satisfiedGate ? GATE_NAMES.indexOf(satisfiedGate) : -1;
|
|
1924
2136
|
// The serial pipeline could have at most one blocking result, so "everything after the
|
|
1925
2137
|
// approved gate" was enough. v1.85 can record both verdict siblings red in one round, and a
|
|
@@ -1943,7 +2155,10 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1943
2155
|
priorResults.set(e.data.gate, e);
|
|
1944
2156
|
}
|
|
1945
2157
|
let remainingGates;
|
|
1946
|
-
if (
|
|
2158
|
+
if (recheck) {
|
|
2159
|
+
remainingGates = GATE_NAMES.filter((gate) => t.gates.includes(gate));
|
|
2160
|
+
}
|
|
2161
|
+
else if (satisfiedGate) {
|
|
1947
2162
|
remainingGates = t.gates.filter((gate) => {
|
|
1948
2163
|
if (gate === satisfiedGate)
|
|
1949
2164
|
return false;
|
|
@@ -2001,10 +2216,11 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2001
2216
|
// This suffix is re-measured to decide whether resume may advance, but the interrupted
|
|
2002
2217
|
// attempt already paid for its red result. The next worker-backed round remains the next
|
|
2003
2218
|
// deterministic-fingerprint occurrence/review round for budget accounting.
|
|
2004
|
-
...(!satisfiedGate ? { replayMeasurement: true } : {}),
|
|
2219
|
+
...(!satisfiedGate && !recheck ? { replayMeasurement: true } : {}),
|
|
2005
2220
|
};
|
|
2221
|
+
await trackedDriver.project?.(t.id, "in-review");
|
|
2006
2222
|
journal.phaseStart(t.id, "gates");
|
|
2007
|
-
const { results } = await runGates(resumedTask, {
|
|
2223
|
+
const { results } = await withSuiteWindow(t.id, resumedTask.gates.includes("test") && commands.test !== undefined, () => runGates(resumedTask, {
|
|
2008
2224
|
worktree: wt, baseRef: taskBase, result: priorResult, author: gateAuthor,
|
|
2009
2225
|
commands, baseline, channels: pools.review, judgeChannels: pools.judge, adapters, cfg, artifactDir: journal.dir,
|
|
2010
2226
|
collateral: collateral.get(t.id) ?? [],
|
|
@@ -2020,6 +2236,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2020
2236
|
}
|
|
2021
2237
|
: undefined,
|
|
2022
2238
|
excludeReviewers: badReviewers,
|
|
2239
|
+
reviewHistory,
|
|
2023
2240
|
onGate: async (e) => {
|
|
2024
2241
|
if (e.phase === "start") {
|
|
2025
2242
|
notePhaseStart(e);
|
|
@@ -2031,6 +2248,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2031
2248
|
return;
|
|
2032
2249
|
}
|
|
2033
2250
|
const g = e.result;
|
|
2251
|
+
classifySignalOnlyTest(g);
|
|
2034
2252
|
inParallelOrder(g.gate, () => {
|
|
2035
2253
|
journalGateResult(g);
|
|
2036
2254
|
noteReviewRetry(g);
|
|
@@ -2040,7 +2258,15 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2040
2258
|
}
|
|
2041
2259
|
});
|
|
2042
2260
|
},
|
|
2043
|
-
});
|
|
2261
|
+
}));
|
|
2262
|
+
results.forEach(classifySignalOnlyTest);
|
|
2263
|
+
if (pendingRechecks(journal.read()).has(t.id)) {
|
|
2264
|
+
journal.append("recheck-battery", t.id, {
|
|
2265
|
+
commit: gateSubject.commit,
|
|
2266
|
+
gates: resumedTask.gates,
|
|
2267
|
+
pass: results.every(gateSatisfied),
|
|
2268
|
+
});
|
|
2269
|
+
}
|
|
2044
2270
|
const approvedCommits = await commitsAheadOf(taskBase, wt);
|
|
2045
2271
|
graph = addEvidence(graph, t.id, { commits: approvedCommits, gateResults: results });
|
|
2046
2272
|
saveGraph(repoRoot, graph);
|
|
@@ -2087,6 +2313,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2087
2313
|
attempts: rs?.attempts ?? 0, assignment: gateAuthor, taskContentDigest: contentDigest,
|
|
2088
2314
|
});
|
|
2089
2315
|
journal.append("merge", t.id, { branch: taskBranch, commit: await integrationHead(intWt) });
|
|
2316
|
+
await trackedDriver.project?.(t.id, "completed");
|
|
2090
2317
|
journal.telemetry({
|
|
2091
2318
|
taskId: t.id, shape: t.shape, adapter: gateAuthor.adapter, model: gateAuthor.model,
|
|
2092
2319
|
channel: gateAuthor.channel, attempts: rs?.attempts ?? 0, outcome: "done",
|
|
@@ -2257,11 +2484,13 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2257
2484
|
lastContextTokens = undefined;
|
|
2258
2485
|
graph = setStatus(graph, t.id, "running");
|
|
2259
2486
|
saveGraph(repoRoot, graph);
|
|
2487
|
+
await trackedDriver.project?.(t.id, "in-progress");
|
|
2260
2488
|
// T6: a dispatch that carries an outstanding finding says so, and names it. Without this the
|
|
2261
2489
|
// ledger cannot tell a carried dispatch from an amnesiac one — the exact question a run that
|
|
2262
2490
|
// spends two frontier attempts re-deriving a known defect has to be able to answer afterwards.
|
|
2263
2491
|
journal.append("task-dispatch", t.id, {
|
|
2264
2492
|
assignment, attempt, provenance: dispatchProvenance(r.provenance), retryMode,
|
|
2493
|
+
excludedChannels: [...demotedChannels].sort(),
|
|
2265
2494
|
...(outstandingFindings.length > 0 ? { carriedFindings: outstandingFindings } : {}),
|
|
2266
2495
|
...(carriedConsultGuidance ? { carriedConsultGuidance } : {}),
|
|
2267
2496
|
});
|
|
@@ -2373,9 +2602,18 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2373
2602
|
// itself before gates regardless of what the worker does with it.
|
|
2374
2603
|
writeFileSync(promptFile, `${WORKTREE_LAYOUT_CONTRACT}\n\n${workerContract}\n\n${readFileSync(promptFile, "utf8")}`);
|
|
2375
2604
|
const adapter = getAdapter(assignment.adapter, adapters);
|
|
2605
|
+
if (adapter.trustDialog && !trackedDriver.sendKey)
|
|
2606
|
+
noteCapabilityAbsent("sendKey");
|
|
2376
2607
|
// VIS-04: workers share one role tab. T2: `owned` names the pane canonically (ownership contract);
|
|
2377
2608
|
// the legacy name stays the fallback for drivers without owned handling (subprocess spies).
|
|
2378
|
-
const
|
|
2609
|
+
const workerSlotOpts = {
|
|
2610
|
+
group: "workers",
|
|
2611
|
+
owned: { role: "worker", taskId: t.id, attempt, runId },
|
|
2612
|
+
};
|
|
2613
|
+
// Keep the established enumerable slot-options shape consumed by legacy drivers while making
|
|
2614
|
+
// the adapter hook available as an own request field to execution surfaces such as Orca.
|
|
2615
|
+
Object.defineProperty(workerSlotOpts, "agent", { value: assignment.adapter });
|
|
2616
|
+
const slot = await trackedDriver.slot(wt, `${t.id}-worker-${assignment.adapter}-a${attempt}-${runTag}`, workerSlotOpts);
|
|
2379
2617
|
const sessionId = retryMode === "resume" ? priorSession.id : slot.name;
|
|
2380
2618
|
const icmd = retryMode === "resume"
|
|
2381
2619
|
? adapter.resumeCommand(sessionId, promptFile, assignment.model)
|
|
@@ -2419,7 +2657,12 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2419
2657
|
// between task-dispatch and this line — worktree recreation, setup, prompt write, slot allocation,
|
|
2420
2658
|
// the launch itself — can still die with no worker having seen the brief, and `--retry-failed`
|
|
2421
2659
|
// must then re-send that same brief rather than a fresh prompt on a possibly banned channel.
|
|
2422
|
-
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" });
|
|
2423
2666
|
let cpuFlat;
|
|
2424
2667
|
let cpuAccountant;
|
|
2425
2668
|
let cpuGapCount = 0;
|
|
@@ -2551,6 +2794,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2551
2794
|
// subprocess tree that REACHED its exit marker, not about what the worker claimed.
|
|
2552
2795
|
let processExited = false;
|
|
2553
2796
|
let earlyLaunchDead = false;
|
|
2797
|
+
let startupFailure = false;
|
|
2554
2798
|
let deadWorkerPark;
|
|
2555
2799
|
let settleParsed;
|
|
2556
2800
|
let seedResult;
|
|
@@ -2633,7 +2877,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2633
2877
|
continue attempts;
|
|
2634
2878
|
return;
|
|
2635
2879
|
}
|
|
2636
|
-
noteLaunched();
|
|
2880
|
+
await noteLaunched();
|
|
2637
2881
|
output = seedResult.output;
|
|
2638
2882
|
}
|
|
2639
2883
|
else {
|
|
@@ -2647,14 +2891,15 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2647
2891
|
continue attempts;
|
|
2648
2892
|
return;
|
|
2649
2893
|
}
|
|
2650
|
-
noteLaunched();
|
|
2894
|
+
await noteLaunched();
|
|
2651
2895
|
output = await driver.read(slot, PANE_READ_ROWS);
|
|
2652
2896
|
}
|
|
2653
2897
|
// The returning paths report the same fact on the result; both callbacks land on the one
|
|
2654
2898
|
// latch, and the second is a no-op. A seed that answered is never re-answered by the loop.
|
|
2655
2899
|
if (seedResult?.trustAnswered)
|
|
2656
2900
|
noteSeedTrustAnswered();
|
|
2657
|
-
|
|
2901
|
+
startupFailure = WORKER_STARTUP_FAILURE_RE.test(stallSnapshotBannerRows(output));
|
|
2902
|
+
if (seedResult?.seedFailed || startupFailure) {
|
|
2658
2903
|
finished = false;
|
|
2659
2904
|
}
|
|
2660
2905
|
else {
|
|
@@ -2742,7 +2987,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2742
2987
|
throw error;
|
|
2743
2988
|
if (!paneReadHeld) {
|
|
2744
2989
|
paneReadHeld = true;
|
|
2745
|
-
|
|
2990
|
+
noteWorkerLiveness("worker-dead-held", {
|
|
2746
2991
|
slot: slot.name, attempt, reason: "pane-read-unreadable",
|
|
2747
2992
|
error: error instanceof Error ? error.message : String(error),
|
|
2748
2993
|
});
|
|
@@ -2755,6 +3000,11 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2755
3000
|
}
|
|
2756
3001
|
if (paneText.length > 0)
|
|
2757
3002
|
everHadOutput = true;
|
|
3003
|
+
if (WORKER_STARTUP_FAILURE_RE.test(stallSnapshotBannerRows(paneText))) {
|
|
3004
|
+
startupFailure = true;
|
|
3005
|
+
output = paneText;
|
|
3006
|
+
break;
|
|
3007
|
+
}
|
|
2758
3008
|
// OBS-117 (v1.71 T6): zero raw output by the early-launch deadline is a dead channel now.
|
|
2759
3009
|
if (!everHadOutput && Date.now() - attemptStart >= earlyLaunchLivenessMs) {
|
|
2760
3010
|
earlyLaunchDead = true;
|
|
@@ -2828,7 +3078,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2828
3078
|
catch (error) {
|
|
2829
3079
|
if (!paneStatusHeld) {
|
|
2830
3080
|
paneStatusHeld = true;
|
|
2831
|
-
|
|
3081
|
+
noteWorkerLiveness("worker-dead-held", {
|
|
2832
3082
|
slot: slot.name, attempt, reason: "pane-status-unreadable",
|
|
2833
3083
|
error: error instanceof Error ? error.message : String(error),
|
|
2834
3084
|
});
|
|
@@ -2905,7 +3155,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2905
3155
|
paneAbsent = false;
|
|
2906
3156
|
if (!paneReadHeld) {
|
|
2907
3157
|
paneReadHeld = true;
|
|
2908
|
-
|
|
3158
|
+
noteWorkerLiveness("worker-dead-held", {
|
|
2909
3159
|
slot: slot.name, attempt, reason: "pane-read-unreadable",
|
|
2910
3160
|
error: error instanceof Error ? error.message : String(error),
|
|
2911
3161
|
});
|
|
@@ -2929,7 +3179,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2929
3179
|
paneAbsent = false;
|
|
2930
3180
|
if (!paneReadHeld) {
|
|
2931
3181
|
paneReadHeld = true;
|
|
2932
|
-
|
|
3182
|
+
noteWorkerLiveness("worker-dead-held", {
|
|
2933
3183
|
slot: slot.name, attempt, reason: "pane-read-unreadable",
|
|
2934
3184
|
error: error instanceof Error ? error.message : String(error),
|
|
2935
3185
|
});
|
|
@@ -2953,7 +3203,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2953
3203
|
if (deathCertain) {
|
|
2954
3204
|
const preservation = await preserveDeadWorker(wt, taskBase);
|
|
2955
3205
|
if (!preservation.ref) {
|
|
2956
|
-
|
|
3206
|
+
noteWorkerLiveness("worker-dead-held", {
|
|
2957
3207
|
slot: slot.name, attempt, reason: `worktree-${preservation.state}`,
|
|
2958
3208
|
});
|
|
2959
3209
|
continue;
|
|
@@ -2962,7 +3212,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2962
3212
|
const reason = `worker is unambiguously dead: pane absent, process tree empty, and worktree unchanged; preserved at ${ref}`;
|
|
2963
3213
|
deadWorkerPark = { ref, reason };
|
|
2964
3214
|
journal.append("worktree-preserved", t.id, { ref });
|
|
2965
|
-
|
|
3215
|
+
noteWorkerLiveness("worker-dead-held", {
|
|
2966
3216
|
slot: slot.name, attempt, reason: "unambiguous-worker-death", ref,
|
|
2967
3217
|
});
|
|
2968
3218
|
break;
|
|
@@ -2978,8 +3228,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2978
3228
|
// Everything the fast-kill can decide from the CHANNEL, evaluated before the CPU leg is
|
|
2979
3229
|
// asked for anything: the kill's own probe costs a `ps` every 100ms, so an ineligible
|
|
2980
3230
|
// slice must not pay for it. The CPU reading is consulted at the kill itself, below.
|
|
2981
|
-
const fastKillEligible = !
|
|
2982
|
-
&& !nudgePending && !nudgeFailed
|
|
3231
|
+
const fastKillEligible = !nudgePending && !nudgeFailed
|
|
2983
3232
|
&& sliceNow - lastOutputGrowthAt >= deadChannelFastKillMs
|
|
2984
3233
|
&& worktreeSinceLaunch === "unchanged";
|
|
2985
3234
|
const harvestCpuEligible = !nudgePending && !nudgeFailed
|
|
@@ -3020,14 +3269,10 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3020
3269
|
// (an elapsed "9s"→"10s" lengthens the read and would hide a frozen pane). But the
|
|
3021
3270
|
// tracker SHARES the read window's ceiling: its row signal saturates once a sample
|
|
3022
3271
|
// FILLS a PANE_READ_ROWS read on raw lines (blanks/chrome included — see stall.ts),
|
|
3023
|
-
// and past that point a flat tracker means "unmeasurable", not "dead".
|
|
3024
|
-
//
|
|
3025
|
-
//
|
|
3026
|
-
//
|
|
3027
|
-
// worker past the ceiling would be concluded dead mid-work. So the kill STANDS DOWN on
|
|
3028
|
-
// a saturated row signal (journaled once per attempt); the rolling window still owns
|
|
3029
|
-
// that pane, exactly as pre-T1. Token growth counts as life either way, so a metered
|
|
3030
|
-
// 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.
|
|
3031
3276
|
// The triad has NO status exemption: a pane that herdr reports as blocked, idle, working
|
|
3032
3277
|
// or unknown dies alike once it holds no trailer, an unchanged tree and no growth — waiting the
|
|
3033
3278
|
// rolling window out on a status reading is exactly the blindness T1 removes. A matched
|
|
@@ -3041,7 +3286,9 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3041
3286
|
// filesystem/git observation answer that clause.
|
|
3042
3287
|
if (stallProgress.rowSignalSaturated && !rowSaturationHeld) {
|
|
3043
3288
|
rowSaturationHeld = true;
|
|
3044
|
-
journal.append("
|
|
3289
|
+
journal.append("contact-unreadable", t.id, {
|
|
3290
|
+
slot: slot.name, attempt, source: "pane-rows", reason: "row-signal-saturated", concludes: false,
|
|
3291
|
+
});
|
|
3045
3292
|
}
|
|
3046
3293
|
// OBS-548: the FOURTH leg, and the one the daemon already measured. The three legs above
|
|
3047
3294
|
// are all channel-side: they say a pane stopped talking. The tree's CPU says whether
|
|
@@ -3058,12 +3305,13 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3058
3305
|
journal.append("worker-dead", t.id, {
|
|
3059
3306
|
slot: slot.name, attempt, silentMs: sliceNow - lastOutputGrowthAt,
|
|
3060
3307
|
cpuMs: leg.cpu.ms, cpuResolutionMs: leg.cpu.resolutionMs,
|
|
3308
|
+
source: trackedDriver.readSource ?? "driver.read",
|
|
3061
3309
|
});
|
|
3062
3310
|
break;
|
|
3063
3311
|
}
|
|
3064
3312
|
if (!cpuHeld) {
|
|
3065
3313
|
cpuHeld = true;
|
|
3066
|
-
|
|
3314
|
+
noteWorkerLiveness("worker-dead-held", {
|
|
3067
3315
|
slot: slot.name, attempt, reason: leg.state === "accruing" ? "cpu-accruing" : "cpu-unmeasurable",
|
|
3068
3316
|
});
|
|
3069
3317
|
}
|
|
@@ -3191,11 +3439,12 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3191
3439
|
continue attempts;
|
|
3192
3440
|
return;
|
|
3193
3441
|
}
|
|
3194
|
-
noteLaunched();
|
|
3442
|
+
await noteLaunched();
|
|
3195
3443
|
// OBS-54: headless workers have the same output-inactivity budget as visible panes.
|
|
3196
3444
|
// v1.76: same monotonic-progress measure as the interactive site; harvest stays raw.
|
|
3197
3445
|
const stallWindowMs = taskTimeoutMinutes * 60_000;
|
|
3198
3446
|
const initialPane = await driver.read(slot, 500);
|
|
3447
|
+
startupFailure = WORKER_STARTUP_FAILURE_RE.test(stallSnapshotBannerRows(initialPane));
|
|
3199
3448
|
let everHadOutput = initialPane.length > 0;
|
|
3200
3449
|
const stallProgress = new StallProgressTracker();
|
|
3201
3450
|
stallProgress.observe({ paneText: initialPane, seedSubmitted: true, contextTokens });
|
|
@@ -3208,7 +3457,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3208
3457
|
// worker-result-harvested row. The interactive site has always kept them apart (`finished`
|
|
3209
3458
|
// there is the trailer regex; the exit marker only sets exitCode), and the cause taxonomy
|
|
3210
3459
|
// already names this shape "clean-exit-no-trailer" — unreachable in print mode until now.
|
|
3211
|
-
while (Date.now() - lastProgressAt < stallWindowMs) {
|
|
3460
|
+
while (!startupFailure && Date.now() - lastProgressAt < stallWindowMs) {
|
|
3212
3461
|
const remaining = stallWindowMs - (Date.now() - lastProgressAt);
|
|
3213
3462
|
let slice = Math.min(BLOCKED_POLL_MS, Math.max(100, Math.min(stallWindowMs / 2, remaining)));
|
|
3214
3463
|
if (!everHadOutput) {
|
|
@@ -3225,6 +3474,10 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3225
3474
|
const paneText = await driver.read(slot, 500);
|
|
3226
3475
|
if (paneText.length > 0)
|
|
3227
3476
|
everHadOutput = true;
|
|
3477
|
+
if (WORKER_STARTUP_FAILURE_RE.test(stallSnapshotBannerRows(paneText))) {
|
|
3478
|
+
startupFailure = true;
|
|
3479
|
+
break;
|
|
3480
|
+
}
|
|
3228
3481
|
if (!everHadOutput && Date.now() - attemptStart >= earlyLaunchLivenessMs) {
|
|
3229
3482
|
earlyLaunchDead = true;
|
|
3230
3483
|
break;
|
|
@@ -3286,7 +3539,9 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3286
3539
|
}
|
|
3287
3540
|
let result = settleParsed ?? adapter.parse(output, nonce);
|
|
3288
3541
|
const workerFinished = finished;
|
|
3289
|
-
const workerCause =
|
|
3542
|
+
const workerCause = startupFailure
|
|
3543
|
+
? "startup-failure"
|
|
3544
|
+
: classifyWorkerResultCause({ output, ok: result.ok, finished, exitCode, summary: result.summary, timedOut, deadChannel: deadChannelKilled });
|
|
3290
3545
|
journal.append("worker-result", t.id, {
|
|
3291
3546
|
ok: result.ok, summary: result.summary, deviations: result.deviations, finished: workerFinished, exitCode,
|
|
3292
3547
|
mode: interactive ? "interactive" : "print", ...(workerCause ? { cause: workerCause } : {}),
|
|
@@ -3403,7 +3658,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3403
3658
|
// T2 review: classify the PRE-HARVEST parse — classifyDeadChannel bails on any ok:true
|
|
3404
3659
|
// result, so reading the synthesized harvest result would swallow auth-required /
|
|
3405
3660
|
// setup-required / provider-outage for every committed-but-walled attempt (in both modes).
|
|
3406
|
-
const dead = classifyDeadChannel(preHarvestResult) ?? (earlyLaunchDead ? "setup-required" : undefined);
|
|
3661
|
+
const dead = classifyDeadChannel(preHarvestResult) ?? (earlyLaunchDead || startupFailure ? "setup-required" : undefined);
|
|
3407
3662
|
if (dead) {
|
|
3408
3663
|
const from = channelKey(assignment);
|
|
3409
3664
|
demotedChannels.add(from); // excluded for later attempts AND later tasks in this run
|
|
@@ -3478,6 +3733,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3478
3733
|
return;
|
|
3479
3734
|
}
|
|
3480
3735
|
const g = e.result;
|
|
3736
|
+
classifySignalOnlyTest(g);
|
|
3481
3737
|
inParallelOrder(g.gate, () => {
|
|
3482
3738
|
// GATE-09 (ROADMAP SC-4): journal every judge retry as an attributable event — which gate flaked,
|
|
3483
3739
|
// which channel flaked, which channel retried — so `tickmarkr journal`/report can distinguish "judge
|
|
@@ -3509,8 +3765,9 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3509
3765
|
gateLoop: while (true) {
|
|
3510
3766
|
const gated = await gitHead(wt);
|
|
3511
3767
|
gateSubject = { commit: await gateCommitSubject(taskBase, gated, wt), attempt };
|
|
3768
|
+
await trackedDriver.project?.(t.id, "in-review");
|
|
3512
3769
|
journal.phaseStart(t.id, "gates");
|
|
3513
|
-
({ results, commits } = await runGates(t, {
|
|
3770
|
+
({ results, commits } = await withSuiteWindow(t.id, t.gates.includes("test") && commands.test !== undefined, () => runGates(t, {
|
|
3514
3771
|
worktree: wt, baseRef: taskBase, result, author: assignment,
|
|
3515
3772
|
commands, baseline, channels: pools.review, judgeChannels: pools.judge, adapters, cfg, artifactDir: journal.dir,
|
|
3516
3773
|
collateral: collateral.get(t.id) ?? [],
|
|
@@ -3532,8 +3789,10 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3532
3789
|
}
|
|
3533
3790
|
: undefined,
|
|
3534
3791
|
excludeReviewers: badReviewers,
|
|
3792
|
+
reviewHistory,
|
|
3535
3793
|
onGate,
|
|
3536
|
-
}));
|
|
3794
|
+
})));
|
|
3795
|
+
results.forEach(classifySignalOnlyTest);
|
|
3537
3796
|
graph = addEvidence(graph, t.id, { commits, gateResults: results, artifacts: [promptFile] });
|
|
3538
3797
|
saveGraph(repoRoot, graph);
|
|
3539
3798
|
if (results.some((g) => g.gate === "test" && !g.pass))
|
|
@@ -3569,6 +3828,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3569
3828
|
attempts: attempt + 1, assignment, taskContentDigest: contentDigest,
|
|
3570
3829
|
});
|
|
3571
3830
|
journal.append("merge", t.id, { branch: taskBranch, commit: await integrationHead(intWt) });
|
|
3831
|
+
await trackedDriver.project?.(t.id, "completed");
|
|
3572
3832
|
// firstAttemptOk/gateFails/consults are recorded FACTS, not policy — a parkKind:"stall" row is
|
|
3573
3833
|
// recorded but NOT quality-negative in v1.6; Phase 12 owns reward policy, so flipping it later needs zero data migration.
|
|
3574
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 });
|
|
@@ -3630,6 +3890,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3630
3890
|
// failing gate, and `feedback` still carries every one of them to the next attempt.
|
|
3631
3891
|
const repairBattery = failing.some((g) => g.gate !== "review") ? failing.filter((g) => g.gate !== "review") : failing;
|
|
3632
3892
|
const repairable = narrowRepairBattery(repairBattery) && lostCommits.length === 0 && landed.length > 0;
|
|
3893
|
+
const repairHistory = repairReachSinceApproval(journal.read(), t.id);
|
|
3633
3894
|
const repairsDrawn = repairsSinceApproval(journal.read(), t.id);
|
|
3634
3895
|
const repair = repairable && repairsDrawn < MAX_REPAIRS;
|
|
3635
3896
|
// v1.85 T3: the fingerprint cap. Two normalized-identical failures of one DETERMINISTIC gate on
|
|
@@ -3699,13 +3960,17 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3699
3960
|
const repairExhausted = repairable && !repair;
|
|
3700
3961
|
if (repair && !capStep) {
|
|
3701
3962
|
journal.append("repair-attempt", t.id, {
|
|
3702
|
-
repair:
|
|
3963
|
+
repair: repairHistory.length + 1, charge: repairsDrawn + 1, of: MAX_REPAIRS,
|
|
3964
|
+
gates: failing.map((g) => g.gate),
|
|
3703
3965
|
commits: landed.length,
|
|
3704
3966
|
findings: feedback, // the failure bytes this repair must carry, replayable across a resume
|
|
3705
3967
|
});
|
|
3706
3968
|
}
|
|
3707
3969
|
else if (repairExhausted && !capStep) {
|
|
3708
|
-
journal.append("repair-exhausted", t.id, {
|
|
3970
|
+
journal.append("repair-exhausted", t.id, {
|
|
3971
|
+
repairs: repairsDrawn, of: MAX_REPAIRS, gates: failing.map((g) => g.gate),
|
|
3972
|
+
reached: repairHistory,
|
|
3973
|
+
});
|
|
3709
3974
|
}
|
|
3710
3975
|
const step = capStep ?? (repair || (reviewFixRetry && !repairExhausted)
|
|
3711
3976
|
? "retry"
|
|
@@ -3714,7 +3979,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3714
3979
|
journal.append("escalation", t.id, {
|
|
3715
3980
|
step, attempt: attempt + 1,
|
|
3716
3981
|
...(reviewFixRetry && !repairExhausted ? { reviewFix: true } : {}),
|
|
3717
|
-
...(repair ? { repair: repairsDrawn + 1 } : {}),
|
|
3982
|
+
...(repair ? { repair: repairHistory.length + 1, repairCharge: repairsDrawn + 1 } : {}),
|
|
3718
3983
|
});
|
|
3719
3984
|
await driver.notify(`tickmarkr ${runId}: ${t.id} escalation: ${step}`, { tier: "attention" });
|
|
3720
3985
|
}
|
|
@@ -3769,7 +4034,13 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3769
4034
|
}
|
|
3770
4035
|
if (inflight.size === 0)
|
|
3771
4036
|
break;
|
|
3772
|
-
|
|
4037
|
+
const waiters = [...inflight.values(), aborted];
|
|
4038
|
+
// A free slot is itself a scheduling boundary: poll the append-only approval stream instead of
|
|
4039
|
+
// sleeping until an unrelated long-running task settles.
|
|
4040
|
+
if (inflight.size < concurrency) {
|
|
4041
|
+
waiters.push(new Promise((wake) => setTimeout(wake, APPROVAL_POLL_MS)));
|
|
4042
|
+
}
|
|
4043
|
+
await Promise.race(waiters); // aborted rejects on termination — unwinds the run
|
|
3773
4044
|
}
|
|
3774
4045
|
// D-07: the sweep now closes only what's LEFT in keptSlots — done-closed worker slots were removed
|
|
3775
4046
|
// (no double-close) and self-cleaned LLM/consult panes were never added under keepLlm:false. This
|
|
@@ -3794,7 +4065,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3794
4065
|
// OBS-34: post-merge integration-tip verify — strict exit codes, no baseline forgiveness.
|
|
3795
4066
|
const lastMergedTask = [...journal.read()].reverse().find((e) => e.event === "merge" && e.taskId)?.taskId;
|
|
3796
4067
|
if (summary.done.length > 0 && Object.keys(commands).length > 0) {
|
|
3797
|
-
const tipFailed = await verifyIntegrationTipCached(intWt, commands, journal, { lastMergedTask, baseline });
|
|
4068
|
+
const tipFailed = await withSuiteWindow(undefined, commands.test !== undefined, () => verifyIntegrationTipCached(intWt, commands, journal, { lastMergedTask, baseline }));
|
|
3798
4069
|
summary.tipVerify = tipFailed ? "failed" : "passed";
|
|
3799
4070
|
if (tipFailed && lastMergedTask)
|
|
3800
4071
|
summary.lastMergedTask = lastMergedTask;
|