omp-conductor 0.7.1 → 0.9.0

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/src/cli.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  import { closeSync, openSync, readFileSync, readSync, statSync } from "node:fs";
9
9
  import { userInfo } from "node:os";
10
10
  import { dirname, join } from "node:path";
11
- import { runBoard } from "./board.ts";
11
+ import { boardJson, boardSnapshotOnce, runBoard } from "./board.ts";
12
12
  import {
13
13
  applyRetrofit,
14
14
  formatBriefReport,
@@ -44,6 +44,7 @@ import {
44
44
  startDaemon,
45
45
  stopDaemon,
46
46
  writeRecord,
47
+ type RestartResult,
47
48
  } from "./lifecycle.ts";
48
49
  import { STALL_MARKER_FILE } from "./orchestrator-tick.ts";
49
50
  import { digestDedupeKey } from "./reports.ts";
@@ -63,7 +64,7 @@ import { handleVerbCall, type VerbChannel } from "./verbs/server.ts";
63
64
  import { REPORT_KINDS, VERB_NAMES } from "./types.ts";
64
65
  import type { ProjectConfig, ReportKind } from "./types.ts";
65
66
  import { formatUnblock, unblockIssue } from "./unblock.ts";
66
- import { upgradeConductor } from "./upgrade.ts";
67
+ import { DEFAULT_DEPS, drainAndRestart, upgradeConductor, type UpgradeDeps } from "./upgrade.ts";
67
68
 
68
69
  const FRICTION_FEEDBACK_KINDS = {
69
70
  "escalation-digest": "feedback:escalation-should-digest",
@@ -89,9 +90,9 @@ usage:
89
90
  omp-conductor start [--port N] [--project NAME]
90
91
  omp-conductor --version
91
92
  omp-conductor stop
92
- omp-conductor restart [--port N] [--project NAME]
93
+ omp-conductor restart [--now] [--timeout SECONDS] [--port N] [--project NAME]
93
94
  omp-conductor upgrade [--to VERSION] [--project NAME]
94
- omp-conductor board [--project NAME]
95
+ omp-conductor board [--project NAME] [--json]
95
96
  omp-conductor status [--project NAME]
96
97
  omp-conductor ledger [--issue N] [--limit N] [--project NAME]
97
98
  omp-conductor hold [--project NAME]
@@ -101,10 +102,10 @@ usage:
101
102
  omp-conductor release-pane [--project NAME]
102
103
  omp-conductor tail <issue> [--project NAME]
103
104
  omp-conductor extend <issue> --turns N [--project NAME]
104
- omp-conductor unblock <issue> [--force] [--project NAME]
105
+ omp-conductor unblock <issue> [--force] [--no-requeue] [--project NAME]
105
106
  omp-conductor verb <conductor_*> [--project NAME] [--arg k=v ...]
106
107
  omp-conductor daemon [--once] [--port N] [--project NAME]
107
- omp-conductor pause
108
+ omp-conductor pause [--reason TEXT]
108
109
  omp-conductor resume
109
110
  omp-conductor graph-setup [--project NAME] [--write]
110
111
  omp-conductor brief-upgrade [--migrate|--retrofit] [--apply] [--file PATH] [--project NAME]
@@ -126,17 +127,23 @@ usage:
126
127
  stop stop the running daemon. Uses systemctl when the omp-conductor
127
128
  unit owns the process (so Restart=on-failure cannot bring it back);
128
129
  otherwise SIGTERM then SIGKILL.
129
- restart stop then start, keeping the running daemon's port and project
130
- unless a flag overrides them. On boot the new process salvages any
131
- dirty live worktrees before orphaning those rows see README
132
- "Deploying a new package onto a busy fleet". Goes through systemctl
133
- when the unit owns the live pid.
130
+ restart drain the fleet by default: pause new claims, wait until live workers
131
+ reach 0/N (bounded by --timeout SECONDS, default 1800), restart keeping
132
+ the running daemon's port and project unless a flag overrides them,
133
+ then restore the prior dispatch state. --now skips the drain and
134
+ restarts immediately, orphaning any live runs (old behavior). A drain
135
+ that hits --timeout restarts nothing and leaves dispatch paused. On boot
136
+ the new process salvages dirty live worktrees before orphaning those
137
+ rows — see README "Deploying a new package onto a busy fleet". Goes
138
+ through systemctl when the unit owns the live pid.
134
139
  status layered fleet report: dispatch (running|paused|stopped), ticks and
135
140
  next due time, pane, herdr, Telegram bot/API health, daemon, caps
136
141
  and active runs.
137
142
  board open the live keyboard-driven fleet board. It renders queue holds,
138
143
  every run lifecycle stage, recent merges, spend and health; Enter
139
- follows a selected transcript without leaving the board.
144
+ follows a selected transcript without leaving the board. --json (or
145
+ a non-interactive stdin/stdout) prints a one-shot JSON snapshot of
146
+ the same lanes instead.
140
147
  ledger every conductor-verb call and how the daemon decided it: the verb,
141
148
  the arguments, allow or refuse, the named refusal reason, and the
142
149
  resulting sha. Sessions cannot push, open, merge, label or release
@@ -170,7 +177,10 @@ usage:
170
177
  operational-continuation budget, not failed implementation attempts.
171
178
  Refuses when the newest attempt's work could not be committed and
172
179
  its worktree is the only copy: re-claiming removes that tree. Use
173
- --force once you have recovered it or accepted the loss.
180
+ --force once you have recovered it or accepted the loss. Restores
181
+ the project queue label by default so the issue is actually
182
+ dispatchable; --no-requeue clears state labels only (the "about to
183
+ close it" case).
174
184
  verb run one conductor_* verb as the orchestrator, from the CLI: the same
175
185
  checks and the same ledger rows a session's call would get. This is
176
186
  how an external orchestrator merges, labels, releases or reads PR
@@ -202,6 +212,7 @@ usage:
202
212
  daemon run the dispatch loop in the foreground; --once runs a single tick
203
213
  and exits. This is what \`start\` launches.
204
214
  pause stop claiming new work only (ticks keep firing if armed). Prefer hold.
215
+ --reason TEXT is recorded in the pause sentinel (\`status\` shows it).
205
216
  resume clear pause only — does NOT re-arm. Prefer hold's inverse: resume + arm.
206
217
  graph-setup
207
218
  print how to set up the code-graph indexes workers query instead of
@@ -525,23 +536,62 @@ try {
525
536
  }
526
537
 
527
538
  case "restart": {
528
- // Inherit the running daemon's port and project: a restart that quietly
529
- // moved to the default port would leave every existing health check
530
- // pointing at nothing. When the unit owns the live pid, restartDaemon
531
- // goes through systemctl so the replacement stays supervised.
532
- const { previous, record, via } = await restartDaemon({
533
- port: portFlag(argv),
534
- project: flag(argv, "project"),
535
- });
536
- if (previous !== undefined) {
539
+ if (argv.includes("--now")) {
540
+ // Inherit the running daemon's port and project: a restart that quietly
541
+ // moved to the default port would leave every existing health check
542
+ // pointing at nothing. When the unit owns the live pid, restartDaemon
543
+ // goes through systemctl so the replacement stays supervised.
544
+ const { previous, record, via } = await restartDaemon({
545
+ port: portFlag(argv),
546
+ project: flag(argv, "project"),
547
+ });
548
+ if (previous !== undefined) {
549
+ process.stdout.write(
550
+ `stopped — pid ${previous.pid}${via === "systemctl" ? " (via systemctl)" : ""}\n`,
551
+ );
552
+ }
553
+ process.stdout.write(
554
+ `started — pid ${record.pid}, /healthz on :${record.port}` +
555
+ `${record.project === undefined ? "" : `, project ${record.project}`}` +
556
+ `${via === "systemctl" ? " (via systemctl)" : ""}\nlog ${record.logFile}\n`,
557
+ );
558
+ break;
559
+ }
560
+
561
+ // Default: drain the fleet before restarting — pause new claims, wait for
562
+ // live workers to reach 0/N (bounded by --timeout), restart, then restore
563
+ // the prior dispatch state. A timed-out drain restarts nothing and leaves
564
+ // dispatch paused, so it is safe to run while the daemon is wedged.
565
+ const timeoutRaw = flag(argv, "timeout");
566
+ const timeoutSeconds =
567
+ timeoutRaw === undefined ? 1800 : Number.parseInt(timeoutRaw, 10);
568
+ const timeoutMs = (Number.isNaN(timeoutSeconds) ? 1800 : timeoutSeconds) * 1000;
569
+ let result!: RestartResult;
570
+ const deps: UpgradeDeps = {
571
+ ...DEFAULT_DEPS,
572
+ setPaused: (v) => setPaused(v, { source: "restart", reason: "restart, draining" }),
573
+ restartDaemon: async () => {
574
+ result = await restartDaemon({
575
+ port: portFlag(argv),
576
+ project: flag(argv, "project"),
577
+ });
578
+ },
579
+ };
580
+ try {
581
+ await drainAndRestart(deps, { project: flag(argv, "project"), timeoutMs });
582
+ } catch (err) {
583
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
584
+ process.exit(1);
585
+ }
586
+ if (result.previous !== undefined) {
537
587
  process.stdout.write(
538
- `stopped — pid ${previous.pid}${via === "systemctl" ? " (via systemctl)" : ""}\n`,
588
+ `stopped — pid ${result.previous.pid}${result.via === "systemctl" ? " (via systemctl)" : ""}\n`,
539
589
  );
540
590
  }
541
591
  process.stdout.write(
542
- `started — pid ${record.pid}, /healthz on :${record.port}` +
543
- `${record.project === undefined ? "" : `, project ${record.project}`}` +
544
- `${via === "systemctl" ? " (via systemctl)" : ""}\nlog ${record.logFile}\n`,
592
+ `started — pid ${result.record.pid}, /healthz on :${result.record.port}` +
593
+ `${result.record.project === undefined ? "" : `, project ${result.record.project}`}` +
594
+ `${result.via === "systemctl" ? " (via systemctl)" : ""}\nlog ${result.record.logFile}\n`,
545
595
  );
546
596
  break;
547
597
  }
@@ -602,9 +652,19 @@ try {
602
652
  break;
603
653
  }
604
654
 
605
- case "board":
606
- await runBoard(flag(argv, "project"));
655
+ case "board": {
656
+ const project = flag(argv, "project");
657
+ // Headless one-shot mode: `--json` asks for it explicitly, and a
658
+ // non-interactive stdin or stdout (pipe, redirect, cron, script) cannot
659
+ // host the key-driven board anyway. The TTY throw inside runBoard stays
660
+ // as a backstop for direct callers.
661
+ if (argv.includes("--json") || !process.stdout.isTTY || !process.stdin.isTTY) {
662
+ process.stdout.write(`${boardJson(await boardSnapshotOnce(project))}\n`);
663
+ break;
664
+ }
665
+ await runBoard(project);
607
666
  break;
667
+ }
608
668
 
609
669
  case "hold": {
610
670
  const r = hold(flag(argv, "project"));
@@ -732,6 +792,7 @@ try {
732
792
  try {
733
793
  const outcome = await unblockIssue(project, makeTracker(project), store, issue, {
734
794
  force: argv.includes("--force"),
795
+ requeue: !argv.includes("--no-requeue"),
735
796
  });
736
797
  process.stdout.write(`${formatUnblock(issue, outcome, project, resolveCaps(project, cfg.defaults))}\n`);
737
798
  // A refusal must not read as success to a script or a board keypress.
@@ -1006,7 +1067,7 @@ try {
1006
1067
  }
1007
1068
 
1008
1069
  case "pause":
1009
- setPaused(true);
1070
+ setPaused(true, { source: "pause", reason: flag(argv, "reason") });
1010
1071
  process.stdout.write(
1011
1072
  "paused — no new work will be claimed\n" +
1012
1073
  "note: ticks keep firing if armed; use hold to silence both\n",
package/src/config.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
16
16
  import { homedir } from "node:os";
17
17
  import { dirname, isAbsolute, join } from "node:path";
18
+ import { backupTimestamp, copyToUniqueBackup } from "./backups.ts";
18
19
  import {
19
20
  AUTHORITY_HOLDERS,
20
21
  BASE_FRESHNESS,
@@ -103,6 +104,11 @@ export function stateDir(): string {
103
104
  return dirname(configPath());
104
105
  }
105
106
 
107
+ /** State-root directory for durable copies of every pre-write config.json. */
108
+ export function configBackupDir(): string {
109
+ return join(stateDir(), "backups", "config");
110
+ }
111
+
106
112
  /**
107
113
  * Where per-run worktrees and bare mirrors live when a project names neither.
108
114
  *
@@ -188,6 +194,13 @@ export function writeConfigRaw(text: string): void {
188
194
  if (created !== undefined) chmodSync(dir, 0o700);
189
195
 
190
196
  const target = configPath();
197
+ // A durable pre-write copy under state keeps the previous bytes recoverable
198
+ // even when the process dies between this moment and the rename, or when a
199
+ // later caller discovers it needs the old file back. Retention is unbounded,
200
+ // matching the brief backups.
201
+ if (existsSync(target)) {
202
+ copyToUniqueBackup(target, configBackupDir(), `config.json.bak-${backupTimestamp()}`);
203
+ }
191
204
  const tmp = join(dir, `.config.json.${process.pid.toString(36)}.${Date.now().toString(36)}.tmp`);
192
205
  try {
193
206
  writeFileSync(tmp, text, { mode: 0o600 });
@@ -208,6 +221,7 @@ export function resolveCaps(p: ProjectConfig, defaults: Caps): Caps {
208
221
  const o: Partial<Caps> = p.caps ?? {};
209
222
  return {
210
223
  maxConcurrentWorkers: o.maxConcurrentWorkers ?? defaults.maxConcurrentWorkers,
224
+ maxConcurrentWorkersPerRepo: o.maxConcurrentWorkersPerRepo ?? defaults.maxConcurrentWorkersPerRepo,
211
225
  dailySpendUsd: o.dailySpendUsd !== undefined ? o.dailySpendUsd : defaults.dailySpendUsd,
212
226
  planUsage: o.planUsage !== undefined ? o.planUsage : defaults.planUsage,
213
227
  workerMaxTurns: o.workerMaxTurns ?? defaults.workerMaxTurns,
@@ -408,6 +422,15 @@ function normalizeProject(
408
422
  if (nonEmptyString(rawQueueLabel)) queueLabel = rawQueueLabel;
409
423
  else problems.push(`${label}: queueLabel must be a non-empty string — it is the human sign-off gate`);
410
424
 
425
+ // Tolerant like the other optional fields: an integer >= 1 is a grooming
426
+ // trigger, and anything else degrades to absent (the tick default) rather
427
+ // than invalidating the whole project.
428
+ const rawGroomBelow = raw["groomBelow"];
429
+ const groomBelow =
430
+ typeof rawGroomBelow === "number" && Number.isInteger(rawGroomBelow) && rawGroomBelow >= 1
431
+ ? rawGroomBelow
432
+ : undefined;
433
+
411
434
  const routing = raw["routing"] as Raw | undefined;
412
435
  const rawPrefix = routing?.["labelPrefix"];
413
436
  const labelPrefix = typeof rawPrefix === "string" ? rawPrefix : DEFAULT_LABEL_PREFIX;
@@ -434,6 +457,7 @@ function normalizeProject(
434
457
  name,
435
458
  tracker: { kind: "github", repo: trackerRepo },
436
459
  queueLabel,
460
+ ...(groomBelow === undefined ? {} : { groomBelow }),
437
461
  stateLabels: {
438
462
  inProgress: pickString(stateLabels?.["inProgress"], DEFAULT_STATE_LABELS.inProgress),
439
463
  blocked: pickString(stateLabels?.["blocked"], DEFAULT_STATE_LABELS.blocked),