omp-conductor 0.10.0 → 0.12.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/README.md +35 -2
- package/package.json +1 -1
- package/src/board.ts +124 -10
- package/src/briefs/orchestrator.md +18 -2
- package/src/briefs/policy.md +4 -1
- package/src/chain-check.ts +157 -0
- package/src/cli.ts +90 -2
- package/src/config.ts +185 -13
- package/src/daemon.ts +238 -27
- package/src/diff-flags.ts +44 -3
- package/src/digest-schedule.ts +59 -0
- package/src/escalate.ts +17 -0
- package/src/failure-class.ts +25 -0
- package/src/fleet.ts +1 -1
- package/src/gitops.ts +49 -0
- package/src/omp.ts +36 -5
- package/src/orchestrator-tick.ts +73 -3
- package/src/plugin.ts +18 -2
- package/src/reports.ts +5 -6
- package/src/session-host.ts +19 -3
- package/src/setup.ts +39 -8
- package/src/store.ts +73 -2
- package/src/types.ts +110 -4
- package/src/verbs/server.ts +49 -0
- package/src/worker.ts +152 -14
package/README.md
CHANGED
|
@@ -1012,6 +1012,13 @@ effective value is persisted and shown beside that active run in `status`; editi
|
|
|
1012
1012
|
flight. A cap that fires aborts the run, records it as `killed`, and names the
|
|
1013
1013
|
ceiling in the escalation.
|
|
1014
1014
|
|
|
1015
|
+
Pause one live worker cooperatively with
|
|
1016
|
+
`omp-conductor worker pause <issue> [--project NAME]`. The daemon aborts the
|
|
1017
|
+
active turn to an idle harness state, freezes the remaining wall-clock budget,
|
|
1018
|
+
and keeps the run in the Running lane. `omp-conductor worker resume <issue>`
|
|
1019
|
+
continues the same session with a prompt to re-check its last action before
|
|
1020
|
+
proceeding. This is separate from fleet-level `pause`, which stops new claims.
|
|
1021
|
+
|
|
1015
1022
|
### Plan allowance (`planUsage`)
|
|
1016
1023
|
|
|
1017
1024
|
`dailySpendUsd` meters money, which is the only thing an API-billed account can
|
|
@@ -1587,7 +1594,8 @@ A complete, valid config for one project with two target repos:
|
|
|
1587
1594
|
{ "cmd": "bun run lint", "cwd": "." },
|
|
1588
1595
|
{ "cmd": "bun test", "cwd": "." }
|
|
1589
1596
|
],
|
|
1590
|
-
"graphProject": "~/.cache/conductor-graph/acme/api"
|
|
1597
|
+
"graphProject": "~/.cache/conductor-graph/acme/api",
|
|
1598
|
+
"migrations": { "dir": "backend/alembic/versions" }
|
|
1591
1599
|
},
|
|
1592
1600
|
"worker": {
|
|
1593
1601
|
"name": "worker",
|
|
@@ -1659,12 +1667,13 @@ Field notes:
|
|
|
1659
1667
|
| `routing.repos` | At least one entry, or nothing can be routed. `name` defaults to the map key, `defaultBranch` to `main`. |
|
|
1660
1668
|
| `gates` | The exact cheap commands CI also runs, each with the `cwd` it runs from (`cwd` defaults to `.`). Running the real gate locally is what makes an unattended push safe — a subset lets an error outside the source dir reach the runners. |
|
|
1661
1669
|
| `graphProject` | Optional, per repo. Absolute path of the **index-only clone** whose code graph this repo's workers query — conductor's own disposable clone, pinned to the repo's default branch, never a checkout you work in and never a worker's worktree. Written by the wizard; `~` is expanded, and a relative path is an error rather than something resolved against whichever cwd happened to read the file. Absent means this repo has no graph and its briefs say nothing about one. See [Code-graph discovery](#code-graph-discovery). |
|
|
1670
|
+
| `migrations` | Optional, per repo: `{ "dir": "backend/alembic/versions" }`. Names the repo-relative directory of an Alembic-style ordered migration chain (`revision` / `down_revision` in `*.py`). When set, `conductor_pr_merge` **refuses** a merge that would corrupt the chain at the base tip: reusing a revision id another file already declares, deleting a published migration, or a merge that would leave the combined graph with more than one head (so a stale parent is refused, and a fork-repair merge migration that unifies the heads passes). Absent means the repo opts out of the chain check entirely. Repo-relative only: a leading `/` or `..` is an error. |
|
|
1662
1671
|
| `caps` | Per-project overrides; omit it or pin only the fields you want to change. |
|
|
1663
1672
|
| `escalation.fallbackToIssueComment` | Defaults to `true`. Absent means "yes, still tell me". |
|
|
1664
1673
|
| `escalation.orchestrator` | Optional; `"embedded"` (default) or `"external"`. `external` means an orchestrator session already runs elsewhere: the daemon starts none, and tier-1 escalations post as issue comments for that session to drain. Any other value is an error. |
|
|
1665
1674
|
| `authority` | Optional; `{ "merge": …, "release": … }`, each `"human"` (default) or `"orchestrator"`. It grants nothing to the daemon — it words the orchestrator's standing orders and the Releases paragraph of the rendered brief, so the config and the prompt cannot disagree about who holds the merge button. Unknown keys and any other value are errors, never folded to the default. |
|
|
1666
1675
|
| `releasePolicy` | Optional; `"none"` (default) or `"operator-brief"`. `none` installs a pre-tool-call tripwire in worker, embedded-orchestrator and external-orchestrator sessions. It blocks `git tag`, tag pushes, package publishing, GitHub release creation and recognised deploy commands before execution. `operator-brief` opens that gate only for the procedure in the operator-owned brief. Unknown values are errors. Every rejection is written to `release-policy-blocks.jsonl`; the heartbeat carries that day's count into the daily digest so configured intent and observed behaviour cannot drift silently. This is the mechanical gate; `authority.release` still says who owns the decision. |
|
|
1667
|
-
| `reporting
|
|
1676
|
+
| `reporting` | Optional; a **legacy scope preset** (`reporting.scope` — `"material"` default, `"decisions"`, `"escalations"`) or the **explicit form** `{ "interruptOn": [...], "digest": { ... } }`. The preset writes which categories may page the operator (`interruptOn`) and when the rollup happens (`digest.cadence`); the explicit form sets both directly and the two forms are mutually exclusive in one config. See [Reporting policy](#reporting-policy-reporting). |
|
|
1668
1677
|
| `orchestratorReadPaths` | **Retired in 0.4.3.** Still accepted in a config and ignored, so a fleet carrying it upgrades without an edit. It widened the orchestrator's file-tool allowlist; there is no allowlist any more — the orchestrator is [unconfined by design](#the-orchestrator-is-unconfined-deliberately). |
|
|
1669
1678
|
| `policy` | Optional; the gating conditions a merge or a release must satisfy, in two sections — `policy.merge` and `policy.release`. Any member may be omitted and the loader fills it from the strict default; an unknown key in either section, or a value outside its vocabulary, is an error naming the field, never a silent downgrade. See [Merge and release preconditions](#merge-and-release-preconditions-policy). |
|
|
1670
1679
|
| `workspaceRoot` / `mirrorRoot` | Optional; default to `worktrees/` and `mirrors/` under the state directory. `~` is expanded. |
|
|
@@ -1673,6 +1682,27 @@ Prefer an SSH `cloneUrl`, or an https URL backed by a credential helper. A clone
|
|
|
1673
1682
|
with credentials embedded is persisted into the mirror's git config, exactly as it
|
|
1674
1683
|
would be for a hand-run clone.
|
|
1675
1684
|
|
|
1685
|
+
### Reporting policy (`reporting`)
|
|
1686
|
+
|
|
1687
|
+
What may interrupt the operator's phone, and when the daily rollup happens. Two
|
|
1688
|
+
spellings, mutually exclusive in one config (the loader rejects a `scope` next to
|
|
1689
|
+
`interruptOn`/`digest`):
|
|
1690
|
+
|
|
1691
|
+
- **Preset** — `reporting.scope`, the three legacy values, mapped verbatim:
|
|
1692
|
+
- `material` (default) → `interruptOn: [tier2, decision-needed, fleet-stopped, confirmed-failure, material]`, digest `per-tick`.
|
|
1693
|
+
- `decisions` → `interruptOn: [tier2, decision-needed, fleet-stopped]`, digest `per-tick`.
|
|
1694
|
+
- `escalations` → `interruptOn: [tier2, fleet-stopped]`, digest `daily` (model-timed).
|
|
1695
|
+
- **Explicit** — `reporting: { "interruptOn": ["tier2", "fleet-stopped", ...], "digest": { "cadence": "none" | "per-tick" | "daily" } }`.
|
|
1696
|
+
`interruptOn` must be a non-empty array of known categories (`tier2`, `decision-needed`, `fleet-stopped`, `confirmed-failure`, `material`), each an escalation's tier-2 category. `daily` may add `at` (`HH:MM`, 24h) and `timezone` (a known IANA zone, defaulting to the host zone) — both only valid with `daily`.
|
|
1697
|
+
|
|
1698
|
+
A tier-2 escalation whose category is **not** in `interruptOn` is not dropped: it
|
|
1699
|
+
is held (`held_notices`) and the next accepted digest is its delivery authority.
|
|
1700
|
+
The digest itself is at-most-once per local day (`digest:<YYYY-MM-DD>` in the
|
|
1701
|
+
configured zone), which remains the delivery authority across restarts. A
|
|
1702
|
+
scheduled `daily` digest is only sent on a day it has not already run, once the
|
|
1703
|
+
local clock has passed `at`; a restart after `at` still sends today's (one
|
|
1704
|
+
catch-up), and a fully missed day is skipped, never sent late.
|
|
1705
|
+
|
|
1676
1706
|
### Merge and release preconditions (`policy`)
|
|
1677
1707
|
|
|
1678
1708
|
These used to be sentences in your `POLICY.md`: when a PR may be merged, what
|
|
@@ -1959,6 +1989,8 @@ omp-conductor disarm [--project NAME]
|
|
|
1959
1989
|
omp-conductor release-pane [--project NAME]
|
|
1960
1990
|
omp-conductor tail <issue> [--project NAME]
|
|
1961
1991
|
omp-conductor extend <issue> --turns N [--project NAME]
|
|
1992
|
+
omp-conductor worker pause <issue> [--project NAME]
|
|
1993
|
+
omp-conductor worker resume <issue> [--project NAME]
|
|
1962
1994
|
omp-conductor unblock <issue> [--force] [--no-requeue] [--project NAME]
|
|
1963
1995
|
omp-conductor verb <conductor_*> [--project NAME] [--arg k=v ...]
|
|
1964
1996
|
omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
|
|
@@ -1991,6 +2023,7 @@ omp-conductor help
|
|
|
1991
2023
|
| `release-pane [--project NAME]` | Clear the `halt --pane` recovery pin so herdr-conductor may resume the fleet agent again. |
|
|
1992
2024
|
| `tail <issue>` | Follow the newest run for that issue: the worker's assistant text as `assistant: …` and each tool it calls as `tool: <name>`, printed as they land. Workers are omp sessions inside the daemon rather than terminals, so this is the only way to watch one live — a herdr pane running it becomes an observation window. Starts from the top of the transcript, not the end, so attaching to a run that is already ten turns in shows those ten turns. Exits `1` with `no run recorded for #N` when the issue has never been dispatched, or `no transcript yet (state: …)` when the attempt has not opened one. Otherwise it runs until `Ctrl-C`, or until the run has finished and its transcript has been silent for five seconds, and prints `run ended: <state>`. |
|
|
1993
2025
|
| `extend <issue> --turns N [--project NAME]` | Monotonically raise that live worker's effective turn ceiling through its owning daemon. The current omp session keeps running; no restart or continuation is created. The daemon persists the new ceiling for `status` and rejects missing, settled, cap-killed, equal, or lower requests instead of implying that an immutable session changed. |
|
|
2026
|
+
| `worker pause <issue>` / `worker resume <issue>` | Cooperatively park one live worker without changing its run state or lane. Pause aborts the active turn to harness idle and freezes the remaining wall-clock budget; resume continues the same session with a prompt to re-check its last action before repeating it. This is distinct from fleet-level `pause`, which stops new claims. |
|
|
1994
2027
|
| `unblock <issue> [--force] [--no-requeue]` | Remove that issue's `blocked` and `failed` labels so an answered escalation can be claimed again, and restore the project queue label by default so the dispatcher actually sees it. `agent:in-progress` comes off too, but only when the newest recorded run is terminal — that row is the proof no worker still owns the issue, so a live run keeps the label (and the queue label stays off until that run settles), and so does an issue with no run row at all. Run history remains intact: blocks consume the independent continuation budget, not failed implementation attempts. The output reports both budgets and warns when either will make the next tick escalate instead of dispatch. The label changes go through the [label projection outbox](#the-tick): they are applied inline before the command returns, but **a tracker that refuses them (403, rate limit) no longer fails the verb** — it exits `0`, the intended label state is durable and the daemon retries it, and the output says `label sync queued (N pending) — the daemon retries` instead of claiming the labels were restored. Safety is preserved, but the issue is only claimable once the queue label itself lands: the queue read asks GitHub for issues carrying that label, so a refused queue-label add keeps the issue out of dispatch until projection succeeds. `--no-requeue` clears the state labels only, leaving the queue label untouched — the case where you are about to close the issue. **Refuses, clearing nothing and exiting `3`, when the newest attempt's work could not be committed and its worktree is the only copy** — re-claiming removes that tree. `--force` records the operator's acceptance on the run row and then clears; the salvage failure stays in history. Exits `2` when the issue number is missing or malformed. |
|
|
1995
2028
|
| `verb <conductor_*> [--arg k=v ...]` | Run one [mediated verb](#the-mediated-verbs-126) as the orchestrator, from the CLI — the external-orchestrator half of the verb surface. Every argument goes in as a `--arg k=v` string; an orchestrator can merge (`conductor_pr_merge`), label (`conductor_label`), release (`conductor_release`), update a branch (`conductor_pr_update_branch`) or title/body (`conductor_pr_update`), or read PR state (`conductor_pr_status`). The daemon applies the same checks and writes the same ledger rows a session's call would; a missing `--arg` is refused exactly as a missing tool argument is, worker-only verbs (`conductor_push`, `conductor_pr_create`) are refused with `role-not-allowed`, and a refusal exits `3`. An unknown verb exits `2`. |
|
|
1996
2029
|
| `friction <kind> --detail TEXT [--issue N]` | Record one bounded judgment the daemon cannot infer: an escalation belonged in a digest, or a tick report was noise/surprising. The detail is limited to 160 characters. One event never changes policy; three observations inside seven days make the aggregate eligible for one Learning-loop prompt, followed by a seven-day cooldown. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
|
package/src/board.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "./fleet.ts";
|
|
13
13
|
import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
|
|
14
14
|
import { healthCheck, livingDaemon } from "./lifecycle.ts";
|
|
15
|
+
import type { WorkerPausePhase } from "./worker.ts";
|
|
15
16
|
import { dbPath, openStore } from "./store.ts";
|
|
16
17
|
import { formatTranscriptLine } from "./transcript.ts";
|
|
17
18
|
import { makeTracker } from "./tracker/github.ts";
|
|
@@ -136,6 +137,11 @@ export interface BoardHealth {
|
|
|
136
137
|
codeGraph?: CodeGraphHealth;
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
interface BoardHealthProbe {
|
|
141
|
+
health: BoardHealth;
|
|
142
|
+
pausedPhases: ReadonlyMap<number, WorkerPausePhase>;
|
|
143
|
+
}
|
|
144
|
+
|
|
139
145
|
/**
|
|
140
146
|
* Which issues currently carry each label the board reasons about, read from
|
|
141
147
|
* the tracker rather than inferred from run rows.
|
|
@@ -183,6 +189,7 @@ export interface BoardSnapshot {
|
|
|
183
189
|
project: ProjectConfig;
|
|
184
190
|
status: StatusSnapshot;
|
|
185
191
|
health: BoardHealth;
|
|
192
|
+
pausedPhases: ReadonlyMap<number, WorkerPausePhase>;
|
|
186
193
|
labels: BoardLabels;
|
|
187
194
|
/** Live re-verification of pushed rows (#173): run id → what the tracker says
|
|
188
195
|
* the PR looks like now. Rendered as a `now:` suffix on the card. */
|
|
@@ -561,13 +568,15 @@ function normalizeCursor(snapshot: BoardSnapshot, cursor: BoardCursor): void {
|
|
|
561
568
|
function runCardLines(run: RunRecord, snapshot: BoardSnapshot, lane: BoardLane): string[] {
|
|
562
569
|
const endedAt = run.endedAt ?? snapshot.now;
|
|
563
570
|
const duration = humanDuration(endedAt - run.startedAt);
|
|
571
|
+
const phase = lane === "running" ? snapshot.pausedPhases.get(run.issue) : undefined;
|
|
572
|
+
const pausePrefix = phase === "paused" ? "⏸ PAUSED " : phase === "pausing" ? "… pausing " : "";
|
|
564
573
|
const lines = [
|
|
565
574
|
// The class, when the sweep has attached one and nothing has recovered it
|
|
566
575
|
// yet (#132): a row state says "failed", which four completed issues on this
|
|
567
576
|
// fleet also said. The class says which of the two this is.
|
|
568
577
|
run.failureClass === undefined || run.recoveredAt !== undefined
|
|
569
|
-
?
|
|
570
|
-
:
|
|
578
|
+
? `${pausePrefix}#${run.issue} · ${run.repo}`
|
|
579
|
+
: `${pausePrefix}#${run.issue} · ${run.repo} [${run.failureClass}]`,
|
|
571
580
|
`attempt ${run.attempt} · ${run.turns}/${run.maxTurns}t`,
|
|
572
581
|
`$${run.spendUsd.toFixed(2)} · ${duration}`,
|
|
573
582
|
];
|
|
@@ -719,7 +728,7 @@ function admissionLine(snapshot: BoardSnapshot): string {
|
|
|
719
728
|
const queue =
|
|
720
729
|
dispatch === undefined
|
|
721
730
|
? "dispatch not recorded"
|
|
722
|
-
: `${dispatch.degraded ? "DEGRADED · " : ""}${dispatch.ready} ready · ${dispatch.
|
|
731
|
+
: `${dispatch.degraded ? "DEGRADED · " : ""}${dispatch.ready} ready · ${dispatch.claimed ?? 0} in flight · ${dispatch.routed} spare · ${dispatch.admitted} admitted`;
|
|
723
732
|
const holdText = dispatch?.holds.map((hold) => `${hold.reason} ${hold.count}`).join(", ");
|
|
724
733
|
const holds = holdText === undefined || holdText === "" ? "none" : holdText;
|
|
725
734
|
return (
|
|
@@ -820,8 +829,20 @@ function renderDetail(snapshot: BoardSnapshot, cursor: BoardCursor, width: numbe
|
|
|
820
829
|
// #173: a blocked/failed run whose label is gone is the reason this card is
|
|
821
830
|
// parked; the header says so rather than presenting the state as current.
|
|
822
831
|
const stateShown = isLastRun(run, snapshot.labels) ? `last run: ${run.state}` : run.state;
|
|
832
|
+
const pausePhase = snapshot.pausedPhases.get(run.issue);
|
|
823
833
|
const metadata = [
|
|
824
834
|
styledCell(` RUN #${run.issue} ${run.repo} ${stateShown} `, width, `${BOLD}${REVERSE}`),
|
|
835
|
+
...(pausePhase === undefined
|
|
836
|
+
? []
|
|
837
|
+
: [
|
|
838
|
+
styledCell(
|
|
839
|
+
pausePhase === "paused"
|
|
840
|
+
? "worker paused · wall clock frozen"
|
|
841
|
+
: "worker pausing · draining to harness idle",
|
|
842
|
+
width,
|
|
843
|
+
YELLOW,
|
|
844
|
+
),
|
|
845
|
+
]),
|
|
825
846
|
styledCell(`attempt ${run.attempt} · ${run.turns}/${run.maxTurns} turns · $${run.spendUsd.toFixed(2)} · ${humanDuration((run.endedAt ?? snapshot.now) - run.startedAt)}`, width),
|
|
826
847
|
styledCell(`branch ${run.branch}`, width, DIM),
|
|
827
848
|
styledCell(`worktree ${run.worktree || "removed"}`, width, DIM),
|
|
@@ -844,6 +865,7 @@ function renderHelp(width: number, height: number): string[] {
|
|
|
844
865
|
"↑/↓ or k/j select card",
|
|
845
866
|
"Enter inspect/follow transcript",
|
|
846
867
|
"u unblock selected blocked, failed or orphaned issue",
|
|
868
|
+
"space pause/resume selected worker",
|
|
847
869
|
"i open selected issue",
|
|
848
870
|
"p open selected pull request",
|
|
849
871
|
"r refresh health now",
|
|
@@ -900,7 +922,37 @@ export function renderBoard(
|
|
|
900
922
|
].join("\n");
|
|
901
923
|
}
|
|
902
924
|
|
|
903
|
-
|
|
925
|
+
export function workerPhasesFromHealthz(
|
|
926
|
+
body: string | undefined,
|
|
927
|
+
project: string,
|
|
928
|
+
): ReadonlyMap<number, WorkerPausePhase> {
|
|
929
|
+
const phases = new Map<number, WorkerPausePhase>();
|
|
930
|
+
if (body === undefined) return phases;
|
|
931
|
+
try {
|
|
932
|
+
const payload = JSON.parse(body) as unknown;
|
|
933
|
+
if (payload === null || typeof payload !== "object") return phases;
|
|
934
|
+
if (Reflect.get(payload, "project") !== project) return phases;
|
|
935
|
+
const workers = Reflect.get(payload, "workers");
|
|
936
|
+
if (!Array.isArray(workers)) return phases;
|
|
937
|
+
for (const worker of workers) {
|
|
938
|
+
if (worker === null || typeof worker !== "object") continue;
|
|
939
|
+
const issue = Reflect.get(worker, "issue");
|
|
940
|
+
const phase = Reflect.get(worker, "phase");
|
|
941
|
+
if (
|
|
942
|
+
Number.isSafeInteger(issue) &&
|
|
943
|
+
(issue as number) > 0 &&
|
|
944
|
+
(phase === "pausing" || phase === "paused")
|
|
945
|
+
) {
|
|
946
|
+
phases.set(issue as number, phase);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
} catch {
|
|
950
|
+
// An unreadable health body means no trustworthy pause phase.
|
|
951
|
+
}
|
|
952
|
+
return phases;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
async function probeBoardHealth(project: ProjectConfig): Promise<BoardHealthProbe> {
|
|
904
956
|
const layers = fleetLayers(project.name);
|
|
905
957
|
const record = livingDaemon();
|
|
906
958
|
const wrongRecord = record?.project !== undefined && record.project !== project.name;
|
|
@@ -921,7 +973,18 @@ async function probeBoardHealth(project: ProjectConfig): Promise<BoardHealth> {
|
|
|
921
973
|
}
|
|
922
974
|
}
|
|
923
975
|
const cachedGraph = daemon === "ok" ? codeGraphFromHealthz(health?.body, project.name) : undefined;
|
|
924
|
-
return {
|
|
976
|
+
return {
|
|
977
|
+
health: {
|
|
978
|
+
layers,
|
|
979
|
+
telegram,
|
|
980
|
+
daemon,
|
|
981
|
+
codeGraph: cachedGraph ?? (await probeCodeGraph(project)),
|
|
982
|
+
},
|
|
983
|
+
pausedPhases:
|
|
984
|
+
daemon === "ok"
|
|
985
|
+
? workerPhasesFromHealthz(health?.body, project.name)
|
|
986
|
+
: new Map<number, WorkerPausePhase>(),
|
|
987
|
+
};
|
|
925
988
|
}
|
|
926
989
|
|
|
927
990
|
/**
|
|
@@ -1020,6 +1083,41 @@ async function unblock(project: ProjectConfig, issue: number): Promise<string> {
|
|
|
1020
1083
|
if (code !== 0) return (stderr || stdout).trim().replace(/\s+/g, " ") || `#${issue}: unblock failed`;
|
|
1021
1084
|
return summarizeUnblockOutput(issue, stdout);
|
|
1022
1085
|
}
|
|
1086
|
+
|
|
1087
|
+
async function toggleWorkerPause(
|
|
1088
|
+
project: ProjectConfig,
|
|
1089
|
+
issue: number,
|
|
1090
|
+
phase: WorkerPausePhase | undefined,
|
|
1091
|
+
): Promise<string> {
|
|
1092
|
+
if (phase === "pausing") return "still pausing — wait";
|
|
1093
|
+
const daemon = livingDaemon();
|
|
1094
|
+
if (daemon === undefined) return "daemon is not running";
|
|
1095
|
+
if (daemon.project !== undefined && daemon.project !== project.name) {
|
|
1096
|
+
return `daemon serves project "${daemon.project}", not requested project "${project.name}"`;
|
|
1097
|
+
}
|
|
1098
|
+
const action = phase === "paused" ? "resume" : "pause";
|
|
1099
|
+
try {
|
|
1100
|
+
const response = await fetch(
|
|
1101
|
+
`http://127.0.0.1:${daemon.port}/runs/${issue}/${action}`,
|
|
1102
|
+
{
|
|
1103
|
+
method: "PUT",
|
|
1104
|
+
headers: { "content-type": "application/json" },
|
|
1105
|
+
body: JSON.stringify({ project: project.name }),
|
|
1106
|
+
},
|
|
1107
|
+
);
|
|
1108
|
+
const payload = (await response.json()) as { error?: unknown; phase?: unknown };
|
|
1109
|
+
if (!response.ok) {
|
|
1110
|
+
return typeof payload.error === "string"
|
|
1111
|
+
? payload.error
|
|
1112
|
+
: `daemon returned HTTP ${response.status}`;
|
|
1113
|
+
}
|
|
1114
|
+
return typeof payload.phase === "string"
|
|
1115
|
+
? `#${issue} worker ${payload.phase}`
|
|
1116
|
+
: "daemon returned an invalid worker-control response";
|
|
1117
|
+
} catch (err) {
|
|
1118
|
+
return err instanceof Error ? err.message : String(err);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1023
1121
|
function enqueue(queue: KeyInput[], key: KeyInput, wake: (() => void) | undefined): void {
|
|
1024
1122
|
queue.push(key);
|
|
1025
1123
|
wake?.();
|
|
@@ -1051,7 +1149,7 @@ export async function boardSnapshotOnce(projectName?: string): Promise<BoardSnap
|
|
|
1051
1149
|
const store: Store = openStore(dbPath());
|
|
1052
1150
|
try {
|
|
1053
1151
|
const now = Date.now();
|
|
1054
|
-
const [
|
|
1152
|
+
const [healthProbe, labels, planUsage] = await Promise.all([
|
|
1055
1153
|
probeBoardHealth(project),
|
|
1056
1154
|
probeBoardLabels(project, store),
|
|
1057
1155
|
readPlanUsage(caps.planUsage, sharedUsageSource()),
|
|
@@ -1059,7 +1157,8 @@ export async function boardSnapshotOnce(projectName?: string): Promise<BoardSnap
|
|
|
1059
1157
|
return {
|
|
1060
1158
|
project,
|
|
1061
1159
|
status: statusSnapshotFromStore(project, caps, store, planUsage),
|
|
1062
|
-
health,
|
|
1160
|
+
health: healthProbe.health,
|
|
1161
|
+
pausedPhases: healthProbe.pausedPhases,
|
|
1063
1162
|
labels,
|
|
1064
1163
|
pr: new Map(),
|
|
1065
1164
|
runs: store.recentRuns(project.name, now - MERGED_HISTORY_MS),
|
|
@@ -1137,11 +1236,13 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
1137
1236
|
onCall: () => trackerCalls.push(Date.now()),
|
|
1138
1237
|
onNotModified: () => trackerFree.push(Date.now()),
|
|
1139
1238
|
});
|
|
1140
|
-
let [
|
|
1239
|
+
let [healthProbe, labels, planUsage] = await Promise.all([
|
|
1141
1240
|
probeBoardHealth(project),
|
|
1142
1241
|
probeBoardLabels(project, store, undefined, boardTracker),
|
|
1143
1242
|
readPlanUsage(caps.planUsage, sharedUsageSource()),
|
|
1144
1243
|
]);
|
|
1244
|
+
let health = healthProbe.health;
|
|
1245
|
+
let pausedPhases = healthProbe.pausedPhases;
|
|
1145
1246
|
let healthAt = Date.now();
|
|
1146
1247
|
let healthRefresh: Promise<void> | undefined;
|
|
1147
1248
|
// The tracker read is gated separately from health and the plan allowance:
|
|
@@ -1183,8 +1284,9 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
1183
1284
|
// subprocess, and an allowance does not move at 1 Hz.
|
|
1184
1285
|
readPlanUsage(caps.planUsage, sharedUsageSource()),
|
|
1185
1286
|
])
|
|
1186
|
-
.then(([
|
|
1187
|
-
health =
|
|
1287
|
+
.then(([nextProbe, nextPlanUsage]) => {
|
|
1288
|
+
health = nextProbe.health;
|
|
1289
|
+
pausedPhases = nextProbe.pausedPhases;
|
|
1188
1290
|
planUsage = nextPlanUsage;
|
|
1189
1291
|
enqueue(queue, { name: "refresh" }, wake);
|
|
1190
1292
|
})
|
|
@@ -1238,6 +1340,7 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
1238
1340
|
project,
|
|
1239
1341
|
status: statusSnapshotFromStore(project, caps, store, planUsage),
|
|
1240
1342
|
health,
|
|
1343
|
+
pausedPhases,
|
|
1241
1344
|
labels,
|
|
1242
1345
|
pr: prProbe,
|
|
1243
1346
|
runs: store.recentRuns(project.name, now - MERGED_HISTORY_MS),
|
|
@@ -1329,6 +1432,17 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
1329
1432
|
else notice = (await openUrl(url)) ? `opened ${url}` : `failed to open ${url}`;
|
|
1330
1433
|
continue;
|
|
1331
1434
|
}
|
|
1435
|
+
if (name === "space") {
|
|
1436
|
+
const lane = COLUMN_DEFS[cursor.column]?.key;
|
|
1437
|
+
if (card === undefined || card.kind !== "run" || lane !== "running") {
|
|
1438
|
+
notice = "pause/resume is available for RUNNING cards";
|
|
1439
|
+
} else {
|
|
1440
|
+
const issue = cardIssue(card);
|
|
1441
|
+
notice = await toggleWorkerPause(project, issue, pausedPhases.get(issue));
|
|
1442
|
+
healthAt = 0;
|
|
1443
|
+
}
|
|
1444
|
+
continue;
|
|
1445
|
+
}
|
|
1332
1446
|
if (name === "u") {
|
|
1333
1447
|
// Lane, not run state. `unblock` clears the blocked and failed labels
|
|
1334
1448
|
// unconditionally and the in-progress label once the newest run row is
|
|
@@ -199,7 +199,8 @@ Keep the queue worth draining.
|
|
|
199
199
|
When the queue is below the grooming trigger, fan out read-only `scout`
|
|
200
200
|
subagents over backlog clusters **in one batch** rather than auditing one issue
|
|
201
201
|
at a time. Scouts do the finding; you still do the deciding and you still write
|
|
202
|
-
the brief.
|
|
202
|
+
the brief. Name the authoritative source in every scout brief and forbid
|
|
203
|
+
unnamed fallbacks. The quality bar above does not move.
|
|
203
204
|
- **Give every scout the same return contract**, or it comes back with prose
|
|
204
205
|
nobody can act on:
|
|
205
206
|
- verdict — `ALREADY DONE` / `PROMOTABLE` / `NEEDS DECOMPOSITION` / `BLOCKED` /
|
|
@@ -209,6 +210,10 @@ Keep the queue worth draining.
|
|
|
209
210
|
- entry points — the 3-6 files to change or read first
|
|
210
211
|
- existing tests covering the behaviour, by path
|
|
211
212
|
- the one thing most likely to be silently faked
|
|
213
|
+
- source — where the code was read: the clone/ref and how fresh it is. A
|
|
214
|
+
scout that cannot reach a source it trusts returns `BLOCKED` and says so;
|
|
215
|
+
silent fallback to an unnamed source is the failure mode of delegated
|
|
216
|
+
research — stale evidence reads exactly like good evidence.
|
|
212
217
|
- **Disqualifying an issue is a successful grooming outcome.** Measured on this
|
|
213
218
|
package's own fleet: four scouts over sixteen backlog issues promoted four and
|
|
214
219
|
*disqualified six* that looked promotable from their titles — four written
|
|
@@ -228,12 +233,23 @@ decides whether this tick ends in a message or in silence.
|
|
|
228
233
|
*How* a report is delivered is not yours, and is not negotiable: run
|
|
229
234
|
`omp-conductor report --text "<the whole report>"` (add `--kind digest` for the
|
|
230
235
|
daily digest). It persists the text before anything is sent and prints a report
|
|
231
|
-
id; the daemon retries until it lands and `omp-conductor status` lists whatever
|
|
236
|
+
report id; the daemon retries until it lands and `omp-conductor status` lists whatever
|
|
232
237
|
has not. Writing a report as end-of-turn text on a tick reaches nobody — that is
|
|
233
238
|
how a suite release and two tier-2 escalations went missing on 2026-08-06 — and
|
|
234
239
|
`telegram_send` reaches somebody but leaves no record that it did, so a report
|
|
235
240
|
sent that way is undetectable when it does not arrive.
|
|
236
241
|
|
|
242
|
+
A report is an update. It never contains a request: no "needs you" header, no
|
|
243
|
+
"let me know", no embedded options. Anything needing a decision, approval or
|
|
244
|
+
answer leaves as its own ask (`telegram_ask`) at the moment it is known — the
|
|
245
|
+
question in one sentence, your recommendation, and the options with their
|
|
246
|
+
consequences, the recommended one marked. Batch several questions into one
|
|
247
|
+
ask (the surface takes up to five); never one call per question, and never a
|
|
248
|
+
numbered menu typed into a plain message. Still open a `decision` row for
|
|
249
|
+
anything you ask: the ask is how it reaches a human, the row is what stops it
|
|
250
|
+
being forgotten. In both directions the delivery contract is explicit: a
|
|
251
|
+
message you did not explicitly send is a message that did not arrive.
|
|
252
|
+
|
|
237
253
|
## Human messages
|
|
238
254
|
|
|
239
255
|
A human writing to you between ticks is not a tick. Answer with a **single
|
package/src/briefs/policy.md
CHANGED
|
@@ -104,9 +104,12 @@ two mistakes. `omp-conductor status` lists anything still undelivered.
|
|
|
104
104
|
an answer to their message, or a question of your own. It is not a report: it
|
|
105
105
|
leaves no record that anything went out. And a `cancelled` or errored
|
|
106
106
|
`telegram_ask` is a delivery failure, not an answer: re-deliver the question with
|
|
107
|
-
`telegram_send`, or report the channel as broken. It is never "asked once, no
|
|
107
|
+
with `telegram_send`, or report the channel as broken. It is never "asked once, no
|
|
108
108
|
reply, dropped".
|
|
109
109
|
|
|
110
|
+
Reports never carry questions: anything needing an answer goes out as its own
|
|
111
|
+
ask, with a recommendation and options.
|
|
112
|
+
|
|
110
113
|
No scope licenses narration. No progress updates, no "checking the queue
|
|
111
114
|
now", no restating this brief back. Evidence, or silence.
|
|
112
115
|
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ordered-migration-chain guard (#227).
|
|
3
|
+
*
|
|
4
|
+
* A project that declares `routing.repos.<repo>.migrations.dir` gets its
|
|
5
|
+
* merges checked against one invariant: after the merge, the base branch's
|
|
6
|
+
* `revision`/`down_revision` graph has exactly one head. The check runs on the
|
|
7
|
+
* base tip at merge time, against the PR's diff — a stale parent, a reused
|
|
8
|
+
* revision id, or a deleted migration is refused before the merge, because a
|
|
9
|
+
* wrong merge here is silent production schema drift.
|
|
10
|
+
*
|
|
11
|
+
* Alembic-convention only (`revision = "<id>"` / `down_revision = <expr>` in
|
|
12
|
+
* `*.py`); a repo without the config key is never touched. Pure by
|
|
13
|
+
* construction: every fact arrives as strings, and the module reads no files.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { PrDiffFile } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
/** One migration file's identity, as the chain check reasons about it. */
|
|
19
|
+
export interface ChainEntry {
|
|
20
|
+
/** Repo-relative path of the file that declares the revision. */
|
|
21
|
+
path: string;
|
|
22
|
+
/** The revision id this file declares. */
|
|
23
|
+
id: string;
|
|
24
|
+
/** Parent revision ids; empty for a root. */
|
|
25
|
+
parents: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const REVISION_LINE = /^\s*revision(?::[^=]*)?\s*=\s*["']([^"']+)["']/m;
|
|
29
|
+
const DOWN_REVISION_LINE = /^\s*down_revision(?::[^=]*)?\s*=\s*(.*)$/m;
|
|
30
|
+
const QUOTED = /["']([^"']+)["']/g;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse one migration file's source into its chain identity, or `undefined`
|
|
34
|
+
* when it declares no `revision` (not a chain file; skip it).
|
|
35
|
+
*
|
|
36
|
+
* `down_revision` may be `"abc"`, `None`, or a tuple `('a', 'b')` (a merge
|
|
37
|
+
* migration) — the parents are every quoted string on the right-hand side, so
|
|
38
|
+
* `None`/empty reads as a root without special-casing syntax.
|
|
39
|
+
*/
|
|
40
|
+
export function parseChainSource(path: string, source: string): ChainEntry | undefined {
|
|
41
|
+
const id = REVISION_LINE.exec(source)?.[1];
|
|
42
|
+
if (id === undefined) return undefined;
|
|
43
|
+
const parents: string[] = [];
|
|
44
|
+
const rhs = DOWN_REVISION_LINE.exec(source)?.[1] ?? "";
|
|
45
|
+
for (const m of rhs.matchAll(QUOTED)) {
|
|
46
|
+
const parent = m[1];
|
|
47
|
+
if (parent !== undefined && !parents.includes(parent)) parents.push(parent);
|
|
48
|
+
}
|
|
49
|
+
return { path, id, parents };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Post-image source of one diff file: the added lines, rejoined as a file. */
|
|
53
|
+
function postImageSource(file: PrDiffFile): string {
|
|
54
|
+
const hunks = file.hunks ?? "";
|
|
55
|
+
const lines: string[] = [];
|
|
56
|
+
for (const line of hunks.split("\n")) {
|
|
57
|
+
if (line.startsWith("+")) lines.push(line.slice(1));
|
|
58
|
+
}
|
|
59
|
+
return lines.join("\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Whether one diff entry lives inside the chain directory. */
|
|
63
|
+
function inChainDir(dir: string, path: string): boolean {
|
|
64
|
+
return path.startsWith(`${dir}/`) && path.endsWith(".py");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What the PR does to the chain: entries added, entries changed (post-image
|
|
69
|
+
* used — a modified file's base entry stands when its added lines declare no
|
|
70
|
+
* revision), and chain files deleted.
|
|
71
|
+
*/
|
|
72
|
+
export function chainEntriesFromDiff(
|
|
73
|
+
files: readonly PrDiffFile[],
|
|
74
|
+
dir: string,
|
|
75
|
+
): { added: ChainEntry[]; changed: ChainEntry[]; deleted: string[] } {
|
|
76
|
+
const added: ChainEntry[] = [];
|
|
77
|
+
const changed: ChainEntry[] = [];
|
|
78
|
+
const deleted: string[] = [];
|
|
79
|
+
for (const file of files) {
|
|
80
|
+
if (!inChainDir(dir, file.path)) continue;
|
|
81
|
+
if (file.status === "removed") {
|
|
82
|
+
deleted.push(file.path);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const entry = parseChainSource(file.path, postImageSource(file));
|
|
86
|
+
if (entry === undefined) continue;
|
|
87
|
+
if (file.status === "added") added.push(entry);
|
|
88
|
+
else changed.push(entry); // modified, or renamed into the chain directory
|
|
89
|
+
}
|
|
90
|
+
return { added, changed, deleted };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ChainViolationInput {
|
|
94
|
+
base: ChainEntry[];
|
|
95
|
+
added: ChainEntry[];
|
|
96
|
+
changed: ChainEntry[];
|
|
97
|
+
deleted: string[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Refuse a merge that would corrupt the ordered chain, or return [] to pass.
|
|
102
|
+
*
|
|
103
|
+
* A combined graph that no PR touches is never judged: a broken base is not
|
|
104
|
+
* this PR's problem.
|
|
105
|
+
*/
|
|
106
|
+
export function chainViolations(input: ChainViolationInput): string[] {
|
|
107
|
+
const touched = input.added.length + input.changed.length + input.deleted.length;
|
|
108
|
+
if (touched === 0) return [];
|
|
109
|
+
|
|
110
|
+
const violations: string[] = [];
|
|
111
|
+
|
|
112
|
+
// 1. The one deletion nothing can justify: a published migration may not
|
|
113
|
+
// disappear from the base branch.
|
|
114
|
+
for (const path of input.deleted) {
|
|
115
|
+
if (input.base.some((entry) => entry.path === path)) {
|
|
116
|
+
violations.push(`deletes ${path}, which exists on the base branch — a published migration may never be deleted`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 2. Reusing an id that already exists on the base at a DIFFERENT path makes
|
|
121
|
+
// two files claim one revision; the graph stops being an order.
|
|
122
|
+
const baseByPath = new Map(input.base.map((entry) => [entry.path, entry]));
|
|
123
|
+
for (const entry of [...input.added, ...input.changed]) {
|
|
124
|
+
const owner = input.base.find((b) => b.id === entry.id);
|
|
125
|
+
if (owner !== undefined && owner.path !== entry.path) {
|
|
126
|
+
violations.push(`reuses revision id ${entry.id}, already declared by ${owner.path} on the base branch`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 3. Head coherence: the combined graph — base, minus deleted files, minus
|
|
131
|
+
// changed files' base entries (replaced by their post-image), plus added
|
|
132
|
+
// entries — must have exactly one head. Two heads means a stale parent or
|
|
133
|
+
// a fork nobody repaired; one head after a repair merge is the goal. A
|
|
134
|
+
// duplicate id inside the PR's own entries is incoherent too.
|
|
135
|
+
const combined = new Map(baseByPath);
|
|
136
|
+
for (const path of input.deleted) combined.delete(path);
|
|
137
|
+
for (const entry of input.changed) combined.delete(entry.path);
|
|
138
|
+
for (const entry of [...input.changed, ...input.added]) combined.set(entry.path, entry);
|
|
139
|
+
|
|
140
|
+
const ids = [...combined.values()].map((entry) => entry.id);
|
|
141
|
+
const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index);
|
|
142
|
+
if (duplicateIds.length > 0) {
|
|
143
|
+
violations.push(
|
|
144
|
+
`the migration graph would declare duplicate revision id${duplicateIds.length > 1 ? "s" : ""} ${[...new Set(duplicateIds)].join(", ")} after merge`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const referenced = new Set([...combined.values()].flatMap((entry) => entry.parents));
|
|
149
|
+
const heads = ids.filter((id, index) => ids.indexOf(id) === index && !referenced.has(id));
|
|
150
|
+
if (heads.length !== 1) {
|
|
151
|
+
violations.push(
|
|
152
|
+
`the migration graph would have ${heads.length} heads after merge (${heads.join(", ") || "none"}); parent(s) must be the current tip`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return violations;
|
|
157
|
+
}
|