omp-conductor 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +732 -0
- package/package.json +40 -0
- package/skills/conductor-onboarding/SKILL.md +626 -0
- package/src/briefs/orchestrator.md +213 -0
- package/src/briefs/worker.md +146 -0
- package/src/cli.ts +179 -0
- package/src/config.ts +446 -0
- package/src/daemon.ts +689 -0
- package/src/escalate.ts +265 -0
- package/src/lifecycle.ts +367 -0
- package/src/omp.ts +273 -0
- package/src/orchestrator-tick.ts +432 -0
- package/src/orchestrator.ts +267 -0
- package/src/plugin.ts +605 -0
- package/src/routing.ts +160 -0
- package/src/setup.ts +644 -0
- package/src/store.ts +263 -0
- package/src/tracker/github.ts +160 -0
- package/src/types.ts +250 -0
- package/src/worker.ts +292 -0
- package/src/worktree.ts +303 -0
package/README.md
ADDED
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
# omp-conductor
|
|
2
|
+
|
|
3
|
+
A 24/7 dispatcher that takes `ready-for-agent` GitHub issues to green, mergeable
|
|
4
|
+
PRs using omp coding sessions. When a run cannot finish on its own it escalates in
|
|
5
|
+
tiers: first to an orchestrator session that can re-brief the worker, then to you.
|
|
6
|
+
|
|
7
|
+
## What it is
|
|
8
|
+
|
|
9
|
+
You label an issue. Within one tick the conductor claims it on the tracker, cuts a
|
|
10
|
+
worktree, hands one omp session a self-contained brief, watches it to a green PR —
|
|
11
|
+
and then stops. Merging is a human act; the conductor never performs it.
|
|
12
|
+
|
|
13
|
+
### Scope
|
|
14
|
+
|
|
15
|
+
One issue, one green PR. That is the whole remit.
|
|
16
|
+
|
|
17
|
+
Merging is a human act, and so is releasing. Releases are **batched**: cut from a
|
|
18
|
+
coherent group of merged work by a human-supervised decision, never one per PR. So
|
|
19
|
+
nothing in this package tags, pins, deploys or publishes, and no worker is ever
|
|
20
|
+
asked to. A worker whose change needs releasing reports that and stops.
|
|
21
|
+
|
|
22
|
+
Code counts every limit that decides whether work starts: concurrency, dollars per
|
|
23
|
+
day, turns and wall clock per worker, attempts per issue. None of it is left to the
|
|
24
|
+
model. A worker asked to respect a budget eventually talks itself out of it, so the
|
|
25
|
+
dispatcher enforces the budget before anything is claimed and kills anything over
|
|
26
|
+
the line.
|
|
27
|
+
|
|
28
|
+
When a run does get stuck, the first responder is not you. A tier-1 escalation is
|
|
29
|
+
injected into a long-lived **orchestrator session** that can read the issue and the
|
|
30
|
+
run's transcript and then either re-brief the worker or decide the problem genuinely
|
|
31
|
+
needs a human. It never edits product code, pushes or merges. Only tier 2 pages you
|
|
32
|
+
directly.
|
|
33
|
+
|
|
34
|
+
The package ships three deployables, plus one skill:
|
|
35
|
+
|
|
36
|
+
| Deployable | Entry | What it is for |
|
|
37
|
+
| --- | --- | --- |
|
|
38
|
+
| omp plugin | `/conductor` slash command | Inspect and arm the conductor from inside an omp session: dry-run the queue, read status, pause, resume. |
|
|
39
|
+
| Standalone daemon | `omp-conductor` binary | The dispatch loop, managed as a background process (`start` / `stop` / `restart`) with a `/healthz` endpoint for a supervisor. |
|
|
40
|
+
| Orchestrator heartbeat | omp extension, activated by `.conductor-tick.json` | Prompts a 24/7 orchestrator session on a fixed interval so its standing loop actually runs. Inert in every other session. See [Orchestrator tick](#orchestrator-tick). |
|
|
41
|
+
| Onboarding skill | `skill://conductor-onboarding` | Directs an omp session to interview you, read your repos for real CI gates, and tailor `ORCHESTRATOR.md` — then finish through the wizard. Discovered automatically once the plugin is installed. See [Onboarding](#onboarding). |
|
|
42
|
+
|
|
43
|
+
The first two are thin wrappers over the same `daemon.ts`, so the plugin and the
|
|
44
|
+
CLI cannot disagree about what a cap means or where the state lives. The heartbeat
|
|
45
|
+
reads the same pause flag both of them write.
|
|
46
|
+
|
|
47
|
+
## Your workflow vs. the package
|
|
48
|
+
|
|
49
|
+
**This package stops at green PRs.** The boundary is in the worker brief and in
|
|
50
|
+
the loop itself: no worker merges, tags, pins, deploys or publishes, and neither
|
|
51
|
+
does the orchestrator. Everything past a green PR — when to merge, what to batch
|
|
52
|
+
into a release, what to deploy — is *your* workflow, and the package deliberately
|
|
53
|
+
holds no opinion about it that it could act on.
|
|
54
|
+
|
|
55
|
+
Your opinion goes in `ORCHESTRATOR.md`, the standing prompt for the long-lived
|
|
56
|
+
session that supervises the fleet. That file is yours: the conductor renders it
|
|
57
|
+
once, on request, and then never reads it back, never rewrites it and never
|
|
58
|
+
enforces a word of it. What you write there binds your orchestrator session and
|
|
59
|
+
nothing else in this package.
|
|
60
|
+
|
|
61
|
+
`/conductor setup` offers to render the shipped template
|
|
62
|
+
(`src/briefs/orchestrator.md`) to `<workspaceRoot>/ORCHESTRATOR.md` with your
|
|
63
|
+
project's coordinates filled in, and never replaces an existing file without a
|
|
64
|
+
second, explicit confirmation. It is a starting point rather than a contract:
|
|
65
|
+
|
|
66
|
+
| Section | Whose |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| Duties (drain, groom, report), escalation tiers, hard boundaries | **Fixed** — they describe how this package already behaves. |
|
|
69
|
+
| Releases | **Yours.** Ships defaulting to "humans release; the conductor and its workers never tag, pin, deploy, or publish". Replace it only if you are deliberately delegating releases to that session — and then be specific about what, when, on what proof, and what stays permanently forbidden. |
|
|
70
|
+
| Reporting | **Yours**, seeded from the scope you chose in setup. |
|
|
71
|
+
|
|
72
|
+
Reporting is the one half of that the config also knows about, because the wizard
|
|
73
|
+
has to ask something in order to seed the brief, and it is the one half the
|
|
74
|
+
runtime acts on:
|
|
75
|
+
|
|
76
|
+
| `reporting.scope` | What the orchestrator says unprompted | The line every tick carries |
|
|
77
|
+
| --- | --- | --- |
|
|
78
|
+
| `material` (default) | Escalations, plus every material event: a run reaching a green PR, a run that failed twice, an issue pulled off the queue, a cap that stopped the fleet. | `Report material events per your brief.` |
|
|
79
|
+
| `escalations` | Escalations when they happen, plus one daily digest. Silent otherwise. | `Report NOTHING this turn except a Tier 1 or Tier 2 escalation; everything else -- releases included -- waits for the daily digest.` |
|
|
80
|
+
|
|
81
|
+
**What the scope does:** the [orchestrator heartbeat](#orchestrator-tick) appends
|
|
82
|
+
that line to every tick it sends, so the reporting contract arrives with the
|
|
83
|
+
prompt instead of only in a brief the session read hours ago. It is re-read from
|
|
84
|
+
`~/.omp/conductor/config.json` on **every** tick, so turning the volume up or
|
|
85
|
+
down — `/conductor setup` again, or an edit to the file — binds the next tick
|
|
86
|
+
without restarting the session. Three cases fall back to `material`: no config
|
|
87
|
+
yet, an unreadable or invalid one, and several projects with none named (the same
|
|
88
|
+
ambiguity `status` refuses to guess through). Stopping the heartbeat over a
|
|
89
|
+
reporting preference would be the worse trade, so it ticks on the default and
|
|
90
|
+
logs the reason once.
|
|
91
|
+
|
|
92
|
+
**What it does not do:** there is no hard outbound filter. Nothing inspects the
|
|
93
|
+
orchestrator's messages and drops the ones your scope did not ask for, so a
|
|
94
|
+
session that ignores its constraint line still reaches you. Scope is a
|
|
95
|
+
constraint the model is handed each turn. It is not a gate the model is held to.
|
|
96
|
+
The enforcement roadmap (a tool-call tripwire, and config-versus-behaviour drift
|
|
97
|
+
in the daily digest) is
|
|
98
|
+
[issue #4](https://github.com/TerrifiedBug/conductor/issues/4).
|
|
99
|
+
|
|
100
|
+
Changing the key later does not rewrite an `ORCHESTRATOR.md` you already have:
|
|
101
|
+
the tick line changes, the brief does not. Edit its Reporting section too, or the
|
|
102
|
+
session is carrying two versions of your policy.
|
|
103
|
+
|
|
104
|
+
## Where issues come from
|
|
105
|
+
|
|
106
|
+
**GitHub Issues is the only supported tracker in v1.** `tracker.kind` accepts
|
|
107
|
+
exactly one value, `"github"`, and every tracker operation shells out to your
|
|
108
|
+
already-authenticated `gh` CLI — the conductor never stores a token of its own.
|
|
109
|
+
Gitea, Jira, and file-based trackers are not supported yet; the seam for them is
|
|
110
|
+
`src/tracker/github.ts`, which implements the whole six-method `Tracker`
|
|
111
|
+
interface in `src/types.ts` (`listReady`, `addLabel`, `removeLabel`, `comment`,
|
|
112
|
+
`close`, `linkParent`) that a future backend would swap in.
|
|
113
|
+
|
|
114
|
+
You tell the conductor where to look with three keys, all in
|
|
115
|
+
`~/.omp/conductor/config.json` (the [Configuration](#configuration) section has
|
|
116
|
+
the full annotated example, and `/conductor setup` will interview you for these
|
|
117
|
+
and create any missing labels):
|
|
118
|
+
|
|
119
|
+
| Key | Meaning |
|
|
120
|
+
| --- | --- |
|
|
121
|
+
| `tracker.repo` | The **one** `owner/repo` whose issue list is the queue. This is your planning repo — it does not have to contain any code. |
|
|
122
|
+
| `queueLabel` | Open issues in `tracker.repo` carrying this label (default `ready-for-agent`) are the work queue. Nothing else is ever read. |
|
|
123
|
+
| `routing.repos` + `repo:<name>` labels | Each queued issue must also carry exactly one routing label naming which code repo the work lands in. The conductor cuts the worktree and PR there, from `routing.repos[name].cloneUrl`. An issue with zero or two routing labels is reported as unroutable and skipped — never guessed. |
|
|
124
|
+
|
|
125
|
+
So: one tracker repo supplies the queue, routing labels fan issues out to any
|
|
126
|
+
number of code repos, and both label names are yours to configure.
|
|
127
|
+
|
|
128
|
+
## Install
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
omp plugin install omp-conductor
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
From a checkout of the monorepo, `./setup.sh` links this plugin and the Herdr half
|
|
135
|
+
after checking everything below.
|
|
136
|
+
|
|
137
|
+
### Prerequisites
|
|
138
|
+
|
|
139
|
+
`@oh-my-pi/pi-coding-agent` (`>=17.1.4`) is a **peer dependency** and must already
|
|
140
|
+
be present. If you run omp, it is.
|
|
141
|
+
|
|
142
|
+
Also required on the host:
|
|
143
|
+
|
|
144
|
+
- `bun`: the CLI and the daemon run on it (`Bun.serve` backs `/healthz`).
|
|
145
|
+
- `gh`, already authenticated: every tracker operation shells out to it, so the
|
|
146
|
+
daemon never handles a GitHub token itself.
|
|
147
|
+
- `git`: mirrors and worktrees.
|
|
148
|
+
- **[omp-telegram](https://www.npmjs.com/package/omp-telegram)**, for the
|
|
149
|
+
escalation channel. It is a separate package and is not vendored here.
|
|
150
|
+
|
|
151
|
+
Two different things depend on it, and they need different amounts of it:
|
|
152
|
+
|
|
153
|
+
- **Tier-2 paging** needs only its bot token. This package reads
|
|
154
|
+
`TELEGRAM_BOT_TOKEN` out of `$OMP_TELEGRAM_STATE_DIR/.env` (default
|
|
155
|
+
`~/.omp/agent/telegram/.env`) and posts to the chat id you configure. No
|
|
156
|
+
pairing required, and no token ever passes through this package's own config.
|
|
157
|
+
- **The interactive channel** — replying to an escalation, approving a brief
|
|
158
|
+
amendment from your phone — needs omp-telegram actually paired, which is what
|
|
159
|
+
writes `access.json`. The fleet heartbeat also reads that file and refuses to
|
|
160
|
+
tick unless exactly one owner is paired, on the grounds that unattended
|
|
161
|
+
dispatch is only defensible while a tier-2 page can reach a person.
|
|
162
|
+
|
|
163
|
+
With neither, tier 2 degrades to a comment on the issue. Nothing is broken in
|
|
164
|
+
that configuration: it is supported, just slower to reach you.
|
|
165
|
+
|
|
166
|
+
## Onboarding
|
|
167
|
+
|
|
168
|
+
Onboarding this package has two layers, and installing it gives you both.
|
|
169
|
+
|
|
170
|
+
| Layer | What it is | What it owns |
|
|
171
|
+
| --- | --- | --- |
|
|
172
|
+
| **`/conductor setup`** | The deterministic wizard. Closed questions, a label plan, a dry run, one confirm. | **Mechanical config.** It is the only thing that writes `config.json`, and it mutates nothing before you confirm. |
|
|
173
|
+
| **`skill://conductor-onboarding`** | A skill bundled in this package (`skills/conductor-onboarding/SKILL.md`), discovered automatically by any omp session once the plugin is installed. | **Brief authoring.** The judgement the wizard cannot prompt for. |
|
|
174
|
+
|
|
175
|
+
The split exists because the two halves fail differently. A wrong config value is
|
|
176
|
+
a run that errors on the next tick; a wrong release boundary is a fleet that
|
|
177
|
+
publishes something at 03:00. The first is worth a text prompt with validation.
|
|
178
|
+
The second is worth an interview.
|
|
179
|
+
|
|
180
|
+
So the skill does the part a dialog cannot:
|
|
181
|
+
|
|
182
|
+
- **Interviews you** on release policy — humans release (the default), the agent
|
|
183
|
+
releases to a named boundary, or the agent releases fully — pressing on the one
|
|
184
|
+
question that makes a delegated release safe: *where does the agent's leg end?*
|
|
185
|
+
Plus escalation taste, and which of the two [`reporting.scope`](#your-workflow-vs-the-package)
|
|
186
|
+
values your answer actually maps to.
|
|
187
|
+
- **Reads your repos instead of asking about them.** It opens each routing repo's
|
|
188
|
+
CI workflows, `package.json` scripts and `Makefile`/`justfile`, then *proposes*
|
|
189
|
+
the exact pre-push [gates](#configuration) with the `cwd` each runs from, so the
|
|
190
|
+
gates match what CI runs.
|
|
191
|
+
- **Tailors `ORCHESTRATOR.md`** from the shipped template. The template is the
|
|
192
|
+
floor: it rewrites the Releases and Reporting sections from your answers, adds
|
|
193
|
+
the hard boundaries only you know about (infra directories, off-limits repos),
|
|
194
|
+
leaves the fixed sections alone, and shows you the diff before writing.
|
|
195
|
+
- **Verifies the worker brief's assumptions** against reality: default branch per
|
|
196
|
+
repo, whether the branch names the conductor cuts survive your branch
|
|
197
|
+
protection, whether each proposed gate exists and exits 0 on a clean checkout,
|
|
198
|
+
and whether `pull_request` actually fires — a workflow that never triggers on a
|
|
199
|
+
PR gives a worker no checks to watch and no verdict to reach.
|
|
200
|
+
- **Then finishes through the wizard**, so the dry run and the consent gate still
|
|
201
|
+
do the writing.
|
|
202
|
+
|
|
203
|
+
From an omp session with the plugin installed, just say what you want: "help me
|
|
204
|
+
set up conductor", "onboard me", "configure the fleet" all reach it, because that
|
|
205
|
+
is what the skill's description matches on. With `skills.enableSkillCommands`
|
|
206
|
+
turned on you can also invoke it directly:
|
|
207
|
+
|
|
208
|
+
```text
|
|
209
|
+
/skill:conductor-onboarding
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Nothing about the wizard changes: `/conductor setup` on its own remains a
|
|
213
|
+
complete, supported path, and the brief it renders is safe unedited.
|
|
214
|
+
|
|
215
|
+
## Quick start
|
|
216
|
+
|
|
217
|
+
1. Write a config (see [Configuration](#configuration)) at
|
|
218
|
+
`~/.omp/conductor/config.json`.
|
|
219
|
+
2. From an omp session:
|
|
220
|
+
|
|
221
|
+
```text
|
|
222
|
+
/conductor setup
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
This is a **dry run first**. It reads the tracker through the same routing code
|
|
226
|
+
the loop uses and prints exactly what the next tick would pick up, which repo
|
|
227
|
+
each issue routes to, the branch it would cut, and every issue that cannot be
|
|
228
|
+
routed. **Nothing is mutated** — no label written, no run row, no worktree —
|
|
229
|
+
until you answer the "Arm omp-conductor?" confirmation. Arming does exactly two
|
|
230
|
+
things: create the state database, and clear the pause flag. Declining leaves
|
|
231
|
+
the machine untouched.
|
|
232
|
+
|
|
233
|
+
Two of its questions are about you rather than the fleet: how loud the
|
|
234
|
+
orchestrator should be (`reporting.scope`), and whether to write an
|
|
235
|
+
`ORCHESTRATOR.md` you then own. See
|
|
236
|
+
[Your workflow vs. the package](#your-workflow-vs-the-package). If you would
|
|
237
|
+
rather be interviewed through those two, and have the brief tailored and your
|
|
238
|
+
gates read out of your CI config, start from
|
|
239
|
+
[Onboarding](#onboarding) instead.
|
|
240
|
+
|
|
241
|
+
3. Start the daemon in the background:
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
omp-conductor start
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`start` does not report success until the daemon actually answers
|
|
248
|
+
`GET /healthz`. Spawning is not starting: a daemon whose config is broken, whose
|
|
249
|
+
port is taken or whose database is locked exits within a second, and a `start`
|
|
250
|
+
that printed "started" for it would hand you a lie you discover only when work
|
|
251
|
+
silently fails to be picked up. On failure the error quotes the tail of
|
|
252
|
+
`daemon.log`. It refuses to start a second daemon, naming the pid of the live one.
|
|
253
|
+
|
|
254
|
+
For a first run, take a single tick in the foreground and watch it:
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
omp-conductor daemon --once
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
The loop ticks every 5 minutes. `omp-conductor stop` sends `SIGTERM`, and the loop
|
|
261
|
+
shuts down after the current tick rather than mid-run.
|
|
262
|
+
|
|
263
|
+
## How one tick works
|
|
264
|
+
|
|
265
|
+
Per tick, for the daemon's project:
|
|
266
|
+
|
|
267
|
+
1. **Paused?** If the pause sentinel exists, the tick claims nothing and returns.
|
|
268
|
+
Pause is checked first, so `omp-conductor pause` takes effect on the next tick
|
|
269
|
+
without signalling the process.
|
|
270
|
+
2. **List the queue.** Open issues in `tracker.repo` labelled `queueLabel`.
|
|
271
|
+
3. **Filter and route.** An issue is eligible only if it carries the queue label
|
|
272
|
+
and none of the three state labels (`inProgress`, `blocked`, `failed`). Eligible
|
|
273
|
+
issues are partitioned into routable and unroutable.
|
|
274
|
+
4. **Escalate the unroutable** at Tier 1, quoting the repo labels actually seen and
|
|
275
|
+
the configured repo names. These are never dispatched.
|
|
276
|
+
5. **Check spend.** If spend since local midnight has reached `dailySpendUsd`, the
|
|
277
|
+
daemon **pauses itself**, pages at Tier 2, and returns.
|
|
278
|
+
6. **Check capacity.** `maxConcurrentWorkers` minus active runs gives the free
|
|
279
|
+
slots. If none are free, the tick logs and returns.
|
|
280
|
+
7. **Admit issues** up to the free slots, skipping any issue that already has
|
|
281
|
+
an active run. An issue that has used `maxAttemptsPerIssue` escalates at Tier 1
|
|
282
|
+
instead of being admitted.
|
|
283
|
+
8. **Dispatch** the admitted issues concurrently.
|
|
284
|
+
|
|
285
|
+
Then, per admitted issue:
|
|
286
|
+
|
|
287
|
+
1. **Apply the `agent:in-progress` label — before any worktree or session exists.**
|
|
288
|
+
This ordering is the whole crash-safety story: the label, not the local
|
|
289
|
+
database, is the guard against dispatching the same issue twice. If the process
|
|
290
|
+
dies at any later point, the next daemon sees the label, eligibility filters the
|
|
291
|
+
issue out, and a human decides what to do with the orphan. A store that is lost
|
|
292
|
+
can be rebuilt from the tracker; a label that was written too late cannot undo a
|
|
293
|
+
duplicate PR.
|
|
294
|
+
2. Create the run row (`claimed`).
|
|
295
|
+
3. Clear any stale tree for this issue, then add a fresh worktree at
|
|
296
|
+
`<workspaceRoot>/<issue>` cut from the bare mirror at `<mirrorRoot>/<repo>.git`,
|
|
297
|
+
on the run's branch off the repo's default branch.
|
|
298
|
+
4. Allocate a session transcript under `<state dir>/sessions/`, one per attempt,
|
|
299
|
+
and move the run to `running`. The run record keeps the exact path and a
|
|
300
|
+
failure escalation quotes it, so you can read what the worker actually did.
|
|
301
|
+
5. Run one omp session with the rendered brief, under the turn and wall-clock caps.
|
|
302
|
+
6. Record the outcome:
|
|
303
|
+
|
|
304
|
+
| Outcome | Labels | Worktree | Escalation |
|
|
305
|
+
| --- | --- | --- | --- |
|
|
306
|
+
| `pushed-green` | `agent:in-progress` stays until the merge closes the issue | removed | none |
|
|
307
|
+
| `blocked` | swapped to `agent:blocked` | removed | Tier 1 |
|
|
308
|
+
| `failed` / `killed` | swapped to `agent:failed` | **kept** as evidence | Tier 1 |
|
|
309
|
+
| unexpected error | swapped to `agent:failed` | kept | Tier 1 |
|
|
310
|
+
|
|
311
|
+
Label swaps add the new label before removing the old one: the reverse order
|
|
312
|
+
leaves a window in which the issue carries no state label at all, which is
|
|
313
|
+
exactly the shape eligibility reads as fresh work.
|
|
314
|
+
|
|
315
|
+
### Branch names
|
|
316
|
+
|
|
317
|
+
`<type>/<slug>`, where the type is `fix` when any label's last segment (after `:`
|
|
318
|
+
or `/`) is `bug`, and `feat` otherwise. The slug is the issue title folded to
|
|
319
|
+
`[a-z0-9-]`, and the whole ref is capped at 60 characters. It is computed from the
|
|
320
|
+
issue alone, so a retried run recomputes the same branch and finds its own work
|
|
321
|
+
instead of forking a second one.
|
|
322
|
+
|
|
323
|
+
## Routing
|
|
324
|
+
|
|
325
|
+
An issue must carry **exactly one** `repo:<name>` label naming a repo in
|
|
326
|
+
`routing.repos`. The prefix is `routing.labelPrefix` and defaults to `repo:`.
|
|
327
|
+
|
|
328
|
+
Routing never guesses. An issue it cannot resolve to a single configured checkout
|
|
329
|
+
is handed back as unroutable:
|
|
330
|
+
|
|
331
|
+
| Reason | Condition |
|
|
332
|
+
| --- | --- |
|
|
333
|
+
| `no-repo-label` | The issue carries no label starting with the prefix. |
|
|
334
|
+
| `multiple-repo-labels` | It carries two or more distinct prefixed labels. A repeated identical label is deduplicated, not treated as an ambiguity. |
|
|
335
|
+
| `unknown-repo` | Its single prefixed label names a repo that is not in `routing.repos`. |
|
|
336
|
+
|
|
337
|
+
In all three cases the issue is **escalated at Tier 1 and never dispatched**. The
|
|
338
|
+
fix is always the same, and the escalation says so: put exactly one
|
|
339
|
+
`repo:<name>` label on the issue.
|
|
340
|
+
|
|
341
|
+
This is deliberate. A request that spans two repos, taken whole by one worker, is
|
|
342
|
+
the precise failure this guard exists to prevent: the worker cannot open a PR
|
|
343
|
+
against two checkouts, so it improvises — it vendors a copy, edits the wrong repo,
|
|
344
|
+
or produces a PR that cannot be merged without the other half. Splitting a
|
|
345
|
+
multi-repo request is a human decision about contracts; it is not something to
|
|
346
|
+
infer from a label. Sending the issue back costs a label edit; guessing costs a
|
|
347
|
+
bad merge.
|
|
348
|
+
|
|
349
|
+
## Caps
|
|
350
|
+
|
|
351
|
+
Caps resolve per project: the global `defaults` block, then the project's own
|
|
352
|
+
`caps` layered on field by field, so a project that pins one cap still inherits the
|
|
353
|
+
rest. `0` is a real value (a hard stop), not "unset".
|
|
354
|
+
|
|
355
|
+
| Cap | Default | What it protects |
|
|
356
|
+
| --- | --- | --- |
|
|
357
|
+
| `maxConcurrentWorkers` | `2` | Parallel omp sessions. 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. Raise it only if you actually have the runners. |
|
|
358
|
+
| `dailySpendUsd` | `25` | Rolling-day spend ceiling. The one cap that stops the fleet rather than deferring work. |
|
|
359
|
+
| `workerMaxTurns` | `120` | Turn ceiling for one worker. Catches a session looping without converging. |
|
|
360
|
+
| `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. |
|
|
361
|
+
| `maxAttemptsPerIssue` | `2` | Retries per issue before it escalates. One clean retry recovers from flaky CI; a third attempt almost always means the issue itself is underspecified. |
|
|
362
|
+
|
|
363
|
+
Days are counted from **local midnight**, matching how a human reads "today".
|
|
364
|
+
|
|
365
|
+
Hitting `dailySpendUsd` is not the same as hitting the other caps. A concurrency
|
|
366
|
+
limit simply defers work to a later tick. The spend cap **pauses the daemon and
|
|
367
|
+
pages at Tier 2**: a loop that is burning money has to halt itself, because
|
|
368
|
+
waiting for someone to notice tomorrow is how a runaway becomes expensive.
|
|
369
|
+
Work resumes only after `omp-conductor resume` (or `/conductor resume`).
|
|
370
|
+
|
|
371
|
+
`workerMaxTurns` and `workerWallClockMs` are enforced inside the session driver: the
|
|
372
|
+
run is aborted, recorded as `killed`, and the escalation names which ceiling fired.
|
|
373
|
+
|
|
374
|
+
## Worker model
|
|
375
|
+
|
|
376
|
+
`workerModel` on a project pins the model its workers run on, as a pattern in
|
|
377
|
+
omp's own model/role syntax (whatever `/model` accepts). It sits beside `caps`
|
|
378
|
+
rather than inside them, because it is not a ceiling:
|
|
379
|
+
|
|
380
|
+
```json
|
|
381
|
+
"workerModel": "smol"
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Omit it and the harness picks, which is the right answer until you have a reason.
|
|
385
|
+
The pattern is passed through unresolved: omp resolves it after its extensions
|
|
386
|
+
load, so a name this package has never heard of still works. If the harness cannot
|
|
387
|
+
honour the pattern it says so, and the daemon logs that per run:
|
|
388
|
+
|
|
389
|
+
```text
|
|
390
|
+
#412 model fallback: <what the harness substituted>
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Worth reading the log for. A run that quietly used a weaker model than you chose
|
|
394
|
+
otherwise looks like a run that was merely unlucky.
|
|
395
|
+
|
|
396
|
+
## Escalation tiers
|
|
397
|
+
|
|
398
|
+
| Tier | Meaning | Raised by | Delivered to |
|
|
399
|
+
| --- | --- | --- | --- |
|
|
400
|
+
| 1 | "Not a human's problem yet" — the run is parked and safe. | Unroutable issue, blocked run, failed or killed run, dispatch error, attempts exhausted. | The orchestrator session, as an injected prompt. Falls back to an issue comment when no orchestrator is running, or when it will not accept the injection. |
|
|
401
|
+
| 2 | "The fleet is stopped until you look." | Daily spend cap reached; the project is already paused. | Telegram, when `escalation.telegramChatId` is set and a bot token is readable; otherwise it falls back to the issue comment. |
|
|
402
|
+
|
|
403
|
+
**The orchestrator** is one persistent, file-backed session per daemon run, resumed
|
|
404
|
+
across restarts so it remembers what it has already handled. Its `cwd` is the state
|
|
405
|
+
directory, deliberately not a checkout. Delivery resolves when the harness *accepts*
|
|
406
|
+
the prompt, not when the model answers it, so a tick never parks behind a model; an
|
|
407
|
+
injection arriving mid-thought queues as a follow-up instead of interrupting the
|
|
408
|
+
turn in flight. Its standing orders are explicit: re-brief the worker, file or
|
|
409
|
+
comment on issues, or promote to tier 2, and never edit product code, push a branch
|
|
410
|
+
or merge a PR. If it fails to start, the daemon logs a warning and runs on, with
|
|
411
|
+
tier-1 escalations degraded to issue comments.
|
|
412
|
+
|
|
413
|
+
Tier 2 borrows the bot token that `omp-telegram` already owns, at
|
|
414
|
+
`~/.omp/agent/telegram/.env` (or `$OMP_TELEGRAM_STATE_DIR/.env`). If you run that
|
|
415
|
+
bot, Tier 2 needs no extra configuration beyond the chat id. If the token is
|
|
416
|
+
absent, Tier 2 degrades to the issue comment instead of failing. The token is
|
|
417
|
+
never logged, and it is redacted out of any error text that could reach a public
|
|
418
|
+
issue comment.
|
|
419
|
+
|
|
420
|
+
**Escalations are deduplicated.** The dispatcher re-notices the same unroutable
|
|
421
|
+
issue on every poll, so a ledger in the store — keyed by project, issue, tier and
|
|
422
|
+
summary — makes a recurring condition page **once** and suppresses the five-minute
|
|
423
|
+
repeats. The marker is recorded only on successful delivery, so a page that could
|
|
424
|
+
not be delivered is retried on the next tick instead of being written off as sent.
|
|
425
|
+
The spend-cap summary carries the date, so the same cap pages again tomorrow but
|
|
426
|
+
only once per day.
|
|
427
|
+
|
|
428
|
+
If `fallbackToIssueComment` is off and no Telegram transport is configured,
|
|
429
|
+
delivery throws instead of dropping silently. The failure is logged and retried,
|
|
430
|
+
because a swallowed escalation looks exactly like a healthy fleet.
|
|
431
|
+
|
|
432
|
+
## Configuration
|
|
433
|
+
|
|
434
|
+
The config lives at `$OMP_CONDUCTOR_HOME/config.json`, or
|
|
435
|
+
`~/.omp/conductor/config.json` when that variable is unset. It is written with mode
|
|
436
|
+
`0600` in a directory created `0700`, because it carries chat ids and clone URLs.
|
|
437
|
+
That same directory holds the SQLite store (`conductor.db`), the `paused` sentinel,
|
|
438
|
+
the `sessions/` worker transcripts and the `orchestrator/` session directory.
|
|
439
|
+
|
|
440
|
+
Runtime state lives elsewhere, under `$OMP_CONDUCTOR_RUNTIME_DIR` (default
|
|
441
|
+
`~/.omp/run/daemons/omp-conductor`): `daemon.json`, a mode-`0600` pidfile written
|
|
442
|
+
atomically, and `daemon.log`, appended across every boot so the previous failure is
|
|
443
|
+
still there when you go looking. It is kept apart from the config directory because
|
|
444
|
+
it is meaningless after a reboot, and the pidfile's liveness is probed on every
|
|
445
|
+
read — a stale one never blocks a `start`.
|
|
446
|
+
|
|
447
|
+
The file is validated on every read. A malformed config produces one readable error
|
|
448
|
+
listing every fault, and the daemon refuses to start rather than running with half
|
|
449
|
+
a project.
|
|
450
|
+
|
|
451
|
+
`version` is `2`. A `version: 1` file still loads: caps it names that this build no
|
|
452
|
+
longer enforces are dropped rather than treated as typos, and the next save writes
|
|
453
|
+
it back as `2`. In a `version: 2` file an unrecognised cap key **is** an error,
|
|
454
|
+
because there is nothing left to retire — a mistyped `dailySpendUSD` would
|
|
455
|
+
otherwise read as configured while the real ceiling stayed the default.
|
|
456
|
+
|
|
457
|
+
A complete, valid config for one project with two target repos:
|
|
458
|
+
|
|
459
|
+
```json
|
|
460
|
+
{
|
|
461
|
+
"version": 2,
|
|
462
|
+
"defaults": {
|
|
463
|
+
"maxConcurrentWorkers": 2,
|
|
464
|
+
"dailySpendUsd": 25,
|
|
465
|
+
"workerMaxTurns": 120,
|
|
466
|
+
"workerWallClockMs": 5400000,
|
|
467
|
+
"maxAttemptsPerIssue": 2
|
|
468
|
+
},
|
|
469
|
+
"projects": [
|
|
470
|
+
{
|
|
471
|
+
"name": "demo",
|
|
472
|
+
"tracker": { "kind": "github", "repo": "acme/planning" },
|
|
473
|
+
"queueLabel": "ready-for-agent",
|
|
474
|
+
"stateLabels": {
|
|
475
|
+
"inProgress": "agent:in-progress",
|
|
476
|
+
"blocked": "agent:blocked",
|
|
477
|
+
"failed": "agent:failed"
|
|
478
|
+
},
|
|
479
|
+
"routing": {
|
|
480
|
+
"labelPrefix": "repo:",
|
|
481
|
+
"repos": {
|
|
482
|
+
"api": {
|
|
483
|
+
"name": "api",
|
|
484
|
+
"cloneUrl": "git@github.com:acme/api.git",
|
|
485
|
+
"defaultBranch": "main",
|
|
486
|
+
"gates": [
|
|
487
|
+
{ "cmd": "bun run lint", "cwd": "." },
|
|
488
|
+
{ "cmd": "bun test", "cwd": "." }
|
|
489
|
+
]
|
|
490
|
+
},
|
|
491
|
+
"worker": {
|
|
492
|
+
"name": "worker",
|
|
493
|
+
"cloneUrl": "git@github.com:acme/worker.git",
|
|
494
|
+
"defaultBranch": "main",
|
|
495
|
+
"gates": [
|
|
496
|
+
{ "cmd": "ruff check .", "cwd": "." },
|
|
497
|
+
{ "cmd": "pytest -q", "cwd": "backend" }
|
|
498
|
+
]
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
},
|
|
502
|
+
"caps": {
|
|
503
|
+
"maxConcurrentWorkers": 1,
|
|
504
|
+
"dailySpendUsd": 15
|
|
505
|
+
},
|
|
506
|
+
"workerModel": "smol",
|
|
507
|
+
"escalation": {
|
|
508
|
+
"telegramChatId": "123456789",
|
|
509
|
+
"fallbackToIssueComment": true
|
|
510
|
+
},
|
|
511
|
+
"reporting": {
|
|
512
|
+
"scope": "material"
|
|
513
|
+
},
|
|
514
|
+
"workspaceRoot": "~/.omp/conductor/worktrees",
|
|
515
|
+
"mirrorRoot": "~/.omp/conductor/mirrors"
|
|
516
|
+
}
|
|
517
|
+
]
|
|
518
|
+
}
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
Field notes:
|
|
522
|
+
|
|
523
|
+
| Field | Notes |
|
|
524
|
+
| --- | --- |
|
|
525
|
+
| `version` | Must be `2`. A `version: 1` file still loads, drops the caps this build no longer enforces, and is rewritten as `2` on the next save. Present from day one so a format change can be migrated instead of silently misread. |
|
|
526
|
+
| `defaults` | Every `Caps` field. Anything omitted falls back to the built-in default. |
|
|
527
|
+
| `tracker.repo` | `owner/repo`. `tracker.kind` may be omitted; `"github"` is the only accepted value. |
|
|
528
|
+
| `queueLabel` | The one label meaning "a human has signed this off as agent-ready". Matched exactly, case-sensitively. |
|
|
529
|
+
| `stateLabels` | Optional; defaults to `agent:in-progress`, `agent:blocked`, `agent:failed`. |
|
|
530
|
+
| `routing.labelPrefix` | Optional; defaults to `repo:`. |
|
|
531
|
+
| `routing.repos` | At least one entry, or nothing can be routed. `name` defaults to the map key, `defaultBranch` to `main`. |
|
|
532
|
+
| `gates` | The exact cheap commands CI also runs, each with the `cwd` it runs from (`cwd` defaults to `.`). Running the real gate locally is what makes an unattended push safe — a subset lets an error outside the source dir reach the runners. |
|
|
533
|
+
| `caps` | Per-project overrides; omit it or pin only the fields you want to change. |
|
|
534
|
+
| `escalation.fallbackToIssueComment` | Defaults to `true`. Absent means "yes, still tell me". |
|
|
535
|
+
| `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. |
|
|
536
|
+
| `workspaceRoot` / `mirrorRoot` | Optional; default to `worktrees/` and `mirrors/` under the state directory. `~` is expanded. |
|
|
537
|
+
|
|
538
|
+
Prefer an SSH `cloneUrl`, or an https URL backed by a credential helper. A clone URL
|
|
539
|
+
with credentials embedded is persisted into the mirror's git config, exactly as it
|
|
540
|
+
would be for a hand-run clone.
|
|
541
|
+
|
|
542
|
+
## Orchestrator tick
|
|
543
|
+
|
|
544
|
+
The escalation path above assumes an orchestrator session that is actually
|
|
545
|
+
running its loop. A 24/7 omp session with a standing brief and nobody typing into
|
|
546
|
+
it never gets prompted, so it never runs anything. Installing
|
|
547
|
+
`omp plugin install omp-conductor` also installs a heartbeat that prompts it.
|
|
548
|
+
|
|
549
|
+
The heartbeat is **inert unless the session's cwd contains
|
|
550
|
+
`.conductor-tick.json`**, so it costs an ordinary session nothing. Drop the file
|
|
551
|
+
in the orchestrator's working directory (on the fleet host, `/root/fleet`):
|
|
552
|
+
|
|
553
|
+
```json
|
|
554
|
+
{
|
|
555
|
+
"intervalSeconds": 900,
|
|
556
|
+
"armedFile": "state/armed",
|
|
557
|
+
"accessFile": "/root/.omp/agent/telegram/access.json",
|
|
558
|
+
"message": "Run your standing loop from ORCHESTRATOR.md now."
|
|
559
|
+
}
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
| Key | Required | Default | Notes |
|
|
563
|
+
| --- | --- | --- | --- |
|
|
564
|
+
| `intervalSeconds` | yes | — | Whole seconds between ticks, minimum `60`. A tick costs a full turn of a frontier model, so a sub-minute period is refused rather than obeyed. |
|
|
565
|
+
| `armedFile` | no | none — the gate passes | Path to the arm marker. A tick does nothing while the file is missing. Relative paths resolve against the session cwd, so `state/armed` means `<cwd>/state/armed`. |
|
|
566
|
+
| `accessFile` | no | none — the gate passes | Path to the Telegram bridge's `access.json`. Every tick re-reads it and requires `enabled: true` with exactly one entry in `allowFrom`. Relative paths resolve against the session cwd. **Configure this on any fleet deploy** — see below. |
|
|
567
|
+
| `message` | no | `Tick <ISO timestamp>: run your standing loop from ORCHESTRATOR.md now.` followed by the `reporting.scope` line | Sent verbatim when set — and then it owns the whole contract: no scope line is appended to a prompt you wrote yourself. The default carries the timestamp, which is what makes two consecutive ticks distinguishable in the session log. |
|
|
568
|
+
|
|
569
|
+
A tick sends one message (`customType` `omp-conductor.tick`, attributed to the
|
|
570
|
+
user): the standing-loop prompt, plus the one constraint line the project's
|
|
571
|
+
[`reporting.scope`](#your-workflow-vs-the-package) resolves to, re-read from the
|
|
572
|
+
conductor config on every tick. It starts a turn if the session is idle; while a
|
|
573
|
+
turn is streaming it is queued as a follow-up and consumed when that turn ends.
|
|
574
|
+
It sends **nothing** when:
|
|
575
|
+
|
|
576
|
+
- `/conductor pause` (or `omp-conductor pause`) holds the pause flag, the same
|
|
577
|
+
flag the dispatch loop reads, so pausing the fleet pauses its heartbeat;
|
|
578
|
+
- `armedFile` is configured and missing;
|
|
579
|
+
- `accessFile` is configured and the escalation channel is not verifiably up;
|
|
580
|
+
- an earlier tick is still queued. Ticks coalesce rather than stack, so a slow
|
|
581
|
+
turn cannot leave a backlog of heartbeats behind it.
|
|
582
|
+
|
|
583
|
+
### The escalation channel is a gate, and it fails closed
|
|
584
|
+
|
|
585
|
+
Unattended dispatch is only defensible while a tier-2 escalation can reach a
|
|
586
|
+
person. So `accessFile` is checked on **every** tick and never cached at session
|
|
587
|
+
start: the bridge is reconfigured out-of-band, and a heartbeat that trusted a
|
|
588
|
+
startup snapshot would keep dispatching for days after the channel went away. A
|
|
589
|
+
stale arm marker must not outlive the channel that makes running unattended safe.
|
|
590
|
+
|
|
591
|
+
The check passes only when the file parses to an object with `enabled: true` and
|
|
592
|
+
exactly one `allowFrom` entry. Everything else stops the heartbeat: file missing,
|
|
593
|
+
unreadable or truncated; not JSON, or JSON that is not an object; `enabled`
|
|
594
|
+
absent or false; zero owners paired (nobody to page) or more than one (ambiguous:
|
|
595
|
+
the conductor refuses to guess which human is on the hook). Failure modes are
|
|
596
|
+
deliberately not distinguished in the decision: each one means a page lands
|
|
597
|
+
nowhere.
|
|
598
|
+
|
|
599
|
+
Leaving `accessFile` unset passes the gate, because an ordinary developer session
|
|
600
|
+
that happens to have a `.conductor-tick.json` has no bridge to check. It is not an
|
|
601
|
+
off switch for the check: **a fleet deploy always sets it.**
|
|
602
|
+
|
|
603
|
+
Every tick — sent or skipped — is logged with its reason (`paused`, `not armed`,
|
|
604
|
+
`escalation channel down`, `tick already pending`) to the omp log. Skips are
|
|
605
|
+
deliberately silent in the UI: a paused fleet would otherwise raise a notification
|
|
606
|
+
every interval, forever. The one exception is a malformed `.conductor-tick.json`,
|
|
607
|
+
which notifies once at session start and leaves the heartbeat off; silent failure
|
|
608
|
+
there is the failure mode the heartbeat exists to prevent. A conductor config that
|
|
609
|
+
cannot supply a reporting scope logs `tick reporting scope: using material` once
|
|
610
|
+
per session. The interval does not re-log it, because the file is unlikely to fix
|
|
611
|
+
itself between two ticks.
|
|
612
|
+
|
|
613
|
+
## CLI reference
|
|
614
|
+
|
|
615
|
+
```bash
|
|
616
|
+
omp-conductor start [--port N] [--project NAME]
|
|
617
|
+
omp-conductor stop
|
|
618
|
+
omp-conductor restart [--port N] [--project NAME]
|
|
619
|
+
omp-conductor status [--project NAME]
|
|
620
|
+
omp-conductor daemon [--once] [--port N] [--project NAME]
|
|
621
|
+
omp-conductor pause
|
|
622
|
+
omp-conductor resume
|
|
623
|
+
omp-conductor help
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
| Command | Behaviour |
|
|
627
|
+
| --- | --- |
|
|
628
|
+
| `start` | Spawn the loop in the background, detached, and wait until it answers `GET /healthz` on `:8787`. Refuses if one is already live, naming its pid. If the process dies or never serves, `start` cleans up after it and quotes the tail of `daemon.log`. |
|
|
629
|
+
| `stop` | `SIGTERM`, then `SIGKILL` after a 10-second grace period. Prints `not running` when there is nothing to stop. |
|
|
630
|
+
| `restart` | `stop` then `start`, inheriting the running daemon's port and project unless a flag overrides them — a restart that quietly moved to the default port would leave every existing health check pointing at nothing. |
|
|
631
|
+
| `status [--project NAME]` | Pause state, config and state paths, resolved caps, active runs and today's usage, plus a `daemon` block: pid, uptime, port, project, `/healthz` result and log path. Reads while a daemon in another process writes. |
|
|
632
|
+
| `daemon` | Run the loop in the **foreground**, ticking every 5 minutes and serving `/healthz`. This is what `start` launches. |
|
|
633
|
+
| `daemon --once` | Run a single tick and exit. No HTTP server. |
|
|
634
|
+
| `--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. |
|
|
635
|
+
| `--project NAME` | Pick the project to service. One daemon process serves exactly one project; with several configured projects the name is required. |
|
|
636
|
+
| `pause` | Stop claiming new work. The running daemon notices on its next tick; runs already in flight finish. |
|
|
637
|
+
| `resume` | Allow claiming again. |
|
|
638
|
+
| `help`, `--help`, `-h` | Print usage. An unknown or missing verb prints it too, and exits `2`. |
|
|
639
|
+
|
|
640
|
+
Pause is a flag file under the state directory, so it applies to every project and
|
|
641
|
+
survives a daemon restart.
|
|
642
|
+
|
|
643
|
+
Four of these are available in-session as `/conductor setup`, `/conductor status`,
|
|
644
|
+
`/conductor pause` and `/conductor resume`, each taking an optional project name as
|
|
645
|
+
a second word. Background-process management is CLI-only: the plugin does not
|
|
646
|
+
start, stop or restart the daemon.
|
|
647
|
+
|
|
648
|
+
### Health endpoint
|
|
649
|
+
|
|
650
|
+
```bash
|
|
651
|
+
curl -s localhost:8787/healthz
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
```json
|
|
655
|
+
{ "ok": true, "paused": false, "activeRuns": 1, "project": "demo" }
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
Any other path or method returns `404`. Note that `ok` reports only that the
|
|
659
|
+
process is serving. It does not report that the fleet is doing work: read
|
|
660
|
+
`paused` to tell those apart.
|
|
661
|
+
|
|
662
|
+
## What a worker may and may not do
|
|
663
|
+
|
|
664
|
+
Each worker gets one brief, one worktree, one branch, and no knowledge of the
|
|
665
|
+
dispatcher. The brief is explicit about the boundary:
|
|
666
|
+
|
|
667
|
+
| It may | It must not |
|
|
668
|
+
| --- | --- |
|
|
669
|
+
| Read the issue and the repo's own guidance (`AGENTS.md`, `CLAUDE.md`, `CONTEXT.md`, relevant ADRs) before writing anything. | Touch any path outside its worktree, or switch branches. |
|
|
670
|
+
| 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. |
|
|
671
|
+
| Add or update tests for behaviour it introduced. | Suppress a warning, delete an assertion, or special-case an input to make a check pass. |
|
|
672
|
+
| 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. |
|
|
673
|
+
| Review its whole diff, then commit and **push once**. One corrective push if CI is red. | Force-push, `git add -f`, or add AI/co-author attribution. Red twice means stop and report, not push a third time. |
|
|
674
|
+
| Open a PR that links the issue, and watch CI to a verdict with `gh pr checks --watch`. | Run `gh pr merge`. **Merge authority is a human's alone**, so PRs land one at a time with a freshness re-check — two workers merging concurrently is how agent PRs clobber each other. |
|
|
675
|
+
| 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 — permanently out of scope. Releases are batched and decided outside this loop, so "this needs releasing" is a thing to report, never a task to take on. |
|
|
676
|
+
|
|
677
|
+
The worker ends with a six-line evidence report (issue, pr, state, gates, changed,
|
|
678
|
+
next). `pushed-green` means it watched the checks go green rather than expecting
|
|
679
|
+
them to.
|
|
680
|
+
|
|
681
|
+
## Limitations
|
|
682
|
+
|
|
683
|
+
Known and deliberate in this version:
|
|
684
|
+
|
|
685
|
+
- **`gh` is shelled out to.** Every tracker operation spawns a process and does its
|
|
686
|
+
own TLS handshake (roughly 200-400 ms each), and failures are classified by
|
|
687
|
+
matching human-readable stderr rather than a status code. The upside is that no
|
|
688
|
+
token is ever handled, stored or logged by the daemon.
|
|
689
|
+
- **`listReady` fetches a single page of 100 issues.** A queue deeper than 100
|
|
690
|
+
ready issues truncates silently. A backlog that size is a staffing problem before
|
|
691
|
+
it is a paging one.
|
|
692
|
+
- **Spend accounting depends on harness telemetry.** Cost arrives only when the
|
|
693
|
+
harness run carries it; without it `spendUsd` reads `0`, `status` shows `$0.00`,
|
|
694
|
+
and the daily-spend cap never fires. The turn and wall-clock ceilings are what
|
|
695
|
+
actually bound a runaway in that case. Do not treat `$0.00` as proof that nothing
|
|
696
|
+
was spent.
|
|
697
|
+
- **GitHub is the only tracker.** The internal `Tracker` port is deliberately
|
|
698
|
+
provider-neutral, but `tracker.kind` accepts only `"github"` today.
|
|
699
|
+
- **One project per daemon process.** Several projects means several processes,
|
|
700
|
+
each with `--project` and its own `--port`.
|
|
701
|
+
- **Labels are matched exactly and case-sensitively.** `Ready-For-Agent` is not
|
|
702
|
+
`ready-for-agent`, and the mismatch is silent: the issue is simply never picked
|
|
703
|
+
up.
|
|
704
|
+
- **No cross-process lock on the mirrors.** Two dispatch loops fetching the same
|
|
705
|
+
repo at the same instant can collide on git's ref locks; the run fails and is
|
|
706
|
+
retried rather than corrupted.
|
|
707
|
+
- **Mirrors grow one branch ref per run.** Unpushed work is never discarded, so
|
|
708
|
+
refs accumulate until you reap them.
|
|
709
|
+
- **`stop` is a deadline, not a clean drain.** `SIGTERM` asks the loop to finish the
|
|
710
|
+
tick it is on, and a tick with a worker in flight can run for that worker's whole
|
|
711
|
+
wall clock; after 10 seconds it is `SIGKILL`. There is no "stop once the current
|
|
712
|
+
worker lands".
|
|
713
|
+
- **A failed orchestrator degrades quietly.** The daemon logs a warning and keeps
|
|
714
|
+
running, but tier-1 escalations then land in issue comments — which is exactly the
|
|
715
|
+
"nobody reads it until morning" path the orchestrator exists to avoid. The warning
|
|
716
|
+
is in `daemon.log`; nothing pages you about it.
|
|
717
|
+
- **Workers are not terminal panes, so you cannot watch them.** A worker is an
|
|
718
|
+
in-process omp session inside the daemon, started by `createSession` and driven
|
|
719
|
+
concurrently via `Promise.allSettled`. Herdr therefore shows exactly one pane
|
|
720
|
+
(the orchestrator's) no matter how many workers are running, and no amount of
|
|
721
|
+
`maxConcurrentWorkers` changes that.
|
|
722
|
+
|
|
723
|
+
The cap does work. The admission loop (`src/daemon.ts:405-445`) computes
|
|
724
|
+
`slots = maxConcurrentWorkers - active runs`, admits at most that many issues per
|
|
725
|
+
tick, and dispatches them together. To see them, read `omp-conductor status`,
|
|
726
|
+
which lists every active run, or follow `daemon.log`.
|
|
727
|
+
- **Merges, releases and deploys are human-only, by design.** The conductor
|
|
728
|
+
produces green PRs and stops.
|
|
729
|
+
|
|
730
|
+
## License
|
|
731
|
+
|
|
732
|
+
MIT
|