omp-conductor 0.3.13 → 0.3.15

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/plugin.ts CHANGED
@@ -22,15 +22,25 @@ import {
22
22
  writeMergedBrief,
23
23
  } from "./brief-upgrade.ts";
24
24
  import { configPath, expandHome, findProject, loadConfig, saveConfig } from "./config.ts";
25
+ import { hostRamBytes, recommendedMaxWorkers } from "./host.ts";
25
26
  import {
26
27
  armConductor,
27
- formatStatus,
28
28
  isPaused,
29
29
  previewQueue,
30
30
  setPaused,
31
- statusSnapshot,
32
31
  type QueuePreview,
33
32
  } from "./daemon.ts";
33
+ import {
34
+ armTicks,
35
+ clearPaneHalt,
36
+ disarmTicks,
37
+ halt,
38
+ haltWithPane,
39
+ hold,
40
+ releaseHold,
41
+ renderStatus,
42
+ } from "./fleet.ts";
43
+
34
44
  import { defaultGraphRoot } from "./graph.ts";
35
45
  import {
36
46
  AMEND_AREAS,
@@ -124,9 +134,14 @@ const SUBCOMMANDS: Completion[] = [
124
134
  label: "setup",
125
135
  description: "wizard: config, labels, dry run, then arm — or amend one area of a configured project",
126
136
  },
127
- { value: "status", label: "status", description: "pause state, caps, active runs, today's usage" },
128
- { value: "pause", label: "pause", description: "stop claiming new work" },
129
- { value: "resume", label: "resume", description: "allow claiming again" },
137
+ { value: "status", label: "status", description: "layered fleet report: dispatch, ticks, pane, herdr, daemon" },
138
+ { value: "hold", label: "hold", description: "soft stop: pause claiming AND disarm ticks" },
139
+ { value: "halt", label: "halt", description: "hold + stop dispatch daemon; pass --pane to pin recovery off" },
140
+ { value: "arm", label: "arm", description: "proof-gated: inbound Telegram round-trip, then write arm marker" },
141
+ { value: "disarm", label: "disarm", description: "remove arm marker so ticks skip" },
142
+ { value: "release-pane", label: "release-pane", description: "clear halt --pane recovery pin" },
143
+ { value: "pause", label: "pause", description: "stop claiming only (ticks keep firing if armed); prefer hold" },
144
+ { value: "resume", label: "resume", description: "clear pause only — does not re-arm" },
130
145
  {
131
146
  value: "brief-upgrade",
132
147
  label: "brief-upgrade",
@@ -136,9 +151,14 @@ const SUBCOMMANDS: Completion[] = [
136
151
 
137
152
  const USAGE = [
138
153
  "/conductor setup [project] create a project, or amend one area of one you already have",
139
- "/conductor status [project] pause state, caps, active runs, today's usage",
140
- "/conductor pause stop claiming new work",
141
- "/conductor resume allow claiming again",
154
+ "/conductor status [project] layered fleet report (dispatch, ticks, pane, herdr, daemon)",
155
+ "/conductor hold [project] soft stop: pause claiming AND disarm ticks",
156
+ "/conductor halt [--pane] [project] hold + stop daemon; --pane pins conductor recovery off only",
157
+ "/conductor arm [project] proof-gated inbound Telegram round-trip, then arm ticks",
158
+ "/conductor disarm [project] remove arm marker (ticks skip)",
159
+ "/conductor release-pane [project] clear halt --pane recovery pin",
160
+ "/conductor pause stop claiming only (prefer hold)",
161
+ "/conductor resume clear pause only — does not re-arm",
142
162
  "/conductor brief-upgrade [project] check ORCHESTRATOR.md against the shipped brief",
143
163
  ].join("\n");
144
164
 
@@ -537,21 +557,38 @@ const askCaps: AreaAsker = async (ctx, a) => {
537
557
  const caps: Partial<Caps> = { ...a.caps };
538
558
  const spendLabel =
539
559
  DEFAULT_CAPS.dailySpendUsd === null ? "no spend cap" : `$${DEFAULT_CAPS.dailySpendUsd}/day`;
560
+ // Workers are in-process omp sessions inside the daemon PID. On a host under
561
+ // 16 GiB the measured shape (2 workers + orchestrator) peaks at 3–4 GB and
562
+ // swaps on a 7.6 GB shared VPS (#51) — setup therefore defaults to 1 there.
563
+ const workersDefault =
564
+ caps.maxConcurrentWorkers ?? recommendedMaxWorkers(hostRamBytes());
565
+ const smallHostNote =
566
+ workersDefault < DEFAULT_CAPS.maxConcurrentWorkers
567
+ ? ` This host looks under 16 GiB RAM, so the worker default is ${workersDefault} instead of ${DEFAULT_CAPS.maxConcurrentWorkers}.`
568
+ : "";
540
569
  const tuneCaps = await ctx.ui.confirm(
541
570
  "Caps",
542
- `Defaults: ${DEFAULT_CAPS.maxConcurrentWorkers} workers, ` +
571
+ `Defaults: ${workersDefault} workers, ` +
543
572
  `${spendLabel}, ${DEFAULT_CAPS.workerMaxTurns} turns and ` +
544
573
  `${Math.round(DEFAULT_CAPS.workerWallClockMs / 60000)} min per worker, ` +
545
- `${DEFAULT_CAPS.maxAttemptsPerIssue} attempts per issue. Change them?`,
574
+ `${DEFAULT_CAPS.maxAttemptsPerIssue} attempts per issue.${smallHostNote} Change them?`,
546
575
  );
547
- if (!tuneCaps) return { ...a, caps };
576
+ if (!tuneCaps) {
577
+ if (
578
+ caps.maxConcurrentWorkers === undefined &&
579
+ workersDefault !== DEFAULT_CAPS.maxConcurrentWorkers
580
+ ) {
581
+ caps.maxConcurrentWorkers = workersDefault;
582
+ }
583
+ return { ...a, caps };
584
+ }
548
585
 
549
586
  // Spelled out rather than looped: adding a cap should fail to compile here,
550
587
  // not silently go unasked.
551
588
  caps.maxConcurrentWorkers = await askNumber(
552
589
  ctx,
553
590
  "Max concurrent workers",
554
- caps.maxConcurrentWorkers ?? DEFAULT_CAPS.maxConcurrentWorkers,
591
+ workersDefault,
555
592
  );
556
593
  caps.dailySpendUsd = await askSpendCap(
557
594
  ctx,
@@ -915,7 +952,10 @@ export default function conductorPlugin(pi: PluginApi): void {
915
952
  handler: async (args, ctx) => {
916
953
  // findProject() throws when the config holds several projects and none is
917
954
  // named, so the project name rides along as an optional second word.
918
- const [sub, project] = args.trim().split(/\s+/);
955
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
956
+ const sub = tokens[0];
957
+ const withPane = tokens.includes("--pane");
958
+ const project = tokens.find((t, i) => i > 0 && t !== "--pane");
919
959
 
920
960
  try {
921
961
  switch (sub) {
@@ -924,17 +964,70 @@ export default function conductorPlugin(pi: PluginApi): void {
924
964
  break;
925
965
 
926
966
  case "status":
927
- ctx.ui.notify(formatStatus(statusSnapshot(project)), "info");
967
+ ctx.ui.notify(await renderStatus(project), "info");
968
+ break;
969
+
970
+ case "hold": {
971
+ const r = hold(project);
972
+ ctx.ui.notify(
973
+ `Held — claiming paused; ticks disarmed at ${r.disarmed.path}. Daemon and pane left running.`,
974
+ "info",
975
+ );
928
976
  break;
977
+ }
978
+
979
+ case "halt": {
980
+ if (withPane) {
981
+ const r = await haltWithPane(project);
982
+ const stop =
983
+ r.stop.kind === "not-running"
984
+ ? "daemon was not running"
985
+ : `daemon stopped (pid ${r.stop.pid})`;
986
+ ctx.ui.notify(
987
+ `Halted — ${stop}. Pane: ${r.pane.stopped} (${r.pane.detail}); recovery pinned at ${r.pane.pinPath}.`,
988
+ "info",
989
+ );
990
+ } else {
991
+ const r = await halt(project);
992
+ const stop =
993
+ r.stop.kind === "not-running"
994
+ ? "daemon was not running"
995
+ : `daemon stopped (pid ${r.stop.pid})`;
996
+ ctx.ui.notify(`Halted — ${stop}. Pane left running.`, "info");
997
+ }
998
+ break;
999
+ }
1000
+
1001
+ case "arm": {
1002
+ ctx.ui.notify("Arm: sending inbound Telegram challenge — reply in the bot DM…", "info");
1003
+ const r = await armTicks(project);
1004
+ ctx.ui.notify(`ARMED — owner ${r.owner}; marker ${r.path}`, "info");
1005
+ break;
1006
+ }
1007
+
1008
+ case "disarm": {
1009
+ const r = disarmTicks(project);
1010
+ ctx.ui.notify(`Disarmed — ${r.path}`, "info");
1011
+ break;
1012
+ }
1013
+
1014
+ case "release-pane": {
1015
+ const r = clearPaneHalt(project);
1016
+ ctx.ui.notify(
1017
+ r.wasHalted ? `Pane recovery pin cleared (${r.path}).` : `No pane recovery pin at ${r.path}.`,
1018
+ "info",
1019
+ );
1020
+ break;
1021
+ }
929
1022
 
930
1023
  case "pause":
931
1024
  setPaused(true);
932
- ctx.ui.notify("Conductor pausedno new work will be claimed.", "info");
1025
+ ctx.ui.notify("Paused claiming only ticks keep firing if armed. Prefer /conductor hold.", "info");
933
1026
  break;
934
1027
 
935
1028
  case "resume":
936
- setPaused(false);
937
- ctx.ui.notify("Conductor resumedwork will be claimed on the next tick.", "info");
1029
+ releaseHold();
1030
+ ctx.ui.notify("Resumed claimingdid NOT re-arm. Run /conductor arm for ticks.", "info");
938
1031
  break;
939
1032
 
940
1033
  case "brief-upgrade": {
@@ -10,7 +10,7 @@
10
10
  * ~200-400ms) and failure classification by matching human-readable stderr
11
11
  * instead of reading a status code. Upgrade path when either bites: replace the
12
12
  * body of `gh()` with `fetch("https://api.github.com/...")` using a token from
13
- * `gh auth token`; the eight Tracker methods above it stay untouched.
13
+ * `gh auth token`; the nine Tracker methods above it stay untouched.
14
14
  */
15
15
 
16
16
  import type { PrState, ProjectConfig, ReadyIssue, Tracker } from "../types.ts";
@@ -189,6 +189,22 @@ export function prStateFrom(raw: string): PrState | undefined {
189
189
  }
190
190
  }
191
191
 
192
+ /**
193
+ * Parent number from a `gh issue view --json parent` payload, or undefined
194
+ * when the issue has no parent. Throws on a payload that claims a parent but
195
+ * does not carry a usable number — admission would otherwise treat garbage as
196
+ * "no parent" and skip the epic soft-cap.
197
+ */
198
+ export function parentNumberFrom(raw: string): number | undefined {
199
+ const parsed = JSON.parse(raw) as { parent: { number?: unknown } | null };
200
+ if (parsed.parent == null) return undefined;
201
+ const n = parsed.parent.number;
202
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
203
+ throw new Error(`unexpected parent number ${JSON.stringify(n)}`);
204
+ }
205
+ return n;
206
+ }
207
+
192
208
  export function makeTracker(p: ProjectConfig): Tracker {
193
209
  const repo = p.tracker.repo;
194
210
 
@@ -261,6 +277,14 @@ export function makeTracker(p: ProjectConfig): Tracker {
261
277
  await gh(["issue", "edit", String(parent), "--repo", repo, "--add-sub-issue", String(child)]);
262
278
  },
263
279
 
280
+ async parentOf(issue: number): Promise<number | undefined> {
281
+ // Native sub-issue parent, not a body scrape. Verified on gh 2.97.0:
282
+ // `parent` is null with no link, otherwise `{ number, title, url, ... }`.
283
+ return parentNumberFrom(
284
+ await gh(["issue", "view", String(issue), "--repo", repo, "--json", "parent"]),
285
+ );
286
+ },
287
+
264
288
  async openCloserFor(issue: number): Promise<string | undefined> {
265
289
  // GraphQL wants the halves of `owner/repo` separately. Config validates
266
290
  // that spelling, so an empty half means a hand-edited config: `gh` then
package/src/types.ts CHANGED
@@ -230,6 +230,16 @@ export interface Tracker {
230
230
  /** Records that a worker split its issue, so follow-up work stays traceable
231
231
  * to the request that spawned it. */
232
232
  linkParent(child: number, parent: number): Promise<void>;
233
+ /**
234
+ * The parent epic/issue number when `issue` is a linked sub-issue, or
235
+ * undefined when it has no parent.
236
+ *
237
+ * Admission serializes siblings under one parent (at most one in flight). A
238
+ * missing parent means "not epic-serialized" and keeps today's concurrent
239
+ * admission. A thrown error is fail-closed for that candidate — same posture
240
+ * as {@link Tracker.openCloserFor}.
241
+ */
242
+ parentOf(issue: number): Promise<number | undefined>;
233
243
  /**
234
244
  * The URL of an OPEN pull request that already closes `issue`, or undefined
235
245
  * when none does.
package/src/worker.ts CHANGED
@@ -152,6 +152,8 @@ export async function runWorker(
152
152
  cwd: o.cwd,
153
153
  ...(o.sessionDir === undefined ? {} : { sessionDir: o.sessionDir }),
154
154
  ...(o.model === undefined ? {} : { model: o.model }),
155
+ // Prevention half of #24: structured file tools cannot leave this worktree.
156
+ confineToCwd: true,
155
157
  });
156
158
 
157
159
  // Before the first turn, not after the last: a caller that only learns the
@@ -0,0 +1,54 @@
1
+ # Example unit for a supervised omp-conductor daemon.
2
+ #
3
+ # Why MemoryMax exists: workers are *in-process* omp sessions inside this
4
+ # service's MainPID (not separate processes). With the default two workers plus
5
+ # the orchestrator session, journald on a reference 7.6 GB host recorded
6
+ # Memory peaks of ~3.2–4.2 GB for this unit (issue #51). That is expected load,
7
+ # not a leak — and on a shared VPS it is enough to thrash swap or OOM the
8
+ # daemon mid-flight (orphan path).
9
+ #
10
+ # Before enabling:
11
+ # 1. Set User=/Group=/HOME=/PATH for the account that owns ~/.omp/conductor.
12
+ # 2. Point ExecStart at the omp-conductor binary on that host.
13
+ # 3. On hosts under ~16 GiB RAM, prefer maxConcurrentWorkers=1 in config and
14
+ # consider MemoryMax=3G.
15
+ # 4. Do not co-locate ClickHouse + other multi-GB services beside a 2-worker
16
+ # fleet on a ≤8 GB box.
17
+ #
18
+ # Install:
19
+ # sudo cp omp-conductor.service.example /etc/systemd/system/omp-conductor.service
20
+ # sudo systemctl daemon-reload && sudo systemctl enable --now omp-conductor
21
+
22
+ [Unit]
23
+ Description=omp-conductor dispatch daemon
24
+ Documentation=https://github.com/TerrifiedBug/conductor
25
+ After=network-online.target
26
+ Wants=network-online.target
27
+
28
+ [Service]
29
+ Type=simple
30
+ # REPLACE: the account that owns ~/.omp/conductor and the gh auth the tracker uses.
31
+ User=fleet
32
+ Group=fleet
33
+ Environment=HOME=/home/fleet
34
+ # systemd's default PATH has no user installs; include wherever `omp` /
35
+ # `omp-conductor` and `gh` live on this host.
36
+ Environment=PATH=/home/fleet/.local/bin:/usr/local/bin:/usr/bin:/bin
37
+ WorkingDirectory=/home/fleet
38
+
39
+ # Foreground daemon so systemd tracks MainPID. `omp-conductor start` backgrounds;
40
+ # under a unit, call `daemon` directly.
41
+ ExecStart=/home/fleet/.local/bin/omp-conductor daemon --port 7432
42
+
43
+ Restart=on-failure
44
+ # Handled SIGTERM exits 143; without this, Restart=on-failure brings the unit
45
+ # straight back after `systemctl stop` / `omp-conductor stop`.
46
+ SuccessExitStatus=0 143
47
+
48
+ MemoryAccounting=yes
49
+ # Hard ceiling just above measured 2-worker peaks. Crossing it cgroups-OOMs the
50
+ # unit — prefer lowering maxConcurrentWorkers before raising this.
51
+ MemoryMax=5G
52
+
53
+ [Install]
54
+ WantedBy=multi-user.target