omp-conductor 0.10.0 → 0.13.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 +73 -19
- package/package.json +1 -1
- package/src/board.ts +125 -11
- package/src/briefs/orchestrator.md +50 -4
- package/src/briefs/policy.md +4 -1
- package/src/chain-check.ts +157 -0
- package/src/cli.ts +188 -19
- package/src/config.ts +195 -15
- package/src/daemon.ts +1017 -119
- package/src/diff-flags.ts +48 -3
- package/src/digest-schedule.ts +59 -0
- package/src/escalate.ts +17 -0
- package/src/failure-class.ts +31 -4
- package/src/fleet.ts +8 -3
- package/src/gitops.ts +49 -0
- package/src/omp.ts +44 -9
- package/src/orchestrator-tick.ts +94 -9
- package/src/orchestrator.ts +3 -2
- package/src/plugin.ts +46 -4
- package/src/release-policy.ts +66 -6
- package/src/reports.ts +5 -6
- package/src/session-host.ts +23 -6
- package/src/setup.ts +43 -10
- package/src/store.ts +263 -31
- package/src/tracker/github.ts +261 -56
- package/src/types.ts +251 -32
- package/src/verbs/actions.ts +127 -15
- package/src/verbs/protocol.ts +7 -6
- package/src/verbs/server.ts +144 -13
- package/src/worker.ts +183 -17
- package/src/worktree.ts +5 -0
package/README.md
CHANGED
|
@@ -989,7 +989,8 @@ rest. `0` is a real value (a hard stop), not "unset".
|
|
|
989
989
|
| `maxConcurrentWorkersPerRepo` | `1` | Max live workers in the **same repo**. The mirror, branch-protection staleness and shared CI egress are all per-repo collision domains, so extra slots should land on other repos. Raise it only when a repo genuinely needs two workers at once. |
|
|
990
990
|
| `dailySpendUsd` | `25` | Rolling-day spend ceiling in USD, or `null` for no spend gate. `0` is a hard stop. Metered from assistant `usage.cost.total`. |
|
|
991
991
|
| `planUsage` | `null` (unmetered) | Subscription/plan allowance guard: `{ "windowId": "anthropic:7d", "maxUsedFraction": 0.85 }`, or `null` for no plan gate. Independent of `dailySpendUsd` — see [Plan allowance](#plan-allowance-planusage) below. |
|
|
992
|
-
| `workerMaxTurns` | `120` |
|
|
992
|
+
| `workerMaxTurns` | `120` | Base ceiling for each new worker. Catches a session looping without converging; use `omp-conductor extend` to raise one live run or one issue's next attempt without changing this default. |
|
|
993
|
+
| `workerMaxTurnsCeiling` | `240` (twice the effective `workerMaxTurns` when omitted) | Upper bound for per-issue turn extensions. Prevents the loopback control from granting an unbounded worker budget. |
|
|
993
994
|
| `workerWallClockMs` | `5400000` (90 minutes) | Wall-clock ceiling for one worker. A session that is merely stuck spends no turns, so turns alone cannot detect it. |
|
|
994
995
|
| `maxAttemptsPerIssue` | `2` | Failed implementation or CI attempts before escalation. Operational stops do not consume this budget, so salvage can continue without stealing the retry needed for a real failure. |
|
|
995
996
|
| `maxContinuationsPerIssue` | `2` | Cap-kill, daemon-orphan and answered-block resumes before escalation. This independently bounds crash/resume loops. |
|
|
@@ -1005,12 +1006,31 @@ Work resumes only after `omp-conductor resume` (or `/conductor resume`).
|
|
|
1005
1006
|
`workerMaxTurns` and `workerWallClockMs` are enforced inside the session driver.
|
|
1006
1007
|
The daemon reads a live run's effective turn ceiling at every turn boundary. Use
|
|
1007
1008
|
`omp-conductor extend <issue> --turns N [--project NAME]` to raise it without
|
|
1008
|
-
restarting or reconstructing the session.
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1009
|
+
restarting or reconstructing the session. For a live worker, extension is
|
|
1010
|
+
monotonic: equal or lower values are refused. If the latest run is failed,
|
|
1011
|
+
killed, orphaned, or blocked, the command instead stores a one-shot ceiling for
|
|
1012
|
+
that issue's next attempt. A next-attempt ceiling must exceed the effective
|
|
1013
|
+
project base, and every extension must stay at or below
|
|
1014
|
+
`workerMaxTurnsCeiling`. `status` shows both active ceilings and pending
|
|
1015
|
+
next-attempt overrides. The store consumes an override atomically when it claims
|
|
1016
|
+
the next run, so later attempts return to the project base. Config edits change
|
|
1017
|
+
that base on the next tick but do not change workers already in flight. A cap
|
|
1018
|
+
that fires aborts the run, records it as `killed`, and names the ceiling in the
|
|
1019
|
+
escalation.
|
|
1020
|
+
|
|
1021
|
+
Pause one live worker cooperatively with
|
|
1022
|
+
`omp-conductor worker pause <issue> [--project NAME]`. The daemon aborts the
|
|
1023
|
+
active turn to an idle harness state, freezes the remaining wall-clock budget,
|
|
1024
|
+
and keeps the run in the Running lane. `omp-conductor worker resume <issue>`
|
|
1025
|
+
continues the same session with a prompt to re-check its last action before
|
|
1026
|
+
proceeding. To end that run instead, use
|
|
1027
|
+
`omp-conductor worker stop <issue> --reason TEXT [--project NAME]`. Stop works
|
|
1028
|
+
from running or paused, salvages dirty work before removing the worktree, records
|
|
1029
|
+
the distinct terminal `stopped` state, and removes `agent:in-progress` through
|
|
1030
|
+
the label outbox. A salvage failure keeps the only copy in place and reports its
|
|
1031
|
+
path. Stopped runs consume neither implementation-failure nor continuation
|
|
1032
|
+
budget. Repeating stop reports the already-terminal state. These worker controls
|
|
1033
|
+
are separate from fleet-level `pause`, which stops new claims.
|
|
1014
1034
|
|
|
1015
1035
|
### Plan allowance (`planUsage`)
|
|
1016
1036
|
|
|
@@ -1587,7 +1607,8 @@ A complete, valid config for one project with two target repos:
|
|
|
1587
1607
|
{ "cmd": "bun run lint", "cwd": "." },
|
|
1588
1608
|
{ "cmd": "bun test", "cwd": "." }
|
|
1589
1609
|
],
|
|
1590
|
-
"graphProject": "~/.cache/conductor-graph/acme/api"
|
|
1610
|
+
"graphProject": "~/.cache/conductor-graph/acme/api",
|
|
1611
|
+
"migrations": { "dir": "backend/alembic/versions" }
|
|
1591
1612
|
},
|
|
1592
1613
|
"worker": {
|
|
1593
1614
|
"name": "worker",
|
|
@@ -1659,12 +1680,13 @@ Field notes:
|
|
|
1659
1680
|
| `routing.repos` | At least one entry, or nothing can be routed. `name` defaults to the map key, `defaultBranch` to `main`. |
|
|
1660
1681
|
| `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
1682
|
| `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). |
|
|
1683
|
+
| `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
1684
|
| `caps` | Per-project overrides; omit it or pin only the fields you want to change. |
|
|
1663
1685
|
| `escalation.fallbackToIssueComment` | Defaults to `true`. Absent means "yes, still tell me". |
|
|
1664
1686
|
| `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
1687
|
| `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
1688
|
| `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
|
|
1689
|
+
| `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
1690
|
| `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
1691
|
| `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
1692
|
| `workspaceRoot` / `mirrorRoot` | Optional; default to `worktrees/` and `mirrors/` under the state directory. `~` is expanded. |
|
|
@@ -1673,6 +1695,27 @@ Prefer an SSH `cloneUrl`, or an https URL backed by a credential helper. A clone
|
|
|
1673
1695
|
with credentials embedded is persisted into the mirror's git config, exactly as it
|
|
1674
1696
|
would be for a hand-run clone.
|
|
1675
1697
|
|
|
1698
|
+
### Reporting policy (`reporting`)
|
|
1699
|
+
|
|
1700
|
+
What may interrupt the operator's phone, and when the daily rollup happens. Two
|
|
1701
|
+
spellings, mutually exclusive in one config (the loader rejects a `scope` next to
|
|
1702
|
+
`interruptOn`/`digest`):
|
|
1703
|
+
|
|
1704
|
+
- **Preset** — `reporting.scope`, the three legacy values, mapped verbatim:
|
|
1705
|
+
- `material` (default) → `interruptOn: [tier2, decision-needed, fleet-stopped, confirmed-failure, material]`, digest `per-tick`.
|
|
1706
|
+
- `decisions` → `interruptOn: [tier2, decision-needed, fleet-stopped]`, digest `per-tick`.
|
|
1707
|
+
- `escalations` → `interruptOn: [tier2, fleet-stopped]`, digest `daily` (model-timed).
|
|
1708
|
+
- **Explicit** — `reporting: { "interruptOn": ["tier2", "fleet-stopped", ...], "digest": { "cadence": "none" | "per-tick" | "daily" } }`.
|
|
1709
|
+
`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`.
|
|
1710
|
+
|
|
1711
|
+
A tier-2 escalation whose category is **not** in `interruptOn` is not dropped: it
|
|
1712
|
+
is held (`held_notices`) and the next accepted digest is its delivery authority.
|
|
1713
|
+
The digest itself is at-most-once per local day (`digest:<YYYY-MM-DD>` in the
|
|
1714
|
+
configured zone), which remains the delivery authority across restarts. A
|
|
1715
|
+
scheduled `daily` digest is only sent on a day it has not already run, once the
|
|
1716
|
+
local clock has passed `at`; a restart after `at` still sends today's (one
|
|
1717
|
+
catch-up), and a fully missed day is skipped, never sent late.
|
|
1718
|
+
|
|
1676
1719
|
### Merge and release preconditions (`policy`)
|
|
1677
1720
|
|
|
1678
1721
|
These used to be sentences in your `POLICY.md`: when a PR may be merged, what
|
|
@@ -1694,7 +1737,7 @@ policy instead of restating it — no threshold lives in two places.
|
|
|
1694
1737
|
|
|
1695
1738
|
| Field | Values | Default | Means |
|
|
1696
1739
|
| --- | --- | --- | --- |
|
|
1697
|
-
| `requires` | `runs-settled`, `no-open-prs`, `queue-drained`, `epic-children-closed` | `["runs-settled"]` | What must already have landed. Order and duplicates do not matter; the loader canonicalises. |
|
|
1740
|
+
| `requires` | `runs-settled`, `no-open-prs`, `queue-drained`, `base-branch-green`, `epic-children-closed` | `["runs-settled"]` | What must already have landed. `base-branch-green` requires the newest observed post-merge workflow verdict for that routed repository to be green; pending, unknown, red, or no observation refuses release. Order and duplicates do not matter; the loader canonicalises. |
|
|
1698
1741
|
| `requiredChecks` | any check names | `[]` | Checks that must be green on the branch being released. Empty means every check it reports. |
|
|
1699
1742
|
| `artefacts` | any names | `[]` | The packages or images this project releases. **Empty denies**: nothing has been authorised to ship. |
|
|
1700
1743
|
| `environments` | any names | `[]` | Deploy targets. **Empty denies** every environment. |
|
|
@@ -1959,6 +2002,9 @@ omp-conductor disarm [--project NAME]
|
|
|
1959
2002
|
omp-conductor release-pane [--project NAME]
|
|
1960
2003
|
omp-conductor tail <issue> [--project NAME]
|
|
1961
2004
|
omp-conductor extend <issue> --turns N [--project NAME]
|
|
2005
|
+
omp-conductor worker pause <issue> [--project NAME]
|
|
2006
|
+
omp-conductor worker resume <issue> [--project NAME]
|
|
2007
|
+
omp-conductor worker stop <issue> --reason TEXT [--project NAME]
|
|
1962
2008
|
omp-conductor unblock <issue> [--force] [--no-requeue] [--project NAME]
|
|
1963
2009
|
omp-conductor verb <conductor_*> [--project NAME] [--arg k=v ...]
|
|
1964
2010
|
omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
|
|
@@ -1982,7 +2028,7 @@ omp-conductor help
|
|
|
1982
2028
|
| `restart [--now] [--timeout SECONDS] [--port N] [--project NAME]` | Drains the fleet by default: pause new claims, wait until live workers reach `0 / N` (bounded by `--timeout SECONDS`, default 1800 = 30 min), restart, then restore the prior dispatch state. Prefer `systemctl restart` when the unit owns the live pid so the replacement stays supervised; otherwise `stop` then `start`, inheriting the running daemon's port and project unless a flag overrides them. `--now` skips the drain and restarts immediately, orphaning any live runs (old behaviour). A drain that hits `--timeout` restarts nothing and leaves dispatch paused — `omp-conductor resume` lifts it, or re-run `restart` to keep waiting. The new process **salvages dirty live worktrees before orphaning** those rows — see [Deploying a new package onto a busy fleet](#deploying-a-new-package-onto-a-busy-fleet). |
|
|
1983
2029
|
| `upgrade [--to VERSION] [--project NAME]` | Deterministically update the Bun-global CLI, omp plugin, Herdr recovery plugin, and managed brief as one release. Resolves the npm version and exact `gitHead`, pauses only new claims, drains active workers, installs all surfaces, reloads Herdr and the daemon, waits for pane recovery, verifies identities and fleet health twice, then restores the original dispatch state. A no-op when already current. Failure leaves dispatch paused. Must run outside a Herdr-managed session. |
|
|
1984
2030
|
| `status [--project NAME]` | Layered fleet report first: `dispatch` / `ticks` / next scheduled tick / `pane` / `recovery` / `herdr` / `telegram` / `brief` / `decisions` / optional `failure classes` and `code graph` / `daemon`, then the project body. The project body includes the latest completed dispatch timestamp, ready/routed/admitted counts, bounded hold groups, and the GitHub API budget (`graphql` / `core` remaining and reset, in the caps block); API failures are marked `DEGRADED` so queue starvation cannot look idle. The next tick comes from the live heartbeat process, not a guess from log timestamps. Telegram health uses `getMe` to prove API authentication without sending a message and reports inbound bridge configuration separately. Configured graphs report prerequisites, indexed repos, timer state, and refresh freshness without blocking dispatch. A `reports` block lists everything the outbox has not delivered, with its age, and prints `pending` (nobody has it) differently from `SENDING` (outcome unknown, it may already have arrived) — see [Report delivery](#report-delivery-the-outbox). The daemon block includes `rss` from `/healthz`; live workers add a busy-deploy warning. A `.conductor-stalled` marker adds an `orchestrator STALLED since …` line. |
|
|
1985
|
-
| `ledger [--issue N] [--limit N]` |
|
|
2031
|
+
| `ledger [--issue N] [--limit N]` | The action audit: every [mediated-verb](#the-mediated-verbs-126) mutation and every next-attempt turn budget. Verb entries include the arguments, decision, named refusal, and resulting SHA. Turn-budget entries remain after an override is replaced or consumed. Reads (`conductor_pr_status`) are absent so polling cannot bury the signal. `--issue` narrows both histories; `--limit` defaults to 50. Recent verb refusals and pending turn overrides also appear in `status`. |
|
|
1986
2032
|
| `board [--project NAME]` | Live keyboard-driven kanban over the same SQLite and `/healthz` truth as `status`, plus the tracker's current labels: Queue, Claimed, Running, Green, Blocked, Failed, Orphaned, the last 24 hours of Merged and Settled, and Parked (an issue the tracker has not confirmed closed — still open, or a label read that failed — so nothing dispatches it until a human labels it). Columns are mutually exclusive and describe current state, not the newest run row, so a requeued issue is queued rather than failed and a closed issue is neither. Refreshes run/spend/turn values every second, and health plus the label read every ten seconds. `Enter` follows the selected transcript in place; `u` invokes the existing unblock workflow on a Blocked, Failed, or Orphaned card; `i` / `p` open the issue / PR; `r` refreshes health; `?` shows all keys. Requires an interactive terminal of at least 50×20. |
|
|
1987
2033
|
| `hold [--project NAME]` | Soft stop: pause claiming **and** disarm ticks. Daemon and pane stay up. Prefer this over `pause` when the intent is "stop the conductor" without killing processes. See [Stop the conductor](#stop-the-conductor-hold--halt). |
|
|
1988
2034
|
| `halt [--pane] [--project NAME]` | `hold`, then stop the dispatch daemon (systemctl-aware). Pane stays up unless `--pane` is passed. `halt --pane` also pins herdr-conductor recovery off for the conductor agent only — it does **not** stop `herdr-fleet.service` or any other herdr session. Fail-closed: exits nonzero unless the agent is proven gone. |
|
|
@@ -1990,7 +2036,9 @@ omp-conductor help
|
|
|
1990
2036
|
| `disarm [--project NAME]` | Remove the arm marker so ticks skip. Processes untouched. |
|
|
1991
2037
|
| `release-pane [--project NAME]` | Clear the `halt --pane` recovery pin so herdr-conductor may resume the fleet agent again. |
|
|
1992
2038
|
| `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
|
-
| `extend <issue> --turns N [--project NAME]` |
|
|
2039
|
+
| `extend <issue> --turns N [--project NAME]` | Raise a live worker's effective turn ceiling through its owning daemon without restarting its session. If the latest run is failed, killed, orphaned, or blocked and has no live controller, store a one-shot ceiling for that issue's next claimed attempt instead. A next-attempt value must exceed the project base, every extension must stay at or below `workerMaxTurnsCeiling`, and live extensions remain monotonic. The pending value appears in `status`, is recorded in `ledger`, and is consumed atomically by one claim. |
|
|
2040
|
+
| `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 separate from fleet-level `pause`, which refuses new claims and work-starting mutations while allowing pre-pause completion work and releases. |
|
|
2041
|
+
| `worker stop <issue> --reason TEXT [--project NAME]` | Terminally end a running or cooperatively paused worker. The reason is required (1–500 characters) and persisted on the run. The command waits for settlement, records the distinct `stopped` state, salvages and publishes dirty work, removes `agent:in-progress` through the durable label outbox, and consumes neither failed-attempt nor continuation budget. If salvage fails, the tree holding the only copy stays in place and the command names it. Repeating stop is idempotent and reports the run's already-terminal state. |
|
|
1994
2042
|
| `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
2043
|
| `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
2044
|
| `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. |
|
|
@@ -2003,7 +2051,7 @@ omp-conductor help
|
|
|
2003
2051
|
| `daemon --once` | Run a single tick, wait for workers admitted by that tick, and exit. No HTTP server or pidfile — a drill must not register itself as the daemon, or the next reader believes it and the real daemon's in-flight runs get reconciled as orphans. |
|
|
2004
2052
|
| `--port N` | Accepted by `start`, `restart` and `daemon`. Both `--port 9000` and `--port=9000` work; missing or out of range exits `2` rather than falling back to the default, because probing the wrong endpoint is worse than a hard failure. |
|
|
2005
2053
|
| `--project NAME` | Pick the project to service. One daemon process serves exactly one project; with several configured projects the name is required. |
|
|
2006
|
-
| `pause [--reason TEXT]` | Stop
|
|
2054
|
+
| `pause [--reason TEXT]` | Stop new claims and work-starting mutations only. The running daemon notices on its next tick; runs already in flight finish. The orchestrator may still merge, update, or label runs admitted before the pause, and may release when the release policy's own preconditions hold. The orchestrator heartbeat keeps ticking if armed — its gate is the arm marker, not this flag. Per-worker pause is separate. Prefer `hold` to silence both. `--reason TEXT` is recorded in the pause sentinel, which `status` shows as the pause provenance. |
|
|
2007
2055
|
| `resume` | Clear pause only — does **not** re-arm. Run `arm` after an inbound Telegram proof to resume ticks. |
|
|
2008
2056
|
| `--version`, `-V`, `version` | Print the installed `omp-conductor` package version and exit `0`. Works from the global binary and npm/plugin install because it reads the package metadata beside the shipped CLI. |
|
|
2009
2057
|
| `graph-setup` | Print how to set up the code-graph indexes workers query instead of grepping: a `git clone` for every index-only clone that does not exist yet, the one-shot index command per repo, and a `cbm-reindex.service` + `cbm-reindex.timer` pair generated from the project's own repos and branches. Reads only, so it is safe on a host where you are not root. Exits `1` when no repo in the project has [`graphProject`](#configuration) set, because the fix is a wizard answer rather than a flag. See [Code-graph discovery](#code-graph-discovery). |
|
|
@@ -2016,8 +2064,11 @@ omp-conductor help
|
|
|
2016
2064
|
| `help`, `--help`, `-h` | Print usage. An unknown or missing verb prints it too, and exits `2`. |
|
|
2017
2065
|
|
|
2018
2066
|
Pause is a flag file under the state directory, so it applies to every project and
|
|
2019
|
-
survives a daemon restart.
|
|
2020
|
-
|
|
2067
|
+
survives a daemon restart. It refuses new claims and work-starting mutations,
|
|
2068
|
+
allows completion verbs only for runs admitted before the pause, and leaves
|
|
2069
|
+
`conductor_release` to its normal authority, grant, and precondition checks.
|
|
2070
|
+
Per-worker pause is independent. Hold also removes the arm marker the heartbeat
|
|
2071
|
+
reads, so both brains go quiet without killing processes.
|
|
2021
2072
|
|
|
2022
2073
|
These are available in-session as `/conductor setup`, `/conductor status`,
|
|
2023
2074
|
`/conductor hold`, `/conductor halt [--pane]`, `/conductor arm`, `/conductor disarm`,
|
|
@@ -2177,7 +2228,7 @@ What holds the orchestrator instead:
|
|
|
2177
2228
|
| | |
|
|
2178
2229
|
| --- | --- |
|
|
2179
2230
|
| **The brief** | `ORCHESTRATOR.md`'s hard boundaries — never read or edit a worker's checkout or the mirror cache; when you need a run's code, read its PR. |
|
|
2180
|
-
| **The
|
|
2231
|
+
| **The action ledger** | Every `conductor_*` mutation and operator-selected next-attempt turn budget remains auditable. `omp-conductor ledger` shows both, including refused calls and consumed or replaced budget overrides. |
|
|
2181
2232
|
| **The dispatcher** | Merge, label and release authority are checked in the daemon against the operator's grant, across a process boundary, never in the prompt. |
|
|
2182
2233
|
|
|
2183
2234
|
Unconfined means auditable, not licensed. `orchestratorReadPaths` is retired: it
|
|
@@ -2292,9 +2343,12 @@ no socket it fails closed and says so, rather than reaching for `git push`.
|
|
|
2292
2343
|
|
|
2293
2344
|
### The ledger
|
|
2294
2345
|
|
|
2295
|
-
Every mutating call is recorded with its arguments, the decision, the
|
|
2296
|
-
refusal reason and any resulting SHA. Reads are not: a status poll every
|
|
2297
|
-
seconds would bury the refusals the record exists to surface.
|
|
2346
|
+
Every mutating verb call is recorded with its arguments, the decision, the
|
|
2347
|
+
named refusal reason and any resulting SHA. Reads are not: a status poll every
|
|
2348
|
+
thirty seconds would bury the refusals the record exists to surface.
|
|
2349
|
+
|
|
2350
|
+
Every `extend` that sets a next-attempt budget also appends an audit entry.
|
|
2351
|
+
Replacing or consuming the pending override does not erase that history.
|
|
2298
2352
|
|
|
2299
2353
|
```console
|
|
2300
2354
|
$ omp-conductor ledger --issue 7
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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";
|
|
@@ -94,7 +95,7 @@ const LIVE_LANES: Partial<Record<RunState, BoardLane>> = {
|
|
|
94
95
|
* that has to stay visible — #173. Differs from {@link LIVE_LANES} in that
|
|
95
96
|
* these are rows, never current work, and from MERGED in that they are not a
|
|
96
97
|
* happy resolution. */
|
|
97
|
-
const PARKED_STATES = new Set<RunState>(["blocked", "failed", "killed", "orphaned"]);
|
|
98
|
+
const PARKED_STATES = new Set<RunState>(["blocked", "failed", "killed", "stopped", "orphaned"]);
|
|
98
99
|
|
|
99
100
|
/** Whether a terminal blocked/failed run is wearing its own state label. When
|
|
100
101
|
* the label is absent the run row is the only record of what happened, so the
|
|
@@ -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
|
|
@@ -335,6 +351,7 @@ the same checks and the same ledger rows, through the CLI:
|
|
|
335
351
|
omp-conductor verb conductor_pr_merge --arg prUrl=<url> --arg headSha=<sha> --arg reason=<reason>
|
|
336
352
|
omp-conductor verb conductor_label --arg issueUrl=<url> --arg label=<name> --arg action=add --arg reason=<reason>
|
|
337
353
|
omp-conductor verb conductor_release --arg shape=git-tag --arg repo=<name> --arg reason=<reason> --arg tag=<tag>
|
|
354
|
+
omp-conductor verb conductor_pr_status --arg prUrl=<url>
|
|
338
355
|
omp-conductor verb conductor_pr_update_branch --arg prUrl=<url>
|
|
339
356
|
omp-conductor verb conductor_pr_update --arg prUrl=<url> --arg title=<title>
|
|
340
357
|
```
|
|
@@ -352,8 +369,37 @@ sessions do not.
|
|
|
352
369
|
| `conductor_label` | always | The label is one this project declared. Lifecycle labels (`agent:in-progress`, `agent:blocked`, `agent:failed`) are refused — those stay the dispatcher's, and `omp-conductor unblock` is how you clear them. |
|
|
353
370
|
| `conductor_release` | `authority.release` is yours | You are the configured holder, the shape is granted, the artefact or environment was declared, and the release preconditions hold. |
|
|
354
371
|
|
|
355
|
-
|
|
356
|
-
|
|
372
|
+
### Stopping and pausing
|
|
373
|
+
|
|
374
|
+
Four controls stop different work:
|
|
375
|
+
|
|
376
|
+
- **Pause one worker:** `omp-conductor worker pause <issue>` cooperatively drains
|
|
377
|
+
the active turn to harness idle, freezes its remaining wall clock, and keeps
|
|
378
|
+
the same live run, session, attempt and worktree. Its slot stays occupied and
|
|
379
|
+
its lifecycle labels stay in place. `omp-conductor worker resume <issue>`
|
|
380
|
+
continues that same session with a prompt to re-check its last action before
|
|
381
|
+
proceeding.
|
|
382
|
+
- **End one worker:** `omp-conductor worker stop <issue> --reason TEXT`
|
|
383
|
+
terminally settles a running or paused run as `stopped`. It salvages dirty
|
|
384
|
+
work, releases the worker slot, and removes `agent:in-progress`; it does not
|
|
385
|
+
spend a failure or continuation budget. Use it when the operator explicitly
|
|
386
|
+
ends obsolete or already-delivered work. A salvage failure keeps the tree and
|
|
387
|
+
names the path; recover that copy before any forced unblock.
|
|
388
|
+
- **Fleet dispatch:** `omp-conductor pause` stops new claims and work-starting
|
|
389
|
+
mutations. Work admitted before the pause may still complete, and completion
|
|
390
|
+
verbs and releases remain available.
|
|
391
|
+
- **Orchestrator ticks:** `omp-conductor disarm` removes the operator-owned
|
|
392
|
+
`ARMED` marker so ticks skip. It does not pause workers or stop processes.
|
|
393
|
+
|
|
394
|
+
Per-worker pause is not SIGSTOP/SIGCONT, fleet pause, unblock/requeue, or a
|
|
395
|
+
durable restart boundary. Daemon loss still follows the normal salvage and
|
|
396
|
+
orphan handling. Pause only when the operator asks to park one worker, or when
|
|
397
|
+
one live run must quiesce before resolving a proven shared-state collision.
|
|
398
|
+
Never pause routine work speculatively.
|
|
399
|
+
|
|
400
|
+
**`conductor_pr_merge` wants the SHA you believe you are merging.** Call
|
|
401
|
+
`conductor_pr_status` with the PR URL; its reply includes the current full SHA.
|
|
402
|
+
Pass that SHA to `conductor_pr_merge`. The dispatcher re-reads the live head
|
|
357
403
|
immediately before merging and refuses on any mismatch, naming both SHAs —
|
|
358
404
|
because any push since you looked invalidates the green you saw. A refusal there
|
|
359
405
|
is the mechanism working: re-read, re-check, call again.
|
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
|
|