omp-conductor 0.20.1 → 0.20.3

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.
package/src/ready-gate.ts CHANGED
@@ -95,6 +95,65 @@ export function acceptanceCriteriaSection(text: string): { heading: string; crit
95
95
  return { heading: lines[start]!.trim(), criteria };
96
96
  }
97
97
 
98
+ /** The leading run of backticked tokens in a bullet: `` `a.ts`, `b/c.ts` ``
99
+ * reads as two paths, while a backticked token after prose is prose, never a
100
+ * declaration. Mirrors admission's private helper of the same shape — the
101
+ * gate cannot import it, and a second grammar beside the first would make
102
+ * issues that render correctly for one parse wrongly for the other. */
103
+ function leadingBacktickedRun(text: string): string[] {
104
+ const trimmed = text.trim();
105
+ if (!trimmed.startsWith("`")) return [];
106
+ const run: string[] = [];
107
+ let pos = 0;
108
+ while (true) {
109
+ if (trimmed[pos] !== "`") break;
110
+ const close = trimmed.indexOf("`", pos + 1);
111
+ if (close < 0) break;
112
+ const token = trimmed.slice(pos + 1, close).trim();
113
+ if (token === "") break;
114
+ run.push(token);
115
+ pos = close + 1;
116
+ const sep = /^[,\s]+/.exec(trimmed.slice(pos));
117
+ if (sep === null) break;
118
+ pos += sep[0].length;
119
+ }
120
+ return run;
121
+ }
122
+
123
+ /**
124
+ * The read-only entry points an issue declares: files its worker must read —
125
+ * the caller to understand, the file that proves the convention — but never
126
+ * write (#1108). The grammar mirrors the write-lane section: a `#`-to-`######`
127
+ * heading spelling `Read-only` optionally followed by `entry points`, an
128
+ * optional trailing colon, then the contiguous bullet run any non-bullet line
129
+ * ends; each bullet contributes its leading backticked run, so an issue that
130
+ * renders correctly for the lane sections renders correctly here too. Unlike
131
+ * the lane there is no inline form and no bare-token fallback: marking a file
132
+ * read-only is a deliberate act, and the backticked bullet is the whole shape.
133
+ *
134
+ * Every occurrence in the text counts, where a lane declaration replaces:
135
+ * read-only lists accumulate, so a correction comment adds references without
136
+ * restating the body's.
137
+ */
138
+ export function readOnlyEntryPoints(text: string): string[] {
139
+ const files: string[] = [];
140
+ let inSection = false;
141
+ for (const line of text.split("\n")) {
142
+ if (/^[ \t]*#{1,6}[ \t]+read[-\s]only(?:[ \t]+entry[ \t]+points)?[ \t]*[:.]?[ \t]*$/i.test(line)) {
143
+ inSection = true;
144
+ continue;
145
+ }
146
+ if (!inSection || line.trim() === "") continue;
147
+ const marker = line.match(/^[ \t]*(?:[-*+]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?/);
148
+ if (marker === null) {
149
+ inSection = false;
150
+ continue;
151
+ }
152
+ files.push(...leadingBacktickedRun(line.slice(marker[0].length)));
153
+ }
154
+ return files;
155
+ }
156
+
98
157
  /** Trimmed non-empty strings out of a value the type system claims is a string
99
158
  * array. Defensive because the gate is the last thing between a hand-edited
100
159
  * store row and a dispatched worker: a malformed field is a refusal, never a
@@ -114,7 +173,7 @@ function strings(value: unknown): string[] {
114
173
  * May this verdict be promoted mechanically?
115
174
  *
116
175
  * Every check is one named miss. The order is fixed — verdict, readability,
117
- * acceptance criteria, write lane, proof commands, sizing evidence,
176
+ * acceptance criteria, write lane, entry points, proof commands, sizing evidence,
118
177
  * dependencies, premise, routing, state labels — so the same input always
119
178
  * produces the same list and a digest line is stable across ticks.
120
179
  *
@@ -181,6 +240,7 @@ export function readyGate(input: ReadyGateInput): ReadyGateVerdict {
181
240
  ? undefined
182
241
  : (writeLaneSectionHeading(body) ??
183
242
  comments.map((comment) => writeLaneSectionHeading(comment.body)).find((found) => found !== undefined));
243
+ const declared = lane === undefined ? [] : [...new Set(strings(lane.files))].sort();
184
244
  if (verdictLane.length === 0) {
185
245
  // The schema requires at least one path, so this is a hand-edited or
186
246
  // corrupt row. An empty lane on both sides would otherwise *match*, and
@@ -190,11 +250,46 @@ export function readyGate(input: ReadyGateInput): ReadyGateVerdict {
190
250
  missing.push(
191
251
  `the write-lane section (${heading}) parsed no path-like files — name them as backticked bullets directly under the heading`,
192
252
  );
193
- } else {
194
- const declared = lane === undefined ? [] : [...new Set(strings(lane.files))].sort();
195
- if (declared.length !== verdictLane.length || declared.some((path, index) => path !== verdictLane[index])) {
253
+ } else if (declared.length !== verdictLane.length || declared.some((path, index) => path !== verdictLane[index])) {
254
+ missing.push(
255
+ `the issue's write lane [${declared.join(", ")}] disagrees with the verdict's file lane [${verdictLane.join(", ")}]`,
256
+ );
257
+ }
258
+
259
+ // An entry point outside the lane is usually legitimate — the reference
260
+ // file, the caller to understand first — so the gate refuses the *unstated*
261
+ // one, not the idea (#1108): eight settlement flags in one day were lane
262
+ // escapes, several into files the issue itself had already named as an
263
+ // entry point. Putting the file in the lane it is meant to be written in,
264
+ // or listing it under a `## Read-only entry points` heading, both satisfy
265
+ // the check; a hard refusal for every reference would push promoters into
266
+ // stopping the one field workers most need.
267
+ //
268
+ // Judged only when the two lane lists agree: a disagreement is refused
269
+ // anyway, and entry points computed against a lane about to be rewritten
270
+ // would be noise stacked on the actionable fact. No parsed lane means
271
+ // nothing to stand outside of.
272
+ const entryPoints = [...new Set(strings(result.entryPoints))];
273
+ const lanesAgree =
274
+ verdictLane.length > 0 &&
275
+ declared.length === verdictLane.length &&
276
+ declared.every((path, index) => path === verdictLane[index]);
277
+ if (entryPoints.length > 0 && lanesAgree) {
278
+ const readOnly = [
279
+ ...new Set([body, ...comments.map((comment) => comment.body)].flatMap((text) => readOnlyEntryPoints(text))),
280
+ ];
281
+ // Exact member or anything under a trailing-slash directory entry — the
282
+ // containment the lane enforcement itself applies to written files
283
+ // (diff-flags.ts:withinLane).
284
+ const escaped = entryPoints.filter(
285
+ (path) =>
286
+ !declared.includes(path) &&
287
+ !declared.some((entry) => entry.endsWith("/") && path.startsWith(entry)) &&
288
+ !readOnly.includes(path),
289
+ );
290
+ if (escaped.length > 0) {
196
291
  missing.push(
197
- `the issue's write lane [${declared.join(", ")}] disagrees with the verdict's file lane [${verdictLane.join(", ")}]`,
292
+ `entry points ${escaped.join(", ")} are neither in the issue's write lane [${declared.join(", ")}] nor marked read-only — put each in the \`## Exact write lane\` bullets meant for writing, or declare it under \`## Read-only entry points\``,
198
293
  );
199
294
  }
200
295
  }
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