omp-conductor 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -317,10 +317,14 @@ Per tick, for the daemon's project:
317
317
  the configured repo names. These are never dispatched.
318
318
  5. **Check spend.** If spend since local midnight has reached `dailySpendUsd`, the
319
319
  daemon **pauses itself**, pages at Tier 2, and returns.
320
- 6. **Check capacity.** `maxConcurrentWorkers` minus active runs gives the free
321
- slots. If none are free, the tick logs and returns.
320
+ 6. **Check capacity.** `maxConcurrentWorkers` minus *live* workers (runs in
321
+ `claimed` or `running`) gives the free slots. A green PR awaiting a human
322
+ merge occupies its issue but not a slot: its worker is finished, and counting
323
+ it would let two green PRs stop the fleet. If no slot is free, the tick logs
324
+ and returns.
322
325
  7. **Admit issues** up to the free slots, skipping any issue that already has
323
- an active run. An issue that has used `maxAttemptsPerIssue` escalates at Tier 1
326
+ an active run including a green PR, so a second attempt can never land on a
327
+ live PR. An issue that has used `maxAttemptsPerIssue` escalates at Tier 1
324
328
  instead of being admitted.
325
329
  8. **Dispatch** the admitted issues concurrently.
326
330
 
@@ -330,9 +334,9 @@ Then, per admitted issue:
330
334
  This ordering is the whole crash-safety story: the label, not the local
331
335
  database, is the guard against dispatching the same issue twice. If the process
332
336
  dies at any later point, the next daemon sees the label, eligibility filters the
333
- issue out, and a human decides what to do with the orphan. A store that is lost
334
- can be rebuilt from the tracker; a label that was written too late cannot undo a
335
- duplicate PR.
337
+ issue out, and the orchestrator's drain duty triages the orphan (see below). A
338
+ store that is lost can be rebuilt from the tracker; a label that was written too
339
+ late cannot undo a duplicate PR.
336
340
  2. Create the run row (`claimed`).
337
341
  3. Clear any stale tree for this issue, then add a fresh worktree at
338
342
  `<workspaceRoot>/<issue>` cut from the bare mirror at `<mirrorRoot>/<repo>.git`,
@@ -354,6 +358,26 @@ Then, per admitted issue:
354
358
  leaves a window in which the issue carries no state label at all, which is
355
359
  exactly the shape eligibility reads as fresh work.
356
360
 
