tickmarkr 2.6.5 → 2.6.7

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 (56) hide show
  1. package/README.md +7 -3
  2. package/dist/adapters/claude-code.js +8 -2
  3. package/dist/adapters/codex-commit-check.d.ts +16 -4
  4. package/dist/adapters/codex-commit-check.js +150 -26
  5. package/dist/adapters/codex.d.ts +1 -0
  6. package/dist/adapters/codex.js +20 -8
  7. package/dist/adapters/types.d.ts +4 -1
  8. package/dist/cli/commands/beat.d.ts +6 -1
  9. package/dist/cli/commands/beat.js +123 -76
  10. package/dist/cli/commands/doctor.js +3 -1
  11. package/dist/cli/commands/fleet.js +368 -85
  12. package/dist/cli/commands/init.js +23 -9
  13. package/dist/cli/commands/plan.js +12 -1
  14. package/dist/cli/commands/verify.js +37 -9
  15. package/dist/cli/help.d.ts +1 -0
  16. package/dist/cli/help.js +10 -5
  17. package/dist/cli/index.d.ts +1 -1
  18. package/dist/cli/index.js +1 -1
  19. package/dist/config/fleet-overlay.d.ts +14 -0
  20. package/dist/config/fleet-overlay.js +154 -25
  21. package/dist/drivers/orca.d.ts +17 -3
  22. package/dist/drivers/orca.js +64 -20
  23. package/dist/gates/baseline.d.ts +9 -0
  24. package/dist/gates/baseline.js +14 -3
  25. package/dist/gates/cache.js +1 -1
  26. package/dist/gates/review.js +15 -0
  27. package/dist/gates/run-gates.d.ts +20 -7
  28. package/dist/gates/run-gates.js +345 -210
  29. package/dist/gates/test-manifest.d.ts +11 -0
  30. package/dist/gates/test-manifest.js +151 -111
  31. package/dist/gates/timeout-shaped.d.ts +3 -0
  32. package/dist/gates/timeout-shaped.js +10 -0
  33. package/dist/run/beat-lifecycle.d.ts +154 -0
  34. package/dist/run/beat-lifecycle.js +825 -0
  35. package/dist/run/daemon.js +202 -126
  36. package/dist/run/git-trust.d.ts +27 -0
  37. package/dist/run/git-trust.js +178 -0
  38. package/dist/run/git.js +34 -14
  39. package/dist/run/journal.js +15 -3
  40. package/dist/run/lease.d.ts +23 -0
  41. package/dist/run/lease.js +232 -38
  42. package/dist/run/merge.js +2 -2
  43. package/dist/run/stall.js +20 -1
  44. package/dist/run/supervision.d.ts +8 -0
  45. package/dist/run/supervision.js +24 -4
  46. package/dist/run/verification-job.d.ts +51 -0
  47. package/dist/run/verification-job.js +137 -0
  48. package/dist/tui/ink/fleet-app.d.ts +35 -5
  49. package/dist/tui/ink/fleet-app.js +244 -124
  50. package/package.json +1 -1
  51. package/skills/tickmarkr-auto/SKILL.md +19 -10
  52. package/skills/tickmarkr-loop/SKILL.md +19 -10
  53. package/skills/tickmarkr-overseer/SKILL.md +25 -16
  54. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +58 -16
  55. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +19 -6
  56. 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,7 +33,8 @@ 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";
@@ -374,9 +376,6 @@ 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));
380
379
  /** C (E1): the one no-verdict that is the host's slowness, not the seat's — the same predicate run-gates
381
380
  * applies to its run-scoped tally, read here for the live task strike and the resume reconstruction. */
382
381
  const proofTimeoutNote = (data) => data.cause === "seat-launch-failed" && data.launchCause === "checkout-proof-timeout";
@@ -2690,7 +2689,29 @@ export async function runDaemon(repoRoot, opts = {}) {
2690
2689
  to: loadedHash,
2691
2690
  });
2692
2691
  }
2693
- 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
+ }
2694
2715
  const replayEvents = journal.read();
2695
2716
  // journal.replayStatuses predates typed releases and re-pends even inert rows. Keep that
