omp-conductor 0.20.1 → 0.20.2

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.
@@ -52,7 +52,10 @@ export type CodeGraphHealth =
52
52
  active: "active" | "inactive" | "unknown";
53
53
  };
54
54
  refresh: {
55
- result: "success" | "failed" | "unknown";
55
+ /** "running": the oneshot is executing right now (#1090). systemd has
56
+ * already cleared the previous exit timestamp mid-run, so this is a
57
+ * progress report — never a claim about staleness or absence. */
58
+ result: "success" | "failed" | "running" | "unknown";
56
59
  fresh: boolean | null;
57
60
  lastSuccessAt?: string;
58
61
  ageMs?: number;
@@ -208,6 +211,12 @@ function refreshResult(result: ReadOnlyCommandResult, now: number): RefreshResul
208
211
  const serviceResult = fields.get("Result");
209
212
  const exitStatus = fields.get("ExecMainStatus");
210
213
  const completedAt = fields.get("ExecMainExitTimestamp");
214
+ // #1090: an empty ExecMainExitTimestamp says "no completed run yet", which
215
+ // is equally true of a unit that has never run and of one executing right
216
+ // now — systemd clears it when the next run starts. ActiveState comes back
217
+ // from the same single `systemctl show` and separates the two; evidence
218
+ // captured without the property keeps the conservative pre-#1090 reading.
219
+ const activeState = fields.get("ActiveState");
211
220
  if (serviceResult === undefined || exitStatus === undefined || completedAt === undefined) {
212
221
  return unknown("refresh service returned an unrecognized result", true);
213
222
  }
@@ -219,6 +228,12 @@ function refreshResult(result: ReadOnlyCommandResult, now: number): RefreshResul
219
228
  };
220
229
  }
221
230
  if (completedAt === "" || completedAt === "n/a") {
231
+ if (activeState === "activating" || activeState === "active") {
232
+ // There is no completed timestamp to measure staleness against yet, but
233
+ // this is progress, not absence: the reindex is keeping the graph fresh
234
+ // at this moment, so neither the row nor the previous message degrades.
235
+ return { health: { result: "running", fresh: null }, uncertain: false };
236
+ }
222
237
  return unknown("no successful refresh has been recorded", false);
223
238
  }
224
239
  const completedMs = Date.parse(completedAt);
@@ -290,6 +305,7 @@ export async function probeCodeGraph(
290
305
  "--property=Result",
291
306
  "--property=ExecMainStatus",
292
307
  "--property=ExecMainExitTimestamp",
308
+ "--property=ActiveState",
293
309
  ]),
294
310
  ]);
295
311
 
package/src/groom.ts CHANGED
@@ -114,6 +114,17 @@ export interface ReadyGateRejection {
114
114
  * {@link toSpecDurableVerdict} still recovers the verdict and the candidate is
115
115
  * not re-groomed — the spec is rejected, not ungroomed.
116
116
  *
117
+ * That durability is bounded by the verdict itself (#1060). A rejection
118
+ * beside a verdict the contract still accepts is not re-groomable, because
119
+ * the fault lies in the issue — missing criteria, a missing lane — and only
120
+ * an edit to the issue can fix it. A rejection beside a verdict the contract
121
+ * refuses — veltro#731's prose lane entry, persisted before parse-time lane
122
+ * validation — reads as not-durable through {@link toSpecDurableVerdict},
123
+ * and the candidate becomes re-groomable: no issue edit can ever satisfy a
124
+ * gate whose refusal is baked into the verdict, and complying with the
125
+ * refusal's remedy would write the unusable entry into the issue itself, so
126
+ * a fresh pass is the only repair.
127
+ *
117
128
  * Fails closed on anything it cannot read: an empty `missing` array is not a
118
129
  * rejection (a gate that found nothing missing passed), and a non-string entry
119
130
  * would render as `undefined` in a digest line an operator is asked to act on.
@@ -3642,14 +3642,20 @@ export async function sendArmNotice(text: string, surface: ArmNoticeSurface): Pr
3642
3642
  throw new Error("no Telegram bot token readable — the arm notice cannot send");
3643
3643
  }
3644
3644
  let topicId: number | undefined;
3645
+ let project: { name: string; workspaceRoot?: string } | undefined;
3645
3646
  if (surface.project !== undefined) {
3646
3647
  try {
3647
- topicId = resolveProjectTopicId(findProject(loadConfig(), surface.project));
3648
+ const p = findProject(loadConfig(), surface.project);
3649
+ topicId = resolveProjectTopicId(p);
3650
+ project = { name: p.name, workspaceRoot: p.workspaceRoot };
3648
3651
  } catch {
3649
3652
  /* no project config — flat chat, exactly like armTicks */
3650
3653
  }
3651
3654
  }
3652
- await sendTelegram(token, channel.owner, text, { topicId });
3655
+ await sendTelegram(token, channel.owner, text, {
3656
+ topicId,
3657
+ ...(project === undefined ? {} : { project }),
3658
+ });
3653
3659
  }
