omp-conductor 0.15.13 → 0.16.0

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 (47) hide show
  1. package/REFERENCE.md +72 -2
  2. package/package.json +2 -1
  3. package/schema/config.schema.json +6 -0
  4. package/src/admission.ts +745 -0
  5. package/src/ask.ts +47 -0
  6. package/src/backups.ts +19 -7
  7. package/src/board.ts +1 -2
  8. package/src/briefs/orchestrator.md +62 -4
  9. package/src/cli.ts +26 -0
  10. package/src/commands/context.ts +3 -0
  11. package/src/commands/decision.ts +10 -1
  12. package/src/commands/doctor.ts +2 -0
  13. package/src/commands/message.ts +8 -1
  14. package/src/commands/restart.ts +15 -3
  15. package/src/commands/restore-db.ts +146 -0
  16. package/src/commands/stop.ts +24 -15
  17. package/src/commands/unfreeze.ts +56 -0
  18. package/src/commands/watch.ts +77 -0
  19. package/src/config-schema.ts +9 -0
  20. package/src/config.ts +24 -0
  21. package/src/daemon.ts +239 -530
  22. package/src/dashboard/server.ts +2 -1
  23. package/src/decisions.ts +32 -7
  24. package/src/depends-on.ts +73 -0
  25. package/src/doctor.ts +178 -5
  26. package/src/escalate.ts +114 -15
  27. package/src/failure-class.ts +47 -0
  28. package/src/fleet.ts +41 -410
  29. package/src/gitops.ts +86 -1
  30. package/src/log.ts +40 -0
  31. package/src/model-fallback.ts +3 -2
  32. package/src/omp-settings.ts +114 -0
  33. package/src/omp.ts +39 -0
  34. package/src/orchestrator-tick.ts +7 -1
  35. package/src/reports.ts +124 -12
  36. package/src/session-host.ts +6 -0
  37. package/src/setup-wizard.ts +36 -0
  38. package/src/setup.ts +58 -1
  39. package/src/status-render.ts +445 -0
  40. package/src/stop-provenance.ts +53 -0
  41. package/src/store.ts +352 -11
  42. package/src/types.ts +187 -4
  43. package/src/unblock.ts +1 -1
  44. package/src/upgrade-verify.ts +1 -1
  45. package/src/upgrade.ts +1 -2
  46. package/src/verbs/server.ts +25 -0
  47. package/src/worker.ts +162 -10
package/src/types.ts CHANGED
@@ -657,6 +657,26 @@ export interface ProjectConfig {
657
657
  * when absent or unusable.
658
658
  */
659
659
  modelFallbackThreshold?: number;
660
+ /**
661
+ * An opaque omp settings map layered into every worker session this project
662
+ * dispatches (#537). At dispatch it is materialised verbatim to a fleet-owned
663
+ * YAML overlay under the run's session directory — never inside the worktree,
664
+ * whose diff is a PR — and passed to the session as an omp settings overlay
665
+ * (`Settings.init({ configFiles: [<path>] })`), so it layers on top of both
666
+ * the daemon account's global config and any project `<cwd>/.omp/config.yml`
667
+ * without erasing either.
668
+ *
669
+ * Conductor validates YAML shape only: a non-mapping is dropped at load, and
670
+ * everything inside the map is omp's schema to own — an unknown key is omp's
671
+ * to reject, never conductor's to understand. A config edit takes effect on
672
+ * the next dispatch (the overlay is rewritten on every attempt), and a
673
+ * project without the field dispatches byte-for-byte as it always has.
674
+ *
675
+ * The retry keys derived from {@link modelFallbacks} are merged into the
676
+ * effective overlay (unless the map already names a `retry` mapping), which
677
+ * is where #539's staging lives.
678
+ */
679
+ ompSettings?: Record<string, unknown>;
660
680
  /**
661
681
  * How a stuck run reaches a human, what to do when it cannot, and who runs
662
682
  * the session that triages it. See {@link ORCHESTRATOR_MODES}.
@@ -751,6 +771,10 @@ export interface ConductorConfig {
751
771
  version: typeof CONFIG_VERSION;
752
772
  defaults: Caps;
753
773
  projects: ProjectConfig[];
774
+ /** Absolute directory for restorable `conductor.db` snapshots; defaults to
775
+ * `<stateDir()>/backups/db` when omitted. A snapshot that cannot land there
776
+ * is `doctor`'s `db-backup` failure. */
777
+ dbBackupDir?: string;
754
778
  }
755
779
 