2696
2717
  // legacy reader intact; scheduling accepts only the fold's recognised authorities.
@@ -2739,8 +2760,47 @@ export async function runDaemon(repoRoot, opts = {}) {
2739
2760
  });
2740
2761
  runStarted = true;
2741
2762
  await placeBoard();
2742
- // A terminal resume with no commands has no execution to admit.
2743
- 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.
2744
2804
  baselineCapture = initializeHost();
2745
2805
  void baselineCapture.catch(() => { baselineFailed = true; });
2746
2806
  }
@@ -3709,6 +3769,81 @@ export async function runDaemon(repoRoot, opts = {}) {
3709
3769
  let testGateFailed = journal.read().some((e) => e.event === "gate-result" && e.taskId === t.id && e.data.gate === "test" && e.data.pass === false
3710
3770
  && !(cfg.executionPolicy?.boundedInfrastructure && e.data.disposition === "infrastructure"));
3711
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
+ };
3712
3847
  let lastContextTokens; // v1.23 reset signal, including stalled/quota attempts
3713
3848
  // v1.29: only a gate-failed attempt can seed same-session retry. The next attempt consumes this
3714
3849
  // once; a changed channel, unknown context, or missing resumeCommand falls back to fresh.
@@ -4306,23 +4441,11 @@ export async function runDaemon(repoRoot, opts = {}) {
4306
4441
  const approvedCommits = await commitsAheadOf(taskBase, wt);
4307
4442
  graph = addEvidence(graph, t.id, { commits: approvedCommits, gateResults: results });
4308
4443
  saveGraph(repoRoot, graph);
4309
- if (!results.every(gateSatisfied)) {
4310
- const unavailableReview = results.find((g) => gateFailed(g) && g.meta?.noEligibleReviewer === true);
4311
- if (unavailableReview) {
4312
- await park(t, gateFailApprovalReason(t.id, unavailableReview.details, true), "gate-fail", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
4313
- return;
4314
- }
4315
- // OBS-1106: same predicate as classification — an infra replay is parked, never repaired.
4316
- const infra = results.find((g) => gateFailed(g) && isInfraResult(g));
4317
- if (infra) {
4318
- await park(t, `${infra.gate}: ${infra.details}`, "infra", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
4319
- return;
4320
- }
4321
- const capTrip = results.find(isDiffCapPark);
4322
- if (capTrip) {
4323
- await park(t, `${capTrip.gate}: ${capTrip.details}`, "diff-cap", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
4324
- return;
4325
- }
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: {},
4326
4449
  // OBS-547: disposition FIRST, and through the same helper the ordinary attempt path uses. A
4327
4450
  // crash between a journaled scope red and its classification must not turn a predicted red
4328
4451
  // into a charged one just because a resume is what observed it.
@@ -4334,15 +4457,12 @@ export async function runDaemon(repoRoot, opts = {}) {
4334
4457
  // erase an EARLIER chargeable attempt and attribute the park to the assignment the rewind
4335
4458
  // restored. Ask the journal which state this is, and take the classified dispatch's own
4336
4459
  // assignment back with it.
4337
- 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)))
4338
- return;
4339
- if (outsideScopeForced && freshOutsideScopeRed(t, gateSubject.commit)) {
4340
- await park(t, outsideScopePark(t), "gate-fail", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
4341
- return;
4342
- }
4343
- const classified = journal.classifiedDispatch(t.id);
4344
- 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)))
4345
- 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") {
4346
4466
  gateFails++;
4347
4467
  // Observed green gates are only measurements. If the resumed suffix is red, preserve that
4348
4468
  // result in the journal and return to the ordinary attempt/consult ladder, which rebuilds
@@ -4385,32 +4505,14 @@ export async function runDaemon(repoRoot, opts = {}) {
4385
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);
4386
4506
  return;
4387
4507
  }
4388
- const m = await mergeSerial(taskBranch, t, gated);
4389
- if (m.tipMoved) {
4390
- journal.append("tip-moved", t.id, m.tipMoved);
4391
- if (tipMoves++ === 0)
4392
- continue gateLoop;
4393
- 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")
4394
4511
  return;
4395
- }
4396
- if (!m.ok) {
4397
- journal.append("merge-conflict", t.id, { conflict: m.conflict });
4398
- 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);
4399
4514
  return;
