omp-conductor 0.3.25 → 0.4.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 +733 -30
- package/package.json +1 -1
- package/skills/conductor-onboarding/SKILL.md +56 -0
- package/skills/conductor-update/SKILL.md +27 -0
- package/src/board.ts +24 -5
- package/src/briefs/orchestrator.md +106 -20
- package/src/briefs/policy.md +48 -27
- package/src/briefs/worker.md +82 -31
- package/src/cli.ts +155 -2
- package/src/config.ts +669 -16
- package/src/confinement.ts +506 -25
- package/src/credentials.ts +2029 -0
- package/src/daemon.ts +1021 -32
- package/src/diff-flags.ts +696 -0
- package/src/escalate.ts +136 -9
- package/src/fleet.ts +110 -3
- package/src/omp.ts +471 -15
- package/src/orchestrator-tick.ts +42 -19
- package/src/orchestrator.ts +32 -5
- package/src/plugin.ts +267 -15
- package/src/release-policy.ts +191 -29
- package/src/reports.ts +440 -0
- package/src/session-host.ts +307 -0
- package/src/setup-host.ts +162 -1
- package/src/setup.ts +207 -18
- package/src/store.ts +506 -3
- package/src/tracker/github.ts +45 -0
- package/src/types.ts +847 -10
- package/src/upgrade.ts +96 -3
- package/src/usage.ts +726 -0
- package/src/verbs/actions.ts +142 -0
- package/src/verbs/client.ts +207 -0
- package/src/verbs/ledger.ts +77 -0
- package/src/verbs/protocol.ts +465 -0
- package/src/verbs/server.ts +1098 -0
- package/src/verbs/socket.ts +446 -0
- package/src/worker.ts +36 -7
- package/src/worktree.ts +202 -109
- package/systemd/omp-conductor.service.example +96 -8
package/README.md
CHANGED
|
@@ -568,31 +568,36 @@ Per tick, for the daemon's project:
|
|
|
568
568
|
its issue but not a slot: its worker is finished, and counting pushed PRs
|
|
569
569
|
would let two completed workers stop the fleet.
|
|
570
570
|
If no slot is free, the tick logs and returns.
|
|
571
|
-
8. **
|
|
571
|
+
8. **Check the plan allowance.** If `caps.planUsage` names a window, the daemon
|
|
572
|
+
reads it (cached, see [Caps](#caps)) and holds *every* candidate under
|
|
573
|
+
`plan-usage-cap` when the window is at or over its threshold. Unlike the
|
|
574
|
+
spend cap this does **not** pause the daemon: the window resets on the
|
|
575
|
+
provider's clock, so dispatch resumes by itself.
|
|
576
|
+
9. **Admit issues** up to the free slots, skipping any issue that already has
|
|
572
577
|
an active run — including a pending or green PR, so a second attempt cannot
|
|
573
578
|
land on a live PR. Repeated implementation failures consume
|
|
574
579
|
`maxAttemptsPerIssue`; cap kills, daemon orphans and answered blocks consume
|
|
575
580
|
the independent `maxContinuationsPerIssue`. Exhausting either escalates.
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
581
|
+
10. **Ask the tracker whether the work already exists.** For each candidate that
|
|
582
|
+
survived step 9 — so at most one API call per free slot, never one per queued
|
|
583
|
+
issue — the daemon asks whether an **open** PR already closes it. An open PR
|
|
584
|
+
normally holds the issue. One narrow exception permits a routed continuation:
|
|
585
|
+
the latest run must be terminal, and the open PR must be that run's retained
|
|
586
|
+
work — either the PR URL it recorded or a PR opened on the branch it retained.
|
|
587
|
+
The branch half matters because a run can be cap-killed before its worker ever
|
|
588
|
+
opens a PR, leaving a retained branch and no recorded URL; a PR pushed to that
|
|
589
|
+
branch afterwards is still the continuation target. Drafts count because their
|
|
590
|
+
branch can hold the only copy of the work.
|
|
591
|
+
The tracker also finds work missing from a new, moved, restored, or cleared
|
|
592
|
+
store. If the check fails, the candidate is **held**, not admitted, and
|
|
593
|
+
retried next tick: the cost of holding is five minutes, the cost of admitting
|
|
594
|
+
on an unknown is a burned attempt and a duplicate PR. Only that candidate is
|
|
595
|
+
held, so a flaky API cannot stall the rest of the queue.
|
|
596
|
+
11. **Record the pass.** Persist ready/routed/admitted counts and group every hold
|
|
592
597
|
under a stable reason code with at most five sample issue numbers. Tracker
|
|
593
598
|
failures mark the summary `DEGRADED`; capacity, sibling, open-PR and budget
|
|
594
599
|
holds remain normal policy state.
|
|
595
|
-
|
|
600
|
+
12. **Dispatch** the admitted issues concurrently.
|
|
596
601
|
|
|
597
602
|
Then, per admitted issue:
|
|
598
603
|
|
|
@@ -763,6 +768,54 @@ issues carrying `agent:in-progress`, which eligibility reads as "a worker owns
|
|
|
763
768
|
this" — with the brief forbidding the orchestrator from editing a state label and
|
|
764
769
|
`unblock` declining to clear that one, neither issue could ever be claimed again.
|
|
765
770
|
|
|
771
|
+
### The settlement audit
|
|
772
|
+
|
|
773
|
+
Verifying `state: pushed-green` left two lines of the same report still taken on
|
|
774
|
+
faith. The worker brief asks for them and, until this existed, nothing read them:
|
|
775
|
+
|
|
776
|
+
```
|
|
777
|
+
gates: <exact commands run and their results>
|
|
778
|
+
changed: <files touched, one line>
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
So at settlement the daemon fetches the pull request's diff and checks the report
|
|
782
|
+
against it. What it finds is a **settlement audit flag** — advisory, never a
|
|
783
|
+
gate. A flagged run settles exactly as an unflagged one does; nothing here can
|
|
784
|
+
change a run's state, hold a merge, or spend an attempt.
|
|
785
|
+
|
|
786
|
+
| Flag | Raised when |
|
|
787
|
+
| --- | --- |
|
|
788
|
+
| `undisclosed-file` | The PR touched a file the `changed:` line never named. Lockfiles are exempt — they are derived from a manifest the report did disclose. |
|
|
789
|
+
| `changed-line-missing` | The report had no usable `changed:` line at all. One flag, not one per file. |
|
|
790
|
+
| `unmatched-claim` | `changed:` named a path the PR never touched. The weaker direction, and reported as such. |
|
|
791
|
+
| `test-file-deleted` | A test file left the tree with no rename to account for it. |
|
|
792
|
+
| `test-disabled` | A `.skip` / `.only` / `xit` / `@pytest.mark.skip` / `t.Skip` marker appears on a line the PR added. |
|
|
793
|
+
| `assertions-removed` | An assertion was commented out, or a test file lost more assertions than it gained. |
|
|
794
|
+
| `test-timeout-raised` | A named timeout in a test file went up — compared against its own previous value, so a brand-new timeout is not a finding. |
|
|
795
|
+
|
|
796
|
+
A flag on a test file the dispatching issue never names is marked
|
|
797
|
+
`[unattributed]`: that is the "don't weaken tests you didn't write" case, and it
|
|
798
|
+
is the one worth reading first.
|
|
799
|
+
|
|
800
|
+
Flags reach you three ways: appended to the settlement report, stored on the run
|
|
801
|
+
row and shown under the run in `omp-conductor status` for as long as its PR is
|
|
802
|
+
open, and — once per flagged settlement — as a tier-1 escalation to the
|
|
803
|
+
orchestrator, whose brief says what judgement each flag invites.
|
|
804
|
+
|
|
805
|
+
**A clean, accurately reported PR produces nothing.** That is a design
|
|
806
|
+
constraint, not an aspiration: an audit that fires on honest work gets muted, and
|
|
807
|
+
a muted audit is worse than none because the fleet still believes it is being
|
|
808
|
+
checked. Every rule resolves ambiguity towards silence, and each accepts a named
|
|
809
|
+
blind spot to stay quiet — a renamed test file is not a deleted one (even when
|
|
810
|
+
git did not detect the rename, matched by basename), a `.skip` inside a string
|
|
811
|
+
literal or a recorded fixture is not a skip, and a rewritten test that keeps its
|
|
812
|
+
coverage is not a weakening.
|
|
813
|
+
|
|
814
|
+
The analyser is pure: it takes a parsed diff, the report and the issue text, and
|
|
815
|
+
returns flags. Only `Tracker.prDiff` touches the network, and a diff it cannot
|
|
816
|
+
read produces no flags *and says so* — silence about a diff nobody read is not a
|
|
817
|
+
clean bill.
|
|
818
|
+
|
|
766
819
|
### Continuation runs
|
|
767
820
|
|
|
768
821
|
When a worktree is provisioned onto a branch that already exists in the mirror
|
|
@@ -852,6 +905,7 @@ rest. `0` is a real value (a hard stop), not "unset".
|
|
|
852
905
|
| --- | --- | --- |
|
|
853
906
|
| `maxConcurrentWorkers` | `2` (setup may write `1` on <16 GiB hosts) | Parallel in-process omp sessions inside the daemon PID. Two, because **CI runner slots, not model tokens, are the usual throughput ceiling** — a third worker would starve its own PR checks on a small self-hosted runner pool. On hosts under ~16 GiB RAM, prefer `1` so the unit stays out of swap ([host sizing](#host-sizing-and-memory)). Raise it only if you actually have the runners *and* the RAM. |
|
|
854
907
|
| `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`. |
|
|
908
|
+
| `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. |
|
|
855
909
|
| `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. |
|
|
856
910
|
| `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. |
|
|
857
911
|
| `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. |
|
|
@@ -875,6 +929,83 @@ effective value is persisted and shown beside that active run in `status`; editi
|
|
|
875
929
|
flight. A cap that fires aborts the run, records it as `killed`, and names the
|
|
876
930
|
ceiling in the escalation.
|
|
877
931
|
|
|
932
|
+
### Plan allowance (`planUsage`)
|
|
933
|
+
|
|
934
|
+
`dailySpendUsd` meters money, which is the only thing an API-billed account can
|
|
935
|
+
run out of. A fixed-price subscription cannot be expressed that way: the real
|
|
936
|
+
ceiling is a **provider allowance** — a weekly token window whose marginal
|
|
937
|
+
dollar cost is zero and whose exhaustion stops every session on the host.
|
|
938
|
+
Pricing that into the dollar meter would mean inventing a number.
|
|
939
|
+
|
|
940
|
+
`planUsage` is the second, independent guard. It reads `omp usage --json` — the
|
|
941
|
+
structured form of the harness `/usage` view — and holds new claims while the
|
|
942
|
+
named window is at or over its threshold. Running workers finish normally, and
|
|
943
|
+
**the daemon is not paused**: the window resets on the provider's clock, so
|
|
944
|
+
dispatch resumes by itself once a fresh reading is below the threshold. Nothing
|
|
945
|
+
estimates a plan quota from conductor's own transcript token counts.
|
|
946
|
+
|
|
947
|
+
```json
|
|
948
|
+
"caps": {
|
|
949
|
+
"planUsage": { "windowId": "anthropic:7d", "maxUsedFraction": 0.85 }
|
|
950
|
+
}
|
|
951
|
+
```
|
|
952
|
+
|
|
953
|
+
**Naming the window.** `limits` in the payload is a *list*, not a single
|
|
954
|
+
number: one Anthropic account reports `anthropic:5h`, `anthropic:7d` and the
|
|
955
|
+
tier-scoped `anthropic:7d:fable` at the same time, and other providers add
|
|
956
|
+
their own. So the cap names its window rather than taking whichever entry came
|
|
957
|
+
first. Run this on the fleet host and copy an `id`:
|
|
958
|
+
|
|
959
|
+
```bash
|
|
960
|
+
omp usage --json | jq -r '.reports[].limits[] | "\(.id) \(.amount.usedFraction) \(.amount.unit)"'
|
|
961
|
+
```
|
|
962
|
+
|
|
963
|
+
A bare window key (`"7d"`) also works, but **only** when exactly one reported
|
|
964
|
+
allowance carries it. On an Anthropic account `7d` matches two, and the guard
|
|
965
|
+
refuses to guess.
|
|
966
|
+
|
|
967
|
+
**`maxUsedFraction` is a fraction, not a percentage.** `0.85` holds at 85%.
|
|
968
|
+
A value outside `0`–`1` is rejected at config load, because `85` would mean
|
|
969
|
+
"hold at 8500% consumed" — a guard that reads as configured and can never fire.
|
|
970
|
+
Comparison always goes through the provider's `usedFraction`, never a raw
|
|
971
|
+
count: `unit` is `percent` for Anthropic and `unknown` with raw counts for
|
|
972
|
+
`xai-oauth`, so a threshold compared against `used` misreads any non-percent
|
|
973
|
+
provider by orders of magnitude.
|
|
974
|
+
|
|
975
|
+
**Availability policy.** The guard never displays a number it did not read, and
|
|
976
|
+
never shows a fabricated `0% used`. What each situation does:
|
|
977
|
+
|
|
978
|
+
| Situation | `status` / `board` | New claims |
|
|
979
|
+
| --- | --- | --- |
|
|
980
|
+
| `planUsage: null` | `unmetered` | admitted |
|
|
981
|
+
| Window below threshold | `5% / 85% of anthropic:7d used · resets in 6d 2h` | admitted |
|
|
982
|
+
| Window at or over threshold | same, plus `holding new claims` | **held** (`plan-usage-cap`, Tier 1) |
|
|
983
|
+
| No provider reports a readable allowance, or `omp usage --json` fails | `unavailable — <reason>` | admitted for up to 30 minutes, then **held** and paged at Tier 2 |
|
|
984
|
+
| `windowId` names a window the reading does not contain | `window "<id>" is not in this reading — Reported: …` | **held**, Tier 2 |
|
|
985
|
+
| `windowId` matches more than one allowance | `window "<id>" matches …` | **held**, Tier 2 |
|
|
986
|
+
| The window reports nothing a fraction can be derived from | `window "<id>" reports no comparable fraction …` | **held**, Tier 2 |
|
|
987
|
+
|
|
988
|
+
The split is deliberate. A *read error* is transient — a token refresh, a
|
|
989
|
+
provider 502, `omp` briefly absent mid-upgrade — and stalling a fleet on one
|
|
990
|
+
would cost more than admitting through it, since spend, turns, wall clock and
|
|
991
|
+
concurrency are all still enforced. Half an hour of continuous failure is not
|
|
992
|
+
an outage, it is a broken meter, and a plan-capped fleet running on a broken
|
|
993
|
+
meter is how the allowance gets spent to zero unnoticed. A *successful* read
|
|
994
|
+
that does not contain the configured window is not a read error at all: the
|
|
995
|
+
source answered, and it says the config names something that is not there. That
|
|
996
|
+
fails closed immediately, like every other config fault in this package, and
|
|
997
|
+
recovers by itself as soon as a reading contains the window again.
|
|
998
|
+
|
|
999
|
+
Readings are cached for 60 seconds (15 for a failure) so one tick costs one
|
|
1000
|
+
provider call rather than one per candidate, and a cached reading is dropped
|
|
1001
|
+
the moment its own `resetsAt` passes — that is what makes admission resume at
|
|
1002
|
+
the rollover instead of a TTL later. `omp usage invalidate` clears omp's own
|
|
1003
|
+
cache; conductor picks the change up at its next read.
|
|
1004
|
+
|
|
1005
|
+
Both controls are shown separately, never folded together — `status` prints a
|
|
1006
|
+
`spend today` row and a `plan usage` row, and the board's admission line ends
|
|
1007
|
+
with `spend $2.40/$25.00 | plan 5%/85%`.
|
|
1008
|
+
|
|
878
1009
|
## Worker model
|
|
879
1010
|
|
|
880
1011
|
`workerModel` on a project pins the model its workers run on, as a pattern in
|
|
@@ -1054,6 +1185,123 @@ If `fallbackToIssueComment` is off and no Telegram transport is configured,
|
|
|
1054
1185
|
delivery throws instead of dropping silently. The failure is logged and retried,
|
|
1055
1186
|
because a swallowed escalation looks exactly like a healthy fleet.
|
|
1056
1187
|
|
|
1188
|
+
## Report delivery (the outbox)
|
|
1189
|
+
|
|
1190
|
+
Escalations are the daemon's. **Reports** — the material events and the daily
|
|
1191
|
+
digest your [`reporting.scope`](#your-workflow-vs-the-package) asks for — are
|
|
1192
|
+
written by the orchestrator, and until v0.3.26 they were also *delivered* by it:
|
|
1193
|
+
a report reached you only if the model remembered to call `telegram_send`. On
|
|
1194
|
+
2026-08-06 a suite release and two tier-2 escalations were written that way and
|
|
1195
|
+
none of the three arrived, and nothing anywhere recorded that fact — an undelivered
|
|
1196
|
+
report and a quiet tick look identical.
|
|
1197
|
+
|
|
1198
|
+
Authorship still needs judgement the daemon does not have, so it stays with the
|
|
1199
|
+
model. Delivery does not, so it moved:
|
|
1200
|
+
|
|
1201
|
+
```bash
|
|
1202
|
+
omp-conductor report --text "<the whole report>" # one material event
|
|
1203
|
+
omp-conductor report --text "<the whole digest>" --kind digest # the daily digest
|
|
1204
|
+
```
|
|
1205
|
+
|
|
1206
|
+
The command writes the text into a `reports` table in the store and prints a
|
|
1207
|
+
report id. Nothing has been sent at that point, and that is the guarantee: the
|
|
1208
|
+
report survives the session being compacted, interrupted or restarted, and it
|
|
1209
|
+
survives the daemon being restarted under it. The daemon then delivers it over
|
|
1210
|
+
the same bot token tier 2 uses, with bounded retries, and `omp-conductor status`
|
|
1211
|
+
lists anything that has not landed.
|
|
1212
|
+
|
|
1213
|
+
### Delivery is at-least-once, and the docs will not pretend otherwise
|
|
1214
|
+
|
|
1215
|
+
The Telegram Bot API accepts no client-supplied idempotency key and offers the
|
|
1216
|
+
bot no readable record of what it has already sent. There is nothing to replay a
|
|
1217
|
+
request against and nothing to reconcile with, so **exactly-once delivery cannot
|
|
1218
|
+
be built on this transport** and this package does not claim it. `delivered` is
|
|
1219
|
+
proof that Telegram accepted *an* attempt, never proof that exactly one message
|
|
1220
|
+
exists.
|
|
1221
|
+
|
|
1222
|
+
What it does instead is make the ambiguity explicit and always resolve it in the
|
|
1223
|
+
direction of the duplicate:
|
|
1224
|
+
|
|
1225
|
+
| State | Meaning | What you do |
|
|
1226
|
+
| --- | --- | --- |
|
|
1227
|
+
| `pending` | Nothing is in flight. Never attempted, or the last attempt failed **definitively** — see below. Nobody has this report. | Nothing. It retries on a bounded backoff (30s doubling to a 15-minute floor) and `status` shows the error. |
|
|
1228
|
+
| `sending` | A request left this host and its outcome was never learned: the daemon died, or the request was cut off after the bytes went out. Telegram may be holding the message. | Nothing, but expect a possible duplicate. Check the chat if you want to know now. |
|
|
1229
|
+
| `delivered` | Telegram answered `ok: true`. The row records the message id from the response **body**, not the HTTP status. | Nothing. |
|
|
1230
|
+
| `failed` | The retry budget ran out — six attempts, roughly half an hour. | Fix the transport. This state pages tier 2 in its own right — see below. |
|
|
1231
|
+
|
|
1232
|
+
The row is written `sending`, with the id of the attempt about to run, **before**
|
|
1233
|
+
the request is made. A crash in that window therefore leaves an explicitly
|
|
1234
|
+
ambiguous row rather than a silently lost one. The next daemon start sweeps every
|
|
1235
|
+
`sending` row, retries it, and the retried message carries the report id and a
|
|
1236
|
+
plain-English line saying it may already be in the chat. A duplicate you can spot
|
|
1237
|
+
by its report id is much the cheaper of the two mistakes; a silently dropped
|
|
1238
|
+
report is the entire reason this exists.
|
|
1239
|
+
|
|
1240
|
+
#### Two kinds of failure, and only one of them is quiet
|
|
1241
|
+
|
|
1242
|
+
A failed send is classified where the socket is watched, not by the caller, and
|
|
1243
|
+
the two classes are treated differently on purpose:
|
|
1244
|
+
|
|
1245
|
+
| Outcome | What happened | Row | Retry says |
|
|
1246
|
+
| --- | --- | --- | --- |
|
|
1247
|
+
| **Definitive** — nobody has it | Telegram answered and refused it (`{"ok":false}` under any status, or a non-2xx status), or the connection never opened at all (refused, DNS failure) so the request provably never left. | back to `pending`, backoff, attempt counted | nothing special — it *is* a first attempt |
|
|
1248
|
+
| **Outcome unknown** — Telegram might have it | The request was cut off after it left: timeout, abort, socket reset, `EPIPE`. Or the POST came back `200` and the **response body could not be read** — Telegram had already decided and the answer was lost coming back. | stays `sending`, flagged as a possible repeat | `POSSIBLE REPEAT`, with the report id to compare against |
|
|
1249
|
+
|
|
1250
|
+
Anything that cannot be classified confidently is treated as **outcome unknown**.
|
|
1251
|
+
That default is deliberate and is the safe direction: the worst case is a
|
|
1252
|
+
duplicate you were warned about, against a delivered report re-posted as though
|
|
1253
|
+
it were new, with nothing anywhere saying it might be a second copy.
|
|
1254
|
+
|
|
1255
|
+
The half of "never double-post" that *is* achievable is enforced: a report cannot
|
|
1256
|
+
be **concurrently** in flight twice. Claiming a report is a conditional update,
|
|
1257
|
+
so only one attempt can move a `pending` row, and every terminal transition names
|
|
1258
|
+
the attempt it is settling — a request that answers after its row was reclaimed
|
|
1259
|
+
is discarded rather than allowed to overwrite a newer attempt's outcome. That is
|
|
1260
|
+
what stops a retry storm.
|
|
1261
|
+
|
|
1262
|
+
### A report nobody can deliver is itself news
|
|
1263
|
+
|
|
1264
|
+
A report that exhausts its retries is marked `failed` **and escalates as tier 2**.
|
|
1265
|
+
This rides the transport that just failed, which is deliberate and accepted: the
|
|
1266
|
+
common failure is a wrong chat id or a bot kicked from the chat, not a global
|
|
1267
|
+
Telegram outage, and in both of those the page reaches an operator who is
|
|
1268
|
+
otherwise being told nothing at all. If the whole channel is down the page
|
|
1269
|
+
degrades to a line in `daemon.log` and the `reports` block in `status`, which is
|
|
1270
|
+
then the only surface — a report has no tracker issue, so there is no issue
|
|
1271
|
+
comment to fall back to. The page goes through the ordinary escalation ledger and
|
|
1272
|
+
carries the report id, so one undeliverable report pages exactly once.
|
|
1273
|
+
|
|
1274
|
+
### The daily digest is deduplicated from the ledger
|
|
1275
|
+
|
|
1276
|
+
`--kind digest` is accepted at most once per **local** day, per project. The
|
|
1277
|
+
second hand-over on the same day is refused and told which report already holds
|
|
1278
|
+
the slot, including when that report has already been delivered. This is decided
|
|
1279
|
+
from the `reports` table, not from the model's memory of the last tick — a
|
|
1280
|
+
restarted or compacted session cannot send a second digest by forgetting the
|
|
1281
|
+
first. Material reports carry no such key: two events in a day are two events.
|
|
1282
|
+
|
|
1283
|
+
### What `status` shows
|
|
1284
|
+
|
|
1285
|
+
```text
|
|
1286
|
+
reports 1 pending · 1 sending · 0 failed (delivery is at-least-once — a retry may duplicate)
|
|
1287
|
+
9f2c1ab0d3e4 pending material 12m old attempt 2/6, retry in 1m (telegram sendMessage rejected: {"ok":false,…)
|
|
1288
|
+
4b7c1ad9e001 SENDING digest 3m old attempt 1, outcome unknown — the process that sent it never said; a daemon start retries it and the message will say it may be a repeat
|
|
1289
|
+
```
|
|
1290
|
+
|
|
1291
|
+
`pending` and `sending` are printed differently because they ask different things
|
|
1292
|
+
of you, and every row carries its age — "1 report pending since 08:15Z" is the
|
|
1293
|
+
signal that was missing when the reports went nowhere. Delivered reports leave
|
|
1294
|
+
the block: it is a list of what you are still owed, not a log.
|
|
1295
|
+
|
|
1296
|
+
Delivery keeps running while the fleet is **paused**. Pause stops claiming, not
|
|
1297
|
+
your right to hear about work that already happened. It runs on its own
|
|
1298
|
+
thirty-second timer rather than the five-minute dispatch tick, so a report does
|
|
1299
|
+
not sit in the outbox for the length of a poll interval.
|
|
1300
|
+
|
|
1301
|
+
The tier-2 escalation ledger (`notifications`) is untouched by all of this. It is
|
|
1302
|
+
a bare dedupe key by design — its primary key *is* the key — which is exactly why
|
|
1303
|
+
reports needed a separate table rather than an extension of that one.
|
|
1304
|
+
|
|
1057
1305
|
## Configuration
|
|
1058
1306
|
|
|
1059
1307
|
The config lives at `$OMP_CONDUCTOR_HOME/config.json`, or
|
|
@@ -1097,6 +1345,7 @@ A complete, valid config for one project with two target repos:
|
|
|
1097
1345
|
"defaults": {
|
|
1098
1346
|
"maxConcurrentWorkers": 2,
|
|
1099
1347
|
"dailySpendUsd": 25,
|
|
1348
|
+
"planUsage": { "windowId": "anthropic:7d", "maxUsedFraction": 0.85 },
|
|
1100
1349
|
"workerMaxTurns": 120,
|
|
1101
1350
|
"workerWallClockMs": 5400000,
|
|
1102
1351
|
"maxAttemptsPerIssue": 2,
|
|
@@ -1150,7 +1399,30 @@ A complete, valid config for one project with two target repos:
|
|
|
1150
1399
|
"merge": "human",
|
|
1151
1400
|
"release": "human"
|
|
1152
1401
|
},
|
|
1153
|
-
"releasePolicy":
|
|
1402
|
+
"releasePolicy": {
|
|
1403
|
+
"git-tag": "human",
|
|
1404
|
+
"git-push-tags": "human",
|
|
1405
|
+
"package-publish": "human",
|
|
1406
|
+
"github-release": "human",
|
|
1407
|
+
"deploy": "human"
|
|
1408
|
+
},
|
|
1409
|
+
"credentials": {
|
|
1410
|
+
"isolation": "per-run"
|
|
1411
|
+
},
|
|
1412
|
+
"policy": {
|
|
1413
|
+
"merge": {
|
|
1414
|
+
"requiredChecks": ["build", "lint"],
|
|
1415
|
+
"baseFreshness": "up-to-date",
|
|
1416
|
+
"drafts": "block",
|
|
1417
|
+
"whenBehindBase": "update-branch"
|
|
1418
|
+
},
|
|
1419
|
+
"release": {
|
|
1420
|
+
"requires": ["runs-settled", "no-open-prs"],
|
|
1421
|
+
"requiredChecks": ["release"],
|
|
1422
|
+
"artefacts": ["@acme/sdk"],
|
|
1423
|
+
"environments": ["staging"]
|
|
1424
|
+
}
|
|
1425
|
+
},
|
|
1154
1426
|
"reporting": {
|
|
1155
1427
|
"scope": "material"
|
|
1156
1428
|
},
|
|
@@ -1180,12 +1452,69 @@ Field notes:
|
|
|
1180
1452
|
| `authority` | Optional; `{ "merge": …, "release": … }`, each `"human"` (default) or `"orchestrator"`. It grants nothing to the daemon — it words the orchestrator's standing orders and the Releases paragraph of the rendered brief, so the config and the prompt cannot disagree about who holds the merge button. Unknown keys and any other value are errors, never folded to the default. |
|
|
1181
1453
|
| `releasePolicy` | Optional; `"none"` (default) or `"operator-brief"`. `none` installs a pre-tool-call tripwire in worker, embedded-orchestrator and external-orchestrator sessions. It blocks `git tag`, tag pushes, package publishing, GitHub release creation and recognised deploy commands before execution. `operator-brief` opens that gate only for the procedure in the operator-owned brief. Unknown values are errors. Every rejection is written to `release-policy-blocks.jsonl`; the heartbeat carries that day's count into the daily digest so configured intent and observed behaviour cannot drift silently. This is the mechanical gate; `authority.release` still says who owns the decision. |
|
|
1182
1454
|
| `reporting.scope` | Optional; `"material"` (default) or `"escalations"`. Every orchestrator tick appends the matching constraint line to its prompt, re-read from this file each tick — see [Your workflow vs. the package](#your-workflow-vs-the-package). It constrains what the session is told to report; it is not an outbound filter. A config written without the key keeps reporting material events. Any other value is an error, never folded to the default. |
|
|
1455
|
+
| `orchestratorReadPaths` | Optional; an array of absolute (or `~`-prefixed) paths the orchestrator session may additionally **read**. The orchestrator's structured file tools are default-refusal — see [Orchestrator confinement](#orchestrator-confinement-default-refusal) — and this is the only way to widen that allowlist. It grants reads, never writes, and it cannot re-open a denied root: an entry pointing at `workspaceRoot`, `mirrorRoot` or the installed package is still refused. A relative entry is an error rather than something resolved against whichever cwd happened to read the file. |
|
|
1456
|
+
| `policy` | Optional; the gating conditions a merge or a release must satisfy, in two sections — `policy.merge` and `policy.release`. Any member may be omitted and the loader fills it from the strict default; an unknown key in either section, or a value outside its vocabulary, is an error naming the field, never a silent downgrade. See [Merge and release preconditions](#merge-and-release-preconditions-policy). |
|
|
1183
1457
|
| `workspaceRoot` / `mirrorRoot` | Optional; default to `worktrees/` and `mirrors/` under the state directory. `~` is expanded. |
|
|
1184
1458
|
|
|
1185
1459
|
Prefer an SSH `cloneUrl`, or an https URL backed by a credential helper. A clone URL
|
|
1186
1460
|
with credentials embedded is persisted into the mirror's git config, exactly as it
|
|
1187
1461
|
would be for a hand-run clone.
|
|
1188
1462
|
|
|
1463
|
+
### Merge and release preconditions (`policy`)
|
|
1464
|
+
|
|
1465
|
+
These used to be sentences in your `POLICY.md`: when a PR may be merged, what
|
|
1466
|
+
must be green, what a release requires. Prose cannot be checked, so every tick
|
|
1467
|
+
re-decided them by reading and interpreting them again. They are configuration
|
|
1468
|
+
now, `POLICY.md` keeps only judgement, and the rendered brief *describes* the
|
|
1469
|
+
policy instead of restating it — no threshold lives in two places.
|
|
1470
|
+
|
|
1471
|
+
`policy.merge`:
|
|
1472
|
+
|
|
1473
|
+
| Field | Values | Default | Means |
|
|
1474
|
+
| --- | --- | --- | --- |
|
|
1475
|
+
| `requiredChecks` | any check names | `[]` | Checks that must have concluded successfully. **Empty is the strict answer** — it means every check the PR reports, not "no checks". |
|
|
1476
|
+
| `baseFreshness` | `up-to-date`, `any` | `up-to-date` | Whether the head must be level with the base branch. `any` accepts a verdict produced against an older base. |
|
|
1477
|
+
| `drafts` | `block`, `allow` | `block` | Whether a draft PR can be merged at all. |
|
|
1478
|
+
| `whenBehindBase` | `update-branch`, `hold`, `escalate` | `update-branch` | What to do with a green PR that fell behind. `update-branch` runs `gh pr update-branch` and waits for the fresh run. Closing it and an admin bypass are not spellable. |
|
|
1479
|
+
|
|
1480
|
+
`policy.release`:
|
|
1481
|
+
|
|
1482
|
+
| Field | Values | Default | Means |
|
|
1483
|
+
| --- | --- | --- | --- |
|
|
1484
|
+
| `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. |
|
|
1485
|
+
| `requiredChecks` | any check names | `[]` | Checks that must be green on the branch being released. Empty means every check it reports. |
|
|
1486
|
+
| `artefacts` | any names | `[]` | The packages or images this project releases. **Empty denies**: nothing has been authorised to ship. |
|
|
1487
|
+
| `environments` | any names | `[]` | Deploy targets. **Empty denies** every environment. |
|
|
1488
|
+
|
|
1489
|
+
A project with no `policy` block loads as the whole default above, which is the
|
|
1490
|
+
strictest reading of the prose it replaced. `/conductor setup` asks for all of
|
|
1491
|
+
it under the **merge & release preconditions** area, so changing one condition
|
|
1492
|
+
costs eight prompts rather than a hand-edit — see
|
|
1493
|
+
[Changing one setting](#changing-one-setting).
|
|
1494
|
+
|
|
1495
|
+
This key grants nothing. Who *may* merge or release is
|
|
1496
|
+
[`authority`](#configuration), and which release tool calls are mechanically
|
|
1497
|
+
permitted is [`releasePolicy`](#configuration). `policy` says what must be true
|
|
1498
|
+
before the act, whoever is doing it.
|
|
1499
|
+
|
|
1500
|
+
#### Reasons are a closed vocabulary
|
|
1501
|
+
|
|
1502
|
+
Where an automated verb takes a `reason`, the argument is one value out of a
|
|
1503
|
+
fixed set, not free text — a reason a rule matches on is a reason that decides,
|
|
1504
|
+
and a decision made out of a model's own wording is one no two runs spell the
|
|
1505
|
+
same way. A reason outside its set is refused, and the refusal names every
|
|
1506
|
+
accepted value.
|
|
1507
|
+
|
|
1508
|
+
| Verb | Accepted reasons |
|
|
1509
|
+
| --- | --- |
|
|
1510
|
+
| merge | `preconditions-met`, `behind-base-refreshed`, `operator-instructed`, `release-blocking` |
|
|
1511
|
+
| release | `batch-complete`, `epic-closed`, `hotfix`, `operator-instructed` |
|
|
1512
|
+
| label change | `promoted-to-queue`, `re-briefed`, `needs-human`, `duplicate`, `superseded`, `out-of-scope` |
|
|
1513
|
+
|
|
1514
|
+
Free-form rationale still has a home: it rides alongside as a separate
|
|
1515
|
+
`rationale` field, is written into the audit trail verbatim, and is never
|
|
1516
|
+
parsed or matched by anything.
|
|
1517
|
+
|
|
1189
1518
|
## Orchestrator tick
|
|
1190
1519
|
|
|
1191
1520
|
The escalation path above assumes an orchestrator session that is actually
|
|
@@ -1407,6 +1736,7 @@ omp-conductor stop
|
|
|
1407
1736
|
omp-conductor restart [--port N] [--project NAME]
|
|
1408
1737
|
omp-conductor upgrade [--to VERSION] [--project NAME]
|
|
1409
1738
|
omp-conductor status [--project NAME]
|
|
1739
|
+
omp-conductor ledger [--issue N] [--limit N] [--project NAME]
|
|
1410
1740
|
omp-conductor board [--project NAME]
|
|
1411
1741
|
omp-conductor hold [--project NAME]
|
|
1412
1742
|
omp-conductor halt [--pane] [--project NAME]
|
|
@@ -1417,6 +1747,7 @@ omp-conductor tail <issue> [--project NAME]
|
|
|
1417
1747
|
omp-conductor extend <issue> --turns N [--project NAME]
|
|
1418
1748
|
omp-conductor unblock <issue> [--force] [--project NAME]
|
|
1419
1749
|
omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
|
|
1750
|
+
omp-conductor report --text TEXT [--kind material|digest] [--project NAME]
|
|
1420
1751
|
omp-conductor daemon [--once] [--port N] [--project NAME]
|
|
1421
1752
|
omp-conductor pause
|
|
1422
1753
|
omp-conductor resume
|
|
@@ -1431,7 +1762,8 @@ omp-conductor help
|
|
|
1431
1762
|
| `stop` | 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. |
|
|
1432
1763
|
| `restart` | 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. 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). |
|
|
1433
1764
|
| `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. |
|
|
1434
|
-
| `status [--project NAME]` | Layered fleet report first: `dispatch` / `ticks` / next scheduled tick / `pane` / `recovery` / `herdr` / `telegram` / optional `code graph` / `daemon`, then the project body. The project body includes the latest completed dispatch timestamp, ready/routed/admitted counts, and bounded hold groups; 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. The daemon block includes `rss` from `/healthz`; live workers add a busy-deploy warning. A `.conductor-stalled` marker adds an `orchestrator STALLED since …` line. |
|
|
1765
|
+
| `status [--project NAME]` | Layered fleet report first: `dispatch` / `ticks` / next scheduled tick / `pane` / `recovery` / `herdr` / `telegram` / optional `code graph` / `daemon`, then the project body. The project body includes the latest completed dispatch timestamp, ready/routed/admitted counts, and bounded hold groups; 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. |
|
|
1766
|
+
| `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`. |
|
|
1435
1767
|
| `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, and the last 24 hours of Merged and History. 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. |
|
|
1436
1768
|
| `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). |
|
|
1437
1769
|
| `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. |
|
|
@@ -1442,6 +1774,7 @@ omp-conductor help
|
|
|
1442
1774
|
| `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. |
|
|
1443
1775
|
| `unblock <issue> [--force]` | Remove that issue's `blocked` and `failed` labels so an answered escalation can be claimed again. `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 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. **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. |
|
|
1444
1776
|
| `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. |
|
|
1777
|
+
| `report --text TEXT [--kind material|digest]` | Hand a rendered report to the daemon's durable outbox. The text is persisted **before** anything is sent and the command prints a report id; the daemon then owns delivery, retries on a bounded backoff, and records the Telegram message id it actually got back. Delivery is [at-least-once](#report-delivery-the-outbox) — a crash mid-send is retried and the retry says it may be a repeat — so `delivered` is never proof of exactly one message. `--kind digest` is accepted at most once per local day, decided from the ledger rather than from what the session remembers sending; an unknown `--kind` exits `2` rather than quietly becoming a material report. Anything undelivered shows in `status` with its age. |
|
|
1445
1778
|
| `daemon` | 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. |
|
|
1446
1779
|
| `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. |
|
|
1447
1780
|
| `--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. |
|
|
@@ -1534,9 +1867,9 @@ dispatcher. The brief is explicit about the boundary:
|
|
|
1534
1867
|
| Edit code inside its own worktree. | Weaken, skip, delete or loosen **any test it did not write** — that is a design question to escalate, and it is checked by diff review before the push. |
|
|
1535
1868
|
| Add or update tests for behaviour it introduced. | Suppress a warning, delete an assertion, or special-case an input to make a check pass. |
|
|
1536
1869
|
| Run the repo's configured cheap gates, each from its listed `cwd`, over the whole tree. | Run docker or image builds, production builds, browser/e2e suites, or the full test suite on the shared host — CI owns the heavy gates. |
|
|
1537
|
-
| Review its whole diff, then commit and
|
|
1538
|
-
| Open a PR
|
|
1539
|
-
| Escalate: ambiguity, a cross-repo contract, a needed credential, a product or data-migration decision, a blocking existing test, CI red twice, or most of the wall-clock budget burned. |
|
|
1870
|
+
| Review its whole diff, then commit and publish once with `conductor_push`. One corrective push if CI is red. | Force-push, `git add -f`, or add AI/co-author attribution. There is no force path to reach: `conductor_push` publishes that run's branch fast-forward only and takes no other ref. Red twice means stop and report, not push a third time. |
|
|
1871
|
+
| Open a PR with `conductor_pr_create`, and poll CI to a verdict with `conductor_pr_status`. | Run `gh pr merge` — or reach `conductor_pr_merge`, which refuses a worker session mechanically. **A worker never merges** — absolute, whoever else holds the authority — so PRs land one at a time with a freshness re-check; two workers merging concurrently is how agent PRs clobber each other. Who *may* merge is the [`authority`](#configuration) answer, and it is never the worker. |
|
|
1872
|
+
| Escalate: ambiguity, a cross-repo contract, a needed credential, a product or data-migration decision, a blocking existing test, CI red twice, or most of the wall-clock budget burned. | Cut a release, push a tag, publish to npm, edit a deployment pin, deploy, or touch infrastructure or secrets. `conductor_release` and `conductor_label` refuse a worker whatever `releasePolicy` says, because the check compares the caller against the configured holder rather than ruling one value out. The in-session tripwire still blocks recognised release/deploy tool calls early and audits the attempt, but it is [defence in depth](#the-mediated-verbs-126), not the gate. |
|
|
1540
1873
|
|
|
1541
1874
|
The worker ends with a seven-line evidence report (issue, PR, observed head SHA,
|
|
1542
1875
|
state, gates, changed, next). A textual `pushed-green` claim is not success: the
|
|
@@ -1545,7 +1878,7 @@ daemon repeats the PR/head/check verification before it records that state.
|
|
|
1545
1878
|
### Worker confinement and the integrity tripwire
|
|
1546
1879
|
|
|
1547
1880
|
A worker session is rooted at its worktree `cwd`. **Structured file tools are
|
|
1548
|
-
gated mechanically:** `runWorker` asks `createSession({
|
|
1881
|
+
gated mechanically:** `runWorker` asks `createSession({ role: "worker" })`,
|
|
1549
1882
|
which installs an inline harness extension that blocks `write` / `edit` /
|
|
1550
1883
|
`read` / `grep` / `glob` when the tool's path resolves outside that worktree
|
|
1551
1884
|
(symlink-aware). Target selection was already mechanical — only a repo in
|
|
@@ -1602,6 +1935,353 @@ Until that uid exists, a root-or-operator daemon still has a mechanical
|
|
|
1602
1935
|
worktree gate on structured tools and an integrity tripwire on its own package —
|
|
1603
1936
|
but `bash` plus host credentials remain a prompt-and-deploy problem.
|
|
1604
1937
|
|
|
1938
|
+
### Orchestrator confinement (default refusal)
|
|
1939
|
+
|
|
1940
|
+
The orchestrator cannot be jailed to its `cwd` the way a worker is: it has to
|
|
1941
|
+
read the state directory, the composed `ORCHESTRATOR.md`, `POLICY.md` and the
|
|
1942
|
+
config. So it gets the other shape — **an allowlist, for reads as well as
|
|
1943
|
+
writes, where anything unmatched is refused.** A deny-list would only stop the
|
|
1944
|
+
paths somebody thought to name and would leave the structured tools pointed at
|
|
1945
|
+
the rest of the filesystem.
|
|
1946
|
+
|
|
1947
|
+
| | Paths |
|
|
1948
|
+
| --- | --- |
|
|
1949
|
+
| **Readable** | The state directory (store, logs, claim files, session transcripts), the composed brief, `POLICY.md`, the config, and the heartbeat's own `.conductor-tick*.json` files beside the briefs — plus every root in [`orchestratorReadPaths`](#configuration). |
|
|
1950
|
+
| **Writable** | The state directory, and `POLICY.md` (the Learning loop amends it after you say yes). The composed `ORCHESTRATOR.md` is regenerated from the package floor each tick, so it is deliberately not writable. |
|
|
1951
|
+
| **Denied, outranking every allow entry** | `workspaceRoot/**` — every worker checkout — the mirror cache, the installed `omp-conductor` package, and *writes* to `config.json` itself: the config declares the session's own jail, grants and authority, so it is readable but only an operator changes it. |
|
|
1952
|
+
|
|
1953
|
+
Denial is checked *after* the allowlist is consulted and *wins over it*, which
|
|
1954
|
+
is the point: an `orchestratorReadPaths` entry pointing at `workspaceRoot` or at
|
|
1955
|
+
the package buys nothing. Reading another run's tree is denied rather than
|
|
1956
|
+
merely un-writable on purpose — it is how the orchestrator ends up reasoning
|
|
1957
|
+
about uncommitted work whose provenance it cannot see, and it has the PR for
|
|
1958
|
+
the parts that matter.
|
|
1959
|
+
|
|
1960
|
+
Every root is computed from your config, never hardcoded: move the state
|
|
1961
|
+
directory with `$OMP_CONDUCTOR_HOME`, or point `workspaceRoot` somewhere else,
|
|
1962
|
+
and the jail moves with it. A refusal names the path and the roots that *are*
|
|
1963
|
+
allowed — a worktree refusal names the individual run's checkout — so a session
|
|
1964
|
+
can correct itself in one turn instead of probing. Refusals are appended to
|
|
1965
|
+
`orchestrator-confinement-refusals.jsonl` in the state directory and today's
|
|
1966
|
+
count appears in `omp-conductor status` as a `confine` line; an orchestrator
|
|
1967
|
+
being refused repeatedly is misbriefed, and that should be visible rather than
|
|
1968
|
+
silent.
|
|
1969
|
+
|
|
1970
|
+
**This is defence in depth, not the boundary.** `bash` is deliberately not
|
|
1971
|
+
parsed here — matching shell strings is false security — so this gate does not
|
|
1972
|
+
contain a determined session, and nothing in this package claims it does. The
|
|
1973
|
+
boundary is an OS principal for the orchestrator with no filesystem access to
|
|
1974
|
+
any run checkout; until that exists, what this buys is real but narrower:
|
|
1975
|
+
accidents are prevented, refusals are legible, and it holds on hosts where no
|
|
1976
|
+
uid split will ever be deployed. Package-source edits that get around it
|
|
1977
|
+
(through `bash`) remain covered by the integrity tripwire above, one tick late.
|
|
1978
|
+
|
|
1979
|
+
## The mediated verbs (#126)
|
|
1980
|
+
|
|
1981
|
+
With the credential boundary in place a session can no longer mutate GitHub by
|
|
1982
|
+
accident or by malice — and can no longer do its job either. These verbs are the
|
|
1983
|
+
only sanctioned way back, and the point of them is *where the checks run*: in
|
|
1984
|
+
the daemon, across a process boundary, not in a prompt the model can rewrite.
|
|
1985
|
+
|
|
1986
|
+
### The verbs
|
|
1987
|
+
|
|
1988
|
+
| Verb | Allowed caller | What the daemon checks before acting |
|
|
1989
|
+
| --- | --- | --- |
|
|
1990
|
+
| `conductor_push` | the worker owning the run | The ref is exactly `refs/heads/<that run's branch>`. Fast-forward only; there is no force argument to reject because none is declared. |
|
|
1991
|
+
| `conductor_pr_create` | the worker owning the run | The run has no open PR (the same guard admission uses); head is the run branch; base is the repo's configured `defaultBranch`. |
|
|
1992
|
+
| `conductor_pr_status` | worker or orchestrator | Read-only. A worker reads only its own run's PR. |
|
|
1993
|
+
| `conductor_pr_update_branch` | orchestrator, or the worker owning the run | The PR belongs to this run and project, and is open. |
|
|
1994
|
+
| `conductor_pr_merge` | **orchestrator only** | `authority.merge` equals the caller; `headSha` equals the live head *at execution time*; checks green at that same SHA; the project's single merge slot is free. |
|
|
1995
|
+
| `conductor_label` | **orchestrator only** | The label is in the project's own vocabulary. Lifecycle labels stay the daemon's. |
|
|
1996
|
+
| `conductor_release` | **orchestrator only** | `authority.release` equals the caller; the per-shape grant permits it; the artefact or environment was declared; the release preconditions hold; the `reason` is in the closed enum. |
|
|
1997
|
+
|
|
1998
|
+
Merge and release are the same rule written twice on purpose: **the caller's
|
|
1999
|
+
role must equal the configured holder.** `authority` has exactly two values, so
|
|
2000
|
+
a `!== "human"` test would have let a *worker* release. A worker is refused
|
|
2001
|
+
every release shape under the most permissive config there is.
|
|
2002
|
+
|
|
2003
|
+
### The transport
|
|
2004
|
+
|
|
2005
|
+
Identity is never an argument. `project`, `run`, `issue` and the caller's role
|
|
2006
|
+
come from **which socket the call arrived on** plus the verified peer uid, and a
|
|
2007
|
+
request carrying any of those field names is refused outright, named. A worker
|
|
2008
|
+
on run X asking to merge run Y's PR is not merely wrong, it is unexpressible.
|
|
2009
|
+
|
|
2010
|
+
```
|
|
2011
|
+
<state dir>/verbs/ daemon-owned, mode 0711
|
|
2012
|
+
run-7-9a783d877d422b9e.sock 0600, owned by run 7's principal
|
|
2013
|
+
run-9-1c40e2a5b6d3f018.sock 0600, owned by run 9's principal
|
|
2014
|
+
orchestrator-4b1f...c2.sock 0600, a third, distinct one
|
|
2015
|
+
```
|
|
2016
|
+
|
|
2017
|
+
`0711` is load-bearing and the obvious `0700` does not work: *connecting* needs
|
|
2018
|
+
**search** (`+x`) on every path component, not read. Searchable by run
|
|
2019
|
+
principals, listable by none, and writable by none but the daemon — that last
|
|
2020
|
+
one is what stops a run unlinking a sibling's socket or binding an impostor in
|
|
2021
|
+
its place. A per-run *directory* owned by the run principal would hand exactly
|
|
2022
|
+
that power back, and is deliberately not used.
|
|
2023
|
+
|
|
2024
|
+
Before binding, the daemon verifies every component of the path is owned by
|
|
2025
|
+
itself (or root), free of symlinks, and unwritable by anyone else; a failed
|
|
2026
|
+
check **refuses dispatch** rather than degrading. Where the run has its own
|
|
2027
|
+
principal it also verifies the chain is traversable, and says which directory to
|
|
2028
|
+
`chmod o+x` when it is not. Paths are unguessably suffixed, and only the daemon
|
|
2029
|
+
ever unlinks one.
|
|
2030
|
+
|
|
2031
|
+
Peer credentials are asserted server-side — `getpeereid` on macOS, `SO_PEERCRED`
|
|
2032
|
+
on Linux — against the uid allocated for that run. A mismatch closes the
|
|
2033
|
+
connection with no reply and logs an impersonation attempt. Where the host has
|
|
2034
|
+
no per-run principals there is no distinct uid to compare, and the daemon says
|
|
2035
|
+
so at startup rather than implying a guarantee it is not making:
|
|
2036
|
+
|
|
2037
|
+
```
|
|
2038
|
+
verb transport: verb sockets in ~/.omp/conductor/verbs (mode 711); each socket
|
|
2039
|
+
0600 under the daemon's own uid (no per-run principals on this host); peer uid
|
|
2040
|
+
asserted with getpeereid
|
|
2041
|
+
```
|
|
2042
|
+
|
|
2043
|
+
**No mutation route exists on the HTTP port**, and none may be added. That
|
|
2044
|
+
surface is unauthenticated loopback TCP reachable by any local user; a `PUT` or
|
|
2045
|
+
`POST` at any verb path answers 404, pinned by a test.
|
|
2046
|
+
|
|
2047
|
+
The child-side tool handler is a thin client only. It forwards arguments and
|
|
2048
|
+
renders the answer — no policy branch, no credential, no local fallback. With no
|
|
2049
|
+
socket it fails closed and says so, rather than reaching for `git push`.
|
|
2050
|
+
|
|
2051
|
+
### The ledger
|
|
2052
|
+
|
|
2053
|
+
Every mutating call is recorded with its arguments, the decision, the named
|
|
2054
|
+
refusal reason and any resulting SHA. Reads are not: a status poll every thirty
|
|
2055
|
+
seconds would bury the refusals the record exists to surface.
|
|
2056
|
+
|
|
2057
|
+
```console
|
|
2058
|
+
$ omp-conductor ledger --issue 7
|
|
2059
|
+
acme — 3 verb call(s), 1 refused (newest first)
|
|
2060
|
+
2026-08-09 11:04:12 REFUSE conductor_pr_merge worker #7 [role-not-allowed]
|
|
2061
|
+
prUrl=https://github.com/acme/api/pull/7 headSha=9a783d8… reason=preconditions-met
|
|
2062
|
+
refused: merge authority is the orchestrator's, never a worker session's.
|
|
2063
|
+
2026-08-09 10:58:03 ALLOW conductor_pr_create worker #7
|
|
2064
|
+
title=fix: settle the head check body=Closes acme/tracker#7
|
|
2065
|
+
opened https://github.com/acme/api/pull/7 (conductor/issue-7 → main).
|
|
2066
|
+
2026-08-09 10:57:41 ALLOW conductor_push worker #7 9a783d877d42
|
|
2067
|
+
(no arguments)
|
|
2068
|
+
pushed refs/heads/conductor/issue-7 at 9a783d877d42….
|
|
2069
|
+
```
|
|
2070
|
+
|
|
2071
|
+
The newest few also appear in `omp-conductor status`, because a refused merge is
|
|
2072
|
+
news: it means a session tried to do something the config does not permit.
|
|
2073
|
+
|
|
2074
|
+
`release-policy.ts` stays installed as defence in depth — it refuses early, in
|
|
2075
|
+
the session, with an explanation the model can act on in the same turn, and it
|
|
2076
|
+
leaves a durable record that something tried. It is no longer what *stops* a
|
|
2077
|
+
release. Treat a block there as evidence about a session's intentions; the
|
|
2078
|
+
daemon is what prevented it.
|
|
2079
|
+
|
|
2080
|
+
## The credential boundary (`credentials`)
|
|
2081
|
+
|
|
2082
|
+
Every prose rule about what a worker "may not" do reduces to one fact: until
|
|
2083
|
+
this existed, **model-executed code held the operator's own GitHub write
|
|
2084
|
+
credential.** Sessions ran inside the daemon process, as the same OS user, with
|
|
2085
|
+
the same `$HOME` and the same filesystem view — and the daemon authenticates by
|
|
2086
|
+
shelling out to the logged-in `gh`, so a worker's `bash` reached the same
|
|
2087
|
+
credential by running the same binary.
|
|
2088
|
+
|
|
2089
|
+
Scrubbing `GH_TOKEN` does not fix that, and this package will not describe it as
|
|
2090
|
+
if it did. Code running as the same uid defeats an environment variable in one
|
|
2091
|
+
line (`GH_CONFIG_DIR=$HOME/.config/gh gh pr merge …`), and the login keychain,
|
|
2092
|
+
`~/.ssh` and `~/.git-credentials` need no environment at all. **The boundary is
|
|
2093
|
+
an OS principal.** The environment scrubbing that ships alongside it is
|
|
2094
|
+
accident-prevention: it makes the common mistakes fail immediately and legibly,
|
|
2095
|
+
and it stops nothing that is trying.
|
|
2096
|
+
|
|
2097
|
+
### What actually changed
|
|
2098
|
+
|
|
2099
|
+
- Worker and orchestrator sessions are **child processes**, launched under a
|
|
2100
|
+
principal scoped to one run. They are still supervised exactly as before —
|
|
2101
|
+
turn caps, wall-clock caps, spend accounting, salvage, transcript paths — and
|
|
2102
|
+
a session that outlives its daemon is not possible: the child exits when its
|
|
2103
|
+
control socket closes.
|
|
2104
|
+
- A run's checkout is **its own git repository**, borrowing objects read-only
|
|
2105
|
+
from the shared mirror through git alternates. It used to be a linked worktree
|
|
2106
|
+
of that mirror, which needs write access to the mirror's refs and therefore
|
|
2107
|
+
handed every run write access to every other run's.
|
|
2108
|
+
- **Publishing is the daemon's.** The worker commits locally; the daemon fetches
|
|
2109
|
+
the run branch out of the run's repository into the mirror and pushes it to
|
|
2110
|
+
GitHub, fast-forward only. There is no force path anywhere in this package. A
|
|
2111
|
+
rejected push settles the run `failed` with git's stderr verbatim.
|
|
2112
|
+
- One function, `credentialedEnv()`, is the only construction site of credential
|
|
2113
|
+
material in the daemon. `gh`, the mirror's clone and fetch, the daemon-side
|
|
2114
|
+
push and the mediated verbs all go through it, so "what can reach the
|
|
2115
|
+
credential" is answered by its call sites rather than by an audit.
|
|
2116
|
+
|
|
2117
|
+
### `credentials.isolation`
|
|
2118
|
+
|
|
2119
|
+
| Value | Meaning |
|
|
2120
|
+
|---|---|
|
|
2121
|
+
| `"per-run"` | Every session runs as its own OS principal. The daemon refuses to dispatch if this host cannot provide one. |
|
|
2122
|
+
| `"none"` | Sessions run as the daemon's user. `status` reports the fleet as **unprotected**, every time. |
|
|
2123
|
+
|
|
2124
|
+
There is deliberately no third value. The degraded `group-mode` mechanism below
|
|
2125
|
+
is something the *host probe* can report, never something a config can ask for,
|
|
2126
|
+
so an operator cannot believe they configured the full boundary and get the
|
|
2127
|
+
weaker one.
|
|
2128
|
+
|
|
2129
|
+
**A config written before this key existed is migrated, not rejected.** On the
|
|
2130
|
+
first start it resolves to `"none"`, the key is written into the file
|
|
2131
|
+
explicitly, and the daemon logs the migration once by project name. The answer
|
|
2132
|
+
then lives on disk rather than being inherited from a default nobody chose — and
|
|
2133
|
+
no running fleet stops dispatching on upgrade. A fleet that upgrades and does
|
|
2134
|
+
nothing else is unprotected and says so in `status`; turning that into a refusal
|
|
2135
|
+
would have been an outage caused by a security feature.
|
|
2136
|
+
|
|
2137
|
+
### `credentials.readToken`
|
|
2138
|
+
|
|
2139
|
+
Optional, and absent by default. The boundary removes GitHub *reads* too, so a
|
|
2140
|
+
worker with no token works from its dispatch brief — which already carries the
|
|
2141
|
+
issue body and the acceptance criteria — and from the mediated verbs. Set this
|
|
2142
|
+
to a **read-scoped** token to give sessions `gh` reads back.
|
|
2143
|
+
|
|
2144
|
+
This package cannot verify the scope. A write-scoped token here re-opens exactly
|
|
2145
|
+
the hole the rest of this section closes.
|
|
2146
|
+
|
|
2147
|
+
### Mechanisms, and what each one does not protect
|
|
2148
|
+
|
|
2149
|
+
The daemon probes the host at startup, states the mechanism, and proves it by
|
|
2150
|
+
running the composed launcher against a trivial child before reporting it
|
|
2151
|
+
available. A mechanism whose argv the host rejects resolves *down* at probe time
|
|
2152
|
+
rather than failing every dispatch at run time.
|
|
2153
|
+
|
|
2154
|
+
| Mechanism | Where | Contains a determined escape? |
|
|
2155
|
+
|---|---|---|
|
|
2156
|
+
| `uid-pool` | Linux, with the capabilities and accounts below | **Yes** |
|
|
2157
|
+
| `sandbox-exec` | macOS | **Yes**, within a deprecated Apple interface |
|
|
2158
|
+
| `group-mode` | Linux, groups but no capabilities | **No** — cross-run separation only |
|
|
2159
|
+
| `none` | anywhere else | **No** |
|
|
2160
|
+
|
|
2161
|
+
`group-mode` exists so an operator who cannot grant capabilities still gets
|
|
2162
|
+
cross-run checkout separation. It runs sessions as the daemon's own uid, so it
|
|
2163
|
+
bounds accidents and does not stop a determined same-uid escape. `status` says
|
|
2164
|
+
which one is live, and lists what it does not close.
|
|
2165
|
+
|
|
2166
|
+
### Deploying `per-run` on a Linux fleet host
|
|
2167
|
+
|
|
2168
|
+
See `systemd/omp-conductor.service.example` for the copy-pasteable version. In
|
|
2169
|
+
summary:
|
|
2170
|
+
|
|
2171
|
+
1. **Capabilities on the existing unprivileged account.** The unit grants
|
|
2172
|
+
`CAP_SETUID CAP_SETGID CAP_CHOWN CAP_SETPCAP`. They exist to be *dropped
|
|
2173
|
+
into* run children, never inherited by them. A capability grant on the
|
|
2174
|
+
`fleet` account is a narrower blast radius than running as root or shipping a
|
|
2175
|
+
setuid binary.
|
|
2176
|
+
|
|
2177
|
+
`CAP_SETPCAP` is there solely so the launcher can empty the child's
|
|
2178
|
+
capability **bounding** set, which `PR_CAPBSET_DROP` requires. A host that
|
|
2179
|
+
withholds it takes a documented fallback: the launcher omits
|
|
2180
|
+
`--bounding-set=-all`, the child still ends with every other set empty behind
|
|
2181
|
+
`NoNewPrivs`, the leftover `CapBnd` is inert, and `status` reports it as a
|
|
2182
|
+
named residual.
|
|
2183
|
+
|
|
2184
|
+
2. **`setpriv` (util-linux) must be on `PATH`.** Every run child is launched
|
|
2185
|
+
through it: group list, then gid, then uid, then empty the
|
|
2186
|
+
permitted/effective/inheritable/ambient sets, drop the bounding set, set
|
|
2187
|
+
`PR_SET_NO_NEW_PRIVS`, then `exec`. Without `setpriv` the probe reports
|
|
2188
|
+
`none`. There is deliberately no `spawn({uid,gid})` fallback — ambient
|
|
2189
|
+
capabilities survive `execve` for ordinary binaries, so a child launched that
|
|
2190
|
+
way holds `CAP_SETUID` itself and can `setuid()` back to a sibling run or to
|
|
2191
|
+
the daemon, voiding the boundary while appearing to work.
|
|
2192
|
+
|
|
2193
|
+
3. **Provision with the shipped command, not by hand.**
|
|
2194
|
+
`omp-conductor boundary-setup --slots <n>` prints the exact idempotent root
|
|
2195
|
+
commands, generated from the same constants the startup probe checks, so the
|
|
2196
|
+
instructions cannot drift from what the daemon then demands.
|
|
2197
|
+
|
|
2198
|
+
```bash
|
|
2199
|
+
omp-conductor boundary-setup --slots 2 # read it first
|
|
2200
|
+
omp-conductor boundary-setup --slots 2 | sudo bash
|
|
2201
|
+
sudo systemctl restart omp-conductor.service
|
|
2202
|
+
```
|
|
2203
|
+
|
|
2204
|
+
It needs no config and must run **before** `setup`: setup writes worktree and
|
|
2205
|
+
mirror paths into the shared root this creates. What it establishes:
|
|
2206
|
+
|
|
2207
|
+
4. **One account per run slot**, `conductor-agent-<n>` for each concurrent
|
|
2208
|
+
worker plus `conductor-agent-orch` for the orchestrator. `maxConcurrentWorkers`
|
|
2209
|
+
is 2 by default, so the pool is small. The orchestrator's account is distinct
|
|
2210
|
+
precisely so it has no read or write access to any run checkout, and it is
|
|
2211
|
+
launched with no supplementary group at all.
|
|
2212
|
+
|
|
2213
|
+
5. **Two groups**, and the split between them is the whole filesystem model:
|
|
2214
|
+
|
|
2215
|
+
| Group | Members | Purpose |
|
|
2216
|
+
|---|---|---|
|
|
2217
|
+
| `conductor-daemon` | the daemon account **only** | lets it reach every run repo — fetch the branch, salvage, reclaim the tree |
|
|
2218
|
+
| `conductor-runs` | every slot principal | read-only access to the shared mirror |
|
|
2219
|
+
|
|
2220
|
+
A slot principal must **never** be in `conductor-daemon`. That membership is
|
|
2221
|
+
the only thing keeping sibling runs out of each other's checkouts, and its
|
|
2222
|
+
absence is asserted from a live session by the probe suite.
|
|
2223
|
+
|
|
2224
|
+
Supplementary group membership is fixed when a process starts, so this
|
|
2225
|
+
**requires a service restart**. Without it the daemon's live credentials lack
|
|
2226
|
+
the group even though `getent` shows it, and it would chown every run repo to
|
|
2227
|
+
a group it cannot itself use — the probe checks the running process's own
|
|
2228
|
+
credentials for exactly this reason, and every run additionally write-tests
|
|
2229
|
+
its tree after the ownership handoff.
|
|
2230
|
+
|
|
2231
|
+
6. **Layout.** Run repos are `conductor-agent-<slot>:conductor-daemon`,
|
|
2232
|
+
directories `2770` (setgid, so what the worker creates inherits the group)
|
|
2233
|
+
and files `0660` with execute bits preserved. The shared mirror is
|
|
2234
|
+
daemon-owned, group `conductor-runs`, `0750`/`0640`.
|
|
2235
|
+
|
|
2236
|
+
Everything a slot must reach lives under **`/var/lib/omp-conductor`** (mode
|
|
2237
|
+
`0711`): worktrees, mirrors, per-run session transcripts and per-run boundary
|
|
2238
|
+
homes. It is deliberately outside the state directory, which stays `0700`
|
|
2239
|
+
because it holds `conductor.db` and the WAL files SQLite recreates at
|
|
2240
|
+
runtime — making that searchable would publish fleet history to every local
|
|
2241
|
+
account. Override with `$OMP_CONDUCTOR_SHARED`. Uninstall is therefore two
|
|
2242
|
+
paths, not one.
|
|
2243
|
+
|
|
2244
|
+
7. **The daemon's home is `0711`, with its credential leaves closed**
|
|
2245
|
+
(`.ssh`, `.config/gh` at `0700`; `.npmrc`, `.git-credentials` at `0600`).
|
|
2246
|
+
Searchable because the runtime and the installed package live in it — a
|
|
2247
|
+
`0700` home kills every worker before it connects, and no shell-based probe
|
|
2248
|
+
notices — and closed at the leaves because that is where the boundary
|
|
2249
|
+
actually rests. The daemon re-checks this empirically at dispatch, asking the
|
|
2250
|
+
slot principal itself whether it can read any credential path, and refuses
|
|
2251
|
+
the run if it can.
|
|
2252
|
+
|
|
2253
|
+
Verify with `omp-conductor status`: the `boundary` row names the live mechanism
|
|
2254
|
+
and lists its residuals.
|
|
2255
|
+
|
|
2256
|
+
### macOS dev hosts
|
|
2257
|
+
|
|
2258
|
+
`sandbox-exec` is Apple-deprecated and is still the only thing on a stock host
|
|
2259
|
+
that denies a *read*, which is what this needs. The generated profile makes the
|
|
2260
|
+
run's checkout the only writable path and denies `~/.ssh`, `~/.config/gh`,
|
|
2261
|
+
`~/.git-credentials`, `~/.npmrc`, the other run checkouts, and mach lookups of
|
|
2262
|
+
the keychain services. Verified on darwin 25.5.0 against the full probe list.
|
|
2263
|
+
|
|
2264
|
+
A dev host may also simply run `credentials.isolation: "none"`, in which case
|
|
2265
|
+
`status` reports it unprotected. That is a legitimate choice for a machine that
|
|
2266
|
+
is not a fleet host; it is not a default.
|
|
2267
|
+
|
|
2268
|
+
### The residual, stated plainly
|
|
2269
|
+
|
|
2270
|
+
A run can **read** another run's git objects out of the shared mirror. That is
|
|
2271
|
+
the price of not cloning the repository per run, and it is bounded: same source,
|
|
2272
|
+
no write path, no credential. It is asserted as *permitted* by the probe suite,
|
|
2273
|
+
so nobody can quietly "fix" it by weakening the mirror's modes.
|
|
2274
|
+
|
|
2275
|
+
### MCP servers are inside the blast radius
|
|
2276
|
+
|
|
2277
|
+
A GitHub MCP server carrying its own PAT re-opens the hole. Sessions discover
|
|
2278
|
+
MCP configuration from the **agent principal's** config root — the redirected
|
|
2279
|
+
`$HOME` this package creates per session, not the operator's — and dispatch
|
|
2280
|
+
**refuses with a named error** when a credential-bearing GitHub server is found
|
|
2281
|
+
there or in the run's checkout. Put servers you want sessions to have in the
|
|
2282
|
+
agent principal's root, without credentials.
|
|
2283
|
+
|
|
2284
|
+
|
|
1605
2285
|
## Limitations
|
|
1606
2286
|
|
|
1607
2287
|
Known and deliberate in this version:
|
|
@@ -1628,6 +2308,18 @@ Known and deliberate in this version:
|
|
|
1628
2308
|
- **No cross-process lock on the mirrors.** Two dispatch loops fetching the same
|
|
1629
2309
|
repo at the same instant can collide on git's ref locks; the run fails and is
|
|
1630
2310
|
retried rather than corrupted.
|
|
2311
|
+
- **A run can read another run's git objects.** The shared mirror is one object
|
|
2312
|
+
store, so the isolation is on writes and credentials, not reads. See
|
|
2313
|
+
[The residual, stated plainly](#the-residual-stated-plainly).
|
|
2314
|
+
- **`group-mode` does not contain a determined escape.** It separates run
|
|
2315
|
+
checkouts by group and mode while sessions still run as the daemon's uid. Only
|
|
2316
|
+
`uid-pool` and `sandbox-exec` make the stronger claim, and `status` says which
|
|
2317
|
+
one is live.
|
|
2318
|
+
- **A fleet that upgrades and changes nothing is unprotected.** The
|
|
2319
|
+
`credentials` migration resolves to `"none"` and writes it down rather than
|
|
2320
|
+
refusing to dispatch, because an outage caused by a security feature is worse
|
|
2321
|
+
than the day before it shipped. `status` reports it every tick until an
|
|
2322
|
+
operator provisions the host and sets `"per-run"`.
|
|
1631
2323
|
- **Uniquely local mirror branches are retained.** Terminal runs are reaped
|
|
1632
2324
|
automatically only after every commit exists on a remote ref. A failed salvage
|
|
1633
2325
|
push deliberately leaves its branch and tree for an operator rather than
|
|
@@ -1654,15 +2346,26 @@ Known and deliberate in this version:
|
|
|
1654
2346
|
`slots = maxConcurrentWorkers - live workers`, admits at most that many issues
|
|
1655
2347
|
per tick, and dispatches them together. To see them, read `omp-conductor
|
|
1656
2348
|
status`, which lists every occupied issue, or follow `daemon.log`.
|
|
2349
|
+
- **Report delivery is at-least-once, never exactly-once.** The Telegram Bot API
|
|
2350
|
+
takes no client-supplied idempotency key, so the window between "Telegram
|
|
2351
|
+
accepted it" and "SQLite recorded that" is irreducible. The daemon resolves it
|
|
2352
|
+
toward a duplicate — the report is retried and the retry says it may be a
|
|
2353
|
+
repeat — because a duplicate you can recognise by its report id is cheaper
|
|
2354
|
+
than a silently dropped page. `delivered` means Telegram accepted an attempt,
|
|
2355
|
+
not that exactly one message exists. See
|
|
2356
|
+
[Report delivery](#report-delivery-the-outbox).
|
|
1657
2357
|
- **Workers stop at green PRs.** They never merge, release or deploy. Those
|
|
1658
2358
|
actions default to a human, but setup may grant either to the orchestrator;
|
|
1659
2359
|
`authority` never grants them to a worker or the dispatch daemon.
|
|
1660
|
-
- **
|
|
1661
|
-
`grep` / `glob` calls are
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
2360
|
+
- **Both confinement gates are partial.** Structured `write` / `edit` / `read` /
|
|
2361
|
+
`grep` / `glob` calls are gated by an inline harness extension — a worker to
|
|
2362
|
+
its worktree, [the orchestrator to an allowlist](#orchestrator-confinement-default-refusal).
|
|
2363
|
+
`bash` is gated in neither: a shell one-liner can still leave the tree, and no
|
|
2364
|
+
claim in this README says otherwise. Prefer a
|
|
2365
|
+
[least-privilege worker uid](#least-privilege-worker-uid-deploy) and a separate
|
|
2366
|
+
OS principal for the orchestrator; the
|
|
2367
|
+
[integrity tripwire](#integrity-tripwire-package-self-hash) still pages if the
|
|
2368
|
+
installed package itself changes under a live daemon.
|
|
1666
2369
|
|
|
1667
2370
|
|
|
1668
2371
|
## License
|