omp-conductor 0.3.12 → 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/cli.ts CHANGED
@@ -17,15 +17,25 @@ import {
17
17
  inspectBriefLayout,
18
18
  migrateToPolicy,
19
19
  proposeRetrofit,
20
+ repairPolicyBannerCrumbs,
20
21
  writeMergedBrief,
21
22
  } from "./brief-upgrade.ts";
22
23
  import { findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
23
- import { dbPath, formatStatus, runDaemon, setPaused, statusSnapshot } from "./daemon.ts";
24
+ import { dbPath, runDaemon, setPaused } from "./daemon.ts";
25
+ import {
26
+ armTicks,
27
+ clearPaneHalt,
28
+ disarmTicks,
29
+ halt,
30
+ haltWithPane,
31
+ hold,
32
+ releaseHold,
33
+ renderStatus,
34
+ } from "./fleet.ts";
24
35
  import { formatGraphSetup, graphRepos, writeGraphSetup, type GraphSetupWrite } from "./graph.ts";
25
36
  import {
26
37
  clearRecord,
27
38
  DEFAULT_PORT,
28
- healthCheck,
29
39
  livingDaemon,
30
40
  restartDaemon,
31
41
  startDaemon,
@@ -52,6 +62,11 @@ usage:
52
62
  omp-conductor stop
53
63
  omp-conductor restart [--port N] [--project NAME]
54
64
  omp-conductor status [--project NAME]
65
+ omp-conductor hold [--project NAME]
66
+ omp-conductor halt [--pane] [--project NAME]
67
+ omp-conductor arm [--project NAME]
68
+ omp-conductor disarm [--project NAME]
69
+ omp-conductor release-pane [--project NAME]
55
70
  omp-conductor tail <issue> [--project NAME]
56
71
  omp-conductor unblock <issue> [--project NAME]
57
72
  omp-conductor daemon [--once] [--port N] [--project NAME]
@@ -72,8 +87,22 @@ usage:
72
87
  dirty live worktrees before orphaning those rows — see README
73
88
  "Deploying a new package onto a busy fleet". Goes through systemctl
74
89
  when the unit owns the live pid.
75
- status show pause state, caps, active runs, today's usage, and whether a
76
- daemon is alive.
90
+ status layered fleet report: dispatch (running|paused|stopped), ticks
91
+ (armed|disarmed|…), pane, herdr, daemon then caps and active runs.
92
+ hold soft stop: pause claiming AND disarm ticks. Daemon and pane stay up.
93
+ This is "stop the conductor overnight" without killing processes.
94
+ halt hold, then stop the dispatch daemon (systemctl-aware). Pane stays up
95
+ unless --pane is passed.
96
+ halt --pane
97
+ halt, then pin herdr-conductor recovery off for the conductor agent
98
+ only — does NOT stop herdr-fleet.service or any other herdr session.
99
+ Clear the pin with release-pane when you want recovery again.
100
+ arm proof-gated: send a Telegram challenge and write the arm marker only
101
+ after your reply appears as a user turn in the orchestrator transcript.
102
+ Never auto-armed by resume/hold.
103
+ disarm remove the arm marker so ticks skip. Processes untouched.
104
+ release-pane
105
+ clear the halt --pane recovery pin so herdr-conductor may resume again.
77
106
  tail follow the newest run for <issue>: the worker's assistant text and
78
107
  the tools it calls, printed as they land. Workers are sessions inside
79
108
  the daemon rather than terminals, so this is the only way to watch
@@ -86,8 +115,8 @@ usage:
86
115
  cost a worker.
87
116
  daemon run the dispatch loop in the foreground; --once runs a single tick
88
117
  and exits. This is what \`start\` launches.
89
- pause stop claiming new work. The running daemon notices on its next tick.
90
- resume allow claiming again.
118
+ pause stop claiming new work only (ticks keep firing if armed). Prefer hold.
119
+ resume clear pause only — does NOT re-arm. Prefer hold's inverse: resume + arm.
91
120
  graph-setup
92
121
  print how to set up the code-graph indexes workers query instead of
93
122
  grepping: the clone commands for any missing index-only clone, the
@@ -106,10 +135,18 @@ usage:
106
135
  help print this text (also --help, -h).
107
136
 
108
137
  Pause is a flag file under the state directory, so it applies to every project
109
- and survives a daemon restart. A running daemon is tracked by a pidfile under
110
- $OMP_CONDUCTOR_RUNTIME_DIR (default ~/.omp/run/daemons/omp-conductor), written
111
- whether it was started in the background or in the foreground, and probed for
112
- liveness on every read a stale one never blocks a start.`;
138
+ and survives a daemon restart. Hold also removes the arm marker the heartbeat
139
+ reads, so both brains go quiet without killing processes. A running daemon is
140
+ tracked by a pidfile under $OMP_CONDUCTOR_RUNTIME_DIR (default
141
+ ~/.omp/run/daemons/omp-conductor), written whether it was started in the
142
+ background or in the foreground, and probed for liveness on every read — a
143
+ stale one never blocks a start.
144
+
145
+ Stop the conductor:
146
+ hold no claims, no tick sends (inspectable)
147
+ halt hold + stop dispatch daemon
148
+ halt --pane halt + pin conductor-pane recovery off
149
+ resume && arm clear pause, then prove inbound Telegram before ticks resume`;
113
150
 
114
151
  /** Accepts both `--port 9000` and `--port=9000`; returns undefined when absent. */
115
152
  function flag(argv: string[], name: string): string | undefined {
@@ -145,26 +182,6 @@ function humanDuration(ms: number): string {
145
182
  return `${Math.floor(h / 24)}d ${String(h % 24).padStart(2, "0")}h`;
146
183
  }
147
184
 
148
- /**
149
- * The daemon half of `status`. Kept separate from `formatStatus` because the
150
- * pidfile and the endpoint are the CLI's business, not the dispatcher's, and
151
- * because a pid without a `/healthz` answer is a distinct — and interesting —
152
- * state: the process is up but the loop is not serving.
153
- */
154
- async function daemonSection(): Promise<string> {
155
- const rec = livingDaemon();
156
- if (rec === undefined) return "daemon not running";
157
- const health = await healthCheck(rec.port);
158
- return [
159
- "daemon",
160
- ` pid ${rec.pid}`,
161
- ` uptime ${humanDuration(Date.now() - rec.startedAt)}`,
162
- ` port ${rec.port}`,
163
- ...(rec.project === undefined ? [] : [` project ${rec.project}`]),
164
- ` healthz ${health.ok ? `ok ${health.body ?? ""}`.trimEnd() : "unreachable — the process is up but not serving"}`,
165
- ` log ${rec.logFile}`,
166
- ].join("\n");
167
- }
168
185
 
169
186
  /**
170
187
  * The orchestrator half, and the one thing `status` has ever known about the
@@ -455,9 +472,84 @@ try {
455
472
  }
456
473
 
457
474
  case "status": {
458
- const snapshot = formatStatus(statusSnapshot(flag(argv, "project")));
475
+ const project = flag(argv, "project");
476
+ const text = await renderStatus(project);
459
477
  const stalled = stallLine();
460
- process.stdout.write(`${snapshot}\n\n${await daemonSection()}\n${stalled === undefined ? "" : `\n${stalled}\n`}`);
478
+ process.stdout.write(`${text}${stalled === undefined ? "\n" : `\n\n${stalled}\n`}`);
479
+ break;
480
+ }
481
+
482
+ case "hold": {
483
+ const r = hold(flag(argv, "project"));
484
+ process.stdout.write(
485
+ `held — claiming paused` +
486
+ `${r.wasPaused ? " (already paused)" : ""}` +
487
+ `; ticks disarmed at ${r.disarmed.path}` +
488
+ `${r.disarmed.wasArmed ? "" : " (was already disarmed)"}\n` +
489
+ `daemon and pane left running; halt to stop the daemon\n`,
490
+ );
491
+ break;
492
+ }
493
+
494
+ case "halt": {
495
+ const project = flag(argv, "project");
496
+ const withPane = argv.includes("--pane");
497
+ if (withPane) {
498
+ const r = await haltWithPane(project);
499
+ const stopLine =
500
+ r.stop.kind === "not-running"
501
+ ? "daemon was not running"
502
+ : `daemon stopped — pid ${r.stop.pid}${r.stop.via === "systemctl" ? " (via systemctl)" : ""}`;
503
+ process.stdout.write(
504
+ `halted — claiming paused; ticks disarmed at ${r.hold.disarmed.path}\n` +
505
+ `${stopLine}\n` +
506
+ `pane recovery pinned at ${r.pane.pinPath}\n` +
507
+ `pane stop: ${r.pane.stopped} — ${r.pane.detail}\n` +
508
+ ` (conductor agent "${r.pane.agentName}" only — herdr-fleet.service was NOT stopped;\n` +
509
+ ` release-pane clears the pin when you want recovery again)\n`,
510
+ );
511
+ } else {
512
+ const r = await halt(project);
513
+ const stopLine =
514
+ r.stop.kind === "not-running"
515
+ ? "daemon was not running"
516
+ : `daemon stopped — pid ${r.stop.pid}${r.stop.via === "systemctl" ? " (via systemctl)" : ""}`;
517
+ process.stdout.write(
518
+ `halted — claiming paused; ticks disarmed at ${r.hold.disarmed.path}\n` +
519
+ `${stopLine}\n` +
520
+ `pane left running (pass --pane to stop the conductor agent and pin recovery)\n`,
521
+ );
522
+ }
523
+ break;
524
+ }
525
+
526
+ case "arm": {
527
+ process.stdout.write("arm: sending inbound Telegram challenge…\n");
528
+ const r = await armTicks(flag(argv, "project"));
529
+ process.stdout.write(
530
+ `ARMED — inbound round-trip proved with owner ${r.owner}; ticks are now live.\n` +
531
+ `marker ${r.path}${r.alreadyArmed ? " (replaced previous marker)" : ""}\n`,
532
+ );
533
+ break;
534
+ }
535
+
536
+ case "disarm": {
537
+ const r = disarmTicks(flag(argv, "project"));
538
+ process.stdout.write(
539
+ `disarmed — ticks will be skipped` +
540
+ `${r.wasArmed ? "" : " (was already disarmed)"}\n` +
541
+ `marker ${r.path}\n`,
542
+ );
543
+ break;
544
+ }
545
+
546
+ case "release-pane": {
547
+ const r = clearPaneHalt(flag(argv, "project"));
548
+ process.stdout.write(
549
+ r.wasHalted
550
+ ? `pane recovery pin cleared — ${r.path}\nherdr-conductor may resume the fleet agent again\n`
551
+ : `no pane recovery pin at ${r.path}\n`,
552
+ );
461
553
  break;
462
554
  }
463
555
 
@@ -483,12 +575,18 @@ try {
483
575
 
484
576
  case "pause":
485
577
  setPaused(true);
486
- process.stdout.write("paused — no new work will be claimed\n");
578
+ process.stdout.write(
579
+ "paused — no new work will be claimed\n" +
580
+ "note: ticks keep firing if armed; use hold to silence both\n",
581
+ );
487
582
  break;
488
583
 
489
584
  case "resume":
490
- setPaused(false);
491
- process.stdout.write("resumed — work will be claimed on the next tick\n");
585
+ releaseHold();
586
+ process.stdout.write(
587
+ "resumed — claiming allowed on the next tick\n" +
588
+ "note: did NOT re-arm; run arm after an inbound Telegram proof to resume ticks\n",
589
+ );
492
590
  break;
493
591
 
494
592
  case "graph-setup": {
@@ -593,7 +691,32 @@ try {
593
691
 
594
692
  if (argv.includes("--migrate")) {
595
693
  if (layout.kind === "overlay") {
596
- process.stdout.write(`${formatBriefStatus(path, layout)}\n`);
694
+ if (!argv.includes("--apply")) {
695
+ process.stdout.write(
696
+ `${formatBriefStatus(path, layout)}\n\n` +
697
+ "POLICY.md already present. --migrate --apply will strip any leading\n" +
698
+ "banner-comment crumbs from POLICY.md and recompose ORCHESTRATOR.md.\n",
699
+ );
700
+ break;
701
+ }
702
+ if (project === undefined) {
703
+ process.stderr.write(
704
+ "omp-conductor: repairing an overlay needs --project (or a config) so the floor renders.\n",
705
+ );
706
+ process.exit(1);
707
+ }
708
+ const repaired = repairPolicyBannerCrumbs({
709
+ orchestratorPath: layout.orchestratorPath,
710
+ policyPath: layout.policyPath,
711
+ floor: renderFloorForProject(project),
712
+ });
713
+ if (repaired === undefined) {
714
+ process.stdout.write(
715
+ `${formatBriefStatus(path, layout)}\n\nrecomposed ORCHESTRATOR.md — POLICY.md needed no crumb strip.\n`,
716
+ );
717
+ } else {
718
+ process.stdout.write(`${formatMigrateResult(repaired)}\n`);
719
+ }
597
720
  break;
598
721
  }
599
722
  if (layout.kind === "missing") {
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Mechanical worktree confinement for worker sessions.
3
+ *
4
+ * The harness has no first-class fs-policy field, but `createAgentSession`
5
+ * accepts inline `extensions` that subscribe to `tool_call` and can return
6
+ * `{ block: true }` before a tool runs (see the harness `protected-paths`
7
+ * example). Workers get that gate for structured file tools; the orchestrator
8
+ * does not — it has to read the state directory and briefs.
9
+ *
10
+ * `bash` is deliberately not confined here: its input is an opaque shell
11
+ * string, and parsing it is a false-sense of security. Closing that gap is a
12
+ * least-privilege uid (deployment), documented beside this module's README
13
+ * section — not a regex over `rm -rf`.
14
+ */
15
+
16
+ import { existsSync, realpathSync } from "node:fs";
17
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
18
+
19
+ /** Tools whose structured `path` (or path-like) field we can gate. */
20
+ const GATED = new Set(["write", "edit", "read", "grep", "glob"]);
21
+
22
+ /**
23
+ * Resolve `candidate` as a worker would, then ask whether it stays under
24
+ * `root`. Symlink-aware: existing path components are realpath'd so a link
25
+ * planted inside the worktree cannot escape by string-prefix tricks.
26
+ *
27
+ * A path that does not exist yet (a new write) realpaths the deepest existing
28
+ * ancestor and appends the rest — the same TOCTOU posture as the harness's
29
+ * own workspace confinement helper.
30
+ */
31
+ export function isInsideWorktree(root: string, candidate: string): boolean {
32
+ if (candidate.length === 0 || candidate.includes("\0")) return false;
33
+
34
+ let rootReal: string;
35
+ try {
36
+ rootReal = realpathSync(resolve(root));
37
+ } catch {
38
+ rootReal = resolve(root);
39
+ }
40
+
41
+ const abs = resolve(rootReal, candidate);
42
+
43
+ const missing: string[] = [];
44
+ let probe = abs;
45
+ while (!existsSync(probe)) {
46
+ const parent = dirname(probe);
47
+ if (parent === probe) break;
48
+ missing.unshift(basename(probe));
49
+ probe = parent;
50
+ }
51
+
52
+ let base: string;
53
+ try {
54
+ base = realpathSync(probe);
55
+ } catch {
56
+ base = probe;
57
+ }
58
+ const resolved = missing.length === 0 ? base : join(base, ...missing);
59
+
60
+ const rel = relative(rootReal, resolved);
61
+ // Inside ⇒ "" or a relative path that does not climb out. Absolute `rel` is
62
+ // a Windows drive mismatch; anything starting with `..` has left the root.
63
+ return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
64
+ }
65
+
66
+ /** Pull the path-like field a gated tool carries, if any. */
67
+ export function pathFromToolInput(toolName: string, input: Record<string, unknown>): string | undefined {
68
+ if (!GATED.has(toolName)) return undefined;
69
+ const path = input.path;
70
+ if (typeof path === "string" && path.length > 0) return path;
71
+ // glob / grep sometimes scope via target_directory / path_filter; only a
72
+ // concrete directory root is confinable without inventing a glob parser.
73
+ const target = input.target_directory ?? input.cwd;
74
+ if (typeof target === "string" && target.length > 0) return target;
75
+ return undefined;
76
+ }
77
+
78
+ export type ConfineDecision = { block: true; reason: string };
79
+
80
+ /**
81
+ * Decide whether one tool_call may run. Undefined means "no opinion" (allow).
82
+ * Pure so tests pin the gate without standing up a harness session.
83
+ */
84
+ export function confineToolCall(
85
+ root: string,
86
+ toolName: string,
87
+ input: Record<string, unknown>,
88
+ ): ConfineDecision | undefined {
89
+ const path = pathFromToolInput(toolName, input);
90
+ if (path === undefined) return undefined;
91
+ if (isInsideWorktree(root, path)) return undefined;
92
+ return {
93
+ block: true,
94
+ reason:
95
+ `Blocked: ${toolName} path "${path}" is outside the worker worktree (${root}). ` +
96
+ `Structured file tools may only touch the assigned checkout; use paths under it.`,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Minimal extension surface this package needs. Kept duck-typed so the peer
102
+ * harness does not have to be on disk for `tsc` — same reason `omp.ts` exists.
103
+ */
104
+ export interface ConfinementPi {
105
+ on(
106
+ event: "tool_call",
107
+ handler: (
108
+ event: { toolName: string; input: Record<string, unknown> },
109
+ ctx: unknown,
110
+ ) => ConfineDecision | undefined | Promise<ConfineDecision | undefined>,
111
+ ): void;
112
+ }
113
+
114
+ /**
115
+ * Inline extension factory for `createAgentSession({ extensions: [...] })`.
116
+ * Installs the worktree gate on every structured file tool_call.
117
+ */
118
+ export function worktreeConfinement(root: string): (pi: ConfinementPi) => void {
119
+ const rootAbs = resolve(root);
120
+ return (pi) => {
121
+ pi.on("tool_call", (event) => confineToolCall(rootAbs, event.toolName, event.input));
122
+ };
123
+ }
package/src/daemon.ts CHANGED
@@ -826,7 +826,8 @@ export interface Admission {
826
826
  *
827
827
  * Exported so the admission rules can be pinned without spawning a worker.
828
828
  * Every one of them exists because of a live incident, and each guards a
829
- * different way the same issue gets worked twice.
829
+ * different way the same issue gets worked twice — including epic siblings
830
+ * racing onto the same files (#48).
830
831
  *
831
832
  * Takes the slice of `Deps` it actually reads rather than the whole thing: what
832
833
  * admission is allowed to consult is the point of the function, and a `Deps`
@@ -838,7 +839,34 @@ export async function admitCandidates(
838
839
  slots: number,
839
840
  ): Promise<Admission[]> {
840
841
  const { project, caps, tracker, store } = d;
841
- const busy = new Set(store.activeRuns(project.name).map((r) => r.issue));
842
+ const busyIssues = store.activeRuns(project.name).map((r) => r.issue);
843
+ const busy = new Set(busyIssues);
844
+
845
+ // parent -> blocking issue. Seeded from active runs (including pushed-green),
846
+ // then extended by candidates admitted earlier in this same pass so two
847
+ // siblings never both clear the gate in one tick.
848
+ const occupiedParents = new Map<number, number>();
849
+ const parentCache = new Map<number, number | undefined>();
850
+
851
+ const resolveParent = async (issue: number): Promise<number | undefined> => {
852
+ if (parentCache.has(issue)) return parentCache.get(issue);
853
+ const parent = await tracker.parentOf(issue);
854
+ parentCache.set(issue, parent);
855
+ return parent;
856
+ };
857
+
858
+ // Bounded by concurrent workers, not queue depth. A failed lookup here cannot
859
+ // mark an epic occupied; candidates still fail closed on their own parentOf.
860
+ for (const issue of busyIssues) {
861
+ try {
862
+ const parent = await resolveParent(issue);
863
+ if (parent !== undefined && !occupiedParents.has(parent)) {
864
+ occupiedParents.set(parent, issue);
865
+ }
866
+ } catch (err) {
867
+ log(`#${issue} parent lookup failed while seeding epic occupancy (${errText(err)})`);
868
+ }
869
+ }
842
870
 
