tickmarkr 2.5.4 → 2.5.5

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 (59) hide show
  1. package/dist/adapters/registry.js +6 -1
  2. package/dist/adapters/types.d.ts +3 -0
  3. package/dist/cli/commands/approve.d.ts +1 -0
  4. package/dist/cli/commands/approve.js +54 -6
  5. package/dist/cli/commands/doctor.js +44 -29
  6. package/dist/cli/commands/fleet.js +195 -33
  7. package/dist/cli/commands/init.js +196 -6
  8. package/dist/cli/commands/resume.js +4 -2
  9. package/dist/cli/commands/run.js +11 -2
  10. package/dist/cli/commands/verify.js +1 -1
  11. package/dist/cli/help.d.ts +2 -0
  12. package/dist/cli/help.js +3 -1
  13. package/dist/config/config.d.ts +14 -8
  14. package/dist/config/config.js +29 -26
  15. package/dist/config/fleet-overlay.d.ts +3 -9
  16. package/dist/config/fleet-overlay.js +68 -11
  17. package/dist/config/fleet-why.d.ts +7 -0
  18. package/dist/config/fleet-why.js +5 -0
  19. package/dist/drivers/index.d.ts +15 -1
  20. package/dist/drivers/index.js +38 -10
  21. package/dist/drivers/orca.d.ts +99 -10
  22. package/dist/drivers/orca.js +586 -97
  23. package/dist/gates/baseline.d.ts +3 -0
  24. package/dist/gates/baseline.js +2 -1
  25. package/dist/gates/cache.d.ts +100 -0
  26. package/dist/gates/cache.js +389 -0
  27. package/dist/gates/run-gates.d.ts +3 -0
  28. package/dist/gates/run-gates.js +117 -10
  29. package/dist/gates/test-manifest.d.ts +99 -0
  30. package/dist/gates/test-manifest.js +389 -0
  31. package/dist/gates/test-reporter.d.ts +4 -0
  32. package/dist/gates/test-reporter.js +49 -0
  33. package/dist/route/preference.d.ts +22 -1
  34. package/dist/route/preference.js +123 -25
  35. package/dist/route/router.js +31 -6
  36. package/dist/run/daemon.d.ts +13 -0
  37. package/dist/run/daemon.js +160 -70
  38. package/dist/run/git.d.ts +10 -1
  39. package/dist/run/git.js +43 -9
  40. package/dist/run/journal.d.ts +14 -2
  41. package/dist/run/journal.js +78 -10
  42. package/dist/run/lease.d.ts +14 -0
  43. package/dist/run/lease.js +87 -0
  44. package/dist/run/merge.d.ts +2 -0
  45. package/dist/run/merge.js +91 -3
  46. package/dist/run/operator-state.d.ts +11 -0
  47. package/dist/run/operator-state.js +17 -3
  48. package/dist/tui/cockpit/board.d.ts +96 -0
  49. package/dist/tui/cockpit/board.js +346 -0
  50. package/dist/tui/cockpit/decision-actions.js +2 -0
  51. package/dist/tui/cockpit/layout.d.ts +5 -1
  52. package/dist/tui/cockpit/layout.js +8 -3
  53. package/dist/tui/cockpit/live-runtime.js +71 -21
  54. package/dist/tui/cockpit/run-view.d.ts +7 -5
  55. package/dist/tui/cockpit/run-view.js +12 -11
  56. package/dist/tui/ink/fleet-app.d.ts +41 -27
  57. package/dist/tui/ink/fleet-app.js +204 -31
  58. package/package.json +1 -1
  59. package/skills/tickmarkr-overseer/SKILL.md +173 -113
@@ -103,6 +103,32 @@ function withoutExcluded(channels, exclude) {
103
103
  // OBS-57: in-run demotion after consecutive no-trailer windows — route around poisoned channels for the rest of the run.
104
104
  return channels.filter((c) => !exclude.has(channelKey(c)));
105
105
  }
