omp-conductor 0.12.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 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` | Startup ceiling for each new worker. Catches a session looping without converging; use `omp-conductor extend` to raise one live run without changing this default. |
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,19 +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. Extension is monotonic: equal or lower
1009
- values are refused, as are runs whose live controller has already settled. The
1010
- effective value is persisted and shown beside that active run in `status`; editing
1011
- `config.json` changes defaults for future daemon starts, not workers already in
1012
- flight. A cap that fires aborts the run, records it as `killed`, and names the
1013
- ceiling in the escalation.
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.
1014
1020
 
1015
1021
  Pause one live worker cooperatively with
1016
1022
  `omp-conductor worker pause <issue> [--project NAME]`. The daemon aborts the
1017
1023
  active turn to an idle harness state, freezes the remaining wall-clock budget,
1018
1024
  and keeps the run in the Running lane. `omp-conductor worker resume <issue>`
1019
1025
  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.
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.
1021
1034
 
1022
1035
  ### Plan allowance (`planUsage`)
1023
1036
 
@@ -1724,7 +1737,7 @@ policy instead of restating it — no threshold lives in two places.
1724
1737
 
1725
1738
  | Field | Values | Default | Means |
1726
1739
  | --- | --- | --- | --- |
1727
- | `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. |
1728
1741
  | `requiredChecks` | any check names | `[]` | Checks that must be green on the branch being released. Empty means every check it reports. |
1729
1742
  | `artefacts` | any names | `[]` | The packages or images this project releases. **Empty denies**: nothing has been authorised to ship. |
1730
1743
  | `environments` | any names | `[]` | Deploy targets. **Empty denies** every environment. |
@@ -1991,6 +2004,7 @@ omp-conductor tail <issue> [--project NAME]
1991
2004
  omp-conductor extend <issue> --turns N [--project NAME]
1992
2005
  omp-conductor worker pause <issue> [--project NAME]
1993
2006
  omp-conductor worker resume <issue> [--project NAME]
2007
+ omp-conductor worker stop <issue> --reason TEXT [--project NAME]
1994
2008
  omp-conductor unblock <issue> [--force] [--no-requeue] [--project NAME]
1995
2009
  omp-conductor verb <conductor_*> [--project NAME] [--arg k=v ...]
1996
2010
  omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
