omp-conductor 0.4.4 → 0.5.0
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 +53 -269
- package/package.json +1 -1
- package/skills/conductor-onboarding/SKILL.md +0 -56
- package/skills/conductor-update/SKILL.md +4 -9
- package/src/briefs/orchestrator.md +13 -19
- package/src/briefs/worker.md +18 -16
- package/src/cli.ts +1 -48
- package/src/config.ts +7 -224
- package/src/confinement.ts +2 -3
- package/src/daemon.ts +49 -406
- package/src/failure-class.ts +44 -0
- package/src/fleet.ts +1 -85
- package/src/gitops.ts +189 -0
- package/src/omp.ts +11 -35
- package/src/orchestrator.ts +0 -4
- package/src/plugin.ts +1 -42
- package/src/session-host.ts +12 -19
- package/src/setup-host.ts +1 -157
- package/src/setup.ts +6 -41
- package/src/store.ts +36 -2
- package/src/tracker/github.ts +1 -1
- package/src/types.ts +1 -85
- package/src/upgrade.ts +0 -88
- package/src/verbs/actions.ts +1 -1
- package/src/verbs/server.ts +3 -28
- package/src/verbs/socket.ts +15 -23
- package/src/worker.ts +1 -13
- package/src/worktree.ts +4 -4
- package/systemd/omp-conductor.service.example +8 -94
- package/src/credentials.ts +0 -2029
package/src/failure-class.ts
CHANGED
|
@@ -62,6 +62,34 @@ function normalise(state: string): string {
|
|
|
62
62
|
* makes every other reading of the row wrong — that is the #362 case, where a
|
|
63
63
|
* `pushed-green` row outlived its own PR and held the issue out of dispatch.
|
|
64
64
|
*/
|
|
65
|
+
/**
|
|
66
|
+
* Harness start failures, matched on the text it prints.
|
|
67
|
+
*
|
|
68
|
+
* Deliberately a small closed list rather than "anything at turn 0": a run that
|
|
69
|
+
* ended at turn 0 for a reason nobody has named is exactly what `unknown` is for,
|
|
70
|
+
* and quietly declaring it an environment fault would waive an attempt that may
|
|
71
|
+
* have been genuinely spent.
|
|
72
|
+
*/
|
|
73
|
+
const START_FAILURE_SIGNATURES = [
|
|
74
|
+
"no model selected",
|
|
75
|
+
"no model configured",
|
|
76
|
+
"invalid api key",
|
|
77
|
+
"authentication failed",
|
|
78
|
+
"could not load its peer dependency",
|
|
79
|
+
] as const;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The first line of a harness start failure, or undefined when this error is not
|
|
83
|
+
* one. Exported so the store's one-time repair recognises exactly what the
|
|
84
|
+
* classifier does — two definitions of "the session never started" would drift.
|
|
85
|
+
*/
|
|
86
|
+
export function startFailure(lastError: string | undefined): string | undefined {
|
|
87
|
+
if (lastError === undefined) return undefined;
|
|
88
|
+
const text = lastError.toLowerCase();
|
|
89
|
+
const hit = START_FAILURE_SIGNATURES.find((signature) => text.includes(signature));
|
|
90
|
+
return hit === undefined ? undefined : lastError.split("\n")[0]?.trim();
|
|
91
|
+
}
|
|
92
|
+
|
|
65
93
|
export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classification {
|
|
66
94
|
const hasArtifacts = run.prUrl !== undefined || run.headSha !== undefined || run.salvageSha !== undefined;
|
|
67
95
|
|
|
@@ -85,6 +113,22 @@ export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classificatio
|
|
|
85
113
|
};
|
|
86
114
|
}
|
|
87
115
|
|
|
116
|
+
// Turn zero with an explicit harness start error is the most classifiable
|
|
117
|
+
// failure there is, and the least deserving of an implementation attempt: the
|
|
118
|
+
// session never got as far as reading the issue. 27 recoveries on the reference
|
|
119
|
+
// fleet were this, logged as `unknown` while the session's own output carried
|
|
120
|
+
// the literal string `No model selected` (#152).
|
|
121
|
+
if ((run.state === "failed" || run.state === "killed") && run.turns === 0) {
|
|
122
|
+
const detail = startFailure(run.lastError);
|
|
123
|
+
if (detail !== undefined) {
|
|
124
|
+
return {
|
|
125
|
+
cls: "env-start-failure",
|
|
126
|
+
recovery: "escalate",
|
|
127
|
+
evidence: `the session never started: ${detail}`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
88
132
|
if (run.state === "blocked") {
|
|
89
133
|
return {
|
|
90
134
|
cls: "question",
|
package/src/fleet.ts
CHANGED
|
@@ -27,8 +27,7 @@ import {
|
|
|
27
27
|
import { createInterface } from "node:readline";
|
|
28
28
|
import { homedir } from "node:os";
|
|
29
29
|
import { dirname, join } from "node:path";
|
|
30
|
-
import { findProject, loadConfig,
|
|
31
|
-
import { describeBoundary, probeHost } from "./credentials.ts";
|
|
30
|
+
import { findProject, loadConfig, stateDir } from "./config.ts";
|
|
32
31
|
import { planUsageLine, readPlanUsage, sharedUsageSource } from "./usage.ts";
|
|
33
32
|
import { readApprovalSurface } from "./approval-surface.ts";
|
|
34
33
|
import { inspectBriefLayout } from "./brief-upgrade.ts";
|
|
@@ -956,15 +955,6 @@ export function formatCodeGraphHealth(graph: CodeGraphHealth, now = Date.now()):
|
|
|
956
955
|
].join("\n");
|
|
957
956
|
}
|
|
958
957
|
|
|
959
|
-
/**
|
|
960
|
-
* The credential boundary row (#125): which mechanism is actually live, and the
|
|
961
|
-
* residuals it does *not* close. Passed in rather than probed inside the
|
|
962
|
-
* formatter so the wording is testable without a host.
|
|
963
|
-
*/
|
|
964
|
-
export interface BoundaryStatus {
|
|
965
|
-
headline: string;
|
|
966
|
-
detail: string[];
|
|
967
|
-
}
|
|
968
958
|
|
|
969
959
|
export function formatFleetStatus(
|
|
970
960
|
s: StatusSnapshot,
|
|
@@ -973,7 +963,6 @@ export function formatFleetStatus(
|
|
|
973
963
|
telegram: TelegramHealth = { kind: "unprobed" },
|
|
974
964
|
now = Date.now(),
|
|
975
965
|
codeGraph: CodeGraphHealth = { configured: false },
|
|
976
|
-
boundary: BoundaryStatus | undefined = undefined,
|
|
977
966
|
brief: string | undefined = undefined,
|
|
978
967
|
decisions: string | undefined = undefined,
|
|
979
968
|
failureClasses: string | undefined = undefined,
|
|
@@ -1029,15 +1018,6 @@ export function formatFleetStatus(
|
|
|
1029
1018
|
}
|
|
1030
1019
|
|
|
1031
1020
|
const graphBlock = formatCodeGraphHealth(codeGraph, now);
|
|
1032
|
-
// Reported every time, never only when it is bad. An operator reading this
|
|
1033
|
-
// has to be able to see "unprotected" on the day they assumed otherwise, and
|
|
1034
|
-
// a line that appears only in the failure case is one whose absence means
|
|
1035
|
-
// nothing (#125). `undefined` here is a status rendered without a host probe,
|
|
1036
|
-
// which is itself worth saying rather than silently omitting.
|
|
1037
|
-
const boundaryLines =
|
|
1038
|
-
boundary === undefined
|
|
1039
|
-
? ["boundary unprobed (no host capability probe was run for this status)"]
|
|
1040
|
-
: [`boundary ${boundary.headline}`, ...boundary.detail.map((d) => ` ${d}`)];
|
|
1041
1021
|
|
|
1042
1022
|
return [
|
|
1043
1023
|
`dispatch ${layers.dispatch}`,
|
|
@@ -1047,7 +1027,6 @@ export function formatFleetStatus(
|
|
|
1047
1027
|
recoveryLine,
|
|
1048
1028
|
herdrLine,
|
|
1049
1029
|
telegramLine,
|
|
1050
|
-
...boundaryLines,
|
|
1051
1030
|
...(brief === undefined ? [] : [brief]),
|
|
1052
1031
|
...(decisions === undefined ? [] : [decisions]),
|
|
1053
1032
|
...(failureClasses === undefined ? [] : [failureClasses]),
|
|
@@ -1128,39 +1107,6 @@ export async function renderStatus(projectName?: string): Promise<string> {
|
|
|
1128
1107
|
]);
|
|
1129
1108
|
const cached = codeGraphFromHealthz(health?.body, project.name);
|
|
1130
1109
|
const codeGraph = cached ?? (await probeCodeGraph(project));
|
|
1131
|
-
// The daemon's own answer wins, and this is not an optimisation.
|
|
1132
|
-
//
|
|
1133
|
-
// Capabilities are a property of the PROCESS, not the host. `status` runs in
|
|
1134
|
-
// an interactive shell with none of the unit's ambient capabilities and none
|
|
1135
|
-
// of the conductor groups, so re-probing here reports `group-mode` — or
|
|
1136
|
-
// "REFUSING DISPATCH" — on a fleet whose daemon is genuinely `uid-pool`. An
|
|
1137
|
-
// operator following the documented verification step would conclude the
|
|
1138
|
-
// boundary had failed while it was working perfectly.
|
|
1139
|
-
//
|
|
1140
|
-
// It is also the only answer that survives a `daemon-reload` without a
|
|
1141
|
-
// restart, which no config check can see: this is what the running process
|
|
1142
|
-
// actually got at startup.
|
|
1143
|
-
//
|
|
1144
|
-
// Falling back to a local probe when no daemon is running is still right —
|
|
1145
|
-
// there is a real question to answer then — but it is labelled, because it is
|
|
1146
|
-
// a different question.
|
|
1147
|
-
const credentials = resolveCredentials(project);
|
|
1148
|
-
const live = boundaryFromHealthz(health?.body);
|
|
1149
|
-
const boundary =
|
|
1150
|
-
live ?? {
|
|
1151
|
-
...describeBoundary(credentials.isolation, await probeHost({ slots: s.caps.maxConcurrentWorkers })),
|
|
1152
|
-
detail:
|
|
1153
|
-
rec === undefined
|
|
1154
|
-
? [
|
|
1155
|
-
"No daemon is running, so this is THIS SHELL's view, not the fleet's — an interactive shell",
|
|
1156
|
-
"holds none of the unit's capabilities. Start the daemon and re-read before concluding anything.",
|
|
1157
|
-
]
|
|
1158
|
-
: [
|
|
1159
|
-
"The daemon did not report a boundary (it predates the field), so this is THIS SHELL's view,",
|
|
1160
|
-
"not the fleet's — an interactive shell holds none of the unit's capabilities. Upgrade and",
|
|
1161
|
-
"restart the daemon before concluding anything.",
|
|
1162
|
-
],
|
|
1163
|
-
};
|
|
1164
1110
|
return formatFleetStatus(
|
|
1165
1111
|
{ ...s, planUsage },
|
|
1166
1112
|
layers,
|
|
@@ -1168,7 +1114,6 @@ export async function renderStatus(projectName?: string): Promise<string> {
|
|
|
1168
1114
|
telegram,
|
|
1169
1115
|
Date.now(),
|
|
1170
1116
|
codeGraph,
|
|
1171
|
-
boundary,
|
|
1172
1117
|
briefStatusLine(project),
|
|
1173
1118
|
decisionStatusLine(project.name),
|
|
1174
1119
|
failureClassBlock(project.name),
|
|
@@ -1570,32 +1515,3 @@ function probeOmpPane(
|
|
|
1570
1515
|
}
|
|
1571
1516
|
}
|
|
1572
1517
|
|
|
1573
|
-
/**
|
|
1574
|
-
* The boundary the running daemon resolved, out of its `/healthz` body.
|
|
1575
|
-
*
|
|
1576
|
-
* Same posture as `codeGraphFromHealthz`: an older daemon that predates the
|
|
1577
|
-
* field simply does not answer, and the caller falls back to a local probe with
|
|
1578
|
-
* that difference stated rather than papered over.
|
|
1579
|
-
*/
|
|
1580
|
-
export function boundaryFromHealthz(
|
|
1581
|
-
body: string | undefined,
|
|
1582
|
-
): { headline: string; detail: string[] } | undefined {
|
|
1583
|
-
// `healthCheck` hands back the raw response TEXT, not a parsed object — the
|
|
1584
|
-
// same shape `codeGraphFromHealthz` takes. Type-guarding this on `object`
|
|
1585
|
-
// meant it never matched, so `status` silently fell back to probing the
|
|
1586
|
-
// operator's own shell and printed "No daemon is running" at a fleet whose
|
|
1587
|
-
// daemon was up. Unit tests fed it objects and agreed with themselves.
|
|
1588
|
-
if (body === undefined || body.length === 0) return undefined;
|
|
1589
|
-
let parsed: unknown;
|
|
1590
|
-
try {
|
|
1591
|
-
parsed = JSON.parse(body);
|
|
1592
|
-
} catch {
|
|
1593
|
-
return undefined;
|
|
1594
|
-
}
|
|
1595
|
-
if (typeof parsed !== "object" || parsed === null) return undefined;
|
|
1596
|
-
const boundary = (parsed as { boundary?: unknown }).boundary;
|
|
1597
|
-
if (typeof boundary !== "object" || boundary === null) return undefined;
|
|
1598
|
-
const headline = (boundary as { headline?: unknown }).headline;
|
|
1599
|
-
if (typeof headline !== "string" || headline.length === 0) return undefined;
|
|
1600
|
-
return { headline, detail: [] };
|
|
1601
|
-
}
|
package/src/gitops.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's own git and `gh` operations.
|
|
3
|
+
*
|
|
4
|
+
* Everything here runs as the daemon, with the operator's credentials, and it
|
|
5
|
+
* is the only place in this package that spawns a credentialed process. That is
|
|
6
|
+
* the whole organising idea: "what can reach the operator's `gh` credential" is
|
|
7
|
+
* answered by this file's call sites rather than by auditing every `Bun.spawn`
|
|
8
|
+
* in the tree.
|
|
9
|
+
*
|
|
10
|
+
* Sessions are not gated at the OS level — a worker is a child process of the
|
|
11
|
+
* daemon and inherits its environment. What keeps a run's publication honest is
|
|
12
|
+
* that the dispatcher performs it: a push or a pull request the daemon did not
|
|
13
|
+
* make is a run it cannot account for. The mediated verbs route a worker's
|
|
14
|
+
* intent through here so the settlement record and the branch cannot disagree.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
import type { ProjectConfig, RepoTarget } from "./types.ts";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One process run, captured. Injected everywhere in this module so the
|
|
23
|
+
* daemon-side push is testable without a network or a credential.
|
|
24
|
+
*/
|
|
25
|
+
export type Exec = (
|
|
26
|
+
argv: readonly string[],
|
|
27
|
+
opts?: { cwd?: string; env?: Record<string, string>; stdin?: string },
|
|
28
|
+
) => Promise<{ code: number; stdout: string; stderr: string }>;
|
|
29
|
+
|
|
30
|
+
/** The real one. Never inherits stdin: an unattended daemon must not block on a prompt. */
|
|
31
|
+
export const spawnCaptured: Exec = async (argv, opts = {}) => {
|
|
32
|
+
const [command, ...rest] = argv;
|
|
33
|
+
if (command === undefined) return { code: 127, stdout: "", stderr: "empty argv" };
|
|
34
|
+
const proc = Bun.spawn([command, ...rest], {
|
|
35
|
+
...(opts.cwd === undefined ? {} : { cwd: opts.cwd }),
|
|
36
|
+
...(opts.env === undefined ? {} : { env: opts.env }),
|
|
37
|
+
stdin: new Blob([opts.stdin ?? ""]),
|
|
38
|
+
stdout: "pipe",
|
|
39
|
+
stderr: "pipe",
|
|
40
|
+
});
|
|
41
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
42
|
+
new Response(proc.stdout).text(),
|
|
43
|
+
new Response(proc.stderr).text(),
|
|
44
|
+
proc.exited,
|
|
45
|
+
]);
|
|
46
|
+
return { code, stdout, stderr };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The daemon's own environment, credentials intact. **The only place this
|
|
51
|
+
* package constructs credential material.**
|
|
52
|
+
*
|
|
53
|
+
* There is nothing clever in the body, and that is deliberate: the value is
|
|
54
|
+
* that it is one named function, so a privileged spawn that does not come
|
|
55
|
+
* through here is a bug, whatever it happens to do.
|
|
56
|
+
*
|
|
57
|
+
* `GIT_TERMINAL_PROMPT=0` rides along because every caller is unattended: a
|
|
58
|
+
* credential prompt nobody can answer is a hang, not a failure.
|
|
59
|
+
*/
|
|
60
|
+
export function credentialedEnv(
|
|
61
|
+
extra: Readonly<Record<string, string>> = {},
|
|
62
|
+
base: Readonly<Record<string, string | undefined>> = process.env,
|
|
63
|
+
): Record<string, string> {
|
|
64
|
+
const env: Record<string, string> = {};
|
|
65
|
+
for (const [key, value] of Object.entries(base)) {
|
|
66
|
+
if (value !== undefined) env[key] = value;
|
|
67
|
+
}
|
|
68
|
+
env["GIT_TERMINAL_PROMPT"] = "0";
|
|
69
|
+
return { ...env, ...extra };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ------------------------------------------------- the privileged publish path
|
|
73
|
+
|
|
74
|
+
/** One run's own repository, as the daemon addresses it. */
|
|
75
|
+
export interface RunRepoRef {
|
|
76
|
+
repo: RepoTarget;
|
|
77
|
+
/** The run's own git repository — not a linked worktree of the mirror. */
|
|
78
|
+
runRepoPath: string;
|
|
79
|
+
branch: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type PushOutcome = { ok: true; sha: string } | { ok: false; stderr: string };
|
|
83
|
+
export type PrOutcome = { ok: true; url: string } | { ok: false; stderr: string };
|
|
84
|
+
|
|
85
|
+
/** A clone URL can carry a token, and it lands in git's own error text. */
|
|
86
|
+
const URL_USERINFO = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^\s/@]+@/g;
|
|
87
|
+
|
|
88
|
+
/** Blank the `user:token@` part of any URL before a string reaches a log or a human. */
|
|
89
|
+
export function scrubUserinfo(text: string): string {
|
|
90
|
+
return text.replace(URL_USERINFO, "$1***@");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function mirrorPath(project: Pick<ProjectConfig, "mirrorRoot">, repo: RepoTarget): string {
|
|
94
|
+
return join(project.mirrorRoot, `${repo.name}.git`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** GitHub slug parsed off the clone URL, falling back to the routing name. */
|
|
98
|
+
export function repoSlugFor(repo: RepoTarget): string {
|
|
99
|
+
const m = /(?:[:/])([^/:]+\/[^/]+?)(?:\.git)?$/.exec(repo.cloneUrl);
|
|
100
|
+
return m?.[1] ?? repo.name;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Publish a run's branch: run repo → mirror → GitHub, **fast-forward only**.
|
|
105
|
+
*
|
|
106
|
+
* The worker commits into its own repository, which is durable and local; every
|
|
107
|
+
* hop that touches the network happens here, so the dispatcher observes the
|
|
108
|
+
* exact sha it recorded.
|
|
109
|
+
*
|
|
110
|
+
* Both hops are fast-forward only and there is no force path anywhere in this
|
|
111
|
+
* package. The fetch refspec deliberately carries **no leading `+`** — that
|
|
112
|
+
* single character is the difference between "advance the mirror's copy of this
|
|
113
|
+
* branch" and "make the mirror's copy whatever the run says it is", and a run
|
|
114
|
+
* repo is the one place in this system that model-executed code fully controls.
|
|
115
|
+
* The push carries no `--force` and no `--force-with-lease`: a lease is still a
|
|
116
|
+
* force, and a rejected push is a decision for a human, not a retry.
|
|
117
|
+
*
|
|
118
|
+
* A rejection — non-fast-forward, protected ref, unauthorised — comes back as a
|
|
119
|
+
* value carrying git's stderr verbatim, because the caller settles the run
|
|
120
|
+
* `failed` on it and a paraphrase is worthless in that report.
|
|
121
|
+
*/
|
|
122
|
+
export async function pushRunBranch(
|
|
123
|
+
project: Pick<ProjectConfig, "mirrorRoot">,
|
|
124
|
+
run: RunRepoRef,
|
|
125
|
+
exec: Exec = spawnCaptured,
|
|
126
|
+
): Promise<PushOutcome> {
|
|
127
|
+
const mirror = mirrorPath(project, run.repo);
|
|
128
|
+
const ref = `refs/heads/${run.branch}`;
|
|
129
|
+
const env = credentialedEnv();
|
|
130
|
+
|
|
131
|
+
const fetched = await exec(["git", "-C", mirror, "fetch", "--no-tags", run.runRepoPath, `${ref}:${ref}`], { env });
|
|
132
|
+
if (fetched.code !== 0) {
|
|
133
|
+
return { ok: false, stderr: scrubUserinfo(fetched.stderr.trim() || fetched.stdout.trim() || `git fetch exited ${String(fetched.code)}`) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const resolved = await exec(["git", "-C", mirror, "rev-parse", ref], { env });
|
|
137
|
+
if (resolved.code !== 0) {
|
|
138
|
+
return { ok: false, stderr: scrubUserinfo(resolved.stderr.trim() || `git rev-parse ${ref} exited ${String(resolved.code)}`) };
|
|
139
|
+
}
|
|
140
|
+
const sha = resolved.stdout.trim();
|
|
141
|
+
|
|
142
|
+
const pushed = await exec(["git", "-C", mirror, "push", "origin", `${ref}:${ref}`], { env });
|
|
143
|
+
if (pushed.code !== 0) {
|
|
144
|
+
return { ok: false, stderr: scrubUserinfo(pushed.stderr.trim() || pushed.stdout.trim() || `git push exited ${String(pushed.code)}`) };
|
|
145
|
+
}
|
|
146
|
+
return { ok: true, sha };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Open the run's pull request from the daemon's side.
|
|
151
|
+
*
|
|
152
|
+
* The body goes over stdin, never argv: a body with newlines, backticks or a
|
|
153
|
+
* leading `-` is ordinary, and interpolating it into a command line is how a
|
|
154
|
+
* worker's report would become an argument.
|
|
155
|
+
*/
|
|
156
|
+
export async function openRunPr(
|
|
157
|
+
project: Pick<ProjectConfig, "mirrorRoot">,
|
|
158
|
+
run: RunRepoRef,
|
|
159
|
+
opts: { title: string; body: string; base: string },
|
|
160
|
+
exec: Exec = spawnCaptured,
|
|
161
|
+
): Promise<PrOutcome> {
|
|
162
|
+
void project;
|
|
163
|
+
const out = await exec(
|
|
164
|
+
[
|
|
165
|
+
"gh",
|
|
166
|
+
"pr",
|
|
167
|
+
"create",
|
|
168
|
+
"--repo",
|
|
169
|
+
repoSlugFor(run.repo),
|
|
170
|
+
"--head",
|
|
171
|
+
run.branch,
|
|
172
|
+
"--base",
|
|
173
|
+
opts.base,
|
|
174
|
+
"--title",
|
|
175
|
+
opts.title,
|
|
176
|
+
"--body-file",
|
|
177
|
+
"-",
|
|
178
|
+
],
|
|
179
|
+
{ env: credentialedEnv(), stdin: opts.body },
|
|
180
|
+
);
|
|
181
|
+
if (out.code !== 0) {
|
|
182
|
+
return { ok: false, stderr: scrubUserinfo(out.stderr.trim() || out.stdout.trim() || `gh pr create exited ${String(out.code)}`) };
|
|
183
|
+
}
|
|
184
|
+
const url = /https:\/\/github\.com\/\S+\/pull\/\d+/.exec(out.stdout)?.[0];
|
|
185
|
+
if (url === undefined) {
|
|
186
|
+
return { ok: false, stderr: `gh pr create succeeded but printed no PR URL: ${out.stdout.trim()}` };
|
|
187
|
+
}
|
|
188
|
+
return { ok: true, url };
|
|
189
|
+
}
|
package/src/omp.ts
CHANGED
|
@@ -19,7 +19,6 @@ import { tmpdir } from "node:os";
|
|
|
19
19
|
import { dirname, join } from "node:path";
|
|
20
20
|
|
|
21
21
|
import { worktreeConfinement } from "./confinement.ts";
|
|
22
|
-
import type { SessionBoundary } from "./credentials.ts";
|
|
23
22
|
import { releasePolicyTripwire } from "./release-policy.ts";
|
|
24
23
|
import type {
|
|
25
24
|
HostToParent,
|
|
@@ -344,14 +343,7 @@ function asRawSession(created: unknown): RawSession {
|
|
|
344
343
|
|
|
345
344
|
// --------------------------------------------------------- the session proxy
|
|
346
345
|
|
|
347
|
-
/**
|
|
348
|
-
* Absolute path of the child entrypoint, resolved against this package.
|
|
349
|
-
*
|
|
350
|
-
* Exported so the boundary suite can assert a slot principal may actually read
|
|
351
|
-
* it. The launcher execs this path under the slot uid, and on a real deployment
|
|
352
|
-
* it sits under the daemon's home — so a home hardened to `0700` makes every
|
|
353
|
-
* worker die before it connects, in a way no shell-based probe notices (#125).
|
|
354
|
-
*/
|
|
346
|
+
/** Absolute path of the child entrypoint, resolved against this package. */
|
|
355
347
|
export const SESSION_HOST = join(import.meta.dir, "session-host.ts");
|
|
356
348
|
|
|
357
349
|
/**
|
|
@@ -381,16 +373,9 @@ export interface CreateSessionOptions {
|
|
|
381
373
|
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
382
374
|
|
|
383
375
|
/**
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
* passes one, and `status` reports which mechanism it names.
|
|
388
|
-
*/
|
|
389
|
-
boundary?: SessionBoundary;
|
|
390
|
-
/**
|
|
391
|
-
* Where the control socket is bound. The daemon puts it inside the run's own
|
|
392
|
-
* boundary root so the slot principal can reach it; omitted, a private temp
|
|
393
|
-
* directory is used, which is what the tests and a `none` fleet want.
|
|
376
|
+
* Where the control socket is bound. The daemon puts it beside the run's own
|
|
377
|
+
* session directory; omitted, a private temp directory is used, which is what
|
|
378
|
+
* the tests want.
|
|
394
379
|
*/
|
|
395
380
|
socketPath?: string;
|
|
396
381
|
/**
|
|
@@ -442,11 +427,10 @@ export interface CreateSessionOptions {
|
|
|
442
427
|
* the deployment mistake it is, not as an unexplained exit code.
|
|
443
428
|
*/
|
|
444
429
|
export async function createSession(opts: CreateSessionOptions): Promise<AgentSessionLike> {
|
|
445
|
-
const boundary = opts.boundary;
|
|
446
430
|
const owned = opts.socketPath === undefined;
|
|
447
|
-
// 0711, not 0700:
|
|
448
|
-
//
|
|
449
|
-
// bind its
|
|
431
|
+
// 0711, not 0700: searchable, so a session reaches its own socket by name, but
|
|
432
|
+
// not listable — nothing here can enumerate the other sockets, unlink one and
|
|
433
|
+
// bind an impostor in its place. Same reasoning as the run workspace parent.
|
|
450
434
|
const socketDir = owned ? mkdtempSync(join(tmpdir(), "omp-session-")) : dirname(opts.socketPath ?? "");
|
|
451
435
|
const socketPath = opts.socketPath ?? join(socketDir, "s");
|
|
452
436
|
mkdirSync(socketDir, { recursive: true, mode: 0o711 });
|
|
@@ -469,11 +453,9 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
469
453
|
resolve();
|
|
470
454
|
});
|
|
471
455
|
});
|
|
472
|
-
// The socket is the run's own channel
|
|
456
|
+
// The socket is the run's own channel, and the daemon's own uid is the only
|
|
457
|
+
// one that speaks on it.
|
|
473
458
|
chmodSync(socketPath, 0o600);
|
|
474
|
-
if (boundary?.principal !== undefined) {
|
|
475
|
-
chownSync(socketPath, boundary.principal.uid, boundary.principal.gid);
|
|
476
|
-
}
|
|
477
459
|
|
|
478
460
|
const spec: SessionHostSpec = {
|
|
479
461
|
socket: socketPath,
|
|
@@ -487,12 +469,7 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
487
469
|
};
|
|
488
470
|
|
|
489
471
|
const log = opts.onChildLog ?? ((line: string) => process.stderr.write(`${line}\n`));
|
|
490
|
-
const argv = [
|
|
491
|
-
...(boundary?.launcher ?? []),
|
|
492
|
-
process.execPath,
|
|
493
|
-
opts.hostModule ?? SESSION_HOST,
|
|
494
|
-
JSON.stringify(spec),
|
|
495
|
-
];
|
|
472
|
+
const argv = [process.execPath, opts.hostModule ?? SESSION_HOST, JSON.stringify(spec)];
|
|
496
473
|
const child = Bun.spawn(argv, {
|
|
497
474
|
cwd: opts.cwd,
|
|
498
475
|
stdin: "ignore",
|
|
@@ -501,7 +478,6 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
501
478
|
// harness build cannot corrupt it.
|
|
502
479
|
stdout: "pipe",
|
|
503
480
|
stderr: "pipe",
|
|
504
|
-
...(boundary === undefined || Object.keys(boundary.env).length === 0 ? {} : { env: boundary.env }),
|
|
505
481
|
});
|
|
506
482
|
|
|
507
483
|
let stderrTail = "";
|
|
@@ -574,7 +550,7 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
574
550
|
new Error(
|
|
575
551
|
`omp-conductor session child did not connect within ${String(
|
|
576
552
|
(opts.startupTimeoutMs ?? START_TIMEOUT_MS) / 1000,
|
|
577
|
-
)}s
|
|
553
|
+
)}s`,
|
|
578
554
|
),
|
|
579
555
|
);
|
|
580
556
|
}, opts.startupTimeoutMs ?? START_TIMEOUT_MS).unref?.();
|
package/src/orchestrator.ts
CHANGED
|
@@ -31,7 +31,6 @@ import { join } from "node:path";
|
|
|
31
31
|
|
|
32
32
|
import { stateDir } from "./config.ts";
|
|
33
33
|
import { formatEscalation } from "./escalate.ts";
|
|
34
|
-
import type { SessionBoundary } from "./credentials.ts";
|
|
35
34
|
import { createSession, disposeSession } from "./omp.ts";
|
|
36
35
|
import type { AgentSessionLike } from "./omp.ts";
|
|
37
36
|
import type { Escalation, ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
|
|
@@ -48,7 +47,6 @@ export type CreateSessionFn = (opts: {
|
|
|
48
47
|
role: SessionRole;
|
|
49
48
|
releaseGrants?: ResolvedGrants;
|
|
50
49
|
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
51
|
-
boundary?: SessionBoundary;
|
|
52
50
|
socketPath?: string;
|
|
53
51
|
verbSocketPath?: string;
|
|
54
52
|
onChildLog?: (line: string) => void;
|
|
@@ -92,7 +90,6 @@ export interface OrchestratorOpts {
|
|
|
92
90
|
* must have no read or write access to any run checkout — a property the
|
|
93
91
|
* adversarial probe asserts rather than assumes.
|
|
94
92
|
*/
|
|
95
|
-
boundary?: SessionBoundary;
|
|
96
93
|
/** Control socket for the session child. See {@link OrchestratorOpts.boundary}. */
|
|
97
94
|
socketPath?: string;
|
|
98
95
|
/**
|
|
@@ -189,7 +186,6 @@ export async function startOrchestrator(o: OrchestratorOpts): Promise<Orchestrat
|
|
|
189
186
|
role: "orchestrator",
|
|
190
187
|
...(o.releaseGrants === undefined ? {} : { releaseGrants: o.releaseGrants }),
|
|
191
188
|
...(o.onReleaseBlocked === undefined ? {} : { onReleaseBlocked: o.onReleaseBlocked }),
|
|
192
|
-
...(o.boundary === undefined ? {} : { boundary: o.boundary }),
|
|
193
189
|
...(o.socketPath === undefined ? {} : { socketPath: o.socketPath }),
|
|
194
190
|
...(o.verbSocketPath === undefined ? {} : { verbSocketPath: o.verbSocketPath }),
|
|
195
191
|
...(o.onChildLog === undefined ? {} : { onChildLog: o.onChildLog }),
|
package/src/plugin.ts
CHANGED
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
repairPolicyBannerCrumbs,
|
|
21
21
|
} from "./brief-upgrade.ts";
|
|
22
22
|
import { configPath, expandHome, findProject, loadConfig, resolveCaps, saveConfig } from "./config.ts";
|
|
23
|
-
import { mechanismSatisfies, probeHost } from "./credentials.ts";
|
|
24
23
|
import { hostRamBytes, recommendedMaxWorkers } from "./host.ts";
|
|
25
24
|
import {
|
|
26
25
|
isPaused,
|
|
@@ -81,13 +80,11 @@ import {
|
|
|
81
80
|
import {
|
|
82
81
|
BASE_FRESHNESS,
|
|
83
82
|
BEHIND_BASE_ACTIONS,
|
|
84
|
-
CREDENTIAL_ISOLATIONS,
|
|
85
83
|
DEFAULT_CAPS,
|
|
86
84
|
DRAFT_POLICIES,
|
|
87
85
|
RELEASE_REQUIREMENTS,
|
|
88
86
|
RELEASE_SHAPES,
|
|
89
87
|
type Caps,
|
|
90
|
-
type CredentialIsolation,
|
|
91
88
|
type ConductorConfig,
|
|
92
89
|
type OrchestratorMode,
|
|
93
90
|
type ProjectConfig,
|
|
@@ -864,43 +861,6 @@ const askAuthorityArea: AreaAsker = async (ctx, a) => ({
|
|
|
864
861
|
* who may act, then under what conditions (#129). */
|
|
865
862
|
const askPolicy: AreaAsker = async (ctx, a) => ({ ...a, policy: await askPolicyPreconditions(ctx, a.policy) });
|
|
866
863
|
|
|
867
|
-
/**
|
|
868
|
-
* Whether model-executed code gets its own OS principal (#125).
|
|
869
|
-
*
|
|
870
|
-
* The host is probed before the question is asked, and the answer the box can
|
|
871
|
-
* actually honour is named in the option itself. That matters because the two
|
|
872
|
-
* failure modes are asymmetric: choosing `none` gets a fleet that dispatches
|
|
873
|
-
* and is unprotected, while choosing `per-run` on a host with no mechanism gets
|
|
874
|
-
* a fleet that refuses every issue. An operator should not have to discover
|
|
875
|
-
* which one they picked by watching the queue stall.
|
|
876
|
-
*/
|
|
877
|
-
const askCredentials: AreaAsker = async (ctx, a) => {
|
|
878
|
-
const probe = await probeHost({ slots: a.caps.maxConcurrentWorkers ?? DEFAULT_CAPS.maxConcurrentWorkers });
|
|
879
|
-
// Each option says whether THIS host can honour it, asked of the same
|
|
880
|
-
// predicate the dispatch gate uses, so the wizard cannot promise a boundary
|
|
881
|
-
// the daemon will then refuse to build.
|
|
882
|
-
const offer = (isolation: CredentialIsolation, claim: string): string =>
|
|
883
|
-
mechanismSatisfies(isolation, probe.mechanism)
|
|
884
|
-
? `${claim} — this host can build it with ${probe.mechanism}`
|
|
885
|
-
: `${claim} — UNAVAILABLE here (${probe.reasons.join("; ") || "no mechanism found"}); dispatch would refuse every issue`;
|
|
886
|
-
const described: { readonly [K in CredentialIsolation]: string } = {
|
|
887
|
-
"per-run": offer("per-run", "each session runs under its own OS principal; it cannot reach the daemon's credentials"),
|
|
888
|
-
"group-mode": offer(
|
|
889
|
-
"group-mode",
|
|
890
|
-
"same uid as the daemon, cross-run separation by group and mode only; bounds accidents, does NOT contain a bash escape",
|
|
891
|
-
),
|
|
892
|
-
none: "sessions run as the daemon's user; env scrubbing only, which same-uid code defeats in one line",
|
|
893
|
-
};
|
|
894
|
-
const isolation = await askLiteral(
|
|
895
|
-
ctx,
|
|
896
|
-
"Credential isolation for worker and orchestrator sessions",
|
|
897
|
-
CREDENTIAL_ISOLATIONS,
|
|
898
|
-
described,
|
|
899
|
-
a.credentials.isolation,
|
|
900
|
-
);
|
|
901
|
-
return { ...a, credentials: { ...a.credentials, isolation } };
|
|
902
|
-
};
|
|
903
|
-
|
|
904
864
|
/** How a stuck run reaches a human, and who triages it when it does. */
|
|
905
865
|
const askEscalation: AreaAsker = async (ctx, a) => {
|
|
906
866
|
const telegram = detectTelegram();
|
|
@@ -957,7 +917,6 @@ const AREA_ASKERS: { readonly [K in AmendAreaId]: AreaAsker } = {
|
|
|
957
917
|
graph: askGraph,
|
|
958
918
|
authority: askAuthorityArea,
|
|
959
919
|
policy: askPolicy,
|
|
960
|
-
credentials: askCredentials,
|
|
961
920
|
escalation: askEscalation,
|
|
962
921
|
reporting: askReporting,
|
|
963
922
|
brief: askBrief,
|
|
@@ -1048,7 +1007,6 @@ async function collectAnswers(
|
|
|
1048
1007
|
a = await askCaps(ctx, a);
|
|
1049
1008
|
a = await askAuthorityArea(ctx, a);
|
|
1050
1009
|
a = await askPolicy(ctx, a);
|
|
1051
|
-
a = await askCredentials(ctx, a);
|
|
1052
1010
|
a = await askWorkerModel(ctx, a);
|
|
1053
1011
|
a = await askEscalation(ctx, a);
|
|
1054
1012
|
a = await askReporting(ctx, a);
|
|
@@ -1166,6 +1124,7 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
|
|
|
1166
1124
|
const telegram = detectTelegram();
|
|
1167
1125
|
const nextConfig = buildConfig(answers, existing);
|
|
1168
1126
|
const project = findProject(nextConfig, answers.projectName);
|
|
1127
|
+
// The same project as it is configured right now, so a moved `workspaceRoot`
|
|
1169
1128
|
if (
|
|
1170
1129
|
project.escalation.orchestrator === "external" &&
|
|
1171
1130
|
!answers.writeOrchestratorBrief &&
|
package/src/session-host.ts
CHANGED
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* The child process one omp session runs in
|
|
3
|
+
* The child process one omp session runs in.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* credential is reachable by running the same binary the daemon runs.
|
|
5
|
+
* Sessions run out of process so that one session's memory, crash or runaway
|
|
6
|
+
* turn is the child's and not the dispatcher's: a harness that exits, hangs or
|
|
7
|
+
* exhausts its heap takes down a process the daemon supervises rather than the
|
|
8
|
+
* daemon itself. It is also what lets `MemoryMax` on the unit govern the whole
|
|
9
|
+
* fleet, and what makes a kill a real kill.
|
|
11
10
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
11
|
+
* This file is the far side: it loads the harness, runs the real session, and
|
|
12
|
+
* speaks a five-verb protocol back over a unix socket to the
|
|
13
|
+
* {@link AgentSessionLike} proxy in `omp.ts`. It holds no conductor state, opens
|
|
14
|
+
* no database, and reads no config — everything it needs arrives in
|
|
15
|
+
* {@link SessionHostSpec}, so the child's inputs are data a caller can see
|
|
16
|
+
* rather than ambient state it inherits.
|
|
18
17
|
*
|
|
19
18
|
* The protocol is deliberately tiny. `AgentSessionLike` has five members, so
|
|
20
19
|
* there are five things to carry, and every one of them is data.
|
|
@@ -156,12 +155,6 @@ export async function runSessionHost(
|
|
|
156
155
|
spec: SessionHostSpec,
|
|
157
156
|
deps: SessionHostDeps = { createSession: createLocalSession },
|
|
158
157
|
): Promise<void> {
|
|
159
|
-
// umask before anything is created. Under uid-pool the run tree is
|
|
160
|
-
// `2770 slot:conductor-daemon`, and the setgid bit only decides the *group*
|
|
161
|
-
// of what this process writes — without `0007` the mode still lands `0644`
|
|
162
|
-
// and the daemon's access to a file the worker created is silently partial,
|
|
163
|
-
// which surfaces as a failed salvage in production rather than here.
|
|
164
|
-
process.umask(0o007);
|
|
165
158
|
|
|
166
159
|
const socket = connect(spec.socket);
|
|
167
160
|
socket.setNoDelay(true);
|