106
+ // Pools spread providers before tier, independently within each marginal-cost run. Stable
107
+ // cost sorting preserves declaration order when forming groups; gateways share the served
108
+ // model's provider, so adding a second transport does not buy that provider another turn.
109
+ function rankAnyPool(live, task) {
110
+ const sorted = [...live].sort((a, b) => marginalCostRank(a) - marginalCostRank(b));
111
+ const out = [];
112
+ for (let i = 0; i < sorted.length;) {
113
+ let j = i + 1;
114
+ while (j < sorted.length && marginalCostRank(sorted[j]) === marginalCostRank(sorted[i]))
115
+ j++;
116
+ const groups = new Map();
117
+ for (const c of sorted.slice(i, j)) {
118
+ const provider = routingModelProvider(c.model, c.vendor);
119
+ const group = groups.get(provider) ?? [];
120
+ group.push(c);
121
+ groups.set(provider, group);
122
+ }
123
+ const ordered = [...groups.values()];
124
+ const k = spreadOffset(task) % ordered.length;
125
+ for (const group of [...ordered.slice(k), ...ordered.slice(0, k)]) {
126
+ out.push(...group.sort((a, b) => TIER_RANK[a.tier] - TIER_RANK[b.tier]));
127
+ }
128
+ i = j;
129
+ }
130
+ return out;
131
+ }
106
132
  const maybeSlaLint = (lints, task, profile, slaMinutes, c) => {
107
133
  if (slaMinutes === undefined || !profile)
108
134
  return;
@@ -206,21 +232,20 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
206
232
  // live preserves pool declaration order — ordered mode and any-mode stable-sort ties depend on it
207
233
  const live = pool.channels
208
234
  .map((id) => channels.find((c) => channelKey(c) === id))
209
- .filter((c) => c !== undefined);
235
+ .filter((c) => c !== undefined && (!taskFloor || TIER_RANK[c.tier] >= TIER_RANK[taskFloor]));
210
236
  if (!live.length) {
211
- throw new RoutingError(`${task.id}: routing.map.${task.shape}.pool has no live channel — declared: ${pool.channels.join(", ")}; doctor found: ${channels.map(channelKey).join(", ") || "(nothing)"}`);
237
+ throw new RoutingError(`${task.id}: routing.map.${task.shape}.pool has no live channel${taskFloor ? ` at task floor ${taskFloor}` : ""} — declared: ${pool.channels.join(", ")}; doctor found: ${channels.map(channelKey).join(", ") || "(nothing)"}`);
212
238
  }
213
- // ponytail: no learned/explore/spread inside pools in v1 — a pool pick is fully static; the
214
- // upgrade path is feeding the pool-filtered set through the learned path below.
239
+ // No learned/explore inside pools: any spreads deterministically; ordered keeps declaration order.
215
240
  const chosen = pool.mode === "ordered"
216
241
  ? live[0]
217
- : [...live].sort((a, b) => marginalCostRank(a) - marginalCostRank(b) || TIER_RANK[a.tier] - TIER_RANK[b.tier])[0];
242
+ : rankAnyPool(live, task)[0];
218
243
  lintMapPinFloor(chosen.tier, "pool");
219
244
  maybeSlaLint(lints, task, profile, slaMinutes, chosen);
220
245
  return { assignment: toAssignment(chosen), ladder: ladderFor(task, entry), lints, provenance: `${degraded}pool ${pool.mode} ${channelKey(chosen)} (config routing.map)` };
221
246
  }
222
247
  const baseTier = floor ?? "cheap";
223
- // D-04: a task floor is hard only on floor/auto paths; the map-pin branch above stays supreme.
248
+ // A task floor is hard on pool and floor/auto paths; the map-pin branch above stays supreme.
224
249
  const minTier = taskFloor && TIER_RANK[taskFloor] > TIER_RANK[baseTier] ? taskFloor : baseTier;
225
250
  if (prefActive)
226
251
  for (const p of prefer ?? [])
@@ -126,6 +126,19 @@ export declare const WORKER_STARTUP_WINDOW_BYTES: number;
126
126
  /** Test seam — exercises both sides of the startup boundary without a production-length fixture. */
127
127
  export declare function setWorkerStartupWindowMsForTests(ms: number): void;
128
128
  export declare function resetWorkerStartupWindowMsForTests(): void;
129
+ export interface StartupFailureEvidence {
130
+ matchedBytes: string;
131
+ offset: number;
132
+ row: string;
133
+ rowNumber: number;
134
+ }
135
+ /** One seat, one startup prefix. Once execution or its input box is seen, later panes are ineligible. */
136
+ export declare class StartupFailureDetector {
137
+ private readonly adapter;
138
+ private closed;
139
+ constructor(adapter: Pick<WorkerAdapter, "inputBox" | "harnessBannerRows">);
140
+ sample(output: string, launchedAt: number | undefined): StartupFailureEvidence | undefined;
141
+ }
129
142
  /** Test seam — shrink the quota-banner silence gate without minute-long sleeps. */
130
143
  export declare function setQuotaBannerSilentMsForTests(ms: number): void;
131
144
  export declare function resetQuotaBannerSilentMsForTests(): void;
@@ -1,3 +1,4 @@
1
+ import { commandLeaseEnvironment, CommandLeases, currentCommandLeaseToken, isRunnerCommand, runWithCommandLease, withCommandLease } from "./lease.js";
1
2
  import { execFileSync, spawn } from "node:child_process";
2
3
  import { createHash, randomBytes } from "node:crypto";
3
4
  import { shq } from "../adapters/types.js";
@@ -9,7 +10,7 @@ import { fileURLToPath } from "node:url";
9
10
  import { stringify } from "yaml";
10
11
  import { classifyDeadChannel, NO_TRAILER_SUMMARY, trailerPattern, UNPARSEABLE_TRAILER_SUMMARY, writePrompt } from "../adapters/prompt.js";
11
12
  import { allAdapters, getAdapter, probeAll, readDoctor, rolePools } from "../adapters/registry.js";
12
- import { SettledTrailerTracker, addUsage, channelKey, matchesTrustDialog, QUOTA_RE } from "../adapters/types.js";
13
+ import { SettledTrailerTracker, addUsage, channelKey, matchesInputBox, matchesTrustDialog, QUOTA_RE } from "../adapters/types.js";
13
14
  import { bannerShell, paneDispatchCommand } from "../brand.js";
