omp-conductor 0.15.9 → 0.15.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.
Files changed (86) hide show
  1. package/README.md +273 -2543
  2. package/REFERENCE.md +2638 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +8 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +55 -29
  10. package/src/briefs/policy.md +14 -5
  11. package/src/briefs/worker.md +7 -1
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +190 -1391
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +49 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +98 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +134 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +48 -0
  32. package/src/commands/report.ts +170 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +93 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +23 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +38 -1
  49. package/src/config.ts +10 -3
  50. package/src/daemon.ts +613 -94
  51. package/src/dashboard/app.js +120 -0
  52. package/src/dashboard/index.html +34 -0
  53. package/src/dashboard/server.ts +267 -0
  54. package/src/dashboard/style.css +180 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +795 -0
  58. package/src/escalate.ts +60 -19
  59. package/src/failure-class.ts +29 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/graph-health.ts +1 -1
  62. package/src/label-projection.ts +1 -1
  63. package/src/lifecycle.ts +198 -2
  64. package/src/notices.ts +9 -0
  65. package/src/omp.ts +2 -0
  66. package/src/orchestrator-tick.ts +315 -17
  67. package/src/release-policy.ts +135 -23
  68. package/src/reports.ts +19 -5
  69. package/src/setup-host.ts +420 -8
  70. package/src/setup-install.ts +69 -14
  71. package/src/setup-wizard.ts +199 -61
  72. package/src/setup.ts +131 -35
  73. package/src/stats.ts +331 -0
  74. package/src/store.ts +206 -21
  75. package/src/tracker/github.ts +27 -4
  76. package/src/types.ts +144 -31
  77. package/src/unblock.ts +55 -11
  78. package/src/upgrade-journal.ts +220 -0
  79. package/src/upgrade-verify.ts +506 -0
  80. package/src/upgrade.ts +295 -26
  81. package/src/verbs/actions.ts +73 -1
  82. package/src/verbs/protocol.ts +29 -4
  83. package/src/verbs/server.ts +183 -20
  84. package/systemd/omp-conductor-recover.sh +433 -0
  85. package/systemd/omp-conductor.service.example +7 -0
  86. package/systemd/recover-unit-test.sh +428 -0
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `restart` — drain the fleet by default, then restart keeping the running daemon's port and project unless a flag overrides them.
3
+ *
4
+ * Moved out of cli.ts's switch by the per-verb module split (#462);
5
+ * only the case wrapper, the injected `ctx` lookups and the imports
6
+ * changed from the original bodies.
7
+ */
8
+
9
+ import type { CommandContext } from "./context.ts";
10
+ import { setPaused } from "../daemon.ts";
11
+ import { restartDaemon, type RestartResult } from "../lifecycle.ts";
12
+ import { DEFAULT_DEPS, drainAndRestart, type UpgradeDeps } from "../upgrade.ts";
13
+
14
+ export async function restartCommand(ctx: CommandContext): Promise<void> {
15
+ if (ctx.argv.includes("--now")) {
16
+ // Inherit the running daemon's port and project: a restart that quietly
17
+ // moved to the default port would leave every existing health check
18
+ // pointing at nothing. When the unit owns the live pid, restartDaemon
19
+ // goes through systemctl so the replacement stays supervised; a failed
20
+ // installed unit is reset and started through systemd the same way and
21
+ // is never replaced by an unmanaged daemon. Success is only reported
22
+ // after the manager is proven to own the reported pid.
23
+ const { previous, record, via } = await restartDaemon({
24
+ port: ctx.portFlag(),
25
+ project: ctx.projectFlag,
26
+ });
27
+ if (previous !== undefined) {
28
+ process.stdout.write(
29
+ `stopped — pid ${previous.pid}${via === "systemctl" ? " (via systemctl)" : ""}\n`,
30
+ );
31
+ }
32
+ process.stdout.write(
33
+ `started — pid ${record.pid}, /healthz on :${record.port}` +
34
+ `${record.project === undefined ? "" : `, project ${record.project}`}` +
35
+ `${via === "systemctl" ? " (via systemctl)" : ""}\nlog ${record.logFile}\n`,
36
+ );
37
+ return;
38
+ }
39
+
40
+ // Default: drain the fleet before restarting — pause new claims, wait for
41
+ // live workers to reach 0/N (bounded by --timeout), restart, then restore
42
+ // the prior dispatch state. A timed-out drain restarts nothing and leaves
43
+ // dispatch paused, so it is safe to run while the daemon is wedged.
44
+ const timeoutRaw = ctx.flag("timeout");
45
+ const timeoutSeconds =
46
+ timeoutRaw === undefined ? 1800 : Number.parseInt(timeoutRaw, 10);
47
+ const timeoutMs = (Number.isNaN(timeoutSeconds) ? 1800 : timeoutSeconds) * 1000;
48
+ let result!: RestartResult;
49
+ const deps: UpgradeDeps = {
50
+ ...DEFAULT_DEPS,
51
+ setPaused: (v, project) =>
52
+ setPaused(v, { source: "restart", reason: "restart, draining" }, project),
53
+ restartDaemon: async () => {
54
+ result = await restartDaemon({
55
+ port: ctx.portFlag(),
56
+ project: ctx.projectFlag,
57
+ });
58
+ },
59
+ };
60
+ try {
61
+ await drainAndRestart(deps, { project: ctx.projectFlag, timeoutMs });
62
+ } catch (err) {
63
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
64
+ process.exit(1);
65
+ }
66
+ if (result.previous !== undefined) {
67
+ process.stdout.write(
68
+ `stopped — pid ${result.previous.pid}${result.via === "systemctl" ? " (via systemctl)" : ""}\n`,
69
+ );
70
+ }
71
+ process.stdout.write(
72
+ `started — pid ${result.record.pid}, /healthz on :${result.record.port}` +
73
+ `${result.record.project === undefined ? "" : `, project ${result.record.project}`}` +
74
+ `${result.via === "systemctl" ? " (via systemctl)" : ""}\nlog ${result.record.logFile}\n`,
75
+ );
76
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `resume` — clear pause and any pane-recovery pin. Does NOT re-arm.
3
+ *
4
+ * A running daemon is woken over its loopback control port so the resumed
5
+ * project is dispatched on a pass that starts immediately instead of after
6
+ * the five-minute tick (#380); when no daemon is alive there is nothing to
7
+ * wake and the output says so rather than implying an immediate pass happened.
8
+ *
9
+ * Moved out of cli.ts's switch by the per-verb module split (#462);
10
+ * only the case wrapper, the injected `ctx` lookups and the imports
11
+ * changed from the original bodies.
12
+ */
13
+
14
+ import type { CommandContext } from "./context.ts";
15
+ import { armState, clearPaneHaltIfResolvable, releaseHold } from "../fleet.ts";
16
+ import { livingDaemon } from "../lifecycle.ts";
17
+
18
+ /** Wakes a running daemon, returning the human line that states what happened.
19
+ * State-only clients (no live daemon) get an honest fallback rather than an
20
+ * error: the hold is already cleared and the next pass or daemon start
21
+ * claims without this poke. */
22
+ async function wakeDaemon(projectName: string): Promise<string> {
23
+ const daemon = livingDaemon();
24
+ if (daemon === undefined) {
25
+ return "daemon not running — claiming starts when the daemon next starts or ticks";
26
+ }
27
+ let response: Response;
28
+ try {
29
+ response = await fetch(`http://127.0.0.1:${daemon.port}/wake`, {
30
+ method: "POST",
31
+ headers: { "content-type": "application/json" },
32
+ body: JSON.stringify({ project: projectName }),
33
+ });
34
+ } catch {
35
+ return "daemon wake failed — daemon unreachable; the next scheduled pass will claim";
36
+ }
37
+ if (!response.ok) {
38
+ return `daemon wake failed (HTTP ${response.status}); the next scheduled pass will claim`;
39
+ }
40
+ return "daemon dispatch loop woken — claiming starts on an immediate pass";
41
+ }
42
+
43
+ export async function resumeCommand(ctx: CommandContext): Promise<void> {
44
+ for (const project of ctx.targetProjects()) {
45
+ releaseHold(project.name);
46
+ const pin = clearPaneHaltIfResolvable(project.name);
47
+ const arm = armState(project.name);
48
+ process.stdout.write(
49
+ `resumed — claiming allowed — ${await wakeDaemon(project.name)}\n` +
50
+ (pin.wasHalted
51
+ ? `pane recovery pin cleared — ${pin.path}\n`
52
+ : "no pane recovery pin to clear\n") +
53
+ (arm.armed
54
+ ? `ticks remain armed — marker ${arm.path}\n`
55
+ : "ticks stay disarmed — run `omp-conductor arm`\n"),
56
+ );
57
+ }
58
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `setup` — interview, then write config.json, the labels, the briefs and the * staged host files behind one confirm.
3
+ *
4
+ * Moved out of cli.ts's switch by the per-verb module split (#462);
5
+ * only the case wrapper, the injected `ctx` lookups and the imports
6
+ * changed from the original bodies.
7
+ */
8
+
9
+ import type { CommandContext } from "./context.ts";
10
+ import { findProject, loadConfig, resolveCaps } from "../config.ts";
11
+ import { AMEND_AREA_IDS, type AmendAreaId } from "../setup.ts";
12
+ import { runGraphInstall, runHostInstall } from "../setup-install.ts";
13
+ import { DEFAULT_PROBES, NO_PROBES, setup } from "../setup-wizard.ts";
14
+ import { telegramStateDir } from "../fleet.ts";
15
+ import { terminalUi } from "../wizard-ui.ts";
16
+
17
+ /**
18
+ * The setup verb's own usage, printed for `setup --help` / `setup -h` before
19
+ * any UI, config read, probe or area parsing. Areas come from `AMEND_AREA_IDS`
20
+ * rather than a second copy, so an added amend area appears both here and in
21
+ * the shared `USAGE` with no third spelling to keep in sync.
22
+ */
23
+ const SETUP_USAGE = `omp-conductor setup — interview, then write config.json, the labels, the briefs
24
+ and the staged host files behind one confirm.
25
+
26
+ usage:
27
+ omp-conductor setup [area] [--no-ai] [--project NAME]
28
+
29
+ Bare setup runs the full interview — or, when the project already exists,
30
+ asks which area to amend. Naming an area positionally skips that chooser and
31
+ amends only that area.
32
+
33
+ amend areas:
34
+ ${AMEND_AREA_IDS.join(", ")}
35
+
36
+ flags:
37
+ --project NAME the project to configure or amend
38
+ --no-ai ask every question, propose nothing (no AI repo reads)`;
39
+
40
+ export async function setupCommand(ctx: CommandContext): Promise<void> {
41
+ // Help first, and only in the first trailing position: a help request
42
+ // must never open a UI, read config, probe GitHub or pause dispatch.
43
+ // `setup bogus --help` still rejects `bogus` as an unknown area, and
44
+ // `setup caps --help` still names `caps` as the governing argument.
45
+ if (ctx.argv[1] === "--help" || ctx.argv[1] === "-h") {
46
+ process.stdout.write(`${SETUP_USAGE}\n`);
47
+ return;
48
+ }
49
+ const sub = ctx.argv[1];
50
+ const positional = sub !== undefined && !sub.startsWith("--") ? sub : undefined;
51
+ // The terminal surface owns stdin for whichever path runs, and is released
52
+ // in a finally: a throw with readline still attached leaves the operator's
53
+ // shell without an echo.
54
+ const ui = terminalUi();
55
+ try {
56
+ // `host` and `graph` are install subcommands, checked BEFORE the amend
57
+ // areas. They are not areas — routing them through the area validation
58
+ // is how `setup host` came to exit 2 as an "unknown setup area".
59
+ if (positional === "host" || positional === "graph") {
60
+ const cfg = loadConfig();
61
+ const project = findProject(cfg, ctx.projectFlag);
62
+ const outcome =
63
+ positional === "host"
64
+ ? await runHostInstall(project, resolveCaps(project, cfg.defaults), telegramStateDir(), ui)
65
+ : await runGraphInstall(project, ui, {
66
+ noSeed: ctx.argv.includes("--no-seed"),
67
+ print: ctx.argv.includes("--print"),
68
+ });
69
+ // `staged` is a success on a host that has no systemd: the files are
70
+ // real, only the enable step is impossible.
71
+ if (outcome.kind === "refused" || outcome.kind === "failed") process.exit(1);
72
+ return;
73
+ }
74
+ let area: AmendAreaId | undefined;
75
+ if (positional !== undefined) {
76
+ if (!(AMEND_AREA_IDS as readonly string[]).includes(positional)) {
77
+ process.stderr.write(
78
+ `omp-conductor: unknown setup argument "${positional}" — ` +
79
+ `install subcommands are host, graph; amend areas are ${AMEND_AREA_IDS.join(", ")}\n`,
80
+ );
81
+ process.exit(2);
82
+ }
83
+ area = positional as AmendAreaId;
84
+ }
85
+ // `--no-ai` is the interview with its reading half removed: every question
86
+ // is still asked, nothing is proposed. For a host with no omp peer, a
87
+ // private repo no probe can clone, or an operator who would rather type the
88
+ // gates than review a model's reading of their CI.
89
+ await setup(ui, ctx.projectFlag, area, ctx.argv.includes("--no-ai") ? NO_PROBES : DEFAULT_PROBES);
90
+ } finally {
91
+ ui.close();
92
+ }
93
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `start` — start the installed herdr-fleet.service when present, then run the dispatch loop in the background and wait for /healthz.
3
+ *
4
+ * Moved out of cli.ts's switch by the per-verb module split (#462);
5
+ * only the case wrapper, the injected `ctx` lookups and the imports
6
+ * changed from the original bodies.
7
+ */
8
+
9
+ import type { CommandContext } from "./context.ts";
10
+ import { startHerdrFleet } from "../fleet.ts";
11
+ import { startDaemon } from "../lifecycle.ts";
12
+
13
+ export async function startCommand(ctx: CommandContext): Promise<void> {
14
+ const project = ctx.projectFlag;
15
+ const herdr = startHerdrFleet(project);
16
+ const rec = await startDaemon({ port: ctx.portFlag(), project });
17
+ process.stdout.write(
18
+ `started — pid ${rec.pid}, /healthz on :${rec.port}` +
19
+ `${rec.project === undefined ? "" : `, project ${rec.project}`}\n` +
20
+ `herdr ${herdr.kind === "active" ? `active (${herdr.unit})${herdr.recoveryReleased ? "; recovery pin cleared" : ""}` : `unmanaged (${herdr.reason})`}\n` +
21
+ `log ${rec.logFile}\n`,
22
+ );
23
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * `stats` — what the fleet accomplished and at what cost over a window (#282,
3
+ * legibility 5/5).
4
+ *
5
+ * The aggregation lives in ../stats.ts, pure over injected store rows; this
6
+ * module only parses the flags, reads the local store read-only, and renders
7
+ * the same report the --json shape carries. Nothing here touches GitHub or any
8
+ * other network surface: the window, runs, spend and gh-call counts all come
9
+ * from SQLite on this host.
10
+ */
11
+
12
+ import type { CommandContext } from "./context.ts";
13
+ import { findProject, loadConfig } from "../config.ts";
14
+ import { dbPath, openStore, utcDay } from "../store.ts";
15
+ import { computeStats, renderStatsHuman, type StatsWindow } from "../stats.ts";
16
+
17
+ const DAY_MS = 24 * 60 * 60 * 1_000;
18
+ const DATE_FORM = /^\d{4}-\d{2}-\d{2}$/;
19
+ const DURATION_FORM = /^(\d{1,3})d$/;
20
+
21
+ const STATS_USAGE = `omp-conductor stats — what the fleet accomplished and at what cost.
22
+
23
+ usage:
24
+ omp-conductor stats [--since 7d|30d|YYYY-MM-DD] [--project NAME] [--json]
25
+
26
+ Reads only the local store — zero GitHub calls. Over a bounded window it
27
+ reports, per repo and in total: issues merged, issues settled (merged or
28
+ terminally failed/blocked), merge rate, queue-to-merge lead time (median and
29
+ p90, from the first claim of the issue's continuation chain to the tick's
30
+ merge settlement), attempts per merged issue, metered spend per merged issue,
31
+ the failure-class breakdown of everything that did not merge, and the tracked
32
+ GitHub API calls consumed over the window's days.
33
+
34
+ Continuation chains collapse into one journey: an issue that took six attempts
35
+ is one merged outcome with six runs and one lead time. Runs whose spend reads
36
+ $0.00 — harness telemetry absent (README Limitations) — are counted separately
37
+ as unmetered and never averaged into cost as if they were free.
38
+
39
+ flags:
40
+ --since 7d | 30d | YYYY-MM-DD window start (default 7d)
41
+ --json print the stable report shape {project,
42
+ window, ghCalls, empty, total, repos} — keys
43
+ never move, so a consumer can rely on it
44
+ --project NAME the project to report (required only when
45
+ the config has more than one)
46
+
47
+ When nothing settled in the window the report says so explicitly ("empty:
48
+ true" in --json) rather than printing zeros that could read as measurements.`;
49
+
50
+ /** `--since` to a window. The bare date form means from 00:00:00Z of that day;
51
+ * the duration form (`7d`) is now minus whole days. Anything else exits 2,
52
+ * because silently defaulting a typo'd window would measure the wrong week. */
53
+ function sinceWindow(raw: string, now: number): Pick<StatsWindow, "sinceDay" | "sinceEpochMs"> {
54
+ const duration = DURATION_FORM.exec(raw);
55
+ if (duration !== null) {
56
+ const days = Number(duration[1]);
57
+ if (days >= 1) {
58
+ const sinceEpochMs = now - days * DAY_MS;
59
+ return { sinceEpochMs, sinceDay: utcDay(sinceEpochMs) };
60
+ }
61
+ }
62
+ if (DATE_FORM.test(raw)) {
63
+ const sinceEpochMs = Date.parse(`${raw}T00:00:00Z`);
64
+ if (Number.isFinite(sinceEpochMs)) {
65
+ return { sinceEpochMs, sinceDay: raw };
66
+ }
67
+ }
68
+ process.stderr.write(
69
+ `omp-conductor: stats --since needs 7d, 30d or YYYY-MM-DD, got "${raw}"\n`,
70
+ );
71
+ process.exit(2);
72
+ }
73
+
74
+ export async function statsCommand(ctx: CommandContext): Promise<void> {
75
+ // Help first: parsing stops before any config read or store open.
76
+ if (ctx.argv[1] === "--help" || ctx.argv[1] === "-h") {
77
+ process.stdout.write(`${STATS_USAGE}\n`);
78
+ return;
79
+ }
80
+ // Every remaining token after the verb is accounted for: the flag presence
81
+ // markers plus the two value flags cli.ts already parsed.
82
+ const STATS_FLAGS: Record<string, true> = {
83
+ "--json": true,
84
+ "--since": true,
85
+ "--project": true,
86
+ };
87
+ for (let i = 1; i < ctx.argv.length; i++) {
88
+ const token = ctx.argv[i];
89
+ if (token === undefined) continue;
90
+ // `--since 7d` and `--since=7d` are both legal — cli.ts's flag helper
91
+ // accepts the prefixed form, so the scan must too.
92
+ if (
93
+ STATS_FLAGS[token] !== true &&
94
+ !token.startsWith("--since=") &&
95
+ !token.startsWith("--project=")
96
+ ) {
97
+ process.stderr.write(`omp-conductor: stats: unexpected argument "${token}"\n`);
98
+ process.exit(2);
99
+ }
100
+ if (token === "--since" || token === "--project") i += 1;
101
+ }
102
+
103
+ const project = findProject(loadConfig(), ctx.projectFlag);
104
+ const now = Date.now();
105
+ const rawSince = ctx.flag("since");
106
+ if (rawSince === undefined && ctx.argv.includes("--since")) {
107
+ process.stderr.write(`omp-conductor: stats --since needs 7d, 30d or YYYY-MM-DD\n`);
108
+ process.exit(2);
109
+ }
110
+ const window: StatsWindow = {
111
+ ...sinceWindow(rawSince ?? "7d", now),
112
+ untilEpochMs: now,
113
+ untilDay: utcDay(now),
114
+ };
115
+ const store = openStore(dbPath());
116
+ try {
117
+ const report = computeStats({
118
+ project: project.name,
119
+ window,
120
+ ghCalls: store.ghCallsBetween(window.sinceDay, window.untilDay),
121
+ runs: store.statsRuns(project.name, window.sinceEpochMs),
122
+ });
123
+ process.stdout.write(
124
+ ctx.argv.includes("--json")
125
+ ? `${JSON.stringify(report, null, 2)}\n`
126
+ : renderStatsHuman(report),
127
+ );
128
+ } finally {
129
+ store.close();
130
+ }
131
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `status` — layered fleet report: dispatch, ticks, pane, herdr, Telegram target, daemon, caps and active runs.
3
+ *
4
+ * Moved out of cli.ts's switch by the per-verb module split (#462);
5
+ * only the case wrapper, the injected `ctx` lookups and the imports
6
+ * changed from the original bodies.
7
+ */
8
+
9
+ import type { CommandContext } from "./context.ts";
10
+ import { readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { stateDir } from "../config.ts";
13
+ import { renderStatus } from "../fleet.ts";
14
+ import { STALL_MARKER_FILE } from "../orchestrator-tick.ts";
15
+
16
+ /**
17
+ * The orchestrator half, and the one thing `status` has ever known about the
18
+ * supervising session: the stall marker its heartbeat writes when its own
19
+ * prompts stop being consumed (see {@link STALL_MARKER_FILE}).
20
+ *
21
+ * The marker is written in the *session's* cwd, which this process has no way
22
+ * to discover — so this reads the state directory, on the reference deploy's
23
+ * convention that the orchestrator session runs from exactly there. That makes
24
+ * the reading one-directional: a line printed here is proof of a wedge, and no
25
+ * line is proof of nothing at all. On a fleet whose session lives elsewhere the
26
+ * check is simply inert, which is why it never prints a reassuring "healthy".
27
+ */
28
+ function stallLine(): string | undefined {
29
+ let raw: string;
30
+ try {
31
+ raw = readFileSync(join(stateDir(), STALL_MARKER_FILE), "utf8").trim();
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ // "<ISO timestamp> <one-line diagnosis>". A file truncated by something else
36
+ // still gets reported: that the marker exists at all is the news.
37
+ const cut = raw.indexOf(" ");
38
+ const since = cut < 0 ? raw : raw.slice(0, cut);
39
+ const diagnosis = cut < 0 ? "" : ` — ${raw.slice(cut + 1)}`;
40
+ return `orchestrator STALLED since ${since === "" ? "an unrecorded time" : since}${diagnosis}`;
41
+ }
42
+
43
+ export async function statusCommand(ctx: CommandContext): Promise<void> {
44
+ const project = ctx.projectFlag;
45
+ const text = await renderStatus(project);
46
+ const stalled = stallLine();
47
+ process.stdout.write(`${text}${stalled === undefined ? "\n" : `\n\n${stalled}\n`}`);
48
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `stop` — stop the conductor: pause claiming, disarm ticks, then stop the dispatch daemon. Pane stays up unless --pane is passed.
3
+ *
4
+ * Moved out of cli.ts's switch by the per-verb module split (#462);
5
+ * only the case wrapper, the injected `ctx` lookups and the imports
6
+ * changed from the original bodies.
7
+ */
8
+
9
+ import type { CommandContext } from "./context.ts";
10
+ import { hold, pinPaneHalt, stopConductorPane } from "../fleet.ts";
11
+ import { stopDaemon } from "../lifecycle.ts";
12
+
13
+ export async function stopCommand(ctx: CommandContext): Promise<void> {
14
+ const withPane = ctx.argv.includes("--pane");
15
+ const targets = ctx.targetProjects().map((project) => {
16
+ // `stop` takes the fleet down, so it always disarms — `--keep-ticks` is a
17
+ // `hold` affordance. Assert the invariant instead of printing a path that
18
+ // might not exist.
19
+ const held = hold(project.name, "halt");
20
+ if (held.disarmed === undefined) throw new Error("stop must disarm ticks");
21
+ return {
22
+ project,
23
+ hold: { ...held, disarmed: held.disarmed },
24
+ pin: withPane ? pinPaneHalt(project.name).path : undefined,
25
+ };
26
+ });
27
+ const stop = await stopDaemon();
28
+ const stopLine =
29
+ stop.kind === "not-running"
30
+ ? "daemon was not running"
31
+ : `daemon stopped — pid ${stop.pid}${stop.via === "systemctl" ? " (via systemctl)" : ""}`;
32
+ for (const target of targets) {
33
+ if (target.pin !== undefined) {
34
+ const pane = await stopConductorPane(target.project.name);
35
+ process.stdout.write(
36
+ `stopped — claiming paused; ticks disarmed at ${target.hold.disarmed.path}\n` +
37
+ `${stopLine}\n` +
38
+ `pane recovery pinned at ${target.pin}\n` +
39
+ `pane stop: ${pane.stopped} — ${pane.detail}\n` +
40
+ ` (conductor agent "${pane.agentName}" only — herdr-fleet.service was NOT stopped;\n` +
41
+ ` resume clears the pin when you want recovery again)\n`,
42
+ );
43
+ } else {
44
+ process.stdout.write(
45
+ `stopped — claiming paused; ticks disarmed at ${target.hold.disarmed.path}\n` +
46
+ `${stopLine}\n` +
47
+ `pane left running (pass --pane to stop the conductor agent and pin recovery)\n`,
48
+ );
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `tail` — follow the newest run for an issue the way `tail -f` follows a log, finishing once the run has left the live states.
3
+ *
4
+ * Moved out of cli.ts's switch by the per-verb module split (#462);
5
+ * only the case wrapper, the injected `ctx` lookups and the imports
6
+ * changed from the original bodies.
7
+ */
8
+
9
+ import type { CommandContext } from "./context.ts";
10
+ import { closeSync, openSync, readSync, statSync } from "node:fs";
11
+ import { findProject, loadConfig } from "../config.ts";
12
+ import { dbPath, LIVE_STATES, openStore } from "../store.ts";
13
+ import { formatTranscriptLine } from "../transcript.ts";
14
+
15
+ /** How often `tail` re-stats the transcript it is following. */
16
+ const TAIL_POLL_MS = 1_000;
17
+
18
+ /**
19
+ * How long the transcript must stay unchanged, after its run has left the live
20
+ * states, before `tail` calls it over. The state flips from the daemon's thread
21
+ * while the harness may still be flushing its last message, so exiting on the
22
+ * state alone truncates the ending an operator ran this command to watch.
23
+ */
24
+ const TAIL_QUIET_MS = 5_000;
25
+
26
+ /**
27
+ * Follow one run's transcript the way `tail -f` follows a log.
28
+ *
29
+ * Reads from byte zero rather than from the end: attaching to a worker that is
30
+ * already ten turns in and then showing nothing until turn eleven is not
31
+ * watching the run. Polls `stat` instead of taking a file watcher because the
32
+ * transcript is a plain append-only file that may sit on a filesystem where
33
+ * change events are a polite fiction, and one stat a second costs nothing.
34
+ *
35
+ * SIGINT is deliberately left to its default, which is immediate exit. Nothing
36
+ * here is buffered, and a handler could only add a poll interval of latency to
37
+ * every Ctrl-C.
38
+ */
39
+ async function tailRun(project: string, issue: number): Promise<void> {
40
+ // Read-only in practice: the store is opened WAL with a busy timeout, so this
41
+ // never contends with the daemon writing the same rows.
42
+ const store = openStore(dbPath());
43
+ try {
44
+ const run = store.latestRun(project, issue);
45
+ if (run === undefined) throw new Error(`no run recorded for #${issue}`);
46
+ const path = run.sessionFile;
47
+ // Claimed but not yet started, or an attempt whose session never opened one.
48
+ if (path === undefined) throw new Error(`no transcript yet (state: ${run.state})`);
49
+
50
+ const fd = openSync(path, "r");
51
+ try {
52
+ let offset = 0;
53
+ let pending = Buffer.alloc(0);
54
+ let lastChange = Date.now();
55
+
56
+ for (;;) {
57
+ let size = offset;
58
+ try {
59
+ size = statSync(path).size;
60
+ } catch {
61
+ // A transcript that vanishes mid-follow is not worth crashing over.
62
+ // The run's own state, below, is what decides when this command ends.
63
+ }
64
+ // Shorter than what we have already read means truncated or replaced;
65
+ // resuming from the old offset would read the middle of another file.
66
+ if (size < offset) {
67
+ offset = 0;
68
+ pending = Buffer.alloc(0);
69
+ }
70
+ if (size > offset) {
71
+ const chunk = Buffer.allocUnsafe(size - offset);
72
+ const read = readSync(fd, chunk, 0, chunk.length, offset);
73
+ offset += read;
74
+ // Split on newlines as bytes, not as text: a UTF-8 sequence straddling
75
+ // a read boundary would be mangled by decoding each chunk on its own.
76
+ pending = Buffer.concat([pending, chunk.subarray(0, read)]);
77
+ for (;;) {
78
+ const nl = pending.indexOf(0x0a);
79
+ if (nl < 0) break;
80
+ const rendered = formatTranscriptLine(pending.subarray(0, nl).toString("utf8"));
81
+ pending = pending.subarray(nl + 1);
82
+ if (rendered !== undefined) process.stdout.write(`${rendered}\n`);
83
+ }
84
+ if (read > 0) lastChange = Date.now();
85
+ }
86
+
87
+ // Re-read this exact run every poll — not `latestRun`, which would jump
88
+ // to a retry started meanwhile and report its state against the wrong
89
+ // transcript. The daemon writes the row from another process, so looking
90
+ // is the only way to notice the run finished.
91
+ const state = store.getRun(run.id)?.state ?? run.state;
92
+ if (!LIVE_STATES.includes(state) && Date.now() - lastChange >= TAIL_QUIET_MS) {
93
+ process.stdout.write(`run ended: ${state}\n`);
94
+ return;
95
+ }
96
+ await new Promise<void>((resolve) => setTimeout(resolve, TAIL_POLL_MS));
97
+ }
98
+ } finally {
99
+ closeSync(fd);
100
+ }
101
+ } finally {
102
+ store.close();
103
+ }
104
+ }
105
+
106
+ export async function tailCommand(ctx: CommandContext): Promise<void> {
107
+ const issue = ctx.issueArg("tail", ctx.argv[1]);
108
+ await tailRun(findProject(loadConfig(), ctx.projectFlag).name, issue);
109
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `unblock` — clear an issue's blocked and failed labels and restore its queue label so the next tick can claim it again.
3
+ *
4
+ * Moved out of cli.ts's switch by the per-verb module split (#462);
5
+ * only the case wrapper, the injected `ctx` lookups and the imports
6
+ * changed from the original bodies.
7
+ */
8
+
9
+ import type { CommandContext } from "./context.ts";
10
+ import { findProject, loadConfig, resolveCaps } from "../config.ts";
11
+ import { dbPath, openStore } from "../store.ts";
12
+ import { makeTracker } from "../tracker/github.ts";
13
+ import { formatUnblock, unblockIssue } from "../unblock.ts";
14
+
15
+ export async function unblockCommand(ctx: CommandContext): Promise<void> {
16
+ const issue = ctx.issueArg("unblock", ctx.argv[1]);
17
+ const cfg = loadConfig();
18
+ const project = findProject(cfg, ctx.projectFlag);
19
+ const store = openStore(dbPath());
20
+ try {
21
+ const caps = resolveCaps(project, cfg.defaults);
22
+ const outcome = await unblockIssue(
23
+ project,
24
+ makeTracker(project),
25
+ store,
26
+ issue,
27
+ {
28
+ force: ctx.argv.includes("--force"),
29
+ requeue: !ctx.argv.includes("--no-requeue"),
30
+ },
31
+ caps,
32
+ );
33
+ process.stdout.write(`${formatUnblock(issue, outcome, project, caps)}\n`);
34
+ // A refusal must not read as success to a script or a board keypress.
35
+ if (outcome.refused !== undefined) process.exitCode = 3;
36
+ } finally {
37
+ store.close();
38
+ }
39
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `upgrade-install` — run the upgrade transaction as the detached fleet
3
+ * installer (#486): the same engine as `upgrade`, with every surface journaled
4
+ * and the in-process verification and dispatch restore handed to the first
5
+ * tick of the post-restart daemon.
6
+ *
7
+ * This is the command the transient unit spawned by `conductor_install` runs —
8
+ * a process in the system manager, outside the pane's process tree and the
9
+ * daemon's cgroup, so the restarts the transaction performs pass it by. It is
10
+ * not for typing in a pane: like `upgrade`, it refuses to run inside the
11
+ * target Herdr session, because a process the restart kills cannot verify
12
+ * what it installed.
13
+ */
14
+
15
+ import type { CommandContext } from "./context.ts";
16
+ import { runDetachedUpgrade } from "../upgrade.ts";
17
+
18
+ export async function upgradeInstallCommand(ctx: CommandContext): Promise<void> {
19
+ const result = await runDetachedUpgrade({
20
+ version: ctx.flag("to"),
21
+ project: ctx.projectFlag,
22
+ });
23
+ process.stdout.write(
24
+ `install complete — awaiting first-tick verification:\n` +
25
+ ` Bun-global CLI omp-conductor@${result.version}\n` +
26
+ ` omp plugin omp-conductor@${result.version}\n` +
27
+ ` Herdr plugin herdr-conductor@${result.gitHead}\n` +
28
+ ` orchestrator brief managed ORCHESTRATOR.md floor current; POLICY.md preserved\n` +
29
+ ` dispatch paused — the first tick after the restart verifies and restores it\n`,
30
+ );
31
+ }