3654
3660
 
3655
3661
  /**
package/src/reports.ts CHANGED
@@ -361,7 +361,10 @@ export function telegramReportSend(p: ProjectConfig): ReportSend {
361
361
  // limit would still be delivered whole (never truncated) — the seam would
362
362
  // simply under-report the extra ids, which is why the split lives at the
363
363
  // caller, not here.
364
- const ids = await sendTelegram(token, chatId, text, { topicId: resolveProjectTopicId(p) });
364
+ const ids = await sendTelegram(token, chatId, text, {
365
+ topicId: resolveProjectTopicId(p),
366
+ project: { name: p.name, workspaceRoot: p.workspaceRoot },
367
+ });
365
368
  return ids[0];
366
369
  };
367
370
  }
package/src/settlement.ts CHANGED
@@ -26,10 +26,12 @@ import {
26
26
  deriveChangedLine,
27
27
  } from "./diff-flags.ts";
28
28
  import {
29
+ EFFECTIVE_BUDGET_SAMPLE_RUNS,
29
30
  SPINNING_CAP_CLASSES,
30
31
  COMPOSE_DEPENDENCY_STARTUP_SIGNATURE,
31
32
  classifyRun,
32
33
  normalise,
34
+ observeTurnBudget,
33
35
  type ClassifyFacts,
34
36
  } from "./failure-class.ts";
35
37
  import { GhPrMissingError } from "./tracker/github.ts";
@@ -1516,6 +1518,13 @@ export async function classifyAndRecover(d: SettlementDeps): Promise<number> {
1516
1518
  // merged is settled here when the settle sweep could not establish identity
1517
1519
  // (#497). Every other exit returns 0.
1518
1520
  let settled = 0;
1521
+ // One read for the whole sweep (#1063): the completed-run sample the
1522
+ // effective turn budget is derived from. Each row is then judged against the
1523
+ // budget *excluding itself*, so a just-killed run's own extreme pace cannot
1524
+ // drag the average it is being explained against.
1525
+ const latencySamples = store.recentLatencySamples(project.name, EFFECTIVE_BUDGET_SAMPLE_RUNS);
1526
+ const observedBudgetFor = (runId: string) =>
1527
+ observeTurnBudget(latencySamples, caps, project.workerModel, runId);
1519
1528
  for (const run of store.runsNeedingClassification(project.name, CLASSIFY_BATCH)) {
1520
1529
  const facts: ClassifyFacts = {};
1521
1530
  let classifiedRun = run;
@@ -1605,8 +1614,12 @@ export async function classifyAndRecover(d: SettlementDeps): Promise<number> {
1605
1614
  continue;
1606
1615
  }
1607
1616
  }
1608
-
1609
- const { cls, recovery, evidence } = classifyRun(classifiedRun, facts, caps);
1617
+ const { cls, recovery, evidence } = classifyRun(
1618
+ classifiedRun,
1619
+ facts,
1620
+ caps,
1621
+ observedBudgetFor(run.id),
1622
+ );
1610
1623
 
1611
1624
  // A healthy green PR is not a failure of any class. Leaving the row
1612
1625
  // unclassified is what keeps it eligible for the sweep on the tick where its
@@ -1695,9 +1708,27 @@ async function recoverRun(
1695
1708
  // candidate admission can never accept — only the failed label comes off,
1696
1709
  // and the exhaustion reaches a human (#490, #348).
1697
1710
  if (cls === "wall-clock-cap-progress") {
1711
+ // Same split the requeue branch applies (#1080): an issue the tracker
1712
+ // reports closed was resolved by another route — a duplicate, a
1713
+ // supersede, a decomposition — so continuing it would dispatch work
1714
+ // nobody asked for. That verdict is terminal: release the lifecycle
1715
+ // label(s) the row could be holding (the kill path swaps in-progress
1716
+ // for failed; a crash between those two writes leaves either) and
1717
+ // stamp `recoveredAt`, or `selectUnclassified` re-offers this row on
1718
+ // every pass forever (#1095). An unreadable answer is the tracker
1719
+ // failing to answer, not answering no — it stays transient and retries.
1698
1720
  const state = await tracker.issueState(run.issue).catch(() => undefined);
1721
+ if (state === "closed") {
1722
+ store.enqueueLabelOps(project.name, [
1723
+ { issue: run.issue, op: "remove", label: project.stateLabels.failed },
1724
+ { issue: run.issue, op: "remove", label: inProgress },
1725
+ ]);
1726
+ store.updateRun(run.id, { recoveredAt: Date.now() });
1727
+ log(`#${run.issue} not continued from ${cls}: issue is closed`);
1728
+ return;
1729
+ }
1699
1730
  if (state !== "open") {
1700
- log(`#${run.issue} not continued from ${cls}: issue is ${state ?? "unreadable"}`);
1731
+ log(`#${run.issue} not continued from ${cls}: issue is unreadable (retrying)`);
1701
1732
  return;
1702
1733
  }
1703
1734
  const continuation = store.continuationsFor(project.name, run.issue);
@@ -1732,6 +1763,42 @@ async function recoverRun(
1732
1763
  return;
1733
1764
  }
1734
1765
 
1766
+ // `progress-stall`: the watch settled a hung session (#1086), which keeps
1767
+ // the in-progress label held the way an orphan row does. Work to continue
1768
+ // from hands that label back for a continuation brief; a budget already
1769
+ // spent releases the issue to a human instead of re-offering a candidate
1770
+ // admission can never accept — the same ceiling the orphan path honours.
1771
+ if (cls === "progress-stall") {
1772
+ const state = await tracker.issueState(run.issue).catch(() => undefined);
1773
+ if (state !== "open") {
1774
+ log(`#${run.issue} not continued from ${cls}: issue is ${state ?? "unreadable"}`);
1775
+ return;
1776
+ }
1777
+ const continuation = store.continuationsFor(project.name, run.issue);
1778
+ if (hasContinuationBudget(continuation, caps.maxContinuationsPerIssue)) {
1779
+ await swapToQueue(d, run.issue, inProgress);
1780
+ store.updateRun(run.id, { recoveredAt: Date.now() });
1781
+ log(`#${run.issue} requeued after a progress stall: ${evidence}`);
1782
+ } else {
1783
+ store.enqueueLabelOps(project.name, [{ issue: run.issue, op: "remove", label: inProgress }]);
1784
+ store.updateRun(run.id, { recoveredAt: Date.now() });
1785
+ await safeEscalate(d, {
1786
+ tier: 1,
1787
+ project: project.name,
1788
+ issue: run.issue,
1789
+ summary: `#${run.issue} exhausted its continuation budget on progress stalls`,
1790
+ detail: [
1791
+ `Attempt ${run.attempt} was settled silent with work to continue from, but ${continuation} continuation(s) are already charged against a cap of ${caps.maxContinuationsPerIssue}.`,
1792
+ evidence,
1793
+ `Work to continue: branch ${run.branch} at ${run.headSha ?? run.salvageSha}${run.prUrl === undefined ? "" : ` — ${run.prUrl}`}.`,
1794
+ "Sessions on this issue keep hanging, so another run would burn a worker slot for the same outcome. Re-scope it, raise maxContinuationsPerIssue for it, or finish the remaining work by hand.",
1795
+ ].join("\n"),
1796
+ });
1797
+ log(`#${run.issue} not continued from ${cls}: continuation budget exhausted`);
1798
+ }
1799
+ return;
1800
+ }
1801
+
1735
1802
  // `model-empty-stop`: the provider answered with empty turns until the
1736
1803
  // harness ended the session. The work is not at fault and the row is not a
1737
1804
  // failed attempt (the counters exclude the class), so this hands the queue
@@ -1740,9 +1807,22 @@ async function recoverRun(
1740
1807
  // wall-clock path is: a provider stuck in that state must reach a human
1741
1808
  // instead of consuming the issue forever, one free retry at a time.
1742
1809
  if (cls === "model-empty-stop") {
1810
+ // Same split as the wall-clock branch above (#1080, #1095): a closed
1811
+ // issue is terminal — release the lifecycle label(s), stamp
1812
+ // `recoveredAt`, stop being offered — while an unreadable tracker keeps
1813
+ // the row unstamped and retrying on a later pass.
1743
1814
  const state = await tracker.issueState(run.issue).catch(() => undefined);
1815
+ if (state === "closed") {
1816
+ store.enqueueLabelOps(project.name, [
1817
+ { issue: run.issue, op: "remove", label: project.stateLabels.failed },
1818
+ { issue: run.issue, op: "remove", label: inProgress },
1819
+ ]);
1820
+ store.updateRun(run.id, { recoveredAt: Date.now() });
1821
+ log(`#${run.issue} not continued from ${cls}: issue is closed`);
1822
+ return;
1823
+ }
1744
1824
  if (state !== "open") {
1745
- log(`#${run.issue} not continued from ${cls}: issue is ${state ?? "unreadable"}`);
1825
+ log(`#${run.issue} not continued from ${cls}: issue is unreadable (retrying)`);
1746
1826
  return;
1747
1827
  }
1748
1828
  const continuation = store.continuationsFor(project.name, run.issue);
@@ -1871,11 +1951,22 @@ async function recoverRun(
1871
1951
  return;
1872
1952
  }
1873
1953
  // Only when the tracker still shows this issue as ours to hand back. An
1874
- // issue that is closed, or has no state label, was resolved by another route
1875
- // and requeueing it would dispatch work nobody asked for.
1954
+ // issue that is closed was resolved by another route and requeueing it
1955
+ // would dispatch work nobody asked for — so that verdict is terminal
1956
+ // (#1080): release the dispatcher-owned in-progress label and stamp
1957
+ // `recoveredAt`, exactly like the operator-withdrawal branch below, or the
1958
+ // sweep re-offers this row on every pass forever. An unreadable answer is
1959
+ // a different thing in kind — the tracker failing to answer, not answering
1960
+ // no — so it stays transient and keeps retrying.
1876
1961
  const state = await tracker.issueState(run.issue).catch(() => undefined);
1962
+ if (state === "closed") {
1963
+ store.enqueueLabelOps(project.name, [{ issue: run.issue, op: "remove", label: inProgress }]);
1964
+ store.updateRun(run.id, { recoveredAt: Date.now() });
1965
+ log(`#${run.issue} not requeued from ${cls}: issue is closed`);
1966
+ return;
1967
+ }
1877
1968
  if (state !== "open") {
1878
- log(`#${run.issue} not requeued from ${cls}: issue is ${state ?? "unreadable"}`);
1969
+ log(`#${run.issue} not requeued from ${cls}: issue is unreadable (retrying)`);
1879
1970
  return;
1880
1971
  }
1881
1972
  // A clean orphan whose queue label is already absent is a deliberate
@@ -490,6 +490,9 @@ export function formatFleetStatus(
490
490
  lastStop: DaemonStop | undefined = undefined,
491
491
  siblings: { project: string; live: number }[] = [],
492
492
  grooming: string | undefined = undefined,
493
+ /** One line naming the flat-chat deliveries this project's stale pin caused
494
+ * (#1094), or undefined when there is nothing outstanding. */
495
+ misrouteNote: string | undefined = undefined,
493
496
  ): string {
494
497
  const tickLine =
495
498
  layers.ticksDetail === undefined
@@ -595,6 +598,7 @@ export function formatFleetStatus(
595
598
  recoveryLine,
596
599
  herdrLine,
597
600
  telegramLine,
601
+ ...(misrouteNote === undefined ? [] : [misrouteNote]),
598
602
  ...(brief === undefined ? [] : [brief]),
599
603
  ...(decisions === undefined ? [] : [decisions]),
600
604
  ...(failureClasses === undefined ? [] : [failureClasses]),
@@ -811,6 +815,18 @@ export function formatReviewCorrections(rounds: readonly ReviewCorrectionRound[]
811
815
  });
812
816
  }