14
15
  import { collateralHits } from "../compile/collateral.js";
15
16
  import { DEFAULT_DIFF_CAP, globalConfigDir, loadConfigWithMode, readOverlayFile, repoOverlayPath, } from "../config/config.js";
@@ -27,7 +28,7 @@ import { augmentRetryBrief, consult, renderRetryGuidance } from "./consult.js";
27
28
  import { runEnvironment } from "./environment.js";
28
29
  import { cleanupRunWorktrees, deriveForkCap, FORK_CAP_ENV, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, preserveWorktree, resolvedCapacity, runWithForkBudget, runWithVerificationBudget, sameCapacity, sh, shGit, SUITE_PARENT_ENV, WORKTREE_LAYOUT_CONTRACT, worktreePath } from "./git.js";
29
30
  import { runInteractiveSeed } from "./interactive-seed.js";
30
- import { activeRetryBan, classifyTaskFailure, classifyWorkerResultCause, deferredReviewFindings, engagementComparable, formatPriorFindingEvidence, GATE_FINGERPRINT_CAP, GATE_SATISFIED_RELEASE, identicalGateFailures, isDeferredFinding, journaledFailureBrief, Journal, loadRoutingProfile, newRunId, normalizeGateFailure, outstandingConsultGuidance, outstandingReviewFindings, pendingRechecks, pendingRepairFindings, phaseForGate, readPriorRunEvidence, recordedTaskFailureKind, RECHECK_RELEASE, renderStructuredReviewFinding, repairReachSinceApproval, repairsSinceApproval, reviewRoundsSinceApproval, runHasEnded, structuredFindings, upheldFeedbackByTask } from "./journal.js";
31
+ import { applyScopeAmendments, activeRetryBan, classifyTaskFailure, classifyWorkerResultCause, deferredReviewFindings, engagementComparable, formatPriorFindingEvidence, GATE_FINGERPRINT_CAP, GATE_SATISFIED_RELEASE, identicalGateFailures, isDeferredFinding, journaledFailureBrief, Journal, loadRoutingProfile, newRunId, normalizeGateFailure, outstandingConsultGuidance, outstandingReviewFindings, pendingRechecks, pendingRepairFindings, phaseForGate, readPriorRunEvidence, recordedTaskFailureKind, RECHECK_RELEASE, renderStructuredReviewFinding, repairReachSinceApproval, repairsSinceApproval, reviewRoundsSinceApproval, runHasEnded, structuredFindings, upheldFeedbackByTask } from "./journal.js";
31
32
  import { isDiffCapPark, pickReviewer } from "../gates/review.js";
32
33
  import { acquireApprovalSerialization, acquireRunLock, isPidLive, releaseRunLock } from "./lock.js";
33
34
  import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
@@ -379,14 +380,67 @@ export function setWorkerStartupWindowMsForTests(ms) {
379
380
  export function resetWorkerStartupWindowMsForTests() {
380
381
  workerStartupWindowMs = WORKER_STARTUP_WINDOW_MS;
381
382
  }
382
- function startupFailureInWindow(output, launchedAt) {
383
- if (launchedAt === undefined || Date.now() - launchedAt > workerStartupWindowMs)
384
- return false;
385
- const bytes = Buffer.from(output);
386
- const bounded = bytes.length <= WORKER_STARTUP_WINDOW_BYTES
387
- ? output
388
- : bytes.subarray(0, WORKER_STARTUP_WINDOW_BYTES).toString("utf8");
389
- return WORKER_STARTUP_FAILURE_RE.test(stallSnapshotBannerRows(bounded));
383
+ /** One seat, one startup prefix. Once execution or its input box is seen, later panes are ineligible. */
384
+ export class StartupFailureDetector {
385
+ adapter;
386
+ closed = false;
387
+ constructor(adapter) {
388
+ this.adapter = adapter;
389
+ }
390
+ sample(output, launchedAt) {
391
+ if (this.closed || launchedAt === undefined || Date.now() - launchedAt > workerStartupWindowMs)
392
+ return;
393
+ // read() returns a tail, not a guaranteed transcript origin. A saturated read cannot
394
+ // establish startup ownership: the first tool frame may already have scrolled out.
395
+ if (Buffer.byteLength(output) >= WORKER_STARTUP_WINDOW_BYTES || output.split("\n").length >= 500) {
396
+ this.closed = true;
397
+ return;
398
+ }
399
+ const bounded = output;
400
+ const rows = bounded.split("\n");
401
+ let inputStart = rows.length;
402
+ const input = this.adapter.inputBox;
403
+ if (input && matchesInputBox(bounded, input)) {
404
+ // Locate the beginning, including multi-row composers whose footer proves the matcher.
405
+ let end = rows.length;
406
+ let low = 1;
407
+ while (low < end) {
408
+ const mid = Math.floor((low + end) / 2);
409
+ if (matchesInputBox(rows.slice(0, mid).join("\n"), input))
410
+ end = mid;
411
+ else
412
+ low = mid + 1;
413
+ }
414
+ inputStart = 0;
415
+ let high = end;
416
+ while (inputStart + 1 < high) {
417
+ const mid = Math.floor((inputStart + high) / 2);
418
+ if (matchesInputBox(rows.slice(mid, end).join("\n"), input))
419
+ inputStart = mid;
420
+ else
421
+ high = mid;
422
+ }
423
+ }
424
+ let offset = 0;
425
+ for (const [index, row] of rows.entries()) {
426
+ const cleanRow = row.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "");
427
+ // Structured tool frames and the terminal's tool headings close the prefix BEFORE their body.
428
+ const toolFrame = /"(?:type|role)"\s*:\s*"(?:tool[^" ]*|function_call[^" ]*|command_execution)"|^\s*(?:[•⏺]\s*(?:Ran|Explored|Searched|Called|Bash|Read|Write|Edit|Shell|Exec|Tool)\b|⎋ |(?:tool[-_ ](?:call|output|result)|function_call)\b)/i.test(cleanRow);
429
+ if (toolFrame || index >= inputStart) {
430
+ this.closed = true;
431
+ return;
432
+ }
433
+ if (!this.adapter.harnessBannerRows?.includes(cleanRow)) {
434
+ const match = WORKER_STARTUP_FAILURE_RE.exec(row);
435
+ if (match)
436
+ return {
437
+ matchedBytes: match[0], offset: offset + Buffer.byteLength(row.slice(0, match.index)),
438
+ row, rowNumber: index + 1,
439
+ };
440
+ }
441
+ offset += Buffer.byteLength(row) + 1;
442
+ }
443
+ }
390
444
  }
391
445
  // OBS-901/906: one daemon-owned writer for every execution surface. Drivers expose one retained
392
446
  // stream through read(); the daemon persists that exact surface before any close/reconcile can make
@@ -570,17 +624,54 @@ async function cancellableTipBattery(intWt, commands, runDir, baseline, signal)
570
624
  });