756
780
  /**
@@ -1102,6 +1126,11 @@ export const FAILURE_CLASSES = [
1102
1126
  "settlement-stuck",
1103
1127
  "provider-credit",
1104
1128
  "provider-transient",
1129
+ /** A run drowned in in-session provider rate limits (repeated HTTP 429s) and
1130
+ * never got clear of the throttle before the cap swallowed it. Distinct from
1131
+ * `provider-credit` (402) and `provider-transient` (a single stream fault)
1132
+ * because its remedy is different (#573). */
1133
+ "provider-capacity",
1105
1134
  /** A reviewer closed pushed-green or pushed-pending work without merging it:
1106
1135
  * a review decision, not a worker failure. */
1107
1136
  "returned-for-revision",
@@ -1151,6 +1180,30 @@ export interface BaseHealth {
1151
1180
  checkedAt: number;
1152
1181
  }
1153
1182
 
1183
+ /**
1184
+ * A per-repo merge freeze, set when a watched merge (or live base observation)
1185
+ * turns the base red and lifted mechanically on green or by the operator's
1186
+ * `unfreeze` verb. While `clearedAt` is absent the repo is frozen: `prMergeVerb`
1187
+ * refuses further merges to it with the `base-red-freeze` refusal naming the
1188
+ * suspected culprit. It is repo-scoped, never global, so sibling repos keep
1189
+ * merging while one base is broken.
1190
+ */
1191
+ export interface BaseFreeze {
1192
+ repo: string;
1193
+ /** The merged SHA suspected of breaking the base. */
1194
+ culpritSha: string;
1195
+ /** Failing workflow/run evidence naming the break. */
1196
+ detail?: string;
1197
+ /** When the freeze was set (or last refreshed). */
1198
+ setAt: number;
1199
+ /** When the freeze was lifted — absent means the repo is actively frozen. */
1200
+ clearedAt?: number;
1201
+ /** Who lifted it: `daemon` for mechanical recovery, else the operator. */
1202
+ clearedBy?: string;
1203
+ /** Why it was lifted: `base-green` or the operator's override reason. */
1204
+ clearedReason?: string;
1205
+ }
1206
+
1154
1207
  /**
1155
1208
  * Execution state is separate from the tracker's own labels on purpose: labels
1156
1209
  * are coarse and human-editable, while the loop needs to distinguish "pushed
@@ -1194,6 +1247,39 @@ export interface RunRecord {
1194
1247
  /** Effective turn ceiling for this run; operators may only raise it. */
1195
1248
  maxTurns: number;
1196
1249
  spendUsd: number;
1250
+ /** How many in-session HTTP 429 responses the harness retried before the run
1251
+ * ended, counted by the worker from the session's `message_end` events while
1252
+ * the run was live (#573). Absent means the column predates the count or no
1253
+ * worker reported one — never "no rate limit happened": a healthy run
1254
+ * records 0. The classifier only reads it as "sustained" above a threshold,
1255
+ * so a single retried 429 is noise. */
1256
+ provider429Count?: number;
1257
+ /**
1258
+ * The model that actually wrote this run's messages, read from the newest
1259
+ * assistant `message_end`'s `AssistantMessage.model` (#539, #535 slice 1).
1260
+ * Distinct from {@link RunRecord.model} (what the daemon dispatched on):
1261
+ * this is what the harness resolved and actually used, present even for a run
1262
+ * that never failed over, so "which model wrote this" is answerable without
1263
+ * reading a transcript. Absent means the run predates the column or recorded
1264
+ * no message carrying a model.
1265
+ */
1266
+ resolvedModel?: string;
1267
+ /** The provider that wrote them, from the same message. */
1268
+ resolvedProvider?: string;
1269
+ /**
1270
+ * Every within-run model fallback the harness applied (`retry_fallback_applied`),
1271
+ * newest first. The `to` target is what a settlement report names when a run
1272
+ * swapped providers mid-run instead of dying on a throttled primary (#539).
1273
+ */
1274
+ retryFallbacks?: { from: string; to: string }[];
1275
+ /** `retry_fallback_succeeded` events: within-run fallbacks the harness recovered on. */
1276
+ retryFallbackSucceeded?: number;
1277
+ /** Assistant messages whose `retryRecovery.recovery === "model"` (#539). */
1278
+ modelRecoveries?: number;
1279
+ /** `auto_retry_start` events: in-session provider retries the harness ran. */
1280
+ autoRetryCount?: number;
1281
+ /** `auto_compaction_start` events: in-session context compactions. */
1282
+ autoCompactionCount?: number;
1197
1283
  /** omp session transcript, so a human can read what the worker actually did. */
