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.
- package/README.md +238 -44
- package/package.json +1 -1
- package/skills/conductor-onboarding/SKILL.md +33 -22
- package/src/briefs/orchestrator.md +89 -25
- package/src/briefs/worker.md +9 -2
- package/src/cli.ts +291 -13
- package/src/config.ts +127 -16
- package/src/daemon.ts +520 -54
- 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 +41 -4
- package/src/tracker/github.ts +94 -2
- package/src/types.ts +62 -4
- 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
|
|
@@ -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,
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
re-claim it
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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, once — a 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
|
-
|
|
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
|
|
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
|
|