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/README.md +60 -30
- package/package.json +1 -1
- package/src/backups.ts +46 -0
- package/src/board.ts +254 -38
- package/src/brief-upgrade.ts +1 -30
- package/src/briefs/orchestrator.md +13 -2
- package/src/cli.ts +90 -29
- package/src/config.ts +24 -0
- package/src/daemon.ts +311 -131
- package/src/decisions.ts +67 -7
- package/src/diff-flags.ts +35 -6
- package/src/fleet.ts +65 -6
- package/src/label-projection.ts +93 -0
- package/src/lifecycle.ts +8 -1
- package/src/orchestrator-tick.ts +150 -0
- package/src/plugin.ts +1 -1
- package/src/routing.ts +20 -0
- package/src/setup.ts +1 -1
- package/src/store.ts +254 -2
- package/src/tracker/github.ts +489 -80
- package/src/types.ts +85 -3
- package/src/unblock.ts +156 -20
- package/src/upgrade.ts +57 -7
- package/src/worktree.ts +23 -4
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
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),
|