tickmarkr 2.6.4 → 2.6.6

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.
Files changed (73) hide show
  1. package/README.md +7 -3
  2. package/dist/adapters/catalog-remote.d.ts +23 -0
  3. package/dist/adapters/catalog-remote.js +38 -0
  4. package/dist/adapters/claude-code.d.ts +1 -1
  5. package/dist/adapters/claude-code.js +4 -1
  6. package/dist/adapters/codex-commit-check.d.ts +36 -0
  7. package/dist/adapters/codex-commit-check.js +205 -0
  8. package/dist/adapters/codex.d.ts +1 -0
  9. package/dist/adapters/codex.js +12 -6
  10. package/dist/adapters/model-discovery-check.d.ts +33 -0
  11. package/dist/adapters/model-discovery-check.js +123 -0
  12. package/dist/adapters/model-lints.d.ts +1 -1
  13. package/dist/adapters/model-lints.js +4 -0
  14. package/dist/adapters/prompt.js +3 -2
  15. package/dist/adapters/types.d.ts +10 -0
  16. package/dist/cli/commands/approve.js +10 -4
  17. package/dist/cli/commands/doctor.d.ts +2 -0
  18. package/dist/cli/commands/doctor.js +17 -3
  19. package/dist/cli/commands/fleet.d.ts +10 -1
  20. package/dist/cli/commands/fleet.js +363 -106
  21. package/dist/cli/commands/init.js +10 -3
  22. package/dist/cli/commands/plan.js +76 -4
  23. package/dist/cli/commands/verify.d.ts +1 -1
  24. package/dist/cli/commands/verify.js +65 -15
  25. package/dist/config/fleet-overlay.d.ts +16 -1
  26. package/dist/config/fleet-overlay.js +230 -28
  27. package/dist/drivers/orca.d.ts +34 -2
  28. package/dist/drivers/orca.js +206 -32
  29. package/dist/gates/cache.d.ts +4 -0
  30. package/dist/gates/cache.js +10 -2
  31. package/dist/gates/llm.d.ts +4 -1
  32. package/dist/gates/llm.js +6 -1
  33. package/dist/gates/review.js +8 -3
  34. package/dist/gates/run-gates.d.ts +5 -3
  35. package/dist/gates/run-gates.js +112 -56
  36. package/dist/gates/test-manifest.d.ts +3 -0
  37. package/dist/gates/test-manifest.js +1 -1
  38. package/dist/gates/timeout-shaped.d.ts +3 -0
  39. package/dist/gates/timeout-shaped.js +10 -0
  40. package/dist/run/daemon.js +326 -130
  41. package/dist/run/git.js +9 -0
  42. package/dist/run/journal.d.ts +6 -2
  43. package/dist/run/journal.js +22 -7
  44. package/dist/run/launchservices-check.d.ts +56 -0
  45. package/dist/run/launchservices-check.js +117 -0
  46. package/dist/run/operator-state.d.ts +22 -2
  47. package/dist/run/operator-state.js +20 -3
  48. package/dist/run/title-environment.d.ts +45 -0
  49. package/dist/run/title-environment.js +127 -0
  50. package/dist/run/title-preload.cjs +38 -0
  51. package/dist/run/title-preload.d.cts +1 -0
  52. package/dist/tui/cockpit/board.js +12 -6
  53. package/dist/tui/cockpit/keys.d.ts +8 -0
  54. package/dist/tui/cockpit/keys.js +4 -1
  55. package/dist/tui/cockpit/layout.d.ts +2 -2
  56. package/dist/tui/cockpit/layout.js +2 -2
  57. package/dist/tui/cockpit/live-runtime.js +46 -20
  58. package/dist/tui/cockpit/live-store.d.ts +24 -1
  59. package/dist/tui/cockpit/live-store.js +99 -8
  60. package/dist/tui/cockpit/log-view.d.ts +28 -0
  61. package/dist/tui/cockpit/log-view.js +60 -0
  62. package/dist/tui/cockpit/run-view.js +4 -0
  63. package/dist/tui/cockpit/shell.d.ts +2 -1
  64. package/dist/tui/cockpit/shell.js +6 -4
  65. package/dist/tui/ink/fleet-app.d.ts +37 -10
  66. package/dist/tui/ink/fleet-app.js +65 -21
  67. package/package.json +1 -1
  68. package/skills/tickmarkr-auto/SKILL.md +32 -2
  69. package/skills/tickmarkr-loop/SKILL.md +46 -2
  70. package/skills/tickmarkr-overseer/SKILL.md +83 -8
  71. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +58 -16
  72. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +19 -6
  73. package/skills/tickmarkr-overseer/scripts/watch-parks.sh +121 -64