813
817
 
818
+ /**
819
+ * The #1063 effective-turn-budget annotation for the `new worker turns` row:
820
+ * shown whenever observed per-turn latency makes the configured ceiling
821
+ * unreachable at all — the break-even pace is exactly wall clock ÷ max turns,
822
+ * so anything slower is material, anything faster is noise.
823
+ */
824
+ function effectiveTurnBudgetSuffix(s: StatusSnapshot): string {
825
+ const observed = s.workerTurnBudgetObserved;
826
+ if (observed === undefined || observed.effectiveTurns >= s.caps.workerMaxTurns) return "";
827
+ return ` (effective ~${observed.effectiveTurns} at ${observed.minutesPerTurn.toFixed(2)} min/turn, last ${observed.sampleSize} runs)`;
828
+ }
829
+
814
830
 
815
831
  function formatProjectBody(
816
832
  s: StatusSnapshot,
@@ -916,7 +932,7 @@ function formatProjectBody(
916
932
  : `${Math.max(1, Math.round(s.labelOps.oldestAgeMs / 1000))}s`
917
933
  })`,
918
934
  ]),
919
- ` new worker turns ${s.caps.workerMaxTurns}`,
935
+ ` new worker turns ${s.caps.workerMaxTurns}${effectiveTurnBudgetSuffix(s)}`,
920
936
  ...s.turnOverrides.map(
921
937
  ({ issue, maxTurns }) => ` turn override #${issue} → ${maxTurns} (next attempt)`,