1198
1284
  sessionFile?: string;
1199
1285
  prUrl?: string;
@@ -1288,6 +1374,8 @@ export type AdmissionHoldReason =
1288
1374
  | "plan-usage-cap"
1289
1375
  | "shutting-down"
1290
1376
  | "stale-base"
1377
+ | "file-lane"
1378
+ | "depends-on"
1291
1379
  | "unroutable:no-repo-label"
1292
1380
  | "unroutable:multiple-repo-labels"
1293
1381
  | "unroutable:unknown-repo";
@@ -1298,6 +1386,14 @@ export interface AdmissionHoldSummary {
1298
1386
  count: number;
1299
1387
  /** Queue-order sample, capped before persistence and rendering. */
1300
1388
  issues: number[];
1389
+ /**
1390
+ * Per-issue detail, aligned by index with {@link AdmissionHoldSummary.issues}
1391
+ * and capped the same way. The file-lane interlock (#555) fills it so an
1392
+ * operator reading status or the digest can see *which file* blocked the
1393
+ * held issue and *which running issue* holds it — the same question the
1394
+ * plain `reason` group answers only with a count.
1395
+ */
1396
+ details?: string[];
1301
1397
  }
1302
1398
 
1303
1399
  /** Persisted outcome of the latest completed dispatch tick. */