571
625
  const { verifyIntegrationTip } = await import(${JSON.stringify(new URL(`./merge.${extension}`, import.meta.url).href)});
572
626
  const { runWithVerificationBudget } = await import(${JSON.stringify(new URL(`./git.${extension}`, import.meta.url).href)});
627
+ const { runWithCommandLease } = await import(${JSON.stringify(new URL(`./lease.${extension}`, import.meta.url).href)});
628
+ let sequence = 0;
629
+ const grants = new Map();
630
+ const commandCapacities = new Map();
631
+ process.on('message', (message) => {
632
+ if (message.type === 'lease-grant') { grants.get(message.id)?.(message); grants.delete(message.id); }
633
+ });
634
+ const lease = async (command, execute) => {
635
+ const id = ++sequence;
636
+ const grant = new Promise(resolve => grants.set(id, resolve));
637
+ process.send({ type: 'lease-acquire', id, command });
638
+ const { capacity, token } = await grant;
639
+ commandCapacities.set(command, capacity);
640
+ try { return await runWithVerificationBudget(capacity, () => execute(token)); }
641
+ finally { process.send({ type: 'lease-release', id }); }
642
+ };
573
643
  let input = ''; for await (const chunk of process.stdin) input += chunk;
574
644
  const { intWt, commands, runDir, baseline, capacity } = JSON.parse(input);
575
645
  try {
576
- const result = await runWithVerificationBudget(capacity, () => verifyIntegrationTip(intWt, commands, runDir, baseline));
577
- if (!cancelled) process.stdout.write(JSON.stringify(result));
646
+ const result = await runWithVerificationBudget(capacity, () => runWithCommandLease(lease, () => verifyIntegrationTip(intWt, commands, runDir, baseline)));
647
+ if (!cancelled) process.stdout.write(JSON.stringify(result.map(row => ({ ...row, capacity: [...commandCapacities].reverse().find(([command]) => command === row.cmd || command.startsWith(row.cmd + " "))?.[1] ?? capacity }))));
578
648
  } catch (error) { if (!cancelled) throw error; }
