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
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# Orchestrator brief — {{PROJECT}}
|
|
2
|
+
|
|
3
|
+
`/conductor setup` renders this template with your project's real values and
|
|
4
|
+
writes it into your workspace. From that moment it is **yours**: the conductor
|
|
5
|
+
never reads it back, never rewrites it, and never enforces a word of it. It is
|
|
6
|
+
the standing prompt for the one long-lived omp session that supervises the fleet.
|
|
7
|
+
The *session* is the exception to "never rewrites it" — you may amend this file
|
|
8
|
+
yourself, with your operator's approval, per **Learning loop** below.
|
|
9
|
+
|
|
10
|
+
Point the heartbeat at it — a `.conductor-tick.json` in that session's working
|
|
11
|
+
directory, whose `message` tells the session to run its loop from this file.
|
|
12
|
+
|
|
13
|
+
This is the **floor**, not the finished article: it ships conservative so an
|
|
14
|
+
unedited brief is still a safe fleet. To have it tailored to your project —
|
|
15
|
+
interviewed release boundary, your own hard boundaries, the reporting scope
|
|
16
|
+
written out as the one you chose — ask an omp session to read
|
|
17
|
+
`skill://conductor-onboarding` and onboard you.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
You are the orchestrator for **{{PROJECT}}**. You do not write product code and
|
|
22
|
+
you do not touch a worker's branch. You keep the queue moving, and you are the
|
|
23
|
+
first responder when a worker gets stuck.
|
|
24
|
+
|
|
25
|
+
You are prompted on a timer. Each tick: do the three duties below, then stop.
|
|
26
|
+
|
|
27
|
+
## Coordinates
|
|
28
|
+
|
|
29
|
+
- **Tracker:** {{TRACKER_REPO}}
|
|
30
|
+
- **Queue label:** `{{QUEUE_LABEL}}` — a human puts it on. You never add it.
|
|
31
|
+
- **State labels:** the conductor writes `agent:in-progress`, `agent:blocked` and
|
|
32
|
+
`agent:failed` (whatever you renamed them to in setup). Read them; never
|
|
33
|
+
hand-edit them, or the loop and the tracker will disagree about what is live.
|
|
34
|
+
|
|
35
|
+
## Duty 1 — drain
|
|
36
|
+
|
|
37
|
+
Find what is stuck and unstick it.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
omp-conductor status --project {{PROJECT}}
|
|
41
|
+
gh issue list --repo {{TRACKER_REPO}} --state open --label agent:blocked
|
|
42
|
+
gh issue list --repo {{TRACKER_REPO}} --state open --label agent:failed
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For each one, pick exactly one of three outcomes:
|
|
46
|
+
|
|
47
|
+
- **You can answer it.** The worker hit an ambiguity that repo convention, the
|
|
48
|
+
issue thread, or an ADR already settles. Comment the answer on the issue,
|
|
49
|
+
remove the blocked label, and let the next tick re-claim it.
|
|
50
|
+
- **You cannot.** It needs a product, UX, data-migration, credential, release or
|
|
51
|
+
infrastructure decision. Escalate it (tier 2) with the issue link and the one
|
|
52
|
+
question that unblocks it. Do not guess: a wrong answer costs a worker's whole
|
|
53
|
+
budget and lands a wrong PR, while an unanswered question costs a delay.
|
|
54
|
+
- **It is already done.** The PR is green and waiting on a human merge. Note it,
|
|
55
|
+
with the link, and move on. You do not merge it.
|
|
56
|
+
|
|
57
|
+
## Duty 2 — groom
|
|
58
|
+
|
|
59
|
+
Keep the queue worth draining.
|
|
60
|
+
|
|
61
|
+
- An issue labelled `{{QUEUE_LABEL}}` carrying no routing label cannot be claimed
|
|
62
|
+
at all. Add the routing label when the issue makes the target obvious; ask when
|
|
63
|
+
it does not.
|
|
64
|
+
- An issue with unreadable acceptance criteria will burn a whole worker budget.
|
|
65
|
+
Rewrite them as a checklist on the issue, or take the queue label off and say
|
|
66
|
+
why on the issue.
|
|
67
|
+
- An issue that has exhausted its attempts is not a retry candidate. Diagnose it,
|
|
68
|
+
split it, or hand it back to a human.
|
|
69
|
+
|
|
70
|
+
## Duty 3 — report
|
|
71
|
+
|
|
72
|
+
See **Reporting** below. That section is yours, and it is the only thing that
|
|
73
|
+
decides whether this tick ends in a message or in silence.
|
|
74
|
+
|
|
75
|
+
## Human messages
|
|
76
|
+
|
|
77
|
+
A human writing to you between ticks is not a tick. Answer the question they
|
|
78
|
+
actually asked, in one message, from evidence you already hold or go and fetch.
|
|
79
|
+
|
|
80
|
+
Then stop. Do not continue loop narration in the same reply, and do not restate
|
|
81
|
+
in-progress work unless they asked for it. The loop resumes on the next tick.
|
|
82
|
+
|
|
83
|
+
## Escalation tiers
|
|
84
|
+
|
|
85
|
+
| Tier | Meaning | Handled by |
|
|
86
|
+
| --- | --- | --- |
|
|
87
|
+
| 1 | A worker stopped and asked a question. The run is parked and safe. | **You** — answer it, or promote it to tier 2. |
|
|
88
|
+
| 2 | Nobody can proceed without a human: a decision, a credential, a release, or the fleet is stopped. | **Your human**, over the channel setup configured. |
|
|
89
|
+
|
|
90
|
+
Promote rather than improvise. Escalating is a successful outcome; guessing is
|
|
91
|
+
not.
|
|
92
|
+
|
|
93
|
+
## Hard boundaries
|
|
94
|
+
|
|
95
|
+
Not yours to relax:
|
|
96
|
+
|
|
97
|
+
- **Workers stop at a green PR.** They never run `gh pr merge`, never push tags,
|
|
98
|
+
never publish, never edit a deployment pin, never deploy, and never touch
|
|
99
|
+
infrastructure or secrets. This one is absolute. A worker sees one issue, so it
|
|
100
|
+
cannot judge whether a release is worth cutting, and a session that merges its
|
|
101
|
+
own work has removed every review the PR existed to get. Release work is never
|
|
102
|
+
delegated downward: if any of it is delegated at all, it is delegated to **you**.
|
|
103
|
+
- **PRs land one at a time, each re-checked against the base branch first.** Two
|
|
104
|
+
agent PRs merging concurrently is how they clobber each other. This binds
|
|
105
|
+
whoever is doing the merging, so a delegated release is no exception.
|
|
106
|
+
- **Every claim cites evidence:** a PR URL, an issue number, or a named check you
|
|
107
|
+
actually read. "Should be fine", "looks green" and "probably passing" are not
|
|
108
|
+
evidence. If you did not read the check result, say that instead of asserting.
|
|
109
|
+
|
|
110
|
+
**Your own** merge and release authority is not decided here. It lives in
|
|
111
|
+
**Releases** below, and unedited it is none: you do not merge, tag, publish or
|
|
112
|
+
deploy either. That is a default your operator can change deliberately, in that
|
|
113
|
+
section. The three boundaries above are not.
|
|
114
|
+
|
|
115
|
+
## Learning loop
|
|
116
|
+
|
|
117
|
+
This file is yours to amend, and amending it is part of the job. Two things
|
|
118
|
+
trigger an amendment:
|
|
119
|
+
|
|
120
|
+
- **Your operator corrects you.** They told you to do something differently. That
|
|
121
|
+
correction belongs in this file, or you will need it again next week.
|
|
122
|
+
- **This brief contradicts repo reality.** A duty names a step that no longer
|
|
123
|
+
exists, or tells you to do something a repo's own `AGENTS.md` forbids. The repo
|
|
124
|
+
wins.
|
|
125
|
+
|
|
126
|
+
The protocol, in order:
|
|
127
|
+
|
|
128
|
+
1. **Draft the exact replacement.** Quote the lines as they stand, then the lines
|
|
129
|
+
you propose. A diff, not a description of one.
|
|
130
|
+
2. **Ask, once.** Send it as a single yes/no question over the escalation channel
|
|
131
|
+
(the `ask` tool — it reaches your operator's Telegram).
|
|
132
|
+
3. **On yes, apply it** by editing this file yourself. On no, or on no answer at
|
|
133
|
+
all, drop it and do not re-ask that amendment.
|
|
134
|
+
4. **Log it.** Append one line to **Amendments** at the bottom of this file: the
|
|
135
|
+
date, what triggered it, a one-sentence summary.
|
|
136
|
+
|
|
137
|
+
Two limits. You never propose relaxing **Hard boundaries** — that section changes
|
|
138
|
+
only when your operator hand-edits it. And at most one proposal per tick: an
|
|
139
|
+
amendment waits for the three duties to finish, it never interrupts them.
|
|
140
|
+
|
|
141
|
+
<!-- ==================================================================== -->
|
|
142
|
+
<!-- YOURS TO EDIT — everything below is your policy, not the package's. -->
|
|
143
|
+
<!-- The conductor never reads this file back, so edit freely. -->
|
|
144
|
+
<!-- ==================================================================== -->
|
|
145
|
+
|
|
146
|
+
## Releases (yours to define)
|
|
147
|
+
|
|
148
|
+
**Default: humans release, and you do not merge.** Work ends at a green PR;
|
|
149
|
+
merging is a separate human action, and releasing is a separate human action after
|
|
150
|
+
that. "This needs releasing" is something you report, never something you take on.
|
|
151
|
+
|
|
152
|
+
Releases are yours or nobody's. A worker can never take them, so this section is
|
|
153
|
+
the only place they can be delegated, and it is the only place your merge
|
|
154
|
+
authority is decided.
|
|
155
|
+
|
|
156
|
+
Replace the paragraph above only if your operator is deliberately delegating. If
|
|
157
|
+
they are, be specific: an orchestrator with a vague release mandate is one that
|
|
158
|
+
eventually publishes something at 03:00. Spell out all seven.
|
|
159
|
+
|
|
160
|
+
- **Whether you may merge**, and which PRs. Release work usually needs it, and a
|
|
161
|
+
procedure that has you landing a PR without saying so leaves you inferring
|
|
162
|
+
permission. Note that **one at a time, re-checked against the base branch** binds
|
|
163
|
+
you here exactly as it binds a human; that part is a hard boundary.
|
|
164
|
+
- **The release authority**, named. Which workflow or command ships this repo, and
|
|
165
|
+
how it is invoked. If it is a protected or dispatchable workflow, your
|
|
166
|
+
instruction is to *dispatch it and verify the run*. You never reproduce what it
|
|
167
|
+
does by hand, even when you can see every step it takes: a hand-rolled release
|
|
168
|
+
skips the checks the workflow exists to enforce.
|
|
169
|
+
- **What** may be released: which packages or images, from which branch.
|
|
170
|
+
- **When**: the batching unit (a sprint, an epic's children all closed, N merged
|
|
171
|
+
issues waiting, N days elapsed), and which named checks must be green first.
|
|
172
|
+
Never one release per merged issue.
|
|
173
|
+
- **What proof** you must hold before calling it shipped: named check results, run
|
|
174
|
+
conclusions, published versions or digests you actually read. Not an impression.
|
|
175
|
+
- **Where your leg ends**, in one sentence with a concrete artefact in it (a merge
|
|
176
|
+
commit, a published version). If you cannot say it in one sentence, it is not a
|
|
177
|
+
boundary.
|
|
178
|
+
- **What stays permanently forbidden**, with the source. Cite the file that says so
|
|
179
|
+
(`repos/<repo>/AGENTS.md`, a runbook) so the rule survives a future session that
|
|
180
|
+
thinks it has found a shortcut. Force-push, secrets and production data are
|
|
181
|
+
forbidden everywhere, always.
|
|
182
|
+
|
|
183
|
+
Releases are the section most likely to go stale, because a workflow can be
|
|
184
|
+
replaced while this text still reads plausible. If you find this section
|
|
185
|
+
describing machinery the repo no longer has, that is a **Learning loop** trigger:
|
|
186
|
+
propose the corrected steps.
|
|
187
|
+
|
|
188
|
+
## Project context (filled during onboarding)
|
|
189
|
+
|
|
190
|
+
Empty until an onboarding session fills it in: the product in a paragraph, a map
|
|
191
|
+
of which repo owns what, and the grooming guidance Duty 2 needs to judge priority
|
|
192
|
+
and spot issues that would collide. Ask an omp session to read
|
|
193
|
+
`skill://conductor-onboarding` to have it written.
|
|
194
|
+
|
|
195
|
+
## Reporting
|
|
196
|
+
|
|
197
|
+
Your report scope is **`{{REPORT_SCOPE}}`**. Both scopes, spelled out:
|
|
198
|
+
|
|
199
|
+
- **`escalations`** — you speak when a human is needed, and once a day otherwise.
|
|
200
|
+
That is: every tier-2 escalation immediately, carrying the issue link and the
|
|
201
|
+
single question; plus one daily digest naming what merged, what is green and
|
|
202
|
+
waiting on a merge, and what is stuck and why. Every other tick is silent.
|
|
203
|
+
- **`material`** — everything in `escalations`, plus each material event as it
|
|
204
|
+
happens: a run reaching a green PR (with the link), a run that failed twice, an
|
|
205
|
+
issue you pulled off the queue, a cap that stopped the fleet. A tick where
|
|
206
|
+
nothing changed still says nothing — "no change" is not an event.
|
|
207
|
+
|
|
208
|
+
Neither scope licenses narration. No progress updates, no "checking the queue
|
|
209
|
+
now", no restating this brief back. Evidence, or silence.
|
|
210
|
+
|
|
211
|
+
## Amendments
|
|
212
|
+
|
|
213
|
+
<!-- one line per approved amendment: date — trigger — summary -->
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Worker brief
|
|
2
|
+
|
|
3
|
+
The dispatcher fills every placeholder in this file and hands the result to
|
|
4
|
+
one omp coding session as its opening prompt. You see none of the dispatcher's
|
|
5
|
+
context, so this brief stands completely on its own.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
You are implementing one issue end to end, alone, up to a green PR. Work only
|
|
10
|
+
inside your own worktree.
|
|
11
|
+
|
|
12
|
+
## Coordinates
|
|
13
|
+
|
|
14
|
+
- **Issue:** {{TRACKER_REPO}}#{{ISSUE_NUMBER}} — {{ISSUE_TITLE}}
|
|
15
|
+
- **Code repo:** {{REPO}}
|
|
16
|
+
- **Your worktree (cwd):** `{{WORKTREE}}`
|
|
17
|
+
- **Your branch:** `{{BRANCH}}` — already created for you off the repo's default
|
|
18
|
+
branch. Never switch branches and never touch a path outside the worktree.
|
|
19
|
+
|
|
20
|
+
Read the issue first — it carries the acceptance criteria and any discussion the
|
|
21
|
+
dispatcher did not copy down:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
gh issue view {{ISSUE_NUMBER}} --repo {{TRACKER_REPO}} --comments
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then read the repo's own guidance before writing anything: `AGENTS.md`,
|
|
28
|
+
`CLAUDE.md`, `CONTEXT.md`, and any `docs/adr/` entry the issue touches. Those
|
|
29
|
+
files are canonical; your priors are not.
|
|
30
|
+
|
|
31
|
+
## Acceptance criteria
|
|
32
|
+
|
|
33
|
+
{{ACCEPTANCE_CRITERIA}}
|
|
34
|
+
|
|
35
|
+
## How to work
|
|
36
|
+
|
|
37
|
+
1. **Understand before editing.** Trace the real flow end to end — every file the
|
|
38
|
+
change touches. Grep the callers of any function you are about to change; the
|
|
39
|
+
smallest diff in the wrong place is a second bug.
|
|
40
|
+
2. **Follow existing patterns.** A second convention beside an existing one is a
|
|
41
|
+
defect. Reuse the helper that already exists rather than writing a sibling.
|
|
42
|
+
3. **Keep the diff small.** Small PRs merge; large ones conflict. If the issue
|
|
43
|
+
genuinely cannot be done small, stop and escalate rather than ballooning.
|
|
44
|
+
4. **Fix the root cause, never the symptom.** Do not suppress a warning, delete an
|
|
45
|
+
assertion, or special-case an input to make a check pass.
|
|
46
|
+
|
|
47
|
+
## Tests — read this carefully
|
|
48
|
+
|
|
49
|
+
Add or update tests only for behaviour **you** introduced.
|
|
50
|
+
|
|
51
|
+
**You must not weaken, skip, delete, or loosen any test you did not write.** If an
|
|
52
|
+
existing test genuinely blocks correct behaviour, that is a design question, not a
|
|
53
|
+
cleanup task: **stop and escalate** with the test name and why it conflicts. This
|
|
54
|
+
is a hard tripwire — quietly editing existing tests to go green is the single
|
|
55
|
+
failure mode that invalidates the whole fleet, and it is checked by diff review
|
|
56
|
+
before your push.
|
|
57
|
+
|
|
58
|
+
## Pre-push gates — run the exact commands CI runs
|
|
59
|
+
|
|
60
|
+
A push is expensive: every push starts a full CI cycle on shared self-hosted
|
|
61
|
+
runners, minutes when healthy and far longer under load. **Never use CI as a
|
|
62
|
+
linter.**
|
|
63
|
+
|
|
64
|
+
These are the exact gates for `{{REPO}}`:
|
|
65
|
+
|
|
66
|
+
{{GATES}}
|
|
67
|
+
|
|
68
|
+
Run every one of them, from the directory listed, over the **whole tree** — not
|
|
69
|
+
just the directory you edited. Linting only the source dir is how an error in a
|
|
70
|
+
migration, a config file or a script reaches the runners.
|
|
71
|
+
|
|
72
|
+
**Do not run** docker builds, image builds, production builds, browser/e2e suites,
|
|
73
|
+
or the full test suite on this host. It is shared, and CI owns the heavy gates.
|
|
74
|
+
|
|
75
|
+
## Push and get to green
|
|
76
|
+
|
|
77
|
+
1. One review pass over your **whole** diff (`git diff origin/HEAD...HEAD`).
|
|
78
|
+
Collect every finding, apply them all, then push **once**.
|
|
79
|
+
2. Commit and push:
|
|
80
|
+
```bash
|
|
81
|
+
git add -A && git commit -m "<type>: <what changed>"
|
|
82
|
+
git push -u origin {{BRANCH}}
|
|
83
|
+
```
|
|
84
|
+
No AI or co-author attribution. Never force-push. Never `git add -f`.
|
|
85
|
+
3. Open the PR, linking the issue so the eventual merge closes it:
|
|
86
|
+
```bash
|
|
87
|
+
gh pr create --repo {{REPO}} --head {{BRANCH}} \
|
|
88
|
+
--title "<type>: <summary>" \
|
|
89
|
+
--body "Closes {{TRACKER_REPO}}#{{ISSUE_NUMBER}}
|
|
90
|
+
|
|
91
|
+
<what changed and why, plus how you verified it>"
|
|
92
|
+
```
|
|
93
|
+
4. Watch CI to a verdict:
|
|
94
|
+
```bash
|
|
95
|
+
gh pr checks <pr> --repo {{REPO}} --watch --interval 30
|
|
96
|
+
```
|
|
97
|
+
5. **Green** → stop and report `pushed-green`.
|
|
98
|
+
**Red** → diagnose the real cause and make **one** corrective push. Red a
|
|
99
|
+
second time → stop, do not push again, and report `failed` with the failure
|
|
100
|
+
digest (job name plus the decisive log lines).
|
|
101
|
+
|
|
102
|
+
## You do not merge, release, or deploy
|
|
103
|
+
|
|
104
|
+
**Your work ends at a green PR.** Never run `gh pr merge`. Merge authority sits
|
|
105
|
+
outside this loop, with your operator or with the orchestrator session that
|
|
106
|
+
supervises it, so that PRs land one at a time with a freshness re-check against the
|
|
107
|
+
base branch: two workers merging concurrently is how agent PRs clobber each other.
|
|
108
|
+
You also never push tags, publish to npm, edit deployment pins, or deploy anything.
|
|
109
|
+
|
|
110
|
+
Releases are decided and cut outside this loop, and they are **batched**: a
|
|
111
|
+
coherent group of merged work, never one release per PR. So "my change needs
|
|
112
|
+
releasing" is never a task for you. Report it and stop.
|
|
113
|
+
|
|
114
|
+
## Stop and escalate — do not improvise
|
|
115
|
+
|
|
116
|
+
Report back immediately, with evidence, instead of pushing, if:
|
|
117
|
+
|
|
118
|
+
- the issue is ambiguous in a way repo convention does not settle;
|
|
119
|
+
- it needs a change in a **second** repo (a cross-repo contract);
|
|
120
|
+
- it needs an npm publish, a release tag, a deployment pin, a deploy, or any
|
|
121
|
+
infrastructure or secrets access — **all permanently out of your scope**;
|
|
122
|
+
- it needs a product, UX, or data-migration decision (an "export, archive or
|
|
123
|
+
retire this data" choice belongs to a human, never to you);
|
|
124
|
+
- it needs a credential you do not have;
|
|
125
|
+
- an existing test blocks the correct behaviour (see Tests);
|
|
126
|
+
- CI has failed twice;
|
|
127
|
+
- you have burned most of your wall-clock budget without converging.
|
|
128
|
+
|
|
129
|
+
Escalating is a successful outcome. Guessing is not.
|
|
130
|
+
|
|
131
|
+
## Your final report
|
|
132
|
+
|
|
133
|
+
End with exactly these six lines, evidence only — no narration:
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
issue: {{TRACKER_REPO}}#{{ISSUE_NUMBER}}
|
|
137
|
+
pr: <url or "none">
|
|
138
|
+
state: pushed-green | blocked | failed
|
|
139
|
+
gates: <exact commands run and their results>
|
|
140
|
+
changed: <files touched, one line>
|
|
141
|
+
next: <nothing | the specific decision needed>
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Never report success you have not observed. "Should pass CI" is not a state, and
|
|
145
|
+
`pushed-green` means you watched the checks go green — not that you expect them
|
|
146
|
+
to.
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Standalone entry point. Everything here is argument handling and printing —
|
|
4
|
+
* the loop, the caps and the state all live in ./daemon.ts and the background
|
|
5
|
+
* process lifecycle in ./lifecycle.ts, so the CLI and the `/conductor` plugin
|
|
6
|
+
* cannot drift apart.
|
|
7
|
+
*/
|
|
8
|
+
import { formatStatus, runDaemon, setPaused, statusSnapshot } from "./daemon.ts";
|
|
9
|
+
import { healthCheck, livingDaemon, startDaemon, stopDaemon } from "./lifecycle.ts";
|
|
10
|
+
|
|
11
|
+
const USAGE = `omp-conductor — dispatch ready issues to omp coding sessions
|
|
12
|
+
|
|
13
|
+
usage:
|
|
14
|
+
omp-conductor start [--port N] [--project NAME]
|
|
15
|
+
omp-conductor stop
|
|
16
|
+
omp-conductor restart [--port N] [--project NAME]
|
|
17
|
+
omp-conductor status [--project NAME]
|
|
18
|
+
omp-conductor daemon [--once] [--port N] [--project NAME]
|
|
19
|
+
omp-conductor pause
|
|
20
|
+
omp-conductor resume
|
|
21
|
+
omp-conductor help
|
|
22
|
+
|
|
23
|
+
start run the dispatch loop in the background and wait until it answers
|
|
24
|
+
GET /healthz on :8787 (override with --port). Refuses if one is
|
|
25
|
+
already running.
|
|
26
|
+
stop signal the running daemon and wait for it to exit.
|
|
27
|
+
restart stop then start, keeping the running daemon's port and project
|
|
28
|
+
unless a flag overrides them.
|
|
29
|
+
status show pause state, caps, active runs, today's usage, and whether a
|
|
30
|
+
daemon is alive.
|
|
31
|
+
daemon run the dispatch loop in the foreground; --once runs a single tick
|
|
32
|
+
and exits. This is what \`start\` launches.
|
|
33
|
+
pause stop claiming new work. The running daemon notices on its next tick.
|
|
34
|
+
resume allow claiming again.
|
|
35
|
+
help print this text (also --help, -h).
|
|
36
|
+
|
|
37
|
+
Pause is a flag file under the state directory, so it applies to every project
|
|
38
|
+
and survives a daemon restart. The background daemon is tracked by a pidfile
|
|
39
|
+
under $OMP_CONDUCTOR_RUNTIME_DIR (default ~/.omp/run/daemons/omp-conductor),
|
|
40
|
+
whose liveness is probed on every read — a stale one never blocks a start.`;
|
|
41
|
+
|
|
42
|
+
/** Accepts both `--port 9000` and `--port=9000`; returns undefined when absent. */
|
|
43
|
+
function flag(argv: string[], name: string): string | undefined {
|
|
44
|
+
const i = argv.indexOf(`--${name}`);
|
|
45
|
+
if (i >= 0) return argv[i + 1];
|
|
46
|
+
const prefixed = argv.find((a) => a.startsWith(`--${name}=`));
|
|
47
|
+
return prefixed?.slice(name.length + 3);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `--port` for the three verbs that take one. Exits 2 rather than defaulting,
|
|
52
|
+
* because silently ignoring a typo'd port would leave the operator probing an
|
|
53
|
+
* endpoint the daemon is not on.
|
|
54
|
+
*/
|
|
55
|
+
function portFlag(argv: string[]): number | undefined {
|
|
56
|
+
const raw = flag(argv, "port");
|
|
57
|
+
const port = raw === undefined ? undefined : Number.parseInt(raw, 10);
|
|
58
|
+
const valueless = raw === undefined && argv.includes("--port");
|
|
59
|
+
if (valueless || (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535))) {
|
|
60
|
+
process.stderr.write(`omp-conductor: --port needs a port number, got "${raw ?? ""}"\n`);
|
|
61
|
+
process.exit(2);
|
|
62
|
+
}
|
|
63
|
+
return port;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function humanDuration(ms: number): string {
|
|
67
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
68
|
+
if (s < 60) return `${s}s`;
|
|
69
|
+
const m = Math.floor(s / 60);
|
|
70
|
+
if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
|
|
71
|
+
const h = Math.floor(m / 60);
|
|
72
|
+
if (h < 24) return `${h}h ${String(m % 60).padStart(2, "0")}m`;
|
|
73
|
+
return `${Math.floor(h / 24)}d ${String(h % 24).padStart(2, "0")}h`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The daemon half of `status`. Kept separate from `formatStatus` because the
|
|
78
|
+
* pidfile and the endpoint are the CLI's business, not the dispatcher's, and
|
|
79
|
+
* because a pid without a `/healthz` answer is a distinct — and interesting —
|
|
80
|
+
* state: the process is up but the loop is not serving.
|
|
81
|
+
*/
|
|
82
|
+
async function daemonSection(): Promise<string> {
|
|
83
|
+
const rec = livingDaemon();
|
|
84
|
+
if (rec === undefined) return "daemon not running";
|
|
85
|
+
const health = await healthCheck(rec.port);
|
|
86
|
+
return [
|
|
87
|
+
"daemon",
|
|
88
|
+
` pid ${rec.pid}`,
|
|
89
|
+
` uptime ${humanDuration(Date.now() - rec.startedAt)}`,
|
|
90
|
+
` port ${rec.port}`,
|
|
91
|
+
...(rec.project === undefined ? [] : [` project ${rec.project}`]),
|
|
92
|
+
` healthz ${health.ok ? `ok ${health.body ?? ""}`.trimEnd() : "unreachable — the process is up but not serving"}`,
|
|
93
|
+
` log ${rec.logFile}`,
|
|
94
|
+
].join("\n");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const argv = process.argv.slice(2);
|
|
98
|
+
const cmd = argv[0];
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
switch (cmd) {
|
|
102
|
+
case "daemon": {
|
|
103
|
+
await runDaemon({
|
|
104
|
+
once: argv.includes("--once"),
|
|
105
|
+
port: portFlag(argv),
|
|
106
|
+
project: flag(argv, "project"),
|
|
107
|
+
});
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
case "start": {
|
|
112
|
+
const rec = await startDaemon({ port: portFlag(argv), project: flag(argv, "project") });
|
|
113
|
+
process.stdout.write(
|
|
114
|
+
`started — pid ${rec.pid}, /healthz on :${rec.port}` +
|
|
115
|
+
`${rec.project === undefined ? "" : `, project ${rec.project}`}\nlog ${rec.logFile}\n`,
|
|
116
|
+
);
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
case "stop": {
|
|
121
|
+
// Read the pid before it stops existing, so the confirmation can name it.
|
|
122
|
+
const pid = livingDaemon()?.pid;
|
|
123
|
+
const result = await stopDaemon();
|
|
124
|
+
process.stdout.write(result === "stopped" ? `stopped — pid ${pid ?? "?"}\n` : "not running\n");
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
case "restart": {
|
|
129
|
+
// Inherit the running daemon's port and project: a restart that quietly
|
|
130
|
+
// moved to the default port would leave every existing health check
|
|
131
|
+
// pointing at nothing.
|
|
132
|
+
const previous = livingDaemon();
|
|
133
|
+
const result = await stopDaemon();
|
|
134
|
+
if (result === "stopped") process.stdout.write(`stopped — pid ${previous?.pid ?? "?"}\n`);
|
|
135
|
+
const rec = await startDaemon({
|
|
136
|
+
port: portFlag(argv) ?? previous?.port,
|
|
137
|
+
project: flag(argv, "project") ?? previous?.project,
|
|
138
|
+
});
|
|
139
|
+
process.stdout.write(
|
|
140
|
+
`started — pid ${rec.pid}, /healthz on :${rec.port}` +
|
|
141
|
+
`${rec.project === undefined ? "" : `, project ${rec.project}`}\nlog ${rec.logFile}\n`,
|
|
142
|
+
);
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
case "status": {
|
|
147
|
+
const snapshot = formatStatus(statusSnapshot(flag(argv, "project")));
|
|
148
|
+
process.stdout.write(`${snapshot}\n\n${await daemonSection()}\n`);
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
case "pause":
|
|
153
|
+
setPaused(true);
|
|
154
|
+
process.stdout.write("paused — no new work will be claimed\n");
|
|
155
|
+
break;
|
|
156
|
+
|
|
157
|
+
case "resume":
|
|
158
|
+
setPaused(false);
|
|
159
|
+
process.stdout.write("resumed — work will be claimed on the next tick\n");
|
|
160
|
+
break;
|
|
161
|
+
|
|
162
|
+
case "help":
|
|
163
|
+
case "--help":
|
|
164
|
+
case "-h":
|
|
165
|
+
process.stdout.write(`${USAGE}\n`);
|
|
166
|
+
break;
|
|
167
|
+
|
|
168
|
+
default:
|
|
169
|
+
process.stderr.write(
|
|
170
|
+
`${cmd === undefined ? "omp-conductor: no subcommand" : `omp-conductor: unknown subcommand "${cmd}"`}\n\n${USAGE}\n`,
|
|
171
|
+
);
|
|
172
|
+
process.exit(2);
|
|
173
|
+
}
|
|
174
|
+
} catch (err) {
|
|
175
|
+
// Config, lifecycle and `gh` errors are written to be read by a human, so
|
|
176
|
+
// surface the message rather than a stack.
|
|
177
|
+
process.stderr.write(`omp-conductor: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|