4400
4515
  }
4401
- graph = setStatus(graph, t.id, "done");
4402
- saveGraph(repoRoot, graph);
4403
- journal.append("task-done", t.id, {
4404
- attempts: rs?.attempts ?? 0, assignment: gateAuthor, taskContentDigest: contentDigest,
4405
- authors: mergedAuthors(await subjectAuthors(journal.read(), t.id, wt, taskBase)),
4406
- });
4407
- journal.append("merge", t.id, { branch: taskBranch, commit: await integrationHead(intWt) });
4408
- await trackedDriver.project?.(t.id, "completed");
4409
- journal.telemetry({
4410
- taskId: t.id, shape: t.shape, adapter: gateAuthor.adapter, model: gateAuthor.model,
4411
- channel: gateAuthor.channel, attempts: rs?.attempts ?? 0, outcome: "done",
4412
- durationMs: Date.now() - startMs, firstAttemptOk: false, gateFails, consults, retryMode,
4413
- });
4414
4516
  await reconcile({ spareLiveLlm: true });
4415
4517
  return;
4416
4518
  }
@@ -5940,13 +6042,23 @@ export async function runDaemon(repoRoot, opts = {}) {
5940
6042
  reapFailure = error instanceof Error ? error.message : String(error);
5941
6043
  }
5942
6044
  }
5943
- else if (keepOpen && (workerFinished || processExited || driver.id !== "subprocess")) {
5944
- await reapWorker(slot);
5945
- keptSlots.push(slot);
5946
- supersededWorkerSlot = slot;
5947
- }
5948
6045
  else {
5949
- await closeSlot(slot);
6046
+ // A worker that exited cleanly can still leave a census the reap cannot read. That is the
6047
+ // same stall disposition as above, never a silent task failure; a kept slot is recorded
6048
+ // first so run end still closes it (public CI 37324617042, D-1278).
6049
+ try {
6050
+ if (keepOpen && (workerFinished || processExited || driver.id !== "subprocess")) {
6051
+ keptSlots.push(slot);
6052
+ supersededWorkerSlot = slot;
6053
+ await reapWorker(slot);
6054
+ }
6055
+ else {
6056
+ await closeSlot(slot);
6057
+ }
6058
+ }
6059
+ catch (error) {
6060
+ reapFailure = error instanceof Error ? error.message : String(error);
6061
+ }
5950
6062
  }