@@ -2014,7 +2028,7 @@ omp-conductor help
2014
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). |
2015
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. |
2016
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. |
2017
- | `ledger [--issue N] [--limit N]` | Every [mediated-verb](#the-mediated-verbs-126) call and how the daemon decided it: the verb, the arguments it was given, allow or refuse, the named refusal reason, and any resulting SHA. Sessions cannot push, open a PR, merge, label or release except through those verbs, so this is the record of what they *tried* as well as what they did — the thing an escalation cites. Reads (`conductor_pr_status`) are deliberately absent: a poll every thirty seconds would bury the refusals. `--issue` narrows to one issue's run; `--limit` defaults to 50. The newest few also appear in `status`. |
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`. |
2018
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. |
2019
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). |
2020
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. |
@@ -2022,8 +2036,9 @@ omp-conductor help
2022
2036
  | `disarm [--project NAME]` | Remove the arm marker so ticks skip. Processes untouched. |
2023
2037
  | `release-pane [--project NAME]` | Clear the `halt --pane` recovery pin so herdr-conductor may resume the fleet agent again. |
2024
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>`. |
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. |
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. |
2027
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. |
2028
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`. |
2029
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. |
@@ -2036,7 +2051,7 @@ omp-conductor help
2036
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. |
2037
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. |
2038
2053
  | `--project NAME` | Pick the project to service. One daemon process serves exactly one project; with several configured projects the name is required. |
2039
- | `pause [--reason TEXT]` | Stop claiming new work only. The running daemon notices on its next tick; runs already in flight finish. The orchestrator heartbeat keeps ticking if armed — its gate is the arm marker, not this flag. Prefer `hold` to silence both. `--reason TEXT` is recorded in the pause sentinel, which `status` shows as the pause provenance. |
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. |
2040
2055
  | `resume` | Clear pause only — does **not** re-arm. Run `arm` after an inbound Telegram proof to resume ticks. |
2041
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. |
2042
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). |
@@ -2049,8 +2064,11 @@ omp-conductor help
2049
2064
  | `help`, `--help`, `-h` | Print usage. An unknown or missing verb prints it too, and exits `2`. |
2050
2065
 
2051
2066
  Pause is a flag file under the state directory, so it applies to every project and
2052
- survives a daemon restart. Hold also removes the arm marker the heartbeat reads,
2053
- so both brains go quiet without killing processes.
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.
2054
2072
 
2055
2073
  These are available in-session as `/conductor setup`, `/conductor status`,
2056
2074
  `/conductor hold`, `/conductor halt [--pane]`, `/conductor arm`, `/conductor disarm`,
@@ -2210,7 +2228,7 @@ What holds the orchestrator instead:
2210
2228
  | | |
2211
2229
  | --- | --- |
2212
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. |
2213
- | **The verb ledger** | Every `conductor_*` call records what was asked for and what the dispatcher decided, refusals included. `omp-conductor ledger` is the audit. |
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. |
2214
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. |
2215
2233
 
2216
2234
  Unconfined means auditable, not licensed. `orchestratorReadPaths` is retired: it
@@ -2325,9 +2343,12 @@ no socket it fails closed and says so, rather than reaching for `git push`.
2325
2343
 
2326
2344
  ### The ledger
2327
2345
 
2328
- Every mutating call is recorded with its arguments, the decision, the named
2329
- refusal reason and any resulting SHA. Reads are not: a status poll every thirty
2330
- 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.
2331
2352
 
2332
2353
  ```console
2333
2354
  $ omp-conductor ledger --issue 7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.12.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
@@ -95,7 +95,7 @@ const LIVE_LANES: Partial<Record<RunState, BoardLane>> = {
95
95
  * that has to stay visible — #173. Differs from {@link LIVE_LANES} in that
96
96
  * these are rows, never current work, and from MERGED in that they are not a
97
97
  * happy resolution. */
98
- const PARKED_STATES = new Set<RunState>(["blocked", "failed", "killed", "orphaned"]);
98
+ const PARKED_STATES = new Set<RunState>(["blocked", "failed", "killed", "stopped", "orphaned"]);
99
99
 
100
100
  /** Whether a terminal blocked/failed run is wearing its own state label. When
101
101
  * the label is absent the run row is the only record of what happened, so the
@@ -351,6 +351,7 @@ the same checks and the same ledger rows, through the CLI:
351
351
  omp-conductor verb conductor_pr_merge --arg prUrl=<url> --arg headSha=<sha> --arg reason=<reason>
352
352
  omp-conductor verb conductor_label --arg issueUrl=<url> --arg label=<name> --arg action=add --arg reason=<reason>
353
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>
354
355
  omp-conductor verb conductor_pr_update_branch --arg prUrl=<url>
355
356
  omp-conductor verb conductor_pr_update --arg prUrl=<url> --arg title=<title>
356
357
  ```
@@ -368,8 +369,37 @@ sessions do not.
368
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. |
369
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. |
370
371
 
371
- **`conductor_pr_merge` wants the SHA you believe you are merging.** Read it with
372
- `conductor_pr_status` and pass it. The dispatcher re-reads the live head
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
373
403
  immediately before merging and refuses on any mismatch, naming both SHAs —
374
404
  because any push since you looked invalidates the green you saw. A refusal there
375
405
  is the mechanism working: re-read, re-check, call again.
package/src/cli.ts CHANGED
@@ -106,6 +106,7 @@ usage:
106
106
  omp-conductor extend <issue> --turns N [--project NAME]
107
107
  omp-conductor worker pause <issue> [--project NAME]
108
108
  omp-conductor worker resume <issue> [--project NAME]
109
+ omp-conductor worker stop <issue> --reason TEXT [--project NAME]
109
110
  omp-conductor unblock <issue> [--force] [--no-requeue] [--project NAME]
110
111
  omp-conductor verb <conductor_*> [--project NAME] [--arg k=v ...]
111
112
  omp-conductor daemon [--once] [--port N] [--project NAME]
@@ -172,8 +173,9 @@ usage:
172
173
  the daemon rather than terminals, so this is the only way to watch
173
174
  one live. Runs until Ctrl-C, or until the run has finished and its
174
175
  transcript has stopped growing.
175
- extend monotonically raise a live run's turn ceiling without restarting its
176
- session. Refuses settled runs and values at or below its current cap.
176
+ extend raise a live run's turn ceiling, or set a bounded one-shot ceiling
177
+ after a failed, killed, orphaned or blocked run. Refuses values outside
178
+ configured bounds.
177
179
  worker cooperatively pause one live worker at harness idle, then resume the
178
180
  same session with a continuation prompt. Its wall clock is frozen
179
181
  while parked. Distinct from fleet-level pause/resume.
@@ -612,14 +614,11 @@ try {
612
614
  }
613
615
 
614
616
  /**
615
- * The action ledger (#126): every conductor-verb call, what it asked for,
616
- * and how the daemon decided it.
617
- *
618
- * Its own command as well as a block in `status`, because the two questions
619
- * are different sizes. `status` answers "is anything being refused right
620
- * now"; this answers "what did run 3 actually try to do", which is the
621
- * question an escalation about a run asks, and it needs the whole record
622
- * rather than the newest five lines of every run at once.
617
+ * The action ledger: mediated conductor verbs plus durable per-issue budget
618
+ * changes. Its own command as well as a block in `status`, because the two
619
+ * questions are different sizes. `status` answers "what is pending or being
620
+ * refused now"; this answers "what did run 3 actually try, and what budget
621
+ * did the operator assign", which needs history rather than only live state.
623
622
  */
624
623
  case "ledger": {
625
624
  const cfg = loadConfig();
@@ -641,18 +640,38 @@ try {
641
640
  ...(issue === undefined ? {} : { issue }),
642
641
  limit,
643
642
  });
644
- if (entries.length === 0) {
643
+ const overrides = store.turnOverrideLedger(p.name, {
644
+ ...(issue === undefined ? {} : { issue }),
645
+ limit,
646
+ });
647
+ if (entries.length === 0 && overrides.length === 0) {
645
648
  process.stdout.write(
646
- `no conductor-verb calls recorded for ${p.name}` +
649
+ `no conductor actions recorded for ${p.name}` +
647
650
  `${issue === undefined ? "" : ` on #${String(issue)}`}\n`,
648
651
  );
649
652
  break;
650
653
  }
651
- const refused = entries.filter((e) => e.decision === "refused").length;
652
- process.stdout.write(
653
- `${p.name} ${entries.length} verb call(s), ${refused} refused (newest first)\n` +
654
- `${entries.flatMap(formatVerbLedgerEntry).join("\n")}\n`,
655
- );
654
+ const blocks: string[] = [];
655
+ if (entries.length > 0) {
656
+ const refused = entries.filter((e) => e.decision === "refused").length;
657
+ blocks.push(
658
+ `${p.name} — ${entries.length} verb call(s), ${refused} refused (newest first)\n` +
659
+ entries.flatMap(formatVerbLedgerEntry).join("\n"),
660
+ );
661
+ }
662
+ if (overrides.length > 0) {
663
+ blocks.push(
664
+ `${p.name} — ${overrides.length} turn override(s) (newest first)\n` +
665
+ overrides
666
+ .map(
667
+ (entry) =>
668
+ ` ${new Date(entry.setAt).toISOString()} #${entry.issue} ` +
669
+ `${entry.maxTurns} turns`,
670
+ )
671
+ .join("\n"),
672
+ );
673
+ }
674
+ process.stdout.write(`${blocks.join("\n\n")}\n`);
656
675
  } finally {
657
676
  store.close();
658
677
  }
@@ -774,14 +793,30 @@ try {
774
793
  );
775
794
  const payload = (await response.json()) as {
776
795
  error?: unknown;
796
+ kind?: unknown;
777
797
  runId?: unknown;
778
798
  maxTurns?: unknown;
799
+ issue?: unknown;
800
+ nextAttemptMaxTurns?: unknown;
801
+ baseMaxTurns?: unknown;
779
802
  };
780
803
  if (!response.ok) {
781
804
  throw new Error(
782
805
  typeof payload.error === "string" ? payload.error : `daemon returned HTTP ${response.status}`,
783
806
  );
784
807
  }
808
+ if (
809
+ payload.kind === "next-attempt" &&
810
+ payload.issue === issue &&
811
+ typeof payload.nextAttemptMaxTurns === "number" &&
812
+ typeof payload.baseMaxTurns === "number"
813
+ ) {
814
+ process.stdout.write(
815
+ `#${issue} next attempt turn ceiling set to ${payload.nextAttemptMaxTurns} ` +
816
+ `(project base ${payload.baseMaxTurns})\n`,
817
+ );
818
+ break;
819
+ }
785
820
  if (typeof payload.runId !== "string" || typeof payload.maxTurns !== "number") {
786
821
  throw new Error("daemon returned an invalid turn-extension response");
787
822
  }
@@ -793,13 +828,21 @@ try {
793
828
 
794
829
  case "worker": {
795
830
  const sub = argv[1];
796
- if (sub !== "pause" && sub !== "resume") {
831
+ if (sub !== "pause" && sub !== "resume" && sub !== "stop") {
797
832
  process.stderr.write(
798
- "omp-conductor: worker needs pause or resume, then an issue number\n",
833
+ "omp-conductor: worker needs pause, resume, or stop, then an issue number\n",
799
834
  );
800
835
  process.exit(2);
801
836
  }
802
837
  const issue = issueArg("worker", argv[2]);
838
+ const rawReason = sub === "stop" ? flag(argv, "reason") : undefined;
839
+ const reason = rawReason?.trim().replace(/\s+/g, " ");
840
+ if (sub === "stop" && (reason === undefined || reason === "" || reason.length > 500)) {
841
+ process.stderr.write(
842
+ "omp-conductor: worker stop needs --reason with 1-500 characters\n",
843
+ );
844
+ process.exit(2);
845
+ }
803
846
  const project = findProject(loadConfig(), flag(argv, "project"));
804
847
  const daemon = livingDaemon();
805
848
  if (daemon === undefined) throw new Error("daemon is not running");
@@ -813,25 +856,63 @@ try {
813
856
  {
814
857
  method: "PUT",
815
858
  headers: { "content-type": "application/json" },
816
- body: JSON.stringify({ project: project.name }),
859
+ body: JSON.stringify({
860
+ project: project.name,
861
+ ...(reason === undefined ? {} : { reason }),
862
+ }),
817
863
  },
818
864
  );
819
865
  const payload = (await response.json()) as {
820
866
  error?: unknown;
821
867
  runId?: unknown;
822
868
  phase?: unknown;
869
+ outcome?: unknown;
870
+ state?: unknown;
871
+ reason?: unknown;
872
+ salvageSha?: unknown;
873
+ salvageError?: unknown;
874
+ worktree?: unknown;
823
875
  };
824
876
  if (!response.ok) {
825
877
  throw new Error(
826
878
  typeof payload.error === "string" ? payload.error : `daemon returned HTTP ${response.status}`,
827
879
  );
828
880
  }
881
+ if (sub === "stop") {
882
+ if (
883
+ typeof payload.runId !== "string" ||
884
+ typeof payload.state !== "string" ||
885
+ (payload.outcome !== "stopped" && payload.outcome !== "already-terminal")
886
+ ) {
887
+ throw new Error("daemon returned an invalid worker-stop response");
888
+ }
889
+ if (payload.outcome === "already-terminal") {
890
+ process.stdout.write(
891
+ `#${issue} worker already terminal: ${payload.state} (run ${payload.runId})\n`,
892
+ );
893
+ break;
894
+ }
895
+ if (typeof payload.reason !== "string") {
896
+ throw new Error("daemon returned an invalid worker-stop response");
897
+ }
898
+ process.stdout.write(
899
+ `#${issue} worker stopped (run ${payload.runId}): ${payload.reason}\n`,
900
+ );
901
+ if (typeof payload.salvageSha === "string") {
902
+ process.stdout.write(`work preserved: ${payload.salvageSha}\n`);
903
+ }
904
+ if (typeof payload.salvageError === "string") {
905
+ process.stdout.write(
906
+ `WIP SALVAGE FAILED: ${payload.salvageError}\n` +
907
+ `worktree kept: ${typeof payload.worktree === "string" ? payload.worktree : "(path unavailable)"}\n`,
908
+ );
909
+ }
910
+ break;
911
+ }
829
912
  if (typeof payload.runId !== "string" || typeof payload.phase !== "string") {
830
913
  throw new Error("daemon returned an invalid worker-control response");
831
914
  }
832
- process.stdout.write(
833
- `#${issue} worker ${payload.phase} (run ${payload.runId})\n`,
834
- );
915
+ process.stdout.write(`#${issue} worker ${payload.phase} (run ${payload.runId})\n`);
835
916
  break;
836
917
  }
837
918
 
package/src/config.ts CHANGED
@@ -227,12 +227,16 @@ export function writeConfigRaw(text: string): void {
227
227
  */
228
228
  export function resolveCaps(p: ProjectConfig, defaults: Caps): Caps {
229
229
  const o: Partial<Caps> = p.caps ?? {};
230
+ const workerMaxTurns = o.workerMaxTurns ?? defaults.workerMaxTurns;
230
231
  return {
231
232
  maxConcurrentWorkers: o.maxConcurrentWorkers ?? defaults.maxConcurrentWorkers,
232
233
  maxConcurrentWorkersPerRepo: o.maxConcurrentWorkersPerRepo ?? defaults.maxConcurrentWorkersPerRepo,
233
234
  dailySpendUsd: o.dailySpendUsd !== undefined ? o.dailySpendUsd : defaults.dailySpendUsd,
234
235
  planUsage: o.planUsage !== undefined ? o.planUsage : defaults.planUsage,
235
- workerMaxTurns: o.workerMaxTurns ?? defaults.workerMaxTurns,
236
+ workerMaxTurns,
237
+ workerMaxTurnsCeiling:
238
+ o.workerMaxTurnsCeiling ??
239
+ (o.workerMaxTurns === undefined ? defaults.workerMaxTurnsCeiling : workerMaxTurns * 2),
236
240
  workerWallClockMs: o.workerWallClockMs ?? defaults.workerWallClockMs,
237
241
  maxAttemptsPerIssue: o.maxAttemptsPerIssue ?? defaults.maxAttemptsPerIssue,
238
242
  maxContinuationsPerIssue:
@@ -366,9 +370,13 @@ function validate(parsed: unknown, path: string): ConductorConfig {
366
370
  );
367
371
  }
368
372
 
373
+ const configuredDefaults = coerceCaps(root["defaults"], `"defaults"`, problems, legacyCaps);
374
+ const defaultWorkerMaxTurns = configuredDefaults.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns;
369
375
  const defaults: Caps = {
370
376
  ...DEFAULT_CAPS,
371
- ...coerceCaps(root["defaults"], `"defaults"`, problems, legacyCaps),
377
+ ...configuredDefaults,
378
+ workerMaxTurnsCeiling:
379
+ configuredDefaults.workerMaxTurnsCeiling ?? defaultWorkerMaxTurns * 2,
372
380
  };
373
381
 
374
382
  const rawProjects = root["projects"];