tickmarkr 2.6.5 → 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.
@@ -15,6 +15,7 @@ import { marginalCostRank } from "../route/router.js";
15
15
  import { carriedAuthorVendors, gateReviewerFloor, pickReviewer, reviewGate } from "./review.js";
16
16
  import { scopeGate } from "./scope.js";
17
17
  import { discoverTestManifest, evaluateManifestedTest, isVitestTestCommand, VITEST_CACHE_ENV, worktreeVitestCache } from "./test-manifest.js";
18
+ import { RUNNER_INFRA_DIAGNOSTIC_RE, timeoutShaped } from "./timeout-shaped.js";
18
19
  import { executionSignal } from "../run/execution-budget.js";
19
20
  import { failureDisposition } from "../run/recovery.js";
20
21
  import { dependencyLinkRefusal, FORK_CAP_ENV, preserveWorktree, producerFields, ROUTING_ENV_SEAMS, shGit, resolvedCapacity, SUITE_PARENT_ENV, verificationProtocol } from "../run/git.js";
@@ -253,7 +254,13 @@ async function listFullManifest(cmd, worktree) {
253
254
  rmSync(dir, { recursive: true, force: true });
254
255
  }
255
256
  }
256
- /** The manifest-report path for a detected vitest test command — never the stdout-count/file-count path. */
257
+ /** The manifest-report path for a detected vitest test command — never the stdout-count/file-count path.
258
+ * ONE outer latch (`retried`) buys at most one more measurement of the exact same selection: the bounded
259
+ * infrastructure retry, or — v2.6.6 C (D-873), default-on — a behavioral timeout red carrying runner
260
+ * infra diagnostics, read by the daemon's own predicate (timeout-shaped.ts). Either waits for calm and
261
+ * spends the bounded allowance when one is configured; neither follows a receipt-backed recovery
262
+ * (retryable:false) or a diagnostic re-observation, and the second sample never stacks the stranded
263
+ * single-fork recovery. The second sample is authoritative; the first keeps its receipts beside it. */
257
264
  async function runVitestManifestGate(worktree, cmd, baseline, selected, artifactDir, retry = {}, retried = false) {
258
265
  const entry = baseline.commands.test;
259
266
  const outcome = await evaluateManifestedTest(cmd, worktree, {
@@ -263,21 +270,28 @@ async function runVitestManifestGate(worktree, cmd, baseline, selected, artifact
263
270
  artifactDir,
264
271
  evidence: retry.evidence,
265
272
  retryBaseCommand: retry.retryBaseCommand, // OBS-1166: the un-narrowed command for a selected screen's stranded retry
273
+ allowStrandedRecovery: !retried,
266
274
  });
267
275
  const reportPath = outcome.reportPath;
268
276
  const evidence = { evidenceReceipt: outcome.evidenceReceipt, evidenceReceipts: outcome.evidenceReceipts };
269
- if (!retried && retry.authorizeRetry && failureDisposition(outcome) === "infrastructure"
270
- && outcome.meta?.retryable !== false) {
277
+ const disposition = failureDisposition(outcome);
278
+ const shaped = disposition === "behavioral" && timeoutShaped(outcome.details) && RUNNER_INFRA_DIAGNOSTIC_RE.test(outcome.details);
279
+ if (!retried && outcome.meta?.retryable !== false && retry.retryBaseCommand !== REOBSERVATION_RETRY_BASE
280
+ && (shaped || (retry.authorizeRetry && disposition === "infrastructure"))) {
271
281
  const waitedMs = await waitForCalmWindow(executionSignal());
272
282
  if (!calmWindowReady())
273
283
  return { ...evidence, gate: "test", pass: false, details: outcome.details,
274
284
  meta: { ...outcome.meta, reportPath, recoveryBlocked: "calm window unavailable within the existing wait ceiling" } };
275
- if (!retry.authorizeRetry("infra")) {
285
+ if (retry.authorizeRetry && !retry.authorizeRetry("infra")) {
276
286
  return { ...evidence, gate: "test", pass: false, details: outcome.details,
277
287
  meta: { ...outcome.meta, reportPath, recoveryBlocked: "infrastructure retry allowance exhausted or subject unavailable" } };
278
288
  }
279
289
  const result = await runVitestManifestGate(worktree, cmd, baseline, selected, artifactDir, retry, true);
280
- return { ...result, evidenceReceipts: [...(outcome.evidenceReceipts ?? []), ...(result.evidenceReceipts ?? [])], meta: { ...result.meta, runnerInfraRerun: { count: 1, waitedMs, firstReportPath: reportPath } } };
290
+ const rerun = { count: 1, waitedMs, firstReportPath: reportPath };
291
+ return { ...result, evidenceReceipts: [...(outcome.evidenceReceipts ?? []), ...(result.evidenceReceipts ?? [])],
292
+ meta: { ...result.meta, ...(shaped
293
+ ? { remeasured: { ...rerun, first: { pass: outcome.pass, details: outcome.details, meta: outcome.meta } } }
294
+ : { runnerInfraRerun: rerun }) } };
281
295
  }
282
296
  return {
283
297
  ...evidence,
@@ -290,7 +304,8 @@ async function runVitestManifestGate(worktree, cmd, baseline, selected, artifact
290
304
  /** A retry base no runner invocation parses. evaluateManifestedTest builds its stranded single-fork
291
305
  * retry from the base it is handed, and one it cannot parse throws before any spawn — so this base
292
306
  * disables that inner recovery: a worker-RPC-stranded re-observation comes back infra (the caller
293
- * parks it as ambiguous) instead of launching a second execution. */
307
+ * parks it as ambiguous) instead of launching a second execution. runVitestManifestGate reads it as
308
+ * the mark of a diagnostic subset, which never buys the outer timeout-shaped remeasurement either. */
294
309
  export const REOBSERVATION_RETRY_BASE = "tickmarkr-reobservation-refuses-stranded-retry";
295
310
  /** OBS-1106 residual: ONE isolated re-observation of a timeout-shaped red's attributed failing files on
296
311
  * the same checkout, narrowed exactly as a screen is. Never cached and never a verdict: the caller
@@ -208,4 +208,7 @@ export declare function evaluateManifestedTest(cmd: string, cwd: string, opts: {
208
208
  /** OBS-1166: the configured command BEFORE a selected screen narrowed it, so a stranded retry's
209
209
  * only positional filters are the stranded files. Absent (a full suite), `cmd` is that command. */
210
210
  retryBaseCommand?: string;
211
+ /** v2.6.6 C: false on the manifest gate's one outer re-measurement — the second sample is one
212
+ * execution and never stacks the stranded single-fork recovery on top of the retry it already is. */
213
+ allowStrandedRecovery?: boolean;
211
214
  }): Promise<ManifestGateOutcome>;
@@ -594,7 +594,7 @@ export async function evaluateManifestedTest(cmd, cwd, opts) {
594
594
  hangActiveMs: invoked.hangActiveMs, hangWallMs: invoked.hangWallMs });
595
595
  evidenceReceipts.push(...invoked.evidenceReceipts);
596
596
  const interruptions = [...invoked.interruptions];
597
- const stranded = strandedSingleForkFiles(files, nonce, invoked);
597
+ const stranded = opts.allowStrandedRecovery === false ? undefined : strandedSingleForkFiles(files, nonce, invoked);
598
598
  if (stranded) {
599
599
  const first = invoked.report;
600
600
  const retryNonce = randomBytes(16).toString("hex");
@@ -0,0 +1,3 @@
1
+ export declare const TIMEOUT_SHAPED_RE: RegExp;
2
+ export declare const RUNNER_INFRA_DIAGNOSTIC_RE: RegExp;
3
+ export declare const timeoutShaped: (details: string) => boolean;
@@ -0,0 +1,10 @@
1
+ import { readableExcerpt } from "../run/journal.js";
2
+ // OBS-1106 residual: a red whose assertion is a timeout or a wall-clock budget, and the runner-level
3
+ // diagnostics (never-started files, a worker RPC timeout) that make such a red infrastructure-SHAPED.
4
+ // Shape alone never parks anything: it only buys one isolated re-observation before a charge. A
5
+ // fresh-fingerprint section masks digits to `#`, so both spellings of a number are read.
6
+ // v2.6.6 C: the daemon's declarations (src/run/daemon.ts at 75dc3187:492-494), exported verbatim so the
7
+ // manifest gate's one remeasurement and the daemon's isolated adjudication read ONE predicate.
8
+ export 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;
9
+ export const RUNNER_INFRA_DIAGNOSTIC_RE = /\bnever-started (?:[1-9]\d*|#)(?![\d#])|\[vitest-worker\]: Timeout calling\b/;
10
+ export const timeoutShaped = (details) => TIMEOUT_SHAPED_RE.test(readableExcerpt(details));
@@ -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
  }
@@ -6455,7 +6557,14 @@ export async function runDaemon(repoRoot, opts = {}) {
6455
6557
  if (results.some((g) => g.gate === "test" && !g.pass
6456
6558
  && !(cfg.executionPolicy?.boundedInfrastructure && failureDisposition(g) === "infrastructure")))
6457
6559
  testGateFailed = true;
6458
- 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 },
6459
6568
  // T6: every gate — the review included — is satisfied on this commit, so the failure brief
6460
6569
  // this loop is still holding describes nothing outstanding. It is dropped HERE, before the
6461
6570
  // merge, because a conflict below sends the task around the attempt loop again: a brief kept
@@ -6464,35 +6573,23 @@ export async function runDaemon(repoRoot, opts = {}) {
6464
6573
  // leaving that dispatch's row indistinguishable from an amnesiac one. Rebuilt exactly as the
6465
6574
  // gate-fail brief is, so prior-RUN evidence (retired by its own rule, not by this reviewer)
6466
6575
  // 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 });
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") {
6496
6593
  // D-07 done means gone (merged-P42-01-worker incident): a merged task's worker pane closes on
6497
6594
  // the task-done path, not at run end. Only THIS successful attempt's `slot` is in scope — prior
6498
6595
  // failed attempts' slots stay in keptSlots governed by keepPanes (they hold failure context the
@@ -6511,38 +6608,6 @@ export async function runDaemon(repoRoot, opts = {}) {
6511
6608
  }
6512
6609
  break gateLoop;
6513
6610
  }
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
6611
  gateFails++; // this attempt's gates failed — the one place quality degradation is verified (never inferred from attempts)
6547
6612
  // v1.53 T3: prefer the CLI's own session id captured from this attempt's output (kimi's resume
6548
6613
  // trailer) over the harness slot name; absent hook or no capture keeps today's slot-name id.
@@ -2,9 +2,21 @@ import { type AuthHealth, type WorkerAdapter } from "../../adapters/types.js";
2
2
  import { type DenyScopeKey, type InitialDenyProps, type MapEntry, type RoutingMode, type StagedDenyKey, type Tier, type TickmarkrConfig } from "../../config/config.js";
3
3
  import { type Effort, type Shape } from "../../graph/schema.js";
4
4
  export type FleetSteeringKey = "review" | "consult";
5
+ /** T10 (D-939): the routing.allow entry a classification staged this session excluded — the row's own
6
+ * entry in its ownAllow spelling, added to the staged complement so Space can admit it in-session.
7
+ * `model` is the classified id (the tiers key), `displayModel` the browser row it was staged from. */
8
+ export type FleetSeededAllow = {
9
+ adapter: string;
10
+ model: string;
11
+ displayModel: string;
12
+ identity?: string;
13
+ entry: string;
14
+ };
5
15
  export type FleetEditorState = Record<DenyScopeKey, string[]> & {
6
16
  /** OBS-1046: the staged routing.allow complement, distinct from the authored deny lists above */
7
17
  allowOut?: string[];
18
+ /** T10: one seed per staged classification the current allow form left out */
19
+ seededAllowOut?: FleetSeededAllow[];
8
20
  classifications: FleetClassification[];
9
21
  /** OBS-1182: staged launch effort per adapter → model; absent = the CLI's own default */
10
22
  efforts?: Record<string, Record<string, Effort>>;
@@ -18,12 +30,17 @@ export type FleetEditorState = Record<DenyScopeKey, string[]> & {
18
30
  };
19
31
  /** T8: the staged classifications and efforts every preview folds — the same metadata a review stages,
20
32
  * so a preview ranks what w would write before the first review and after an escaped one. */
21
- export type FleetStagedMetadata = Pick<FleetEditorState, "classifications" | "efforts">;
33
+ export type FleetStagedMetadata = Pick<FleetEditorState, "classifications" | "efforts" | "seededAllowOut">;
22
34
  /** OBS-525: auto reverts the shape's WHOLE routing declaration — pool included, not just the pin.
23
35
  * prefer survives: it biases auto routing and is valid without pin/pool. */
24
36
  export declare function autoMapEntry(entry: MapEntry | undefined): MapEntry;
25
37
  export type FleetOverlayReview = {
26
38
  kind: "empty";
39
+ }
40
+ /** T10: a staged batch the one-destination review cannot write (mixed destinations, a stray seed) */
41
+ | {
42
+ kind: "refused";
43
+ reason: string;
27
44
  } | {
28
45
  kind: "diff";
29
46
  before: string;
@@ -131,7 +148,7 @@ export type FleetStagedRouting = {
131
148
  ok: false;
132
149
  error: string;
133
150
  };
134
- export declare function FleetApp({ ageMs, agents, initialAllowOut, initialEfforts, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewAdvisories, reviewOverlay, reloadGuard, stagedRouting, entry, initialJudge, judgeSeats, viewRows, frameColumns, frameRows, ...initialDenyProps }: InitialDenyProps & {
151
+ export declare function FleetApp({ ageMs, agents, initialAllowOut, initialEfforts, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewAdvisories, reviewOverlay, reloadGuard, stagedRouting, holderOf, entry, initialJudge, judgeSeats, viewRows, frameColumns, frameRows, ...initialDenyProps }: InitialDenyProps & {
135
152
  ageMs: number | null;
136
153
  agents: AgentCli[];
137
154
  /** OBS-1046: adapter ids and adapter:model keys routing.allow leaves out of the fleet */
@@ -158,7 +175,9 @@ export declare function FleetApp({ ageMs, agents, initialAllowOut, initialEffort
158
175
  reloadGuard: (bytes: string) => string | null;
159
176
  /** the routing policy the staged deny sets load as (fleet: the config loader over the candidate
160
177
  * bytes); absent ⇒ the four staged sets alone, with no allowlist */
161
- stagedRouting?: (deny: FleetStagedDeny) => FleetStagedRouting;
178
+ stagedRouting?: (deny: FleetStagedDeny, stage?: FleetStagedMetadata) => FleetStagedRouting;
179
+ /** T10: the overlay `path:line` holding a dotted config scope in this repository, undefined = defaults */
180
+ holderOf?: (dotted: string) => string | undefined;
162
181
  entry?: "presets" | "probe";
163
182
  /** resolved config judge as "adapter:model" — shown on the (keep default) picker row */
164
183
  initialJudge?: string;
@@ -171,7 +190,7 @@ export declare function FleetApp({ ageMs, agents, initialAllowOut, initialEffort
171
190
  /** terminal height — set only on a real TTY so the footer sticks to the bottom */
172
191
  frameRows?: number;
173
192
  }): import("react").JSX.Element;
174
- export declare function runFleetInkEditor({ ageMs, adapters, health, initialAllowOut, initialEfforts, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewAdvisories, reviewOverlay, reloadGuard, stagedRouting, entry, initialJudge, judgeSeats, initialInput, input, output, debug, ...initialDenyProps }: InitialDenyProps & {
193
+ export declare function runFleetInkEditor({ ageMs, adapters, health, initialAllowOut, initialEfforts, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewAdvisories, reviewOverlay, reloadGuard, stagedRouting, holderOf, entry, initialJudge, judgeSeats, initialInput, input, output, debug, ...initialDenyProps }: InitialDenyProps & {
175
194
  ageMs: number | null;
176
195
  adapters: WorkerAdapter[];
177
196
  health: Record<string, AuthHealth>;
@@ -199,7 +218,9 @@ export declare function runFleetInkEditor({ ageMs, adapters, health, initialAllo
199
218
  reloadGuard: (bytes: string) => string | null;
200
219
  /** the routing policy the staged deny sets load as (fleet: the config loader over the candidate
201
220
  * bytes); absent ⇒ the four staged sets alone, with no allowlist */
202
- stagedRouting?: (deny: FleetStagedDeny) => FleetStagedRouting;
221
+ stagedRouting?: (deny: FleetStagedDeny, stage?: FleetStagedMetadata) => FleetStagedRouting;
222
+ /** T10: the overlay `path:line` holding a dotted config scope in this repository, undefined = defaults */
223
+ holderOf?: (dotted: string) => string | undefined;
203
224
  /** "presets" = init's entry: Esc in the browser is HOME to the preset overlay, not quit */
204
225
  entry?: "presets" | "probe";
205
226
  initialJudge?: string;