omp-conductor 0.18.0 → 0.18.1
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 +34 -0
- package/REFERENCE.md +60 -10
- package/agents/to-spec.md +90 -0
- package/package.json +2 -1
- package/schema/config.schema.json +29 -0
- package/src/admission.ts +204 -75
- package/src/ask.ts +268 -7
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +42 -14
- package/src/briefs/to-spec.md +84 -0
- package/src/briefs/worker.md +2 -1
- package/src/cli.ts +2 -0
- package/src/command-help.ts +11 -0
- package/src/command-manifest.ts +22 -0
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +50 -2
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +24 -0
- package/src/config.ts +42 -1
- package/src/daemon.ts +965 -36
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +235 -17
- package/src/diff-flags.ts +75 -1
- package/src/doctor.ts +52 -0
- package/src/escalate.ts +9 -3
- package/src/failure-class.ts +28 -2
- package/src/fleet.ts +146 -22
- package/src/gitops.ts +188 -81
- package/src/graph-health.ts +35 -1
- package/src/graph.ts +66 -1
- package/src/harness-loader.ts +59 -0
- package/src/host.ts +567 -2
- package/src/lifecycle.ts +122 -1
- package/src/omp.ts +227 -20
- package/src/orchestrator-tick.ts +1386 -15
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/session-host.ts +99 -5
- package/src/settlement.ts +69 -17
- package/src/setup-host.ts +1205 -6
- package/src/setup-install.ts +28 -0
- package/src/setup-wizard.ts +13 -2
- package/src/setup.ts +29 -13
- package/src/shell.ts +15 -0
- package/src/status-render.ts +78 -11
- package/src/store.ts +443 -42
- package/src/to-spec.ts +387 -0
- package/src/tracker/github.ts +104 -14
- package/src/types.ts +343 -13
- package/src/upgrade-verify.ts +209 -2
- package/src/upgrade.ts +175 -1
- package/src/verbs/protocol.ts +39 -0
- package/src/verbs/server.ts +730 -56
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +25 -2
- package/src/worktree.ts +29 -12
package/src/session-host.ts
CHANGED
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
|
|
22
22
|
import { connect } from "node:net";
|
|
23
23
|
|
|
24
|
+
import { identityMismatch } from "./host.ts";
|
|
24
25
|
import { createLocalSession, disposeSession, type AgentSessionLike } from "./omp.ts";
|
|
26
|
+
import type { GraphToolsObservation } from "./types.ts";
|
|
25
27
|
import type { GateShape, ReleaseBlockContext } from "./release-policy.ts";
|
|
26
28
|
import type { ResolvedGrants, SessionRole } from "./types.ts";
|
|
27
29
|
|
|
@@ -34,6 +36,16 @@ export interface SessionHostSpec {
|
|
|
34
36
|
socket: string;
|
|
35
37
|
cwd: string;
|
|
36
38
|
role: SessionRole;
|
|
39
|
+
/**
|
|
40
|
+
* The kernel identity this session was launched under (#798): the uid/gid
|
|
41
|
+
* setpriv dropped to before this process started. The child asserts
|
|
42
|
+
* `process.getuid()/getgid()` against it as its first act, and refuses to
|
|
43
|
+
* build a session when they differ — a worker session whose kernel identity
|
|
44
|
+
* is not the one its parent established must not run worker code. Absent,
|
|
45
|
+
* the session runs as the daemon's own uid, which is what every
|
|
46
|
+
* non-worker surface (orchestrator, tests) wants.
|
|
47
|
+
*/
|
|
48
|
+
identity?: { uid: number; gid: number };
|
|
37
49
|
sessionDir?: string;
|
|
38
50
|
model?: string;
|
|
39
51
|
resume?: boolean;
|
|
@@ -79,7 +91,35 @@ export type ParentToHost =
|
|
|
79
91
|
|
|
80
92
|
/** Child → parent. */
|
|
81
93
|
export type HostToParent =
|
|
82
|
-
| {
|
|
94
|
+
| {
|
|
95
|
+
t: "ready";
|
|
96
|
+
sessionFile?: string;
|
|
97
|
+
modelFallbackMessage?: string;
|
|
98
|
+
/**
|
|
99
|
+
* The omp-conductor version this child process loaded, attested at the
|
|
100
|
+
* ready handshake (#832): the session host's `ready` is the exact-session
|
|
101
|
+
* handoff the upgrade verifies against, so the live process's own loaded
|
|
102
|
+
* module version travels with it instead of being read off disk later.
|
|
103
|
+
* Absent only when the version cannot be read — absence is "not
|
|
104
|
+
* attested", never "the newest".
|
|
105
|
+
*/
|
|
106
|
+
extensionVersion?: string;
|
|
107
|
+
/** Exact peer entry and package version loaded by this child (#828). */
|
|
108
|
+
harnessPath?: string;
|
|
109
|
+
harnessVersion?: string;
|
|
110
|
+
}
|
|
111
|
+
| {
|
|
112
|
+
t: "graph-tools";
|
|
113
|
+
/**
|
|
114
|
+
* The code-graph session observation (#726): whether the graph MCP tools
|
|
115
|
+
* were in this session's registry. Sent once the session's tool registry
|
|
116
|
+
* settles (it finalises after creation, so the ready handshake never
|
|
117
|
+
* waits for it). Absent value never travels — no message means no
|
|
118
|
+
* observation was recorded, never "graph tools absent", which is the
|
|
119
|
+
* `present: false` truth value.
|
|
120
|
+
*/
|
|
121
|
+
graphTools: GraphToolsObservation;
|
|
122
|
+
}
|
|
83
123
|
| { t: "start-error"; message: string }
|
|
84
124
|
| { t: "event"; event: unknown }
|
|
85
125
|
| { t: "session-file"; path: string }
|
|
@@ -172,11 +212,17 @@ export interface SessionHostDeps {
|
|
|
172
212
|
* into the same `Error` the in-process path would have raised, so a missing
|
|
173
213
|
* peer dependency still reads as a deployment mistake rather than as an
|
|
174
214
|
* unexplained child exit.
|
|
215
|
+
*
|
|
216
|
+
* Returns `false` when the session refused to start because the identity it
|
|
217
|
+
* was launched for does not match the kernel identity this process actually
|
|
218
|
+
* runs under — that refusal is the fail-closed arm of the worker boundary,
|
|
219
|
+
* and the caller turns it into a non-zero exit so nothing downstream can read
|
|
220
|
+
* it as a clean session.
|
|
175
221
|
*/
|
|
176
222
|
export async function runSessionHost(
|
|
177
223
|
spec: SessionHostSpec,
|
|
178
224
|
deps: SessionHostDeps = { createSession: createLocalSession },
|
|
179
|
-
): Promise<
|
|
225
|
+
): Promise<boolean> {
|
|
180
226
|
|
|
181
227
|
const socket = connect(spec.socket);
|
|
182
228
|
socket.setNoDelay(true);
|
|
@@ -184,6 +230,26 @@ export async function runSessionHost(
|
|
|
184
230
|
if (!socket.writableEnded) socket.write(encodeFrame(message));
|
|
185
231
|
};
|
|
186
232
|
|
|
233
|
+
// The kernel identity check comes as early as there is a socket to report
|
|
234
|
+
// over and before the harness (`createSession`) has run a single byte of
|
|
235
|
+
// worker-controlled code. A child whose uid/gid are not the ones its parent
|
|
236
|
+
// launched it for proves the boundary did not hold — the daemon would be
|
|
237
|
+
// supervising a session that is not what it thinks it is.
|
|
238
|
+
const violation = identityMismatch(spec.identity);
|
|
239
|
+
if (violation !== undefined) {
|
|
240
|
+
send({ t: "start-error", message: violation });
|
|
241
|
+
// The frame must reach the parent before this process exits: `process.exit`
|
|
242
|
+
// does not flush the socket, and a parent that only observes the exit used
|
|
243
|
+
// to report a bare exit code instead of the child's own words. The parent
|
|
244
|
+
// resolves the run as failed with no live child — fail closed.
|
|
245
|
+
await new Promise<void>((resolve) => {
|
|
246
|
+
socket.once("finish", () => resolve());
|
|
247
|
+
socket.once("error", () => resolve());
|
|
248
|
+
socket.end();
|
|
249
|
+
});
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
|
|
187
253
|
const { promise: connected, resolve: onConnect, reject: onConnectFail } = Promise.withResolvers<void>();
|
|
188
254
|
socket.once("connect", () => {
|
|
189
255
|
onConnect();
|
|
@@ -220,12 +286,15 @@ export async function runSessionHost(
|
|
|
220
286
|
// The frame must reach the parent before this process exits: `process.exit`
|
|
221
287
|
// does not flush the socket, and a parent that only observes the exit used
|
|
222
288
|
// to report a bare exit code instead of the child's own words.
|
|
289
|
+
// This is a failed start, not an identity refusal: the process still walks
|
|
290
|
+
// away with a clean exit code, because the parent's verdict comes from the
|
|
291
|
+
// frame, not the status — 91 is reserved for the boundary refusal above.
|
|
223
292
|
await new Promise<void>((resolve) => {
|
|
224
293
|
socket.once("finish", () => resolve());
|
|
225
294
|
socket.once("error", () => resolve());
|
|
226
295
|
socket.end();
|
|
227
296
|
});
|
|
228
|
-
return;
|
|
297
|
+
return true;
|
|
229
298
|
}
|
|
230
299
|
|
|
231
300
|
const live = session;
|
|
@@ -248,8 +317,28 @@ export async function runSessionHost(
|
|
|
248
317
|
...(live.modelFallbackMessage === undefined
|
|
249
318
|
? {}
|
|
250
319
|
: { modelFallbackMessage: live.modelFallbackMessage }),
|
|
320
|
+
// The loaded-module attestation (#832): what THIS process's session
|
|
321
|
+
// machinery actually executes, carried in the exact-session handoff the
|
|
322
|
+
// upgrade verifies against.
|
|
323
|
+
...(live.extensionVersion === undefined ? {} : { extensionVersion: live.extensionVersion }),
|
|
324
|
+
...(live.harnessPath === undefined ? {} : { harnessPath: live.harnessPath }),
|
|
325
|
+
...(live.harnessVersion === undefined ? {} : { harnessVersion: live.harnessVersion }),
|
|
251
326
|
});
|
|
252
327
|
|
|
328
|
+
// The code-graph session observation (#726) rides its own frame: the factory
|
|
329
|
+
// reads the session's tool registry asynchronously (the harness finalises
|
|
330
|
+
// MCP wiring after creation), so the ready handshake never waits for it. The
|
|
331
|
+
// child is the session's own process — this is the runtime observation the
|
|
332
|
+
// config check in `resolvePrereqs` deliberately is not, and an observation
|
|
333
|
+
// that never resolves simply never travels: no message is "not recorded",
|
|
334
|
+
// never "graph tools absent".
|
|
335
|
+
const graphToolsReady = live.graphToolsReady;
|
|
336
|
+
if (graphToolsReady !== undefined) {
|
|
337
|
+
void graphToolsReady.then((graphTools) => {
|
|
338
|
+
if (graphTools !== undefined) send({ t: "graph-tools", graphTools });
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
253
342
|
const { promise: finished, resolve: finish } = Promise.withResolvers<void>();
|
|
254
343
|
let buffer = "";
|
|
255
344
|
socket.on("data", (chunk: Buffer) => {
|
|
@@ -316,6 +405,7 @@ export async function runSessionHost(
|
|
|
316
405
|
// Teardown noise must not become the run's outcome.
|
|
317
406
|
}
|
|
318
407
|
socket.end();
|
|
408
|
+
return true;
|
|
319
409
|
}
|
|
320
410
|
|
|
321
411
|
/** Reads the spec the parent handed over. Argv, not stdin: stdin is the harness's. */
|
|
@@ -331,6 +421,10 @@ if (import.meta.main) {
|
|
|
331
421
|
// Exit code is the parent's only signal when the socket never opened, so a
|
|
332
422
|
// spec that will not parse fails loudly here rather than as a silent hang.
|
|
333
423
|
const spec = specFromArgv(process.argv);
|
|
334
|
-
await runSessionHost(spec);
|
|
335
|
-
process
|
|
424
|
+
const started = await runSessionHost(spec);
|
|
425
|
+
// A refused start — the launched identity did not match this process's
|
|
426
|
+
// kernel identity — exits 91 (the same code the rejected cgroup-boundary
|
|
427
|
+
// launcher used) so an operator reading `ps` or the exit status sees a
|
|
428
|
+
// boundary refusal, never a clean exit.
|
|
429
|
+
process.exit(started ? 0 : 91);
|
|
336
430
|
}
|
package/src/settlement.ts
CHANGED
|
@@ -23,8 +23,9 @@ import {
|
|
|
23
23
|
deriveChangedLine,
|
|
24
24
|
} from "./diff-flags.ts";
|
|
25
25
|
import { classifyRun, normalise, type ClassifyFacts } from "./failure-class.ts";
|
|
26
|
+
import { GhPrMissingError } from "./tracker/github.ts";
|
|
26
27
|
import { formatModelsTried, modelsTried } from "./model-fallback.ts";
|
|
27
|
-
import { PR_LOOKUP_WINDOW_MS } from "./
|
|
28
|
+
import { PR_LOOKUP_WINDOW_MS } from "./decisions.ts";
|
|
28
29
|
import {
|
|
29
30
|
removeWorktree,
|
|
30
31
|
salvageWip,
|
|
@@ -35,6 +36,7 @@ import type {
|
|
|
35
36
|
Caps,
|
|
36
37
|
Escalation,
|
|
37
38
|
FailureClass,
|
|
39
|
+
FileLane,
|
|
38
40
|
MergedPrInfo,
|
|
39
41
|
OpenCloser,
|
|
40
42
|
PrState,
|
|
@@ -107,7 +109,7 @@ export interface SettlementAuditResult {
|
|
|
107
109
|
*/
|
|
108
110
|
export async function collectSettlementFlags(
|
|
109
111
|
tracker: Pick<Tracker, "prDiff" | "prBody">,
|
|
110
|
-
claim: { prUrl?: string; issueText: string; sessionFile?: string },
|
|
112
|
+
claim: { prUrl?: string; issueText: string; sessionFile?: string; lane?: FileLane },
|
|
111
113
|
): Promise<SettlementAuditResult> {
|
|
112
114
|
if (claim.prUrl === undefined) return { flags: [UNREADABLE_TREE_FLAG], truncated: false };
|
|
113
115
|
// The diff and the claimed-proof body read in parallel; both are advisory,
|
|
@@ -135,6 +137,7 @@ export async function collectSettlementFlags(
|
|
|
135
137
|
diff,
|
|
136
138
|
...(prBody === undefined ? {} : { prBody }),
|
|
137
139
|
...(transcript === undefined ? {} : { transcript }),
|
|
140
|
+
...(claim.lane === undefined ? {} : { lane: claim.lane }),
|
|
138
141
|
}),
|
|
139
142
|
truncated: diff.truncated,
|
|
140
143
|
changedLine: deriveChangedLine(diff),
|
|
@@ -174,14 +177,24 @@ export interface Settlement {
|
|
|
174
177
|
* `returned-for-revision` at settlement. A review decision asks for another
|
|
175
178
|
* implementation pass, not a failure, so it consumes the continuation budget
|
|
176
179
|
* instead of the failed-attempt budget.
|
|
180
|
+
* - `missing` — the claimed PR definitively does not exist (a definitive 404
|
|
181
|
+
* from the tracker, never a transient outage). Work was pushed but no PR
|
|
182
|
+
* ever appeared, so the honest terminal is the same as `closed`: `failed`,
|
|
183
|
+
* work preserved on the branch and the issue returned to the queue as a
|
|
184
|
+
* continuation. Nothing retrying can recover — a missing PR does not grow
|
|
185
|
+
* back (#779).
|
|
177
186
|
* - `open`, and undefined — nothing changes. Undefined is "could not tell": a
|
|
178
|
-
* flaky network, a revoked token
|
|
179
|
-
*
|
|
180
|
-
*
|
|
187
|
+
* flaky network, a revoked token. Settling on it would record a merge that
|
|
188
|
+
* never happened, and the next tick asks again for free. An ambiguous answer
|
|
189
|
+
* must never settle a row.
|
|
181
190
|
*/
|
|
182
|
-
export function settlementFor(
|
|
191
|
+
export function settlementFor(
|
|
192
|
+
pr: PrState | "missing" | undefined,
|
|
193
|
+
prUrl: string,
|
|
194
|
+
): Settlement | undefined {
|
|
183
195
|
if (pr === "merged") return { state: "merged", reason: `${prUrl} merged` };
|
|
184
196
|
if (pr === "closed") return { state: "failed", reason: `${prUrl} closed without merging` };
|
|
197
|
+
if (pr === "missing") return { state: "failed", reason: `PR ${prUrl} does not exist` };
|
|
185
198
|
return undefined;
|
|
186
199
|
}
|
|
187
200
|
|
|
@@ -311,16 +324,27 @@ export async function settlePushedGreen(
|
|
|
311
324
|
// must not buy a `gh` call every five minutes forever.
|
|
312
325
|
if (run.prUrl === undefined) continue;
|
|
313
326
|
|
|
314
|
-
let pr: PrState | undefined;
|
|
327
|
+
let pr: PrState | "missing" | undefined;
|
|
315
328
|
try {
|
|
316
329
|
pr = await tracker.prState(run.prUrl);
|
|
317
330
|
} catch (err) {
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
|
|
331
|
+
// The adapter throws exactly one classified error: `GhPrMissingError`,
|
|
332
|
+
// an individual REST 404 the adapter has corroborated with a
|
|
333
|
+
// same-repository pulls-list read, so the claimed PR definitively does
|
|
334
|
+
// not exist (#779). That is not "could not tell" — retrying can never
|
|
335
|
+
// conjure the PR — so the row is settled as a missing PR through the
|
|
336
|
+
// same mapping as a closed one: work preserved on the branch, busy guard
|
|
337
|
+
// released, issue back on the queue. A raw 404 that was never
|
|
338
|
+
// corroborated (a hidden repository, a lost token scope) stays "could
|
|
339
|
+
// not tell": the instance check is the corroboration, and every other
|
|
340
|
+
// failure retries next tick, per row — a tracker that throws
|
|
341
|
+
// unclassified must cost its own row, not the whole sweep.
|
|
342
|
+
if (err instanceof GhPrMissingError) {
|
|
343
|
+
pr = "missing";
|
|
344
|
+
} else {
|
|
345
|
+
log(`#${run.issue} not settled: PR state lookup failed (${errText(err)}) — retrying next tick`);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
324
348
|
}
|
|
325
349
|
|
|
326
350
|
const settlement = settlementFor(pr, run.prUrl);
|
|
@@ -1297,10 +1321,23 @@ export async function classifyAndRecover(d: SettlementDeps): Promise<number> {
|
|
|
1297
1321
|
}
|
|
1298
1322
|
}
|
|
1299
1323
|
} catch (err) {
|
|
1300
|
-
//
|
|
1301
|
-
//
|
|
1302
|
-
|
|
1303
|
-
|
|
1324
|
+
// The tracker throws exactly one classified error: `GhPrMissingError`,
|
|
1325
|
+
// an individual REST 404 the adapter corroborated with a
|
|
1326
|
+
// same-repository pulls-list read, so the claimed PR definitively does
|
|
1327
|
+
// not exist (#779). That is a fact, not "could not tell" — retrying can
|
|
1328
|
+
// never conjure the PR — so it is recorded as the absent-PR fact and
|
|
1329
|
+
// classification proceeds instead of leaving the row in
|
|
1330
|
+
// `selectUnclassified` to be offered forever. Everything else (a 5xx, a
|
|
1331
|
+
// revoked token, an opaque 404) is genuinely unreadable and the next
|
|
1332
|
+
// tick asks again for free.
|
|
1333
|
+
if (err instanceof GhPrMissingError) {
|
|
1334
|
+
facts.pr = "missing";
|
|
1335
|
+
} else {
|
|
1336
|
+
// Per row, like every other sweep here: one unreachable PR must not stop
|
|
1337
|
+
// the rest from being classified. The next tick asks again for free.
|
|
1338
|
+
log(`#${run.issue} not classified: fact gathering failed (${errText(err)}) — retrying next tick`);
|
|
1339
|
+
continue;
|
|
1340
|
+
}
|
|
1304
1341
|
}
|
|
1305
1342
|
|
|
1306
1343
|
const { cls, recovery, evidence } = classifyRun(classifiedRun, facts, caps);
|
|
@@ -1310,6 +1347,21 @@ export async function classifyAndRecover(d: SettlementDeps): Promise<number> {
|
|
|
1310
1347
|
// base does move under it.
|
|
1311
1348
|
if (run.state === "pushed-green" && cls === "unknown") continue;
|
|
1312
1349
|
|
|
1350
|
+
// A recovery of `none` is the classifier naming no failure class for the
|
|
1351
|
+
// row at all: a terminal `failed` row whose PR is open and every check is
|
|
1352
|
+
// green is the strongest mechanical success evidence there is short of
|
|
1353
|
+
// merge, so it is not a failed attempt. Restore it to `pushed-green` — the
|
|
1354
|
+
// settle sweep owns the merge decision from there, `failuresFor` stops
|
|
1355
|
+
// counting it, and no `[unknown]` escalation is persisted. Nothing else is
|
|
1356
|
+
// written, so the row stays in the normal `pushed-green` lifecycle
|
|
1357
|
+
// (merge-conflict on a moved base, settle on a merged or closed PR) rather
|
|
1358
|
+
// than being pinned by a class it never had (#766).
|
|
1359
|
+
if (recovery === "none") {
|
|
1360
|
+
store.updateRun(run.id, { state: "pushed-green" });
|
|
1361
|
+
log(`#${run.issue} restored to pushed-green: ${evidence}`);
|
|
1362
|
+
continue;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1313
1365
|
const retry = run.failureClass !== undefined;
|
|
1314
1366
|
store.updateRun(run.id, { failureClass: cls, recoveryAction: recovery });
|
|
1315
1367
|
log(
|