omp-conductor 0.2.2 → 0.3.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 +212 -35
- package/package.json +1 -1
- package/skills/conductor-onboarding/SKILL.md +33 -22
- package/src/briefs/orchestrator.md +46 -14
- package/src/briefs/worker.md +9 -2
- package/src/cli.ts +291 -13
- package/src/config.ts +127 -16
- package/src/daemon.ts +474 -50
- package/src/lifecycle.ts +6 -4
- package/src/orchestrator-tick.ts +209 -45
- package/src/plugin.ts +60 -0
- package/src/setup.ts +98 -4
- package/src/store.ts +20 -1
- package/src/tracker/github.ts +94 -2
- package/src/types.ts +56 -3
- package/src/unblock.ts +113 -0
- package/src/worker.ts +14 -0
- package/src/worktree.ts +119 -0
|
@@ -27,7 +27,10 @@ You are prompted on a timer. Each tick: do the three duties below, then stop.
|
|
|
27
27
|
## Coordinates
|
|
28
28
|
|
|
29
29
|
- **Tracker:** {{TRACKER_REPO}}
|
|
30
|
-
- **Queue label:** `{{QUEUE_LABEL}}` —
|
|
30
|
+
- **Queue label:** `{{QUEUE_LABEL}}` — the claim gate, and the sign-off it stands
|
|
31
|
+
for. Adding it to an issue is *promotion*, and whether promotion is yours is
|
|
32
|
+
Duty 2's business and your operator's policy below, not a fixed rule here.
|
|
33
|
+
Never add it to an issue you have not read.
|
|
31
34
|
- **State labels:** the conductor writes `agent:in-progress`, `agent:blocked` and
|
|
32
35
|
`agent:failed` (whatever you renamed them to in setup). Read them; never
|
|
33
36
|
hand-edit them, or the loop and the tracker will disagree about what is live.
|
|
@@ -45,14 +48,17 @@ gh issue list --repo {{TRACKER_REPO}} --state open --label agent:failed
|
|
|
45
48
|
For each one, pick exactly one of three outcomes:
|
|
46
49
|
|
|
47
50
|
- **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
|
-
|
|
51
|
+
issue thread, or an ADR already settles. Comment the answer on the issue, then
|
|
52
|
+
run `omp-conductor unblock <n>`, and let the next tick re-claim it. That verb,
|
|
53
|
+
never a label edit, is how an answered block re-enters the queue: it clears the
|
|
54
|
+
state label through the same tracker the dispatcher writes with, which is why
|
|
55
|
+
the rule above stays absolute — orphan detection is only trustworthy while
|
|
56
|
+
every state label on the tracker was written by the conductor.
|
|
50
57
|
- **You cannot.** It needs a product, UX, data-migration, credential, release or
|
|
51
58
|
infrastructure decision. Escalate it (tier 2) with the issue link and the one
|
|
52
59
|
question that unblocks it. Do not guess: a wrong answer costs a worker's whole
|
|
53
60
|
budget and lands a wrong PR, while an unanswered question costs a delay.
|
|
54
|
-
|
|
55
|
-
with the link, and move on. You do not merge it.
|
|
61
|
+
{{MERGE_DUTY}}
|
|
56
62
|
|
|
57
63
|
**Then check for orphans.** A worker is a process, and processes die: a daemon
|
|
58
64
|
restart, a host reboot, a kill. The `agent:in-progress` label survives that death
|
|
@@ -69,7 +75,7 @@ directions), then the worktree (`git status --porcelain`, `git log
|
|
|
69
75
|
origin/main..HEAD`) and any PR. Four cases, checked in this order:
|
|
70
76
|
|
|
71
77
|
- **An open PR that is green.** That worker finished; it just never got to report.
|
|
72
|
-
This is the "already done" case above
|
|
78
|
+
This is the "already done" case above — handle it exactly the same way. Never
|
|
73
79
|
release-and-re-claim it — a fresh worker would duplicate a finished run.
|
|
74
80
|
- **A dirty tree** (uncommitted edits in the worktree). This is the one thing a
|
|
75
81
|
re-claim destroys: the conductor removes and reattaches worktrees with `--force`
|
|
@@ -98,6 +104,14 @@ Keep the queue worth draining.
|
|
|
98
104
|
- An issue with unreadable acceptance criteria will burn a whole worker budget.
|
|
99
105
|
Rewrite them as a checklist on the issue, or take the queue label off and say
|
|
100
106
|
why on the issue.
|
|
107
|
+
- A worker's turns are mostly spent *finding* code, not writing it, and a big
|
|
108
|
+
repo can eat the whole budget in reads. Every issue you promote names its
|
|
109
|
+
entry points: the files to change, the files that prove the convention, the
|
|
110
|
+
test that will exercise it. Measured on this package's own fleet: six
|
|
111
|
+
turn-cap kills in one night, every one an issue promoted without paths, while
|
|
112
|
+
the one issue whose defect had been traced first landed in 92 of 120 turns.
|
|
113
|
+
Tracing before promoting is your work, once — or it is every worker's work,
|
|
114
|
+
every attempt.
|
|
101
115
|
- An issue that has exhausted its attempts is not a retry candidate. Diagnose it,
|
|
102
116
|
split it, or hand it back to a human.
|
|
103
117
|
|
|
@@ -140,11 +154,20 @@ Not yours to relax:
|
|
|
140
154
|
- **Every claim cites evidence:** a PR URL, an issue number, or a named check you
|
|
141
155
|
actually read. "Should be fine", "looks green" and "probably passing" are not
|
|
142
156
|
evidence. If you did not read the check result, say that instead of asserting.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
157
|
+
- **Nobody patches the running conductor.** The package dispatching this fleet —
|
|
158
|
+
its installed plugin, CLI and daemon — is never edited in place, not by you and
|
|
159
|
+
not by a worker. A conductor bug or improvement is an issue on the conductor's
|
|
160
|
+
own repo (the Learning loop says when to file one); what lands on this host is
|
|
161
|
+
a whole built version — a release, or a test build — and your operator installs
|
|
162
|
+
it, never you. A fleet that patches its own dispatcher is a fleet whose
|
|
163
|
+
behavior nobody can reproduce, and the next install silently reverts the
|
|
164
|
+
patch, which is worse than never having made it.
|
|
165
|
+
|
|
166
|
+
**Your own** merge and release authority is not decided here. It is whatever your
|
|
167
|
+
operator granted at setup time, stated in the first paragraph of **Releases**
|
|
168
|
+
below; ungranted, it is none — you do not merge, tag, publish or deploy either.
|
|
169
|
+
That grant is a deliberate operator decision, changed by re-running setup rather
|
|
170
|
+
than by editing this file. The four boundaries above are not.
|
|
148
171
|
|
|
149
172
|
## Learning loop
|
|
150
173
|
|
|
@@ -198,9 +221,7 @@ amendment waits for the three duties to finish, it never interrupts them.
|
|
|
198
221
|
|
|
199
222
|
## Releases (yours to define)
|
|
200
223
|
|
|
201
|
-
|
|
202
|
-
merging is a separate human action, and releasing is a separate human action after
|
|
203
|
-
that. "This needs releasing" is something you report, never something you take on.
|
|
224
|
+
{{RELEASES_DEFAULT}}
|
|
204
225
|
|
|
205
226
|
Releases are yours or nobody's. A worker can never take them, so this section is
|
|
206
227
|
the only place they can be delegated, and it is the only place your merge
|
|
@@ -258,6 +279,17 @@ Your report scope is **`{{REPORT_SCOPE}}`**. Both scopes, spelled out:
|
|
|
258
279
|
issue you pulled off the queue, a cap that stopped the fleet. A tick where
|
|
259
280
|
nothing changed still says nothing — "no change" is not an event.
|
|
260
281
|
|
|
282
|
+
**Delivery.** Your end-of-turn text reaches your operator only on a turn that
|
|
283
|
+
*began* as an inbound Telegram message. A tick did not: it is injected locally,
|
|
284
|
+
so a report you merely write at the end of one is read by nobody, however well
|
|
285
|
+
you wrote it. On a tick, deliver every reportable event by explicitly calling
|
|
286
|
+
`telegram_send`, as plain text — Telegram renders none of your markdown, so
|
|
287
|
+
asterisks and backticks arrive as literal characters and a pasted section becomes
|
|
288
|
+
a wall. Never claim something was reported unless you made that call and saw it
|
|
289
|
+
succeed. And a `cancelled` or errored `telegram_ask` is a delivery failure, not
|
|
290
|
+
an answer: re-deliver it with `telegram_send`, or report the channel as broken.
|
|
291
|
+
It is never "asked once, no reply, dropped".
|
|
292
|
+
|
|
261
293
|
Neither scope licenses narration. No progress updates, no "checking the queue
|
|
262
294
|
now", no restating this brief back. Evidence, or silence.
|
|
263
295
|
|
package/src/briefs/worker.md
CHANGED
|
@@ -34,8 +34,15 @@ files are canonical; your priors are not.
|
|
|
34
34
|
|
|
35
35
|
## How to work
|
|
36
36
|
|
|
37
|
-
1. **Understand before editing
|
|
38
|
-
|
|
37
|
+
1. **Understand before editing — and ask the graph before you grep.** Your turns
|
|
38
|
+
are mostly spent finding code, not writing it, and running out of turns
|
|
39
|
+
mid-refactor loses the run. If code-graph MCP tools are mounted (a
|
|
40
|
+
`codebase-memory` server or similar), start there: list its indexed projects,
|
|
41
|
+
and query by **project name** — your worktree is a throwaway path the index
|
|
42
|
+
has never seen, so a cwd-based lookup finds nothing while the canonical
|
|
43
|
+
checkout's index has the whole call graph. Fall back to grep where the graph
|
|
44
|
+
is silent. Either way, trace the real flow end to end — every file the change
|
|
45
|
+
touches — and check the callers of any function you are about to change; the
|
|
39
46
|
smallest diff in the wrong place is a second bug.
|
|
40
47
|
2. **Follow existing patterns.** A second convention beside an existing one is a
|
|
41
48
|
defect. Reuse the helper that already exists rather than writing a sibling.
|
package/src/cli.ts
CHANGED
|
@@ -5,13 +5,26 @@
|
|
|
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";
|
|
8
|
+
import { closeSync, openSync, readFileSync, readSync, statSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
9
10
|
import { checkBrief, formatBriefStatus, writeMergedBrief } from "./brief-upgrade.ts";
|
|
10
|
-
import { findProject, loadConfig } from "./config.ts";
|
|
11
|
-
import { formatStatus, runDaemon, setPaused, statusSnapshot } from "./daemon.ts";
|
|
12
|
-
import {
|
|
11
|
+
import { findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
|
|
12
|
+
import { dbPath, formatStatus, runDaemon, setPaused, statusSnapshot } from "./daemon.ts";
|
|
13
|
+
import {
|
|
14
|
+
clearRecord,
|
|
15
|
+
DEFAULT_PORT,
|
|
16
|
+
healthCheck,
|
|
17
|
+
livingDaemon,
|
|
18
|
+
startDaemon,
|
|
19
|
+
stopDaemon,
|
|
20
|
+
writeRecord,
|
|
21
|
+
} from "./lifecycle.ts";
|
|
22
|
+
import { STALL_MARKER_FILE } from "./orchestrator-tick.ts";
|
|
13
23
|
import { briefPathForProject, renderBriefForProject, shippedBriefTemplate } from "./setup.ts";
|
|
24
|
+
import { LIVE_STATES, openStore } from "./store.ts";
|
|
25
|
+
import { makeTracker } from "./tracker/github.ts";
|
|
14
26
|
import type { ProjectConfig } from "./types.ts";
|
|
27
|
+
import { formatUnblock, unblockIssue } from "./unblock.ts";
|
|
15
28
|
|
|
16
29
|
const USAGE = `omp-conductor — dispatch ready issues to omp coding sessions
|
|
17
30
|
|
|
@@ -20,6 +33,8 @@ usage:
|
|
|
20
33
|
omp-conductor stop
|
|
21
34
|
omp-conductor restart [--port N] [--project NAME]
|
|
22
35
|
omp-conductor status [--project NAME]
|
|
36
|
+
omp-conductor tail <issue> [--project NAME]
|
|
37
|
+
omp-conductor unblock <issue> [--project NAME]
|
|
23
38
|
omp-conductor daemon [--once] [--port N] [--project NAME]
|
|
24
39
|
omp-conductor pause
|
|
25
40
|
omp-conductor resume
|
|
@@ -34,6 +49,16 @@ usage:
|
|
|
34
49
|
unless a flag overrides them.
|
|
35
50
|
status show pause state, caps, active runs, today's usage, and whether a
|
|
36
51
|
daemon is alive.
|
|
52
|
+
tail follow the newest run for <issue>: the worker's assistant text and
|
|
53
|
+
the tools it calls, printed as they land. Workers are sessions inside
|
|
54
|
+
the daemon rather than terminals, so this is the only way to watch
|
|
55
|
+
one live. Runs until Ctrl-C, or until the run has finished and its
|
|
56
|
+
transcript has stopped growing.
|
|
57
|
+
unblock clear <issue>'s blocked and failed labels so the next tick can claim
|
|
58
|
+
it again — the supported way back for an escalation you answered,
|
|
59
|
+
and why the brief's "never hand-edit a state label" rule can stay
|
|
60
|
+
absolute. Attempts already spent are kept: an answered block still
|
|
61
|
+
cost a worker.
|
|
37
62
|
daemon run the dispatch loop in the foreground; --once runs a single tick
|
|
38
63
|
and exits. This is what \`start\` launches.
|
|
39
64
|
pause stop claiming new work. The running daemon notices on its next tick.
|
|
@@ -49,9 +74,10 @@ usage:
|
|
|
49
74
|
help print this text (also --help, -h).
|
|
50
75
|
|
|
51
76
|
Pause is a flag file under the state directory, so it applies to every project
|
|
52
|
-
and survives a daemon restart.
|
|
53
|
-
|
|
54
|
-
|
|
77
|
+
and survives a daemon restart. A running daemon is tracked by a pidfile under
|
|
78
|
+
$OMP_CONDUCTOR_RUNTIME_DIR (default ~/.omp/run/daemons/omp-conductor), written
|
|
79
|
+
whether it was started in the background or in the foreground, and probed for
|
|
80
|
+
liveness on every read — a stale one never blocks a start.`;
|
|
55
81
|
|
|
56
82
|
/** Accepts both `--port 9000` and `--port=9000`; returns undefined when absent. */
|
|
57
83
|
function flag(argv: string[], name: string): string | undefined {
|
|
@@ -108,17 +134,248 @@ async function daemonSection(): Promise<string> {
|
|
|
108
134
|
].join("\n");
|
|
109
135
|
}
|
|
110
136
|
|
|
137
|
+
/**
|
|
138
|
+
* The orchestrator half, and the one thing `status` has ever known about the
|
|
139
|
+
* supervising session: the stall marker its heartbeat writes when its own
|
|
140
|
+
* prompts stop being consumed (see {@link STALL_MARKER_FILE}).
|
|
141
|
+
*
|
|
142
|
+
* The marker is written in the *session's* cwd, which this process has no way
|
|
143
|
+
* to discover — so this reads the state directory, on the reference deploy's
|
|
144
|
+
* convention that the orchestrator session runs from exactly there. That makes
|
|
145
|
+
* the reading one-directional: a line printed here is proof of a wedge, and no
|
|
146
|
+
* line is proof of nothing at all. On a fleet whose session lives elsewhere the
|
|
147
|
+
* check is simply inert, which is why it never prints a reassuring "healthy".
|
|
148
|
+
*/
|
|
149
|
+
function stallLine(): string | undefined {
|
|
150
|
+
let raw: string;
|
|
151
|
+
try {
|
|
152
|
+
raw = readFileSync(join(stateDir(), STALL_MARKER_FILE), "utf8").trim();
|
|
153
|
+
} catch {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
// "<ISO timestamp> <one-line diagnosis>". A file truncated by something else
|
|
157
|
+
// still gets reported: that the marker exists at all is the news.
|
|
158
|
+
const cut = raw.indexOf(" ");
|
|
159
|
+
const since = cut < 0 ? raw : raw.slice(0, cut);
|
|
160
|
+
const diagnosis = cut < 0 ? "" : ` — ${raw.slice(cut + 1)}`;
|
|
161
|
+
return `orchestrator STALLED since ${since === "" ? "an unrecorded time" : since}${diagnosis}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* `DaemonRecord.logFile` for a daemon nobody spawned. The field is required and
|
|
166
|
+
* `status` prints it, so it has to say something true: a foreground daemon
|
|
167
|
+
* opened no log of its own — whoever started it owns its stdout, be that
|
|
168
|
+
* systemd's journal, a terminal, or a pane.
|
|
169
|
+
*/
|
|
170
|
+
const FOREGROUND_LOG = "<inherited stdout — started in the foreground>";
|
|
171
|
+
|
|
172
|
+
/** How often `tail` re-stats the transcript it is following. */
|
|
173
|
+
const TAIL_POLL_MS = 1_000;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* How long the transcript must stay unchanged, after its run has left the live
|
|
177
|
+
* states, before `tail` calls it over. The state flips from the daemon's thread
|
|
178
|
+
* while the harness may still be flushing its last message, so exiting on the
|
|
179
|
+
* state alone truncates the ending an operator ran this command to watch.
|
|
180
|
+
*/
|
|
181
|
+
const TAIL_QUIET_MS = 5_000;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The `<issue>` positional, for the two verbs that take one. Exits 2 rather
|
|
185
|
+
* than following run #NaN or clearing the labels of issue #0; `verb` is named
|
|
186
|
+
* in the message so the operator is told which of the two they mistyped.
|
|
187
|
+
*/
|
|
188
|
+
function issueArg(verb: string, raw: string | undefined): number {
|
|
189
|
+
const issue = raw === undefined ? Number.NaN : Number.parseInt(raw.replace(/^#/, ""), 10);
|
|
190
|
+
if (!Number.isInteger(issue) || issue < 1) {
|
|
191
|
+
process.stderr.write(`omp-conductor: ${verb} needs an issue number, got "${raw ?? ""}"\n`);
|
|
192
|
+
process.exit(2);
|
|
193
|
+
}
|
|
194
|
+
return issue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Read one property off an unvalidated transcript entry. */
|
|
198
|
+
function prop(source: unknown, key: string): unknown {
|
|
199
|
+
if (source === null || typeof source !== "object") return undefined;
|
|
200
|
+
return Reflect.get(source, key);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* One transcript line rendered for somebody watching, or `undefined` for the
|
|
205
|
+
* lines not worth a row: thinking blocks, tool results, session metadata, and
|
|
206
|
+
* anything this parser does not recognise.
|
|
207
|
+
*
|
|
208
|
+
* Defensive throughout. The transcript is written by the harness, not by this
|
|
209
|
+
* package, so its shape is a peer dependency's business and can gain entry
|
|
210
|
+
* types without warning. A `tail` that dies on one unfamiliar line is strictly
|
|
211
|
+
* worse than one that skips it — the operator is watching a run they have no
|
|
212
|
+
* other window onto.
|
|
213
|
+
*/
|
|
214
|
+
function formatTranscriptLine(line: string): string | undefined {
|
|
215
|
+
let entry: unknown;
|
|
216
|
+
try {
|
|
217
|
+
entry = JSON.parse(line);
|
|
218
|
+
} catch {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
if (prop(entry, "type") !== "message") return undefined;
|
|
222
|
+
const message = prop(entry, "message");
|
|
223
|
+
if (prop(message, "role") !== "assistant") return undefined;
|
|
224
|
+
|
|
225
|
+
const content = prop(message, "content");
|
|
226
|
+
// The harness writes an array of blocks; a bare string is the degenerate form
|
|
227
|
+
// some sessions still produce, and dropping it would silently lose the text.
|
|
228
|
+
if (typeof content === "string") {
|
|
229
|
+
return content.trim() === "" ? undefined : `assistant: ${content.trim()}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const blocks: readonly unknown[] = Array.isArray(content) ? content : [];
|
|
233
|
+
const out: string[] = [];
|
|
234
|
+
for (const block of blocks) {
|
|
235
|
+
const type = prop(block, "type");
|
|
236
|
+
if (type === "text") {
|
|
237
|
+
const text = prop(block, "text");
|
|
238
|
+
if (typeof text === "string" && text.trim() !== "") out.push(`assistant: ${text.trim()}`);
|
|
239
|
+
} else if (type === "toolCall") {
|
|
240
|
+
const name = prop(block, "name");
|
|
241
|
+
if (typeof name === "string" && name !== "") out.push(`tool: ${name}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return out.length === 0 ? undefined : out.join("\n");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Follow one run's transcript the way `tail -f` follows a log.
|
|
249
|
+
*
|
|
250
|
+
* Reads from byte zero rather than from the end: attaching to a worker that is
|
|
251
|
+
* already ten turns in and then showing nothing until turn eleven is not
|
|
252
|
+
* watching the run. Polls `stat` instead of taking a file watcher because the
|
|
253
|
+
* transcript is a plain append-only file that may sit on a filesystem where
|
|
254
|
+
* change events are a polite fiction, and one stat a second costs nothing.
|
|
255
|
+
*
|
|
256
|
+
* SIGINT is deliberately left to its default, which is immediate exit. Nothing
|
|
257
|
+
* here is buffered, and a handler could only add a poll interval of latency to
|
|
258
|
+
* every Ctrl-C.
|
|
259
|
+
*/
|
|
260
|
+
async function tailRun(project: string, issue: number): Promise<void> {
|
|
261
|
+
// Read-only in practice: the store is opened WAL with a busy timeout, so this
|
|
262
|
+
// never contends with the daemon writing the same rows.
|
|
263
|
+
const store = openStore(dbPath());
|
|
264
|
+
try {
|
|
265
|
+
const run = store.latestRun(project, issue);
|
|
266
|
+
if (run === undefined) throw new Error(`no run recorded for #${issue}`);
|
|
267
|
+
const path = run.sessionFile;
|
|
268
|
+
// Claimed but not yet started, or an attempt whose session never opened one.
|
|
269
|
+
if (path === undefined) throw new Error(`no transcript yet (state: ${run.state})`);
|
|
270
|
+
|
|
271
|
+
const fd = openSync(path, "r");
|
|
272
|
+
try {
|
|
273
|
+
let offset = 0;
|
|
274
|
+
let pending = Buffer.alloc(0);
|
|
275
|
+
let lastChange = Date.now();
|
|
276
|
+
|
|
277
|
+
for (;;) {
|
|
278
|
+
let size = offset;
|
|
279
|
+
try {
|
|
280
|
+
size = statSync(path).size;
|
|
281
|
+
} catch {
|
|
282
|
+
// A transcript that vanishes mid-follow is not worth crashing over.
|
|
283
|
+
// The run's own state, below, is what decides when this command ends.
|
|
284
|
+
}
|
|
285
|
+
// Shorter than what we have already read means truncated or replaced;
|
|
286
|
+
// resuming from the old offset would read the middle of another file.
|
|
287
|
+
if (size < offset) {
|
|
288
|
+
offset = 0;
|
|
289
|
+
pending = Buffer.alloc(0);
|
|
290
|
+
}
|
|
291
|
+
if (size > offset) {
|
|
292
|
+
const chunk = Buffer.allocUnsafe(size - offset);
|
|
293
|
+
const read = readSync(fd, chunk, 0, chunk.length, offset);
|
|
294
|
+
offset += read;
|
|
295
|
+
// Split on newlines as bytes, not as text: a UTF-8 sequence straddling
|
|
296
|
+
// a read boundary would be mangled by decoding each chunk on its own.
|
|
297
|
+
pending = Buffer.concat([pending, chunk.subarray(0, read)]);
|
|
298
|
+
for (;;) {
|
|
299
|
+
const nl = pending.indexOf(0x0a);
|
|
300
|
+
if (nl < 0) break;
|
|
301
|
+
const rendered = formatTranscriptLine(pending.subarray(0, nl).toString("utf8"));
|
|
302
|
+
pending = pending.subarray(nl + 1);
|
|
303
|
+
if (rendered !== undefined) process.stdout.write(`${rendered}\n`);
|
|
304
|
+
}
|
|
305
|
+
if (read > 0) lastChange = Date.now();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Re-read this exact run every poll — not `latestRun`, which would jump
|
|
309
|
+
// to a retry started meanwhile and report its state against the wrong
|
|
310
|
+
// transcript. The daemon writes the row from another process, so looking
|
|
311
|
+
// is the only way to notice the run finished.
|
|
312
|
+
const state = store.getRun(run.id)?.state ?? run.state;
|
|
313
|
+
if (!LIVE_STATES.includes(state) && Date.now() - lastChange >= TAIL_QUIET_MS) {
|
|
314
|
+
process.stdout.write(`run ended: ${state}\n`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
await new Promise<void>((resolve) => setTimeout(resolve, TAIL_POLL_MS));
|
|
318
|
+
}
|
|
319
|
+
} finally {
|
|
320
|
+
closeSync(fd);
|
|
321
|
+
}
|
|
322
|
+
} finally {
|
|
323
|
+
store.close();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
111
327
|
const argv = process.argv.slice(2);
|
|
112
328
|
const cmd = argv[0];
|
|
113
329
|
|
|
114
330
|
try {
|
|
115
331
|
switch (cmd) {
|
|
116
332
|
case "daemon": {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
333
|
+
// Until now only `lifecycle.startDaemon()` — the spawn path — wrote the
|
|
334
|
+
// pidfile, which left a daemon started in the foreground (which is how
|
|
335
|
+
// systemd runs it) invisible twice over: `omp-conductor status` reported
|
|
336
|
+
// no daemon at all, and a `daemon --once` drill run beside it saw
|
|
337
|
+
// `livingDaemon() === undefined`, concluded nothing else was dispatching,
|
|
338
|
+
// and reconciled the live daemon's in-flight runs as orphans. Writing the
|
|
339
|
+
// record here closes both holes.
|
|
340
|
+
const once = argv.includes("--once");
|
|
341
|
+
const port = portFlag(argv);
|
|
342
|
+
const project = flag(argv, "project");
|
|
343
|
+
|
|
344
|
+
// `--once` registers nothing, on purpose. It is precisely the single-tick
|
|
345
|
+
// drill the orphan guard exists to protect, so a drill that announced
|
|
346
|
+
// itself as the daemon would be the process that misleads the next reader
|
|
347
|
+
// — and would clear the real daemon's record on its way out.
|
|
348
|
+
if (once) {
|
|
349
|
+
await runDaemon({ once, port, project });
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const running = livingDaemon();
|
|
354
|
+
if (running !== undefined && running.pid !== process.pid) {
|
|
355
|
+
process.stderr.write(`omp-conductor: another daemon is alive (pid ${running.pid}); stop it first\n`);
|
|
356
|
+
process.exit(1);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// A living record that already names this pid was written by the `start`
|
|
360
|
+
// that spawned us, and it knows the log file our stdout is really going
|
|
361
|
+
// to. Replacing it with a guess would be a downgrade.
|
|
362
|
+
if (running === undefined) {
|
|
363
|
+
writeRecord({
|
|
364
|
+
pid: process.pid,
|
|
365
|
+
port: port ?? DEFAULT_PORT,
|
|
366
|
+
startedAt: Date.now(),
|
|
367
|
+
logFile: FOREGROUND_LOG,
|
|
368
|
+
...(project === undefined ? {} : { project }),
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
await runDaemon({ once, port, project });
|
|
374
|
+
} finally {
|
|
375
|
+
// The record names a pid that is about to stop existing. Leaving it
|
|
376
|
+
// behind makes the next reader probe a ghost before believing us.
|
|
377
|
+
clearRecord();
|
|
378
|
+
}
|
|
122
379
|
break;
|
|
123
380
|
}
|
|
124
381
|
|
|
@@ -159,7 +416,28 @@ try {
|
|
|
159
416
|
|
|
160
417
|
case "status": {
|
|
161
418
|
const snapshot = formatStatus(statusSnapshot(flag(argv, "project")));
|
|
162
|
-
|
|
419
|
+
const stalled = stallLine();
|
|
420
|
+
process.stdout.write(`${snapshot}\n\n${await daemonSection()}\n${stalled === undefined ? "" : `\n${stalled}\n`}`);
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
case "tail": {
|
|
425
|
+
const issue = issueArg("tail", argv[1]);
|
|
426
|
+
await tailRun(findProject(loadConfig(), flag(argv, "project")).name, issue);
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
case "unblock": {
|
|
431
|
+
const issue = issueArg("unblock", argv[1]);
|
|
432
|
+
const cfg = loadConfig();
|
|
433
|
+
const project = findProject(cfg, flag(argv, "project"));
|
|
434
|
+
const store = openStore(dbPath());
|
|
435
|
+
try {
|
|
436
|
+
const outcome = await unblockIssue(project, makeTracker(project), store, issue);
|
|
437
|
+
process.stdout.write(`${formatUnblock(issue, outcome, project, resolveCaps(project, cfg.defaults))}\n`);
|
|
438
|
+
} finally {
|
|
439
|
+
store.close();
|
|
440
|
+
}
|
|
163
441
|
break;
|
|
164
442
|
}
|
|
165
443
|
|
package/src/config.ts
CHANGED
|
@@ -16,9 +16,12 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, wri
|
|
|
16
16
|
import { homedir } from "node:os";
|
|
17
17
|
import { dirname, join } from "node:path";
|
|
18
18
|
import {
|
|
19
|
+
AUTHORITY_HOLDERS,
|
|
19
20
|
CONFIG_VERSION,
|
|
21
|
+
DEFAULT_AUTHORITY,
|
|
20
22
|
DEFAULT_CAPS,
|
|
21
23
|
DEFAULT_REPORT_SCOPE,
|
|
24
|
+
ORCHESTRATOR_MODES,
|
|
22
25
|
READABLE_CONFIG_VERSIONS,
|
|
23
26
|
REPORT_SCOPES,
|
|
24
27
|
type Caps,
|
|
@@ -43,8 +46,10 @@ type Raw = { readonly [key: string]: unknown };
|
|
|
43
46
|
/** Derived from the data so a new `Caps` field cannot be silently ignored. */
|
|
44
47
|
const CAP_KEYS = Object.keys(DEFAULT_CAPS) as (keyof Caps)[];
|
|
45
48
|
|
|
46
|
-
/** Quoted for error messages, from the same data the
|
|
47
|
-
const REPORT_SCOPE_LIST = REPORT_SCOPES
|
|
49
|
+
/** Quoted for error messages, from the same data the guards below read. */
|
|
50
|
+
const REPORT_SCOPE_LIST = quoteList(REPORT_SCOPES);
|
|
51
|
+
const AUTHORITY_HOLDER_LIST = quoteList(AUTHORITY_HOLDERS);
|
|
52
|
+
const ORCHESTRATOR_MODE_LIST = quoteList(ORCHESTRATOR_MODES);
|
|
48
53
|
|
|
49
54
|
/** `owner/repo`, the only tracker spelling `gh` accepts without a host. */
|
|
50
55
|
const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
@@ -267,13 +272,8 @@ function normalizeProject(
|
|
|
267
272
|
|
|
268
273
|
const stateLabels = raw["stateLabels"] as Raw | undefined;
|
|
269
274
|
|
|
270
|
-
const
|
|
271
|
-
const
|
|
272
|
-
const escalation: ProjectConfig["escalation"] = {
|
|
273
|
-
// Absent means "yes, still tell me": a silently stuck run is the worst case.
|
|
274
|
-
fallbackToIssueComment: escalationIn?.["fallbackToIssueComment"] !== false,
|
|
275
|
-
};
|
|
276
|
-
if (nonEmptyString(chatId)) escalation.telegramChatId = chatId;
|
|
275
|
+
const escalation = normalizeEscalation(raw["escalation"], label, problems);
|
|
276
|
+
const authority = normalizeAuthority(raw["authority"], label, problems);
|
|
277
277
|
|
|
278
278
|
const caps = coerceCaps(raw["caps"], `${label}: caps`, problems, legacyCaps);
|
|
279
279
|
const reporting = normalizeReporting(raw["reporting"], label, problems);
|
|
@@ -298,6 +298,7 @@ function normalizeProject(
|
|
|
298
298
|
caps,
|
|
299
299
|
...(workerModel === undefined ? {} : { workerModel }),
|
|
300
300
|
escalation,
|
|
301
|
+
authority,
|
|
301
302
|
reporting,
|
|
302
303
|
workspaceRoot: expandHome(pickString(raw["workspaceRoot"], join(stateDir(), "worktrees"))),
|
|
303
304
|
mirrorRoot: expandHome(pickString(raw["mirrorRoot"], join(stateDir(), "mirrors"))),
|
|
@@ -326,14 +327,92 @@ function normalizeReporting(parsed: unknown, label: string, problems: string[]):
|
|
|
326
327
|
problems.push(`${label}: reporting has unknown key(s): ${unknownKeys.join(", ")}`);
|
|
327
328
|
}
|
|
328
329
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
330
|
+
return {
|
|
331
|
+
scope: pickLiteral(
|
|
332
|
+
raw["scope"],
|
|
333
|
+
REPORT_SCOPES,
|
|
334
|
+
DEFAULT_REPORT_SCOPE,
|
|
335
|
+
`${label}: reporting.scope`,
|
|
336
|
+
REPORT_SCOPE_LIST,
|
|
337
|
+
problems,
|
|
338
|
+
),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Who triages escalations, and how they are delivered when nobody answers.
|
|
344
|
+
*
|
|
345
|
+
* `orchestrator` is validated rather than folded to the default for the reason
|
|
346
|
+
* `reporting.scope` is: a misspelt `"externl"` that quietly resolved to
|
|
347
|
+
* `"embedded"` would start a second brain beside the operator's own session,
|
|
348
|
+
* and both of them would triage the same issue from different transcripts.
|
|
349
|
+
*/
|
|
350
|
+
function normalizeEscalation(parsed: unknown, label: string, problems: string[]): ProjectConfig["escalation"] {
|
|
351
|
+
let raw: Raw = {};
|
|
352
|
+
if (parsed !== undefined) {
|
|
353
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) raw = parsed as Raw;
|
|
354
|
+
else problems.push(`${label}: escalation must be an object`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const escalation: ProjectConfig["escalation"] = {
|
|
358
|
+
// Absent means "yes, still tell me": a silently stuck run is the worst case.
|
|
359
|
+
fallbackToIssueComment: raw["fallbackToIssueComment"] !== false,
|
|
360
|
+
orchestrator: pickLiteral(
|
|
361
|
+
raw["orchestrator"],
|
|
362
|
+
ORCHESTRATOR_MODES,
|
|
363
|
+
"embedded",
|
|
364
|
+
`${label}: escalation.orchestrator`,
|
|
365
|
+
ORCHESTRATOR_MODE_LIST,
|
|
366
|
+
problems,
|
|
367
|
+
),
|
|
368
|
+
};
|
|
369
|
+
const chatId = raw["telegramChatId"];
|
|
370
|
+
if (nonEmptyString(chatId)) escalation.telegramChatId = chatId;
|
|
371
|
+
return escalation;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Who lands PRs and who cuts releases. Both default to the human: this is the
|
|
376
|
+
* one config value that decides whether an unattended session may write to a
|
|
377
|
+
* main branch, so it is granted explicitly or not at all.
|
|
378
|
+
*
|
|
379
|
+
* Unknown keys are rejected outright, as in `reporting` and for the same
|
|
380
|
+
* reason: the object has exactly two members, so an unrecognised one is a typo
|
|
381
|
+
* every time — and a `authority: { merges: "orchestrator" }` that loaded
|
|
382
|
+
* cleanly would read as delegated while the orchestrator was still told to keep
|
|
383
|
+
* its hands off.
|
|
384
|
+
*/
|
|
385
|
+
function normalizeAuthority(parsed: unknown, label: string, problems: string[]): ProjectConfig["authority"] {
|
|
386
|
+
if (parsed === undefined) return { ...DEFAULT_AUTHORITY };
|
|
387
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
388
|
+
problems.push(`${label}: authority must be an object with "merge" and "release" of ${AUTHORITY_HOLDER_LIST}`);
|
|
389
|
+
return { ...DEFAULT_AUTHORITY };
|
|
335
390
|
}
|
|
336
|
-
|
|
391
|
+
const raw = parsed as Raw;
|
|
392
|
+
|
|
393
|
+
const unknownKeys = Object.keys(raw).filter((k) => k !== "merge" && k !== "release");
|
|
394
|
+
if (unknownKeys.length > 0) {
|
|
395
|
+
problems.push(`${label}: authority has unknown key(s): ${unknownKeys.join(", ")}`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
merge: pickLiteral(
|
|
400
|
+
raw["merge"],
|
|
401
|
+
AUTHORITY_HOLDERS,
|
|
402
|
+
DEFAULT_AUTHORITY.merge,
|
|
403
|
+
`${label}: authority.merge`,
|
|
404
|
+
AUTHORITY_HOLDER_LIST,
|
|
405
|
+
problems,
|
|
406
|
+
),
|
|
407
|
+
release: pickLiteral(
|
|
408
|
+
raw["release"],
|
|
409
|
+
AUTHORITY_HOLDERS,
|
|
410
|
+
DEFAULT_AUTHORITY.release,
|
|
411
|
+
`${label}: authority.release`,
|
|
412
|
+
AUTHORITY_HOLDER_LIST,
|
|
413
|
+
problems,
|
|
414
|
+
),
|
|
415
|
+
};
|
|
337
416
|
}
|
|
338
417
|
|
|
339
418
|
function normalizeRepos(parsed: unknown, label: string, problems: string[]): Record<string, RepoTarget> {
|
|
@@ -439,6 +518,38 @@ function pickString(v: unknown, fallback: string): string {
|
|
|
439
518
|
return nonEmptyString(v) ? v : fallback;
|
|
440
519
|
}
|
|
441
520
|
|
|
521
|
+
/** Quoted alternatives for an error message, from the same data the guard reads. */
|
|
522
|
+
function quoteList(values: readonly string[]): string {
|
|
523
|
+
return values.map((v) => `"${v}"`).join(" or ");
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* One rule for "a declared literal out of a closed set, else the documented
|
|
528
|
+
* default", used by every such field here.
|
|
529
|
+
*
|
|
530
|
+
* Absent takes the default silently; a value outside the set is always
|
|
531
|
+
* reported and never folded. Each of these sets decides something the operator
|
|
532
|
+
* would otherwise believe they had configured — who merges, who triages, how
|
|
533
|
+
* loud the fleet is — and a typo that resolves to the default reads exactly
|
|
534
|
+
* like a deliberate choice in the file afterwards.
|
|
535
|
+
*/
|
|
536
|
+
function pickLiteral<T extends string>(
|
|
537
|
+
v: unknown,
|
|
538
|
+
allowed: readonly T[],
|
|
539
|
+
fallback: T,
|
|
540
|
+
field: string,
|
|
541
|
+
quoted: string,
|
|
542
|
+
problems: string[],
|
|
543
|
+
): T {
|
|
544
|
+
if (v === undefined) return fallback;
|
|
545
|
+
const hit = allowed.find((a) => a === v);
|
|
546
|
+
if (hit === undefined) {
|
|
547
|
+
problems.push(`${field} must be ${quoted}, found ${JSON.stringify(v)}`);
|
|
548
|
+
return fallback;
|
|
549
|
+
}
|
|
550
|
+
return hit;
|
|
551
|
+
}
|
|
552
|
+
|
|
442
553
|
/** `~/x` in a hand-written config must not create a literal `~` directory. */
|
|
443
554
|
function expandHome(p: string): string {
|
|
444
555
|
if (p === "~") return homedir();
|