922
938
  ),
@@ -1082,6 +1098,15 @@ function formatProjectBody(
1082
1098
  // the phase the line is naming, and a number attributed to the wrong phase
1083
1099
  // is worse than no number. The attempt's own cap remains visible as
1084
1100
  // `N/M turns cumulative`.
1101
+ // Transcript silence (#1086), from the progress watch's recorded write
1102
+ // instant — read off the row, never probed at render time (#919). This
1103
+ // is where a stall is visible forming, before the pass settles it.
1104
+ // Skipped under pause, whose silence is the pause working (#938), and
1105
+ // for rows no longer live, whose transcripts have stopped forever.
1106
+ const silent =
1107
+ paused || r.state !== "running" || r.lastProgressAt === undefined
1108
+ ? ""
1109
+ : ` silent ${formatDownDuration(Math.max(0, now - r.lastProgressAt))}`;
1085
1110
  const progress = paused
1086
1111
  ? ""
1087
1112
  : round !== undefined
@@ -1093,7 +1118,7 @@ function formatProjectBody(
1093
1118
  ).toFixed(1)} turns/min avg` +
1094
1119
  ` projects ${Math.round(
1095
1120
  (r.turns * s.caps.workerWallClockMs) / Math.max(elapsedMs, 1_000),
1096
- )} turns at cap`;
1121
+ )} turns at cap` + silent;
1097
1122
  lines.push(
1098
1123
  ` #${r.issue} ${r.repo} ${state} attempt ${r.attempt} ` +
1099
1124
  `${r.turns}/${r.maxTurns} turns${progress} ${r.spendUsd.toFixed(2)} ${r.branch}` +
package/src/store.ts CHANGED
@@ -221,6 +221,7 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
221
221
  failureCharges: true,
222
222
  continuationCharges: true,
223
223
  terminalEvidence: true,
224
+ lastProgressAt: true,
224
225
  };
