omp-conductor 0.4.2 → 0.4.3

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.
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Why a run ended badly, and what to do about it (#132).
3
+ *
4
+ * Half of this fleet's spend produced no merged PR, and every one of those runs
5
+ * ended at a human who then re-derived the same triage by hand: read the row,
6
+ * read the PR's checks, decide whether to requeue, re-run, settle or escalate —
7
+ * and then threw the conclusion away. The daemon already knew *that* a run ended
8
+ * badly; it never recorded *why*, so a mechanical recovery cost human attention
9
+ * and an implementation budget it had not spent.
10
+ *
11
+ * This module is the "why", and nothing else: pure, synchronous, and given the
12
+ * facts rather than fetching them. The caller gathers what the row needs (a PR
13
+ * state, a mergeability, a check list), classifies, then performs the one
14
+ * recovery the class names. Keeping the decision table here is what makes "one
15
+ * green test per class" possible without a daemon, a tracker or a network.
16
+ */
17
+
18
+ import type { FailureClass, RecoveryAction, RunRecord } from "./types.ts";
19
+
20
+ /** Facts the caller fetched, each only for the rows that need it. */
21
+ export interface ClassifyFacts {
22
+ pr?: "open" | "merged" | "closed";
23
+ mergeable?: "conflicting" | "clean" | "unknown";
24
+ checks?: { name: string; state: string; link?: string }[];
25
+ }
26
+
27
+ export interface Classification {
28
+ cls: FailureClass;
29
+ recovery: RecoveryAction;
30
+ /** One line naming the signals that matched, carried into the escalation. */
31
+ evidence: string;
32
+ }
33
+
34
+ /**
35
+ * Check states GitHub reports for a run that never produced a verdict.
36
+ *
37
+ * Infrastructure, not code: a cancelled or timed-out check says nothing about
38
+ * the diff, so re-running it is free information and charging an implementation
39
+ * attempt for it is simply wrong. Compared lowercased because `gh pr checks
40
+ * --json state` has emitted both spellings across versions.
41
+ */
42
+ const INFRA_CHECK_STATES: Record<string, true> = {
43
+ cancelled: true,
44
+ timed_out: true,
45
+ startup_failure: true,
46
+ stale: true,
47
+ skipped: true,
48
+ };
49
+
50
+ /** States that mean "this check has a verdict and it is good". */
51
+ const SUCCESS_CHECK_STATES: Record<string, true> = { success: true, neutral: true };
52
+
53
+ function normalise(state: string): string {
54
+ return state.trim().toLowerCase();
55
+ }
56
+
57
+ /**
58
+ * Classify one terminal run.
59
+ *
60
+ * First match wins, and the order is the contract: the earlier a row matches,
61
+ * the less its raw state matters. `settlement-stuck` leads because a merged PR
62
+ * makes every other reading of the row wrong — that is the #362 case, where a
63
+ * `pushed-green` row outlived its own PR and held the issue out of dispatch.
64
+ */
65
+ export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classification {
66
+ const hasArtifacts = run.prUrl !== undefined || run.headSha !== undefined || run.salvageSha !== undefined;
67
+
68
+ // The PR landed while the row says otherwise. Whatever else is true about this
69
+ // run, it succeeded, and the recovery is bookkeeping.
70
+ if (run.prUrl !== undefined && facts.pr === "merged") {
71
+ return {
72
+ cls: "settlement-stuck",
73
+ recovery: "settle",
74
+ evidence: `row state ${run.state} but ${run.prUrl} is merged`,
75
+ };
76
+ }
77
+
78
+ // A green PR whose base moved under it. Mechanical to fix and it never spent
79
+ // an implementation attempt: veltro#365 lost a continuation to exactly this.
80
+ if (run.state === "pushed-green" && facts.pr === "open" && facts.mergeable === "conflicting") {
81
+ return {
82
+ cls: "merge-conflict",
83
+ recovery: "continue",
84
+ evidence: `${run.prUrl ?? "the PR"} is open and conflicting — the base moved under a green PR`,
85
+ };
86
+ }
87
+
88
+ if (run.state === "blocked") {
89
+ return {
90
+ cls: "question",
91
+ recovery: "escalate",
92
+ evidence: run.lastError ?? "the worker stopped to ask a question and left no report",
93
+ };
94
+ }
95
+
96
+ if (run.state === "orphaned") {
97
+ // Dirty means the worktree holds the only copy: re-claiming would delete it,
98
+ // which is the #319 data-loss shape. The existing unsalvaged-WIP admission
99
+ // hold already fails dispatch closed, so recording the class is the whole
100
+ // recovery — an operator has to look either way.
101
+ if (run.salvageError !== undefined && run.salvageAckAt === undefined) {
102
+ return {
103
+ cls: "orphan-dirty",
104
+ recovery: "hold",
105
+ evidence: `uncommitted work in ${run.worktree} is the only copy: ${run.salvageError}`,
106
+ };
107
+ }
108
+ return {
109
+ cls: "orphan-clean",
110
+ recovery: "requeue",
111
+ evidence: `daemon died holding the claim; nothing uncommitted (${run.branch})`,
112
+ };
113
+ }
114
+
115
+ if (run.state === "killed") {
116
+ if (run.turns >= run.maxTurns) {
117
+ return hasArtifacts
118
+ ? {
119
+ cls: "turn-cap-progress",
120
+ recovery: "continue",
121
+ evidence: `turns ${run.turns}/${run.maxTurns} with work to continue from (${run.prUrl ?? run.salvageSha ?? run.headSha})`,
122
+ }
123
+ : {
124
+ cls: "turn-cap-spinning",
125
+ recovery: "escalate",
126
+ evidence: `turns ${run.turns}/${run.maxTurns}, no PR, no commits — $${run.spendUsd.toFixed(2)} spent`,
127
+ };
128
+ }
129
+ // Below its own ceiling, so nothing this worker did ended it: a daemon
130
+ // restart, a drain, an operator. Costs no budget and needs no human.
131
+ return {
132
+ cls: "admin-kill",
133
+ recovery: "requeue",
134
+ evidence: `killed at ${run.turns}/${run.maxTurns} turns — under its own ceiling, so not a cap kill`,
135
+ };
136
+ }
137
+
138
+ if (run.state === "failed" && facts.pr === "open") {
139
+ const checks = facts.checks ?? [];
140
+ const unresolved = checks.filter((c) => !SUCCESS_CHECK_STATES[normalise(c.state)] === true);
141
+ if (checks.length > 0 && unresolved.length > 0) {
142
+ const failing = unresolved.filter((c) => normalise(c.state) === "failure");
143
+ if (failing.length > 0) {
144
+ return {
145
+ cls: "ci-deterministic",
146
+ recovery: "escalate",
147
+ evidence: `checks failed: ${failing
148
+ .map((c) => `${c.name}${c.link === undefined ? "" : ` (${c.link})`}`)
149
+ .join(", ")}`,
150
+ };
151
+ }
152
+ if (unresolved.every((c) => INFRA_CHECK_STATES[normalise(c.state)] === true)) {
153
+ return {
154
+ cls: "ci-infra",
155
+ recovery: "rerun-checks",
156
+ evidence: `no check reached a verdict: ${unresolved.map((c) => `${c.name} ${normalise(c.state)}`).join(", ")}`,
157
+ };
158
+ }
159
+ }
160
+ }
161
+
162
+ // Deliberately escalate rather than retry. An unrecognised shape is a gap in
163
+ // this table, and a silent requeue would spend a budget on a cause nobody has
164
+ // named — the exact behaviour #132 exists to end.
165
+ return {
166
+ cls: "unknown",
167
+ recovery: "escalate",
168
+ evidence: `state ${run.state}, turns ${run.turns}/${run.maxTurns}, pr ${facts.pr ?? "unknown"}${
169
+ run.lastError === undefined ? "" : `, last error: ${run.lastError}`
170
+ }`,
171
+ };
172
+ }
package/src/fleet.ts CHANGED
@@ -31,7 +31,10 @@ import { findProject, loadConfig, resolveCredentials, stateDir } from "./config.
31
31
  import { describeBoundary, probeHost } from "./credentials.ts";
32
32
  import { planUsageLine, readPlanUsage, sharedUsageSource } from "./usage.ts";
33
33
  import { readApprovalSurface } from "./approval-surface.ts";
34
- import { confinementRefusalsToday, type ConfinementRefusalSummary } from "./confinement.ts";
34
+ import { inspectBriefLayout } from "./brief-upgrade.ts";
35
+ import { dbPath, openStore } from "./store.ts";
36
+ import { renderBriefForProject } from "./setup.ts";
37
+ import type { ProjectConfig, Store } from "./types.ts";
35
38
  import { settlementFlagSummary } from "./diff-flags.ts";
36
39
  import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
37
40
  import { formatOpenReports } from "./reports.ts";
@@ -128,7 +131,14 @@ export interface ArmResult {
128
131
 
129
132
  export interface ArmDeps {
130
133
  sendChallenge?: (token: string, owner: string, text: string) => Promise<void>;
131
- waitForUserTurn?: (transcript: string, code: string, timeoutMs: number) => Promise<boolean>;
134
+ /**
135
+ * Waits for the challenge to appear as a user turn somewhere under the
136
+ * session directory. The waiter owns transcript discovery — not the caller —
137
+ * because the reply may land in a session that starts *after* the send, so a
138
+ * path resolved before the challenge went out can be the wrong file by the
139
+ * time the operator answers (#142).
140
+ */
141
+ waitForUserTurn?: (dir: string, code: string, sentAt: number, timeoutMs: number) => Promise<boolean>;
132
142
  now?: () => number;
133
143
  sleep?: (ms: number) => Promise<void>;
134
144
  timeoutMs?: number;
@@ -167,11 +177,11 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
167
177
  );
168
178
  }
169
179
 
170
- const transcript = newestSessionTranscript(tick.cwd);
171
- if (transcript === undefined) {
180
+ const dir = sessionDirForCwd(tick.cwd);
181
+ if (!existsSync(dir)) {
172
182
  throw new Error(
173
- `no orchestrator session transcript under ${sessionDirForCwd(tick.cwd)} — ` +
174
- `the inbound proof is read from a user turn there. Start the pane orchestrator, let it settle, then arm again`,
183
+ `no orchestrator session directory at ${dir} — ` +
184
+ `the inbound proof is read from a user turn in a transcript there. Start the pane orchestrator, let it settle, then arm again`,
175
185
  );
176
186
  }
177
187
 
@@ -183,6 +193,9 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
183
193
  `Nothing will be dispatched until that reply is seen in the orchestrator session.`;
184
194
 
185
195
  const send = deps.sendChallenge ?? sendTelegramMessage;
196
+ // Read before the send, not after: a transcript untouched since this instant
197
+ // cannot contain the reply, and that is what the waiter filters on.
198
+ const sentAt = (deps.now ?? Date.now)();
186
199
  try {
187
200
  await send(token, channel.owner, text);
188
201
  } catch (err) {
@@ -192,9 +205,16 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
192
205
  }
193
206
 
194
207
  const timeoutMs = deps.timeoutMs ?? ARM_CHALLENGE_TIMEOUT_MS;
195
- const wait = deps.waitForUserTurn ?? ((tr, c, ms) => waitForChallengeInTranscript(tr, c, ms, deps));
196
- const seen = await wait(transcript, code, timeoutMs);
197
- if (!seen) {
208
+ const injected = deps.waitForUserTurn;
209
+ const scan: SessionScan = injected
210
+ ? { seen: await injected(dir, code, sentAt, timeoutMs), scanned: [], ignored: [] }
211
+ : await waitForChallengeInSessions(dir, code, sentAt, timeoutMs, deps);
212
+ if (!scan.seen) {
213
+ const listing = [
214
+ `session dir: ${dir}`,
215
+ ...scan.scanned.map((f) => ` watched: ${f}`),
216
+ ...scan.ignored.map((f) => ` ignored (stale, last written before the challenge): ${f}`),
217
+ ].join("\n");
198
218
  throw new Error(
199
219
  `arm: the challenge never arrived as a user turn in time — NOT armed.\n` +
200
220
  `Inbound Telegram is not reaching the omp session. Check, in order:\n` +
@@ -202,7 +222,7 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
202
222
  ` * is another process holding this bot token? Telegram allows exactly one\n` +
203
223
  ` getUpdates consumer and rejects the second with HTTP 409.\n` +
204
224
  ` * did you reply in the DM with the bot, not another chat?\n` +
205
- `transcript: ${transcript}`,
225
+ listing,
206
226
  );
207
227
  }
208
228
 
@@ -953,8 +973,10 @@ export function formatFleetStatus(
953
973
  telegram: TelegramHealth = { kind: "unprobed" },
954
974
  now = Date.now(),
955
975
  codeGraph: CodeGraphHealth = { configured: false },
956
- refusals: ConfinementRefusalSummary | undefined = undefined,
957
976
  boundary: BoundaryStatus | undefined = undefined,
977
+ brief: string | undefined = undefined,
978
+ decisions: string | undefined = undefined,
979
+ failureClasses: string | undefined = undefined,
958
980
  ): string {
959
981
  const tickLine =
960
982
  layers.ticksDetail === undefined
@@ -1007,14 +1029,6 @@ export function formatFleetStatus(
1007
1029
  }
1008
1030
 
1009
1031
  const graphBlock = formatCodeGraphHealth(codeGraph, now);
1010
- // Silent when there were none: a line reading "0" every day is one nobody
1011
- // reads on the day it says 40. A repeatedly-refused orchestrator is
1012
- // misbriefed, and that is the operator's problem to see (#127).
1013
- const confinementLine =
1014
- refusals === undefined
1015
- ? undefined
1016
- : `confine ${refusals.count} orchestrator refusal(s) today ` +
1017
- `(latest: ${refusals.latest.tool} ${refusals.latest.path} — ${refusals.latest.kind})`;
1018
1032
  // Reported every time, never only when it is bad. An operator reading this
1019
1033
  // has to be able to see "unprotected" on the day they assumed otherwise, and
1020
1034
  // a line that appears only in the failure case is one whose absence means
@@ -1034,7 +1048,9 @@ export function formatFleetStatus(
1034
1048
  herdrLine,
1035
1049
  telegramLine,
1036
1050
  ...boundaryLines,
1037
- ...(confinementLine === undefined ? [] : [confinementLine]),
1051
+ ...(brief === undefined ? [] : [brief]),
1052
+ ...(decisions === undefined ? [] : [decisions]),
1053
+ ...(failureClasses === undefined ? [] : [failureClasses]),
1038
1054
  ...(graphBlock === undefined ? [] : [graphBlock]),
1039
1055
  daemonBlock,
1040
1056
  "",
@@ -1133,10 +1149,17 @@ export async function renderStatus(projectName?: string): Promise<string> {
1133
1149
  const boundary =
1134
1150
  live ?? {
1135
1151
  ...describeBoundary(credentials.isolation, await probeHost({ slots: s.caps.maxConcurrentWorkers })),
1136
- detail: [
1137
- "No daemon is running, so this is THIS SHELL's view, not the fleet's — an interactive shell holds",
1138
- "none of the unit's capabilities. Start the daemon and re-read before concluding anything.",
1139
- ],
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
+ ],
1140
1163
  };
1141
1164
  return formatFleetStatus(
1142
1165
  { ...s, planUsage },
@@ -1145,11 +1168,88 @@ export async function renderStatus(projectName?: string): Promise<string> {
1145
1168
  telegram,
1146
1169
  Date.now(),
1147
1170
  codeGraph,
1148
- confinementRefusalsToday(),
1149
1171
  boundary,
1172
+ briefStatusLine(project),
1173
+ decisionStatusLine(project.name),
1174
+ failureClassBlock(project.name),
1150
1175
  );
1151
1176
  }
1152
1177
 
1178
+ /**
1179
+ * One line naming the brief layout, or nothing when it cannot be read.
1180
+ *
1181
+ * `brief-upgrade` stays the verb that migrates a legacy layout — this is the
1182
+ * surface that tells an operator they still have one, in the place they already
1183
+ * look (#131). Silent on any throw: a status that cannot render because the
1184
+ * brief moved is worse than a status missing one row.
1185
+ */
1186
+ function briefStatusLine(project: ProjectConfig): string | undefined {
1187
+ try {
1188
+ const layout = inspectBriefLayout(project.workspaceRoot, renderBriefForProject(project));
1189
+ return layout.kind === "overlay"
1190
+ ? "brief overlay (package floor + POLICY.md)"
1191
+ : `brief ${layout.kind} — run omp-conductor brief-upgrade`;
1192
+ } catch {
1193
+ return undefined;
1194
+ }
1195
+ }
1196
+
1197
+ /**
1198
+ * Unrecovered runs grouped by failure class (#132), or nothing when there are
1199
+ * none.
1200
+ *
1201
+ * Reported as classes rather than as row states because a row state is not an
1202
+ * issue state: the FAILED column counted four completed issues on this fleet
1203
+ * while the genuinely stuck ones were invisible (#109). A class says which of
1204
+ * those it is, and `recoveredAt` is what keeps a recovered row out of the list.
1205
+ */
1206
+ function failureClassBlock(projectName: string): string | undefined {
1207
+ const path = dbPath();
1208
+ if (!existsSync(path)) return undefined;
1209
+ let store: Store | undefined;
1210
+ try {
1211
+ store = openStore(path);
1212
+ const counts = store.failureClassCounts(projectName);
1213
+ if (counts.length === 0) return undefined;
1214
+ return [
1215
+ "failure classes (unrecovered)",
1216
+ ...counts.map((c) => ` ${c.cls.padEnd(18)}${c.n}`),
1217
+ ].join("\n");
1218
+ } catch {
1219
+ return undefined;
1220
+ } finally {
1221
+ store?.close();
1222
+ }
1223
+ }
1224
+
1225
+ /**
1226
+ * One line naming what the orchestrator still owes its operator (#136).
1227
+ *
1228
+ * Reported whenever the store exists, `none open` included: a row that appears
1229
+ * only when something is outstanding is a row whose absence means nothing, and
1230
+ * "did it forget to record the question, or is there genuinely none?" is exactly
1231
+ * the ambiguity this ledger exists to remove. Omitted on any throw — a status
1232
+ * that fails to render because the store is mid-migration is worse than one
1233
+ * missing a row.
1234
+ */
1235
+ function decisionStatusLine(projectName: string): string | undefined {
1236
+ const path = dbPath();
1237
+ if (!existsSync(path)) return undefined;
1238
+ let store: Store | undefined;
1239
+ try {
1240
+ store = openStore(path);
1241
+ const open = store.openDecisions(projectName);
1242
+ if (open.length === 0) return "decisions none open";
1243
+ const oldest = open[0]!;
1244
+ const hours = Math.max(0, Math.round((Date.now() - oldest.askedAt) / 3_600_000));
1245
+ return `decisions ${open.length} open (oldest ${hours}h)`;
1246
+ } catch {
1247
+ return undefined;
1248
+ } finally {
1249
+ store?.close();
1250
+ }
1251
+ }
1252
+
1153
1253
  // ---------------------------------------------------------------------------
1154
1254
  // arm proof helpers
1155
1255
  // ---------------------------------------------------------------------------
@@ -1267,23 +1367,6 @@ export function sessionDirForCwd(cwd: string): string {
1267
1367
  return join(home, ".omp", "agent", "sessions", slug.replaceAll("/", "-"));
1268
1368
  }
1269
1369
 
1270
- function newestSessionTranscript(cwd: string): string | undefined {
1271
- const dir = sessionDirForCwd(cwd);
1272
- if (!existsSync(dir)) return undefined;
1273
- let best: { path: string; mtime: number } | undefined;
1274
- for (const name of readdirSync(dir)) {
1275
- if (!name.endsWith(".jsonl")) continue;
1276
- const path = join(dir, name);
1277
- try {
1278
- const mtime = statSync(path).mtimeMs;
1279
- if (best === undefined || mtime > best.mtime) best = { path, mtime };
1280
- } catch {
1281
- /* race */
1282
- }
1283
- }
1284
- return best?.path;
1285
- }
1286
-
1287
1370
  function makeChallengeCode(): string {
1288
1371
  const bytes = new Uint8Array(4);
1289
1372
  crypto.getRandomValues(bytes);
@@ -1301,20 +1384,66 @@ async function sendTelegramMessage(token: string, owner: string, text: string):
1301
1384
  }
1302
1385
  }
1303
1386
 
1304
- async function waitForChallengeInTranscript(
1305
- transcript: string,
1387
+ interface SessionScan {
1388
+ seen: boolean;
1389
+ /** `name (mtime <iso>)` for every transcript the last pass actually parsed. */
1390
+ scanned: string[];
1391
+ /** Same shape, for the ones skipped as written before the challenge. */
1392
+ ignored: string[];
1393
+ }
1394
+
1395
+ /**
1396
+ * Polls the session directory — re-read on every pass, never snapshotted —
1397
+ * until the challenge shows up as a user turn or the deadline passes.
1398
+ *
1399
+ * Discovery lives here because the reply can land in a transcript that does not
1400
+ * exist yet when the challenge is sent: a rotated session, or the first one of
1401
+ * a pane started right after arming (#142). A waiter handed one path polls a
1402
+ * file the answer will never be written to, and arming becomes impossible.
1403
+ *
1404
+ * Files untouched since just before the send are named, not parsed: an append
1405
+ * bumps mtime, so a transcript older than the challenge cannot hold the reply,
1406
+ * and skipping it keeps a large stale session out of every 5 s pass.
1407
+ */
1408
+ async function waitForChallengeInSessions(
1409
+ dir: string,
1306
1410
  code: string,
1411
+ sentAt: number,
1307
1412
  timeoutMs: number,
1308
1413
  deps: ArmDeps,
1309
- ): Promise<boolean> {
1414
+ ): Promise<SessionScan> {
1310
1415
  const now = deps.now ?? Date.now;
1311
1416
  const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
1312
1417
  const deadline = now() + timeoutMs;
1313
- while (now() < deadline) {
1314
- if (await transcriptHasUserCode(transcript, code)) return true;
1418
+ for (;;) {
1419
+ const scanned: string[] = [];
1420
+ const ignored: string[] = [];
1421
+ let names: string[] = [];
1422
+ try {
1423
+ names = readdirSync(dir);
1424
+ } catch {
1425
+ /* the directory can go away under a rotation; the next pass re-reads it */
1426
+ }
1427
+ for (const name of names.sort()) {
1428
+ if (!name.endsWith(".jsonl")) continue;
1429
+ const path = join(dir, name);
1430
+ let mtimeMs: number;
1431
+ try {
1432
+ mtimeMs = statSync(path).mtimeMs;
1433
+ } catch {
1434
+ continue; /* race */
1435
+ }
1436
+ const label = `${name} (mtime ${new Date(mtimeMs).toISOString()})`;
1437
+ if (mtimeMs < sentAt - 1_000) {
1438
+ ignored.push(label);
1439
+ continue;
1440
+ }
1441
+ scanned.push(label);
1442
+ if (await transcriptHasUserCode(path, code)) return { seen: true, scanned, ignored };
1443
+ }
1444
+ if (now() >= deadline) return { seen: false, scanned, ignored };
1315
1445
  await sleep(5_000);
1316
1446
  }
1317
- return false;
1318
1447
  }
1319
1448
 
1320
1449
  export async function transcriptHasUserCode(path: string, code: string): Promise<boolean> {
@@ -1449,10 +1578,22 @@ function probeOmpPane(
1449
1578
  * that difference stated rather than papered over.
1450
1579
  */
1451
1580
  export function boundaryFromHealthz(
1452
- body: unknown,
1581
+ body: string | undefined,
1453
1582
  ): { headline: string; detail: string[] } | undefined {
1454
- if (typeof body !== "object" || body === null) return undefined;
1455
- const boundary = (body as { boundary?: unknown }).boundary;
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;
1456
1597
  if (typeof boundary !== "object" || boundary === null) return undefined;
1457
1598
  const headline = (boundary as { headline?: unknown }).headline;
1458
1599
  if (typeof headline !== "string" || headline.length === 0) return undefined;
package/src/omp.ts CHANGED
@@ -18,14 +18,7 @@ import { createServer, type Server, type Socket } from "node:net";
18
18
  import { tmpdir } from "node:os";
19
19
  import { dirname, join } from "node:path";
20
20
 
21
- import {
22
- orchestratorConfinement,
23
- orchestratorJailFromConfig,
24
- worktreeConfinement,
25
- type OrchestratorJail,
26
- type OrchestratorRefusal,
27
- } from "./confinement.ts";
28
- import { recordConfinementRefusal } from "./confinement.ts";
21
+ import { worktreeConfinement } from "./confinement.ts";
29
22
  import type { SessionBoundary } from "./credentials.ts";
30
23
  import { releasePolicyTripwire } from "./release-policy.ts";
31
24
  import type {
@@ -156,22 +149,6 @@ export async function createLocalSession(opts: {
156
149
  releaseGrants?: ResolvedGrants;
157
150
  /** Durable audit callback invoked only when that gate rejects a call. */
158
151
  onReleaseBlocked?: (shape: ReleaseShape) => void;
159
- /**
160
- * The allowlist an orchestrator session is held to (#127). Omitted, it is
161
- * derived from the config on disk — which is the only honest default, since
162
- * a jail that silently resolved to "no jail" would leave the roots this gate
163
- * exists to close wide open on the day a caller forgot the option.
164
- */
165
- orchestratorJail?: OrchestratorJail;
166
- /**
167
- * Where an orchestrator confinement refusal is recorded. Defaults to the
168
- * durable audit in the daemon's state directory — which is exactly why it is
169
- * injectable: under `uid-pool` this function runs as a principal that cannot
170
- * write that directory, so {@link runSessionHost} substitutes a forwarder and
171
- * the *parent* performs the write. A refusal the operator never learns about
172
- * is indistinguishable from a session that behaved (#127).
173
- */
174
- onConfinementRefusal?: (refusal: OrchestratorRefusal) => void;
175
152
  /**
176
153
  * The conductor verb socket this session's mutation tools call (#126).
177
154
  *
@@ -218,18 +195,12 @@ export async function createLocalSession(opts: {
218
195
  // transcript is the only record of what the worker actually did.
219
196
  const sessionManager = await openSessionManager(mod, opts);
220
197
  const extensions = [
221
- // Two shapes, because the two sessions need different things: a worker is
222
- // jailed to its checkout, while the orchestrator has to read the state
223
- // directory and its briefs and is jailed to an allowlist instead (#127).
224
- ...(opts.role === "worker"
225
- ? [worktreeConfinement(opts.cwd)]
226
- : [
227
- orchestratorConfinement(
228
- opts.orchestratorJail ?? orchestratorJailFromConfig(),
229
- opts.cwd,
230
- opts.onConfinementRefusal,
231
- ),
232
- ]),
198
+ // Workers only. The orchestrator runs unconfined by operator ruling
199
+ // (#143): the gate could only ever be installed in sessions this daemon
200
+ // spawns, so an external orchestrator the supported shape never had it,
201
+ // and a boundary that exists in one deployment out of two is not a
202
+ // boundary. What holds the orchestrator is its brief and the verb ledger.
203
+ ...(opts.role === "worker" ? [worktreeConfinement(opts.cwd)] : []),
233
204
  ...(opts.releaseGrants === undefined
234
205
  ? []
235
206
  : [releasePolicyTripwire(opts.releaseGrants, opts.role, opts.onReleaseBlocked)]),
@@ -408,8 +379,7 @@ export interface CreateSessionOptions {
408
379
  role: SessionRole;
409
380
  releaseGrants?: ResolvedGrants;
410
381
  onReleaseBlocked?: (shape: ReleaseShape) => void;
411
- orchestratorJail?: OrchestratorJail;
412
- onConfinementRefusal?: (refusal: OrchestratorRefusal) => void;
382
+
413
383
  /**
414
384
  * The OS principal and environment this session runs behind (#125). Omitted,
415
385
  * the child runs as the daemon's own user with the daemon's environment —
@@ -513,12 +483,6 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
513
483
  ...(opts.model === undefined ? {} : { model: opts.model }),
514
484
  ...(opts.resume === undefined ? {} : { resume: opts.resume }),
515
485
  ...(opts.releaseGrants === undefined ? {} : { releaseGrants: opts.releaseGrants }),
516
- // Resolved here rather than in the child: the child may not be able to read
517
- // the config the jail is derived from, and a jail that fell back to
518
- // defaults would silently widen the gate #127 closed.
519
- ...(opts.role === "orchestrator"
520
- ? { orchestratorJail: opts.orchestratorJail ?? orchestratorJailFromConfig() }
521
- : {}),
522
486
  ...(opts.verbSocketPath === undefined ? {} : { verbSocketPath: opts.verbSocketPath }),
523
487
  };
524
488
 
@@ -665,16 +629,6 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
665
629
  case "release-blocked":
666
630
  opts.onReleaseBlocked?.(message.shape);
667
631
  break;
668
- case "confinement-refusal":
669
- // Performed on this side because the child may not be able to write
670
- // the state directory the audit lives in. Wrapped for the same reason
671
- // the in-process version is: the audit is evidence, not the gate.
672
- try {
673
- (opts.onConfinementRefusal ?? recordConfinementRefusal)(message.refusal);
674
- } catch {
675
- // A full disk must not turn a deny into an allow.
676
- }
677
- break;
678
632
  }
679
633
  }
680
634
  });