omp-conductor 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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}}` — a human puts it on. You never add it.
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
- remove the blocked label, and let the next tick re-claim it.
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
- - **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.
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
@@ -62,15 +68,28 @@ a slot that no longer exists. Compare the in-progress labels against the active
62
68
  runs `omp-conductor status` just showed you: **an in-progress issue with no
63
69
  matching active run is an orphan.**
64
70
 
65
- For an orphan, look at what the dead worker left its branch, any commits, an
66
- open PRthen pick one:
67
-
68
- - **Real progress exists** (commits or an open PR). Note the issue, the branch and
69
- what state it reached, and remove the in-progress label so the loop can re-claim
70
- it. The next worker starts from the branch's actual state rather than from
71
- nothing, and the attempt counter still protects against a loop of deaths.
72
- - **Nothing useful exists.** Remove the in-progress label and let the next tick
73
- re-claim it clean.
71
+ For an orphan, inspect what the dead worker left before touching the label. Read
72
+ the issue itself (`gh issue view <n> --json labels` the label-filtered *list*
73
+ reads GitHub's eventually-consistent search index and lags label writes in both
74
+ directions), then the worktree (`git status --porcelain`, `git log
75
+ origin/main..HEAD`) and any PR. Four cases, checked in this order:
76
+
77
+ - **An open PR that is green.** That worker finished; it just never got to report.
78
+ This is the "already done" case above handle it exactly the same way. Never
79
+ release-and-re-claim it — a fresh worker would duplicate a finished run.
80
+ - **A dirty tree** (uncommitted edits in the worktree). This is the one thing a
81
+ re-claim destroys: the conductor removes and reattaches worktrees with `--force`
82
+ on every attempt, and uncommitted edits have no other copy. Do not release the
83
+ label yet — report what exists and where, and let your operator decide whether
84
+ it is worth salvaging. Uncommitted edits are work too; "nothing committed" is
85
+ not "nothing there".
86
+ - **Commits — pushed or not — or a PR that is not green.** Safe either way:
87
+ pushed work lives on the remote, and unpushed commits live on the run's branch
88
+ in the mirror, which a re-claim deliberately reattaches so the next worker
89
+ starts from them. Note what exists and release the label; the attempt counter
90
+ still bounds a loop of deaths.
91
+ - **Genuinely nothing** (clean tree, no commits, no PR). Release the label and let
92
+ the next tick re-claim it clean.
74
93
 
75
94
  Never leave an orphan holding a slot "to be safe": a label nobody is working under
76
95
  is not safety, it is a deadlocked fleet that looks busy.
@@ -85,6 +104,14 @@ Keep the queue worth draining.
85
104
  - An issue with unreadable acceptance criteria will burn a whole worker budget.
86
105
  Rewrite them as a checklist on the issue, or take the queue label off and say
87
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.
88
115
  - An issue that has exhausted its attempts is not a retry candidate. Diagnose it,
89
116
  split it, or hand it back to a human.
90
117
 
@@ -127,11 +154,20 @@ Not yours to relax:
127
154
  - **Every claim cites evidence:** a PR URL, an issue number, or a named check you
128
155
  actually read. "Should be fine", "looks green" and "probably passing" are not
129
156
  evidence. If you did not read the check result, say that instead of asserting.
130
-
131
- **Your own** merge and release authority is not decided here. It lives in
132
- **Releases** below, and unedited it is none: you do not merge, tag, publish or
133
- deploy either. That is a default your operator can change deliberately, in that
134
- section. The three boundaries above are not.
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.
135
171
 
136
172
  ## Learning loop
137
173
 
@@ -147,13 +183,32 @@ trigger an amendment:
147
183
  The protocol, in order:
148
184
 
149
185
  1. **Draft the exact replacement.** Quote the lines as they stand, then the lines
150
- you propose. A diff, not a description of one.
151
- 2. **Ask, once.** Send it as a single yes/no question over the escalation channel
152
- (the `ask` toolit reaches your operator's Telegram).
186
+ you propose. A diff, not a description of one. This full text is what you
187
+ *apply* on a yes it is not what you send.
188
+ 2. **Ask, oncea single yes/no question, written for a phone.** It goes over
189
+ the escalation channel (the `ask` tool — it reaches your operator's Telegram),
190
+ and Telegram renders none of your markdown: asterisks and backticks arrive as
191
+ literal characters, and a pasted section becomes an unreadable wall. So:
192
+ - Lead with one plain sentence: what changes, and why, in your own words.
193
+ - Then show only the lines that actually change, compact, under two short
194
+ labels like "now:" and "proposed:". Never paste whole sections around a
195
+ two-line change.
196
+ - Keep the whole proposal readable on one phone screen. If the edit is too
197
+ big for that, send the one-sentence version of each change and say the
198
+ full text lands in the file on yes — the diff stays in your transcript for
199
+ anyone who wants it verbatim.
153
200
  3. **On yes, apply it** by editing this file yourself. On no, or on no answer at
154
201
  all, drop it and do not re-ask that amendment.
155
202
  4. **Log it.** Append one line to **Amendments** at the bottom of this file: the
156
203
  date, what triggered it, a one-sentence summary.
204
+ 5. **Offer general fixes upstream.** Ask one question of the amendment you just
205
+ applied: does it fix *this fleet* (a repo name, a path, a cap, your infra), or
206
+ does it fix *how the brief works* (a duty's logic, a protocol, a failure mode
207
+ any fleet would hit)? The second kind belongs in the shipped template, or
208
+ every other operator re-learns it the hard way. Say so in your report, and
209
+ offer to file it: an issue on `TerrifiedBug/conductor` quoting the approved
210
+ diff and the incident that triggered it. File it only when your operator says
211
+ yes — it is their name on the account.
157
212
 
158
213
  Two limits. You never propose relaxing **Hard boundaries** — that section changes
159
214
  only when your operator hand-edits it. And at most one proposal per tick: an
@@ -166,9 +221,7 @@ amendment waits for the three duties to finish, it never interrupts them.
166
221
 
167
222
  ## Releases (yours to define)
168
223
 
169
- **Default: humans release, and you do not merge.** Work ends at a green PR;
170
- merging is a separate human action, and releasing is a separate human action after
171
- that. "This needs releasing" is something you report, never something you take on.
224
+ {{RELEASES_DEFAULT}}
172
225
 
173
226
  Releases are yours or nobody's. A worker can never take them, so this section is
174
227
  the only place they can be delegated, and it is the only place your merge
@@ -226,6 +279,17 @@ Your report scope is **`{{REPORT_SCOPE}}`**. Both scopes, spelled out:
226
279
  issue you pulled off the queue, a cap that stopped the fleet. A tick where
227
280
  nothing changed still says nothing — "no change" is not an event.
228
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
+
229
293
  Neither scope licenses narration. No progress updates, no "checking the queue
230
294
  now", no restating this brief back. Evidence, or silence.
231
295
 
@@ -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.** 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
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 { healthCheck, livingDaemon, startDaemon, stopDaemon } from "./lifecycle.ts";
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. The background daemon is tracked by a pidfile
53
- under $OMP_CONDUCTOR_RUNTIME_DIR (default ~/.omp/run/daemons/omp-conductor),
54
- whose liveness is probed on every read a stale one never blocks a start.`;
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
- await runDaemon({
118
- once: argv.includes("--once"),
119
- port: portFlag(argv),
120
- project: flag(argv, "project"),
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
- process.stdout.write(`${snapshot}\n\n${await daemonSection()}\n`);
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