omp-conductor 0.2.0 → 0.2.2
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 +80 -10
- package/package.json +1 -1
- package/src/brief-upgrade.ts +242 -0
- package/src/briefs/orchestrator.md +56 -3
- package/src/cli.ts +68 -0
- package/src/daemon.ts +61 -7
- package/src/plugin.ts +43 -6
- package/src/setup.ts +35 -10
- package/src/store.ts +22 -4
- package/src/types.ts +6 -1
package/README.md
CHANGED
|
@@ -212,6 +212,48 @@ turned on you can also invoke it directly:
|
|
|
212
212
|
Nothing about the wizard changes: `/conductor setup` on its own remains a
|
|
213
213
|
complete, supported path, and the brief it renders is safe unedited.
|
|
214
214
|
|
|
215
|
+
### Keeping a brief current
|
|
216
|
+
|
|
217
|
+
Upgrading the package does not upgrade a brief you are already running, and it is
|
|
218
|
+
worth knowing exactly which half of that sentence is true.
|
|
219
|
+
|
|
220
|
+
| What | Updates on `omp plugin install`? |
|
|
221
|
+
| --- | --- |
|
|
222
|
+
| `skills/conductor-onboarding/SKILL.md` | Yes. The session reads it from the installed package. |
|
|
223
|
+
| `src/briefs/worker.md` | Yes. It is read per run, so the next worker gets the new text. |
|
|
224
|
+
| `src/briefs/orchestrator.md` | Yes, but it is only a *template*: it is read when the wizard renders a brief. |
|
|
225
|
+
| Your rendered `ORCHESTRATOR.md` | **No.** It was written once and is yours from then on. |
|
|
226
|
+
|
|
227
|
+
That last row is the point. Once the wizard renders your brief, nothing in this
|
|
228
|
+
package reads it back or rewrites it, so a later version that ships a new protocol
|
|
229
|
+
above the `YOURS TO EDIT` banner is invisible to every fleet already running:
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
omp-conductor brief-upgrade # report only
|
|
233
|
+
omp-conductor brief-upgrade --apply # replace the shipped half, keep yours
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
The banner is what makes this safe. Everything above it belongs to the package and
|
|
237
|
+
everything below it belongs to you, so an upgrade replaces the first and copies the
|
|
238
|
+
second across untouched, keeping the previous file as
|
|
239
|
+
`ORCHESTRATOR.md.bak-<timestamp>`. Three cases where it will not write at all:
|
|
240
|
+
|
|
241
|
+
- **Your brief has no banner** (hand-written, or predating the split). There is no
|
|
242
|
+
way to tell which lines are yours, so it lists the sections the template has and
|
|
243
|
+
yours does not, and leaves the file alone. Retitled sections count as present, so
|
|
244
|
+
`## Reporting (low noise)` is not reported as a missing `## Reporting`.
|
|
245
|
+
- **No config resolved**, so the template still carries its `{{PLACEHOLDER}}`
|
|
246
|
+
coordinates. Merging it would write those literals into a live prompt.
|
|
247
|
+
- **Nothing changed.** It says so and exits.
|
|
248
|
+
|
|
249
|
+
`--file PATH` checks a brief that is not where the wizard would have put it, which
|
|
250
|
+
is the normal case on a dedicated fleet host: the supervising session runs from its
|
|
251
|
+
own directory, and that host may never have configured a dispatch daemon.
|
|
252
|
+
|
|
253
|
+
A brief with the **Learning loop** section has a second route. The session running
|
|
254
|
+
from it can propose the missing sections itself, as a diff, for you to approve with
|
|
255
|
+
a yes over Telegram, which is the same protocol it uses for any other amendment.
|
|
256
|
+
|
|
215
257
|
## Quick start
|
|
216
258
|
|
|
217
259
|
1. Write a config (see [Configuration](#configuration)) at
|
|
@@ -275,10 +317,14 @@ Per tick, for the daemon's project:
|
|
|
275
317
|
the configured repo names. These are never dispatched.
|
|
276
318
|
5. **Check spend.** If spend since local midnight has reached `dailySpendUsd`, the
|
|
277
319
|
daemon **pauses itself**, pages at Tier 2, and returns.
|
|
278
|
-
6. **Check capacity.** `maxConcurrentWorkers` minus
|
|
279
|
-
|
|
320
|
+
6. **Check capacity.** `maxConcurrentWorkers` minus *live* workers (runs in
|
|
321
|
+
`claimed` or `running`) gives the free slots. A green PR awaiting a human
|
|
322
|
+
merge occupies its issue but not a slot: its worker is finished, and counting
|
|
323
|
+
it would let two green PRs stop the fleet. If no slot is free, the tick logs
|
|
324
|
+
and returns.
|
|
280
325
|
7. **Admit issues** up to the free slots, skipping any issue that already has
|
|
281
|
-
an active run
|
|
326
|
+
an active run — including a green PR, so a second attempt can never land on a
|
|
327
|
+
live PR. An issue that has used `maxAttemptsPerIssue` escalates at Tier 1
|
|
282
328
|
instead of being admitted.
|
|
283
329
|
8. **Dispatch** the admitted issues concurrently.
|
|
284
330
|
|
|
@@ -288,9 +334,9 @@ Then, per admitted issue:
|
|
|
288
334
|
This ordering is the whole crash-safety story: the label, not the local
|
|
289
335
|
database, is the guard against dispatching the same issue twice. If the process
|
|
290
336
|
dies at any later point, the next daemon sees the label, eligibility filters the
|
|
291
|
-
issue out, and
|
|
292
|
-
can be rebuilt from the tracker; a label that was written too
|
|
293
|
-
duplicate PR.
|
|
337
|
+
issue out, and the orchestrator's drain duty triages the orphan (see below). A
|
|
338
|
+
store that is lost can be rebuilt from the tracker; a label that was written too
|
|
339
|
+
late cannot undo a duplicate PR.
|
|
294
340
|
2. Create the run row (`claimed`).
|
|
295
341
|
3. Clear any stale tree for this issue, then add a fresh worktree at
|
|
296
342
|
`<workspaceRoot>/<issue>` cut from the bare mirror at `<mirrorRoot>/<repo>.git`,
|
|
@@ -312,6 +358,26 @@ Then, per admitted issue:
|
|
|
312
358
|
leaves a window in which the issue carries no state label at all, which is
|
|
313
359
|
exactly the shape eligibility reads as fresh work.
|
|
314
360
|
|
|
361
|
+
### What a restart does to runs that were in flight
|
|
362
|
+
|
|
363
|
+
A `claimed` or `running` row is a promise that a worker process exists, and a
|
|
364
|
+
daemon that just started knows that promise is broken: its workers died with the
|
|
365
|
+
previous process. At startup — unless another daemon is alive, so a foreground
|
|
366
|
+
`daemon --once` cannot orphan a running daemon's real workers — every such row is
|
|
367
|
+
moved to `orphaned`, with a log line naming the issue, the attempt and the
|
|
368
|
+
worktree. That frees the slots immediately; a fleet must never resume as
|
|
369
|
+
deadlocked as it crashed.
|
|
370
|
+
|
|
371
|
+
Only the rows change. The issue keeps `agent:in-progress` — the label is the
|
|
372
|
+
crash guard against double-dispatch — and deciding what the dead worker's remains
|
|
373
|
+
are worth is the orchestrator's drain-duty judgement, spelled out in its brief:
|
|
374
|
+
an open green PR goes to the merge path, a dirty tree is reported before anything
|
|
375
|
+
destroys it (uncommitted edits have no other copy; unpushed *commits* are safe on
|
|
376
|
+
the run's branch in the mirror, which a retry deliberately reattaches), and a
|
|
377
|
+
clean orphan has its label released so the next tick re-claims it. Orphaned
|
|
378
|
+
attempts still count toward `maxAttemptsPerIssue`, so a crash loop escalates
|
|
379
|
+
instead of redispatching forever.
|
|
380
|
+
|
|
315
381
|
### Branch names
|
|
316
382
|
|
|
317
383
|
`<type>/<slug>`, where the type is `fix` when any label's last segment (after `:`
|
|
@@ -620,6 +686,7 @@ omp-conductor status [--project NAME]
|
|
|
620
686
|
omp-conductor daemon [--once] [--port N] [--project NAME]
|
|
621
687
|
omp-conductor pause
|
|
622
688
|
omp-conductor resume
|
|
689
|
+
omp-conductor brief-upgrade [--apply] [--file PATH] [--project NAME]
|
|
623
690
|
omp-conductor help
|
|
624
691
|
```
|
|
625
692
|
|
|
@@ -635,6 +702,9 @@ omp-conductor help
|
|
|
635
702
|
| `--project NAME` | Pick the project to service. One daemon process serves exactly one project; with several configured projects the name is required. |
|
|
636
703
|
| `pause` | Stop claiming new work. The running daemon notices on its next tick; runs already in flight finish. |
|
|
637
704
|
| `resume` | Allow claiming again. |
|
|
705
|
+
| `brief-upgrade` | Compare a project's `ORCHESTRATOR.md` against the brief this version of the package ships. Reports by default; see [Keeping a brief current](#keeping-a-brief-current). |
|
|
706
|
+
| `--apply` | Only for `brief-upgrade`. Replaces the half above the `YOURS TO EDIT` banner and keeps everything below it, backing the previous file up first. Ignored when the brief cannot be split or the template is unrendered. |
|
|
707
|
+
| `--file PATH` | Only for `brief-upgrade`. Check a brief that is not where the wizard would have put it, on a host that may have no config at all. |
|
|
638
708
|
| `help`, `--help`, `-h` | Print usage. An unknown or missing verb prints it too, and exits `2`. |
|
|
639
709
|
|
|
640
710
|
Pause is a flag file under the state directory, so it applies to every project and
|
|
@@ -720,10 +790,10 @@ Known and deliberate in this version:
|
|
|
720
790
|
(the orchestrator's) no matter how many workers are running, and no amount of
|
|
721
791
|
`maxConcurrentWorkers` changes that.
|
|
722
792
|
|
|
723
|
-
The cap does work. The admission loop (`src/daemon.ts:
|
|
724
|
-
`slots = maxConcurrentWorkers -
|
|
725
|
-
tick, and dispatches them together. To see them, read `omp-conductor
|
|
726
|
-
which lists every
|
|
793
|
+
The cap does work. The admission loop (`src/daemon.ts:410-448`) computes
|
|
794
|
+
`slots = maxConcurrentWorkers - live workers`, admits at most that many issues
|
|
795
|
+
per tick, and dispatches them together. To see them, read `omp-conductor
|
|
796
|
+
status`, which lists every occupied issue, or follow `daemon.log`.
|
|
727
797
|
- **Merges, releases and deploys are human-only, by design.** The conductor
|
|
728
798
|
produces green PRs and stops.
|
|
729
799
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping a live `ORCHESTRATOR.md` current with the shipped template.
|
|
3
|
+
*
|
|
4
|
+
* The wizard renders the template once and then never touches the file again,
|
|
5
|
+
* because from that moment it is the operator's. That is the right ownership
|
|
6
|
+
* rule and it has one consequence nobody notices until months later: every
|
|
7
|
+
* later improvement to the *shipped* half of the brief — a new duty, a protocol
|
|
8
|
+
* like the amendment loop — is invisible to every fleet already running. The
|
|
9
|
+
* package updates; the standing prompt does not.
|
|
10
|
+
*
|
|
11
|
+
* This module is the missing half of that story. It never guesses: the brief has
|
|
12
|
+
* an explicit banner separating the package's text from the operator's, so when
|
|
13
|
+
* that banner is present the split is exact and the merge is mechanical. When it
|
|
14
|
+
* is absent — a hand-written brief, or one predating the split — there is no
|
|
15
|
+
* honest way to know which lines are the operator's, so nothing is rewritten and
|
|
16
|
+
* the missing sections are reported instead.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The line that divides the two halves. Matched on this substring rather than
|
|
23
|
+
* the whole comment banner so a reflowed or re-decorated banner still splits.
|
|
24
|
+
*/
|
|
25
|
+
const EDIT_BANNER = "YOURS TO EDIT";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A brief split into the package's half and the operator's half.
|
|
29
|
+
*
|
|
30
|
+
* `shipped` runs to the end of the banner line; `owned` is everything after it.
|
|
31
|
+
* Concatenating them reproduces the input byte for byte, which is what makes a
|
|
32
|
+
* merge safe to write back.
|
|
33
|
+
*/
|
|
34
|
+
export interface BriefHalves {
|
|
35
|
+
shipped: string;
|
|
36
|
+
owned: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Splits on the banner, or returns `undefined` when there is none.
|
|
41
|
+
*
|
|
42
|
+
* `undefined` is a real answer, not a failure: it means this brief cannot be
|
|
43
|
+
* merged mechanically, and every caller is expected to degrade to reporting
|
|
44
|
+
* rather than to assume a boundary.
|
|
45
|
+
*/
|
|
46
|
+
export function splitBrief(text: string): BriefHalves | undefined {
|
|
47
|
+
const at = text.indexOf(EDIT_BANNER);
|
|
48
|
+
if (at < 0) return undefined;
|
|
49
|
+
// Keep the whole banner line on the shipped side: the operator's half starts
|
|
50
|
+
// at the first line they own, so a merge never has to reconstruct the banner.
|
|
51
|
+
const lineEnd = text.indexOf("\n", at);
|
|
52
|
+
const cut = lineEnd < 0 ? text.length : lineEnd + 1;
|
|
53
|
+
return { shipped: text.slice(0, cut), owned: text.slice(cut) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A `## Heading` in the shipped half, by its exact text. */
|
|
57
|
+
function headings(text: string): string[] {
|
|
58
|
+
const out: string[] = [];
|
|
59
|
+
for (const line of text.split("\n")) {
|
|
60
|
+
if (line.startsWith("## ")) out.push(line.slice(3).trim());
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The comparable part of a heading: everything before the first dash, colon or
|
|
67
|
+
* bracket, lowercased.
|
|
68
|
+
*
|
|
69
|
+
* Operators retitle sections freely — `## Reporting` becomes `## Reporting (low
|
|
70
|
+
* noise, evidence-backed)`, `## Duty 1 — drain` becomes `## Duty 1 — the dispatch
|
|
71
|
+
* loop (run this on every tick)` — and an exact match would report all of those as
|
|
72
|
+
* absent. Ten reported sections when four are genuinely missing is a list nobody
|
|
73
|
+
* reads, which is the same as reporting nothing.
|
|
74
|
+
*/
|
|
75
|
+
function topicKey(heading: string): string {
|
|
76
|
+
const cut = heading.search(/[—–:(-]/u);
|
|
77
|
+
return (cut < 0 ? heading : heading.slice(0, cut)).trim().toLowerCase();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Shipped sections the live brief has no heading for.
|
|
82
|
+
*
|
|
83
|
+
* Matched on {@link topicKey}, so a retitled section counts as present. The
|
|
84
|
+
* remaining bias is deliberate: this decides what to *offer* for a hand-merge, and
|
|
85
|
+
* a section reported that the operator already covers costs them one read, while a
|
|
86
|
+
* new protocol silently counted as present costs them the protocol.
|
|
87
|
+
*/
|
|
88
|
+
export function missingSections(live: string, rendered: string): string[] {
|
|
89
|
+
const present = new Set(headings(live).map(topicKey));
|
|
90
|
+
return headings(rendered).filter((h) => !present.has(topicKey(h)));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Section bodies from a rendered template, keyed by heading, for reporting. */
|
|
94
|
+
export function sectionText(rendered: string, heading: string): string {
|
|
95
|
+
const lines = rendered.split("\n");
|
|
96
|
+
const start = lines.findIndex((l) => l.startsWith("## ") && l.slice(3).trim() === heading);
|
|
97
|
+
if (start < 0) return "";
|
|
98
|
+
let end = lines.length;
|
|
99
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
100
|
+
const line = lines[i];
|
|
101
|
+
if (line !== undefined && line.startsWith("## ")) {
|
|
102
|
+
end = i;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return lines.slice(start, end).join("\n").trimEnd();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** An unfilled `{{KEY}}` coordinate in a template nobody rendered. */
|
|
110
|
+
const PLACEHOLDER_PATTERN = /\{\{[A-Za-z0-9_]+\}\}/;
|
|
111
|
+
|
|
112
|
+
/** What a check found, and what a caller may do about it. */
|
|
113
|
+
export type BriefStatus =
|
|
114
|
+
| { kind: "current" }
|
|
115
|
+
/** Banner present and the shipped half differs: a merge is exact. */
|
|
116
|
+
| { kind: "mergeable"; merged: string; liveShipped: string; freshShipped: string }
|
|
117
|
+
/** No banner, so the boundary is unknown and only reporting is honest. */
|
|
118
|
+
| { kind: "unsplittable"; missing: string[] }
|
|
119
|
+
/** Template never rendered, so merging it would write `{{PROJECT}}` into a brief. */
|
|
120
|
+
| { kind: "unrendered"; missing: string[] };
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Compares a live brief against the freshly rendered template.
|
|
124
|
+
*
|
|
125
|
+
* `rendered` should come from `renderBriefForProject`, so the coordinates already
|
|
126
|
+
* match and a diff reflects policy changes rather than substitution noise. A raw
|
|
127
|
+
* template is accepted — a host that runs only the supervising session has no
|
|
128
|
+
* config to render from — but it can only ever produce a report.
|
|
129
|
+
*/
|
|
130
|
+
export function checkBrief(live: string, rendered: string): BriefStatus {
|
|
131
|
+
const liveHalves = splitBrief(live);
|
|
132
|
+
const freshHalves = splitBrief(rendered);
|
|
133
|
+
|
|
134
|
+
// A template without the banner is a packaging error, not an operator problem,
|
|
135
|
+
// so treat it the same as an unmergeable live brief rather than inventing a cut.
|
|
136
|
+
if (liveHalves === undefined || freshHalves === undefined) {
|
|
137
|
+
return { kind: "unsplittable", missing: missingSections(live, rendered) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Enforced here rather than at each caller: merging an unrendered template would
|
|
141
|
+
// write `{{PROJECT}}` into a live standing prompt, and a session reading its own
|
|
142
|
+
// coordinates as a literal placeholder is worse than an out-of-date brief.
|
|
143
|
+
if (PLACEHOLDER_PATTERN.test(freshHalves.shipped)) {
|
|
144
|
+
return { kind: "unrendered", missing: missingSections(live, rendered) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (liveHalves.shipped === freshHalves.shipped) return { kind: "current" };
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
kind: "mergeable",
|
|
151
|
+
// The operator's half is carried across untouched. This is the whole safety
|
|
152
|
+
// property: an upgrade that reformats one of their sections is an upgrade
|
|
153
|
+
// nobody runs twice.
|
|
154
|
+
merged: freshHalves.shipped + liveHalves.owned,
|
|
155
|
+
liveShipped: liveHalves.shipped,
|
|
156
|
+
freshShipped: freshHalves.shipped,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Line-level diff of the two shipped halves, for a human to read before saying
|
|
162
|
+
* yes. Deliberately not a real diff algorithm: the shipped half changes by whole
|
|
163
|
+
* sections between versions, so listing removed and added lines in order is both
|
|
164
|
+
* enough to review and impossible to misread as a merge preview.
|
|
165
|
+
*/
|
|
166
|
+
export function shippedDiff(before: string, after: string): string {
|
|
167
|
+
const old = new Set(before.split("\n"));
|
|
168
|
+
const now = new Set(after.split("\n"));
|
|
169
|
+
const lines: string[] = [];
|
|
170
|
+
for (const line of before.split("\n")) {
|
|
171
|
+
if (!now.has(line) && line.trim() !== "") lines.push(`- ${line}`);
|
|
172
|
+
}
|
|
173
|
+
for (const line of after.split("\n")) {
|
|
174
|
+
if (!old.has(line) && line.trim() !== "") lines.push(`+ ${line}`);
|
|
175
|
+
}
|
|
176
|
+
return lines.join("\n");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Writes the merged brief, leaving the previous one beside it.
|
|
181
|
+
*
|
|
182
|
+
* The backup is not optional and not configurable: this file is a standing
|
|
183
|
+
* prompt an operator may have spent an hour on, and the one thing an upgrade
|
|
184
|
+
* must never do is be the reason it is gone.
|
|
185
|
+
*/
|
|
186
|
+
export function writeMergedBrief(path: string, merged: string): string {
|
|
187
|
+
const backup = `${path}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
188
|
+
writeFileSync(backup, readFileSync(path));
|
|
189
|
+
writeFileSync(path, merged);
|
|
190
|
+
return backup;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The check rendered for a terminal, including what to do next. */
|
|
194
|
+
export function formatBriefStatus(path: string, status: BriefStatus): string {
|
|
195
|
+
if (status.kind === "current") {
|
|
196
|
+
return [`brief ${path}`, "", "up to date — its shipped half matches this version of the template."].join("\n");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (status.kind === "mergeable") {
|
|
200
|
+
return [
|
|
201
|
+
`brief ${path}`,
|
|
202
|
+
"",
|
|
203
|
+
"This version of the package ships a different brief above the YOURS TO EDIT",
|
|
204
|
+
"banner. Everything below the banner is yours and would be carried across",
|
|
205
|
+
"unchanged.",
|
|
206
|
+
"",
|
|
207
|
+
shippedDiff(status.liveShipped, status.freshShipped),
|
|
208
|
+
"",
|
|
209
|
+
"Apply it with: omp-conductor brief-upgrade --apply",
|
|
210
|
+
"The previous file is kept beside it as ORCHESTRATOR.md.bak-<timestamp>.",
|
|
211
|
+
].join("\n");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const lines = [`brief ${path}`, ""];
|
|
215
|
+
lines.push(
|
|
216
|
+
...(status.kind === "unrendered"
|
|
217
|
+
? [
|
|
218
|
+
"The shipped template still carries its {{PLACEHOLDER}} coordinates, because no",
|
|
219
|
+
"project config resolved on this host. Merging it would write those literals",
|
|
220
|
+
"into a live standing prompt, so this can only be reported on. Run it with",
|
|
221
|
+
"--project on a host that has the config to apply an upgrade.",
|
|
222
|
+
]
|
|
223
|
+
: [
|
|
224
|
+
"This brief has no YOURS TO EDIT banner, so it was written by hand or predates",
|
|
225
|
+
"the template split. There is no way to tell which lines are yours, so nothing",
|
|
226
|
+
"will be rewritten automatically.",
|
|
227
|
+
]),
|
|
228
|
+
);
|
|
229
|
+
if (status.missing.length === 0) {
|
|
230
|
+
lines.push("", "It already has a heading for every section the template ships.");
|
|
231
|
+
return lines.join("\n");
|
|
232
|
+
}
|
|
233
|
+
lines.push(
|
|
234
|
+
"",
|
|
235
|
+
`Sections the shipped template has and this brief does not (${status.missing.length}):`,
|
|
236
|
+
...status.missing.map((h) => ` - ${h}`),
|
|
237
|
+
"",
|
|
238
|
+
"Merge the ones you want by hand, or ask the session running from this brief to",
|
|
239
|
+
"propose them through its own amendment protocol.",
|
|
240
|
+
);
|
|
241
|
+
return lines.join("\n");
|
|
242
|
+
}
|
|
@@ -54,6 +54,40 @@ For each one, pick exactly one of three outcomes:
|
|
|
54
54
|
- **It is already done.** The PR is green and waiting on a human merge. Note it,
|
|
55
55
|
with the link, and move on. You do not merge it.
|
|
56
56
|
|
|
57
|
+
**Then check for orphans.** A worker is a process, and processes die: a daemon
|
|
58
|
+
restart, a host reboot, a kill. The `agent:in-progress` label survives that death
|
|
59
|
+
by design — it is the guard that stops the next tick double-dispatching — but
|
|
60
|
+
nothing removes it, so a dead worker's issue sits "in progress" forever, occupying
|
|
61
|
+
a slot that no longer exists. Compare the in-progress labels against the active
|
|
62
|
+
runs `omp-conductor status` just showed you: **an in-progress issue with no
|
|
63
|
+
matching active run is an orphan.**
|
|
64
|
+
|
|
65
|
+
For an orphan, inspect what the dead worker left before touching the label. Read
|
|
66
|
+
the issue itself (`gh issue view <n> --json labels` — the label-filtered *list*
|
|
67
|
+
reads GitHub's eventually-consistent search index and lags label writes in both
|
|
68
|
+
directions), then the worktree (`git status --porcelain`, `git log
|
|
69
|
+
origin/main..HEAD`) and any PR. Four cases, checked in this order:
|
|
70
|
+
|
|
71
|
+
- **An open PR that is green.** That worker finished; it just never got to report.
|
|
72
|
+
This is the "already done" case above: note it with the link and move on. Never
|
|
73
|
+
release-and-re-claim it — a fresh worker would duplicate a finished run.
|
|
74
|
+
- **A dirty tree** (uncommitted edits in the worktree). This is the one thing a
|
|
75
|
+
re-claim destroys: the conductor removes and reattaches worktrees with `--force`
|
|
76
|
+
on every attempt, and uncommitted edits have no other copy. Do not release the
|
|
77
|
+
label yet — report what exists and where, and let your operator decide whether
|
|
78
|
+
it is worth salvaging. Uncommitted edits are work too; "nothing committed" is
|
|
79
|
+
not "nothing there".
|
|
80
|
+
- **Commits — pushed or not — or a PR that is not green.** Safe either way:
|
|
81
|
+
pushed work lives on the remote, and unpushed commits live on the run's branch
|
|
82
|
+
in the mirror, which a re-claim deliberately reattaches so the next worker
|
|
83
|
+
starts from them. Note what exists and release the label; the attempt counter
|
|
84
|
+
still bounds a loop of deaths.
|
|
85
|
+
- **Genuinely nothing** (clean tree, no commits, no PR). Release the label and let
|
|
86
|
+
the next tick re-claim it clean.
|
|
87
|
+
|
|
88
|
+
Never leave an orphan holding a slot "to be safe": a label nobody is working under
|
|
89
|
+
is not safety, it is a deadlocked fleet that looks busy.
|
|
90
|
+
|
|
57
91
|
## Duty 2 — groom
|
|
58
92
|
|
|
59
93
|
Keep the queue worth draining.
|
|
@@ -126,13 +160,32 @@ trigger an amendment:
|
|
|
126
160
|
The protocol, in order:
|
|
127
161
|
|
|
128
162
|
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
|
-
|
|
131
|
-
|
|
163
|
+
you propose. A diff, not a description of one. This full text is what you
|
|
164
|
+
*apply* on a yes — it is not what you send.
|
|
165
|
+
2. **Ask, once — a single yes/no question, written for a phone.** It goes over
|
|
166
|
+
the escalation channel (the `ask` tool — it reaches your operator's Telegram),
|
|
167
|
+
and Telegram renders none of your markdown: asterisks and backticks arrive as
|
|
168
|
+
literal characters, and a pasted section becomes an unreadable wall. So:
|
|
169
|
+
- Lead with one plain sentence: what changes, and why, in your own words.
|
|
170
|
+
- Then show only the lines that actually change, compact, under two short
|
|
171
|
+
labels like "now:" and "proposed:". Never paste whole sections around a
|
|
172
|
+
two-line change.
|
|
173
|
+
- Keep the whole proposal readable on one phone screen. If the edit is too
|
|
174
|
+
big for that, send the one-sentence version of each change and say the
|
|
175
|
+
full text lands in the file on yes — the diff stays in your transcript for
|
|
176
|
+
anyone who wants it verbatim.
|
|
132
177
|
3. **On yes, apply it** by editing this file yourself. On no, or on no answer at
|
|
133
178
|
all, drop it and do not re-ask that amendment.
|
|
134
179
|
4. **Log it.** Append one line to **Amendments** at the bottom of this file: the
|
|
135
180
|
date, what triggered it, a one-sentence summary.
|
|
181
|
+
5. **Offer general fixes upstream.** Ask one question of the amendment you just
|
|
182
|
+
applied: does it fix *this fleet* (a repo name, a path, a cap, your infra), or
|
|
183
|
+
does it fix *how the brief works* (a duty's logic, a protocol, a failure mode
|
|
184
|
+
any fleet would hit)? The second kind belongs in the shipped template, or
|
|
185
|
+
every other operator re-learns it the hard way. Say so in your report, and
|
|
186
|
+
offer to file it: an issue on `TerrifiedBug/conductor` quoting the approved
|
|
187
|
+
diff and the incident that triggered it. File it only when your operator says
|
|
188
|
+
yes — it is their name on the account.
|
|
136
189
|
|
|
137
190
|
Two limits. You never propose relaxing **Hard boundaries** — that section changes
|
|
138
191
|
only when your operator hand-edits it. And at most one proposal per tick: an
|
package/src/cli.ts
CHANGED
|
@@ -5,8 +5,13 @@
|
|
|
5
5
|
* process lifecycle in ./lifecycle.ts, so the CLI and the `/conductor` plugin
|
|
6
6
|
* cannot drift apart.
|
|
7
7
|
*/
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { checkBrief, formatBriefStatus, writeMergedBrief } from "./brief-upgrade.ts";
|
|
10
|
+
import { findProject, loadConfig } from "./config.ts";
|
|
8
11
|
import { formatStatus, runDaemon, setPaused, statusSnapshot } from "./daemon.ts";
|
|
9
12
|
import { healthCheck, livingDaemon, startDaemon, stopDaemon } from "./lifecycle.ts";
|
|
13
|
+
import { briefPathForProject, renderBriefForProject, shippedBriefTemplate } from "./setup.ts";
|
|
14
|
+
import type { ProjectConfig } from "./types.ts";
|
|
10
15
|
|
|
11
16
|
const USAGE = `omp-conductor — dispatch ready issues to omp coding sessions
|
|
12
17
|
|
|
@@ -18,6 +23,7 @@ usage:
|
|
|
18
23
|
omp-conductor daemon [--once] [--port N] [--project NAME]
|
|
19
24
|
omp-conductor pause
|
|
20
25
|
omp-conductor resume
|
|
26
|
+
omp-conductor brief-upgrade [--apply] [--file PATH] [--project NAME]
|
|
21
27
|
omp-conductor help
|
|
22
28
|
|
|
23
29
|
start run the dispatch loop in the background and wait until it answers
|
|
@@ -32,6 +38,14 @@ usage:
|
|
|
32
38
|
and exits. This is what \`start\` launches.
|
|
33
39
|
pause stop claiming new work. The running daemon notices on its next tick.
|
|
34
40
|
resume allow claiming again.
|
|
41
|
+
brief-upgrade
|
|
42
|
+
compare a project's ORCHESTRATOR.md against the brief this version of
|
|
43
|
+
the package ships. Reports by default; --apply replaces the half above
|
|
44
|
+
the YOURS TO EDIT banner and keeps everything below it, backing the old
|
|
45
|
+
file up first. --file checks a brief that is not where the wizard would
|
|
46
|
+
have put it, on a host that may have no config at all. Nothing is
|
|
47
|
+
written for a brief with no banner, or when no config resolved and the
|
|
48
|
+
template still carries its {{PLACEHOLDER}} coordinates.
|
|
35
49
|
help print this text (also --help, -h).
|
|
36
50
|
|
|
37
51
|
Pause is a flag file under the state directory, so it applies to every project
|
|
@@ -159,6 +173,60 @@ try {
|
|
|
159
173
|
process.stdout.write("resumed — work will be claimed on the next tick\n");
|
|
160
174
|
break;
|
|
161
175
|
|
|
176
|
+
case "brief-upgrade": {
|
|
177
|
+
// `--file` exists because a real fleet's brief is often not where the wizard
|
|
178
|
+
// would have put it: the supervising session runs from its own directory, and
|
|
179
|
+
// that host may never have configured a dispatch daemon at all. Without this
|
|
180
|
+
// the command cannot check the one file it was written for.
|
|
181
|
+
const override = flag(argv, "file");
|
|
182
|
+
let project: ProjectConfig | undefined;
|
|
183
|
+
let path: string;
|
|
184
|
+
if (override === undefined) {
|
|
185
|
+
project = findProject(loadConfig(), flag(argv, "project"));
|
|
186
|
+
path = briefPathForProject(project);
|
|
187
|
+
} else {
|
|
188
|
+
path = override;
|
|
189
|
+
try {
|
|
190
|
+
project = findProject(loadConfig(), flag(argv, "project"));
|
|
191
|
+
} catch {
|
|
192
|
+
// No config here, or several projects and no name given. With an explicit
|
|
193
|
+
// file we need neither, and refusing would make the command unusable on a
|
|
194
|
+
// fleet host that runs only the supervising session.
|
|
195
|
+
project = undefined;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let live: string;
|
|
200
|
+
try {
|
|
201
|
+
live = readFileSync(path, "utf8");
|
|
202
|
+
} catch {
|
|
203
|
+
process.stderr.write(
|
|
204
|
+
`omp-conductor: no brief at ${path}` +
|
|
205
|
+
`${override === undefined ? " — run /conductor setup and say yes to writing ORCHESTRATOR.md." : "."}\n`,
|
|
206
|
+
);
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// With no config there are no coordinates to substitute, so the template is
|
|
211
|
+
// compared raw. Headings carry no placeholders, so the section-level report is
|
|
212
|
+
// unaffected; the note below keeps the printed text honest.
|
|
213
|
+
const status = checkBrief(live, project === undefined ? shippedBriefTemplate() : renderBriefForProject(project));
|
|
214
|
+
// Report first, always: --apply on a brief with no banner must not be the
|
|
215
|
+
// command that silently discards an operator's hand-written policy.
|
|
216
|
+
process.stdout.write(`${formatBriefStatus(path, status)}\n`);
|
|
217
|
+
if (project === undefined) {
|
|
218
|
+
process.stdout.write(
|
|
219
|
+
"\nnote: no conductor config resolved on this host, so the template's\n" +
|
|
220
|
+
"{{PLACEHOLDER}} coordinates are unsubstituted. Section names are unaffected.\n",
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
if (argv.includes("--apply") && status.kind === "mergeable") {
|
|
224
|
+
const backup = writeMergedBrief(path, status.merged);
|
|
225
|
+
process.stdout.write(`\napplied — previous brief kept at ${backup}\n`);
|
|
226
|
+
}
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
|
|
162
230
|
case "help":
|
|
163
231
|
case "--help":
|
|
164
232
|
case "-h":
|
package/src/daemon.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
|
11
11
|
import { dirname, join } from "node:path";
|
|
12
12
|
import { configPath, findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
|
|
13
13
|
import { createEscalator } from "./escalate.ts";
|
|
14
|
+
import { livingDaemon } from "./lifecycle.ts";
|
|
14
15
|
import { startOrchestrator } from "./orchestrator.ts";
|
|
15
16
|
import type { OrchestratorHandle } from "./orchestrator.ts";
|
|
16
17
|
import { branchName, route } from "./routing.ts";
|
|
@@ -402,16 +403,18 @@ async function tick(d: Deps): Promise<void> {
|
|
|
402
403
|
return;
|
|
403
404
|
}
|
|
404
405
|
|
|
405
|
-
|
|
406
|
-
|
|
406
|
+
// Two different questions, deliberately two queries. Capacity counts worker
|
|
407
|
+
// *processes*, so a green PR awaiting a human merge must not consume a slot —
|
|
408
|
+
// two of those would otherwise stop the fleet. The busy set protects *issues*,
|
|
409
|
+
// so that same green PR must be in it, or a second attempt lands on a live PR.
|
|
410
|
+
const live = store.liveRuns(project.name);
|
|
411
|
+
const slots = caps.maxConcurrentWorkers - live.length;
|
|
407
412
|
if (slots <= 0) {
|
|
408
|
-
log(`at capacity: ${
|
|
413
|
+
log(`at capacity: ${live.length}/${caps.maxConcurrentWorkers} workers`);
|
|
409
414
|
return;
|
|
410
415
|
}
|
|
411
416
|
|
|
412
|
-
|
|
413
|
-
// merge, so this also stops a second attempt landing on a live PR.
|
|
414
|
-
const busy = new Set(active.map((r) => r.issue));
|
|
417
|
+
const busy = new Set(store.activeRuns(project.name).map((r) => r.issue));
|
|
415
418
|
|
|
416
419
|
const admitted: { r: Routed; attempt: number }[] = [];
|
|
417
420
|
for (const r of routed) {
|
|
@@ -453,7 +456,10 @@ export interface StatusSnapshot {
|
|
|
453
456
|
stateDir: string;
|
|
454
457
|
paused: boolean;
|
|
455
458
|
caps: Caps;
|
|
459
|
+
/** Occupied issues: live workers plus green PRs awaiting a human merge. */
|
|
456
460
|
activeRuns: RunRecord[];
|
|
461
|
+
/** Runs backed by a worker process — the number capacity compares against. */
|
|
462
|
+
liveWorkers: number;
|
|
457
463
|
runsToday: number;
|
|
458
464
|
spendTodayUsd: number;
|
|
459
465
|
}
|
|
@@ -474,6 +480,7 @@ export function statusSnapshot(project?: string): StatusSnapshot {
|
|
|
474
480
|
paused: isPaused(),
|
|
475
481
|
caps: resolveCaps(p, cfg.defaults),
|
|
476
482
|
activeRuns: store.activeRuns(p.name),
|
|
483
|
+
liveWorkers: store.liveRuns(p.name).length,
|
|
477
484
|
runsToday: store.runsStartedSince(p.name, since),
|
|
478
485
|
spendTodayUsd: store.spendSince(p.name, since),
|
|
479
486
|
};
|
|
@@ -489,7 +496,7 @@ export function formatStatus(s: StatusSnapshot): string {
|
|
|
489
496
|
`state ${s.stateDir}`,
|
|
490
497
|
"",
|
|
491
498
|
"caps",
|
|
492
|
-
` workers ${s.
|
|
499
|
+
` workers ${s.liveWorkers} / ${s.caps.maxConcurrentWorkers}`,
|
|
493
500
|
` issues today ${s.runsToday}`,
|
|
494
501
|
` spend today $${s.spendTodayUsd.toFixed(2)} / $${s.caps.dailySpendUsd.toFixed(2)}`,
|
|
495
502
|
` worker max turns ${s.caps.workerMaxTurns}`,
|
|
@@ -564,6 +571,38 @@ export function armConductor(): void {
|
|
|
564
571
|
setPaused(false);
|
|
565
572
|
}
|
|
566
573
|
|
|
574
|
+
/**
|
|
575
|
+
* Settles the runs a previous daemon process left in flight.
|
|
576
|
+
*
|
|
577
|
+
* A `claimed` or `running` row is a promise that a worker exists in *some*
|
|
578
|
+
* process. This is called from a freshly started daemon, so when no other
|
|
579
|
+
* daemon is alive every such row is a worker that died with the previous
|
|
580
|
+
* process. Left "active", those rows deadlock admission forever: the slot
|
|
581
|
+
* count reads full while nothing runs, and the fleet looks busy doing nothing
|
|
582
|
+
* (found live, after a host restart killed two workers mid-run).
|
|
583
|
+
*
|
|
584
|
+
* Only the rows change. The issue keeps its in-progress label — that label is
|
|
585
|
+
* the crash guard against double-dispatch, and deciding what a dead worker's
|
|
586
|
+
* remains are worth (an open PR? unpushed commits? a dirty tree?) is the
|
|
587
|
+
* orchestrator's drain-duty judgement, not something to automate here. The
|
|
588
|
+
* rows also keep counting toward `maxAttemptsPerIssue`, so a loop of deaths
|
|
589
|
+
* still escalates instead of retrying forever.
|
|
590
|
+
*
|
|
591
|
+
* `pushed-green` rows are deliberately left alone: they hold no process — they
|
|
592
|
+
* are finished work waiting on a human merge, and they must keep occupying the
|
|
593
|
+
* issue so a second attempt cannot land on a live PR.
|
|
594
|
+
*/
|
|
595
|
+
export function reconcileOrphanedRuns(store: Store, project: string): RunRecord[] {
|
|
596
|
+
// Live runs only: `pushed-green` holds no process, so it cannot be orphaned by
|
|
597
|
+
// a process dying — it is finished work waiting on a human merge.
|
|
598
|
+
const stale = store.liveRuns(project);
|
|
599
|
+
const endedAt = Date.now();
|
|
600
|
+
for (const r of stale) {
|
|
601
|
+
store.updateRun(r.id, { state: "orphaned", endedAt });
|
|
602
|
+
}
|
|
603
|
+
return stale;
|
|
604
|
+
}
|
|
605
|
+
|
|
567
606
|
// ------------------------------------------------------------------- the daemon
|
|
568
607
|
|
|
569
608
|
export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
@@ -573,6 +612,21 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
573
612
|
const store = openStore(dbPath());
|
|
574
613
|
const tracker = makeTracker(project);
|
|
575
614
|
|
|
615
|
+
// Before the first tick, settle what the last process left behind — unless
|
|
616
|
+
// another daemon is alive (a foreground `daemon --once` beside a running
|
|
617
|
+
// daemon must not orphan that daemon's real, live workers).
|
|
618
|
+
const alive = livingDaemon();
|
|
619
|
+
if (alive === undefined || alive.pid === process.pid) {
|
|
620
|
+
for (const r of reconcileOrphanedRuns(store, project.name)) {
|
|
621
|
+
log(
|
|
622
|
+
`#${r.issue} orphaned by a previous daemon (attempt ${r.attempt}, was ${r.state}, worktree ${r.worktree}) — ` +
|
|
623
|
+
`slot freed; the ${project.stateLabels.inProgress} label stays until the orchestrator triages what the worker left`,
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
} else {
|
|
627
|
+
log(`skipping orphan reconciliation: daemon pid ${alive.pid} is alive and owns the active runs`);
|
|
628
|
+
}
|
|
629
|
+
|
|
576
630
|
// Standing orders. The orchestrator holds none of this file's context, so
|
|
577
631
|
// everything it needs to act — which tracker, which labels, what the fleet
|
|
578
632
|
// does — has to be said once, in words.
|
package/src/plugin.ts
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* tested; this file turns answers into questions and back again. The invariant
|
|
11
11
|
* worth protecting is on `setup()` below — nothing is written before the confirm.
|
|
12
12
|
*/
|
|
13
|
-
import { existsSync } from "node:fs";
|
|
14
|
-
import {
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { checkBrief, formatBriefStatus, writeMergedBrief } from "./brief-upgrade.ts";
|
|
15
|
+
import { configPath, findProject, loadConfig, saveConfig } from "./config.ts";
|
|
15
16
|
import {
|
|
16
17
|
armConductor,
|
|
17
18
|
formatStatus,
|
|
@@ -25,12 +26,14 @@ import {
|
|
|
25
26
|
ORCHESTRATOR_BRIEF_NAME,
|
|
26
27
|
REPORT_SCOPE_CHOICES,
|
|
27
28
|
SETUP_DEFAULTS,
|
|
29
|
+
briefPathForProject,
|
|
28
30
|
buildConfig,
|
|
29
31
|
checkTokenScopes,
|
|
30
32
|
createMissingLabels,
|
|
31
33
|
detectTelegram,
|
|
32
34
|
orchestratorBriefPath,
|
|
33
35
|
planLabels,
|
|
36
|
+
renderBriefForProject,
|
|
34
37
|
summarisePlan,
|
|
35
38
|
writeOrchestratorBrief,
|
|
36
39
|
type SetupAnswers,
|
|
@@ -100,13 +103,19 @@ const SUBCOMMANDS: Completion[] = [
|
|
|
100
103
|
{ value: "status", label: "status", description: "pause state, caps, active runs, today's usage" },
|
|
101
104
|
{ value: "pause", label: "pause", description: "stop claiming new work" },
|
|
102
105
|
{ value: "resume", label: "resume", description: "allow claiming again" },
|
|
106
|
+
{
|
|
107
|
+
value: "brief-upgrade",
|
|
108
|
+
label: "brief-upgrade",
|
|
109
|
+
description: "check ORCHESTRATOR.md against the brief this version ships",
|
|
110
|
+
},
|
|
103
111
|
];
|
|
104
112
|
|
|
105
113
|
const USAGE = [
|
|
106
|
-
"/conductor setup [project]
|
|
107
|
-
"/conductor status [project]
|
|
108
|
-
"/conductor pause
|
|
109
|
-
"/conductor resume
|
|
114
|
+
"/conductor setup [project] create or update a project, then arm after you confirm",
|
|
115
|
+
"/conductor status [project] pause state, caps, active runs, today's usage",
|
|
116
|
+
"/conductor pause stop claiming new work",
|
|
117
|
+
"/conductor resume allow claiming again",
|
|
118
|
+
"/conductor brief-upgrade [project] check ORCHESTRATOR.md against the shipped brief",
|
|
110
119
|
].join("\n");
|
|
111
120
|
|
|
112
121
|
/**
|
|
@@ -588,6 +597,34 @@ export default function conductorPlugin(pi: PluginApi): void {
|
|
|
588
597
|
ctx.ui.notify("Conductor resumed — work will be claimed on the next tick.", "info");
|
|
589
598
|
break;
|
|
590
599
|
|
|
600
|
+
case "brief-upgrade": {
|
|
601
|
+
const p = findProject(loadConfig(), project);
|
|
602
|
+
const path = briefPathForProject(p);
|
|
603
|
+
if (!existsSync(path)) {
|
|
604
|
+
ctx.ui.notify(
|
|
605
|
+
`No brief at ${path} — run /conductor setup and say yes to writing ${ORCHESTRATOR_BRIEF_NAME}.`,
|
|
606
|
+
"warning",
|
|
607
|
+
);
|
|
608
|
+
break;
|
|
609
|
+
}
|
|
610
|
+
const status = checkBrief(readFileSync(path, "utf8"), renderBriefForProject(p));
|
|
611
|
+
ctx.ui.notify(formatBriefStatus(path, status), status.kind === "current" ? "info" : "warning");
|
|
612
|
+
// Confirmed here rather than applied on sight: this file is a standing
|
|
613
|
+
// prompt the operator may have spent an hour on, so the diff they just
|
|
614
|
+
// read is the thing they are agreeing to.
|
|
615
|
+
if (status.kind === "mergeable") {
|
|
616
|
+
const apply = await ctx.ui.confirm(
|
|
617
|
+
"Upgrade the brief?",
|
|
618
|
+
"Replace the half above the YOURS TO EDIT banner with the one this version ships? Everything below the banner is kept exactly as it is, and the current file is backed up first.",
|
|
619
|
+
);
|
|
620
|
+
if (apply) {
|
|
621
|
+
const backup = writeMergedBrief(path, status.merged);
|
|
622
|
+
ctx.ui.notify(`Brief upgraded. Previous version kept at ${backup}.`, "info");
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
|
|
591
628
|
default:
|
|
592
629
|
ctx.ui.notify(
|
|
593
630
|
`${sub ? `Unknown subcommand "${sub}".` : "Pick a subcommand."}\n\n${USAGE}` +
|
package/src/setup.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { configPath, resolveCaps, stateDir } from "./config.ts";
|
|
|
27
27
|
import {
|
|
28
28
|
CONFIG_VERSION,
|
|
29
29
|
DEFAULT_CAPS,
|
|
30
|
+
DEFAULT_REPORT_SCOPE,
|
|
30
31
|
type Caps,
|
|
31
32
|
type ConductorConfig,
|
|
32
33
|
type ProjectConfig,
|
|
@@ -392,31 +393,55 @@ export function buildConfig(a: SetupAnswers, existing?: ConductorConfig): Conduc
|
|
|
392
393
|
}
|
|
393
394
|
|
|
394
395
|
/**
|
|
395
|
-
* Where
|
|
396
|
+
* Where a configured project's brief lives: beside its worktrees, under the state
|
|
396
397
|
* directory, so it is on the same disk the fleet already owns and survives a
|
|
397
|
-
* reinstall of the package. Derived from the
|
|
398
|
+
* reinstall of the package. Derived from the project rather than fixed, so a
|
|
398
399
|
* project that ever gains a chosen workspace root keeps its brief with it.
|
|
399
400
|
*/
|
|
400
|
-
export function
|
|
401
|
-
return join(
|
|
401
|
+
export function briefPathForProject(p: ProjectConfig): string {
|
|
402
|
+
return join(p.workspaceRoot, ORCHESTRATOR_BRIEF_NAME);
|
|
402
403
|
}
|
|
403
404
|
|
|
404
405
|
/**
|
|
405
|
-
* The
|
|
406
|
+
* The brief template exactly as shipped, placeholders and all.
|
|
407
|
+
*
|
|
408
|
+
* Exported for the upgrade check, which has to be able to read the shipped text
|
|
409
|
+
* on a host that has no config to render it against.
|
|
410
|
+
*/
|
|
411
|
+
export function shippedBriefTemplate(): string {
|
|
412
|
+
return readFileSync(ORCHESTRATOR_TEMPLATE_PATH, "utf8");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The shipped template with a configured project's real values in it.
|
|
406
417
|
*
|
|
407
418
|
* Only the coordinates and the chosen scope are substituted: the policy text is
|
|
408
419
|
* left exactly as shipped, because from here on the file is the operator's to
|
|
409
420
|
* edit and nothing in this package reads it back.
|
|
421
|
+
*
|
|
422
|
+
* Takes a `ProjectConfig` rather than answers so that a *later* upgrade check can
|
|
423
|
+
* reproduce the same render from what is on disk, months after the wizard's
|
|
424
|
+
* answers are gone.
|
|
410
425
|
*/
|
|
411
|
-
export function
|
|
426
|
+
export function renderBriefForProject(p: ProjectConfig): string {
|
|
412
427
|
return renderBrief(readFileSync(ORCHESTRATOR_TEMPLATE_PATH, "utf8"), {
|
|
413
|
-
PROJECT:
|
|
414
|
-
TRACKER_REPO:
|
|
415
|
-
QUEUE_LABEL:
|
|
416
|
-
REPORT_SCOPE:
|
|
428
|
+
PROJECT: p.name,
|
|
429
|
+
TRACKER_REPO: p.tracker.repo,
|
|
430
|
+
QUEUE_LABEL: p.queueLabel,
|
|
431
|
+
REPORT_SCOPE: p.reporting?.scope ?? DEFAULT_REPORT_SCOPE,
|
|
417
432
|
});
|
|
418
433
|
}
|
|
419
434
|
|
|
435
|
+
/** Wizard-time path, via the project the answers describe. */
|
|
436
|
+
export function orchestratorBriefPath(a: SetupAnswers): string {
|
|
437
|
+
return briefPathForProject(buildProject(a));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Wizard-time render, via the project the answers describe. */
|
|
441
|
+
export function renderOrchestratorBrief(a: SetupAnswers): string {
|
|
442
|
+
return renderBriefForProject(buildProject(a));
|
|
443
|
+
}
|
|
444
|
+
|
|
420
445
|
/**
|
|
421
446
|
* Writes the rendered brief and returns where it went.
|
|
422
447
|
*
|
package/src/store.ts
CHANGED
|
@@ -16,12 +16,21 @@ import { dirname } from "node:path";
|
|
|
16
16
|
import type { RunRecord, RunState, Store } from "./types.ts";
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* States
|
|
20
|
-
*
|
|
21
|
-
* and the issue claim are all still held.
|
|
19
|
+
* States backed by a worker process. These are what worker capacity counts:
|
|
20
|
+
* a slot is a process, and only a claimed or running attempt has one.
|
|
22
21
|
*/
|
|
23
|
-
const
|
|
22
|
+
const LIVE_STATES: readonly RunState[] = ["claimed", "running"];
|
|
24
23
|
|
|
24
|
+
/**
|
|
25
|
+
* States that keep an *issue* occupied. `pushed-green` belongs here but not in
|
|
26
|
+
* {@link LIVE_STATES}: its worker is finished and its worktree already removed,
|
|
27
|
+
* so it must not consume a slot — two green PRs awaiting a human merge would
|
|
28
|
+
* otherwise stop the whole fleet — but its issue has a live PR that a second
|
|
29
|
+
* attempt must not land on.
|
|
30
|
+
*/
|
|
31
|
+
const ACTIVE_STATES: readonly RunState[] = [...LIVE_STATES, "pushed-green"];
|
|
32
|
+
|
|
33
|
+
const LIVE_PLACEHOLDERS = LIVE_STATES.map(() => "?").join(", ");
|
|
25
34
|
const ACTIVE_PLACEHOLDERS = ACTIVE_STATES.map(() => "?").join(", ");
|
|
26
35
|
|
|
27
36
|
/**
|
|
@@ -165,6 +174,11 @@ export function openStore(dbPath: string): Store {
|
|
|
165
174
|
WHERE project = ? AND state IN (${ACTIVE_PLACEHOLDERS})
|
|
166
175
|
ORDER BY startedAt ASC`,
|
|
167
176
|
);
|
|
177
|
+
const selectLive = db.query<RunRow, SqlValue[]>(
|
|
178
|
+
`SELECT * FROM runs
|
|
179
|
+
WHERE project = ? AND state IN (${LIVE_PLACEHOLDERS})
|
|
180
|
+
ORDER BY startedAt ASC`,
|
|
181
|
+
);
|
|
168
182
|
const countAttempts = db.query<{ n: number }, [string, number]>(
|
|
169
183
|
`SELECT COUNT(*) AS n FROM runs WHERE project = ? AND issue = ?`,
|
|
170
184
|
);
|
|
@@ -233,6 +247,10 @@ export function openStore(dbPath: string): Store {
|
|
|
233
247
|
return selectActive.all(project, ...ACTIVE_STATES).map(toRecord);
|
|
234
248
|
},
|
|
235
249
|
|
|
250
|
+
liveRuns(project: string): RunRecord[] {
|
|
251
|
+
return selectLive.all(project, ...LIVE_STATES).map(toRecord);
|
|
252
|
+
},
|
|
253
|
+
|
|
236
254
|
attemptsFor(project: string, issue: number): number {
|
|
237
255
|
return countAttempts.get(project, issue)?.n ?? 0;
|
|
238
256
|
},
|
package/src/types.ts
CHANGED
|
@@ -170,7 +170,9 @@ export type RunState =
|
|
|
170
170
|
| "merged"
|
|
171
171
|
| "blocked"
|
|
172
172
|
| "failed"
|
|
173
|
-
| "killed"
|
|
173
|
+
| "killed"
|
|
174
|
+
/** In flight when its daemon process died; reconciled at the next startup. */
|
|
175
|
+
| "orphaned";
|
|
174
176
|
|
|
175
177
|
/**
|
|
176
178
|
* One attempt at one issue. Persisted so a daemon restart can reconcile
|
|
@@ -207,7 +209,10 @@ export interface Store {
|
|
|
207
209
|
createRun(r: Omit<RunRecord, "id">): RunRecord;
|
|
208
210
|
updateRun(id: string, patch: Partial<RunRecord>): void;
|
|
209
211
|
getRun(id: string): RunRecord | undefined;
|
|
212
|
+
/** Runs whose issue is occupied: a live worker, or a green PR awaiting merge. */
|
|
210
213
|
activeRuns(project: string): RunRecord[];
|
|
214
|
+
/** Runs backed by a worker process — what capacity counts. Subset of {@link Store.activeRuns}. */
|
|
215
|
+
liveRuns(project: string): RunRecord[];
|
|
211
216
|
attemptsFor(project: string, issue: number): number;
|
|
212
217
|
runsStartedSince(project: string, sinceEpochMs: number): number;
|
|
213
218
|
spendSince(project: string, sinceEpochMs: number): number;
|