omp-conductor 0.20.0 → 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.
package/src/types.ts CHANGED
@@ -90,6 +90,12 @@ export interface Caps {
90
90
  /** Wall-clock ceiling for one worker (90 min): a session that is merely
91
91
  * stuck spends no turns, so turns alone cannot detect it. */
92
92
  workerWallClockMs: number;
93
+ /** How long a live worker's transcript may stay unwritten before the
94
+ * daemon settles it as a progress stall (#1086). `null` derives it from the
95
+ * wall-clock ceiling — one third, so a hung run is caught long before its
96
+ * clock runs out. A single turn can legitimately run for minutes on a slow
97
+ * provider, so this reads *transcript writes*, never the turn counter. */
98
+ workerStallSilenceMs: number | null;
93
99
  /** Failed implementation/CI attempts allowed before escalation. Operational
94
100
  * continuations do not consume this budget. */
95
101
  maxAttemptsPerIssue: number;
@@ -1797,6 +1803,14 @@ export const FAILURE_CLASSES = [
1797
1803
  "dispatch-infra",
1798
1804
  "merge-conflict",
1799
1805
  "question",
1806
+ /** A run that finished its work and stopped because it is waiting on an
1807
+ * observable condition — its settlement names `blockers:` (a PR whose
1808
+ * checks were still pending, say) and no question (#1068). Distinct from
1809
+ * `question` because the remedy is not a human: the recovery is the later
1810
+ * observation, which the settle sweep performs by re-offering the blocked
1811
+ * row until its PR resolves (this fleet's #1062 sat `blocked`/`question`
1812
+ * after its PR merged and its tick kept listing it for Duty 1 triage). */
1813
+ "awaiting-observation",
1800
1814
  "orphan-clean",
1801
1815
  "orphan-dirty",
1802
1816
  "settlement-stuck",
@@ -1823,6 +1837,13 @@ export const FAILURE_CLASSES = [
1823
1837
  * 2026-08-23 failures were this, at 100/180 and 54/180 turns, while a third
1824
1838
  * run on the same model finished green in the same window. */
1825
1839
  "model-empty-stop",
1840
+ /** A live session stopped writing its transcript for longer than the
1841
+ * configured silence window, and the daemon settled it (#1086). Not a size
1842
+ * verdict: the work did not run out of budget, the session simply never
1843
+ * came back — the evidence names how long it was silent and at which turn.
1844
+ * When the attempt had already pushed a green PR, the settle path lands the
1845
+ * row `pushed-green` instead, so landed work is never charged anything. */
1846
+ "progress-stall",
1826
1847
  "unknown",
1827
1848
  ] as const;
1828
1849
 
@@ -1831,21 +1852,27 @@ export type FailureClass = (typeof FAILURE_CLASSES)[number];
1831
1852
  /**
1832
1853
  * What the daemon does about a class.
1833
1854
  *
1834
- * Two of these perform nothing, and they are not the same nothing:
1855
+ * Three of these perform nothing, and they are not the same nothing:
1835
1856
  *
1857
+ * - `observe` — recorded and deliberately left alone, because the recovery IS
1858
+ * the later observation: `awaiting-observation` rows wait on something the
1859
+ * world will resolve (their PR), and the settle sweep re-offers the blocked
1860
+ * row until it does, settling it the way it settles `settlement-stuck` when
1861
+ * the PR merges. Like `hold` it never stamps `recoveredAt` — nothing is
1862
+ * recovered yet, by design (#1068).
1836
1863
  * - `hold` — recorded and deliberately left alone, because acting would destroy
1837
1864
  * something. `orphan-dirty` is the case: the tree is the only copy, so
1838
1865
  * `unblock` refuses rather than re-claiming over it.
1839
1866
  * - `none` — the classifier declining to name a failure at all. Two callers
1840
1867
  * reach it, and neither is "unclassifiable": `classifyRun` returns it for a
1841
- * terminal row whose PR is open and fully green, where `classifyAndRecover`
1868
+ * terminal row whose PR is open and fully green, where `classifyAndRecover`
1842
1869
  * restores `pushed-green` *without* persisting a class (#766); and the settle
1843
1870
  * sweep persists it beside `returned-for-revision` when a reviewer closes
1844
1871
  * pushed work without merging, where the remedy is the queue label the sweep
1845
1872
  * deliberately leaves on.
1846
1873
  *
1847
1874
  * An *unclassifiable* run is `unknown` → `escalate`, and never a silent retry.
1848
- * This comment previously said nothing in the decision table maps to `none`,
1875
+ * This comment previously said nothing in the decision tree maps to `none`,
1849
1876
  * which both callers above contradict; it cost a reading of two modules to
1850
1877
  * discover, which is what a wrong comment costs (#132).
1851
1878
  */
@@ -1855,6 +1882,7 @@ export const RECOVERY_ACTIONS = [
1855
1882
  "rerun-checks",
1856
1883
  "settle",
1857
1884
  "escalate",
1885
+ "observe",
1858
1886
  "hold",
1859
1887
  "none",
1860
1888
  ] as const;
@@ -2116,6 +2144,14 @@ export interface RunRecord {
2116
2144
  autoCompactionCount?: number;
2117
2145
  /** omp session transcript, so a human can read what the worker actually did. */
2118
2146
  sessionFile?: string;
2147
+ /**
2148
+ * When the run's transcript was last observed to grow, as epoch ms (#1086).
2149
+ * Recorded by the daemon's progress watch from the file's own mtime — the
2150
+ * write instant, not the observation instant — and read by `status` to show
2151
+ * a silence interval while the row is live. Absent means the pass has not
2152
+ * looked at this run (or it has no transcript yet), never "no progress".
2153
+ */
2154
+ lastProgressAt?: number;
2119
2155
  /**
2120
2156
  * The exact `session-host` pid this run's Herdr representation was reported
2121
2157
  * for, and the pane Herdr knows it as (#842).
@@ -3357,6 +3393,11 @@ export interface Store {
3357
3393
  * predicate pick the window. Read-only.
3358
3394
  */
3359
3395
  recentSpendSamples(project: string, limit: number): { turns: number; spendUsd: number }[];
3396
+ /** The newest runs carrying a usable per-turn latency (#1063): terminal
3397
+ * rows with turns and elapsed time recorded, newest first. Bounded by
3398
+ * `limit` rows like `recentSpendSamples` — the caller over-samples and lets
3399
+ * its own model-attribution filter pick the window. Read-only. */
3400
+ recentLatencySamples(project: string, limit: number): RunRecord[];
3360
3401
  /** Total tracked `gh` calls between two UTC day keys, inclusive (#198). */
3361
3402
  ghCallsBetween(sinceDay: string, untilDay: string): number;
3362
3403
  /** Idempotence guard so a retry loop cannot page a human repeatedly for the
@@ -4019,6 +4060,11 @@ export const DEFAULT_CAPS: Caps = {
4019
4060
  workerMaxTurns: 120,
4020
4061
  workerMaxTurnsCeiling: 240,
4021
4062
  workerWallClockMs: 90 * 60 * 1000,
4063
+ // Derived as a third of the wall-clock ceiling unless an operator names the
4064
+ // window: a hung run is caught with most of its budget unspent, while a
4065
+ // slow-but-writing session (measured fleet latency has reached
4066
+ // 1.5 min/turn) never trips it (#1086).
4067
+ workerStallSilenceMs: null,
4022
4068
  maxAttemptsPerIssue: 2,
4023
4069
  maxContinuationsPerIssue: 2,
4024
4070
  };
@@ -426,7 +426,8 @@ export function releaseRequirementRefusal(
426
426
  const drain =
427
427
  ". Settle the fleet with a bounded drain: " +
428
428
  `omp-conductor drain start${facts.project === undefined ? "" : ` --project ${facts.project}`} --until <deadline>` +
429
- " — claiming pauses while settlement runs, and admission resumes at the deadline on its own.";
429
+ " — claiming pauses until the deadline even once the fleet goes quiet (an emptied fleet opens" +
430
+ " the window rather than ending the drain early), and admission resumes at the deadline on its own.";
430
431
  return (
431
432
  `policy.release.requires includes ${requirement} and ${facts.unsettledRuns} run(s) have not settled` +
432
433
  (parts.length === 0 ? "" : ` — ${parts.join("; ")}`) +
@@ -2292,22 +2293,25 @@ async function prUpdateVerb(
2292
2293
  */
2293
2294
 
2294
2295
  /**
2295
- * The run states a review revision may start from (#795).
2296
+ * The run states a review revision may start from (#795, #1101).
2296
2297
  *
2297
2298
  * A revision round resumes the exact run whose row owns the PR, so the
2298
- * revisable states are exactly the terminal runs that pushed one: a settled
2299
- * `pushed-green` row, or a `failed` / `killed` row — a run that capped or
2300
- * failed *after* pushing a green PR. The PR is the durable artefact, the
2301
- * exact-head green verification in the verb is the gate on "green at the
2302
- * reviewed SHA", and a terminal row proves no worker is in flight, so the
2303
- * findings are returned without the close-PR unblock continuation dance.
2299
+ * revisable states are exactly the terminal runs that own the named PR: a
2300
+ * settled `pushed-green` row, a `failed` / `killed` row — a run that capped or
2301
+ * failed *after* pushing a green PR and, since #1101, a `stopped` row that
2302
+ * had already pushed when the operator stopped it. Ownership is proved by the
2303
+ * selection itself (`runsForProjectPr` matches rows that recorded the PR), so
2304
+ * a stopped run that never pushed can never reach this gate. Without the
2305
+ * stopped leg a stopped-with-green-PR run was both unreviewable (this verb)
2306
+ * and unreplaceable (the open-PR dispatch guard) at once — closing the PR by
2307
+ * hand was the only exit, which is exactly what #1096 hit.
2304
2308
  *
2305
2309
  * Closed on purpose: a live row (`running` / `claimed`) is already doing its
2306
2310
  * own work, a `pushed-pending` PR is not green yet, and a `blocked` /
2307
- * `orphaned` / `stopped` / `merged` row is not work returned for revision.
2308
- * The set itself is the shared `REVISABLE_RUN_STATES` in `decisions.ts` —
2309
- * the one definition the review verb and the `pr-review-ready` watch gate on,
2310
- * so the two can never drift (#844).
2311
+ * `orphaned` / `merged` row is not work returned for revision. The set itself
2312
+ * is the shared `REVISABLE_RUN_STATES` in `decisions.ts` — the one definition
2313
+ * the review verb and the `pr-review-ready` watch gate on, so the two can
2314
+ * never drift (#844).
2311
2315
  */
2312
2316
 
2313
2317
  async function prReviewVerb(
package/src/worker.ts CHANGED
@@ -15,7 +15,7 @@ import { join } from "node:path";
15
15
  import { knowledgeSection } from "./knowledge.ts";
16
16
  import { createSession, disposeSession, SessionAdmissionClosedError, type AgentSessionLike } from "./omp.ts";
17
17
  import type { GateShape, ReleaseBlockContext } from "./release-policy.ts";
18
- import { TO_SPEC_SCHEMA } from "./to-spec.ts";
18
+ import { gateToSpecPayload, TO_SPEC_SCHEMA, type ToSpecFailure } from "./to-spec.ts";
19
19
  import type { Caps, GraphToolsObservation, ResolvedGrants, RunState } from "./types.ts";
20
20
 
21
21
  /** Structured evidence fields from the worker's final report. */
@@ -1057,7 +1057,18 @@ export async function runAdjudicator(
1057
1057
  }
1058
1058
 
1059
1059
  /**
1060
- * The turn ceiling for one to-spec grooming scout (#1041).
1060
+ * The highest turn count any to-spec pass that finished used, measured across
1061
+ * both projects' grooming ledger on 2026-08-25 (#1064): of the fourteen
1062
+ * groom sessions ever run, the eight that finished verdicts used 10–19 turns
1063
+ * (BLOCKED ×6, PROMOTABLE ×1, NEEDS DECOMPOSITION ×1), while every session
1064
+ * killed at the ceiling was 21+ turns in when the abort fired. The ceiling
1065
+ * and its measurement live here, in one place: the ceiling is the measured
1066
+ * max plus one turn of headroom.
1067
+ */
1068
+ const TO_SPEC_MEASURED_FINISHED_MAX_TURNS = 19;
1069
+
1070
+ /**
1071
+ * The turn ceiling for one to-spec grooming scout (#1041, #1064).
1061
1072
  *
1062
1073
  * Half the adjudicator's 40, and for the opposite reason: an adjudication is
1063
1074
  * handed its whole evidence in the brief (issue, diff, checks, every prior
@@ -1065,28 +1076,48 @@ export async function runAdjudicator(
1065
1076
  * and must go and READ — the source repo at a ref, the entry points, the
1066
1077
  * existing tests, the later work that may have retired the premise. That is
1067
1078
  * search, and search is where a session bounded only by a worker's budget
1068
- * quietly spends a worker's budget. Twenty read/answer turns is enough for a
1069
- * bounded repo walk plus the structured answer; a scout still going after
1070
- * them has stopped grooming one candidate and started exploring the
1071
- * repository, which is exactly the cost the mechanical selection bounds
1072
- * everywhere else.
1079
+ * quietly spends a worker's budget. One turn above the highest measured
1080
+ * finishing pass is enough for a bounded repo walk plus the structured
1081
+ * answer; a scout still going after them has stopped grooming one candidate
1082
+ * and started exploring the repository, which is exactly the cost the
1083
+ * mechanical selection bounds everywhere else.
1073
1084
  */
1074
- export const TO_SPEC_MAX_TURNS = 20;
1085
+ export const TO_SPEC_MAX_TURNS = TO_SPEC_MEASURED_FINISHED_MAX_TURNS + 1;
1075
1086
 
1076
1087
  /** What one to-spec scout produced (#1041). */
1077
1088
  export interface ToSpecScoutResult {
1078
1089
  /**
1079
1090
  * The agent's answer, verbatim and unjudged — the structured payload it
1080
- * yielded, serialized, or its own last text when it yielded nothing. This
1081
- * string is `recordToSpecGrooming`'s `input`, and nothing here reads a field
1082
- * of it: conductor re-validates every result through the strict parser at
1083
- * persistence time, so a runner that pre-parsed would be a second, weaker
1084
- * contract. Empty when the session produced neither.
1091
+ * yielded (through the repair round when the first answer failed), or a
1092
+ * fully conforming block recovered from its own text when it never yielded
1093
+ * (#1064). This string is `recordToSpecGrooming`'s `input`, and nothing
1094
+ * here reads a field of it beyond what the repair round must probe:
1095
+ * conductor re-validates every result through the strict parser at
1096
+ * persistence time, so a runner that judged would be a second, weaker
1097
+ * contract. Empty when the session produced no answer at all — the
1098
+ * narration stays in `report`, and the pass records `no-answer`.
1085
1099
  */
1086
1100
  raw: string;
1087
1101
  /** The session's last words, kept whether or not it answered: for a scout
1088
- * that produced nothing this text IS the evidence of what went wrong. */
1102
+ * that produced nothing this text IS the evidence of what went wrong, and
1103
+ * it is recorded as the refusal's context — never parsed as a verdict
1104
+ * (#1064). */
1089
1105
  report: string;
1106
+ /**
1107
+ * How `raw` arrived (#1064): through the `yield` tool, or recovered from
1108
+ * the session's text because it never yielded (`"text"` is the record's own
1109
+ * admission that the answer did not come through the contract), or `"none"`
1110
+ * when the pass produced no answer at all. Absent on results written before
1111
+ * #1064; callers read a non-empty `raw` as a yield then.
1112
+ */
1113
+ via?: "yield" | "text" | "none";
1114
+ /** Whether the one bounded repair round produced the carried answer
1115
+ * (#1064): the first answer parsed but violated the contract, the
1116
+ * validation error was handed back, and this raw is the re-answer. */
1117
+ repaired?: boolean;
1118
+ /** Whether the turn ceiling aborted the session before it finished — the
1119
+ * cap-kill shape of the no-answer class (#1064). */
1120
+ killedAtCeiling?: boolean;
1090
1121
  /** What actually ran it, for the durable provenance (#875). */
1091
1122
  model?: string;
1092
1123
  provider?: string;
@@ -1115,7 +1146,7 @@ export interface ToSpecScoutOpts {
1115
1146
  }
1116
1147
 
1117
1148
  /**
1118
- * Run one bounded to-spec grooming pass (#1041).
1149
+ * Run one bounded to-spec grooming pass (#1041, #1064).
1119
1150
  *
1120
1151
  * The adjudicator's twin, and read-only by the same construction: no
1121
1152
  * `verbSocketPath`, so no conductor mutation verb — no label change, no
@@ -1124,14 +1155,31 @@ export interface ToSpecScoutOpts {
1124
1155
  * the scout cannot queue the candidate it is grooming, only describe it. The
1125
1156
  * queue label is added later, by the daemon, and only through the ready gate.
1126
1157
  *
1127
- * Two deliberate differences from {@link runAdjudicator}. It carries
1158
+ * Deliberate differences from {@link runAdjudicator}. It carries
1128
1159
  * {@link TO_SPEC_SCHEMA} with `outputSchemaMode: "strict"` — the to-spec
1129
1160
  * contract has always been invoked strictly (#772), and a permissive schema
1130
1161
  * would let a half-filled result through the harness only for the parser to
1131
1162
  * refuse it as malformed one layer later, spending the batch to learn nothing.
1132
- * And it returns the answer UNPARSED: `recordToSpecGrooming` is the single
1133
- * persistence path and the only validator, so a second decode here would be
1134
- * a second contract to keep in sync.
1163
+ * And the answer is carried UNJUDGED: `recordToSpecGrooming` is the single
1164
+ * persistence path and the only validator. The one exception is the repair
1165
+ * round (#1064): a payload that parses but violates the contract is probed
1166
+ * with the same collector parser (`gateToSpecPayload`), the validation error
1167
+ * is prompted back into the session, and the re-answer is carried instead —
1168
+ * a complete, source-verified verdict must not be discarded over a key name.
1169
+ * A probe that refuses a payload is stale narration, never a verdict.
1170
+ *
1171
+ * What comes back goes beyond a plain string:
1172
+ *
1173
+ * - `raw` is ONLY the structured answer — the yield payload, or a fully
1174
+ * conforming block recovered from the session's text when it never
1175
+ * yielded. The runner never substitutes narration for the answer, so a
1176
+ * session that produced no payload returns `raw: ""` and the pass records
1177
+ * `no-answer` rather than grading prose as JSON.
1178
+ * - `via` says how the answer arrived; a text-recovery carries `"text"` so
1179
+ * the durable record admits the answer did not come through the yield
1180
+ * contract even though its verdict may be used.
1181
+ * - `killedAtCeiling` names the turn-ceiling kill, which is what makes the
1182
+ * no-answer shape legible instead of a silent failure.
1135
1183
  */
1136
1184
  export async function runToSpecScout(
1137
1185
  o: ToSpecScoutOpts,
@@ -1162,6 +1210,7 @@ export async function runToSpecScout(
1162
1210
  return {
1163
1211
  raw: "",
1164
1212
  report: err instanceof Error ? err.message : String(err),
1213
+ via: "none",
1165
1214
  turns: 0,
1166
1215
  spendUsd: 0,
1167
1216
  };
@@ -1173,10 +1222,16 @@ export async function runToSpecScout(
1173
1222
  let yielded = "";
1174
1223
  let model: string | undefined;
1175
1224
  let provider: string | undefined;
1176
- const finished = Promise.withResolvers<void>();
1225
+ let killedAtCeiling = false;
1226
+ // Re-armed for the repair round: the completion resolvers serve one prompt
1227
+ // at a time, and the repair prompt completes its own (turn-bounded) window.
1228
+ let finished = Promise.withResolvers<void>();
1177
1229
  session.on("turn_start", () => {
1178
1230
  turns += 1;
1179
- if (turns > o.maxTurns) session.abort();
1231
+ if (turns > o.maxTurns) {
1232
+ killedAtCeiling = true;
1233
+ session.abort();
1234
+ }
1180
1235
  });
1181
1236
  session.on("message_end", (event) => {
1182
1237
  const message = field(event, "message");
@@ -1202,26 +1257,85 @@ export async function runToSpecScout(
1202
1257
  if (shouldComplete(event as { isTerminal?: boolean })) finished.resolve();
1203
1258
  });
1204
1259
  session.on("session_exit", () => finished.resolve());
1260
+
1261
+ /**
1262
+ * What the session produced after one prompt, and whether that answer is a
1263
+ * single re-answer away from the contract (#1064). The newest yield wins
1264
+ * and is carried unjudged; without a yield, a fully conforming fenced block
1265
+ * from the text is carried as the answer (marked `via: "text"`), a
1266
+ * repairable block is NOT carried but its failure is reported so the repair
1267
+ * round can run, and prose is never carried — narration is context, not an
1268
+ * answer.
1269
+ */
1270
+ const inspect = (): {
1271
+ payload: string;
1272
+ via: "yield" | "text" | "none";
1273
+ failure: ToSpecFailure | undefined;
1274
+ } => {
1275
+ if (yielded !== "") {
1276
+ const gate = gateToSpecPayload(yielded);
1277
+ return {
1278
+ payload: yielded,
1279
+ via: "yield",
1280
+ failure: gate.state === "repairable" ? gate.failure : undefined,
1281
+ };
1282
+ }
1283
+ const gate = gateToSpecPayload(report);
1284
+ if (gate.state === "conforming") return { payload: gate.body, via: "text", failure: undefined };
1285
+ if (gate.state === "repairable") return { payload: "", via: "none", failure: gate.failure };
1286
+ return { payload: "", via: "none", failure: undefined };
1287
+ };
1288
+
1289
+ let repaired = false;
1205
1290
  try {
1206
1291
  await session.prompt(o.brief);
1207
1292
  await finished.promise;
1293
+ let answer = inspect();
1294
+ // One bounded repair round (#1064): a payload that parsed but violated
1295
+ // the contract is handed back once with the validation error before any
1296
+ // refusal is persisted. The same turn ceiling still bounds the session —
1297
+ // a cap kill means the session is gone and there is nothing to prompt.
1298
+ if (answer.failure !== undefined && !killedAtCeiling) {
1299
+ repaired = true;
1300
+ finished = Promise.withResolvers<void>();
1301
+ try {
1302
+ await session.prompt(toSpecRepairPrompt(answer.failure.detail));
1303
+ await finished.promise;
1304
+ answer = inspect();
1305
+ } catch {
1306
+ // The session could not take a repair prompt (it had already
1307
+ // exited). Whatever the first round produced stands, unjudged; the
1308
+ // persistence layer refuses it, with the payload kept in the row.
1309
+ }
1310
+ }
1311
+ return {
1312
+ raw: answer.payload,
1313
+ report,
1314
+ via: answer.via,
1315
+ ...(repaired ? { repaired: true } : {}),
1316
+ ...(killedAtCeiling ? { killedAtCeiling: true } : {}),
1317
+ ...(model === undefined ? {} : { model }),
1318
+ ...(provider === undefined ? {} : { provider }),
1319
+ turns,
1320
+ spendUsd,
1321
+ };
1208
1322
  } finally {
1209
1323
  await disposeSession(session);
1210
1324
  }
1211
- return {
1212
- // The yielded payload first: under `requireYieldTool` that IS the answer,
1213
- // and a scout's prose is usually a sentence about it rather than the
1214
- // contract. The text stands in when nothing was yielded, so a session
1215
- // that answered in a fenced JSON block still persists as a verdict and
1216
- // one that only chatted persists as `blocked(malformed)` which is the
1217
- // honest record of a spent pass, not a discard.
1218
- raw: yielded !== "" ? yielded : report,
1219
- report,
1220
- ...(model === undefined ? {} : { model }),
1221
- ...(provider === undefined ? {} : { provider }),
1222
- turns,
1223
- spendUsd,
1224
- };
1325
+ }
1326
+
1327
+ /**
1328
+ * The one repair round's prompt (#1064): what failed and which field, plus
1329
+ * the yield contract the corrected answer must come through. Bounded by the
1330
+ * same remaining turn ceiling as the original pass.
1331
+ */
1332
+ function toSpecRepairPrompt(detail: string): string {
1333
+ return (
1334
+ "Your grooming verdict was received but failed validation and was NOT recorded.\n\n" +
1335
+ `Validation error: ${detail}\n\n` +
1336
+ "This is your one repair round. Yield the complete, corrected verdict payload through the yield tool — " +
1337
+ "an answer written as plain text is not an answer."
1338
+ );
1225
1339
  }
1226
1340
 
1227
1341
  /**