225
226
 
226
227
  /** Everything SQLite will accept from us. */
@@ -281,6 +282,8 @@ interface RunRow {
281
282
  graphTools: string | null;
282
283
  failureCharges: number | null;
283
284
  continuationCharges: number | null;
285
+ /** NULL for a row the progress pass has not observed, or one with no transcript yet (#1086). */
286
+ lastProgressAt: number | null;
284
287
  }
285
288
 
286
289
  /** The `base_health` table exactly as SQLite hands it back. */
@@ -887,7 +890,11 @@ CREATE TABLE IF NOT EXISTS runs (
887
890
  model TEXT,
888
891
  graphTools TEXT,
889
892
  failureCharges INTEGER,
890
- continuationCharges INTEGER
893
+ continuationCharges INTEGER,
894
+ -- When the run's transcript was last observed to grow, as epoch ms (#1086).
895
+ -- Written by the daemon's progress watch from the file's own mtime; NULL
896
+ -- reads as "not observed yet", never "no progress".
897
+ lastProgressAt INTEGER
891
898
  );
892
899
  CREATE INDEX IF NOT EXISTS runs_project_issue ON runs (project, issue);
893
900
  CREATE INDEX IF NOT EXISTS runs_project_state ON runs (project, state);
@@ -1752,6 +1759,7 @@ function toRecord(row: RunRow): RunRecord {
1752
1759
  };