361
+ ### What a restart does to runs that were in flight
362
+
363
+ A `claimed` or `running` row is a promise that a worker process exists, and a
364
+ daemon that just started knows that promise is broken: its workers died with the
365
+ previous process. At startup — unless another daemon is alive, so a foreground
366
+ `daemon --once` cannot orphan a running daemon's real workers — every such row is
367
+ moved to `orphaned`, with a log line naming the issue, the attempt and the
368
+ worktree. That frees the slots immediately; a fleet must never resume as
369
+ deadlocked as it crashed.
370
+
371
+ Only the rows change. The issue keeps `agent:in-progress` — the label is the
372
+ crash guard against double-dispatch — and deciding what the dead worker's remains
373
+ are worth is the orchestrator's drain-duty judgement, spelled out in its brief:
374
+ an open green PR goes to the merge path, a dirty tree is reported before anything
375
+ destroys it (uncommitted edits have no other copy; unpushed *commits* are safe on
376
+ the run's branch in the mirror, which a retry deliberately reattaches), and a
377
+ clean orphan has its label released so the next tick re-claims it. Orphaned
378
+ attempts still count toward `maxAttemptsPerIssue`, so a crash loop escalates
379
+ instead of redispatching forever.
380
+
357
381
  ### Branch names
358
382
 
359
383
  `<type>/<slug>`, where the type is `fix` when any label's last segment (after `:`
@@ -766,10 +790,10 @@ Known and deliberate in this version:
766
790
  (the orchestrator's) no matter how many workers are running, and no amount of
767
791
  `maxConcurrentWorkers` changes that.
768
792
 
769
- The cap does work. The admission loop (`src/daemon.ts:405-445`) computes
770
- `slots = maxConcurrentWorkers - active runs`, admits at most that many issues per
771
- tick, and dispatches them together. To see them, read `omp-conductor status`,
772
- which lists every active run, or follow `daemon.log`.
793
+ The cap does work. The admission loop (`src/daemon.ts:410-448`) computes
794
+ `slots = maxConcurrentWorkers - live workers`, admits at most that many issues
795
+ per tick, and dispatches them together. To see them, read `omp-conductor
796
+ status`, which lists every occupied issue, or follow `daemon.log`.
773
797
  - **Merges, releases and deploys are human-only, by design.** The conductor
774
798
  produces green PRs and stops.
775
799
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
@@ -62,15 +62,28 @@ a slot that no longer exists. Compare the in-progress labels against the active
62
62
  runs `omp-conductor status` just showed you: **an in-progress issue with no
63
63
  matching active run is an orphan.**
64
64
 
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.
65
+ For an orphan, inspect what the dead worker left before touching the label. Read
66
+ the issue itself (`gh issue view <n> --json labels` the label-filtered *list*
67
+ reads GitHub's eventually-consistent search index and lags label writes in both
68
+ directions), then the worktree (`git status --porcelain`, `git log
69
+ origin/main..HEAD`) and any PR. Four cases, checked in this order:
70
+
71
+ - **An open PR that is green.** That worker finished; it just never got to report.
72
+ This is the "already done" case above: note it with the link and move on. Never
73
+ release-and-re-claim it — a fresh worker would duplicate a finished run.
74
+ - **A dirty tree** (uncommitted edits in the worktree). This is the one thing a
75
+ re-claim destroys: the conductor removes and reattaches worktrees with `--force`
76
+ on every attempt, and uncommitted edits have no other copy. Do not release the
77
+ label yet — report what exists and where, and let your operator decide whether
78
+ it is worth salvaging. Uncommitted edits are work too; "nothing committed" is
79
+ not "nothing there".
80
+ - **Commits — pushed or not — or a PR that is not green.** Safe either way:
81
+ pushed work lives on the remote, and unpushed commits live on the run's branch
82
+ in the mirror, which a re-claim deliberately reattaches so the next worker
83
+ starts from them. Note what exists and release the label; the attempt counter
84
+ still bounds a loop of deaths.
85
+ - **Genuinely nothing** (clean tree, no commits, no PR). Release the label and let
86
+ the next tick re-claim it clean.
74
87
 
75
88
  Never leave an orphan holding a slot "to be safe": a label nobody is working under
76
89
  is not safety, it is a deadlocked fleet that looks busy.
@@ -147,13 +160,32 @@ trigger an amendment:
147
160
  The protocol, in order:
148
161
 
149
162
  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).
163
+ you propose. A diff, not a description of one. This full text is what you
164
+ *apply* on a yes it is not what you send.
165
+ 2. **Ask, oncea single yes/no question, written for a phone.** It goes over
166
+ the escalation channel (the `ask` tool — it reaches your operator's Telegram),
167
+ and Telegram renders none of your markdown: asterisks and backticks arrive as
168
+ literal characters, and a pasted section becomes an unreadable wall. So:
169
+ - Lead with one plain sentence: what changes, and why, in your own words.
170
+ - Then show only the lines that actually change, compact, under two short
171
+ labels like "now:" and "proposed:". Never paste whole sections around a
172
+ two-line change.
173
+ - Keep the whole proposal readable on one phone screen. If the edit is too
174
+ big for that, send the one-sentence version of each change and say the
175
+ full text lands in the file on yes — the diff stays in your transcript for
176
+ anyone who wants it verbatim.
153
177
  3. **On yes, apply it** by editing this file yourself. On no, or on no answer at
154
178
  all, drop it and do not re-ask that amendment.
155
179
  4. **Log it.** Append one line to **Amendments** at the bottom of this file: the
156
180
  date, what triggered it, a one-sentence summary.
181
+ 5. **Offer general fixes upstream.** Ask one question of the amendment you just
182
+ applied: does it fix *this fleet* (a repo name, a path, a cap, your infra), or
183
+ does it fix *how the brief works* (a duty's logic, a protocol, a failure mode
184
+ any fleet would hit)? The second kind belongs in the shipped template, or
185
+ every other operator re-learns it the hard way. Say so in your report, and
186
+ offer to file it: an issue on `TerrifiedBug/conductor` quoting the approved
187
+ diff and the incident that triggered it. File it only when your operator says
188
+ yes — it is their name on the account.
157
189
 
158
190
  Two limits. You never propose relaxing **Hard boundaries** — that section changes
159
191
  only when your operator hand-edits it. And at most one proposal per tick: an
package/src/daemon.ts CHANGED
@@ -11,6 +11,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
11
11
  import { dirname, join } from "node:path";
12
12
  import { configPath, findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
13
13
  import { createEscalator } from "./escalate.ts";
14
+ import { livingDaemon } from "./lifecycle.ts";
14
15
  import { startOrchestrator } from "./orchestrator.ts";
15
16
  import type { OrchestratorHandle } from "./orchestrator.ts";
16
17
  import { branchName, route } from "./routing.ts";
@@ -402,16 +403,18 @@ async function tick(d: Deps): Promise<void> {
402
403
  return;
403
404
  }
404
405
 
405
- const active = store.activeRuns(project.name);
406
- const slots = caps.maxConcurrentWorkers - active.length;
406
+ // Two different questions, deliberately two queries. Capacity counts worker
407
+ // *processes*, so a green PR awaiting a human merge must not consume a slot —
408
+ // two of those would otherwise stop the fleet. The busy set protects *issues*,
409
+ // so that same green PR must be in it, or a second attempt lands on a live PR.
410
+ const live = store.liveRuns(project.name);
411
+ const slots = caps.maxConcurrentWorkers - live.length;
407
412
  if (slots <= 0) {
408
- log(`at capacity: ${active.length}/${caps.maxConcurrentWorkers} workers`);
413
+ log(`at capacity: ${live.length}/${caps.maxConcurrentWorkers} workers`);
409
414
  return;
410
415
  }
411
416
 
412
- // activeRuns includes pushed-green work that is still waiting on a human
413
- // merge, so this also stops a second attempt landing on a live PR.
414
- const busy = new Set(active.map((r) => r.issue));
417
+ const busy = new Set(store.activeRuns(project.name).map((r) => r.issue));
415
418
 
416
419
  const admitted: { r: Routed; attempt: number }[] = [];
417
420
  for (const r of routed) {
@@ -453,7 +456,10 @@ export interface StatusSnapshot {
453
456
  stateDir: string;
454
457
  paused: boolean;
455
458
  caps: Caps;
459
+ /** Occupied issues: live workers plus green PRs awaiting a human merge. */
456
460
  activeRuns: RunRecord[];
461
+ /** Runs backed by a worker process — the number capacity compares against. */
462
+ liveWorkers: number;
457
463
  runsToday: number;
458
464
  spendTodayUsd: number;
459
465
  }
@@ -474,6 +480,7 @@ export function statusSnapshot(project?: string): StatusSnapshot {
474
480
  paused: isPaused(),
475
481
  caps: resolveCaps(p, cfg.defaults),
476
482
  activeRuns: store.activeRuns(p.name),
483
+ liveWorkers: store.liveRuns(p.name).length,
477
484
  runsToday: store.runsStartedSince(p.name, since),
478
485
  spendTodayUsd: store.spendSince(p.name, since),
479
486
  };
@@ -489,7 +496,7 @@ export function formatStatus(s: StatusSnapshot): string {
489
496
  `state ${s.stateDir}`,
490
497
  "",
491
498
  "caps",
492
- ` workers ${s.activeRuns.length} / ${s.caps.maxConcurrentWorkers}`,
499
+ ` workers ${s.liveWorkers} / ${s.caps.maxConcurrentWorkers}`,
493
500
  ` issues today ${s.runsToday}`,
494
501
  ` spend today $${s.spendTodayUsd.toFixed(2)} / $${s.caps.dailySpendUsd.toFixed(2)}`,
495
502
  ` worker max turns ${s.caps.workerMaxTurns}`,
@@ -564,6 +571,38 @@ export function armConductor(): void {
564
571
  setPaused(false);
565
572
  }
566
573
 
574
+ /**
575
+ * Settles the runs a previous daemon process left in flight.
576
+ *
577
+ * A `claimed` or `running` row is a promise that a worker exists in *some*
578
+ * process. This is called from a freshly started daemon, so when no other
579
+ * daemon is alive every such row is a worker that died with the previous
580
+ * process. Left "active", those rows deadlock admission forever: the slot
581
+ * count reads full while nothing runs, and the fleet looks busy doing nothing
582
+ * (found live, after a host restart killed two workers mid-run).
583
+ *
584
+ * Only the rows change. The issue keeps its in-progress label — that label is
585
+ * the crash guard against double-dispatch, and deciding what a dead worker's
586
+ * remains are worth (an open PR? unpushed commits? a dirty tree?) is the
587
+ * orchestrator's drain-duty judgement, not something to automate here. The
588
+ * rows also keep counting toward `maxAttemptsPerIssue`, so a loop of deaths
589
+ * still escalates instead of retrying forever.
590
+ *
591
+ * `pushed-green` rows are deliberately left alone: they hold no process — they
592
+ * are finished work waiting on a human merge, and they must keep occupying the
593
+ * issue so a second attempt cannot land on a live PR.
594
+ */
595
+ export function reconcileOrphanedRuns(store: Store, project: string): RunRecord[] {
596
+ // Live runs only: `pushed-green` holds no process, so it cannot be orphaned by
597
+ // a process dying — it is finished work waiting on a human merge.
598
+ const stale = store.liveRuns(project);
599
+ const endedAt = Date.now();
600
+ for (const r of stale) {
601
+ store.updateRun(r.id, { state: "orphaned", endedAt });
602
+ }
603
+ return stale;
604
+ }
605
+
567
606
  // ------------------------------------------------------------------- the daemon
568
607
 
569
608
  export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
@@ -573,6 +612,21 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
573
612
  const store = openStore(dbPath());
574
613
  const tracker = makeTracker(project);
575
614
 
615
+ // Before the first tick, settle what the last process left behind — unless
616
+ // another daemon is alive (a foreground `daemon --once` beside a running
617
+ // daemon must not orphan that daemon's real, live workers).
618
+ const alive = livingDaemon();
619
+ if (alive === undefined || alive.pid === process.pid) {
620
+ for (const r of reconcileOrphanedRuns(store, project.name)) {
621
+ log(
622
+ `#${r.issue} orphaned by a previous daemon (attempt ${r.attempt}, was ${r.state}, worktree ${r.worktree}) — ` +
623
+ `slot freed; the ${project.stateLabels.inProgress} label stays until the orchestrator triages what the worker left`,
624
+ );
625
+ }
626
+ } else {
627
+ log(`skipping orphan reconciliation: daemon pid ${alive.pid} is alive and owns the active runs`);
628
+ }
629
+
576
630
  // Standing orders. The orchestrator holds none of this file's context, so
577
631
  // everything it needs to act — which tracker, which labels, what the fleet
578
632
  // does — has to be said once, in words.
package/src/store.ts CHANGED
@@ -16,12 +16,21 @@ import { dirname } from "node:path";
16
16
  import type { RunRecord, RunState, Store } from "./types.ts";
17
17
 
18
18
  /**
19
- * States that consume a worker slot. `pushed-green` counts: the branch is
20
- * pushed and CI is green but nothing is merged, so the worktree, the branch
21
- * and the issue claim are all still held.
19
+ * States backed by a worker process. These are what worker capacity counts:
20
+ * a slot is a process, and only a claimed or running attempt has one.
22
21
  */
23
- const ACTIVE_STATES: readonly RunState[] = ["claimed", "running", "pushed-green"];
22
+ const LIVE_STATES: readonly RunState[] = ["claimed", "running"];
24
23
 
24
+ /**
25
+ * States that keep an *issue* occupied. `pushed-green` belongs here but not in
26
+ * {@link LIVE_STATES}: its worker is finished and its worktree already removed,
27
+ * so it must not consume a slot — two green PRs awaiting a human merge would
28
+ * otherwise stop the whole fleet — but its issue has a live PR that a second
29
+ * attempt must not land on.
30
+ */
31
+ const ACTIVE_STATES: readonly RunState[] = [...LIVE_STATES, "pushed-green"];
32
+
33
+ const LIVE_PLACEHOLDERS = LIVE_STATES.map(() => "?").join(", ");
25
34
  const ACTIVE_PLACEHOLDERS = ACTIVE_STATES.map(() => "?").join(", ");
26
35
 
27
36
  /**
@@ -165,6 +174,11 @@ export function openStore(dbPath: string): Store {
165
174
  WHERE project = ? AND state IN (${ACTIVE_PLACEHOLDERS})
166
175
  ORDER BY startedAt ASC`,
167
176
  );
177
+ const selectLive = db.query<RunRow, SqlValue[]>(
178
+ `SELECT * FROM runs
179
+ WHERE project = ? AND state IN (${LIVE_PLACEHOLDERS})
180
+ ORDER BY startedAt ASC`,
181
+ );
168
182
  const countAttempts = db.query<{ n: number }, [string, number]>(
169
183
  `SELECT COUNT(*) AS n FROM runs WHERE project = ? AND issue = ?`,
170
184
  );
@@ -233,6 +247,10 @@ export function openStore(dbPath: string): Store {
233
247
  return selectActive.all(project, ...ACTIVE_STATES).map(toRecord);
234
248
  },
235
249
 
250
+ liveRuns(project: string): RunRecord[] {
251
+ return selectLive.all(project, ...LIVE_STATES).map(toRecord);
252
+ },
253
+
236
254
  attemptsFor(project: string, issue: number): number {
237
255
  return countAttempts.get(project, issue)?.n ?? 0;
238
256
  },
package/src/types.ts CHANGED
@@ -170,7 +170,9 @@ export type RunState =
170
170
  | "merged"
171
171
  | "blocked"
172
172
  | "failed"
173
- | "killed";
173
+ | "killed"
174
+ /** In flight when its daemon process died; reconciled at the next startup. */
175
+ | "orphaned";
174
176
 
175
177
  /**
176
178
  * One attempt at one issue. Persisted so a daemon restart can reconcile
@@ -207,7 +209,10 @@ export interface Store {
207
209
  createRun(r: Omit<RunRecord, "id">): RunRecord;
208
210
  updateRun(id: string, patch: Partial<RunRecord>): void;
209
211
  getRun(id: string): RunRecord | undefined;
212
+ /** Runs whose issue is occupied: a live worker, or a green PR awaiting merge. */
210
213
  activeRuns(project: string): RunRecord[];
214
+ /** Runs backed by a worker process — what capacity counts. Subset of {@link Store.activeRuns}. */
215
+ liveRuns(project: string): RunRecord[];
211
216
  attemptsFor(project: string, issue: number): number;
212
217
  runsStartedSince(project: string, sinceEpochMs: number): number;
213
218
  spendSince(project: string, sinceEpochMs: number): number;