843
871
  const admitted: Admission[] = [];
844
872
  for (const r of routed) {
@@ -862,6 +890,27 @@ export async function admitCandidates(
862
890
  continue;
863
891
  }
864
892
 
893
+ // Soft concurrency per epic: at most one in-flight child of a given parent.
894
+ // No parent means today's concurrent admission. Cheap local filters already
895
+ // ran; this sits before the open-PR API call so a held sibling frees the
896
+ // slot for unrelated work without spending a closers query.
897
+ let parent: number | undefined;
898
+ try {
899
+ parent = await resolveParent(r.issue.number);
900
+ } catch (err) {
901
+ log(`#${r.issue.number} held: parent check failed (${errText(err)}) — retrying next tick`);
902
+ continue;
903
+ }
904
+ if (parent !== undefined) {
905
+ const blocker = occupiedParents.get(parent);
906
+ if (blocker !== undefined) {
907
+ log(
908
+ `#${r.issue.number} skipped: sibling #${blocker} in flight under epic #${parent}`,
909
+ );
910
+ continue;
911
+ }
912
+ }
913
+
865
914
  // The busy set is built from run rows, so it can only speak for work this
866
915
  // database recorded. Work pushed before this store existed — a migration, a
867
916
  // wiped or relocated state dir, a restore onto a new host — looks exactly
@@ -889,6 +938,7 @@ export async function admitCandidates(
889
938
  }
890
939
 
891
940
  admitted.push({ r, attempt: prior + 1 });
941
+ if (parent !== undefined) occupiedParents.set(parent, r.issue.number);
892
942
  }
893
943
 
894
944
  return admitted;
@@ -1341,6 +1391,9 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
1341
1391
  paused: isPaused(),
1342
1392
  activeRuns: store.activeRuns(project.name).length,
1343
1393
  project: project.name,
1394
+ // Resident set of *this* process: workers are in-process omp sessions,
1395
+ // so the unit's Memory peak is this number, not a separate worker pid.
1396
+ rssBytes: process.memoryUsage().rss,
1344
1397
  });
1345
1398
  }
1346
1399
  return new Response("not found\n", { status: 404 });