5951
6063
  if (retryMode === "resume") {
5952
6064
  const transcript = readResumeTranscript();
@@ -5970,7 +6082,8 @@ export async function runDaemon(repoRoot, opts = {}) {
5970
6082
  tokens = addUsage(tokens, attemptUsage);
5971
6083
  metered++;
5972
6084
  }
5973
- if (deadWorkerPark) {
6085
+ // An unreadable cleanup outranks a dead-worker park: only the reapFailure park re-verifies the census.
6086
+ if (deadWorkerPark && !reapFailure) {
5974
6087
  await park(t, deadWorkerPark.reason, "stall", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode, { ref: deadWorkerPark.ref });
5975
6088
  return;
5976
6089
  }
@@ -6455,7 +6568,14 @@ export async function runDaemon(repoRoot, opts = {}) {
6455
6568
  if (results.some((g) => g.gate === "test" && !g.pass
6456
6569
  && !(cfg.executionPolicy?.boundedInfrastructure && failureDisposition(g) === "infrastructure")))
6457
6570
  testGateFailed = true;
6458
- if (results.every(gateSatisfied)) {
6571
+ const live = assignment;
6572
+ const settled = await settleResults(results, {
6573
+ wt, taskBase, taskBranch, gated, commit: gateSubject.commit, outsideScopeForced,
6574
+ author: live, attempts: attempt + 1,
6575
+ infraReason: (g) => `${g.gate}: ${g.details}${g.meta?.recoveryBlocked ? ` — ${g.meta.recoveryBlocked}` : ""}`,
6576
+ reobserve: { attempt, replayed: gateSubject.replayedFromAttempt !== undefined },
6577
+ disposition: () => ({ author: live, attempt }),
6578
+ firstAttemptOk: attempt === 0, metering: { tokens, meteredAttempts: tokens ? metered : undefined },
6459
6579
  // T6: every gate — the review included — is satisfied on this commit, so the failure brief
6460
6580
  // this loop is still holding describes nothing outstanding. It is dropped HERE, before the
6461
6581
  // merge, because a conflict below sends the task around the attempt loop again: a brief kept
@@ -6464,35 +6584,23 @@ export async function runDaemon(repoRoot, opts = {}) {
6464
6584
  // leaving that dispatch's row indistinguishable from an amnesiac one. Rebuilt exactly as the
6465
6585
  // gate-fail brief is, so prior-RUN evidence (retired by its own rule, not by this reviewer)
6466
6586
  // survives and only this run's settled findings go.
6467
- feedback = withCarriedEvidence("");
6468
- fatalStop.signal.throwIfAborted();
6469
- executionSignal()?.throwIfAborted();
6470
- const m = await mergeSerial(taskBranch, t, gated);
6471
- if (m.tipMoved) {
6472
- journal.append("tip-moved", t.id, m.tipMoved);
6473
- if (tipMoves++ === 0)
6474
- continue gateLoop;
6475
- await park(t, "task branch tip moved twice after gating", "tip-moved", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode);
6476
- return;
6477
- }
6478
- if (!m.ok) {
6479
- journal.append("merge-conflict", t.id, { conflict: m.conflict });
6480
- const v = await runConsult("merge-conflict", output, m.conflict ?? "", results);
6481
- if (await applyVerdict(v, attempt + 1, "merge-conflict"))
6482
- continue attempts;
6483
- return;
6484
- }
6485
- graph = setStatus(graph, t.id, "done");
6486
- saveGraph(repoRoot, graph);
6487
- journal.append("task-done", t.id, {
6488
- attempts: attempt + 1, assignment, taskContentDigest: contentDigest,
6489
- authors: mergedAuthors(await subjectAuthors(journal.read(), t.id, wt, taskBase)),
6490
- });
6491
- journal.append("merge", t.id, { branch: taskBranch, commit: await integrationHead(intWt) });
6492
- await trackedDriver.project?.(t.id, "completed");
6493
- // firstAttemptOk/gateFails/consults are recorded FACTS, not policy — a parkKind:"stall" row is
6494
- // recorded but NOT quality-negative in v1.6; Phase 12 owns reward policy, so flipping it later needs zero data migration.
6495
- 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 });
6587
+ beforeMerge: () => {
6588
+ feedback = withCarriedEvidence("");
6589
+ fatalStop.signal.throwIfAborted();
6590
+ executionSignal()?.throwIfAborted();
6591
+ },
6592
+ });
6593
+ if (settled.kind === "regate")
6594
+ continue gateLoop;
6595
+ if (settled.kind === "parked")
6596
+ return;
6597
+ if (settled.kind === "conflict") {
6598
+ const v = await runConsult("merge-conflict", output, settled.conflict ?? "", results);
6599
+ if (await applyVerdict(v, attempt + 1, "merge-conflict"))
6600
+ continue attempts;
6601
+ return;
6602
+ }
6603
+ if (settled.kind === "merged") {
6496
6604
  // D-07 done means gone (merged-P42-01-worker incident): a merged task's worker pane closes on
6497
6605
  // the task-done path, not at run end. Only THIS successful attempt's `slot` is in scope — prior
6498
6606
  // failed attempts' slots stay in keptSlots governed by keepPanes (they hold failure context the
@@ -6511,38 +6619,6 @@ export async function runDaemon(repoRoot, opts = {}) {
6511
6619
  }
6512
6620
  break gateLoop;
6513
6621
  }
6514
- // OBS-540: infra is a fail-closed NON-verdict, not quality degradation. Park with the blocker
6515
- // already journaled by onGate, before gateFails and before any ladder selection can fund an
6516
- // identical retry in the same environment. Parsed judge refusals never carry infra and keep
6517
- // flowing through the chargeable quality path below.
6518
- const unavailableReview = results.find((g) => gateFailed(g) && g.meta?.noEligibleReviewer === true);
6519
- if (unavailableReview) {
6520
- await park(t, gateFailApprovalReason(t.id, unavailableReview.details, true), "gate-fail", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode);
6521
- return;
6522
- }
6523
- const infraFailure = results.find((g) => gateFailed(g) && isInfraResult(g));
6524
- if (infraFailure) {
6525
- await park(t, `${infraFailure.gate}: ${infraFailure.details}${infraFailure.meta?.recoveryBlocked ? ` — ${infraFailure.meta.recoveryBlocked}` : ""}`, "infra", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode);
6526
- return;
6527
- }
6528
- // OBS-1007: a cap trip is a typed park of its own — the diff cannot shrink by retrying and the
6529
- // only honest verb is a recheck under a raised cap. Decided BEFORE the authoring classifier, which
6530
- // used to lift `gates.diffCap` out of the cap prose as a files[] hint.
6531
- const capTrip = results.find(isDiffCapPark);
6532
- if (capTrip) {
6533
- await park(t, `${capTrip.gate}: ${capTrip.details}`, "diff-cap", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode);
6534
- return;
6535
- }
6536
- 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)))
6537
- return;
6538
- if (outsideScopeForced && freshOutsideScopeRed(t, gateSubject.commit)) {
6539
- await park(t, outsideScopePark(t), "gate-fail", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode);
6540
- return;
6541
- }
6542
- // OBS-547: who pays for this red is decided by the run's collateral prediction (see
6543
- // dispositionScopeRed) — an authoring defect parks unchargeable before any accounting below.
6544
- if (await dispositionScopeRed(t, results, assignment, attempt, startMs, gateFails, consults, tokens, metered, retryMode, await treeOrDiffPaths(taskBase, wt)))
6545
- return;
6546
6622
  gateFails++; // this attempt's gates failed — the one place quality degradation is verified (never inferred from attempts)
