tickmarkr 2.5.7 → 2.5.8

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.
@@ -31,7 +31,8 @@ import { failureDisposition, reserveInfrastructureRetry } from "./recovery.js";
31
31
  import { runEnvironment } from "./environment.js";
32
32
  import { cleanupRunWorktrees, deriveForkCap, FORK_CAP_ENV, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, preserveWorktree, resolvedCapacity, runWithForkBudget, runWithVerificationBudget, sameCapacity, sameVerification, sh, shGit, SUITE_PARENT_ENV, verificationProtocol, WORKTREE_LAYOUT_CONTRACT, worktreePath } from "./git.js";
33
33
  import { runInteractiveSeed } from "./interactive-seed.js";
34
- import { applyScopeAmendments, 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";
34
+ import { classifyRepairDisposition, resolveScopeHints } from "./repair-disposition.js";
35
+ import { applyScopeAmendments, activeRetryBan, classifyTaskFailure, classifyWorkerResultCause, deferredReviewFindings, engagementComparable, formatPriorFindingEvidence, GATE_FINGERPRINT_CAP, GATE_SATISFIED_RELEASE, identicalGateFailures, isDeferredFinding, journaledFailureBrief, Journal, loadRoutingProfile, newRunId, normalizeGateFailure, outstandingConsultGuidance, outstandingReviewFindings, pendingApprovalActions, pendingRechecks, pendingRepairFindings, phaseForGate, readPriorRunEvidence, recordedTaskFailureKind, RECHECK_RELEASE, renderStructuredReviewFinding, repairReachSinceApproval, repairsSinceApproval, reviewRoundsSinceApproval, runHasEnded, structuredFindings, upheldFeedbackByTask } from "./journal.js";
35
36
  import { isDiffCapPark, pickReviewer } from "../gates/review.js";
36
37
  import { acquireApprovalSerialization, acquireRunLock, isPidLive, releaseRunLock } from "./lock.js";
37
38
  import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
@@ -175,6 +176,17 @@ const RECHECK_ENACTMENT = "recheck-battery";
175
176
  // persisted task branch after the approved gate (execTask's satisfiedGate branch), whose first act
176
177
  // for the task is worktree-recreation. That event is causal for this path, not incidental.
177
178
  const GATE_SATISFIED_ENACTMENT = "worktree-recreation";
179
+ // Older daemons enacted rechecks through worker-launch, before recheck-battery existed.
180
+ // Preserve that consumption at every scheduling read without changing continuing permission.
181
+ function pendingDaemonApprovalActions(events) {
182
+ const actions = pendingApprovalActions(events);
183
+ const rechecks = pendingRechecks(events);
184
+ for (const [id, action] of actions) {
185
+ if (action.authority === "battery" && !rechecks.has(id))
186
+ actions.delete(id);
187
+ }
188
+ return actions;
189
+ }
178
190
  /**
179
191
  * T14, amended by v2.2 T3: approvals the run accepted and never acted on. `approved` above is still
180
192
  * built ONCE at startup — replay determinism depends on it — but a live approval is no longer inert:
@@ -357,6 +369,14 @@ function repairDiffFilesCut(diff, cap, names) {
357
369
  });
358
370
  return files;
359
371
  }
372
+ /** Assertion evidence is context, never part of the normalized failure identity. */
373
+ function repairFindingsBrief(results) {
374
+ return results.filter(gateFailed).map((g) => {
375
+ const evidence = g.meta?.failureEvidence;
376
+ return `${g.gate}: ${g.details}` + (Array.isArray(evidence) && evidence.length
377
+ ? `\nAssertion evidence:\n${JSON.stringify(evidence, null, 2)}` : "");
378
+ }).join("\n\n");
379
+ }
360
380
  /** The fix-only contract: the findings verbatim, then the diff content of the work already landed. */
361
381
  function repairBrief(findings, diff, baseRef) {
362
382
  const repair = { findings, diff };
@@ -688,6 +708,47 @@ function lastVerifyCycle(events) {
688
708
  }
689
709
  return cur;
690
710
  }
711
+ /**
712
+ * OBS-1077 close rider: what the engagement's LATEST verification cycle proved — its
713
+ * `tip-verify-start` row and what followed, never a commit comparison. Exactly one kind per close:
714
+ * fresh (every tip command ran AND passed), reused (an eligible cached cycle carried forward),
715
+ * failed, or incomplete (cancelled, cut short, mixed or undelimited). Nothing before the start row
716
+ * is read, so an unfinished cycle inherits nothing from an earlier green one.
717
+ */
718
+ export function runEndTipProof(events) {
719
+ const start = events.map((e) => e.event).lastIndexOf("tip-verify-start");
720
+ if (start < 0)
721
+ return { kind: "incomplete" };
722
+ const { tip: rawTip, gates: rawGates } = events[start].data;
723
+ const tip = typeof rawTip === "string" ? rawTip : undefined;
724
+ const proof = (kind) => ({ kind, ...(tip ? { tip } : {}) });
725
+ const after = events.slice(start + 1);
726
+ if (after.some((e) => e.event === "tip-verify-failed"))
727
+ return proof("failed");
728
+ if (after.some((e) => e.event === "tip-verify-cancelled"))
729
+ return proof("incomplete");
730
+ const rows = after.filter((e) => e.event === "tip-verify" && e.data.pass === true && e.data.tip === tip);
731
+ const gates = Array.isArray(rawGates) ? rawGates : [];
732
+ if (!tip || gates.length === 0 || !gates.every((g) => rows.some((r) => r.data.gate === g)))
733
+ return proof("incomplete");
734
+ const carried = rows.filter((r) => r.data.cached === true).length;
735
+ const cachedRow = after.some((e) => e.event === "tip-verify-cached");
736
+ // A whole-cycle carry must be declared by the start row, stated by the cache row and borne by every verdict row.
737
+ if ((events[start].data.cached === true) !== cachedRow || (cachedRow && carried !== rows.length))
738
+ return proof("incomplete");
739
+ // D-131: fresh only when EVERY command executed; one per-gate persisted verdict makes the cycle reused.
740
+ return carried > 0 ? proof("reused") : proof("fresh");
741
+ }
742
+ /** The close notification's statement of the proof — one clause per kind. */
743
+ export function formatTipProof(p) {
744
+ const at = p.tip ? ` ${p.tip.slice(0, 12)}` : "";
745
+ switch (p.kind) {
746
+ case "fresh": return `tip proof: fresh — every tip command ran and passed on${at || " the integration tip"}`;
747
+ case "reused": return `tip proof: reused — carried from verified commit${at}, commands not re-run`;
748
+ case "failed": return `tip proof: failed —${at ? ` commit${at}` : ""} latest verification cycle is red`;
749
+ case "incomplete": return `tip proof: incomplete —${at ? ` commit${at}` : ""} latest verification cycle did not finish`;
750
+ }
751
+ }
691
752
  // Isolate the battery so cancellation can stop its control flow as well as its shell children.
692
753
  // The child uses the same verifier and capacity; only the daemon writes lifecycle verdict rows.
693
754
  async function cancellableTipBattery(intWt, commands, runDir, baseline, signal) {
@@ -859,7 +920,7 @@ export async function verifyIntegrationTipCached(intWt, commands, journal, opts
859
920
  const measuredCapacity = r.capacity ?? capacity;
860
921
  if (r.pass) {
861
922
  // Q121s: a forgiven pass journals its fingerprints — honest about what was carried, never a silent green.
862
- journal.append("tip-verify", undefined, { gate: r.gate, cmd: r.cmd, pass: true, exitCode: r.exitCode, details: r.details, ...(r.forgiven ? { forgiven: true, fingerprints: r.fingerprints } : {}), tip, cmdHash, capacity: measuredCapacity });
923
+ journal.append("tip-verify", undefined, { gate: r.gate, cmd: r.cmd, pass: true, exitCode: r.exitCode, details: r.details, ...(r.reused ? { cached: true } : {}), ...(r.forgiven ? { forgiven: true, fingerprints: r.fingerprints } : {}), tip, cmdHash, capacity: measuredCapacity });
863
924
  }
864
925
  else {
865
926
  journal.append("tip-verify-failed", undefined, {
@@ -1411,11 +1472,23 @@ const terminateTornJournalTail = (journalPath) => {
1411
1472
  // a persistently unwritable sink reports a crash naming the journal path and carrying NO terminal
1412
1473
  // record, rather than fabricating an ended run on evidence the harness could not write. (OBS-313:
1413
1474
  // with the sink dead the crash CAUSE is unrecordable — recorded as an observation, not papered over.)
1414
- function recordFatalRunEnd(journal, runId, branch, err) {
1475
+ export function recordFatalRunEnd(journal, runId, branch, err, graph, phase = "setup") {
1415
1476
  const original = err instanceof Error ? err.message : String(err);
1477
+ // A fatal close never finished a cycle: fresh/reused green is withheld, a recorded red still stands.
1478
+ let tipProof = { kind: "incomplete" };
1416
1479
  try {
1417
- if (journal.read().some((e) => e.event === "run-end"))
1418
- return; // already terminal — nothing to add
1480
+ // Only the latest engagement can already own this terminal outcome.
1481
+ const events = journal.read();
1482
+ let from = events.length;
1483
+ while (from > 0) {
1484
+ const event = events[--from];
1485
+ if (event.event === "run-start" || event.event === "run-resume")
1486
+ break;
1487
+ if (event.event === "run-end")
1488
+ return undefined;
1489
+ }
1490
+ const latest = runEndTipProof(events.slice(from));
1491
+ tipProof = { ...latest, kind: latest.kind === "failed" ? "failed" : "incomplete" };
1419
1492
  }
1420
1493
  catch (readErr) {
1421
1494
  console.error(`tickmarkr ${runId}: journal read failed while recording the fatal run-end (${readErr instanceof Error ? readErr.message : String(readErr)}) — original error: ${original}`);
@@ -1423,18 +1496,19 @@ function recordFatalRunEnd(journal, runId, branch, err) {
1423
1496
  const record = {
1424
1497
  runId,
1425
1498
  branch,
1426
- done: [],
1427
- failed: [],
1428
- human: [],
1429
- blocked: [],
1430
- pending: [],
1431
- phase: "setup",
1499
+ done: graph?.tasks.filter((t) => t.status === "done").map((t) => t.id) ?? [],
1500
+ failed: graph?.tasks.filter((t) => t.status === "failed").map((t) => t.id) ?? [],
1501
+ human: graph?.tasks.filter((t) => t.status === "human").map((t) => t.id) ?? [],
1502
+ blocked: graph ? blockedTasks(graph).map((t) => t.id) : [],
1503
+ pending: graph ? pendingTasks(graph).map((t) => t.id) : [],
1504
+ phase,
1432
1505
  fatal: true,
1433
1506
  error: original,
1507
+ tipProof,
1434
1508
  };
1435
1509
  try {
1436
1510
  journal.append("run-end", undefined, record);
1437
- return;
1511
+ return tipProof;
1438
1512
  }
1439
1513
  catch {
1440
1514
  // one retry below — the first failure is reported only if the retry also fails
@@ -1443,6 +1517,7 @@ function recordFatalRunEnd(journal, runId, branch, err) {
1443
1517
  try {
1444
1518
  terminateTornJournalTail(journalPath);
1445
1519
  journal.append("run-end", undefined, record);
1520
+ return tipProof;
1446
1521
  }
1447
1522
  catch (retryErr) {
1448
1523
  console.error(`tickmarkr ${runId}: run crashed — no terminal record written; journal sink unwritable at ${journalPath} (${retryErr instanceof Error ? retryErr.message : String(retryErr)}) — original error: ${original}`);
@@ -1512,12 +1587,18 @@ export async function runDaemon(repoRoot, opts = {}) {
1512
1587
  let journal;
1513
1588
  let runStarted = false;
1514
1589
  let taskLoopStarted = false;
1590
+ let graph;
1591
+ let fatalPhase = "setup";
1592
+ let deliberateTermination = false;
1593
+ const fatalStop = new AbortController();
1594
+ const inflight = new Map();
1595
+ let retireFatalSlots;
1515
1596
  let branch = "";
1516
1597
  let releaseApprovalSerialization;
1517
1598
  let baselineCapture = Promise.resolve();
1518
1599
  let baselineFailed = false;
1519
1600
  try {
1520
- let graph = loadGraph(repoRoot);
1601
+ graph = loadGraph(repoRoot);
1521
1602
  // One bounded snapshot supplies every dispatch. On resume the current journal still participates
1522
1603
  // in the fold so its task-done/ordinary-approval events can retire older evidence, but findings it
1523
1604
  // produced are suppressed: same-run feedback already replays those bytes and must not deliver them
@@ -1629,6 +1710,14 @@ export async function runDaemon(repoRoot, opts = {}) {
1629
1710
  await reapWorker(slot);
1630
1711
  await driver.close(slot);
1631
1712
  } }, s);
1713
+ retireFatalSlots = async () => {
1714
+ for (const slot of Array.from(liveSlots)) {
1715
+ try {
1716
+ await closeSlot(slot);
1717
+ }
1718
+ catch { /* preserve the original fatal error */ }
1719
+ }
1720
+ };
1632
1721
  const budgetSlots = new Map();
1633
1722
  const trackedDriver = {
1634
1723
  id: driver.id,
@@ -1637,19 +1726,24 @@ export async function runDaemon(repoRoot, opts = {}) {
1637
1726
  ...(driver.describe ? { describe: driver.describe.bind(driver) } : {}),
1638
1727
  ...(driver.focus ? { focus: driver.focus.bind(driver) } : {}),
1639
1728
  slot: async (cwd, name, o) => {
1729
+ fatalStop.signal.throwIfAborted();
1640
1730
  const signal = executionSignal();
1641
1731
  signal?.throwIfAborted();
1642
1732
  const s = await driver.slot(cwd, name, o);
1643
1733
  liveSlots.add(s);
1644
1734
  if (signal)
1645
1735
  budgetSlots.get(signal)?.add(s);
1646
- if (signal?.aborted) {
1736
+ if (signal?.aborted || fatalStop.signal.aborted) {
1647
1737
  await withoutExecutionBudget(() => closeSlot(s));
1648
- signal.throwIfAborted();
1738
+ fatalStop.signal.throwIfAborted();
1739
+ signal?.throwIfAborted();
1649
1740
  }
1650
1741
  return s;
1651
1742
  },
1652
- run: (s, cmd) => driver.run(s, cmd),
1743
+ run: (s, cmd) => {
1744
+ fatalStop.signal.throwIfAborted();
1745
+ return driver.run(s, cmd);
1746
+ },
1653
1747
  waitOutput: (s, p, ms, o) => driver.waitOutput(s, p, ms, o),
1654
1748
  waitAgentStatus: (s, st, ms) => driver.waitAgentStatus(s, st, ms),
1655
1749
  status: (s) => driver.status(s),
@@ -1692,6 +1786,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1692
1786
  const exit = opts.exit ?? ((code) => process.exit(code));
1693
1787
  onTermination = (sig) => {
1694
1788
  termSignal = sig;
1789
+ deliberateTermination = true;
1695
1790
  void (async () => {
1696
1791
  if (!reaping) {
1697
1792
  reaping = true;
@@ -1794,7 +1889,11 @@ export async function runDaemon(repoRoot, opts = {}) {
1794
1889
  // flag. Startup approvals seed the first scheduling pass; live approvals are folded at task
1795
1890
  // boundaries below so a sibling can release parked work without waiting for run-end + resume.
1796
1891
  const approvalStartupEvents = journal.read();
1797
- const approved = new Set(approvalStartupEvents.filter((e) => e.event === "task-approved" && e.taskId).map((e) => e.taskId));
1892
+ // Continuing human-gate permission survives enactment; malformed releases grant none.
1893
+ const validApproval = (e) => e.event === "task-approved" && e.taskId
1894
+ && pendingApprovalActions([e]).get(e.taskId)?.authority !== "inert";
1895
+ const approved = new Set(approvalStartupEvents.filter(validApproval).map((e) => e.taskId));
1896
+ const startupActions = pendingDaemonApprovalActions(approvalStartupEvents);
1798
1897
  let approvalSweepCursor = approvalStartupEvents.length;
1799
1898
  const commands = detectGateCommands(repoRoot, cfg);
1800
1899
  const watchName = formatOwnedName({ role: "watch", taskId: "run", attempt: 0, runId });
@@ -1900,6 +1999,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1900
1999
  const startedAt = Date.now();
1901
2000
  for (;;) {
1902
2001
  signal?.throwIfAborted();
2002
+ fatalStop.signal.throwIfAborted();
1903
2003
  executionSignal()?.throwIfAborted();
1904
2004
  const count = await liveSuiteCount(repoRoot);
1905
2005
  if (count === 0)
@@ -1941,11 +2041,15 @@ export async function runDaemon(repoRoot, opts = {}) {
1941
2041
  // Empty Map on fresh runs — every seed below conditions on resume.get(t.id), never on opts.resume (the
1942
2042
  // GATE-08 lesson at the humanGate guard: condition on the data, not the code path). Dead-code
1943
2043
  // equivalence to the router.ts:194 profile⇒undefined pattern: no map entry ⇒ today's literal.
1944
- let resume = opts.resume ? journal.replayResumeState() : new Map();
1945
- let satisfiedGates = opts.resume ? journal.replaySatisfiedGates() : new Map();
1946
- let replayedGateResults = resumeLifecycleOpen
2044
+ const resume = opts.resume ? journal.replayResumeState() : new Map();
2045
+ const satisfiedGates = opts.resume ? journal.replaySatisfiedGates() : new Map();
2046
+ const replayedGateResults = resumeLifecycleOpen
1947
2047
  ? journal.replayCurrentAttemptGateResults()
1948
2048
  : new Map();
2049
+ for (const [taskId, action] of startupActions) {
2050
+ if (action.authority === "worker")
2051
+ replayedGateResults.delete(taskId);
2052
+ }
1949
2053
  // T7: the capacity THIS session resolved — read inside the run's fork budget, so it is the number
1950
2054
  // every shell this run spawns will divide the machine by. Recorded evidence from another session
1951
2055
  // is only reusable against this.
@@ -1998,7 +2102,29 @@ export async function runDaemon(repoRoot, opts = {}) {
1998
2102
  }
1999
2103
  baseline = JSON.parse(readFileSync(join(journal.dir, "baseline.json"), "utf8"));
2000
2104
  const replayEvents = journal.read();
2001
- for (const [id, st] of journal.replayStatuses()) {
2105
+ // journal.replayStatuses predates typed releases and re-pends even inert rows. Keep that
2106
+ // legacy reader intact; scheduling accepts only the fold's recognised authorities.
2107
+ const statuses = new Map();
2108
+ for (const e of replayEvents) {
2109
+ if (!e.taskId)
2110
+ continue;
2111
+ if (e.event === "task-dispatch")
2112
+ statuses.set(e.taskId, "pending");
2113
+ else if (e.event === "task-done")
2114
+ statuses.set(e.taskId, "done");
2115
+ else if (e.event === "task-failed")
2116
+ statuses.set(e.taskId, "failed");
2117
+ else if (e.event === "task-human")
2118
+ statuses.set(e.taskId, "human");
2119
+ else if (validApproval(e))
2120
+ statuses.set(e.taskId, "pending");
2121
+ }
2122
+ for (const [id, action] of startupActions) {
2123
+ // Legacy gate-only approvals can finish and merge without a dispatch consuming funding.
2124
+ if (action.authority !== "inert" && statuses.get(id) !== "done")
2125
+ statuses.set(id, "pending");
2126
+ }
2127
+ for (const [id, st] of statuses) {
2002
2128
  if (opts.retryFailed && st === "failed" && recordedTaskFailureKind(replayEvents, id) === "dispatch") {
2003
2129
  graph = setStatus(graph, id, "pending");
2004
2130
  // OBS-254: clear ATTEMPT AND CHANNEL STATE ONLY. Deleting the whole entry also deleted
@@ -2021,6 +2147,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2021
2147
  ...(opts.retryFailed ? { retryFailed: true } : {}),
2022
2148
  ...(opts.graphChanged ? { graphChanged: true } : {}), // OBS-1073: the release is the engagement's
2023
2149
  });
2150
+ runStarted = true;
2024
2151
  await placeBoard();
2025
2152
  }
2026
2153
  else {
@@ -2142,6 +2269,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2142
2269
  let mergeChain = Promise.resolve();
2143
2270
  const mergeSerial = (taskBranch, t, gated) => {
2144
2271
  const next = mergeChain.then(() => {
2272
+ fatalStop.signal.throwIfAborted();
2145
2273
  executionSignal()?.throwIfAborted();
2146
2274
  journal.phaseStart(t.id, "merge");
2147
2275
  return mergeTask(intWt, taskBranch, `tickmarkr: merge ${t.id} ${t.title}`, gated);
@@ -2173,15 +2301,22 @@ export async function runDaemon(repoRoot, opts = {}) {
2173
2301
  const tree = await shGit("git ls-files -z", wt);
2174
2302
  return new Set([...(tree.code === 0 ? tree.stdout.split("\0").filter(Boolean) : []), ...await diffPaths(base, wt)]);
2175
2303
  };
2304
+ // The base each task was dispatched on: the integration branch moves when a sibling merges, the pin does not.
2305
+ const taskBases = new Map();
2176
2306
  const park = async (t, reason, kind, assignment, attempts, startMs, gateFails = 0, consults = 0, tokens, metered = 0, retryMode = "fresh", details = {}) => {
2177
2307
  // OBS-979: a worker refusal can identify the missing authoring scope even when gates
2178
2308
  // subsequently supply the park's disposition. Keep that actionable path on the park itself.
2179
2309
  const worker = journal.read().reverse().find((e) => e.taskId === t.id && e.event === "worker-result");
2180
2310
  if (!details.approveCommand && t.files.length > 0 && worker?.data.ok === false && typeof worker.data.summary === "string") {
2181
2311
  const allowed = filesGlob(t.files);
2182
- const paths = namedPaths(worker.data.summary).filter((path) => !allowed(path) && existsSync(join(repoRoot, path)));
2312
+ // OBS-1072 residual: the same inventory as the main path — the task tree and its diff from the
2313
+ // task's PINNED base, never the main checkout and never the moving integration branch.
2314
+ const wt = worktreePath(repoRoot, `${branch}--${t.id}`);
2315
+ const inventory = existsSync(wt) ? await treeOrDiffPaths(taskBases.get(t.id) ?? "HEAD", wt) : new Set();
2316
+ const paths = resolveScopeHints(namedPaths(worker.data.summary).filter((path) => !allowed(path)), inventory)
2317
+ .resolved.filter((path) => !allowed(path));
2183
2318
  if (paths.length)
2184
- reason += ` — files[] repair hint: ${[...new Set(paths)].join(", ")}`;
2319
+ reason += ` — files[] repair hint: ${paths.join(", ")}`;
2185
2320
  }
2186
2321
  graph = setStatus(graph, t.id, "human");
2187
2322
  saveGraph(repoRoot, graph);
@@ -2212,42 +2347,34 @@ export async function runDaemon(repoRoot, opts = {}) {
2212
2347
  const dispositionScopeRed = async (t, results, assignment, attempts, startMs, gateFails, consults, tokens, metered, retryMode, changed = new Set()) => {
2213
2348
  const scopeRed = results.find((g) => g.gate === "scope" && gateFailed(g));
2214
2349
  const verdict = scopeRed?.meta?.collateral;
2215
- if (!verdict && t.files.length > 0) {
2216
- const allowed = filesGlob(t.files);
2217
- const worker = journal.read().reverse().find((e) => e.taskId === t.id && e.event === "worker-result");
2218
- const requested = worker?.data.ok === false && typeof worker.data.summary === "string"
2219
- && /outside|out.of.scope|allowlist|scope expansion|not (?:in|own)|unowned/i.test(worker.data.summary)
2220
- ? namedPaths(worker.data.summary).filter((path) => !allowed(path)) : [];
2221
- const reds = results.filter(gateFailed);
2222
- // A scope verdict already attributes actual out-of-scope EDITS using the collateral map.
2223
- // Path-only diagnostics from the other gates name code that needs fixing, not an edit the
2224
- // worker made. Do not replace the scope gate's stronger attribution with that inference.
2225
- const hints = reds.filter((g) => g.gate !== "scope" || !g.meta?.collateral).flatMap((g) => namedPaths(g.details));
2226
- const unresolved = [...new Set([...requested, ...hints].filter((path) => !changed.has(path)))];
2227
- if (unresolved.length)
2228
- journal.append("scope-hint-unresolved", t.id, { paths: unresolved,
2229
- reason: `scope hint resolves to nothing in the task tree: ${unresolved.join(", ")}`, attempt: attempts + 1 });
2230
- const refusal = requested.filter((path) => changed.has(path));
2231
- const paths = hints.filter((path) => changed.has(path));
2232
- if (refusal.length || (paths.length > 0 && paths.every((path) => !allowed(path)))) {
2233
- const classification = {
2234
- gate: refusal.length ? "worker" : reds[0]?.gate,
2235
- paths: [...new Set(refusal.length ? refusal : paths)],
2236
- repair: `files[] repair hint: ${[...new Set(refusal.length ? refusal : paths)].join(", ")}`,
2237
- attempt: attempts + 1, chargeable: false, source: "diagnostic",
2238
- };
2239
- const scopeRequest = refusal.length > 0 || reds.some((g) => g.gate === "review");
2240
- if (scopeRequest)
2241
- journal.append("scope-request", t.id, classification);
2242
- else
2243
- journal.append("scope-authoring", t.id, classification);
2244
- // OBS-547 (Leg-2 T3 material): unchargeable ⇒ metered 0, exactly as the predicted path below —
2245
- // passing the physical count would write `meteredAttempts: 1` beside `attempts: 0`.
2246
- const approveCommand = `tickmarkr approve ${runId} ${t.id} --files ${[...new Set(refusal.length ? refusal : paths)].map((path) => /^[\w./-]+$/.test(path) ? path : shq(path)).join(",")}`;
2247
- await park(t, `${scopeRequest ? "scope request" : "authoring defect"} — files[] repair hint: ${[...new Set(refusal.length ? refusal : paths)].join(", ")}; current files[]: ${t.files.join(", ")}${scopeRequest ? `\n${approveCommand}` : ""}`, scopeRequest ? "scope-request" : "authoring", assignment, attempts, startMs, gateFails, consults, tokens, 0, retryMode, scopeRequest ? { paths: [...new Set(refusal.length ? refusal : paths)], approveCommand, graphDefinitionHash: graphDefinitionHash(graph) } : {});
2248
- return true;
2249
- }
2350
+ const worker = journal.read().reverse().find((e) => e.taskId === t.id && e.event === "worker-result");
2351
+ const disposition = classifyRepairDisposition({
2352
+ results, files: t.files, inventory: changed,
2353
+ refusalSummary: worker?.data.ok === false && typeof worker.data.summary === "string" ? worker.data.summary : undefined,
2354
+ });
2355
+ for (const diagnostic of disposition.diagnostics) {
2356
+ journal.append("scope-hint-unresolved", t.id, {
2357
+ paths: [diagnostic.candidate], reason: diagnostic.reason, diagnostic, attempt: attempts + 1,
2358
+ });
2359
+ }
2360
+ if (disposition.blocker === "infra")
2361
+ return false;
2362
+ if (!disposition.blocker && (disposition.kind === "scope-request" || disposition.kind === "authoring")) {
2363
+ const paths = disposition.paths;
2364
+ const scopeRequest = disposition.kind === "scope-request";
2365
+ const classification = {
2366
+ gate: disposition.source, paths, repair: `files[] repair hint: ${paths.join(", ")}`,
2367
+ attempt: attempts + 1, chargeable: false, source: "diagnostic",
2368
+ };
2369
+ if (scopeRequest)
2370
+ journal.append("scope-request", t.id, classification);
2371
+ else
2372
+ journal.append("scope-authoring", t.id, classification);
2373
+ const approveCommand = `tickmarkr approve ${runId} ${t.id} --files ${paths.map((path) => /^[\w./-]+$/.test(path) ? path : shq(path)).join(",")}`;
2374
+ await park(t, `${scopeRequest ? "scope request" : "authoring defect"} — files[] repair hint: ${paths.join(", ")}; current files[]: ${t.files.join(", ")}${scopeRequest ? `\n${approveCommand}` : ""}`, scopeRequest ? "scope-request" : "authoring", assignment, attempts, startMs, gateFails, consults, tokens, 0, retryMode, scopeRequest ? { paths, approveCommand, graphDefinitionHash: graphDefinitionHash(graph) } : {});
2375
+ return true;
2250
2376
  }
2377
+ // A detection site permits the ordinary chargeable policy; it does not itself grant budget.
2251
2378
  if (!verdict)
2252
2379
  return false;
2253
2380
  if (verdict.authoring) {
@@ -2284,17 +2411,33 @@ export async function runDaemon(repoRoot, opts = {}) {
2284
2411
  approvalSweepCursor = events.length;
2285
2412
  if (approvals.length === 0)
2286
2413
  return;
2287
- graph = applyScopeAmendments(graph, journal, false, opts.graphChanged === true); // OBS-1073: same release as launch
2288
- resume = journal.replayResumeState();
2289
- satisfiedGates = journal.replaySatisfiedGates();
2290
- replayedGateResults = resumeLifecycleOpen
2291
- ? journal.replayCurrentAttemptGateResults()
2292
- : new Map();
2414
+ // Replay every authority from this pass's single snapshot. In particular, helper reads must
2415
+ // neither rescan the file under load nor observe approvals beyond approvalSweepCursor.
2416
+ // Keep audit writes on the live journal so narration and persistence retain their owner.
2417
+ const sweepJournal = Journal.open(repoRoot, runId);
2418
+ sweepJournal.read = () => events;
2419
+ sweepJournal.append = journal.append.bind(journal);
2420
+ graph = applyScopeAmendments(graph, sweepJournal, false, opts.graphChanged === true); // OBS-1073: same release as launch
2421
+ const actions = pendingDaemonApprovalActions(events);
2422
+ const nextResume = sweepJournal.replayResumeState();
2423
+ const nextSatisfied = sweepJournal.replaySatisfiedGates();
2293
2424
  let changed = false;
2294
- for (const e of approvals) {
2295
- const taskId = e.taskId;
2425
+ for (const taskId of new Set(approvals.map((e) => e.taskId))) {
2426
+ const action = actions.get(taskId);
2427
+ if (!action || action.authority === "inert")
2428
+ continue;
2296
2429
  approved.add(taskId);
2297
- if (e.data.release !== GATE_SATISFIED_RELEASE)
2430
+ const state = nextResume.get(taskId);
2431
+ if (state)
2432
+ resume.set(taskId, state);
2433
+ else
2434
+ resume.delete(taskId);
2435
+ const gate = nextSatisfied.get(taskId);
2436
+ if (gate)
2437
+ satisfiedGates.set(taskId, gate);
2438
+ else
2439
+ satisfiedGates.delete(taskId);
2440
+ if (action.authority !== "waiver")
2298
2441
  replayedGateResults.delete(taskId);
2299
2442
  try {
2300
2443
  const task = getTask(graph, taskId);
@@ -2311,6 +2454,8 @@ export async function runDaemon(repoRoot, opts = {}) {
2311
2454
  saveGraph(repoRoot, graph);
2312
2455
  };
2313
2456
  const execTask = async (t) => {
2457
+ const approvalAction = pendingDaemonApprovalActions(journal.read()).get(t.id);
2458
+ const fundedRerun = approvalAction?.authority === "worker";
2314
2459
  const startMs = Date.now();
2315
2460
  let stallSeat;
2316
2461
  let stallReaps = 0;
@@ -2540,7 +2685,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2540
2685
  ...(typeof g.meta.preservedRef === "string" ? { preservedRef: g.meta.preservedRef } : {}),
2541
2686
  } : {}),
2542
2687
  ...(cfg.executionPolicy && !g.pass ? { disposition: failureDisposition(g) } : {}),
2543
- ...Object.fromEntries(["runnerInfraRerun", "hostStarvedRerun", "recoveryBlocked", "failingFiles", "selectionDecision"]
2688
+ ...Object.fromEntries(["runnerInfraRerun", "hostStarvedRerun", "recoveryBlocked", "failingFiles", "selectionDecision", "failureEvidence"]
2544
2689
  .filter((key) => g.meta?.[key] !== undefined).map((key) => [key, g.meta[key]])),
2545
2690
  // OBS-540: preserve terminal-vs-retryable infra exactly. normalizeGateOutcome deliberately
2546
2691
  // defaults a legacy infra row to retryable, so dropping an explicit false here reverses the
@@ -2816,10 +2961,11 @@ export async function runDaemon(repoRoot, opts = {}) {
2816
2961
  // An operator approval skips its exact failed gate by authority; observed results skip only the
2817
2962
  // contiguous green prefix whose recorded commit is still the task branch tip.
2818
2963
  const satisfiedGate = satisfiedGates.get(t.id);
2819
- const replayedGates = replayedGateResults.get(t.id);
2820
- const recheck = pendingRechecks(journal.read()).has(t.id);
2964
+ const replayedGates = fundedRerun ? undefined : replayedGateResults.get(t.id);
2965
+ const recheck = approvalAction?.authority === "battery";
2821
2966
  resumeGateReplay: if (satisfiedGate || replayedGates || recheck) {
2822
2967
  const taskBase = await integrationHead(intWt);
2968
+ taskBases.set(t.id, taskBase);
2823
2969
  const taskBranch = `${branch}--${t.id}`;
2824
2970
  const priorWt = worktreePath(repoRoot, taskBranch);
2825
2971
  // OBS-1022: the ref `approve --recheck` validated (commits ahead of the base) is the tree the
@@ -3016,6 +3162,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3016
3162
  }
3017
3163
  const resumedTask = { ...t, gates: remainingGates };
3018
3164
  gateLoop: while (true) {
3165
+ fatalStop.signal.throwIfAborted();
3019
3166
  executionSignal()?.throwIfAborted();
3020
3167
  const gated = await gitHead(wt);
3021
3168
  gateSubject = {
@@ -3073,7 +3220,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3073
3220
  },
3074
3221
  }, false));
3075
3222
  results.forEach(classifySignalOnlyTest);
3076
- if (pendingRechecks(journal.read()).has(t.id)) {
3223
+ if (pendingDaemonApprovalActions(journal.read()).get(t.id)?.authority === "battery") {
3077
3224
  journal.append("recheck-battery", t.id, {
3078
3225
  commit: gateSubject.commit,
3079
3226
  gates: resumedTask.gates,
@@ -3125,6 +3272,26 @@ export async function runDaemon(repoRoot, opts = {}) {
3125
3272
  }
3126
3273
  assignment = { adapter: seat.adapter, model: seat.model, channel: seat.channel, tier: seat.tier };
3127
3274
  }
3275
+ // Carry completeness was verified before this replay battery. Apply the ordinary
3276
+ // repair bounds here too; a restored red must fund the same findings-bearing dispatch.
3277
+ // Funding is durable intent only: repairReachSinceApproval charges it after launch
3278
+ // reaches a funded gate, and a restart before launch must not duplicate that intent.
3279
+ const failing = results.filter(gateFailed);
3280
+ const battery = failing.some((g) => g.gate !== "review") ? failing.filter((g) => g.gate !== "review") : failing;
3281
+ const landed = await commitsAheadOf(taskBase, wt);
3282
+ const events = journal.read();
3283
+ const drawn = repairsSinceApproval(events, t.id);
3284
+ const repeated = failing.some((g) => isDeterministicFailure(g)
3285
+ && identicalGateFailures(events, t.id, g.gate, normalizeGateFailure(g.details)) >= GATE_FINGERPRINT_CAP);
3286
+ if (narrowRepairBattery(battery) && landed.length > 0 && drawn < MAX_REPAIRS
3287
+ && (rs?.attempts ?? 0) < MAX_ATTEMPTS && !repeated
3288
+ && pendingRepairFindings(events, t.id) === undefined) {
3289
+ journal.append("repair-attempt", t.id, {
3290
+ repair: repairReachSinceApproval(events, t.id).length + 1, charge: drawn + 1, of: MAX_REPAIRS,
3291
+ gates: failing.map((g) => g.gate), commits: landed.length,
3292
+ findings: withCarriedEvidence(repairFindingsBrief(results)),
3293
+ });
3294
+ }
3128
3295
  break gateLoop;
3129
3296
  }
3130
3297
  await park(t, gateFailApprovalReason(t.id, "post-approval gate failed", results.some((g) => g.gate === "review" && gateFailed(g))), "gate-fail", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
@@ -3170,6 +3337,9 @@ export async function runDaemon(repoRoot, opts = {}) {
3170
3337
  // the same worktree. The next loop iteration retires it before reading/recreating the tree.
3171
3338
  let supersededWorkerSlot;
3172
3339
  attempts: for (let attempt = rs?.attempts ?? 0;; attempt++) {
3340
+ // Funding belongs to the next attempt. Later automatic repairs retain ordinary red reuse.
3341
+ const explicitlyFundedAttempt = fundedRerun && attempt === (rs?.attempts ?? 0);
3342
+ fatalStop.signal.throwIfAborted();
3173
3343
  executionSignal()?.throwIfAborted();
3174
3344
  if (attempt !== providerDeathAttempt) {
3175
3345
  providerDeathRequeues = 0;
@@ -3344,6 +3514,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3344
3514
  // T6: a dispatch that carries an outstanding finding says so, and names it. Without this the
3345
3515
  // ledger cannot tell a carried dispatch from an amnesiac one — the exact question a run that
3346
3516
  // spends two frontier attempts re-deriving a known defect has to be able to answer afterwards.
3517
+ fatalStop.signal.throwIfAborted();
3347
3518
  journal.append("task-dispatch", t.id, {
3348
3519
  ...(scopeApproval?.data.release === "scope-request" ? { files: t.files, graphDefinitionHash: graphDefinitionHash(graph) } : {}),
3349
3520
  assignment, attempt, provenance: dispatchProvenance([
@@ -3362,6 +3533,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3362
3533
  });
3363
3534
  journal.phaseStart(t.id, "worker", { attempt, assignment });
3364
3535
  const taskBase = await integrationHead(intWt); // deps are merged → visible to this task
3536
+ taskBases.set(t.id, taskBase);
3365
3537
  const taskBranch = `${branch}--${t.id}`; // "--": a ref can't nest under the existing integration branch (locked decision 10)
3366
3538
  const priorWt = worktreePath(repoRoot, taskBranch);
3367
3539
  const recreating = existsSync(priorWt);
@@ -3869,6 +4041,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3869
4041
  while (Date.now() < hardDeadline) {
3870
4042
  if (termSignal)
3871
4043
  throw new Error(`terminated by ${termSignal}`);
4044
+ fatalStop.signal.throwIfAborted();
3872
4045
  executionSignal()?.throwIfAborted();
3873
4046
  driverProbeFailed = false;
3874
4047
  const sliceStart = Date.now();
@@ -4452,6 +4625,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4452
4625
  // there is the trailer regex; the exit marker only sets exitCode), and the cause taxonomy
4453
4626
  // already names this shape "clean-exit-no-trailer" — unreachable in print mode until now.
4454
4627
  while (!startupFailure && Date.now() - lastProgressAt < stallWindowMs) {
4628
+ fatalStop.signal.throwIfAborted();
4455
4629
  executionSignal()?.throwIfAborted();
4456
4630
  const remaining = stallWindowMs - (Date.now() - lastProgressAt);
4457
4631
  let slice = Math.min(BLOCKED_POLL_MS, Math.max(100, Math.min(stallWindowMs / 2, remaining)));
@@ -4881,6 +5055,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4881
5055
  let results = [];
4882
5056
  let commits = [];
4883
5057
  gateLoop: while (true) {
5058
+ fatalStop.signal.throwIfAborted();
4884
5059
  executionSignal()?.throwIfAborted();
4885
5060
  const gated = await gitHead(wt);
4886
5061
  gateSubject = { commit: await gateCommitSubject(taskBase, gated, wt), attempt };
@@ -4895,7 +5070,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4895
5070
  && e.data.attempt === attempt - 1)
4896
5071
  : [];
4897
5072
  journal.phaseStart(t.id, "gates");
4898
- const replay = previousRows.length > 0
5073
+ const replay = !explicitlyFundedAttempt && previousRows.length > 0
4899
5074
  && previousRows.every((e) => e.data.commit === gateSubject.commit)
4900
5075
  && previousRows.some((e) => e.data.pass === false && e.data.skipped !== true);
4901
5076
  if (replay) {
@@ -4922,6 +5097,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4922
5097
  commands, baseline, channels: pools.review, judgeChannels: pools.judge, adapters, cfg, artifactDir: journal.dir,
4923
5098
  collateral: collateral.get(t.id) ?? [],
4924
5099
  selectTests: !testGateFailed,
5100
+ cachedRedBypass: explicitlyFundedAttempt ? "operator-rerun" : undefined,
4925
5101
  via: cfg.visibility.llm === "pane"
4926
5102
  ? {
4927
5103
  driver: trackedDriver,
@@ -4960,6 +5136,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4960
5136
  // gate-fail brief is, so prior-RUN evidence (retired by its own rule, not by this reviewer)
4961
5137
  // survives and only this run's settled findings go.
4962
5138
  feedback = withCarriedEvidence("");
5139
+ fatalStop.signal.throwIfAborted();
4963
5140
  executionSignal()?.throwIfAborted();
4964
5141
  const m = await mergeSerial(taskBranch, t, gated);
4965
5142
  if (m.tipMoved) {
@@ -5029,7 +5206,7 @@ export async function runDaemon(repoRoot, opts = {}) {
5029
5206
  // v1.53 T3: prefer the CLI's own session id captured from this attempt's output (kimi's resume
5030
5207
  // trailer) over the harness slot name; absent hook or no capture keeps today's slot-name id.
5031
5208
  retrySession = { channel: channelKey(assignment), id: adapter.sessionIdFrom?.(output) ?? sessionId, contextTokens };
5032
- feedback = withCarriedEvidence(results.filter(gateFailed).map((g) => `${g.gate}: ${g.details}`).join("\n\n"));
5209
+ feedback = withCarriedEvidence(repairFindingsBrief(results));
5033
5210
  // OBS-189/G3 (park-economics patch): a request-changes review is a findings brief, not a worker
5034
5211
  // defect — the fix attempt stays on the same channel with the findings as feedback and consumes
5035
5212
  // no escalation-ladder rung. Bounded by the engagement round cap at the top of this loop.
@@ -5057,7 +5234,7 @@ export async function runDaemon(repoRoot, opts = {}) {
5057
5234
  const repairable = narrowRepairBattery(repairBattery) && lostCommits.length === 0 && landed.length > 0;
5058
5235
  const repairHistory = repairReachSinceApproval(journal.read(), t.id);
5059
5236
  const repairsDrawn = repairsSinceApproval(journal.read(), t.id);
5060
- const repair = repairable && repairsDrawn < MAX_REPAIRS;
5237
+ const repair = repairable && repairsDrawn < MAX_REPAIRS && attempt + 1 < MAX_ATTEMPTS;
5061
5238
  // v1.85 T3: the fingerprint cap. Two normalized-identical failures of one DETERMINISTIC gate on
5062
5239
  // one task (volatile tokens — worktree prefixes, line refs, durations, run ids — are not
5063
5240
  // information) mean the round about to be bought is a re-measurement: ~663m across 5 runs went to
@@ -5217,7 +5394,7 @@ export async function runDaemon(repoRoot, opts = {}) {
5217
5394
  });
5218
5395
  };
5219
5396
  taskLoopStarted = true;
5220
- const inflight = new Map();
5397
+ fatalPhase = "scheduler";
5221
5398
  // A settled worker can release a dependency or an approval in the same poll tick.
5222
5399
  // Audit the current graph at every empty-flight boundary, never a prior ready snapshot.
5223
5400
  // WB-1 rider: `end-condition-held` names a CLOSE the daemon refused, so only a close attempt journals
@@ -5236,6 +5413,7 @@ export async function runDaemon(repoRoot, opts = {}) {
5236
5413
  return true;
5237
5414
  };
5238
5415
  closeLoop: while (true) {
5416
+ fatalPhase = "scheduler";
5239
5417
  let approvalDeadline;
5240
5418
  while (true) {
5241
5419
  // v1.54 T2: a signal that landed while nothing was racing `aborted` (empty inflight window)
@@ -5262,6 +5440,8 @@ export async function runDaemon(repoRoot, opts = {}) {
5262
5440
  cleanupErrors.push({ slot: slot.name, attempt: owner.attempt, error: String(error) });
5263
5441
  }
5264
5442
  }
5443
+ if (fatalStop.signal.aborted)
5444
+ return;
5265
5445
  const cleanupEvidence = cleanupErrors.length ? { cleanupErrors } : {};
5266
5446
  if (err instanceof HeldProbeExhausted) {
5267
5447
  const wt = worktreePath(repoRoot, `${branch}--${t.id}`);
@@ -5364,7 +5544,9 @@ export async function runDaemon(repoRoot, opts = {}) {
5364
5544
  blocked: blockedTasks(graph).map((t) => t.id),
5365
5545
  pending: pendingTasks(graph).map((t) => t.id),
5366
5546
  };
5547
+ fatalPhase = "baseline";
5367
5548
  await baselineCapture; // also drain capture when every worker parks or fails before its gates
5549
+ fatalPhase = "tip-verify";
5368
5550
  // OBS-34: post-merge integration-tip verify — strict exit codes, no baseline forgiveness.
5369
5551
  const lastMergedTask = [...journal.read()].reverse().find((e) => e.event === "merge" && e.taskId)?.taskId;
5370
5552
  if (summary.done.length > 0 && Object.keys(commands).length > 0) {
@@ -5407,9 +5589,14 @@ export async function runDaemon(repoRoot, opts = {}) {
5407
5589
  clearTimeout(poll);
5408
5590
  }
5409
5591
  summary.tipVerify = tipFailed ? "failed" : "passed";
5592
+ summary.tipProof = runEndTipProof(journal.read());
5410
5593
  if (tipFailed && lastMergedTask)
5411
5594
  summary.lastMergedTask = lastMergedTask;
5412
5595
  }
5596
+ else {
5597
+ // Exactly one proof per close: no cycle ran for THIS close, and an earlier one is not inherited.
5598
+ summary.tipProof = { kind: "incomplete" };
5599
+ }
5413
5600
  // T14: read the journal, not the startup `approved` set — an approval appended DURING this run is
5414
5601
  // exactly the one the set cannot see, and it is the one the record has to name.
5415
5602
  //
@@ -5417,6 +5604,7 @@ export async function runDaemon(repoRoot, opts = {}) {
5417
5604
  // graph.lock release in the outer finally: an approval that wins first is included below, while an
5418
5605
  // approval that loses cannot append until the live owner is gone and reports recorded-no-owner.
5419
5606
  // Thus no accepted approval can land after this sample while still being attributed to this run.
5607
+ fatalPhase = "scheduler";
5420
5608
  const approvalSerialization = await acquireApprovalSerialization(repoRoot, runId);
5421
5609
  releaseApprovalSerialization = approvalSerialization.release;
5422
5610
  // Close the last poll-to-run-end race while holding the same serializer as approve.
@@ -5448,7 +5636,7 @@ export async function runDaemon(repoRoot, opts = {}) {
5448
5636
  const tipFail = summary.tipVerify === "failed"
5449
5637
  ? ` — TIP VERIFY FAILED on ${summary.lastMergedTask ? `last merge ${summary.lastMergedTask}` : "integration tip"}`
5450
5638
  : "";
5451
- await driver.notify(`tickmarkr ${runId}: ${summary.done.length} done, ${summary.failed.length} failed, ${summary.human.length} awaiting human, ${summary.blocked.length} blocked, ${summary.pending.length} pending${attribution ? ` (${attribution})` : ""}${tipFail} — integration branch ${branch} (merge to main is yours)`, { tier: summary.tipVerify === "failed" ? "attention" : "routine" });
5639
+ await driver.notify(`tickmarkr ${runId}: ${summary.done.length} done, ${summary.failed.length} failed, ${summary.human.length} awaiting human, ${summary.blocked.length} blocked, ${summary.pending.length} pending${attribution ? ` (${attribution})` : ""}${tipFail} — ${formatTipProof(summary.tipProof)} — integration branch ${branch} (merge to main is yours)`, { tier: summary.tipVerify === "failed" ? "attention" : "routine" });
5452
5640
  return summary;
5453
5641
  }
5454
5642
  });
@@ -5458,8 +5646,22 @@ export async function runDaemon(repoRoot, opts = {}) {
5458
5646
  // T7 (v1.86): guarded — a journal read/append failure while recording the fatal run-end is
5459
5647
  // reported alongside err, never instead of it; recordFatalRunEnd never throws, so the original
5460
5648
  // error (message, stack, cause) always reaches the caller verbatim.
5461
- if (runStarted && (!taskLoopStarted || baselineFailed))
5462
- recordFatalRunEnd(journal, runId, branch, err);
5649
+ // Freeze failure-time buckets, stop new dispatch, and drain all journal writers before closing.
5650
+ if (!deliberateTermination) {
5651
+ const failedGraph = graph;
5652
+ const phase = taskLoopStarted ? (baselineFailed ? "baseline" : fatalPhase) : "setup";
5653
+ fatalStop.abort(err);
5654
+ await retireFatalSlots?.();
5655
+ await Promise.allSettled(inflight.values());
5656
+ await baselineCapture.catch(() => { });
5657
+ if (runStarted && !deliberateTermination) {
5658
+ const tipProof = recordFatalRunEnd(journal, runId, branch, err, taskLoopStarted || opts.resume ? failedGraph : undefined, phase);
5659
+ // The fatal close states its proof too; a dead notifier never replaces the original error.
5660
+ if (tipProof) {
5661
+ await driver.notify(`tickmarkr ${runId}: run crashed — ${formatTipProof(tipProof)} — integration branch ${branch}`, { tier: "attention" }).catch(() => { });
5662
+ }
5663
+ }
5664
+ }
5463
5665
  throw err;
5464
5666
  }
5465
5667
  finally {