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/README.md +168 -41
- package/package.json +2 -1
- package/src/brief-upgrade.ts +1 -1
- package/src/briefs/orchestrator.md +3 -2
- package/src/briefs/worker.md +1 -1
- package/src/cli.ts +132 -35
- package/src/confinement.ts +123 -0
- package/src/daemon.ts +55 -2
- package/src/fleet.ts +1100 -0
- package/src/host.ts +90 -0
- package/src/lifecycle.ts +182 -77
- package/src/omp.ts +12 -0
- package/src/orchestrator-tick.ts +1 -1
- package/src/plugin.ts +110 -17
- package/src/tracker/github.ts +25 -1
- package/src/types.ts +10 -0
- package/src/worker.ts +2 -0
- package/systemd/omp-conductor.service.example +54 -0
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: "
|
|
128
|
-
{ value: "
|
|
129
|
-
{ value: "
|
|
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]
|
|
140
|
-
"/conductor
|
|
141
|
-
"/conductor
|
|
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: ${
|
|
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
|
|
574
|
+
`${DEFAULT_CAPS.maxAttemptsPerIssue} attempts per issue.${smallHostNote} Change them?`,
|
|
546
575
|
);
|
|
547
|
-
if (!tuneCaps)
|
|
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
|
-
|
|
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
|
|
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(
|
|
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("
|
|
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
|
-
|
|
937
|
-
ctx.ui.notify("
|
|
1029
|
+
releaseHold();
|
|
1030
|
+
ctx.ui.notify("Resumed claiming — did NOT re-arm. Run /conductor arm for ticks.", "info");
|
|
938
1031
|
break;
|
|
939
1032
|
|
|
940
1033
|
case "brief-upgrade": {
|
package/src/tracker/github.ts
CHANGED
|
@@ -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
|
|
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
|