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/README.md +168 -41
- package/package.json +2 -1
- package/src/brief-upgrade.ts +150 -8
- package/src/briefs/orchestrator.md +3 -2
- package/src/briefs/worker.md +1 -1
- package/src/cli.ts +159 -36
- 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 +128 -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/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,
|
|
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
|
|
76
|
-
daemon
|
|
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
|
|
90
|
-
resume
|
|
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.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
475
|
+
const project = flag(argv, "project");
|
|
476
|
+
const text = await renderStatus(project);
|
|
459
477
|
const stalled = stallLine();
|
|
460
|
-
process.stdout.write(`${
|
|
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(
|
|
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
|
-
|
|
491
|
-
process.stdout.write(
|
|
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
|
-
|
|
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
|
|
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 });
|