@@ -1437,9 +1533,29 @@ export interface ReportRecord {
1437
1533
  updatedAt: number;
1438
1534
  /** Backoff gate: a `pending` row is due only once this has passed. */
1439
1535
  nextAttemptAt: number;
1440
- /** Telegram's own id for the delivered message, read out of the response
1441
- * body rather than inferred from the HTTP status. */
1536
+ /** Telegram's own id for the delivered message, read out of the response
1537
+ * body rather than inferred from the HTTP status. A report split into
1538
+ * several messages records the first part's id here; {@link messageIds}
1539
+ * carries every part. */
1442
1540
  messageId?: number;
1541
+ /** Every Telegram message id a split delivery accepted, in order. Absent
1542
+ * for rows delivered before multi-part sends existed (#566), and whenever
1543
+ * Telegram answered without an id — `ok: true` is the verdict, the id is a
1544
+ * courtesy. */
1545
+ messageIds?: number[];
1546
+ /**
1547
+ * Parts of the current delivery contract already accepted, persisted as the
1548
+ * retry's resume point. The split is a pure function of the message text, so
1549
+ * a count is the whole state: a retry whose message is byte-identical to the
1550
+ * attempt that wrote this watermark (see {@link sentPartsHash}) starts at
1551
+ * part `sentParts` instead of re-sending confirmed parts (#566).
1552
+ */
1553
+ sentParts?: number;
1554
+ /** SHA-256 of the message text the `sentParts` watermark was written under.
1555
+ * Progress is only valid while the text is unchanged — a report flagged
1556
+ * ambiguous re-renders with a possible-repeat banner, which reshapes the
1557
+ * split, so the watermark must not carry across it. */
1558
+ sentPartsHash?: string;
1443
1559
  deliveredAt?: number;
1444
1560
  /** Bounded text of the last known failure, surfaced verbatim by `status`. */
1445
1561
  lastError?: string;
@@ -1555,10 +1671,22 @@ export type DecisionState = (typeof DECISION_STATES)[number];
1555
1671
  */
1556
1672
  export const DECISION_TTL_MS = 7 * 24 * 60 * 60_000;
1557
1673
 
1674
+ /** What a pending decision row is owed — answered by a human, or a condition
1675
+ * the orchestrator set for itself with no human in the loop. The split is a
1676
+ * stored column, never inferred from the wording or from whether a condition
1677
+ * is attached: a real question may legitimately carry a condition ("ask me
1678
+ * once this PR merges"), and a watch is defined by having no human, not by
1679
+ * having one. */
1680
+ export type DecisionKind = "question" | "watch";
1681
+
1558
1682
  /** One question put to the operator, and its answer if it has one. */
1559
1683
  export interface DecisionRecord {
1560
1684
  id: string;
1561
1685
  project: string;
1686
+ /** Whether this row is a question a human must answer or a watch the
1687
+ * orchestrator set for itself. A watch turns up under its own heading and
1688
+ * is never offered to the operator to resolve. */
1689
+ kind: DecisionKind;
1562
1690
  /** The question verbatim, as it was sent. Re-asking must not reword it. */
1563
1691
  question: string;
1564
1692
  /** What is waiting on the answer — an issue, a release, a PR. Free text,
@@ -1583,6 +1711,11 @@ export interface DecisionRecord {
1583
1711
  export interface DecisionDraft {
1584
1712
  project: string;
1585
1713
  question: string;
1714
+ /** `"watch"` marks a row the orchestrator set for itself. Absent means a
1715
+ * question a human must answer — the default, so every existing caller
1716
+ * (`message` with a non-material category, `decision open`) stays a
1717
+ * question without remembering to say so. */
1718
+ kind?: DecisionKind;
1586
1719
  blocks?: string;
1587
1720
  condition?: string;
1588
1721
  at: number;
@@ -1642,6 +1775,35 @@ export interface Store {
1642
1775
  project: string,
1643
1776
  sinceEpochMs: number,
1644
1777
  ): { repo: string; baseRef?: string }[];
1778
+ /** The per-repo base-red freeze row, active or cleared, for one routed repo. */
1779
+ baseFreeze(project: string, repo: string): BaseFreeze | undefined;
1780
+ /** Every base-red freeze row for a project, active first, for `status`. */
1781
+ freezes(project: string): BaseFreeze[];
1782
+ /**
1783
+ * Set or refresh an *active* base-red freeze for one repo, naming the
1784
+ * suspected culprit merge and the failing evidence. Refreshing an
1785
+ * already-active freeze keeps the repo frozen while the same head is still
1786
+ * red. Returns true only when it *newly* froze the repo — a transition worth
1787
+ * surfacing as a material event — and false for a pure refresh so the queue
1788
+ * digest is not flooded by per-tick re-observation of a still-red base.
1789
+ */
1790
+ setBaseFreeze(
1791
+ project: string,
1792
+ freeze: { repo: string; culpritSha: string; detail?: string; setAt?: number },
1793
+ ): boolean;
1794
+ /**
1795
+ * Lift an active freeze — the mechanical green recovery or the operator's
1796
+ * `unfreeze`. Records who lifted it and the ledger reason. Returns true only
1797
+ * when an active freeze was actually lifted (so a recovery/override event is
1798
+ * emitted once, never on an already-clear row).
1799
+ */
1800
+ clearBaseFreeze(
1801
+ project: string,
1802
+ repo: string,
1803
+ by: string,
1804
+ reason: string,
1805
+ at?: number,
1806
+ ): boolean;
1645
1807
  /** Newest attempt per issue that preserved work or failed to, so `status`
1646
1808
  * can name every WIP tip a re-claim would build on and every tree that is
1647
1809
  * still the only copy. */
@@ -1808,8 +1970,21 @@ export interface Store {
1808
1970
  * means somebody else claimed it first — the guard against the same report
1809
1971
  * being concurrently in flight twice. */
1810
1972
  claimReport(id: string, attemptId: string, at: number): ReportRecord | undefined;
1811
- /** Record the message id Telegram returned, and the moment it did. */
1812
- markReportDelivered(id: string, attemptId: string, messageId: number | undefined, at: number): boolean;
1973
+ /** Record the message ids Telegram returned for every accepted part, and
1974
+ * the moment the last one landed. `messageId` (the first part's id) is
1975
+ * derived here, so the two can never disagree. */
1976
+ markReportDelivered(id: string, attemptId: string, messageIds: readonly number[], at: number): boolean;
1977
+ /** Record progress mid-split: the first `sentParts` parts of the delivery
1978
+ * contract fingerprinted by `sentPartsHash` are confirmed accepted. Written
1979
+ * before the next part ships, so a crash in the window can only ever
1980
+ * re-send the part that was in flight, never confirmed ones. */
1981
+ markReportPartsSent(
1982
+ id: string,
1983
+ attemptId: string,
1984
+ sentParts: number,
1985
+ sentPartsHash: string,
1986
+ at: number,
1987
+ ): boolean;
1813
1988
  /** The attempt ended without an answer: Telegram may hold the message. The
1814
1989
  * row *stays* `sending`, because that state means exactly "outcome unknown",
1815
1990
  * and is flagged {@link ReportRecord.ambiguous} so the retry that stale
@@ -2182,6 +2357,14 @@ export const VERB_REFUSALS = [
2182
2357
  "checks-not-green",
2183
2358
  /** Another merge is in flight for this project. */
2184
2359
  "merge-in-flight",
2360
+ /**
2361
+ * The routed repository's base branch is frozen because a watched merge (or
2362
+ * live base observation) turned it red — a base-red-freeze. Further merges to
2363
+ * that repo stay refused until the base is observed green again or the
2364
+ * operator lifts the freeze with `omp-conductor unfreeze <repo>`; sibling
2365
+ * repos are unaffected.
2366
+ */
2367
+ "base-red-freeze",
2185
2368
  /** The label is not in this project's own vocabulary. */
2186
2369
  "label-not-in-vocabulary",
2187
2370
  /** The label is a lifecycle label; those transitions stay the daemon's (#26). */
package/src/unblock.ts CHANGED
@@ -35,7 +35,7 @@
35
35
  * failed-attempt budget.
36
36
  */
37
37
 
38
- import { hasContinuationBudget, hasFailedAttemptBudget } from "./daemon.ts";
38
+ import { hasContinuationBudget, hasFailedAttemptBudget } from "./admission.ts";
39
39
  import { projectLabels } from "./label-projection.ts";
40
40
  import { LIVE_STATES } from "./store.ts";
41
41
  import type { Caps, ProjectConfig, RunRecord, Store, Tracker } from "./types.ts";
@@ -26,7 +26,7 @@ import { DEFAULT_PORT } from "./lifecycle.ts";
26
26
  import { dbPath, openStore } from "./store.ts";
27
27
  import { appendJournal, readUpgradeJournal, upgradeJournalPath, type UpgradeCheck, type UpgradeJournalEntry } from "./upgrade-journal.ts";
28
28
  import type { DoctorReport } from "./doctor.ts";
29
- import type { FleetLayers } from "./fleet.ts";
29
+ import type { FleetLayers } from "./status-render.ts";
30
30
  import type { ReportDraft, ReportKind } from "./types.ts";
31
31
 
32
32
  /** One command run to completion with captured output. The transient unit, the
package/src/upgrade.ts CHANGED
@@ -8,9 +8,8 @@ import {
8
8
  LEGACY_HERDR_SESSION_HINT,
9
9
  resolveHerdrSession,
10
10
  telegramStateDir,
11
- type DispatchLayer,
12
- type FleetLayers,
13
11
  } from "./fleet.ts";
12
+ import type { DispatchLayer, FleetLayers } from "./status-render.ts";
14
13
  import { livingDaemon, restartDaemon } from "./lifecycle.ts";
15
14
  import { configBackupDir, configPath, findProject, loadConfig, resolveCaps, stateDir, writeConfigRaw } from "./config.ts";
16
15
  import { renderBriefForProject } from "./setup.ts";
@@ -884,6 +884,31 @@ async function prMergeVerb(
884
884
  if (paused !== undefined) return paused;
885
885
  }
886
886
 
887
+ // Base-red-freeze gate (#283): a repo whose base is frozen must not accept
888
+ // another merge, no matter how green this PR's own checks look. Deliberately
889
+ // placed here — after target-repo identity resolution, before the merge lock
890
+ // and every `gh` call — so the refusal is a verb-level assertion, not a
891
+ // daemon-side flag the merge path could miss. Only the frozen repo is gated:
892
+ // sibling repos keep merging. The freeze lifts mechanically on a green base
893
+ // re-observation or via the operator's sanctioned `omp-conductor unfreeze
894
+ // <repo>` — never by label surgery or a DB edit.
895
+ const freezeRepo = target === undefined ? routedRepo?.name : target.repo;
896
+ if (freezeRepo !== undefined) {
897
+ const freeze = deps.store.baseFreeze(project.name, freezeRepo);
898
+ if (freeze !== undefined && freeze.clearedAt === undefined) {
899
+ return refuse(
900
+ "base-red-freeze",
901
+ `refused: merges to ${freezeRepo} are frozen because its base is red at ` +
902
+ `${freeze.culpritSha.slice(0, 8)} (base-red-freeze). ${freeze.detail ?? ""} ` +
903
+ "This is not a merge-lock or check verdict: the base that merges land on is broken, so " +
904
+ "`conductor_pr_merge` for this repo is refused until the base is observed green again or the " +
905
+ `operator lifts the freeze with \`omp-conductor unfreeze ${freezeRepo}\`. The override is the sanctioned ` +
906
+ "path — never work around this refusal with label surgery or a DB edit.",
907
+ issue,
908
+ );
909
+ }
910
+ }
911
+
887
912
  // Taken before any network call, so two concurrent callers contend here
888
913
  // rather than both spending a `gh` round trip and racing at the merge.
889
914
  const holderId = randomUUID();
package/src/worker.ts CHANGED
@@ -99,6 +99,15 @@ export interface WorkerOpts {
99
99
  * the harness to pick, which is what an unconfigured project wants.
100
100
  */
101
101
  model?: string;
102
+ /**
103
+ * Absolute path to the fleet-owned omp settings overlay (#537): the YAML the
104
+ * daemon materialised from the project's `ompSettings` map (plus the retry
105
+ * keys derived from `modelFallbacks`, which is where #539's staging lives)
106
+ * under the run's session directory. Forwarded to `createSession`, which
107
+ * loads it through `Settings.init({ configFiles: [<path>] })`. Absent, no
108
+ * settings are staged and dispatch is byte-for-byte what it is today.
109
+ */
110
+ ompSettingsFile?: string;
102
111
  /**
103
112
  * Effective per-shape release grants for this session. A worker is refused
104
113
  * every shape whatever they say — see {@link SessionRole} — so this is passed
@@ -169,6 +178,37 @@ export interface WorkerResult {
169
178
  turns: number;
170
179
  spendUsd: number;
171
180
  report: string;
181
+ /** In-session HTTP 429 responses the session recorded (stopReason "error",
182
+ * errorStatus 429), counted as the messages streamed in. A healthy run
183
+ * reports 0; a run the harness retried through a barrel of rate limits
184
+ * carries the number, which is what distinguishes provider-capacity (the
185
+ * provider was throttling all along) from an ordinary failure (#573). */
186
+ provider429Count: number;
187
+ /**
188
+ * The model that actually wrote this run's messages, read from
189
+ * `AssistantMessage.model` on the newest assistant `message_end`. Present
190
+ * even for a run that never failed over — it is the durable answer to "which
191
+ * model wrote this" (#535 slice 1, from the message field rather than the
192
+ * payload-free `model_changed` event). Absent only when no assistant message
193
+ * carried a model.
194
+ */
195
+ model?: string;
196
+ /** The provider that wrote them, read from the same `AssistantMessage.provider`. */
197
+ provider?: string;
198
+ /** Every within-run model fallback the harness applied
199
+ * (`retry_fallback_applied`), newest first. The `to` target is what a
200
+ * settlement report names when a run swapped providers mid-run. */
201
+ retryFallbacks: { from: string; to: string }[];
202
+ /** `retry_fallback_succeeded` events: within-run fallbacks the harness
203
+ * confirmed recovered on. */
204
+ retryFallbackSucceeded: number;
205
+ /** Assistant messages whose `retryRecovery.recovery === "model"` — the durable
206
+ * transcript record of a within-run model swap. */
207
+ modelRecoveries: number;
208
+ /** `auto_retry_start` events: in-session provider retries the harness ran. */
209
+ autoRetryCount: number;
210
+ /** `auto_compaction_start` events: in-session context compactions. */
211
+ autoCompactionCount: number;
172
212
  killedBy?: KilledBy;
173
213
  /** Present only when an operator terminally stopped this run. */
174
214
  stoppedReason?: string;
@@ -287,6 +327,10 @@ export async function runWorker(
287
327
  ...(o.sessionDir === undefined ? {} : { sessionDir: o.sessionDir }),
288
328
  ...(o.model === undefined ? {} : { model: o.model }),
289
329
  ...(o.resume === undefined ? {} : { resume: o.resume }),
330
+ // The fleet-owned omp settings overlay (#537): carry the staged overlay
331
+ // path to the session so it loads the project's omp settings. Absent,
332
+ // nothing is staged and the harness discovers settings as it does today.
333
+ ...(o.ompSettingsFile === undefined ? {} : { ompSettingsFile: o.ompSettingsFile }),
290
334
  // Prevention half of #24: as a worker, structured file tools cannot leave
291
335
  // this worktree, and no release grant can ever reach this session (#122).
292
336
  role: "worker",
@@ -308,6 +352,12 @@ export async function runWorker(
308
352
  state: "stopped",
309
353
  turns: 0,
310
354
  spendUsd: 0,
355
+ provider429Count: 0,
356
+ retryFallbacks: [],
357
+ retryFallbackSucceeded: 0,
358
+ modelRecoveries: 0,
359
+ autoRetryCount: 0,
360
+ autoCompactionCount: 0,
311
361
  report: "",
312
362
  stoppedReason: "daemon shutdown began before the worker session started",
313
363
  };
@@ -324,7 +374,19 @@ export async function runWorker(
324
374
  // transcript it actually opened, and any model downgrade it announced. Read at
325
375
  // return time so a session that materialises either late is still reported
326
376
  // honestly.
327
- const withSessionFacts = (result: WorkerResult): WorkerResult => {
377
+ // The within-run reliability surface this worker now records (#539): the
378
+ // resolved model/provider plus the fallback/retry/compaction events. Layered
379
+ // last, at return time, so every exit path reports the same shape without
380
+ // each spelling the metrics out by hand.
381
+ type ReliabilityKeys =
382
+ | "retryFallbacks"
383
+ | "retryFallbackSucceeded"
384
+ | "modelRecoveries"
385
+ | "autoRetryCount"
386
+ | "autoCompactionCount";
387
+ const withSessionFacts = (
388
+ result: Omit<WorkerResult, ReliabilityKeys>,
389
+ ): Omit<WorkerResult, ReliabilityKeys> => {
328
390
  const { sessionFile, modelFallbackMessage } = session;
329
391
  return {
330
392
  ...result,
@@ -333,9 +395,38 @@ export async function runWorker(
333
395
  };
334
396
  };
335
397
 
398
+ // The count fields always travel (0 for a clean run, so an absent field can
399
+ // never be misread); the resolved model/provider only when some assistant
400
+ // message actually carried them.
401
+ const withMetrics = (result: Omit<WorkerResult, ReliabilityKeys>): WorkerResult => ({
402
+ ...result,
403
+ ...(resolvedModel === undefined ? {} : { model: resolvedModel }),
404
+ ...(resolvedProvider === undefined ? {} : { provider: resolvedProvider }),
405
+ retryFallbacks,
406
+ retryFallbackSucceeded,
407
+ modelRecoveries,
408
+ autoRetryCount,
409
+ autoCompactionCount,
410
+ });
411
+
336
412
  let turns = 0;
337
413
  let spendUsd = 0;
414
+ let provider429Count = 0;
338
415
  let report = "";
416
+ // Which model/provider actually wrote the newest assistant message. Last
417
+ // assistant message wins: that is the durable answer even for a run that
418
+ // never failed over (#535 slice 1, read off the message field which is where
419
+ // the resolved model actually lives).
420
+ let resolvedModel: string | undefined;
421
+ let resolvedProvider: string | undefined;
422
+ // Harness reliability surface (#539): within-run provider failover and the
423
+ // retry/compaction activity that surrounds it, all of it events this worker
424
+ // does not yet subscribe to but that the run row and settlement report want.
425
+ let retryFallbacks: { from: string; to: string }[] = [];
426
+ let retryFallbackSucceeded = 0;
427
+ let modelRecoveries = 0;
428
+ let autoRetryCount = 0;
429
+ let autoCompactionCount = 0;
339
430
  // The newest COMPLETE `pushed-green` verdict this session emitted. Tracked
340
431
  // apart from `report` because `report` is deliberately the newest non-empty
341
432
  // text — a run cut off mid-sentence must still report what it said last —
@@ -497,6 +588,48 @@ export async function runWorker(
497
588
  spendUsd += cost;
498
589
  o.onSpend?.(spendUsd);
499
590
  }
591
+
592
+ // Count the provider rate limits the harness retried in-session (#573). A
593
+ // run that drowns in 429s records `stopReason:"error", errorStatus:429`
594
+ // dozens of times and never surfaces one as `lastError` — omp swallowed
595
+ // every retry — so `unknown` and the provider failover chain (#286) never
596
+ // see them. Counted here, alongside spend, so the classifier can tell
597
+ // "the provider was throttling the whole run" from an ordinary failure.
598
+ if (provider429FromMessage(message)) provider429Count += 1;
599
+
600
+ // The resolved model and provider live on the message, not on any event
601
+ // (#539). Last assistant message wins, which is the run's durable answer
602
+ // even when it never failed over.
603
+ const model = field(message, "model");
604
+ if (typeof model === "string" && model !== "") resolvedModel = model;
605
+ const provider = field(message, "provider");
606
+ if (typeof provider === "string" && provider !== "") resolvedProvider = provider;
607
+ // A within-run model swap the harness persisted into the transcript rather
608
+ // than only emitting as a transient event. Recovery kind "model" is the
609
+ // durable spelling of the same thing `retry_fallback_applied` says.
610
+ if (field(field(message, "retryRecovery"), "recovery") === "model") modelRecoveries += 1;
611
+ });
612
+
613
+ // The harness reliability events the run row and settlement report now want
614
+ // (#539): within-run model fallback, and the retry/compaction activity that
615
+ // surrounds a throttled provider. Each is a session event (AgentSessionEvent)
616
+ // carrying the fields this worker reads — a fallback's from→to pair, the
617
+ // confirmed recoveries, and the auto-retry/compaction attempt counts.
618
+ session.on("retry_fallback_applied", (event) => {
619
+ const from = field(event, "from");
620
+ const to = field(event, "to");
621
+ if (typeof from === "string" && typeof to === "string") {
622
+ retryFallbacks = [{ from, to }, ...retryFallbacks];
623
+ }
624
+ });
625
+ session.on("retry_fallback_succeeded", () => {
626
+ retryFallbackSucceeded += 1;
627
+ });
628
+ session.on("auto_retry_start", () => {
629
+ autoRetryCount += 1;
630
+ });
631
+ session.on("auto_compaction_start", () => {
632
+ autoCompactionCount += 1;
500
633
  });
501
634
 
502
635
  session.on("agent_end", (event) => {
@@ -561,12 +694,13 @@ export async function runWorker(
561
694
  // Our own abort surfaces here on some paths; that is a kill, not a crash.
562
695
  if (killedBy === undefined && stoppedReason === undefined) {
563
696
  const detail = cause instanceof Error ? cause.message : String(cause);
564
- return withSessionFacts({
697
+ return withMetrics(withSessionFacts({
565
698
  state: "failed",
566
699
  turns,
567
700
  spendUsd,
701
+ provider429Count,
568
702
  report: report === "" ? detail : report,
569
- });
703
+ }));
570
704
  }
571
705
  } finally {
572
706
  done = true;
@@ -581,14 +715,15 @@ export async function runWorker(
581
715
  }
582
716
 
583
717
  if (stoppedReason !== undefined) {
584
- return withSessionFacts({
718
+ return withMetrics(withSessionFacts({
585
719
  state: "stopped",
586
720
  turns,
587
721
  spendUsd,
722
+ provider429Count,
588
723
  report,
589
724
  stoppedReason,
590
725
  ...(claim === undefined ? {} : { prUrl: claim.prUrl, headSha: claim.headSha }),
591
- });
726
+ }));
592
727
  }
593
728
 
594
729
  if (killedBy !== undefined) {
@@ -596,28 +731,30 @@ export async function runWorker(
596
731
  // survive the kill. Without them `shouldContinueAfterTurnsCap` sees no
597
732
  // artifacts and charges an implementation attempt for a cap kill that had
598
733
  // real work to continue from.
599
- return withSessionFacts({
734
+ return withMetrics(withSessionFacts({
600
735
  state: "killed",
601
736
  turns,
602
737
  spendUsd,
738
+ provider429Count,
603
739
  report,
604
740
  killedBy,
605
741
  ...(claim === undefined ? {} : { prUrl: claim.prUrl, headSha: claim.headSha }),
606
- });
742
+ }));
607
743
  }
608
744
 
609
745
  // An explicit later verdict always wins: a worker that pushed green and then
610
746
  // stopped to ask a question means the question. The earlier claim is only
611
747
  // restored when the last thing said was not a verdict at all.
612
748
  if (claim !== undefined && !hasVerdictLine(report)) {
613
- return withSessionFacts({ state: "pushed-green", ...claim, turns, spendUsd, report });
749
+ return withMetrics(withSessionFacts({ state: "pushed-green", ...claim, turns, spendUsd, provider429Count, report }));
614
750
  }
615
- return withSessionFacts({
751
+ return withMetrics(withSessionFacts({
616
752
  ...deriveResult(report, o.repoSlug),
617
753
  turns,
618
754
  spendUsd,
755
+ provider429Count,
619
756
  report,
620
- });
757
+ }));
621
758
  }
622
759
 
623
760
  /**
@@ -645,6 +782,21 @@ export function costUsdFromMessage(message: unknown): number | undefined {
645
782
  return any ? sum : undefined;
646
783
  }
647
784
 
785
+ /**
786
+ * Is this assistant message a provider HTTP 429 the harness recorded mid-run?
787
+ *
788
+ * Live transcripts mark a rate-limited turn with `stopReason:"error"`,
789
+ * `errorStatus:429` and a message like `429 Provider returned error`, and the
790
+ * harness's in-session auto-retry usually swallows it — the run carries on and
791
+ * the 429 never surfaces as `lastError` (#573). Exported so a unit test can pin
792
+ * the signature without standing up a session; the count itself distinguishes a
793
+ * run that drowned in them from a run that hit one and recovered.
794
+ */
795
+ export function provider429FromMessage(message: unknown): boolean {
796
+ if (field(message, "stopReason") !== "error") return false;
797
+ return field(message, "errorStatus") === 429;
798
+ }
799
+
648
800
  /**
649
801
  * Read one property off an unvalidated harness event. The event union lives in
650
802
  * the peer dependency, so the worker narrows the handful of fields it reads