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
|
@@ -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 });
|