6547
6623
  // v1.53 T3: prefer the CLI's own session id captured from this attempt's output (kimi's resume
6548
6624
  // trailer) over the harness slot name; absent hook or no capture keeps today's slot-name id.
@@ -0,0 +1,27 @@
1
+ /** A covered git invocation refused before spawn; `path` names the offending metadata. */
2
+ export declare class GitTrustRefusal extends Error {
3
+ readonly path: string;
4
+ constructor(path: string, detail: string);
5
+ }
6
+ /**
7
+ * The canonical, independently expected common git directory for the checkout enclosing `cwd` — never what
8
+ * a mutable commondir says. Throws GitTrustRefusal naming the authority path when it cannot be established.
9
+ */
10
+ export declare function trustedCommonDir(cwd: string): string;
11
+ /**
12
+ * The uncached pre-spawn check for a covered git invocation at `cwd`. Outside any checkout it is a no-op
13
+ * (unrelated shell commands stay compatible). Inside one it refuses, naming the file, when the commondir git
14
+ * would follow resolves anywhere but the expected common directory, or when the trusted common config enables
15
+ * extensions.worktreeConfig and the linked gitdir holds a config.worktree.
16
+ */
17
+ export declare function assertGitTrust(cwd: string): void;
18
+ /** The inert hooks directory every protected git child receives (no hook can exist beneath it). */
19
+ export declare const INERT_HOOKS_PATH = "/dev/null";
20
+ /**
21
+ * A copy of `env` whose git children run with core.fsmonitor=false and an inert core.hooksPath, despite repo
22
+ * config and inherited GIT_CONFIG_COUNT/GIT_CONFIG_PARAMETERS: git applies GIT_CONFIG_PARAMETERS after
23
+ * GIT_CONFIG_COUNT and the last value wins, so the forced pair is appended last. Every other value survives.
24
+ * Named keys only — this does not neutralize arbitrary executable drivers. The pair is inherited by every
25
+ * descendant of the protected child (a suite that needs real hooks must clear it for its own git).
26
+ */
27
+ export declare function protectedGitEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;