omp-conductor 0.16.2 → 0.17.1
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 +38 -4
- package/REFERENCE.md +18 -12
- package/package.json +2 -1
- package/schema/config.schema.json +16 -0
- package/src/admission.ts +159 -43
- package/src/availability.ts +27 -1
- package/src/briefs/worker.md +2 -0
- package/src/clack-ui.ts +83 -0
- package/src/command-manifest.ts +16 -7
- package/src/commands/arm.ts +11 -3
- package/src/commands/decision.ts +17 -7
- package/src/commands/doctor.ts +18 -1
- package/src/commands/hold.ts +9 -7
- package/src/commands/ledger.ts +25 -4
- package/src/commands/message.ts +32 -4
- package/src/commands/setup.ts +61 -10
- package/src/commands/stats.ts +9 -5
- package/src/commands/status.ts +32 -5
- package/src/commands/tail.ts +13 -1
- package/src/commands/watch.ts +16 -7
- package/src/config-schema.ts +20 -0
- package/src/config.ts +37 -0
- package/src/daemon.ts +1240 -18
- package/src/doctor.ts +310 -22
- package/src/escalate.ts +560 -57
- package/src/failure-class.ts +56 -13
- package/src/fleet.ts +224 -47
- package/src/gitops.ts +103 -24
- package/src/lifecycle.ts +7 -2
- package/src/orchestrator-tick.ts +372 -157
- package/src/privileged.ts +3 -0
- package/src/release-policy.ts +177 -5
- package/src/setup-answers.ts +135 -0
- package/src/setup-host.ts +193 -4
- package/src/setup-install.ts +2 -0
- package/src/setup-probe.ts +1 -0
- package/src/setup-wizard.ts +1296 -101
- package/src/setup.ts +60 -3
- package/src/status-render.ts +11 -1
- package/src/store.ts +333 -12
- package/src/tracker/github.ts +562 -13
- package/src/types.ts +204 -2
- package/src/ui/progress.ts +32 -0
- package/src/ui/style.ts +11 -0
- package/src/upgrade.ts +50 -19
- package/src/verbs/actions.ts +66 -18
- package/src/verbs/protocol.ts +45 -0
- package/src/verbs/server.ts +212 -11
- package/src/wizard-ui.ts +14 -5
- package/src/worker.ts +26 -0
- package/systemd/omp-conductor-recover.sh +73 -0
- package/systemd/recover-unit-test.sh +61 -0
package/README.md
CHANGED
|
@@ -154,6 +154,26 @@ completions this way and adds the rc source line only when it is absent.
|
|
|
154
154
|
|
|
155
155
|
The wizard reads the tracker with the same routing code as the daemon. It shows every issue that the next tick can route.
|
|
156
156
|
|
|
157
|
+
On an interactive TTY, setup uses the styled Clack interface. Piped setup
|
|
158
|
+
keeps the stable line protocol for automation. Set
|
|
159
|
+
`OMP_CONDUCTOR_PLAIN_UI=1` to use the readline interface on a TTY and replace
|
|
160
|
+
Clack progress in other verbs with their stable plain messages.
|
|
161
|
+
|
|
162
|
+
To make an interactive run replayable, save every accepted answer by its
|
|
163
|
+
stable key:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
omp-conductor setup --save-answers ./setup-answers.json
|
|
167
|
+
omp-conductor setup --answers ./setup-answers.json
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
An answers file is a JSON object whose values are strings or booleans.
|
|
171
|
+
Confirmations require booleans, text prompts require strings, and select
|
|
172
|
+
prompts require the exact displayed label. `--answers` never falls back to
|
|
173
|
+
an interactive prompt: a missing key or wrong value fails with the key and
|
|
174
|
+
question name. Piped line input remains supported, but an exhausted pipe
|
|
175
|
+
fails with the `--answers` alternative instead of waiting indefinitely.
|
|
176
|
+
|
|
157
177
|
Nothing changes before the consent step. After consent, setup does these actions:
|
|
158
178
|
|
|
159
179
|
- creates the required labels;
|
|
@@ -381,6 +401,19 @@ fleet, and both are covered above under
|
|
|
381
401
|
paused-fleet failure hides. `tail`, `extend`, `unblock` and the `worker`
|
|
382
402
|
controls are the day-to-day levers:
|
|
383
403
|
|
|
404
|
+
For automation, `status --json` emits the same project, layer, daemon,
|
|
405
|
+
Telegram, code-graph, run, cap, report, and sibling data as the text report.
|
|
406
|
+
The other read-only ledgers have stable JSON forms too:
|
|
407
|
+
|
|
408
|
+
```bash
|
|
409
|
+
omp-conductor ledger --json
|
|
410
|
+
omp-conductor decision list --json
|
|
411
|
+
omp-conductor watch list --json
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
JSON output is never styled. Empty ledgers return their normal top-level object
|
|
415
|
+
with an empty `entries`, `decisions`, or `watches` array.
|
|
416
|
+
|
|
384
417
|
### Outcomes and cost: `stats`
|
|
385
418
|
|
|
386
419
|
`omp-conductor stats [--since 7d | 30d | YYYY-MM-DD] [--project NAME] [--json]`
|
|
@@ -545,13 +578,13 @@ reference — usage strings, flags and per-command behaviour — is in the
|
|
|
545
578
|
|
|
546
579
|
| Verb | What it does |
|
|
547
580
|
| --- | --- |
|
|
548
|
-
| `setup [area]` | The wizard: interview + probes. `--no-ai`, per-area amend, `host`, `graph`. |
|
|
581
|
+
| `setup [area]` | The wizard: interview + probes. `--no-ai`, `--answers FILE`, `--save-answers FILE`, per-area amend, `host`, `graph`. |
|
|
549
582
|
| `start` / `stop` / `restart` | Run the dispatch daemon. `restart` drains first; `stop --pane` also halts the pane. |
|
|
550
583
|
| `upgrade [--to VERSION]` | Pin one published npm release across CLI, omp plugin, Herdr plugin and brief. |
|
|
551
|
-
| `status` | Layered fleet report: dispatch, ticks, pane, recovery, telegram, daemon, then the project body. |
|
|
584
|
+
| `status [--json]` | Layered fleet report: dispatch, ticks, pane, recovery, telegram, daemon, then the project body. |
|
|
552
585
|
| `stats [--since 7d|30d|YYYY-MM-DD] [--json]` | What the fleet accomplished and at what cost, from the local store only: merges, merge rate, lead time, attempts and metered cost per merged issue, failure classes, gh calls. |
|
|
553
586
|
| `doctor [--json] [--probe-telegram]` | Read-only deployment health — gh auth, exact-case labels, systemd drift, config backup, sqlite integrity, spend telemetry, timezones, Telegram. Run after install and after every upgrade; exit 0 only when nothing failed. |
|
|
554
|
-
| `ledger` | The action audit — every mediated verb, refusal and turn budget. |
|
|
587
|
+
| `ledger [--json]` | The action audit — every mediated verb, refusal and turn budget. |
|
|
555
588
|
| `board` | Live terminal kanban from Queue to Settled. |
|
|
556
589
|
| `dashboard [--port N] [--host ADDR]` | Browser UI plus bearer-authenticated `/api/projects`: every project's daemon state, port and healthz. Loopback by default; the token lives at `<state>/dashboard-token`. |
|
|
557
590
|
| `hold [--keep-ticks]` | Pause claims and disarm ticks — the soft stop. |
|
|
@@ -563,7 +596,8 @@ reference — usage strings, flags and per-command behaviour — is in the
|
|
|
563
596
|
| `verb <conductor_*>` | Run a mediated verb from the CLI (external orchestration). |
|
|
564
597
|
| `friction <kind> --detail TEXT` | Record a bounded operator judgment — an escalation that belonged in a digest, or a report that was noise/surprising — feeding the learning loop. |
|
|
565
598
|
| `event record` / `report` / `message` | Record and deliver reports and messages through the durable outbox. |
|
|
566
|
-
| `decision open/resolve/withdraw/list` | Read and answer questions in the decision ledger
|
|
599
|
+
| `decision open/resolve/withdraw/list [--json]` | Read and answer questions in the decision ledger; JSON applies to `list`. |
|
|
600
|
+
| `watch add/list [--json]` | Record or list orchestrator-only conditions and carry notes; JSON applies to `list`. |
|
|
567
601
|
| `intake "<text>"` / `intake list` / `intake dismiss <id>` | Capture a raw idea durably (it lives in the store and survives restarts), list what is still pending, dismiss what turned out to be nothing. |
|
|
568
602
|
| `daemon [--once]` | Run the loop in the foreground — systemd's entry point. |
|
|
569
603
|
| `resume` | Clear pause and any `stop --pane` recovery pin; never re-arms. |
|
package/REFERENCE.md
CHANGED
|
@@ -265,6 +265,12 @@ Skip the reading half entirely with `--no-ai`:
|
|
|
265
265
|
omp-conductor setup --no-ai
|
|
266
266
|
```
|
|
267
267
|
|
|
268
|
+
For unattended setup, pass `--answers FILE` with a JSON object keyed by the
|
|
269
|
+
stable prompt keys captured by `--save-answers`; the command exits `1` and
|
|
270
|
+
names the first missing key instead of prompting. Add `--save-answers FILE`
|
|
271
|
+
to an interactive run to capture only accepted answers for deterministic
|
|
272
|
+
replay. Unknown keys are ignored so one file can cover a larger interview.
|
|
273
|
+
|
|
268
274
|
To fill in or revise just the brief later — the two `POLICY.md` sections above — run
|
|
269
275
|
the `brief` area, which re-asks the judgment questions and re-runs the probes:
|
|
270
276
|
|
|
@@ -1858,7 +1864,7 @@ policy instead of restating it — no threshold lives in two places.
|
|
|
1858
1864
|
|
|
1859
1865
|
| Field | Values | Default | Means |
|
|
1860
1866
|
| --- | --- | --- | --- |
|
|
1861
|
-
| `requires` | `runs-settled`, `no-open-prs`, `queue-drained`, `base-branch-green`, `epic-children-closed` | `["runs-settled"]` | What must already have landed. `runs-settled` reads each active run's PR fact at release time: a pushed run whose PR has merged counts as settled even when the settle sweep has not yet written the terminal row — so a hold-drained release does not wait an extra tick the operator reached the gate by holding. Live workers and unmerged/unknown PRs still refuse,
|
|
1867
|
+
| `requires` | `runs-settled`, `fleet-runs-settled`, `no-open-prs`, `queue-drained`, `base-branch-green`, `epic-children-closed` | `["runs-settled"]` | What must already have landed. `runs-settled` reads each active run of the released repo's PR fact at release time: a pushed run whose PR has merged counts as settled even when the settle sweep has not yet written the terminal row — so a hold-drained release does not wait an extra tick the operator reached the gate by holding. Runs in other routed repos never gate a repo-scoped release — they cannot invalidate the artifact being shipped; a genuinely suite-wide shape (a pin or manifest consuming several repos) opts back into project-wide strictness with the named `fleet-runs-settled` requirement. Live workers and unmerged/unknown PRs still refuse, the message names which is which and which runs are blocking by repo and issue. `base-branch-green` requires the current live head's push-triggered 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 canonicali…
|
|
1862
1868
|
| `requiredChecks` | any check names | `[]` | Checks that must be green on the branch being released. Empty means every check it reports. |
|
|
1863
1869
|
| `artefacts` | any names | `[]` | The packages or images this project releases. **Empty denies**: nothing has been authorised to ship. |
|
|
1864
1870
|
| `environments` | any names | `[]` | Deploy targets. **Empty denies** every environment. |
|
|
@@ -2202,7 +2208,7 @@ least of all on a fleet whose session lives somewhere else.
|
|
|
2202
2208
|
## CLI reference
|
|
2203
2209
|
|
|
2204
2210
|
```bash
|
|
2205
|
-
omp-conductor setup [area] [--no-ai] [--project NAME]
|
|
2211
|
+
omp-conductor setup [area] [--no-ai] [--answers FILE] [--save-answers FILE] [--project NAME]
|
|
2206
2212
|
omp-conductor setup host [NAME] [--project NAME]
|
|
2207
2213
|
omp-conductor setup graph [--no-seed] [--print] [--project NAME]
|
|
2208
2214
|
omp-conductor start [--port N] [--project NAME]
|
|
@@ -2212,10 +2218,10 @@ omp-conductor restart [--now] [--timeout SECONDS] [--port N] [--project NAME]
|
|
|
2212
2218
|
omp-conductor upgrade [--to VERSION] [--project NAME]
|
|
2213
2219
|
omp-conductor upgrade-install --to VERSION [--project NAME]
|
|
2214
2220
|
omp-conductor upgrade-rollback
|
|
2215
|
-
omp-conductor status [--project NAME]
|
|
2221
|
+
omp-conductor status [--project NAME] [--json]
|
|
2216
2222
|
omp-conductor doctor [--project NAME] [--json] [--probe-telegram]
|
|
2217
|
-
omp-conductor ledger [--issue N] [--limit N] [--project NAME]
|
|
2218
|
-
omp-conductor board [--project NAME]
|
|
2223
|
+
omp-conductor ledger [--issue N] [--limit N] [--project NAME] [--json]
|
|
2224
|
+
omp-conductor board [--project NAME] [--json]
|
|
2219
2225
|
omp-conductor hold [--keep-ticks] [--project NAME]
|
|
2220
2226
|
omp-conductor stop [--pane] [--project NAME]
|
|
2221
2227
|
omp-conductor arm [--project NAME]
|
|
@@ -2234,9 +2240,9 @@ omp-conductor message --text TEXT [--project NAME]
|
|
|
2234
2240
|
omp-conductor decision open --question TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
|
|
2235
2241
|
omp-conductor decision resolve <id> --answer TEXT [--project NAME]
|
|
2236
2242
|
omp-conductor decision withdraw <id> [--reason TEXT] [--project NAME]
|
|
2237
|
-
omp-conductor decision list [--project NAME]
|
|
2243
|
+
omp-conductor decision list [--project NAME] [--json]
|
|
2238
2244
|
omp-conductor watch add --note TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
|
|
2239
|
-
omp-conductor watch list [--project NAME]
|
|
2245
|
+
omp-conductor watch list [--project NAME] [--json]
|
|
2240
2246
|
omp-conductor daemon [--once] [--port N] [--project NAME]
|
|
2241
2247
|
omp-conductor resume [--project NAME]
|
|
2242
2248
|
omp-conductor brief-upgrade [--migrate|--retrofit] [--apply] [--file PATH] [--project NAME]
|
|
@@ -2247,15 +2253,15 @@ omp-conductor help
|
|
|
2247
2253
|
|
|
2248
2254
|
| Command | Scope | Behaviour |
|
|
2249
2255
|
| --- | --- | --- |
|
|
2250
|
-
| `setup [area] [--no-ai] [--project NAME]` | project | The deterministic interview,
|
|
2256
|
+
| `setup [area] [--no-ai] [--answers FILE] [--save-answers FILE] [--project NAME]` | project | The deterministic interview, with styled Clack prompts on an interactive TTY and byte-stable plain output for pipes or `OMP_CONDUCTOR_PLAIN_UI=1`. `--answers` validates a JSON object of stable prompt keys before setup and replaces every prompt; a missing required key exits `1` naming the key and file instead of hanging. `--save-answers` records accepted interactive answers as replayable JSON after a successful run. Bare setup is a full first run, or — when the project already exists — a chooser of which area to amend. Naming an area positionally skips that chooser and amends only that area: `tracker`, `gates`, `caps`, `code-graph`, `authority`, `policy`, `escalation`, `reporting`, `brief`. `host` and `graph` are install subcommands rather than areas and are matched first; anything else exits `2` listing both vocabularies. Every prompt shows its current value as the default, and Enter accepts what you see; `Ctrl-C` at any prompt abandons the run and writes nothing. Setup also **reads your repos to propose answers**: the gates prompt is pre-filled from what CI actually runs, and the brief's `## Project context` and release procedure are drafted from every routing repo and shown for confirmation before anything is written. Each probe is a short session with **no shell, no editor and no verbs** in a throwaway shallow clone, and every answer is a proposal you edit or decline — a probe that cannot clone, cannot reach a model, or answers unusably costs you one warning and the shipped stub. `--no-ai` asks every question with the reading half removed. |
|
|
2251
2257
|
| `setup host [--project NAME]` | host | Re-render and stage the systemd unit, then **run** the install: `install -m 0644` into `/etc/systemd/system`, `daemon-reload`, `enable`, `restart`. Stages the fleet recovery oneshot (`omp-conductor-recover.service`) and its playbook (`/usr/local/sbin/omp-conductor-recover`) alongside, and installs them **before** the fleet units: both fleet units carry `OnFailure=` to the recovery unit, so a crash-looped daemon or herdr session now collects evidence durably, attempts one bounded recovery, and pages tier-2 instead of dying silently (#485). Every command is shown with its exact argv, one confirm covers the batch, and `sudo` asks for your password once before the first step — or is skipped entirely on a fleet that genuinely runs as root. The first failure stops the rest and prints the un-run remainder verbatim so you can finish by hand. Refuses an *escalated* invocation (`sudo`, or `sudo -i`/`su -` detected by the invoking account disagreeing with the fleet's) before writing anything, naming both accounts, because staging derives the unit's `User=`/`HOME=` from whoever ran it. On a non-Linux host the files are still staged and only the `systemctl` steps are refused. |
|
|
2252
2258
|
| `setup graph [--no-seed] [--print] [--project NAME]` | project | The code-graph install end to end, in one preview and one confirm: check the prerequisites read-only and stop before installing anything when `codebase-memory-mcp` is absent or no MCP entry mounts it (printing the entry to add); `git clone` each missing index-only checkout **as you, never through sudo**; install and enable `cbm-reindex.timer` as root; then seed one indexing run so the first fetch happens while you watch, and verify with the same probe `status` uses. A repo that does not verify is a failure with the remediation, not a success — staged-but-not-trusted is how you discover months later that no worker read an index. `--no-seed` enables the timer without the seeding run and says plainly the graph is unusable until it first fires; it never skips the prerequisite or clone steps. `--print` changes nothing. Exits `1` when no repo has [`graphProject`](#configuration). |
|
|
2253
2259
|
| `start` | host | Start `herdr-fleet.service` when that optional unit is installed, clearing a previous pane-recovery pin, then start the dispatch daemon and wait until it answers `GET /healthz`. When `omp-conductor.service` is installed, systemd is the only start path: even `start --project NAME` restores the shared unit and uses the name only to verify that `/healthz` serves the requested project. A detached daemon is allowed only when the unit is proven absent. It never clears pause or arms ticks. Refuses if a daemon is already live, naming its pid; manager refusal or unprovable ownership is an error rather than a detached fallback. |
|
|
2254
2260
|
| `stop` | fleet | Prefer `systemctl stop omp-conductor.service` when that unit's MainPID is the live daemon — systemd then owns the stop and will not schedule a restart for the exit it just requested. Otherwise `SIGTERM`, then `SIGKILL` after a 10-second grace period. Prints `not running` when there is nothing to stop, and tags the confirmation with `(via systemctl)` when the unit path was used. |
|
|
2255
2261
|
| `restart [--now] [--timeout SECONDS] [--port N] [--project NAME]` | host | 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. A daemon serving multiple configured projects makes restart host-wide: `--project` is rejected because draining one queue and restarting the shared process would kill another project's workers. Prefer `systemctl restart` when the unit owns the live pid so the replacement stays supervised; only a host proven not to have the installed unit may fall back to the standalone stop/start path. `--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). |
|
|
2256
2262
|
| `upgrade [--to VERSION] [--project NAME]` | host | 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. Host-wide by default: one daemon serves every configured project, so a bare run drains all of them and refreshes every brief. `--project` is rejected when the live daemon serves several projects — draining one queue and restarting the shared daemon would kill another's workers. A no-op when already current. Failure leaves dispatch paused. Must run outside a Herdr-managed session. The same transaction runs detached, without a human, as the fleet-installs-itself path: the orchestrator calls the `conductor_install` verb under the granted `install` shape, the daemon validates the version against npm and starts a transient systemd unit (`upgrade-install`) outside the pane and the daemon, and the first tick after the restart verifies version, `/healthz`, ticks, pane and `doctor` against the durable upgrade journal before restoring dispatch — rolling back and paging tier-2 on any gap. |
|
|
2257
|
-
| `status [--project NAME]` | fleet | 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. Active-run lines overlay cooperative worker `paused`/`pausing` from `/healthz` without changing SQLite `running` state or the live worker count. 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. |
|
|
2258
|
-
| `ledger [--issue N] [--limit N]` | project | 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`. |
|
|
2263
|
+
| `status [--project NAME] [--json]` | fleet | 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. `--json` emits the stable structured snapshot directly, without ANSI or prose, for automation. The text 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. Active-run lines overlay cooperative worker `paused`/`pausing` from `/healthz` without changing SQLite `running` state or the live worker count. 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. |
|
|
2264
|
+
| `ledger [--issue N] [--limit N] [--json]` | project | 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. `--json` emits one stable object with `project`, optional `issue`, `entries`, `refused`, and `turnOverrides`. Recent verb refusals and pending turn overrides also appear in `status`. |
|
|
2259
2265
|
| `board [--project NAME]` | fleet | 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. |
|
|
2260
2266
|
| `hold [--keep-ticks] [--project NAME]` | fleet | Soft stop: pause claiming **and** disarm ticks. Daemon and pane stay up. Prefer this when the intent is "stop the conductor" without killing processes. `--keep-ticks` pauses claiming but leaves the arm marker, so the heartbeat keeps reporting and `resume` alone restores the fleet — no fresh arm challenge. See [Stop the conductor](README.md#stop-the-conductor-hold--stop). |
|
|
2261
2267
|
| `stop [--pane] [--project NAME]` | fleet | Stop the conductor: pause claiming, disarm ticks, then stop the dispatch daemon (systemctl-aware). Pane stays up unless `--pane` is passed. `stop --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. To bounce the daemon without stopping the fleet, use `restart`. |
|
|
@@ -2272,9 +2278,9 @@ omp-conductor help
|
|
|
2272
2278
|
| `decision open --question TEXT [--blocks TEXT] [--resolves-when COND]` | project | Record a question the orchestrator has put to you, and print its id. A question that lives only in a session's context is lost at the next compaction — after which it is either asked twice or dropped silently. `--resolves-when` attaches a machine-checkable condition: `pr-merged:<https url>`, `pr-checks-green:<https url>`, `pr-mergeable:<https url>`, `issue-closed:<n>`, `npm-version:<pkg>@<version>`, or `rate-limit-reset:github`; anything else exits `2` listing the six forms. See [The decision ledger](#the-decision-ledger-136). |
|
|
2273
2279
|
| `decision resolve <id> --answer TEXT` | project | Record what you decided. Exits `1` naming the id when it is unknown or no longer open, so a second answer cannot overwrite the first. |
|
|
2274
2280
|
| `decision withdraw <id> [--reason TEXT]` | project | Close a question the session stopped needing, with why. Same guard as `resolve`. |
|
|
2275
|
-
| `decision list` | project | Open questions, oldest first: id, age, what each blocks, whether its condition is met, and the question. Prints `no open decisions` when there are none. Watches are not listed here — `watch list` shows those. |
|
|
2281
|
+
| `decision list [--json]` | project | Open questions, oldest first: id, age, what each blocks, whether its condition is met, and the question. `--json` emits `{ project, decisions }`; the empty state is an empty array. Prints `no open decisions` in text mode when there are none. Watches are not listed here — `watch list` shows those. |
|
|
2276
2282
|
| `watch add --note TEXT [--blocks TEXT] [--resolves-when COND]` | project | Record a condition or carry note the orchestrator set for itself, with no human in the loop (#459). `--resolves-when` attaches a machine-checkable condition the daemon checks for you; a met watch wakes the next tick exactly as a met question does. Renders under its own "Watches" heading, is never counted in `decisions N open`, and has no seven-day expiry. |
|
|
2277
|
-
| `watch list` | project | Open watches, oldest first: id, age, what each blocks, whether its condition is met, and the note. Prints `no watches` when there are none. |
|
|
2283
|
+
| `watch list [--json]` | project | Open watches, oldest first: id, age, what each blocks, whether its condition is met, and the note. `--json` emits `{ project, watches }`; the empty state is an empty array. Prints `no watches` in text mode when there are none. |
|
|
2278
2284
|
| `daemon` | host | Run the loop in the **foreground**, ticking every 5 minutes and serving `/healthz`. Admitted workers run in a tracked background pool, so settlement and capacity checks remain periodic while they work; shutdown drains the pool before closing the store. This is what `start` launches and what a systemd unit should call. |
|
|
2279
2285
|
| `daemon --once` | host | 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. |
|
|
2280
2286
|
| `--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. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
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.",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@bomb.sh/tab": "0.0.22",
|
|
37
|
+
"@clack/prompts": "1.7.0",
|
|
37
38
|
"yaml": "^2.9.0",
|
|
38
39
|
"zod": "^4"
|
|
39
40
|
},
|
|
@@ -325,6 +325,21 @@
|
|
|
325
325
|
},
|
|
326
326
|
"additionalProperties": false
|
|
327
327
|
},
|
|
328
|
+
"arm": {
|
|
329
|
+
"type": "object",
|
|
330
|
+
"properties": {
|
|
331
|
+
"proof": {
|
|
332
|
+
"default": "challenge",
|
|
333
|
+
"type": "string",
|
|
334
|
+
"enum": [
|
|
335
|
+
"challenge",
|
|
336
|
+
"claim-only"
|
|
337
|
+
]
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
"additionalProperties": false,
|
|
341
|
+
"description": "How `arm` proves a human just approved arming"
|
|
342
|
+
},
|
|
328
343
|
"authority": {
|
|
329
344
|
"type": "object",
|
|
330
345
|
"properties": {
|
|
@@ -433,6 +448,7 @@
|
|
|
433
448
|
"type": "string",
|
|
434
449
|
"enum": [
|
|
435
450
|
"runs-settled",
|
|
451
|
+
"fleet-runs-settled",
|
|
436
452
|
"no-open-prs",
|
|
437
453
|
"queue-drained",
|
|
438
454
|
"base-branch-green",
|
package/src/admission.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { log, errText, safeEscalate } from "./log.ts";
|
|
|
19
19
|
import type {
|
|
20
20
|
Caps,
|
|
21
21
|
Escalation,
|
|
22
|
+
IssueComment,
|
|
22
23
|
IssueSnapshot,
|
|
23
24
|
OpenCloser,
|
|
24
25
|
ProjectConfig,
|
|
@@ -28,7 +29,7 @@ import type {
|
|
|
28
29
|
AdmissionHoldReason,
|
|
29
30
|
} from "./types.ts";
|
|
30
31
|
import { readPlanUsage, type PlanUsageStatus, type UsageSource } from "./usage.ts";
|
|
31
|
-
import type { CriticalBaseProbe, CriticalBaseVerdict, RunLaneProbe } from "./gitops.ts";
|
|
32
|
+
import type { CriticalBaseProbe, CriticalBaseVerdict, LaneFile, LaneSource, RunLaneProbe } from "./gitops.ts";
|
|
32
33
|
import { repoSlugFor } from "./gitops.ts";
|
|
33
34
|
import { branchName, type Routed } from "./routing.ts";
|
|
34
35
|
import { parseDependsOn } from "./depends-on.ts";
|
|
@@ -77,10 +78,16 @@ export function hasFailedAttemptBudget(failures: number, maxAttempts: number): b
|
|
|
77
78
|
return failures < maxAttempts;
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
/** A candidate cleared for dispatch, with the attempt number it will run as.
|
|
81
|
+
/** A candidate cleared for dispatch, with the attempt number it will run as.
|
|
82
|
+
* `lane` is the effective file-lane declaration admission resolved for it
|
|
83
|
+
* (#608): the exact snapshot the overlap gate enforced. Dispatch renders this
|
|
84
|
+
* value into the worker brief, so a changed or failed second comment read can
|
|
85
|
+
* neither hide nor reword the lane admission held — the gate and the
|
|
86
|
+
* worker-visible brief are one value, not two reads of the same thread. */
|
|
81
87
|
export interface Admission {
|
|
82
88
|
r: Routed;
|
|
83
89
|
attempt: number;
|
|
90
|
+
lane?: FileLane;
|
|
84
91
|
}
|
|
85
92
|
|
|
86
93
|
export interface AdmissionHold {
|
|
@@ -174,27 +181,82 @@ function planUsageEscalation(project: string, plan: PlanUsageStatus): Escalation
|
|
|
174
181
|
* files an issue declares it will touch (#555).
|
|
175
182
|
*
|
|
176
183
|
* The orchestrator already writes exactly this list into every promotion brief
|
|
177
|
-
* in prose; this parses that same sentence out of the issue body
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
184
|
+
* in prose; this parses that same sentence out of the issue body — or out of a
|
|
185
|
+
* pre-dispatch comment, which is the same sentence on a supported brief
|
|
186
|
+
* surface since #517 — so the interlock is load-bearing rather than advisory.
|
|
187
|
+
* A line beginning with "file lane" (case-insensitive, optional
|
|
188
|
+
* bold/heading markers) is accepted, and paths are read as backtick-delimited
|
|
189
|
+
* spans (the brief form) with a bare comma/space-separated fallback that keeps
|
|
190
|
+
* tokens that look like relative paths. A line whose tokens are not pathlike
|
|
191
|
+
* (like `File lane: none`) is not a declaration. Anything else — including an
|
|
192
|
+
* absent declaration, which is the default — is an empty lane: the issue is
|
|
193
|
+
* admitted exactly as today (`fail open`), and the gate never refuses work for
|
|
194
|
+
* wanting a lane. This is the one grammar for all surfaces; there is no second
|
|
195
|
+
* comment-only spelling.
|
|
186
196
|
*/
|
|
187
|
-
export function
|
|
188
|
-
const match =
|
|
197
|
+
export function laneDeclaration(text: string): LaneDeclaration | undefined {
|
|
198
|
+
const match = text.match(
|
|
189
199
|
/^\s*(?:[#>*-]\s*)*file[- ]lane\s*[:=]\s*([^\n]*)$/im,
|
|
190
200
|
);
|
|
191
|
-
if (match === null) return
|
|
201
|
+
if (match === null) return undefined;
|
|
192
202
|
const rest = match[1] ?? "";
|
|
193
203
|
const backticked = [...rest.matchAll(/`([^`]+)`/g)]
|
|
194
204
|
.map((m) => m[1]!.trim())
|
|
195
205
|
.filter(isPathLike);
|
|
196
|
-
|
|
197
|
-
|
|
206
|
+
const files =
|
|
207
|
+
backticked.length > 0
|
|
208
|
+
? [...new Set(backticked)]
|
|
209
|
+
: [...new Set(rest.split(/[,\s]+/).map((s) => s.trim()).filter(isPathLike))];
|
|
210
|
+
if (files.length === 0) return undefined;
|
|
211
|
+
return { files, source: match[0].trim() };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** One parsed `File lane:` declaration: the paths and the verbatim source
|
|
215
|
+
* line, so a renderer can reproduce the declaration itself rather than a
|
|
216
|
+
* summary of it. Exported for the brief's guarantee that the gate's effective
|
|
217
|
+
* lane is always visible to the worker (#608). */
|
|
218
|
+
export interface LaneDeclaration {
|
|
219
|
+
files: string[];
|
|
220
|
+
/** The declaration line verbatim, as written on the surface it came from. */
|
|
221
|
+
source: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The effective file lane as both admission and the worker brief must read it
|
|
226
|
+
* (#608): the latest `File lane:` declaration among the issue body and every
|
|
227
|
+
* comment, in the tracker's oldest-first order. This is the "later correction
|
|
228
|
+
* visibly supersedes" contract applied across both surfaces at once, and it is
|
|
229
|
+
* the single source of truth the gate enforces and the brief renders — so a
|
|
230
|
+
* declaration can never control admission while staying invisible to the
|
|
231
|
+
* worker. `at` records which surface won (`"body"`, or the 0-based comment
|
|
232
|
+
* index), letting the brief reproduce the declaration verbatim when the
|
|
233
|
+
* winning comment sits beyond its rendered discussion budget.
|
|
234
|
+
*/
|
|
235
|
+
export interface FileLane extends LaneDeclaration {
|
|
236
|
+
at: "body" | number;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Who holds one file's lane this admission pass, and which read proved it.
|
|
240
|
+
* `"declared"` is the same-pass half: an admitted candidate's own lane
|
|
241
|
+
* occupies for the rest of the pass without any probe read. */
|
|
242
|
+
export type LaneHolder = { issue: number; source: LaneSource | "declared" };
|
|
243
|
+
|
|
244
|
+
/** Resolves the effective lane across the body and the whole comment thread. */
|
|
245
|
+
export function effectiveLane(body: string, comments: IssueComment[]): FileLane | undefined {
|
|
246
|
+
let current: FileLane | undefined;
|
|
247
|
+
const bodyDecl = laneDeclaration(body);
|
|
248
|
+
if (bodyDecl !== undefined) current = { ...bodyDecl, at: "body" };
|
|
249
|
+
for (let i = 0; i < comments.length; i++) {
|
|
250
|
+
const decl = laneDeclaration(comments[i]!.body);
|
|
251
|
+
if (decl !== undefined) current = { ...decl, at: i };
|
|
252
|
+
}
|
|
253
|
+
return current;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The paths of an issue body's declared lane (empty for no declaration).
|
|
257
|
+
* Exported so the format is pinned independent of admission. */
|
|
258
|
+
export function declaredLane(body: string): string[] {
|
|
259
|
+
return laneDeclaration(body)?.files ?? [];
|
|
198
260
|
}
|
|
199
261
|
|
|
200
262
|
/** A plausible relative path: it has a `.` extension or a directory separator. */
|
|
@@ -271,14 +333,14 @@ export async function admitCandidates(
|
|
|
271
333
|
// and one on the web repo are different files. Built lazily and once, only
|
|
272
334
|
// when the first candidate that declares a lane reaches the gate, so a queue
|
|
273
335
|
// of laneless issues pays nothing for it.
|
|
274
|
-
let laneOccupancy: Map<string, Map<string,
|
|
275
|
-
const ensureLaneOccupancy = async (): Promise<Map<string, Map<string,
|
|
336
|
+
let laneOccupancy: Map<string, Map<string, LaneHolder>> | undefined;
|
|
337
|
+
const ensureLaneOccupancy = async (): Promise<Map<string, Map<string, LaneHolder>>> => {
|
|
276
338
|
if (laneOccupancy !== undefined) return laneOccupancy;
|
|
277
|
-
const occupied = new Map<string, Map<string,
|
|
339
|
+
const occupied = new Map<string, Map<string, LaneHolder>>();
|
|
278
340
|
for (const run of activeRuns) {
|
|
279
341
|
if (d.probeWorktreeLane === undefined) break;
|
|
280
342
|
const base = project.routing.repos[run.repo]?.defaultBranch ?? "main";
|
|
281
|
-
let files:
|
|
343
|
+
let files: LaneFile[];
|
|
282
344
|
try {
|
|
283
345
|
files = await d.probeWorktreeLane({
|
|
284
346
|
worktree: run.worktree,
|
|
@@ -291,20 +353,64 @@ export async function admitCandidates(
|
|
|
291
353
|
// well-formed issue because one probe could not be answered.
|
|
292
354
|
files = [];
|
|
293
355
|
}
|
|
294
|
-
for (const file of files) {
|
|
295
|
-
if (occupied.get(run.repo)?.has(file) === true) continue;
|
|
356
|
+
for (const { file, source } of files) {
|
|
296
357
|
let perRepo = occupied.get(run.repo);
|
|
297
358
|
if (perRepo === undefined) {
|
|
298
359
|
perRepo = new Map();
|
|
299
360
|
occupied.set(run.repo, perRepo);
|
|
300
361
|
}
|
|
301
|
-
|
|
362
|
+
// First read wins: a file the run both commits and edits in its
|
|
363
|
+
// worktree is tagged by its live uncommitted half.
|
|
364
|
+
if (!perRepo.has(file)) perRepo.set(file, { issue: run.issue, source });
|
|
302
365
|
}
|
|
303
366
|
}
|
|
304
367
|
laneOccupancy = occupied;
|
|
305
368
|
return occupied;
|
|
306
369
|
};
|
|
307
370
|
|
|
371
|
+
// The candidate half of the file-lane interlock (#555): the machine-readable
|
|
372
|
+
// lane an issue declares. Since #517 a promotion brief may live in a
|
|
373
|
+
// pre-dispatch comment rather than the body, so the lane is read from both
|
|
374
|
+
// surfaces — the same two the worker brief renders — through the tracker's
|
|
375
|
+
// existing comment port, never a second comment reader. Body and comments
|
|
376
|
+
// share the one `laneDeclaration` grammar, and the brief's "later correction
|
|
377
|
+
// visibly supersedes" contract holds: the effective lane is the latest
|
|
378
|
+
// declaration among the body and the WHOLE thread, so a correction posted as
|
|
379
|
+
// a comment replaces an earlier body lane instead of widening it (#608).
|
|
380
|
+
//
|
|
381
|
+
// Reading every comment — not just the ones the brief's discussion budget
|
|
382
|
+
// renders — is deliberate: this is the same `effectiveLane` the brief
|
|
383
|
+
// renders, and the dispatch side guarantees the winning declaration appears
|
|
384
|
+
// in the brief verbatim even when it sits beyond the budget. The gate and
|
|
385
|
+
// the worker therefore agree on one lane, which is the #608 defect's shape.
|
|
386
|
+
//
|
|
387
|
+
// Comments are read at most once per issue per pass — the lane feeds both
|
|
388
|
+
// the gate and the same-pass sibling occupancy below, so the cache is what
|
|
389
|
+
// stops one candidate costing two comment reads. Unreadable comments fail
|
|
390
|
+
// open to the body declaration (a body lane stays load-bearing), and with no
|
|
391
|
+
// readable declaration at all the #555 fail-open admission is unchanged.
|
|
392
|
+
//
|
|
393
|
+
// The resolved `FileLane` — not just its paths — is what an admitted
|
|
394
|
+
// candidate carries into dispatch (#608): the gate and the brief must agree
|
|
395
|
+
// on the *same declaration* (paths, verbatim source line, and which surface
|
|
396
|
+
// it came from), so dispatch renders this cached value rather than reading
|
|
397
|
+
// the thread a second time and hoping it did not change.
|
|
398
|
+
const laneCache = new Map<number, FileLane | undefined>();
|
|
399
|
+
const laneFor = async (r: Routed): Promise<FileLane | undefined> => {
|
|
400
|
+
const issue = r.issue.number;
|
|
401
|
+
if (laneCache.has(issue)) return laneCache.get(issue);
|
|
402
|
+
let lane: FileLane | undefined;
|
|
403
|
+
try {
|
|
404
|
+
lane = effectiveLane(r.issue.body, await tracker.listComments(issue));
|
|
405
|
+
} catch (err) {
|
|
406
|
+
const bodyDecl = laneDeclaration(r.issue.body);
|
|
407
|
+
lane = bodyDecl === undefined ? undefined : { ...bodyDecl, at: "body" };
|
|
408
|
+
log(`#${issue} comments unreadable at admission; the body's file lane stands: ${errText(err)}`);
|
|
409
|
+
}
|
|
410
|
+
laneCache.set(issue, lane);
|
|
411
|
+
return lane;
|
|
412
|
+
};
|
|
413
|
+
|
|
308
414
|
// The plan allowance is a fleet-wide question, so it is asked once per pass
|
|
309
415
|
// and answers for every candidate — unlike every gate below it, which is
|
|
310
416
|
// per-issue. It sits here rather than beside the spend cap in `tick` for one
|
|
@@ -505,25 +611,30 @@ export async function admitCandidates(
|
|
|
505
611
|
}
|
|
506
612
|
if (verdict.state === "unknown") {
|
|
507
613
|
// Fail closed: a branch that cannot be *proven* to contain the marker
|
|
508
|
-
// is refused
|
|
509
|
-
//
|
|
510
|
-
|
|
614
|
+
// is refused. This is a verification/lookup failure — the mirror fetch
|
|
615
|
+
// failed, the marker did not resolve, or no probe is wired — never
|
|
616
|
+
// evidence that the branch predates the marker. Hold with a distinct
|
|
617
|
+
// reason so status and the friction rollup do not read a provider
|
|
618
|
+
// outage as branch staleness, and prescribe no branch change: the next
|
|
619
|
+
// admission pass re-runs the probe and admits the unchanged branch
|
|
620
|
+
// once verification succeeds.
|
|
621
|
+
hold(issue, "critical-base-verify-error");
|
|
511
622
|
log(
|
|
512
|
-
`#${issue} held (
|
|
623
|
+
`#${issue} held (critical-base-verify-error): continuation branch ${branch} could not be verified ` +
|
|
513
624
|
`against critical-base marker(s) ${markers.join(", ")} (${verdict.error})`,
|
|
514
625
|
);
|
|
515
626
|
await safeEscalate(d, {
|
|
516
627
|
tier: 1,
|
|
517
628
|
project: project.name,
|
|
518
629
|
issue,
|
|
519
|
-
summary: `#${issue} continuation branch could not be verified against a critical base safety commit and is held (
|
|
630
|
+
summary: `#${issue} continuation branch could not be verified against a critical base safety commit and is held (critical-base-verify-error)`,
|
|
520
631
|
detail: [
|
|
521
632
|
r.issue.title,
|
|
522
633
|
r.issue.url,
|
|
523
634
|
`The retained branch ${branch} could not be verified against critical-base marker(s) ${markers.join(", ")}: ${verdict.error}`,
|
|
524
|
-
"
|
|
525
|
-
"
|
|
526
|
-
"
|
|
635
|
+
"The branch is not claimed to predate the marker: this is a verification failure.",
|
|
636
|
+
"No branch change is prescribed. The next admission pass retries the critical-base",
|
|
637
|
+
"verification automatically and admits the unchanged branch once the probe succeeds.",
|
|
527
638
|
].join("\n"),
|
|
528
639
|
});
|
|
529
640
|
continue;
|
|
@@ -795,18 +906,23 @@ export async function admitCandidates(
|
|
|
795
906
|
// a live run's *actual* lane is held until that run's work has merged, so
|
|
796
907
|
// no second worker is sent at files another worker is still writing. The
|
|
797
908
|
// gate is the mechanical version of the prose rule that failed three times
|
|
798
|
-
// in one day. Only the machine-readable lane participates
|
|
799
|
-
//
|
|
800
|
-
//
|
|
801
|
-
|
|
802
|
-
|
|
909
|
+
// in one day. Only the machine-readable lane participates — read from the
|
|
910
|
+
// body and pre-dispatch comments alike, so the gate sees the same surface
|
|
911
|
+
// the worker brief renders (#608) — and a candidate without one is
|
|
912
|
+
// admitted exactly as today (fail open), while a candidate's own retained
|
|
913
|
+
// run never holds it — that is the continuation it continues.
|
|
914
|
+
const lane = await laneFor(r);
|
|
915
|
+
if (lane !== undefined && lane.files.length > 0) {
|
|
803
916
|
const occupied = await ensureLaneOccupancy();
|
|
804
917
|
const perRepo = occupied.get(r.repo.name);
|
|
805
918
|
let blocked = false;
|
|
806
|
-
for (const file of lane) {
|
|
919
|
+
for (const file of lane.files) {
|
|
807
920
|
const holder = perRepo?.get(file);
|
|
808
|
-
if (holder !== undefined && holder !== issue) {
|
|
809
|
-
|
|
921
|
+
if (holder !== undefined && holder.issue !== issue) {
|
|
922
|
+
// The source names the probe read that proved the occupancy, so a
|
|
923
|
+
// hold reads as authored work — worktree or branch — rather than the
|
|
924
|
+
// base-reconciliation noise #684 filters out of the probe entirely.
|
|
925
|
+
const detail = `${file} held by run #${holder.issue} (${holder.source})`;
|
|
810
926
|
hold(issue, "file-lane", detail);
|
|
811
927
|
log(`#${issue} held (file-lane): ${detail}`);
|
|
812
928
|
blocked = true;
|
|
@@ -816,20 +932,20 @@ export async function admitCandidates(
|
|
|
816
932
|
if (blocked) continue;
|
|
817
933
|
}
|
|
818
934
|
|
|
819
|
-
admitted.push({ r, attempt: priorRuns + 1 });
|
|
935
|
+
admitted.push({ r, attempt: priorRuns + 1, lane });
|
|
820
936
|
liveByRepo.set(r.repo.name, (liveByRepo.get(r.repo.name) ?? 0) + 1);
|
|
821
937
|
// Same-pass sibling occupancy for the file-lane gate: once admitted, a
|
|
822
938
|
// candidate's declared lane occupies for the rest of the pass, so a later
|
|
823
939
|
// overlapping candidate is held rather than both clearing in one tick.
|
|
824
|
-
if (lane.length > 0) {
|
|
940
|
+
if (lane !== undefined && lane.files.length > 0) {
|
|
825
941
|
const occupied = await ensureLaneOccupancy();
|
|
826
942
|
let perRepo = occupied.get(r.repo.name);
|
|
827
943
|
if (perRepo === undefined) {
|
|
828
944
|
perRepo = new Map();
|
|
829
945
|
occupied.set(r.repo.name, perRepo);
|
|
830
946
|
}
|
|
831
|
-
for (const file of lane) {
|
|
832
|
-
if (!perRepo.has(file)) perRepo.set(file, issue);
|
|
947
|
+
for (const file of lane.files) {
|
|
948
|
+
if (!perRepo.has(file)) perRepo.set(file, { issue, source: "declared" });
|
|
833
949
|
}
|
|
834
950
|
}
|
|
835
951
|
if (parent !== undefined) {
|
package/src/availability.ts
CHANGED
|
@@ -91,17 +91,35 @@ export function availabilityDisposition(
|
|
|
91
91
|
return "availability";
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/** A question-kind category. Every ask surface — `message --category
|
|
95
|
+
* decision-needed`, a `QUESTION:`-marked message, and `conductor_ask`'s
|
|
96
|
+
* default — resolves to this one category, so treating it as
|
|
97
|
+
* availability-deferred covers the whole question pathway at once (#596). */
|
|
98
|
+
export const QUESTION_KIND: InterruptCategory = "decision-needed";
|
|
99
|
+
|
|
94
100
|
/**
|
|
95
101
|
* Decide one tier-2 category. `digest` means the category policy itself defers
|
|
96
102
|
* it; `availability` means it was otherwise interruptible and may be released
|
|
97
103
|
* when the configured window next opens.
|
|
104
|
+
*
|
|
105
|
+
* A question is availability-deferred, never digest-deferred (#596): the
|
|
106
|
+
* category policy decides what may interrupt the operator's phone, but a
|
|
107
|
+
* question's deferral is literally "the operator is not available" — it waits
|
|
108
|
+
* for the window and the working-hours catch-up can release it at the next
|
|
109
|
+
* opening. With no window configured the next opening is now, so a question
|
|
110
|
+
* delivers rather than being silently bound to the daily digest. The
|
|
111
|
+
* `interruptOn` list never applies to a question's deferral.
|
|
98
112
|
*/
|
|
99
113
|
export function interruptDisposition(
|
|
100
114
|
policy: ReportingPolicy | undefined,
|
|
101
115
|
category: InterruptCategory,
|
|
102
116
|
at: number,
|
|
103
117
|
): InterruptDisposition {
|
|
104
|
-
if (
|
|
118
|
+
if (
|
|
119
|
+
policy !== undefined &&
|
|
120
|
+
category !== QUESTION_KIND &&
|
|
121
|
+
!policy.interruptOn.includes(category)
|
|
122
|
+
) return "digest";
|
|
105
123
|
return availabilityDisposition(policy?.availability, category, at);
|
|
106
124
|
}
|
|
107
125
|
|
|
@@ -148,6 +166,14 @@ export function formatZonedMinute(at: number, timezone: string): string {
|
|
|
148
166
|
return `${local.date} ${local.clock} ${timezone} (${new Date(at).toISOString()})`;
|
|
149
167
|
}
|
|
150
168
|
|
|
169
|
+
/** Compact local timestamp for a one-line future, e.g. "2026-08-24 09:00 UTC":
|
|
170
|
+
* the same instant as {@link formatZonedMinute} without the ISO tail, so a
|
|
171
|
+
* held-notice line read on a phone says plainly when the window opens. */
|
|
172
|
+
export function formatNextWindowOpening(at: number, timezone: string): string {
|
|
173
|
+
const local = localMinute(at, timezone);
|
|
174
|
+
return `${local.date} ${local.clock} ${timezone}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
151
177
|
/** One prompt sentence; the runtime gate, not this prose, owns the decision. */
|
|
152
178
|
export function availabilityPrompt(policy: ReportingPolicy | undefined, now: number): string {
|
|
153
179
|
const state = availabilityState(policy, now);
|
package/src/briefs/worker.md
CHANGED
|
@@ -98,6 +98,8 @@ These are the exact gates for `{{REPO}}`:
|
|
|
98
98
|
|
|
99
99
|
{{GATES}}
|
|
100
100
|
|
|
101
|
+
{{SHARED_HOST_NOTICE}}
|
|
102
|
+
|
|
101
103
|
Run every one of them, from the directory listed, over the **whole tree** — not
|
|
102
104
|
just the directory you edited. Linting only the source dir is how an error in a
|
|
103
105
|
migration, a config file or a script reaches the runners.
|