1753
1760
  if (row.spendReservedUsd !== null) record.spendReservedUsd = row.spendReservedUsd;
1754
1761
  if (row.sessionFile !== null) record.sessionFile = row.sessionFile;
1762
+ if (row.lastProgressAt !== null) record.lastProgressAt = row.lastProgressAt;
1755
1763
  if (row.workerPid !== null) record.workerPid = row.workerPid;
1756
1764
  if (row.paneId !== null) record.paneId = row.paneId;
1757
1765
  if (row.paneLabel !== null) record.paneLabel = row.paneLabel;
@@ -2321,6 +2329,12 @@ export function openStore(dbPath: string): Store {
2321
2329
  if (!columns.some((column) => column.name === "terminalEvidence")) {
2322
2330
  db.exec("ALTER TABLE runs ADD COLUMN terminalEvidence TEXT");
2323
2331
  }
2332
+ // The progress watch's last-observed transcript write (#1086). Rows written
2333
+ // before the column were never observed, so NULL is the honest reading — no
2334
+ // backfill, because the instant only the file's own mtime holds.
2335
+ if (!columns.some((column) => column.name === "lastProgressAt")) {
2336
+ db.exec("ALTER TABLE runs ADD COLUMN lastProgressAt INTEGER");
2337
+ }
2324
2338
  // Every row written before #132 is unclassified, and NULL is the honest
2325
2339
  // reading of that: the budget counters below deliberately still count an
2326
2340
  // unclassified terminal row exactly as this release's predecessor did, so an
@@ -2686,8 +2700,8 @@ export function openStore(dbPath: string): Store {
2686
2700
  id, project, issue, repo, branch, worktree, state, attempt, turns,
2687
2701
  maxTurns, spendUsd, spendReservedUsd, sessionFile, resumedFromRunId, lane, prUrl, headSha, mergeSha, baseRef,
2688
2702
  baseCheck, baseCheckAt, salvageSha, salvageError, salvageAckAt, startedAt,
2689
- endedAt, lastError, settlementFlags, report, graphTools
2690
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2703
+ endedAt, lastError, settlementFlags, report, graphTools, lastProgressAt
2704
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2691
2705
  );
2692
2706
  const selectRun = db.query<RunRow, [string]>(`SELECT * FROM runs WHERE id = ?`);
2693
2707
  const selectGraphToolsObs = db.query<{ graphTools: string }, [string]>(
@@ -3070,6 +3084,16 @@ export function openStore(dbPath: string): Store {
3070
3084
  ORDER BY endedAt DESC, rowid DESC
3071
3085
  LIMIT ?`,
3072
3086
  );
3087
+ // Newest runs that can yield a per-turn pace (#1063): rows with turns and
3088
+ // elapsed time recorded. Same over-sample discipline as the spend query
3089
+ // above — the caller filters by model attribution, so this stays unfiltered
3090
+ // beyond what no row can answer without.
3091
+ const selectLatencySamples = db.query<RunRow, [string, number]>(
3092
+ `SELECT * FROM runs
3093
+ WHERE project = ? AND turns > 0 AND endedAt IS NOT NULL
3094
+ ORDER BY endedAt DESC, rowid DESC
3095
+ LIMIT ?`,
3096
+ );
3073
3097
  const selectFailureClassCounts = db.query<{ cls: string; n: number }, [string]>(
3074
3098
  `SELECT failureClass AS cls, COUNT(*) AS n FROM runs
3075
3099
  WHERE project = ? AND failureClass IS NOT NULL AND recoveredAt IS NULL
@@ -4481,11 +4505,12 @@ export function openStore(dbPath: string): Store {
4481
4505
  },
4482
4506
  );
4483
4507
  // The states the review verb admits and the dispatch pass may therefore
4484
- // claim (#795): a settled green run, or a capped/failed run that pushed one
4485
- // (`failed` / `killed`). The terminal set is closed on purpose a live row
4486
- // (`running` / `claimed`) is already doing its own work, a `pushed-pending`
4487
- // PR is not green yet, and a `blocked` / `orphaned` / `stopped` / `merged`
4488
- // row is not work the orchestrator returned for revision.
4508
+ // claim (#795, #1101): a settled green run, a capped/failed run that pushed
4509
+ // one (`failed` / `killed`), and since #1101 a `stopped` run that had
4510
+ // already pushed when the operator stopped it. The set is closed on purpose
4511
+ // a live row (`running` / `claimed`) is already doing its own work, a
4512
+ // `pushed-pending` PR is not green yet, and a `blocked` / `orphaned` /
4513
+ // `merged` row is not work the orchestrator returned for revision.
4489
4514
  //
4490
4515
  // The terminal leg does two things in the same atomic statement (#795 review
4491
4516
  // rounds 1-2): it PRESERVES the budget charge the row's terminal event was
@@ -4522,13 +4547,21 @@ export function openStore(dbPath: string): Store {
4522
4547
  const claimRunForReviewGreen = db.query<unknown, [string]>(
4523
4548
  `UPDATE runs SET state = 'running', endedAt = NULL WHERE id = ? AND state = 'pushed-green'`,
4524
4549
  );
4525
- // Exactly one leg can match (the row is either terminal or pushed-green at
4526
- // the moment of the claim), a repeated claim matches neither, and the two
4527
- // statements plus the read of `changes` are one transaction the dispatch
4528
- // pass can never see a half-claimed row.
4550
+ // The stopped leg (#1101): reclaiming an operator-stopped run spends no
4551
+ // budget an operator stop never charged one and `prUrl IS NOT NULL`
4552
+ // keeps a stop before any push unclaimable: only a run whose row records
4553
+ // the reviewed PR may be woken for its revision.
4554
+ const claimRunForReviewStopped = db.query<unknown, [string]>(
4555
+ `UPDATE runs SET state = 'running', endedAt = NULL WHERE id = ? AND state = 'stopped' AND prUrl IS NOT NULL`,
4556
+ );
4557
+ // Exactly one leg can match (the row is in exactly one state at the moment
4558
+ // of the claim), a repeated claim matches none, and the three statements
4559
+ // plus the read of `changes` are one transaction — the dispatch pass can
4560
+ // never see a half-claimed row.
4529
4561
  const claimRunForReviewTx = db.transaction((runId: string): boolean => {
4530
4562
  if (claimRunForReviewTerminal.run(runId).changes > 0) return true;
4531
- return claimRunForReviewGreen.run(runId).changes > 0;
4563
+ if (claimRunForReviewGreen.run(runId).changes > 0) return true;
4564
+ return claimRunForReviewStopped.run(runId).changes > 0;
4532
4565
  });
4533
4566
 
4534
4567
  // Appending is a single-statement concatenation so even a write from a
@@ -4772,6 +4805,7 @@ export function openStore(dbPath: string): Store {
4772
4805
  toSql(record.settlementFlags),
4773
4806
  toSql(record.report),
4774
4807
  toSql(record.graphTools),
4808
+ toSql(record.lastProgressAt),
4775
4809
  );
4776
4810
  return record;
4777
4811
  };
@@ -5889,6 +5923,9 @@ export function openStore(dbPath: string): Store {
5889
5923
  recentSpendSamples(project: string, limit: number): { turns: number; spendUsd: number }[] {
5890
5924
  return selectSpendSamples.all(project, limit).map((row) => ({ ...row }));
5891
5925
  },
5926
+ recentLatencySamples(project: string, limit: number): RunRecord[] {
5927
+ return selectLatencySamples.all(project, limit).map(toRecord);
5928
+ },
5892
5929
 
5893
5930
  failureClassCounts(project: string): { cls: FailureClass; n: number }[] {
5894
5931
  return selectFailureClassCounts
package/src/to-spec.ts CHANGED
@@ -99,6 +99,44 @@ export const TO_SPEC_MAX_SOURCE_AGE_MS = 24 * 60 * 60 * 1000;
99
99
  /** `routing: "MULTI"` requires `routingSplit` naming one repo per slice. */
100
100
  const MULTI_ROUTING = "MULTI" as const;
101
101
 
102
+ /**
103
+ * One write-lane entry (#1060): a plausible repository-relative path.
104
+ *
105
+ * A lane entry that is prose — veltro#731's
106
+ * `"docker-compose.dev.yml (only if …)"` parsed as one non-empty string —
107
+ * used to harden into the durable verdict and brick its issue: the ready
108
+ * gate then refuses every promotion whose issue-side lane disagrees, a
109
+ * gate-rejected candidate is deliberately never re-groomed, and the
110
+ * refusal's own remedy (edit the issue to match) would have written the
111
+ * parenthetical into the issue. The grammar is therefore enforced here, at
112
+ * parse time, where a refusal is `malformed`, the retry cooldown can act on
113
+ * it, and nothing unusable becomes an admission contract. Deliberately
114
+ * narrow so no valid lane is newly rejected: dots, hyphens, nested
115
+ * directories, globs and trailing slashes all pass; only a leading `/`
116
+ * (absolute) or whitespace/parentheses inside the entry (prose) is refused.
117
+ */
118
+ const LANE_ENTRY_PROSE = /[\s()]/;
119
+
120
+ /** Every problem with one write lane's entries, each naming the offending
121
+ * entry verbatim (`field[index] "…"`), in array order — the detail a repair
122
+ * round hands back and a refusal records must be readable against the next
123
+ * pass. Empty for a usable lane. */
124
+ function laneEntryProblems(field: string, entries: readonly string[]): string[] {
125
+ const problems: string[] = [];
126
+ for (const [index, entry] of entries.entries()) {
127
+ if (entry.startsWith("/")) {
128
+ problems.push(
129
+ `${field}[${index}] ${JSON.stringify(entry)} is absolute — a write lane names repository-relative paths`,
130
+ );
131
+ } else if (LANE_ENTRY_PROSE.test(entry)) {
132
+ problems.push(
133
+ `${field}[${index}] ${JSON.stringify(entry)} is not a path — write-lane entries carry no whitespace or parentheses`,
134
+ );
135
+ }
136
+ }
137
+ return problems;
138
+ }
139
+
102
140
  const ToSpecSourceSchema = z
103
141
  .object({
104
142
  name: z.string().trim().min(1).describe("The authoritative source that was read: repo or tracker, e.g. `TerrifiedBug/conductor`."),
@@ -146,6 +184,14 @@ const ToSpecDecompositionChildSchema = z
146
184
  .describe("The focused commands that prove this child, each with its cwd when it matters."),
147
185
  })
148
186
  .strict()
187
+ .superRefine((child, ctx) => {
188
+ // #1060: a decomposition child carries the identical admission contract,
189
+ // so its write lane obeys the identical grammar — prose here would
190
+ // harden into a child slice nothing can file.
191
+ for (const problem of laneEntryProblems("writeLane", child.writeLane)) {
192
+ ctx.addIssue({ code: "custom", message: problem });
193
+ }
194
+ })
149
195
  .describe("One ordered child slice a decomposition proposal names (#1041).");
150
196
 
151
197
  /**
@@ -309,6 +355,12 @@ const ToSpecResultSchema = z
309
355
  if (value.routing !== MULTI_ROUTING && value.routingSplit !== undefined) {
310
356
  ctx.addIssue({ code: "custom", message: "routingSplit is only valid with routing `MULTI`" });
311
357
  }
358
+ // #1060: the lane is the admission contract, so its entries must be
359
+ // plausible repository-relative paths — refused here, at parse time,
360
+ // rather than hardening into a durable verdict no promotion can use.
361
+ for (const problem of laneEntryProblems("fileLane", value.fileLane)) {
362
+ ctx.addIssue({ code: "custom", message: problem });
363
+ }
312
364
  })
313
365
  .describe("A complete, to-spec grooming result; nothing outside this shape is accepted.");
314
366