omp-conductor 0.3.7 → 0.3.11

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 CHANGED
@@ -355,8 +355,11 @@ a yes over Telegram, which is the same protocol it uses for any other amendment.
355
355
  omp-conductor daemon --once
356
356
  ```
357
357
 
358
- The loop ticks every 5 minutes. `omp-conductor stop` sends `SIGTERM`, and the loop
359
- shuts down after the current tick rather than mid-run.
358
+ The loop ticks every 5 minutes. `omp-conductor stop` shuts the loop down after
359
+ the current tick rather than mid-run. When the live process is the MainPID of
360
+ `omp-conductor.service`, stop goes through `systemctl stop` so a unit with
361
+ `Restart=on-failure` cannot bring it straight back; otherwise it is a raw
362
+ `SIGTERM` (then `SIGKILL` after 10 seconds).
360
363
 
361
364
  ## How one tick works
362
365
 
@@ -454,19 +457,48 @@ A `claimed` or `running` row is a promise that a worker process exists, and a
454
457
  daemon that just started knows that promise is broken: its workers died with the
455
458
  previous process. At startup — unless another daemon is alive, so a foreground
456
459
  `daemon --once` cannot orphan a running daemon's real workers — every such row is
457
- moved to `orphaned`, with a log line naming the issue, the attempt and the
458
- worktree. That frees the slots immediately; a fleet must never resume as
459
- deadlocked as it crashed.
460
-
461
- Only the rows change. The issue keeps `agent:in-progress` the label is the
462
- crash guard against double-dispatch — and deciding what the dead worker's remains
463
- are worth is the orchestrator's drain-duty judgement, spelled out in its brief:
464
- an open green PR goes to the merge path, a dirty tree is reported before anything
465
- destroys it (uncommitted edits have no other copy; unpushed *commits* are safe on
466
- the run's branch in the mirror, which a retry deliberately reattaches), and a
467
- clean orphan has its label released so the next tick re-claims it. Orphaned
468
- attempts still count toward `maxAttemptsPerIssue`, so a crash loop escalates
469
- instead of redispatching forever.
460
+ **salvaged first** (dirty tree `wip(#N): attempt N killed by a daemon restart
461
+ auto-salvaged` on the run's branch, same path as a turns-cap kill), then moved to
462
+ `orphaned`, with a log line naming the issue, the attempt and the worktree. That
463
+ frees the slots immediately; a fleet must never resume as deadlocked as it
464
+ crashed, and uncommitted edits must not wait for a human with `bun -e`.
465
+
466
+ Only the rows change after salvage. The issue keeps `agent:in-progress` the
467
+ label is the crash guard against double-dispatch and deciding what the dead
468
+ worker's remains are worth is the orchestrator's drain-duty judgement, spelled
469
+ out in its brief: an open green PR goes to the merge path, a salvaged sha is a
470
+ continuation hand-off, and a clean orphan has its label released so the next
471
+ tick re-claims it. Orphaned attempts still count toward `maxAttemptsPerIssue`,
472
+ so a crash loop escalates instead of redispatching forever.
473
+
474
+ ### Deploying a new package onto a busy fleet
475
+
476
+ `systemctl restart` / `omp-conductor restart` is safe for **work product** once
477
+ this version is installed: startup salvage commits dirty trees before orphaning
478
+ rows, and salvage rewrites the mirror's managed `info/exclude` to the package's
479
+ current list before `git add` so a narrowed ignore cannot hide deliverables.
480
+
481
+ It is still disruptive for **in-flight sessions** (the worker process dies; the
482
+ attempt is spent). Prefer draining when you can wait:
483
+
484
+ 1. `omp-conductor pause` — stop new claims; live workers finish.
485
+ 2. Wait until `omp-conductor status` shows `workers 0 / N` (no `deploy` hint line).
486
+ 3. Install the new package (`bun add -g omp-conductor@…`, `omp plugin install …`).
487
+ 4. `systemctl restart omp-conductor` (or `omp-conductor restart`).
488
+ 5. `omp-conductor resume` if you left it paused.
489
+
490
+ If you cannot wait:
491
+
492
+ 1. `omp-conductor pause` (optional but keeps new claims off during the swap).
493
+ 2. Install.
494
+ 3. Restart — salvage runs on boot for every live worktree, then rows go `orphaned`.
495
+ 4. Resume; the pane orchestrator triages `agent:in-progress` orphans (continuation
496
+ / merge / release label). Status prints a `deploy` line while live workers > 0
497
+ so you can see the risk before you restart.
498
+
499
+ Do **not** edit files under the running install and expect the daemon to keep
500
+ dispatching — the integrity tripwire pauses and pages. Install, then restart, so
501
+ the new process records a fresh baseline.
470
502
 
471
503
  ### What settles a green PR
472
504
 
@@ -496,6 +528,17 @@ merged and whose issues were all closed, through two daemon restarts — and bec
496
528
  the active set *is* the busy set, those three issues were permanently unclaimable.
497
529
  A status page that has stopped being evidence is worse than no status page.
498
530
 
531
+ ### Continuation runs
532
+
533
+ When a worktree is provisioned onto a branch that already exists in the mirror
534
+ (reattach after a prior attempt, orphan, or turns-cap auto-requeue), the worker
535
+ brief includes a **Continuation** section: read `git log` / `git diff` against
536
+ the default branch first, and do not recreate work already on the branch.
537
+
538
+ A **turns-cap kill with attempts remaining** salvages the tree, puts the queue
539
+ label back on, and skips the failed label so the next tick reclaims as a
540
+ continuation automatically.
541
+
499
542
  ### Branch names
500
543
 
501
544
  `<type>/<slug>`, where the type is `fix` when any label's last segment (after `:`
@@ -539,14 +582,14 @@ rest. `0` is a real value (a hard stop), not "unset".
539
582
  | Cap | Default | What it protects |
540
583
  | --- | --- | --- |
541
584
  | `maxConcurrentWorkers` | `2` | Parallel omp sessions. Two, because **CI runner slots, not model tokens, are the usual throughput ceiling** — a third worker would starve its own PR checks on a small self-hosted runner pool. Raise it only if you actually have the runners. |
542
- | `dailySpendUsd` | `25` | Rolling-day spend ceiling. The one cap that stops the fleet rather than deferring work. |
585
+ | `dailySpendUsd` | `25` | Rolling-day spend ceiling in USD, or `null` for no spend gate. `0` is a hard stop. Metered from assistant `usage.cost.total`. |
543
586
  | `workerMaxTurns` | `120` | Turn ceiling for one worker. Catches a session looping without converging. |
544
587
  | `workerWallClockMs` | `5400000` (90 minutes) | Wall-clock ceiling for one worker. A session that is merely stuck spends no turns, so turns alone cannot detect it. |
545
588
  | `maxAttemptsPerIssue` | `2` | Retries per issue before it escalates. One clean retry recovers from flaky CI; a third attempt almost always means the issue itself is underspecified. |
546
589
 
547
590
  Days are counted from **local midnight**, matching how a human reads "today".
548
591
 
549
- Hitting `dailySpendUsd` is not the same as hitting the other caps. A concurrency
592
+ Set `dailySpendUsd` to `null` (wizard: blank) for no money gate — turns and wall-clock still apply. Hitting a numeric `dailySpendUsd` is not the same as hitting the other caps. A concurrency
550
593
  limit simply defers work to a later tick. The spend cap **pauses the daemon and
551
594
  pages at Tier 2**: a loop that is burning money has to halt itself, because
552
595
  waiting for someone to notice tomorrow is how a runaway becomes expensive.
@@ -1065,9 +1108,9 @@ omp-conductor help
1065
1108
  | Command | Behaviour |
1066
1109
  | --- | --- |
1067
1110
  | `start` | Spawn the loop in the background, detached, and wait until it answers `GET /healthz` on `:8787`. Refuses if one is already live, naming its pid. If the process dies or never serves, `start` cleans up after it and quotes the tail of `daemon.log`. |
1068
- | `stop` | `SIGTERM`, then `SIGKILL` after a 10-second grace period. Prints `not running` when there is nothing to stop. |
1069
- | `restart` | `stop` then `start`, inheriting the running daemon's port and project unless a flag overrides them a restart that quietly moved to the default port would leave every existing health check pointing at nothing. |
1070
- | `status [--project NAME]` | Pause state, config and state paths, resolved caps, active runs and today's usage, plus a `daemon` block: pid, uptime, port, project, `/healthz` result and log path. A `.conductor-stalled` marker in the state directory adds an `orchestrator STALLED since …` line — see [the stall marker](#a-wedged-session-and-the-marker-that-notices). Reads while a daemon in another process writes. |
1111
+ | `stop` | Prefer `systemctl stop omp-conductor.service` when that unit's MainPID is the live daemon — systemd then owns the stop and will not schedule a restart for the exit it just requested. Otherwise `SIGTERM`, then `SIGKILL` after a 10-second grace period. Prints `not running` when there is nothing to stop, and tags the confirmation with `(via systemctl)` when the unit path was used. |
1112
+ | `restart` | Prefer `systemctl restart` when the unit owns the live pid so the replacement stays supervised; otherwise `stop` then `start`, inheriting the running daemon's port and project unless a flag overrides them. The new process **salvages dirty live worktrees before orphaning** those rows see [Deploying a new package onto a busy fleet](#deploying-a-new-package-onto-a-busy-fleet). |
1113
+ | `status [--project NAME]` | Pause state, config and state paths, resolved caps, active runs and today's usage, plus a `daemon` block: pid, uptime, port, project, `/healthz` result and log path. While live workers > 0, prints a `deploy` line naming the count so a busy restart is visible before you take it. A `.conductor-stalled` marker in the state directory adds an `orchestrator STALLED since …` line — see [the stall marker](#a-wedged-session-and-the-marker-that-notices). Reads while a daemon in another process writes. |
1071
1114
  | `tail <issue>` | Follow the newest run for that issue: the worker's assistant text as `assistant: …` and each tool it calls as `tool: <name>`, printed as they land. Workers are omp sessions inside the daemon rather than terminals, so this is the only way to watch one live — a herdr pane running it becomes an observation window. Starts from the top of the transcript, not the end, so attaching to a run that is already ten turns in shows those ten turns. Exits `1` with `no run recorded for #N` when the issue has never been dispatched, or `no transcript yet (state: …)` when the attempt has not opened one. Otherwise it runs until `Ctrl-C`, or until the run has finished and its transcript has been silent for five seconds, and prints `run ended: <state>`. |
1072
1115
  | `unblock <issue>` | Remove that issue's `blocked` and `failed` state labels through the tracker, so the next tick can claim it again. This is the supported way back for an escalation you answered: eligibility disqualifies any issue carrying a state label, so an answered issue that keeps one is never re-claimed and the answer is inert. Removing a label the issue does not carry is a no-op, so both are always cleared and neither has to be looked up first. `agent:in-progress` is deliberately not touched — it means a worker process exists, which is not something an answer changes. The run history is left exactly as it is: an answered block still spent a worker, so it still counts toward `maxAttemptsPerIssue`, and the output says how many attempts remain — or warns that the next tick will escalate instead of dispatching, when none do. Exits `2` with `unblock needs an issue number` on a missing or malformed positional. |
1073
1116
  | `daemon` | Run the loop in the **foreground**, ticking every 5 minutes and serving `/healthz`. This is what `start` launches, and what a systemd unit should call. Writes the pidfile itself, and refuses with `another daemon is alive (pid N); stop it first` rather than becoming a second dispatcher. |
@@ -1187,10 +1230,14 @@ Known and deliberate in this version:
1187
1230
  retried rather than corrupted.
1188
1231
  - **Mirrors grow one branch ref per run.** Unpushed work is never discarded, so
1189
1232
  refs accumulate until you reap them.
1190
- - **`stop` is a deadline, not a clean drain.** `SIGTERM` asks the loop to finish the
1191
- tick it is on, and a tick with a worker in flight can run for that worker's whole
1192
- wall clock; after 10 seconds it is `SIGKILL`. There is no "stop once the current
1193
- worker lands".
1233
+ - **`stop` is a deadline, not a clean drain.** Whether it goes through
1234
+ `systemctl` or a raw signal, the loop is asked to finish the tick it is on, and
1235
+ a tick with a worker in flight can run for that worker's whole wall clock; the
1236
+ signal path escalates to `SIGKILL` after 10 seconds. There is no "stop once the
1237
+ current worker lands". A unit that supervises the daemon should set
1238
+ `SuccessExitStatus=0 143` (belt-and-braces for a handled `SIGTERM` exit) and
1239
+ operators should prefer `omp-conductor stop` / `systemctl stop` over a raw
1240
+ `kill`, so `Restart=on-failure` cannot misread a deliberate stop as a crash.
1194
1241
  - **A failed orchestrator degrades quietly.** The daemon logs a warning and keeps
1195
1242
  running, but tier-1 escalations then land in issue comments — which is exactly the
1196
1243
  "nobody reads it until morning" path the orchestrator exists to avoid. The warning
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.3.7",
3
+ "version": "0.3.11",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
@@ -18,9 +18,11 @@ written out as the one you chose — ask an omp session to read
18
18
 
19
19
  ---
20
20
 
21
- You are the orchestrator for **{{PROJECT}}**. You do not write product code and
22
- you do not touch a worker's branch. You keep the queue moving, and you are the
23
- first responder when a worker gets stuck.
21
+ You are the orchestrator for **{{PROJECT}}**. You do not write product code
22
+ **Hard boundaries** below names the exact acts that are out (checkout / commit /
23
+ push inside a worker's worktree; inventing a commit identity) and the one that
24
+ is in (`gh pr update-branch` for a green PR that fell behind). You keep the
25
+ queue moving, and you are the first responder when a worker gets stuck.
24
26
 
25
27
  You are prompted on a timer. Each tick: do the three duties below, then stop.
26
28
 
@@ -100,8 +102,11 @@ checked in this order:
100
102
  - **Commits — pushed or not — or a PR that is not green.** Safe either way:
101
103
  pushed work lives on the remote, and unpushed commits live on the run's branch
102
104
  in the mirror, which a re-claim deliberately reattaches so the next worker
103
- starts from them. Note what exists and release the label; the attempt counter
104
- still bounds a loop of deaths.
105
+ starts from them with a **continuation brief** (read the log/diff first; do
106
+ not recreate existing work). Note what exists and release the label; the
107
+ attempt counter still bounds a loop of deaths. A turns-cap kill with attempts
108
+ left is re-queued automatically by the daemon — you should still notice it on
109
+ drain, but you do not have to invent the continuation prompt.
105
110
  - **Genuinely nothing** (clean tree, no commits, no PR). Release the label and let
106
111
  the next tick re-claim it clean.
107
112
 
@@ -222,8 +227,12 @@ The protocol, in order:
222
227
  big for that, send the one-sentence version of each change and say the
223
228
  full text lands in the file on yes — the diff stays in your transcript for
224
229
  anyone who wants it verbatim.
225
- 3. **On yes, apply it** by editing this file yourself. On no, or on no answer at
226
- all, drop it and do not re-ask that amendment.
230
+ 3. **On yes, apply it** by editing this file yourself. **On explicit no, drop
231
+ it** forever and do not re-ask that amendment. **On cancel, timeout, or no
232
+ answer**, park it — that means "not now", not "never": mention it once in the
233
+ next report as `pending amendment: <one-liner> — say 'apply it' or 'drop it'`,
234
+ never re-open the yes/no dialog, and drop it if still unanswered after 7 days.
235
+ A cancelled dialog is not a permanent rejection.
227
236
  4. **Log it.** Append one line to **Amendments** at the bottom of this file: the
228
237
  date, what triggered it, a one-sentence summary.
229
238
  5. **Offer general fixes upstream.** Ask one question of the amendment you just
@@ -28,7 +28,7 @@ Then read the repo's own guidance before writing anything: `AGENTS.md`,
28
28
  `CLAUDE.md`, `CONTEXT.md`, and any `docs/adr/` entry the issue touches. Those
29
29
  files are canonical; your priors are not.
30
30
 
31
- ## Acceptance criteria
31
+ {{CONTINUATION}}## Acceptance criteria
32
32
 
33
33
  {{ACCEPTANCE_CRITERIA}}
34
34
 
package/src/cli.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  DEFAULT_PORT,
17
17
  healthCheck,
18
18
  livingDaemon,
19
+ restartDaemon,
19
20
  startDaemon,
20
21
  stopDaemon,
21
22
  writeRecord,
@@ -46,9 +47,14 @@ usage:
46
47
  start run the dispatch loop in the background and wait until it answers
47
48
  GET /healthz on :8787 (override with --port). Refuses if one is
48
49
  already running.
49
- stop signal the running daemon and wait for it to exit.
50
+ stop stop the running daemon. Uses systemctl when the omp-conductor
51
+ unit owns the process (so Restart=on-failure cannot bring it back);
52
+ otherwise SIGTERM then SIGKILL.
50
53
  restart stop then start, keeping the running daemon's port and project
51
- unless a flag overrides them.
54
+ unless a flag overrides them. On boot the new process salvages any
55
+ dirty live worktrees before orphaning those rows — see README
56
+ "Deploying a new package onto a busy fleet". Goes through systemctl
57
+ when the unit owns the live pid.
52
58
  status show pause state, caps, active runs, today's usage, and whether a
53
59
  daemon is alive.
54
60
  tail follow the newest run for <issue>: the worker's assistant text and
@@ -398,27 +404,35 @@ try {
398
404
  }
399
405
 
400
406
  case "stop": {
401
- // Read the pid before it stops existing, so the confirmation can name it.
402
- const pid = livingDaemon()?.pid;
403
407
  const result = await stopDaemon();
404
- process.stdout.write(result === "stopped" ? `stopped — pid ${pid ?? "?"}\n` : "not running\n");
408
+ if (result.kind === "not-running") {
409
+ process.stdout.write("not running\n");
410
+ } else {
411
+ process.stdout.write(
412
+ `stopped — pid ${result.pid}${result.via === "systemctl" ? " (via systemctl)" : ""}\n`,
413
+ );
414
+ }
405
415
  break;
406
416
  }
407
417
 
408
418
  case "restart": {
409
419
  // Inherit the running daemon's port and project: a restart that quietly
410
420
  // moved to the default port would leave every existing health check
411
- // pointing at nothing.
412
- const previous = livingDaemon();
413
- const result = await stopDaemon();
414
- if (result === "stopped") process.stdout.write(`stopped — pid ${previous?.pid ?? "?"}\n`);
415
- const rec = await startDaemon({
416
- port: portFlag(argv) ?? previous?.port,
417
- project: flag(argv, "project") ?? previous?.project,
421
+ // pointing at nothing. When the unit owns the live pid, restartDaemon
422
+ // goes through systemctl so the replacement stays supervised.
423
+ const { previous, record, via } = await restartDaemon({
424
+ port: portFlag(argv),
425
+ project: flag(argv, "project"),
418
426
  });
427
+ if (previous !== undefined) {
428
+ process.stdout.write(
429
+ `stopped — pid ${previous.pid}${via === "systemctl" ? " (via systemctl)" : ""}\n`,
430
+ );
431
+ }
419
432
  process.stdout.write(
420
- `started — pid ${rec.pid}, /healthz on :${rec.port}` +
421
- `${rec.project === undefined ? "" : `, project ${rec.project}`}\nlog ${rec.logFile}\n`,
433
+ `started — pid ${record.pid}, /healthz on :${record.port}` +
434
+ `${record.project === undefined ? "" : `, project ${record.project}`}` +
435
+ `${via === "systemctl" ? " (via systemctl)" : ""}\nlog ${record.logFile}\n`,
422
436
  );
423
437
  break;
424
438
  }
package/src/config.ts CHANGED
@@ -139,15 +139,16 @@ export function saveConfig(c: ConductorConfig): void {
139
139
  /**
140
140
  * Layers a project's overrides on the global defaults, field by field, so a
141
141
  * project that pins one cap still inherits the other five. `??` not `||`: a
142
- * deliberate `dailySpendUsd: 0` is a hard stop, not "unset". Spelled out per
143
- * field so adding a `Caps` member fails to compile here instead of resolving
144
- * to `undefined` at a call site.
142
+ * deliberate `dailySpendUsd: 0` is a hard stop, not "unset", and `null` is a
143
+ * deliberate "no spend gate" that must not fall through to the default.
144
+ * Spelled out per field so adding a `Caps` member fails to compile here.
145
145
  */
146
146
  export function resolveCaps(p: ProjectConfig, defaults: Caps): Caps {
147
147
  const o: Partial<Caps> = p.caps ?? {};
148
148
  return {
149
149
  maxConcurrentWorkers: o.maxConcurrentWorkers ?? defaults.maxConcurrentWorkers,
150
- dailySpendUsd: o.dailySpendUsd ?? defaults.dailySpendUsd,
150
+ // nullish only — `null` means off; do not coalesce it to the default.
151
+ dailySpendUsd: o.dailySpendUsd !== undefined ? o.dailySpendUsd : defaults.dailySpendUsd,
151
152
  workerMaxTurns: o.workerMaxTurns ?? defaults.workerMaxTurns,
152
153
  workerWallClockMs: o.workerWallClockMs ?? defaults.workerWallClockMs,
153
154
  maxAttemptsPerIssue: o.maxAttemptsPerIssue ?? defaults.maxAttemptsPerIssue,
@@ -520,8 +521,18 @@ function coerceCaps(parsed: unknown, label: string, problems: string[], legacy:
520
521
  for (const key of CAP_KEYS) {
521
522
  const v = raw[key];
522
523
  if (v === undefined) continue;
524
+ // Spend is the only cap that may be null (= no gate). Every other ceiling
525
+ // is a non-negative number; 0 remains a hard stop where it already was.
526
+ if (key === "dailySpendUsd" && v === null) {
527
+ out.dailySpendUsd = null;
528
+ continue;
529
+ }
523
530
  if (typeof v !== "number" || !Number.isFinite(v) || v < 0) {
524
- problems.push(`${label}.${key} must be a non-negative finite number, found ${JSON.stringify(v)}`);
531
+ problems.push(
532
+ key === "dailySpendUsd"
533
+ ? `${label}.${key} must be a non-negative finite number or null (no cap), found ${JSON.stringify(v)}`
534
+ : `${label}.${key} must be a non-negative finite number, found ${JSON.stringify(v)}`,
535
+ );
525
536
  continue;
526
537
  }
527
538
  out[key] = v;