@@ -4,7 +4,7 @@ import { COMMAND_LEASE_TOKEN_ENV, commandLeaseEnvironment, CommandLeases, curren
4
4
  import { execFileSync, spawn } from "node:child_process";
5
5
  import { createHash, randomBytes } from "node:crypto";
6
6
  import { shq } from "../adapters/types.js";
7
- import { appendFileSync, closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, readSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
7
+ import { appendFileSync, closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, readSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
8
8
  import { createRequire } from "node:module";
9
9
  import { tmpdir } from "node:os";
10
10
  import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
@@ -23,6 +23,7 @@ import { formatOwnedName } from "../drivers/types.js";
23
23
  import { captureBaseline, detectGateCommands, detectVacuousOracles } from "../gates/baseline.js";
24
24
  import { reobserveTestFiles, runGates } from "../gates/run-gates.js";
25
25
  import { isInfraResult } from "../gates/cache.js";
26
+ import { RUNNER_INFRA_DIAGNOSTIC_RE, timeoutShaped } from "../gates/timeout-shaped.js";
26
27
  import { filesGlob } from "../graph/files-glob.js";
27
28
  import { addEvidence, attributeBlocked, batteryPriority, blockedTasks, getTask, graphDefinitionHash, loadGraph, pendingTasks, readyTasks, saveGraph, setStatus, taskContentDigest, tickmarkrDir } from "../graph/graph.js";
28
29
  import { GATE_NAMES } from "../graph/schema.js";
@@ -32,11 +33,12 @@ import { executionSignal, remainingExecutionMs, withExecutionBudget, withoutExec
32
33
  import { repairSelectionDecision, repairSelectionEnabled } from "./repair-selection.js";
33
34
  import { failureDisposition, reserveInfrastructureRetry } from "./recovery.js";
34
35
  import { runEnvironment } from "./environment.js";
35
- import { changeRepresented, cleanupRunWorktrees, deriveForkCap, FORK_CAP_ENV, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, PRESERVE_COMMIT_SUBJECT, PRESERVE_PRODUCER_TRAILER, preserveWorktree, producerFields, resolvedCapacity, runWithForkBudget, runWithVerificationBudget, sameCapacity, sameVerification, sh, shGit, SUITE_PARENT_ENV, verificationProtocol, WORKTREE_LAYOUT_CONTRACT, worktreePath } from "./git.js";
36
+ import { changeRepresented, cleanupRunWorktrees, deriveForkCap, FORK_CAP_ENV, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, PRESERVE_COMMIT_SUBJECT, PRESERVE_PRODUCER_TRAILER, preserveWorktree, producerFields, removeWorktree, resolvedCapacity, runWithForkBudget, runWithVerificationBudget, sameCapacity, sameVerification, sh, shGit, SUITE_PARENT_ENV, verificationProtocol, WORKTREE_LAYOUT_CONTRACT, WORKTREES_DIR, worktreePath } from "./git.js";
37
+ import { MASK } from "./redact.js";
36
38
  import { runInteractiveSeed } from "./interactive-seed.js";
37
39
  import { classifyRepairDisposition, resolveScopeHints } from "./repair-disposition.js";
38
40
  import { applyScopeAmendments, activeRetryBan, interruptedAttempt, RESUME_HARVEST_SOURCE, resumeHarvestAuthor, approvalAction, APPROVAL_REFUSED, bindingToken, effectiveEvents, foldDecisions, physicalLine, staleApprovals, classifyTaskFailure, classifyWorkerResultCause, deferredReviewFindings, engagementComparable, formatPriorFindingEvidence, GATE_FINGERPRINT_CAP, GATE_SATISFIED_RELEASE, identicalGateFailures, isDeferredFinding, journaledFailureBrief, Journal, loadRoutingProfile, newRunId, normalizeGateFailure, outstandingConsultGuidance, readableExcerpt, receiptOrigin, outstandingReviewFindings, pendingApprovalActions, pendingRechecks, pendingRepairFindings, phaseForGate, priorJudgments, readPriorRunEvidence, recordedTaskFailureKind, reraisedReviewChains, RECHECK_RELEASE, renderStructuredReviewFinding, repairReachSinceApproval, repairsSinceApproval, reviewRoundsSinceApproval, runHasEnded, standingRulings, structuredFindings, upheldFeedbackByTask } from "./journal.js";
39
- import { gateReviewerFloor, isDiffCapPark, isGarbageReview, pickReviewer } from "../gates/review.js";
41
+ import { carriedAuthorVendors, gateReviewerFloor, isDiffCapPark, isGarbageReview, pickReviewer } from "../gates/review.js";
40
42
  import { acquireApprovalSerialization, acquireRunLock, isPidLive, releaseRunLock } from "./lock.js";
41
43
  import { ensureIntegration, integrationBranch, integrationHead, mergeTask, reusedTipEvidence, verifyIntegrationTip } from "./merge.js";
42
44
  import { channelExclusion, climbChannel, inTaskPool, marginalCostRank, nextChannel, poolExhaustion, route, taskPool } from "../route/router.js";
@@ -374,9 +376,49 @@ function classifyInfraResult(g) {
374
376
  // diagnostics (never-started files, a worker RPC timeout) that make such a red infrastructure-SHAPED.
375
377
  // Shape alone never parks anything: it only buys one isolated re-observation before a charge. A
376
378
  // fresh-fingerprint section masks digits to `#`, so both spellings of a number are read.
377
- const TIMEOUT_SHAPED_RE = /\b(?:Test|Hook) timed out in (?:\d+|#)\s*ms\b|\bexpected (?:\d+|#)(?:\.(?:\d+|#))? to be (?:less than|below)(?: or equal to)? (?:\d+|#)/i;
378
- const RUNNER_INFRA_DIAGNOSTIC_RE = /\bnever-started (?:[1-9]\d*|#)(?![\d#])|\[vitest-worker\]: Timeout calling\b/;
379
- const timeoutShaped = (details) => TIMEOUT_SHAPED_RE.test(readableExcerpt(details));
379
+ /** C (E1): the one no-verdict that is the host's slowness, not the seat's — the same predicate run-gates
380
+ * applies to its run-scoped tally, read here for the live task strike and the resume reconstruction. */
381
+ const proofTimeoutNote = (data) => data.cause === "seat-launch-failed" && data.launchCause === "checkout-proof-timeout";
382
+ /** C: the gate-fresh-forced reason that bounds the out-of-scope re-execution, one per journaled subject. */
383
+ const OUTSIDE_SCOPE_RED = "no-commit-out-of-scope-red";
384
+ const NAMED_FAILURE_FILE_RE = /^\s*(\S+\.(?:test|spec)\.[cm]?[jt]sx?)\s+>/gm;
385
+ /** D-854: the runner certificate the vitest manifest path appends to a red (test-manifest.ts). */
386
+ const RUNNER_CERTIFICATE_RE = /runner-level diagnostic: never-started ([^;\s]+); reporter errors ([^;\s]+)/g;
387
+ /** C: a journaled test red whose failing-file attribution is nonempty, trustworthy and complete (repair
388
+ * selection's own rule: a behavioral red on a real subject from a full suite or a screen that names
389
+ * every failure), POSITIVELY certified complete by its runner, uncontradicted by the files its own text
390
+ * names, and wholly outside a declared files[]. Empty files[] is unrestricted and never qualifies;
391
+ * every other shape keeps its replay. */
392
+ function outsideScopeRed(t, taskId, data, commit) {
393
+ if (!t.files.length || data.gate !== "test" || data.pass !== false || data.skipped === true || data.commit !== commit
394
+ // a failed producer — an infra row, a dirty-tree refusal, a recovery the runner could not finish —
395
+ // never attributes anything, whatever files it lists
396
+ || data.infra === true || data.dirtyWorktree === true || data.recoveryBlocked !== undefined)
397
+ return false;
398
+ const attribution = repairSelectionDecision([{ event: "gate-result", taskId, data }], taskId, true);
399
+ if (attribution.reason !== "known-failing-files" || attribution.requiredFiles.length === 0)
400
+ return false;
401
+ const text = readableExcerpt(String(data.details ?? ""));
402
+ // D-854: validate, never filter. A collection failure has no module record, so failingFiles omits it
403
+ // and it lives only in the certificate's counts; any never-started file or reporter error, an unknown
404
+ // count, or no certificate at all leaves the attribution unproven.
405
+ // ponytail: a nonzero count never qualifies, even when each diagnostic names an outside file — the
406
+ // details interleave diagnostics with the stdout tail; map them only if outside-scope collection reds need it.
407
+ const certificates = [...text.matchAll(RUNNER_CERTIFICATE_RE)];
408
+ if (!certificates.length || certificates.some((m) => m[1] !== "0" || m[2] !== "0"))
409
+ return false;
410
+ const named = [...text.matchAll(ATTRIBUTED_FILE_RE), ...text.matchAll(NAMED_FAILURE_FILE_RE)].map((m) => m[1].replace(/^\.\//, ""));
411
+ if (named.some((file) => !attribution.requiredFiles.includes(file)))
412
+ return false; // contradictory
413
+ const inScope = filesGlob(t.files);
414
+ return attribution.requiredFiles.every((file) => !inScope(file));
415
+ }
416
+ /** C: every test red a round journaled on `commit` is an out-of-scope red (none means none). */
417
+ const allOutsideScopeReds = (t, rows, commit) => {
418
+ const reds = rows.filter((e) => e.event === "gate-result" && e.data.gate === "test" && e.data.pass === false
419
+ && e.data.skipped !== true && e.data.commit === commit);
420
+ return reds.length > 0 && reds.every((e) => outsideScopeRed(t, e.taskId ?? t.id, e.data, commit));
421
+ };
380
422
  const ATTRIBUTED_FILE_RE = /^\s*(?:FAIL|×|✗)\s+(?:\|[^|\n]*\|\s+)?(\S+\.(?:test|spec)\.[cm]?[jt]sx?)\b/gm;
381
423
  /** The failing files a red attributes to itself: the runner's own list, else its FAIL headlines. */
382
424
  const attributedFailingFiles = (g) => [...new Set(Array.isArray(g.meta?.failingFiles) && g.meta.failingFiles.length
@@ -2203,10 +2245,13 @@ export async function runDaemon(repoRoot, opts = {}) {
2203
2245
  // is what bounds a seat that flakes once per task (or once per session) across the run. Seeded from the
2204
2246
  // journal's review-no-verdict rows — the same notes noteReviewEvent appends — so a resumed daemon keeps
2205
2247
  // the count; run-gates appends to it in place, exactly as it does demotedReviewers.
2248
+ // C (E1): a checkout-proof timeout never struck the live tally (run-gates), so a resume never seeds one.
2206
2249
  const reviewNoVerdicts = new Map();
2207
2250
  for (const event of journal.read()) {
2208
2251
  if (event.event !== "review-no-verdict" || event.data.noVerdict !== true || typeof event.data.reviewer !== "string")
2209
2252
  continue;
2253
+ if (proofTimeoutNote(event.data))
2254
+ continue;
2210
2255
  reviewNoVerdicts.set(event.data.reviewer, [...(reviewNoVerdicts.get(event.data.reviewer) ?? []), String(event.data.cause)]);
2211
2256
  }
2212
2257
  const reviewHistory = journal.read()
@@ -2644,7 +2689,29 @@ export async function runDaemon(repoRoot, opts = {}) {
2644
2689
  to: loadedHash,
2645
2690
  });
2646
2691
  }
2647
- baseline = JSON.parse(readFileSync(join(journal.dir, "baseline.json"), "utf8"));
2692
+ // B (D-812): a daemon interrupted inside its capture leaves run-start without baseline.json. Only
2693
+ // that absence recaptures; existing bytes that do not parse fail closed and are never written over.
2694
+ const baselinePath = join(journal.dir, "baseline.json");
2695
+ let recapture = false;
2696
+ try {
2697
+ baseline = JSON.parse(readFileSync(baselinePath, "utf8"));
2698
+ }
2699
+ catch (error) {
2700
+ if (error.code !== "ENOENT") {
2701
+ throw new Error(`refusing to resume ${runId}: existing baseline.json is unreadable (${error instanceof Error ? error.message : String(error)}) — it is never recaptured over`);
2702
+ }
2703
+ // The recapture measures run-start's commands; current config is no authority for that base.
2704
+ // Only unmasked bytes prove equality: redaction maps a changed credential onto the same mask, so a
2705
+ // journaled command carrying the mask cannot be proven unchanged and is refused like a change.
2706
+ const recorded = (typeof start.data.commands === "object" && start.data.commands !== null ? start.data.commands : {});
2707
+ const effective = commands;
2708
+ const changed = [...new Set([...Object.keys(recorded), ...Object.keys(effective)])]
2709
+ .filter((name) => recorded[name] !== effective[name] || String(recorded[name]).includes(MASK)).sort();
2710
+ if (changed.length > 0) {
2711
+ throw new Error(`refusing to resume ${runId}: baseline recapture refused — effective commands differ from (or are masked in) run-start's journaled commands: ${changed.join(", ")}`);
2712
+ }
2713
+ recapture = true;
2714
+ }
2648
2715
  const replayEvents = journal.read();
2649
2716
  // journal.replayStatuses predates typed releases and re-pends even inert rows. Keep that
2650
2717
  // legacy reader intact; scheduling accepts only the fold's recognised authorities.
@@ -2693,8 +2760,47 @@ export async function runDaemon(repoRoot, opts = {}) {
2693
2760
  });
2694
2761
  runStarted = true;
2695
2762
  await placeBoard();
2696
- // A terminal resume with no commands has no execution to admit.
2697
- if (Object.keys(commands).length > 0 || graph.tasks.some(t => ["pending", "running", "gated"].includes(t.status))) {
2763
+ if (recapture) {
2764
+ // Moved HEAD is never baseline authority: measure the journaled base in a detached checkout this
2765
+ // daemon owns, and publish by rename so any interruption leaves no baseline.json or a whole one.
2766
+ journal.append("baseline-recapture-start", undefined, { baseRef, commands, capacity: resolvedCapacity() });
2767
+ baselinePending = true;
2768
+ baselineCapture = withCommandContext(undefined, async () => {
2769
+ const parent = join(tickmarkrDir(repoRoot), WORKTREES_DIR);
2770
+ mkdirSync(parent, { recursive: true });
2771
+ // A killed daemon never reaches the finally below; sweep checkouts whose owner is dead.
2772
+ for (const entry of readdirSync(parent)) {
2773
+ const owner = /^baseline-recapture-(\d+)-\w{6}$/.exec(entry)?.[1];
2774
+ if (owner && !isPidLive(Number(owner)))
2775
+ await removeWorktree(repoRoot, join(parent, entry));
2776
+ }
2777
+ const pristine = mkdtempSync(join(parent, `baseline-recapture-${process.pid}-`));
2778
+ const staged = `${baselinePath}.${process.pid}.tmp`;
2779
+ try {
2780
+ const added = await shGit(`git worktree add --detach ${shq(pristine)} ${shq(baseRef)}`, repoRoot);
2781
+ if (added.code !== 0)
2782
+ throw new Error(`baseline recapture: journaled base ${baseRef} cannot be checked out: ${added.stderr.trim()}`);
2783
+ if (!linkNodeModules(repoRoot, pristine, { force: true }))
2784
+ throw new Error("baseline recapture: pristine node_modules link could not be provisioned");
2785
+ await initializeHost();
2786
+ const captured = { ...await captureBaseline(pristine, commands),
2787
+ provenance: { baseRef, capturedAt: new Date().toISOString() } };
2788
+ writeFileSync(staged, JSON.stringify(captured, null, 2));
2789
+ renameSync(staged, baselinePath);
2790
+ baseline = captured;
2791
+ journal.append("baseline-recapture-complete", undefined, { provenance: captured.provenance });
2792
+ for (const warning of captured.warnings ?? [])
2793
+ journal.append("baseline-warning", undefined, { ...warning });
2794
+ }
2795
+ finally {
2796
+ rmSync(staged, { force: true });
2797
+ await removeWorktree(repoRoot, pristine);
2798
+ }
2799
+ }).finally(() => { baselinePending = false; });
2800
+ void baselineCapture.catch(() => { baselineFailed = true; });
2801
+ }
2802
+ else if (Object.keys(commands).length > 0 || graph.tasks.some(t => ["pending", "running", "gated"].includes(t.status))) {
2803
+ // A terminal resume with no commands has no execution to admit.
2698
2804
  baselineCapture = initializeHost();
2699
2805
  void baselineCapture.catch(() => { baselineFailed = true; });
2700
2806
  }
@@ -2930,6 +3036,18 @@ export async function runDaemon(repoRoot, opts = {}) {
2930
3036
  return red !== undefined
2931
3037
  && identicalGateFailures(journal.read(), t.id, "test", normalizeGateFailure(String(red.data.details))) < GATE_FINGERPRINT_CAP;
2932
3038
  };
3039
+ // C: an unchanged cached test red attributed wholly outside files[] is re-executed ONCE per journaled
3040
+ // subject — the worker cannot repair those files, so a copy of the red would only buy another round.
3041
+ const owesOutsideScopeRefresh = (t, priorRows, commit) => allOutsideScopeReds(t, priorRows, commit)
3042
+ && !journal.read().some((e) => e.taskId === t.id && e.event === "gate-fresh-forced"
3043
+ && e.data.reason === OUTSIDE_SCOPE_RED && e.data.commit === commit);
3044
+ // C: the forced re-execution's own round failed again only outside files[]: independent evidence the
3045
+ // worker cannot act on. It keeps its red row and parks gate-fail before any cap, consult or repair move.
3046
+ const freshOutsideScopeRed = (t, commit) => {
3047
+ const own = journal.read().filter((e) => e.taskId === t.id);
3048
+ return allOutsideScopeReds(t, own.slice(own.map((e) => e.event === "phase-start" && e.data.phase === "gates").lastIndexOf(true) + 1), commit);
3049
+ };
3050
+ const outsideScopePark = (t) => gateFailApprovalReason(t.id, "test: a fresh re-execution of the unchanged subject failed again only in files outside files[] — no repair can reach them");
2933
3051
  // OBS-1106 residual: before a timeout or wall-budget red accompanied by runner infra diagnostics is
2934
3052
  // charged, its attributed failing files are re-run ONCE, on the same checkout, under the suite
2935
3053
  // lease. Only a reproduced assertion is charged. A pass, an infra rerun, a rerun naming none of the
@@ -3297,11 +3415,15 @@ export async function runDaemon(repoRoot, opts = {}) {
3297
3415
  }
3298
3416
  // Keep one live list: recovery retries must see exclusions added by onGate during the round.
3299
3417
  const badReviewers = [...replayedReviewerExclusions];
3418
+ // C (E1): a checkout-proof timeout is the host being slow, not the seat failing — it is excluded for
3419
+ // the CURRENT round only (so an always-timeout pool still ends the round), never struck task-wide.
3420
+ let roundTimeouts = [];
3300
3421
  const noteReviewEvent = (e) => {
3301
3422
  journal.append(e.name, t.id, e.payload);
3302
3423
  if (e.name === "review-no-verdict" && typeof e.payload.reviewer === "string") {
3303
- if (!badReviewers.includes(e.payload.reviewer))
3304
- badReviewers.push(e.payload.reviewer);
3424
+ const strikes = proofTimeoutNote(e.payload) ? roundTimeouts : badReviewers;
3425
+ if (!strikes.includes(e.payload.reviewer))
3426
+ strikes.push(e.payload.reviewer);
3305
3427
  }
3306
3428
  // OBS-1196: a seat whose same-subject re-emission delivered a valid verdict was a delivery flake,
3307
3429
  // not a bad reviewer — it stays seatable for this task's later rounds.
@@ -3337,17 +3459,31 @@ export async function runDaemon(repoRoot, opts = {}) {
3337
3459
  gateRound: journal.read().filter((row) => row.taskId === task.id
3338
3460
  && row.event === "phase-start" && row.data.phase === "gates").length,
3339
3461
  };
3340
- const round = await runGates(task, ctx);
3462
+ roundTimeouts = [];
3463
+ // C: every gate-round boundary — a returned or a THROWN round — publishes the one row held behind
3464
+ // a still-pending sibling and forgets the pending set, before any park or task-failed row lands.
3465
+ const gateRound = async (subject) => {
3466
+ try {
3467
+ return await runGates(subject, { ...ctx, excludeReviewers: [...badReviewers, ...roundTimeouts] });
3468
+ }
3469
+ finally {
3470
+ settleParallelRound();
3471
+ }
3472
+ };
3473
+ const round = await gateRound(task);
3341
3474
  let review = round.results.find((g) => g.gate === "review");
3475
+ // The recovery pick carries every preserved author, exactly as reviewGate's own pick does.
3476
+ const authors = ctx.carriedAuthors?.length ? [...ctx.carriedAuthors] : [channelKey(ctx.author)];
3342
3477
  while (review?.meta?.noVerdict === true) {
3343
3478
  // Recovery must honor the gate's floor, including the seats that just failed to
3344
- // return a verdict; a below-floor alternative cannot replace the infra result.
3345
- const { floor } = gateReviewerFloor(task, ctx.cfg, ctx.author, ctx.channels, [...(ctx.priorReviewers ?? []), ...badReviewers]);
3346
- const next = pickReviewer(ctx.author, ctx.channels, badReviewers, cfg.review.prefer ?? [], floor, reviewHistory, undefined, demotedReviewers);
3479
+ // return a verdict; a below-floor alternative cannot replace the infra result. This round's
3480
+ // proof timeouts count too — runGates folds every excluded seat into its own floor.
3481
+ const { floor } = gateReviewerFloor(task, ctx.cfg, ctx.author, ctx.channels, [...(ctx.priorReviewers ?? []), ...badReviewers, ...roundTimeouts]);
3482
+ const next = pickReviewer(ctx.author, ctx.channels, [...badReviewers, ...roundTimeouts], cfg.review.prefer ?? [], floor, reviewHistory, undefined, demotedReviewers, carriedAuthorVendors(ctx.channels, authors), authors);
3347
3483
  if (!next)
3348
3484
  break;
3349
3485
  journal.append("review-infra-retry", t.id, { reviewer: channelKey(next), cause: review.meta.cause });
3350
- const retried = await runGates({ ...task, gates: ["review"] }, ctx);
3486
+ const retried = await gateRound({ ...task, gates: ["review"] });
3351
3487
  const replacement = retried.results.find((g) => g.gate === "review");
3352
3488
  // A dirty tree or another pre-dispatch refusal must still fail closed.
3353
3489
  if (!replacement) {
@@ -3374,13 +3510,16 @@ export async function runDaemon(repoRoot, opts = {}) {
3374
3510
  gate: "review", flaked: rr.flaked, retried: rr.retried,
3375
3511
  ...(g.meta?.unparseable === true ? { secondUnparseable: true } : {}),
3376
3512
  });
3377
- badReviewers.push(rr.flaked);
3513
+ // C (E1): a seat re-routed after a checkout-proof timeout is excluded for this round only.
3514
+ if (!roundTimeouts.includes(rr.flaked))
3515
+ badReviewers.push(rr.flaked);
3378
3516
  }
3379
3517
  };
3380
3518
  // v1.85 T3 (ruling R4): every BLOCKING review/judge result lands its findings in the journal
3381
3519
  // structured — class + canonical path + stable symbol — so a retry, a consult or an auto-uphold
3382
3520
  // decision reads identity instead of re-parsing prose, and line-number churn is not a new finding.
3383
3521
  // One helper, both onGate sites (satisfied-gate resume + main attempt loop).
3522
+ // `journaledGates` narrows replayMeasurement to the gates an interrupted round already journaled (C).
3384
3523
  let gateSubject;
3385
3524
  const journalGateResult = (g) => {
3386
3525
  const blocking = g.meta?.infra !== true && gateFailed(g) && (g.gate === "review" || g.gate === "acceptance");
@@ -3416,7 +3555,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3416
3555
  // for the worktree it ran in; absent only for a row that never went through the battery.
3417
3556
  ...(g.meta?.verification !== undefined ? { verification: g.meta.verification } : {}),
3418
3557
  ...(gateSubject ? { commit: gateSubject.commit, attempt: gateSubject.attempt } : {}),
3419
- ...(gateSubject?.replayMeasurement ? { replayMeasurement: true } : {}),
3558
+ ...(gateSubject?.replayMeasurement && (gateSubject.journaledGates?.includes(g.gate) ?? true) ? { replayMeasurement: true } : {}),
3420
3559
  ...(gateSubject?.replayedFromAttempt !== undefined ? { replayedFromAttempt: gateSubject.replayedFromAttempt } : {}),
3421
3560
  ...(g.meta?.skipped === true || noVerdictReview ? { skipped: true } : {}),
3422
3561
  // T9: an infra-only exit is journaled AS one. The operator reading a red `test` row has to
@@ -3530,10 +3669,17 @@ export async function runDaemon(repoRoot, opts = {}) {
3530
3669
  // ever held, and only while an earlier sibling is still in flight — a review that finishes first
3531
3670
  // waits for acceptance, never the reverse. That keeps T4's durability where it pays (the first
3532
3671
  // verdict to land is still published immediately) and bounds the exposure to one row for the
3533
- // remainder of one already-running gate. A gate that THROWS kills the round before merge, so a
3534
- // row held behind it is lost with the round it belonged to — not a verdict that could have merged.
3672
+ // remainder of one already-running gate. A gate that THROWS still ends its round: C publishes the
3673
+ // row held behind it at that boundary (settleParallelRound), so it is never lost or carried over.
3535
3674
  const parallelPending = new Set();
3536
3675
  let heldParallel;
3676
+ const settleParallelRound = () => {
3677
+ parallelPending.clear();
3678
+ const held = heldParallel;
3679
+ heldParallel = undefined;
3680
+ if (liveEngagement())
3681
+ held?.();
3682
+ };
3537
3683
  const notePhaseStart = (e) => {
3538
3684
  const active = activeGatePhases.get(t.id) ?? new Set();
3539
3685
  active.add(e.gate);
@@ -3623,6 +3769,81 @@ export async function runDaemon(repoRoot, opts = {}) {
3623
3769
  let testGateFailed = journal.read().some((e) => e.event === "gate-result" && e.taskId === t.id && e.data.gate === "test" && e.data.pass === false
3624
3770
  && !(cfg.executionPolicy?.boundedInfrastructure && e.data.disposition === "infrastructure"));
3625
3771
  let retryMode = "fresh";
3772
+ // v2.6.6 A: the ONE post-result settlement both gate loops call. Order is the contract:
3773
+ // satisfied → merge (tip-moved once regates, twice parks; a conflict goes back to the caller), else
3774
+ // noEligible → infra → diff-cap → adjudication → outside-scope → disposition, and whatever survives
3775
+ // is the caller's "red". Every declared resume/live difference arrives as a parameter; nothing here
3776
+ // normalizes one. Preparation, replay, conflict handling, repair funding, feedback and cleanup stay
3777
+ // in the callers.
3778
+ const settleResults = async (results, p) => {
3779
+ const parkAs = (reason, kind) => park(t, reason, kind, p.author, p.attempts, startMs, gateFails, consults, tokens, metered, retryMode);
3780
+ if (results.every(gateSatisfied)) {
3781
+ p.beforeMerge?.();
3782
+ const m = await mergeSerial(p.taskBranch, t, p.gated);
3783
+ if (m.tipMoved) {
3784
+ journal.append("tip-moved", t.id, m.tipMoved);
3785
+ if (tipMoves++ === 0)
3786
+ return { kind: "regate" };
3787
+ await parkAs("task branch tip moved twice after gating", "tip-moved");
3788
+ return { kind: "parked" };
3789
+ }
3790
+ if (!m.ok) {
3791
+ journal.append("merge-conflict", t.id, { conflict: m.conflict });
3792
+ return { kind: "conflict", conflict: m.conflict };
3793
+ }
3794
+ graph = setStatus(graph, t.id, "done");
3795
+ saveGraph(repoRoot, graph);
3796
+ journal.append("task-done", t.id, {
3797
+ attempts: p.attempts, assignment: p.author, taskContentDigest: contentDigest,
3798
+ authors: mergedAuthors(await subjectAuthors(journal.read(), t.id, p.wt, p.taskBase)),
3799
+ });
3800
+ journal.append("merge", t.id, { branch: p.taskBranch, commit: await integrationHead(intWt) });
3801
+ await trackedDriver.project?.(t.id, "completed");
3802
+ // firstAttemptOk/gateFails/consults are recorded FACTS, not policy — a parkKind:"stall" row is
3803
+ // recorded but NOT quality-negative in v1.6; Phase 12 owns reward policy, so flipping it later needs zero data migration.
3804
+ journal.telemetry({
3805
+ taskId: t.id, shape: t.shape, adapter: p.author.adapter, model: p.author.model,
3806
+ channel: p.author.channel, attempts: p.attempts, outcome: "done",
3807
+ durationMs: Date.now() - startMs, firstAttemptOk: p.firstAttemptOk, gateFails, consults, ...p.metering, retryMode,
3808
+ });
3809
+ return { kind: "merged" };
3810
+ }
3811
+ // OBS-540: infra is a fail-closed NON-verdict, not quality degradation. Park with the blocker
3812
+ // already journaled by onGate, before gateFails and before any ladder selection can fund an
3813
+ // identical retry in the same environment. Parsed judge refusals never carry infra and keep
3814
+ // flowing through the chargeable quality path below.
3815
+ const unavailableReview = results.find((g) => gateFailed(g) && g.meta?.noEligibleReviewer === true);
3816
+ if (unavailableReview) {
3817
+ await parkAs(gateFailApprovalReason(t.id, unavailableReview.details, true), "gate-fail");
3818
+ return { kind: "parked" };
3819
+ }
3820
+ // OBS-1106: same predicate as classification — an infra replay is parked, never repaired.
3821
+ const infra = results.find((g) => gateFailed(g) && isInfraResult(g));
3822
+ if (infra) {
3823
+ await parkAs(p.infraReason(infra), "infra");
3824
+ return { kind: "parked" };
3825
+ }
3826
+ // OBS-1007: a cap trip is a typed park of its own — the diff cannot shrink by retrying and the
3827
+ // only honest verb is a recheck under a raised cap. Decided BEFORE the authoring classifier, which
3828
+ // used to lift `gates.diffCap` out of the cap prose as a files[] hint.
3829
+ const capTrip = results.find(isDiffCapPark);
3830
+ if (capTrip) {
3831
+ await parkAs(`${capTrip.gate}: ${capTrip.details}`, "diff-cap");
3832
+ return { kind: "parked" };
3833
+ }
3834
+ if (await adjudicateInfraShapedRed(t, results, p.wt, p.commit, p.reobserve.attempt, p.reobserve.replayed, (reason) => parkAs(reason, "infra")))
3835
+ return { kind: "parked" };
3836
+ if (p.outsideScopeForced && freshOutsideScopeRed(t, p.commit)) {
3837
+ await parkAs(outsideScopePark(t), "gate-fail");
3838
+ return { kind: "parked" };
3839
+ }
3840
+ // OBS-547: who pays for this red is decided by the run's collateral prediction (see
3841
+ // dispositionScopeRed) — an authoring defect parks unchargeable before any accounting below.
3842
+ const d = p.disposition();
3843
+ if (await dispositionScopeRed(t, results, d.author, d.attempt, startMs, gateFails, consults, tokens, metered, retryMode, await treeOrDiffPaths(p.taskBase, p.wt)))
3844
+ return { kind: "parked" };
3845
+ return { kind: "red" };
3846
+ };
3626
3847
  let lastContextTokens; // v1.23 reset signal, including stalled/quota attempts
3627
3848
  // v1.29: only a gate-failed attempt can seed same-session retry. The next attempt consumes this
3628
3849
  // once; a changed channel, unknown context, or missing resumeCommand falls back to fresh.
@@ -4096,6 +4317,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4096
4317
  journal.append("gate-provisioned", t.id, provisionedRow);
4097
4318
  const resumedTask = { ...t, gates: remainingGates };
4098
4319
  const operatorContext = approvalReviewContext(journal.read(), t.id);
4320
+ let outsideScopeForced = false;
4099
4321
  gateLoop: while (true) {
4100
4322
  fatalStop.signal.throwIfAborted();
4101
4323
  executionSignal()?.throwIfAborted();
@@ -4124,13 +4346,42 @@ export async function runDaemon(repoRoot, opts = {}) {
4124
4346
  const own = journal.read().filter((e) => e.taskId === t.id);
4125
4347
  return own.slice(own.map((e) => e.event === "phase-start" && e.data.phase === "gates").lastIndexOf(true) + 1);
4126
4348
  })();
4127
- const freshTimeout = !recheck && !satisfiedGate && ((harvestAuthor !== undefined && owesTimeoutRefresh(t, lastRound, gateSubject.commit))
4128
- || (lastRound.some((e) => e.event === "gate-fresh-forced" && e.data.commit === gateSubject.commit)
4129
- && !lastRound.some((e) => e.event === "gate-result" && e.data.gate === "test")));
4349
+ // C: a passing selected-test screen is never a forced round's conclusive test result (the
4350
+ // journal's own replay rule): its full suite is still owed, fresh, under the round's bypass.
4351
+ const measured = lastRound.filter((e) => e.event === "gate-result" && !(e.data.gate === "test"
4352
+ && Array.isArray(e.data.selectedTests) && e.data.fullSuite !== true && e.data.pass !== false));
4353
+ const forcedUnmeasured = (outside) => lastRound.some((e) => e.event === "gate-fresh-forced"
4354
+ && e.data.commit === gateSubject.commit && (e.data.reason === OUTSIDE_SCOPE_RED) === outside)
4355
+ && !measured.some((e) => e.data.gate === "test");
4356
+ // C: a crash after the forced re-execution journaled its red but before its park still owes that
4357
+ // park — restored from the journaled subject and result, never re-measured, replayed or repaired.
4358
+ // Infra adjudication stays authoritative, exactly as it is ahead of the live park.
4359
+ if (!recheck && !satisfiedGate && lastRound.some((e) => e.event === "gate-fresh-forced"
4360
+ && e.data.reason === OUTSIDE_SCOPE_RED && e.data.commit === gateSubject.commit)
4361
+ && allOutsideScopeReds(t, lastRound, gateSubject.commit)) {
4362
+ const restored = lastRound.filter((e) => e.event === "gate-result" && e.data.gate === "test" && e.data.pass === false)
4363
+ .map((e) => ({ gate: "test", pass: false, details: String(e.data.details ?? ""), meta: e.data }));
4364
+ if (await adjudicateInfraShapedRed(t, restored, wt, gateSubject.commit, rs?.attempts ?? 0, true, (reason) => park(t, reason, "infra", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode)))
4365
+ return;
4366
+ await park(t, outsideScopePark(t), "gate-fail", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
4367
+ return;
4368
+ }
4369
+ // C: the same out-of-scope re-execution the live loop owes, and a crash inside it still owes it. It
4370
+ // takes precedence over a timeout refresh: a qualifying red keeps its own bypass and park.
4371
+ outsideScopeForced = !recheck && !satisfiedGate && ((harvestAuthor !== undefined && owesOutsideScopeRefresh(t, lastRound, gateSubject.commit)) || forcedUnmeasured(true));
4372
+ const freshTimeout = !recheck && !satisfiedGate && !outsideScopeForced && ((harvestAuthor !== undefined && owesTimeoutRefresh(t, lastRound, gateSubject.commit)) || forcedUnmeasured(false));
4373
+ // C: a measurement this forced round EXECUTES fresh — its forced test above all — is independent
4374
+ // evidence, never a replay measurement; only a gate the interrupted round already journaled is.
4375
+ if (outsideScopeForced) {
4376
+ gateSubject = { ...gateSubject, journaledGates: measured.map((e) => String(e.data.gate)) };
4377
+ }
4130
4378
  journal.phaseStart(t.id, "gates");
4131
4379
  if (freshTimeout) {
4132
4380
  journal.append("gate-fresh-forced", t.id, { gate: "test", attempt: gateSubject.attempt, commit: gateSubject.commit, reason: "no-commit-timeout-red", resumed: true });
4133
4381
  }
4382
+ if (outsideScopeForced) {
4383
+ journal.append("gate-fresh-forced", t.id, { gate: "test", attempt: gateSubject.attempt, commit: gateSubject.commit, reason: OUTSIDE_SCOPE_RED, resumed: true });
4384
+ }
4134
4385
  const { results } = await withCommandContext(t.id, async () => runReviewRecovery(resumedTask, {
4135
4386
  carriedAuthors: await subjectAuthors(journal.read(), t.id, wt, taskBase),
4136
4387
  producer: producerNow(),
@@ -4155,7 +4406,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4155
4406
  // flaking seat is re-asked on every task.
4156
4407
  reviewNoVerdicts,
4157
4408
  recheck, // OBS-1055: a recheck discards cached reds — the battery re-measures what the operator questioned
4158
- cachedRedBypass: freshTimeout ? "timeout-fresh" : undefined,
4409
+ cachedRedBypass: freshTimeout ? "timeout-fresh" : outsideScopeForced ? "out-of-scope-fresh" : undefined,
4159
4410
  onGate: async (e) => {
4160
4411
  if (!liveEngagement())
4161
4412
  return;
@@ -4190,23 +4441,11 @@ export async function runDaemon(repoRoot, opts = {}) {
4190
4441
  const approvedCommits = await commitsAheadOf(taskBase, wt);
4191
4442
  graph = addEvidence(graph, t.id, { commits: approvedCommits, gateResults: results });
4192
4443
  saveGraph(repoRoot, graph);
4193
- if (!results.every(gateSatisfied)) {
4194
- const unavailableReview = results.find((g) => gateFailed(g) && g.meta?.noEligibleReviewer === true);
4195
- if (unavailableReview) {
4196
- await park(t, gateFailApprovalReason(t.id, unavailableReview.details, true), "gate-fail", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
4197
- return;
4198
- }
4199
- // OBS-1106: same predicate as classification — an infra replay is parked, never repaired.
4200
- const infra = results.find((g) => gateFailed(g) && isInfraResult(g));
4201
- if (infra) {
4202
- await park(t, `${infra.gate}: ${infra.details}`, "infra", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
4203
- return;
4204
- }
4205
- const capTrip = results.find(isDiffCapPark);
4206
- if (capTrip) {
4207
- await park(t, `${capTrip.gate}: ${capTrip.details}`, "diff-cap", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
4208
- return;
4209
- }
4444
+ const n = rs?.attempts ?? 0;
4445
+ const settled = await settleResults(results, {
4446
+ wt, taskBase, taskBranch, gated, commit: gateSubject.commit, outsideScopeForced,
4447
+ author: gateAuthor, attempts: n, infraReason: (g) => `${g.gate}: ${g.details}`,
4448
+ reobserve: { attempt: n, replayed: false }, firstAttemptOk: false, metering: {},
4210
4449
  // OBS-547: disposition FIRST, and through the same helper the ordinary attempt path uses. A
4211
4450
  // crash between a journaled scope red and its classification must not turn a predicted red
4212
4451
  // into a charged one just because a resume is what observed it.
@@ -4218,11 +4457,12 @@ export async function runDaemon(repoRoot, opts = {}) {
4218
4457
  // erase an EARLIER chargeable attempt and attribute the park to the assignment the rewind
4219
4458
  // restored. Ask the journal which state this is, and take the classified dispatch's own
4220
4459
  // assignment back with it.
4221
- if (await adjudicateInfraShapedRed(t, results, wt, gateSubject.commit, rs?.attempts ?? 0, false, (reason) => park(t, reason, "infra", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode)))
4222
- return;
4223
- const classified = journal.classifiedDispatch(t.id);
4224
- if (await dispositionScopeRed(t, results, classified?.assignment ?? gateAuthor, classified ? (rs?.attempts ?? 0) : Math.max(0, (rs?.attempts ?? 0) - 1), startMs, gateFails, consults, tokens, metered, retryMode, await treeOrDiffPaths(taskBase, wt)))
4225
- return;
4460
+ disposition: () => {
4461
+ const classified = journal.classifiedDispatch(t.id);
4462
+ return { author: classified?.assignment ?? gateAuthor, attempt: classified ? n : Math.max(0, n - 1) };
4463
+ },
4464
+ });
4465
+ if (settled.kind === "red") {
4226
4466
  gateFails++;
4227
4467
  // Observed green gates are only measurements. If the resumed suffix is red, preserve that
4228
4468
  // result in the journal and return to the ordinary attempt/consult ladder, which rebuilds
@@ -4265,32 +4505,14 @@ export async function runDaemon(repoRoot, opts = {}) {
4265
4505
  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);
4266
4506
  return;
4267
4507
  }
4268
- const m = await mergeSerial(taskBranch, t, gated);
4269
- if (m.tipMoved) {
4270
- journal.append("tip-moved", t.id, m.tipMoved);
4271
- if (tipMoves++ === 0)
4272
- continue gateLoop;
4273
- await park(t, "task branch tip moved twice after gating", "tip-moved", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
4508
+ if (settled.kind === "regate")
4509
+ continue gateLoop;
4510
+ if (settled.kind === "parked")
4274
4511
  return;
4275
- }
4276
- if (!m.ok) {
4277
- journal.append("merge-conflict", t.id, { conflict: m.conflict });
4278
- await park(t, `merge conflict after ${resumeReason}: ${m.conflict ?? "unknown conflict"}`, "merge-conflict", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
4512
+ if (settled.kind === "conflict") {
4513
+ await park(t, `merge conflict after ${resumeReason}: ${settled.conflict ?? "unknown conflict"}`, "merge-conflict", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
4279
4514
  return;
4280
4515
  }
4281
- graph = setStatus(graph, t.id, "done");
4282
- saveGraph(repoRoot, graph);
4283
- journal.append("task-done", t.id, {
4284
- attempts: rs?.attempts ?? 0, assignment: gateAuthor, taskContentDigest: contentDigest,
4285
- authors: mergedAuthors(await subjectAuthors(journal.read(), t.id, wt, taskBase)),
4286
- });
4287
- journal.append("merge", t.id, { branch: taskBranch, commit: await integrationHead(intWt) });
4288
- await trackedDriver.project?.(t.id, "completed");
4289
- journal.telemetry({
4290
- taskId: t.id, shape: t.shape, adapter: gateAuthor.adapter, model: gateAuthor.model,
4291
- channel: gateAuthor.channel, attempts: rs?.attempts ?? 0, outcome: "done",
4292
- durationMs: Date.now() - startMs, firstAttemptOk: false, gateFails, consults, retryMode,
4293
- });
4294
4516
  await reconcile({ spareLiveLlm: true });
4295
4517
  return;
4296
4518
  }
@@ -6246,6 +6468,7 @@ export async function runDaemon(repoRoot, opts = {}) {
6246
6468
  };
6247
6469
  let results = [];
6248
6470
  let commits = [];
6471
+ let outsideScopeForced = false;
6249
6472
  gateLoop: while (true) {
6250
6473
  fatalStop.signal.throwIfAborted();
6251
6474
  executionSignal()?.throwIfAborted();
@@ -6261,12 +6484,18 @@ export async function runDaemon(repoRoot, opts = {}) {
6261
6484
  && e.data.attempt === attempt - 1);
6262
6485
  const previousRows = retryMode === "repair" ? lastRoundRows : [];
6263
6486
  // OBS-1106 residual: see owesTimeoutRefresh — the replay below and the verdict cache are both refused.
6264
- const freshTimeout = !explicitlyFundedAttempt && owesTimeoutRefresh(t, lastRoundRows, gateSubject.commit);
6487
+ // C: an unchanged red wholly outside files[] is re-executed once instead of copied (journal or cache);
6488
+ // it outranks the timeout refresh, so a timeout-shaped qualifying red keeps its own bypass and park.
6489
+ outsideScopeForced = !explicitlyFundedAttempt && owesOutsideScopeRefresh(t, lastRoundRows, gateSubject.commit);
6490
+ const freshTimeout = !explicitlyFundedAttempt && !outsideScopeForced && owesTimeoutRefresh(t, lastRoundRows, gateSubject.commit);
6265
6491
  journal.phaseStart(t.id, "gates");
6266
6492
  if (freshTimeout) {
6267
6493
  journal.append("gate-fresh-forced", t.id, { gate: "test", attempt, priorAttempt: attempt - 1, commit: gateSubject.commit, reason: "no-commit-timeout-red" });
6268
6494
  }
6269
- const replay = !explicitlyFundedAttempt && !freshTimeout && previousRows.length > 0
6495
+ if (outsideScopeForced) {
6496
+ journal.append("gate-fresh-forced", t.id, { gate: "test", attempt, priorAttempt: attempt - 1, commit: gateSubject.commit, reason: OUTSIDE_SCOPE_RED });
6497
+ }
6498
+ const replay = !explicitlyFundedAttempt && !freshTimeout && !outsideScopeForced && previousRows.length > 0
6270
6499
  && previousRows.every((e) => e.data.commit === gateSubject.commit)
6271
6500
  && previousRows.some((e) => e.data.pass === false && e.data.skipped !== true);
6272
6501
  if (replay) {
@@ -6299,7 +6528,7 @@ export async function runDaemon(repoRoot, opts = {}) {
6299
6528
  commands, baseline, channels: pools.review, judgeChannels: pools.judge, health, adapters, cfg, artifactDir: journal.dir,
6300
6529
  collateral: collateral.get(t.id) ?? [],
6301
6530
  selectTests: !testGateFailed,
6302
- cachedRedBypass: explicitlyFundedAttempt ? "operator-rerun" : freshTimeout ? "timeout-fresh" : undefined,
6531
+ cachedRedBypass: explicitlyFundedAttempt ? "operator-rerun" : freshTimeout ? "timeout-fresh" : outsideScopeForced ? "out-of-scope-fresh" : undefined,
6303
6532
  via: cfg.visibility.llm === "pane"
6304
6533
  ? {
6305
6534
  driver: trackedDriver,
@@ -6328,7 +6557,14 @@ export async function runDaemon(repoRoot, opts = {}) {
6328
6557
  if (results.some((g) => g.gate === "test" && !g.pass
6329
6558
  && !(cfg.executionPolicy?.boundedInfrastructure && failureDisposition(g) === "infrastructure")))
6330
6559
  testGateFailed = true;
6331
- if (results.every(gateSatisfied)) {
6560
+ const live = assignment;
6561
+ const settled = await settleResults(results, {
6562
+ wt, taskBase, taskBranch, gated, commit: gateSubject.commit, outsideScopeForced,
6563
+ author: live, attempts: attempt + 1,
6564
+ infraReason: (g) => `${g.gate}: ${g.details}${g.meta?.recoveryBlocked ? ` — ${g.meta.recoveryBlocked}` : ""}`,
6565
+ reobserve: { attempt, replayed: gateSubject.replayedFromAttempt !== undefined },
6566
+ disposition: () => ({ author: live, attempt }),
6567
+ firstAttemptOk: attempt === 0, metering: { tokens, meteredAttempts: tokens ? metered : undefined },
6332
6568
  // T6: every gate — the review included — is satisfied on this commit, so the failure brief
6333
6569
  // this loop is still holding describes nothing outstanding. It is dropped HERE, before the
6334
6570
  // merge, because a conflict below sends the task around the attempt loop again: a brief kept
@@ -6337,35 +6573,23 @@ export async function runDaemon(repoRoot, opts = {}) {
6337
6573
  // leaving that dispatch's row indistinguishable from an amnesiac one. Rebuilt exactly as the
6338
6574
  // gate-fail brief is, so prior-RUN evidence (retired by its own rule, not by this reviewer)
6339
6575
  // survives and only this run's settled findings go.
6340
- feedback = withCarriedEvidence("");
6341
- fatalStop.signal.throwIfAborted();
6342
- executionSignal()?.throwIfAborted();
6343
- const m = await mergeSerial(taskBranch, t, gated);
6344
- if (m.tipMoved) {
6345
- journal.append("tip-moved", t.id, m.tipMoved);
6346
- if (tipMoves++ === 0)
6347
- continue gateLoop;
6348
- await park(t, "task branch tip moved twice after gating", "tip-moved", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode);
6349
- return;
6350
- }
6351
- if (!m.ok) {
6352
- journal.append("merge-conflict", t.id, { conflict: m.conflict });
6353
- const v = await runConsult("merge-conflict", output, m.conflict ?? "", results);
6354
- if (await applyVerdict(v, attempt + 1, "merge-conflict"))
6355
- continue attempts;
6356
- return;
6357
- }
6358
- graph = setStatus(graph, t.id, "done");
6359
- saveGraph(repoRoot, graph);
6360
- journal.append("task-done", t.id, {
6361
- attempts: attempt + 1, assignment, taskContentDigest: contentDigest,
6362
- authors: mergedAuthors(await subjectAuthors(journal.read(), t.id, wt, taskBase)),
6363
- });
6364
- journal.append("merge", t.id, { branch: taskBranch, commit: await integrationHead(intWt) });
6365
- await trackedDriver.project?.(t.id, "completed");
6366
- // firstAttemptOk/gateFails/consults are recorded FACTS, not policy — a parkKind:"stall" row is
6367
- // recorded but NOT quality-negative in v1.6; Phase 12 owns reward policy, so flipping it later needs zero data migration.
6368
- 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 });
6576
+ beforeMerge: () => {
6577
+ feedback = withCarriedEvidence("");
6578
+ fatalStop.signal.throwIfAborted();
6579
+ executionSignal()?.throwIfAborted();
6580
+ },
6581
+ });
6582
+ if (settled.kind === "regate")
6583
+ continue gateLoop;
6584
+ if (settled.kind === "parked")
6585
+ return;
6586
+ if (settled.kind === "conflict") {
6587
+ const v = await runConsult("merge-conflict", output, settled.conflict ?? "", results);
6588
+ if (await applyVerdict(v, attempt + 1, "merge-conflict"))
6589
+ continue attempts;
6590
+ return;
6591
+ }
6592
+ if (settled.kind === "merged") {
6369
6593
  // D-07 done means gone (merged-P42-01-worker incident): a merged task's worker pane closes on
6370
6594
  // the task-done path, not at run end. Only THIS successful attempt's `slot` is in scope — prior
6371
6595
  // failed attempts' slots stay in keptSlots governed by keepPanes (they hold failure context the
@@ -6384,34 +6608,6 @@ export async function runDaemon(repoRoot, opts = {}) {
6384
6608
  }
6385
6609
  break gateLoop;
6386
6610
  }
6387
- // OBS-540: infra is a fail-closed NON-verdict, not quality degradation. Park with the blocker
6388
- // already journaled by onGate, before gateFails and before any ladder selection can fund an
6389
- // identical retry in the same environment. Parsed judge refusals never carry infra and keep
6390
- // flowing through the chargeable quality path below.
6391
- const unavailableReview = results.find((g) => gateFailed(g) && g.meta?.noEligibleReviewer === true);
6392
- if (unavailableReview) {
6393
- await park(t, gateFailApprovalReason(t.id, unavailableReview.details, true), "gate-fail", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode);
6394
- return;
6395
- }
6396
- const infraFailure = results.find((g) => gateFailed(g) && isInfraResult(g));
6397
- if (infraFailure) {
6398
- await park(t, `${infraFailure.gate}: ${infraFailure.details}${infraFailure.meta?.recoveryBlocked ? ` — ${infraFailure.meta.recoveryBlocked}` : ""}`, "infra", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode);
6399
- return;
6400
- }
6401
- // OBS-1007: a cap trip is a typed park of its own — the diff cannot shrink by retrying and the
6402
- // only honest verb is a recheck under a raised cap. Decided BEFORE the authoring classifier, which
6403
- // used to lift `gates.diffCap` out of the cap prose as a files[] hint.
6404
- const capTrip = results.find(isDiffCapPark);
6405
- if (capTrip) {
6406
- await park(t, `${capTrip.gate}: ${capTrip.details}`, "diff-cap", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode);
6407
- return;
6408
- }
6409
- if (await adjudicateInfraShapedRed(t, results, wt, gateSubject.commit, attempt, gateSubject.replayedFromAttempt !== undefined, (reason) => park(t, reason, "infra", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode)))
6410
- return;
6411
- // OBS-547: who pays for this red is decided by the run's collateral prediction (see
6412
- // dispositionScopeRed) — an authoring defect parks unchargeable before any accounting below.
6413
- if (await dispositionScopeRed(t, results, assignment, attempt, startMs, gateFails, consults, tokens, metered, retryMode, await treeOrDiffPaths(taskBase, wt)))
6414
- return;
6415
6611
  gateFails++; // this attempt's gates failed — the one place quality degradation is verified (never inferred from attempts)
6416
6612
  // v1.53 T3: prefer the CLI's own session id captured from this attempt's output (kimi's resume
6417
6613
  // trailer) over the harness slot name; absent hook or no capture keeps today's slot-name id.