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.
- package/REFERENCE.md +72 -2
- package/package.json +2 -1
- package/schema/config.schema.json +6 -0
- package/src/admission.ts +745 -0
- package/src/ask.ts +47 -0
- package/src/backups.ts +19 -7
- package/src/board.ts +1 -2
- package/src/briefs/orchestrator.md +62 -4
- package/src/cli.ts +26 -0
- package/src/commands/context.ts +3 -0
- package/src/commands/decision.ts +10 -1
- package/src/commands/doctor.ts +2 -0
- package/src/commands/message.ts +8 -1
- package/src/commands/restart.ts +15 -3
- package/src/commands/restore-db.ts +146 -0
- package/src/commands/stop.ts +24 -15
- package/src/commands/unfreeze.ts +56 -0
- package/src/commands/watch.ts +77 -0
- package/src/config-schema.ts +9 -0
- package/src/config.ts +24 -0
- package/src/daemon.ts +239 -530
- package/src/dashboard/server.ts +2 -1
- package/src/decisions.ts +32 -7
- package/src/depends-on.ts +73 -0
- package/src/doctor.ts +178 -5
- package/src/escalate.ts +114 -15
- package/src/failure-class.ts +47 -0
- package/src/fleet.ts +41 -410
- package/src/gitops.ts +86 -1
- package/src/log.ts +40 -0
- package/src/model-fallback.ts +3 -2
- package/src/omp-settings.ts +114 -0
- package/src/omp.ts +39 -0
- package/src/orchestrator-tick.ts +7 -1
- package/src/reports.ts +124 -12
- package/src/session-host.ts +6 -0
- package/src/setup-wizard.ts +36 -0
- package/src/setup.ts +58 -1
- package/src/status-render.ts +445 -0
- package/src/stop-provenance.ts +53 -0
- package/src/store.ts +352 -11
- package/src/types.ts +187 -4
- package/src/unblock.ts +1 -1
- package/src/upgrade-verify.ts +1 -1
- package/src/upgrade.ts +1 -2
- package/src/verbs/server.ts +25 -0
- 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
|
-
|
|
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
|
|
1812
|
-
|
|
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 "./
|
|
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";
|
package/src/upgrade-verify.ts
CHANGED
|
@@ -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 "./
|
|
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";
|
package/src/verbs/server.ts
CHANGED
|
@@ -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
|
-
|
|
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
|