649
+ finally { process.disconnect(); }
579
650
  `;
580
651
  const child = spawn(process.execPath, [
581
652
  ...(extension === "ts" ? ["--import", createRequire(import.meta.url).resolve("tsx")] : []),
582
653
  "--input-type=module", "-e", script,
583
- ], { stdio: ["pipe", "pipe", "pipe"], detached: true });
654
+ ], { env: commandLeaseEnvironment(process.env), stdio: ["pipe", "pipe", "pipe", "ipc"], detached: true });
655
+ const releases = new Map();
656
+ const leases = new Set();
657
+ let closed = false;
658
+ child.on("message", (message) => {
659
+ if (message.type === "lease-release") {
660
+ releases.get(message.id)?.();
661
+ releases.delete(message.id);
662
+ }
663
+ if (message.type !== "lease-acquire")
664
+ return;
665
+ const held = withCommandLease(message.command, async () => {
666
+ if (closed || signal.aborted)
667
+ return;
668
+ const released = new Promise((resolve) => releases.set(message.id, resolve));
669
+ child.send({ type: "lease-grant", id: message.id, capacity: resolvedCapacity(), token: currentCommandLeaseToken() });
670
+ await released;
671
+ });
672
+ leases.add(held);
673
+ void held.catch(() => { }).finally(() => leases.delete(held));
674
+ });
584
675
  let output = "";
585
676
  let errors = "";
586
677
  child.stdout.on("data", (chunk) => { output += chunk; });
@@ -589,6 +680,10 @@ async function cancellableTipBattery(intWt, commands, runDir, baseline, signal)
589
680
  const finished = new Promise((resolve, reject) => {
590
681
  child.once("error", reject);
591
682
  child.once("close", (code) => {
683
+ closed = true;
684
+ for (const release of releases.values())
685
+ release();
686
+ releases.clear();
592
687
  if (signal.aborted && (code === 143 || code === null))
593
688
  reject(signal.reason);
594
689
  else if (code !== 0)
@@ -608,6 +703,7 @@ async function cancellableTipBattery(intWt, commands, runDir, baseline, signal)
608
703
  }
609
704
  finally {
610
705
  signal.removeEventListener("abort", cancel);
706
+ await Promise.allSettled(leases);
611
707
  }
612
708
  }
613
709
  /**
@@ -662,9 +758,10 @@ export async function verifyIntegrationTipCached(intWt, commands, journal, opts
662
758
  for (const r of await (opts.signal
663
759
  ? cancellableTipBattery(intWt, commands, journal.dir, opts.baseline, opts.signal)
664
760
  : verifyIntegrationTip(intWt, commands, journal.dir, opts.baseline))) {
761
+ const measuredCapacity = r.capacity ?? capacity;
665
762
  if (r.pass) {
666
763
  // Q121s: a forgiven pass journals its fingerprints — honest about what was carried, never a silent green.
667
- journal.append("tip-verify", undefined, { gate: r.gate, cmd: r.cmd, pass: true, exitCode: r.exitCode, details: r.details, ...(r.forgiven ? { forgiven: true, fingerprints: r.fingerprints } : {}), tip, cmdHash, capacity });
764
+ journal.append("tip-verify", undefined, { gate: r.gate, cmd: r.cmd, pass: true, exitCode: r.exitCode, details: r.details, ...(r.forgiven ? { forgiven: true, fingerprints: r.fingerprints } : {}), tip, cmdHash, capacity: measuredCapacity });
668
765
  }
669
766
  else {
670
767
  journal.append("tip-verify-failed", undefined, {
@@ -677,7 +774,7 @@ export async function verifyIntegrationTipCached(intWt, commands, journal, opts
677
774
  lastMergedTask: opts.lastMergedTask,
678
775
  tip,
679
776
  cmdHash,
680
- capacity,
777
+ capacity: measuredCapacity,
681
778
  });
682
779
  tipFailed = true;
683
780
  }
@@ -734,7 +831,6 @@ async function observeWorkerProcessTree(marker, cwd) {
734
831
  }
735
832
  return tree.size === 0 ? "empty" : "running";
736
833
  }
737
- const SUITE_COMMAND_RE = /(?:^|[\s/])(vitest(?:\.mjs)?|jest|mocha)(?:[\s/]|$)|\bnpm(?:\s+run)?\s+test\b/i;
738
834
  function processCwd(pid) {
739
835
  try {
740
836
  return realpathSync(readlinkSync(`/proc/${pid}/cwd`));
@@ -802,7 +898,7 @@ export function countLiveSuites(snapshot, repoRoot, daemonPid = process.pid, cwd
802
898
  // runner ("…as a vitest test whose…"), so a finished interactive worker counted as a live suite and
803
899
  // held a task's gates for 9 min 46 s. A runner is named in a command's HEAD — `node <bin>`,
804
900
  // `npm test`, `npx vitest`, `sh -c npm test` — never 140 KB into it: read the first four tokens only.
805
- const candidates = rows.filter((row) => !ancestors.has(row.pid) && SUITE_COMMAND_RE.test(row.command.split(/\s+/, 4).join(" ")));
901
+ const candidates = rows.filter((row) => !ancestors.has(row.pid) && isRunnerCommand(row.command));
806
902
  const attributable = new Set(candidates.filter((row) => {
807
903
  if (descendants.has(row.pid))
808
904
  return true;
@@ -1347,7 +1443,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1347
1443
  const rm = resolveRunMode(repoRoot, { flag: opts.mode, spec: graph.mode, globalDir: opts.globalDir });
1348
1444
  const cfg = rm.cfg;
1349
1445
  // Resolve both budgets once: worker fan-out divides by dispatch concurrency; verification
1350
- // divides by one suite occupying the serialized window. Explicit operator caps still win.
1446
+ // divides by one runner command holding a lease. Explicit operator caps still win.
1351
1447
  const concurrency = opts.concurrency ?? cfg.concurrency;
1352
1448
  return await runWithForkBudget(concurrency, async () => {
1353
1449
  const conservativeCapacity = resolvedCapacity();
@@ -1604,52 +1700,30 @@ export async function runDaemon(repoRoot, opts = {}) {
1604
1700
  journal.append("watch-board-reopen-failed", undefined, { pane: loss.pane, attempt, error: reopened.error, ...(boardless ? { boardless: true } : {}) });
1605
1701
  console.error(`tickmarkr: board not reopened (attempt ${attempt}): ${reopened.error}`);
1606
1702
  };
1607
- // OBS-829/OBS-854: one full-suite verdict round at a time in this run, and do not begin beside an
1608
- // externally live suite attributable to this repository. The process scan catches nested scratch
1609
- // suites through daemon parentage even after cwd stops naming a worktree.
1610
- let suiteChain = Promise.resolve();
1611
- let suitePending = 0;
1612
- const withSuiteWindow = async (taskId, enabled, run, signal) => {
1613
- if (!enabled)
1614
- return run();
1615
- const previous = suiteChain;
1616
- let release;
1617
- suiteChain = new Promise((resolve) => { release = resolve; });
1618
- const queued = suitePending++ > 0;
1619
- if (queued) {
1620
- const count = Math.max(1, await liveSuiteCount(repoRoot));
1621
- journal.append("suite-wait", taskId, { count });
1622
- }
1623
- await previous;
1624
- try {
1625
- let lastCount = -1;
1626
- const startedAt = Date.now();
1627
- for (;;) {
1628
- signal?.throwIfAborted();
1629
- const count = await liveSuiteCount(repoRoot);
1630
- if (count === 0)
1631
- break;
1632
- // OBS-889: a census that never reaches zero held T5's gates with no row and no end. Proceed at
1633
- // the ceiling, flagged: the verdict that follows was produced beside whatever is still counted.
1634
- if (Date.now() - startedAt >= suiteWaitCeilingMs) {
1635
- journal.append("suite-wait-ceiling", taskId, { count, waitedMs: Date.now() - startedAt });
1636
- journal.append("suite-budget", taskId, {
1637
- count, occupancyCap: occupancyCapacity.forkCap, conservativeCap: conservativeCapacity.forkCap,
1638
- });
1639
- return await runWithVerificationBudget(conservativeCapacity, run);
1640
- }
1641
- if (count !== lastCount)
1642
- journal.append("suite-wait", taskId, { count });
1643
- lastCount = count;
1644
- await new Promise((wake) => setTimeout(wake, SUITE_POLL_MS));
1703
+ // Context carries attribution through gates and remote inference; only shell commands acquire.
1704
+ const commandLeases = new CommandLeases();
1705
+ const withCommandContext = (taskId, run, signal) => runWithCommandLease((_command, execute) => commandLeases.run(async () => {
1706
+ let lastCount = -1;
1707
+ const startedAt = Date.now();
1708
+ for (;;) {
1709
+ signal?.throwIfAborted();
1710
+ const count = await liveSuiteCount(repoRoot);
1711
+ if (count === 0)
1712
+ break;
1713
+ if (count !== lastCount)
1714
+ journal.append("suite-wait", taskId, { count });
1715
+ lastCount = count;
1716
+ if (Date.now() - startedAt >= suiteWaitCeilingMs) {
1717
+ journal.append("suite-wait-ceiling", taskId, { count, waitedMs: Date.now() - startedAt });
1718
+ journal.append("suite-budget", taskId, {
1719
+ count, occupancyCap: occupancyCapacity.forkCap, conservativeCap: conservativeCapacity.forkCap,
1720
+ });
1721
+ return await runWithVerificationBudget(conservativeCapacity, execute);
1645
1722
  }
1646
- return await run();
1647
- }
1648
- finally {
1649
- suitePending--;
1650
- release();
1723
+ await new Promise((wake) => setTimeout(wake, SUITE_POLL_MS));
1651
1724
  }
1652
- };
1725
+ return await execute();
1726
+ }, (count) => journal.append("suite-wait", taskId, { count }), SUITE_POLL_MS, signal), run);
1653
1727
  let baseRef;
1654
1728
  let baseline;
1655
1729
  let baselinePending = false;
@@ -1687,6 +1761,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1687
1761
  // The SAME comparator status uses (engagementComparable); one decision, two consumers. Fail closed:
1688
1762
  // no resume path silently accepts a mismatched or unbound journal. --graph-changed is the operator's
1689
1763
  // audited release for the stop-amend-resume workflow, journaling a graph-rehash naming both hashes.
1764
+ graph = applyScopeAmendments(graph, journal, true);
1690
1765
  const loadedHash = graphDefinitionHash(graph);
1691
1766
  const cmp = engagementComparable(journal.read(), loadedHash);
1692
1767
  if (!cmp.comparable) {
@@ -1732,7 +1807,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1732
1807
  baselinePending = true;
1733
1808
  // Workers can run beside capture, but no gate may observe an absent or partial baseline.
1734
1809
  // Keep publication and warnings inside the same barrier as the suite's final verdict.
1735
- baselineCapture = withSuiteWindow(undefined, commands.test !== undefined || commands.tipTest !== undefined, async () => {
1810
+ baselineCapture = withCommandContext(undefined, async () => {
1736
1811
  const captured = await captureBaseline(repoRoot, commands);
1737
1812
  writeFileSync(join(journal.dir, "baseline.json"), JSON.stringify(captured, null, 2));
1738
1813
  baseline = captured;
@@ -1919,15 +1994,20 @@ export async function runDaemon(repoRoot, opts = {}) {
1919
1994
  // worker made. Do not replace the scope gate's stronger attribution with that inference.
1920
1995
  const paths = reds.filter((g) => g.gate !== "scope" || !g.meta?.collateral).flatMap((g) => namedPaths(g.details));
1921
1996
  if (refusal.length || (paths.length > 0 && paths.every((path) => !allowed(path)))) {
1922
- journal.append("scope-authoring", t.id, {
1997
+ const classification = {
1923
1998
  gate: refusal.length ? "worker" : reds[0]?.gate,
1924
1999
  paths: [...new Set(refusal.length ? refusal : paths)],
1925
2000
  repair: `files[] repair hint: ${[...new Set(refusal.length ? refusal : paths)].join(", ")}`,
1926
2001
  attempt: attempts + 1, chargeable: false, source: "diagnostic",
1927
- });
2002
+ };
2003
+ if (refusal.length)
2004
+ journal.append("scope-request", t.id, classification);
2005
+ else
2006
+ journal.append("scope-authoring", t.id, classification);
1928
2007
  // OBS-547 (Leg-2 T3 material): unchargeable ⇒ metered 0, exactly as the predicted path below —
1929
2008
  // passing the physical count would write `meteredAttempts: 1` beside `attempts: 0`.
1930
- await park(t, `authoring defect — files[] repair hint: ${[...new Set(refusal.length ? refusal : paths)].join(", ")}; current files[]: ${t.files.join(", ")}`, "authoring", assignment, attempts, startMs, gateFails, consults, tokens, 0, retryMode);
2009
+ const approveCommand = `tickmarkr approve ${runId} ${t.id} --files ${[...new Set(refusal)].map((path) => /^[\w./-]+$/.test(path) ? path : shq(path)).join(",")}`;
2010
+ await park(t, `${refusal.length ? "scope request" : "authoring defect"} — files[] repair hint: ${[...new Set(refusal.length ? refusal : paths)].join(", ")}; current files[]: ${t.files.join(", ")}`, refusal.length ? "scope-request" : "authoring", assignment, attempts, startMs, gateFails, consults, tokens, 0, retryMode, refusal.length ? { paths: [...new Set(refusal)], approveCommand, graphDefinitionHash: graphDefinitionHash(graph) } : {});
1931
2011
  return true;
1932
2012
  }
1933
2013
  }
@@ -1967,6 +2047,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1967
2047
  approvalSweepCursor = events.length;
1968
2048
  if (approvals.length === 0)
1969
2049
  return;
2050
+ graph = applyScopeAmendments(graph, journal);
1970
2051
  resume = journal.replayResumeState();
1971
2052
  satisfiedGates = journal.replaySatisfiedGates();
1972
2053
  replayedGateResults = resumeLifecycleOpen
@@ -2626,7 +2707,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2626
2707
  await trackedDriver.project?.(t.id, "in-review");
2627
2708
  await waitForBaseline(t.id);
2628
2709
  journal.phaseStart(t.id, "gates");
2629
- const { results } = await withSuiteWindow(t.id, resumedTask.gates.includes("test") && commands.test !== undefined, () => runReviewRecovery(resumedTask, {
2710
+ const { results } = await withCommandContext(t.id, () => runReviewRecovery(resumedTask, {
2630
2711
  carriedFindings: outstandingReviewFindings(journal.read(), t.id),
2631
2712
  worktree: wt, baseRef: taskBase, result: priorResult, author: gateAuthor,
2632
2713
  commands, baseline, channels: pools.review, judgeChannels: pools.judge, adapters, cfg, artifactDir: journal.dir,
@@ -2892,7 +2973,8 @@ export async function runDaemon(repoRoot, opts = {}) {
2892
2973
  feedback = feedback ? `${feedback}\n\n${consultBrief}` : consultBrief;
2893
2974
  }
2894
2975
  }
2895
- retryMode = repairFindings
2976
+ const scopeApproval = [...journaledSoFar].reverse().find((e) => e.taskId === t.id && (e.event === "task-approved" || e.event === "task-dispatch"));
2977
+ retryMode = scopeApproval?.data.release === "scope-request" ? "fresh" : repairFindings
2896
2978
  ? "repair"
2897
2979
  : priorSession
2898
2980
  && priorSession.channel === channelKey(assignment)
@@ -2919,6 +3001,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2919
3001
  // ledger cannot tell a carried dispatch from an amnesiac one — the exact question a run that
2920
3002
  // spends two frontier attempts re-deriving a known defect has to be able to answer afterwards.
2921
3003
  journal.append("task-dispatch", t.id, {
3004
+ ...(scopeApproval?.data.release === "scope-request" ? { files: t.files, graphDefinitionHash: graphDefinitionHash(graph) } : {}),
2922
3005
  assignment, attempt, provenance: dispatchProvenance([
2923
3006
  channelKey(assignment) === channelKey(r.assignment) ? r.provenance
2924
3007
  : t.routingHints?.pin ? `pin ${t.routingHints.pin.via}:${t.routingHints.pin.model} not re-tried` : "ladder assignment",
@@ -3243,6 +3326,12 @@ export async function runDaemon(repoRoot, opts = {}) {
3243
3326
  let processExited = false;
3244
3327
  let earlyLaunchDead = false;
3245
3328
  let startupFailure = false;
3329
+ let startupEvidence;
3330
+ const startupDetector = new StartupFailureDetector(adapter);
3331
+ const startupFailureInWindow = (text, launchedAt) => {
3332
+ startupEvidence ??= startupDetector.sample(text, launchedAt);
3333
+ return startupEvidence !== undefined;
3334
+ };
3246
3335
  let deadWorkerPark;
3247
3336
  let settleParsed;
3248
3337
  const trailerFrames = interactive && adapter.busyFrameMarkers
@@ -4039,6 +4128,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4039
4128
  if (!workerFinished) {
4040
4129
  journal.append("worker-reaped-before-harvest", t.id, {
4041
4130
  slot: slot.name, attempt, cause: workerCause ?? "stall-timeout",
4131
+ ...(startupEvidence ? { evidence: startupEvidence } : {}),
4042
4132
  });
4043
4133
  }
4044
4134
  }
@@ -4374,7 +4464,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4374
4464
  }
4375
4465
  }
4376
4466
  else {
4377
- ({ results, commits } = await withSuiteWindow(t.id, t.gates.includes("test") && commands.test !== undefined, () => runReviewRecovery(t, {
4467
+ ({ results, commits } = await withCommandContext(t.id, () => runReviewRecovery(t, {
4378
4468
  carriedFindings: outstandingFindings,
4379
4469
  worktree: wt, baseRef: taskBase, result, author: assignment,
4380
4470
  commands, baseline, channels: pools.review, judgeChannels: pools.judge, adapters, cfg, artifactDir: journal.dir,
@@ -4767,7 +4857,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4767
4857
  poll = setTimeout(pollApprovals, APPROVAL_POLL_MS);
4768
4858
  let tipFailed;
4769
4859
  try {
4770
- const verification = withSuiteWindow(undefined, commands.test !== undefined || commands.tipTest !== undefined, () => verifyIntegrationTipCached(intWt, commands, journal, { lastMergedTask, baseline, signal: controller.signal }), controller.signal);
4860
+ const verification = withCommandContext(undefined, () => verifyIntegrationTipCached(intWt, commands, journal, { lastMergedTask, baseline, signal: controller.signal }), controller.signal);
4771
4861
  activeTipVerify = { controller, settled: verification.then(() => { }, () => { }) };
4772
4862
  tipFailed = await verification;
4773
4863
  }
package/dist/run/git.d.ts CHANGED
@@ -4,7 +4,7 @@ export { ROUTING_ENV_SEAMS };
4
4
  export declare const FORK_CAP_ENV = "VITEST_MAX_FORKS";
5
5
  export declare const SUITE_PARENT_ENV = "TICKMARKR_SUITE_PARENT";
6
6
  export declare const DEFAULT_FORK_CAP = "6";
7
- /** Verification owns the serialized suite window; workers keep their concurrency budget. */
7
+ /** Verification uses command leases; workers keep their concurrency budget. */
8
8
  export declare const runWithVerificationBudget: <T>(capacity: RunCapacity, fn: () => Promise<T>) => Promise<T>;
9
9
  /**
10
10
  * OBS-618: how many PROCESSES one vitest fork can hold at its peak — the fork, a daemon it spawns,
@@ -89,6 +89,7 @@ export declare const resolvedCapacity: () => RunCapacity;
89
89
  export declare const DEFAULT_SHELL_TIMEOUT_MS = 600000;
90
90
  export interface ShResult {
91
91
  code: number;
92
+ signalExit?: boolean;
92
93
  stdout: string;
93
94
  stderr: string;
94
95
  timedOut?: boolean;
@@ -104,6 +105,14 @@ export declare const setSpawnForTests: (fn: typeof spawn) => void;
104
105
  export declare const resetSpawnForTests: () => void;
105
106
  export declare const SPAWN_ATTEMPT_LIMIT = 4;
106
107
  export declare const SPAWN_RETRY_BACKOFF_MS = 50;
108
+ export interface ShellOptions {
109
+ env?: NodeJS.ProcessEnv;
110
+ signal?: AbortSignal;
111
+ onSpawn?: (pid: number | undefined) => void;
112
+ onTimeout?: () => void;
113
+ }
114
+ /** Shared command seam, including invocation-bound manifested runners. */
115
+ export declare function shell(cmd: string, cwd: string, timeoutMs: number, login?: boolean, options?: ShellOptions): Promise<ShResult>;
107
116
  export declare function sh(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
108
117
  export declare function shGit(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
109
118
  export declare function shOk(cmd: string, cwd: string): Promise<string>;