omp-conductor 0.4.4 → 0.4.5

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 CHANGED
@@ -149,6 +149,25 @@ be present. If you run omp, it is.
149
149
  Also required on the host:
150
150
 
151
151
  - `bun`: the CLI and the daemon run on it (`Bun.serve` backs `/healthz`).
152
+ - **A model credential a *session* can reach.** Sessions run with `$HOME`
153
+ redirected into a per-run tree, so the harness never reads the operator's own
154
+ configuration — that is what keeps a credential-bearing MCP server out of every
155
+ session. It also means a login recorded only in `~/.omp/agent/agent.db` is
156
+ invisible to a worker, which then dies at turn 0 with `No model selected`. Two
157
+ supported shapes:
158
+ - a model API key in the **daemon's** environment (`ANTHROPIC_API_KEY`,
159
+ `OPENAI_API_KEY`, …); these are deliberately not scrubbed, unlike the GitHub
160
+ and npm keys.
161
+ - the harness's **auth broker** — `omp auth-broker serve` plus `omp auth-broker
162
+ token`, or `auth.broker.url` / `auth.broker.token` in
163
+ `~/.omp/agent/config.yml`. The conductor resolves that connection and injects
164
+ it into each session. Refresh tokens stay in the broker and a session receives
165
+ only short-lived access tokens, which is exactly what per-run isolation wants.
166
+
167
+ With neither, `omp-conductor status` reports the hold and **nothing is
168
+ dispatched** — no issue is claimed and no attempt is spent. Copying `agent.db`
169
+ into each run is deliberately not how this works: the harness rotates the OAuth
170
+ credentials in it, so a copy goes stale and can invalidate the original.
152
171
  - `gh`, already authenticated: every tracker operation shells out to it, so the
153
172
  daemon never handles a GitHub token itself.
154
173
  - `git`: mirrors and worktrees.
@@ -1376,6 +1395,7 @@ persists the class on the row, and performs the one recovery that class names.
1376
1395
 
1377
1396
  | Class | Signals | Recovery | Budget |
1378
1397
  | --- | --- | --- | --- |
1398
+ | `env-start-failure` | turn 0 plus an explicit harness start error (`No model selected`, a rejected key) | escalate — the session never read the issue | none |
1379
1399
  | `settlement-stuck` | a row carrying a PR that has since merged | settle: release the label, mark the row merged | none |
1380
1400
  | `merge-conflict` | `pushed-green`, PR open, GitHub reports conflicting | requeue for a rebase continuation | continuation |
1381
1401
  | `question` | the worker stopped to ask something (`blocked`) | escalate, carrying the worker's own report as evidence | none |
@@ -1394,9 +1414,10 @@ cause nobody has named — the behaviour this exists to end.
1394
1414
 
1395
1415
  ### The budgets follow the cause
1396
1416
 
1397
- `failuresFor` (implementation attempts) excludes `ci-infra` and
1398
- `settlement-stuck`; `continuationsFor` excludes `admin-kill` and
1399
- `settlement-stuck`. A merge conflict *is* charged as a continuation, because a
1417
+ `failuresFor` (implementation attempts) excludes `ci-infra`, `settlement-stuck`
1418
+ and `env-start-failure`; `continuationsFor` excludes `admin-kill`,
1419
+ `settlement-stuck` and `env-start-failure`. An environment fault charges neither:
1420
+ the session never started, so nothing about the issue was attempted. A merge conflict *is* charged as a continuation, because a
1400
1421
  rebase is real work — just never as a failed implementation attempt.
1401
1422
 
1402
1423
  An **unclassified** row (every row written before 0.4.3) counts exactly as it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
@@ -37,6 +37,7 @@ import {
37
37
  writeFileSync,
38
38
  type Stats,
39
39
  } from "node:fs";
40
+ import { homedir } from "node:os";
40
41
  import { dirname, join, resolve } from "node:path";
41
42
 
42
43
  import {
@@ -207,6 +208,159 @@ export function prepareSessionEnvRoot(root: string): SessionEnvRoot {
207
208
  return paths;
208
209
  }
209
210
 
211
+ /**
212
+ * Environment variables that carry a model credential directly.
213
+ *
214
+ * Deliberately NOT in {@link CREDENTIAL_ENV_KEYS}: those are the ones a session
215
+ * must never inherit. A model credential is the opposite — without one there is
216
+ * no session at all.
217
+ */
218
+ export const MODEL_ENV_KEYS = [
219
+ "ANTHROPIC_API_KEY",
220
+ "OPENAI_API_KEY",
221
+ "OPENROUTER_API_KEY",
222
+ "XAI_API_KEY",
223
+ "GEMINI_API_KEY",
224
+ "GOOGLE_API_KEY",
225
+ ] as const;
226
+
227
+ /** The harness's auth-broker connection, as its own env vars name it. */
228
+ export const BROKER_URL_ENV = "OMP_AUTH_BROKER_URL";
229
+ export const BROKER_TOKEN_ENV = "OMP_AUTH_BROKER_TOKEN";
230
+
231
+ export type ModelCredential =
232
+ /** Already in the daemon's environment, so it survives the redirect untouched. */
233
+ | { kind: "env" }
234
+ /** Resolved from the operator's own home and injected as broker env vars. */
235
+ | { kind: "broker"; url: string; token: string };
236
+
237
+ /**
238
+ * How a session under a redirected `$HOME` is going to authenticate a model —
239
+ * or `undefined` when it cannot, which is a fleet that must not dispatch.
240
+ *
241
+ * Sessions run with `$HOME` pointed at a per-run tree so the harness never reads
242
+ * the operator's configuration; that is what kept a GitHub MCP server carrying
243
+ * its own PAT out of every session. It also hid the harness's *own* credentials,
244
+ * so every worker died at turn 0 with `No model selected` and spent an issue
245
+ * attempt doing it — 27 recoveries on the reference fleet, while the fleet looked
246
+ * busy (#152).
247
+ *
248
+ * The fix is emphatically **not** to copy `<agentDir>/agent.db` into each run.
249
+ * That file holds OAuth credentials the harness rotates, plus threads, jobs and
250
+ * caches; the first run to refresh a token would update its own private copy and
251
+ * strand every later run — and the provider may invalidate the old refresh token
252
+ * as it rotates, so a copy can break the operator's own login too. The harness
253
+ * ships the right answer: an auth broker whose whole point is that *refresh
254
+ * tokens never leave it*, handing clients short-lived access tokens. That is also
255
+ * exactly the shape per-run isolation wants.
256
+ *
257
+ * Precedence mirrors the harness's own resolver, narrowly:
258
+ * 1. broker env vars, or any model API key, already in this process.
259
+ * 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml`.
260
+ * 3. `<configRoot>/auth-broker.token`, paired with a URL from 1 or 2.
261
+ *
262
+ * Re-read here rather than imported from the harness because this package must
263
+ * type-check and publish without its optional peer dependency — `omp.ts` is the
264
+ * only file allowed to touch it. `!command` indirection is deliberately not
265
+ * supported: an operator using that has the env-var path, which this honours.
266
+ */
267
+ export function resolveModelCredential(
268
+ base: Readonly<Record<string, string | undefined>> = process.env,
269
+ agentDir = join(homedir(), ".omp", "agent"),
270
+ configRoot = join(homedir(), ".omp"),
271
+ ): ModelCredential | undefined {
272
+ const envUrl = base[BROKER_URL_ENV];
273
+ const envToken = base[BROKER_TOKEN_ENV];
274
+ if ((envUrl ?? "").length > 0 && (envToken ?? "").length > 0) return { kind: "env" };
275
+ if (MODEL_ENV_KEYS.some((key) => (base[key] ?? "").length > 0)) return { kind: "env" };
276
+
277
+ const configured = readBrokerConfig(agentDir);
278
+ const url = (envUrl ?? "").length > 0 ? envUrl : configured.url;
279
+ if (url === undefined || url.length === 0) return undefined;
280
+ const token = configured.token ?? readTokenFile(join(configRoot, "auth-broker.token"));
281
+ if (token === undefined || token.length === 0) return undefined;
282
+ return { kind: "broker", url, token };
283
+ }
284
+
285
+ /**
286
+ * The two `auth.broker.*` values out of the harness's `config.yml`.
287
+ *
288
+ * A deliberately tiny reader rather than a YAML dependency: exactly two scalar
289
+ * keys under one mapping, and anything it cannot understand simply reads as
290
+ * absent — which lands on the same refusal as no broker at all, rather than a
291
+ * parse error during dispatch.
292
+ */
293
+ function readBrokerConfig(agentDir: string): { url?: string; token?: string } {
294
+ for (const name of ["config.yml", "config.yaml"]) {
295
+ const path = join(agentDir, name);
296
+ if (!existsSync(path)) continue;
297
+ let text: string;
298
+ try {
299
+ text = readFileSync(path, "utf8");
300
+ } catch {
301
+ continue;
302
+ }
303
+ const found = scanBrokerKeys(text);
304
+ if (found.url !== undefined || found.token !== undefined) return found;
305
+ }
306
+ return {};
307
+ }
308
+
309
+ /**
310
+ * `auth.broker.url` / `auth.broker.token` out of a YAML document, in either the
311
+ * dotted or the nested shape the harness's own docs use.
312
+ *
313
+ * An indentation scan rather than a regex or a YAML dependency: two scalar keys
314
+ * under one known path, and anything it cannot understand reads as absent, which
315
+ * lands on the same refusal as no broker at all rather than a parse error during
316
+ * dispatch.
317
+ */
318
+ function scanBrokerKeys(text: string): { url?: string; token?: string } {
319
+ const out: { url?: string; token?: string } = {};
320
+ /** Enclosing mapping keys, innermost last, by indentation. */
321
+ const path: { indent: number; key: string }[] = [];
322
+ for (const raw of text.split("\n")) {
323
+ const line = raw.replace(/\s+$/, "");
324
+ if (line.trim().length === 0 || line.trim().startsWith("#")) continue;
325
+ const indent = line.length - line.trimStart().length;
326
+ const at = line.indexOf(":");
327
+ if (at < 0) continue;
328
+ const key = line.slice(indent, at).trim();
329
+ const value = line.slice(at + 1);
330
+ while (path.length > 0 && (path.at(-1) as { indent: number }).indent >= indent) path.pop();
331
+
332
+ const dotted = key.split(".");
333
+ const full = [...path.map((p) => p.key), ...dotted];
334
+ if (value.trim().length === 0) {
335
+ path.push({ indent, key: dotted.join(".") });
336
+ continue;
337
+ }
338
+ if (full.length !== 3 || full[0] !== "auth" || full[1] !== "broker") continue;
339
+ const scalar = unquote(value);
340
+ if (scalar.length === 0) continue;
341
+ if (full[2] === "url") out.url ??= scalar;
342
+ else if (full[2] === "token") out.token ??= scalar;
343
+ }
344
+ return out;
345
+ }
346
+
347
+ function unquote(raw: string): string {
348
+ const text = raw.trim();
349
+ if (text.startsWith("!")) return "";
350
+ const quoted = /^(['"])(.*)\1$/.exec(text);
351
+ return (quoted?.[2] ?? text).trim();
352
+ }
353
+
354
+ function readTokenFile(path: string): string | undefined {
355
+ if (!existsSync(path)) return undefined;
356
+ try {
357
+ const text = readFileSync(path, "utf8").trim();
358
+ return text.length === 0 ? undefined : text;
359
+ } catch {
360
+ return undefined;
361
+ }
362
+ }
363
+
210
364
  /**
211
365
  * The environment one session child runs with.
212
366
  *
@@ -222,7 +376,7 @@ export function prepareSessionEnvRoot(root: string): SessionEnvRoot {
222
376
  */
223
377
  export function sessionEnv(
224
378
  paths: SessionEnvRoot,
225
- opts: { readToken?: string } = {},
379
+ opts: { readToken?: string; model?: ModelCredential } = {},
226
380
  base: Readonly<Record<string, string | undefined>> = process.env,
227
381
  ): Record<string, string> {
228
382
  const env: Record<string, string> = {};
@@ -244,6 +398,16 @@ export function sessionEnv(
244
398
  env["NPM_CONFIG_USERCONFIG"] = paths.npmrc;
245
399
  env["TMPDIR"] = paths.tmp;
246
400
 
401
+ // The redirect hides the operator's `config.yml` and token file, so a broker
402
+ // configured *there* has to be handed over explicitly or the session falls back
403
+ // to an empty local store and dies at turn 0 (#152). Env vars are the harness's
404
+ // own highest-precedence shape, and they carry an access-minting token rather
405
+ // than a refresh credential: the broker keeps that.
406
+ if (opts.model?.kind === "broker") {
407
+ env[BROKER_URL_ENV] = opts.model.url;
408
+ env[BROKER_TOKEN_ENV] = opts.model.token;
409
+ }
410
+
247
411
  // The one credential a session may be given, and only when an operator
248
412
  // configured it. Read-scoped by construction on GitHub's side — this package
249
413
  // cannot verify the scope, so the README says plainly that a write-scoped
@@ -1620,6 +1784,13 @@ export interface BoundaryRequest {
1620
1784
  denyReadRoots?: readonly string[];
1621
1785
  denyReadFiles?: readonly string[];
1622
1786
  readToken?: string;
1787
+ /**
1788
+ * How this session authenticates a model. Resolved once by the daemon and
1789
+ * handed down rather than re-read per session, so every child of one tick
1790
+ * agrees — and so a fleet with no credential is refused at admission instead of
1791
+ * discovering it at turn 0 (#152).
1792
+ */
1793
+ model?: ModelCredential;
1623
1794
  /** Where a generated `sandbox-exec` profile is written. */
1624
1795
  profilePath?: string;
1625
1796
  }
@@ -1654,7 +1825,10 @@ export function mechanismSatisfies(
1654
1825
  */
1655
1826
  export function buildSessionBoundary(req: BoundaryRequest): SessionBoundary {
1656
1827
  const paths = prepareSessionEnvRoot(req.envRoot);
1657
- const env = sessionEnv(paths, req.readToken === undefined ? {} : { readToken: req.readToken });
1828
+ const env = sessionEnv(paths, {
1829
+ ...(req.readToken === undefined ? {} : { readToken: req.readToken }),
1830
+ ...(req.model === undefined ? {} : { model: req.model }),
1831
+ });
1658
1832
 
1659
1833
  if (req.isolation === "none") {
1660
1834
  return { mechanism: "none", launcher: [], env };
package/src/daemon.ts CHANGED
@@ -107,8 +107,10 @@ import {
107
107
  openRunPr,
108
108
  probeHost,
109
109
  pushRunBranch,
110
+ resolveModelCredential,
110
111
  verifyDaemonAccess,
111
112
  type HostProbe,
113
+ type ModelCredential,
112
114
  type RunRepoRef,
113
115
  type SessionBoundary,
114
116
  type SlotPool,
@@ -198,6 +200,17 @@ interface Deps {
198
200
  integrity: IntegrityGate;
199
201
  stall: StallGate;
200
202
  cleanup?: RetainedCleanupCursor;
203
+ /**
204
+ * How sessions authenticate a model, resolved once at startup so every child of
205
+ * one tick agrees — and so a fleet with none is refused at admission instead of
206
+ * discovering it at turn 0 (#152).
207
+ *
208
+ * Optional exactly as {@link Deps.boundary} is: absent means no resolution was
209
+ * made, which is the shape a unit test that does not exercise this gate wants.
210
+ * `runDaemon` always resolves one. `paged` is the page-once latch, so a fleet
211
+ * held for an environment fault says so once rather than every five minutes.
212
+ */
213
+ model?: { credential?: ModelCredential; paged: boolean };
201
214
  /**
202
215
  * Reads the connecting uid off a verb socket (#126). Resolved once at startup
203
216
  * so the mechanism is logged before the first socket exists; absent on a host
@@ -1065,6 +1078,10 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
1065
1078
  denyReadRoots: [...credentialDenyRoots(), project.workspaceRoot],
1066
1079
  denyReadFiles: credentialDenyFiles(),
1067
1080
  ...(credentials.readToken === undefined ? {} : { readToken: credentials.readToken }),
1081
+ // Resolved from the operator's own home at startup, because the session's
1082
+ // redirected $HOME cannot see it (#152). Admission already refused the whole
1083
+ // pass when it is absent, so undefined here is a fleet not dispatching.
1084
+ ...(d.model?.credential === undefined ? {} : { model: d.model.credential }),
1068
1085
  });
1069
1086
 
1070
1087
  if (boundary.principal !== undefined) {
@@ -1931,7 +1948,7 @@ function planUsageEscalation(project: string, plan: PlanUsageStatus): Escalation
1931
1948
  * that grows a field has no business breaking these tests.
1932
1949
  */
1933
1950
  export async function admitCandidates(
1934
- d: Pick<Deps, "project" | "caps" | "tracker" | "store" | "escalate" | "usage" | "boundary">,
1951
+ d: Pick<Deps, "project" | "caps" | "tracker" | "store" | "escalate" | "usage" | "boundary" | "model">,
1935
1952
  routed: Routed[],
1936
1953
  slots: number,
1937
1954
  ): Promise<AdmissionPass> {
@@ -1968,6 +1985,48 @@ export async function admitCandidates(
1968
1985
  return { admitted: [], holds };
1969
1986
  }
1970
1987
 
1988
+ // One layer down from the boundary above: that asks whether this host can run a
1989
+ // session as its own principal, this asks whether a session can do anything at
1990
+ // all once started. Every worker on the reference fleet died at turn 0 with
1991
+ // "No model selected" and consumed an issue attempt for it — three issues in
1992
+ // eleven minutes, while the fleet looked busy (#152).
1993
+ //
1994
+ // Above the tracker calls for the same reason as the boundary: a fleet that
1995
+ // cannot dispatch must not claim an issue it will fail, and must not spend API
1996
+ // budget discovering that.
1997
+ if (d.model !== undefined && d.model.credential === undefined) {
1998
+ for (const r of routed) hold(r.issue.number, "no-model-credential");
1999
+ log(`no model credential holding ${String(routed.length)} candidate(s)`);
2000
+ if (!d.model.paged) {
2001
+ d.model.paged = true;
2002
+ await safeEscalate(d, {
2003
+ tier: 2,
2004
+ project: project.name,
2005
+ issue: NO_ISSUE,
2006
+ summary: `${project.name} cannot dispatch: no model credential is reachable from a worker session`,
2007
+ detail: [
2008
+ "Sessions run with $HOME redirected into a per-run tree, so the harness never reads the",
2009
+ "operator's own configuration — that is what keeps a credential-bearing MCP server out of",
2010
+ "every session. It also means a login recorded only in ~/.omp/agent/agent.db is invisible to",
2011
+ "a worker, which then dies at turn 0 with \"No model selected\".",
2012
+ "",
2013
+ "Two supported shapes, either of which fixes it:",
2014
+ " * a model API key in the daemon's environment (ANTHROPIC_API_KEY, OPENAI_API_KEY, …)",
2015
+ " * the harness's auth broker — `omp auth-broker serve` plus `omp auth-broker token`, or",
2016
+ " auth.broker.url / auth.broker.token in ~/.omp/agent/config.yml. Refresh tokens stay in",
2017
+ " the broker and each session gets a short-lived access token, which is what per-run",
2018
+ " isolation wants.",
2019
+ "",
2020
+ "Copying agent.db into each run is deliberately NOT how this works: the harness rotates the",
2021
+ "OAuth credentials in it, so a copy goes stale and can invalidate the original.",
2022
+ "",
2023
+ "Nothing is dispatched until then. No work has been claimed, and no attempt was spent.",
2024
+ ].join("\n"),
2025
+ });
2026
+ }
2027
+ return { admitted: [], holds };
2028
+ }
2029
+
1971
2030
  // The plan allowance is a fleet-wide question, so it is asked once per pass
1972
2031
  // and answers for every candidate — unlike every gate below it, which is
1973
2032
  // per-issue. It sits here rather than beside the spend cap in `tick` for one
@@ -2961,15 +3020,38 @@ async function recoverRun(
2961
3020
  }
2962
3021
 
2963
3022
  if (recovery === "continue") {
2964
- // The branch is retained and its PR is open, so #50's continuation guard
2965
- // admits it: the next tick reattaches the branch and briefs a rebase.
3023
+ // Two classes recover by continuing, and only one of them has anything left
3024
+ // to do here.
3025
+ //
3026
+ // `turn-cap-progress` was already handed back by the completion path, which
3027
+ // swapped its labels and left the branch retained. There is nothing to
3028
+ // perform, and writing anything would be actively wrong: overwriting
3029
+ // `lastError` with a rebase brief tells the continuation worker to rebase a
3030
+ // run that simply ran out of turns, and re-swapping labels the completion
3031
+ // path already swapped is a pair of no-op `gh` calls. Record-only, so the
3032
+ // sweep stops re-offering it.
3033
+ if (cls === "turn-cap-progress") {
3034
+ store.updateRun(run.id, { recoveredAt: Date.now() });
3035
+ log(`#${run.issue} already continuing from ${cls}: ${evidence}`);
3036
+ return;
3037
+ }
3038
+
3039
+ // `merge-conflict`: the branch is retained and its PR is open, so #50's
3040
+ // continuation guard admits it and the next tick briefs a rebase.
3041
+ //
3042
+ // The tracker write goes FIRST, and that ordering is the whole retry
3043
+ // contract. Writing `recoveredAt` before the swap took the row out of
3044
+ // `runsNeedingClassification` — which selects on `recoveredAt IS NULL` — so a
3045
+ // refused label swap stranded the row permanently under a log line promising
3046
+ // a retry. That was the defect 0.4.4 claimed to have fixed and did not, for
3047
+ // this one recovery.
3048
+ if (!(await swapToQueue(d, run.issue, inProgress))) return;
2966
3049
  store.updateRun(run.id, {
2967
3050
  state: "killed",
2968
3051
  lastError:
2969
3052
  "merge-conflict: base moved under a green PR — rebase, regenerate recorded artifacts, push without force",
2970
3053
  recoveredAt: Date.now(),
2971
3054
  });
2972
- if (!(await swapToQueue(d, run.issue, inProgress))) return;
2973
3055
  log(`#${run.issue} requeued for a rebase continuation: ${evidence}`);
2974
3056
  return;
2975
3057
  }
@@ -3253,6 +3335,19 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
3253
3335
  // the other thing — the package changing while the daemon that dispatches
3254
3336
  // work is holding it open, whether that is a worker that wandered out of its
3255
3337
  // worktree or a human editing the live install "just to test something".
3338
+ // Resolved once, here, for the same reason the host probe above is: every
3339
+ // session of every tick must agree, and a fleet with no way to authenticate a
3340
+ // model must refuse at admission rather than burn an attempt per dispatch
3341
+ // (#152).
3342
+ const model: { credential?: ModelCredential; paged: boolean } = { paged: false };
3343
+ const resolvedModel = resolveModelCredential();
3344
+ if (resolvedModel !== undefined) model.credential = resolvedModel;
3345
+ log(
3346
+ resolvedModel === undefined
3347
+ ? "model credential: NONE reachable from a session — dispatch is held until an API key or an auth broker is configured"
3348
+ : `model credential: ${resolvedModel.kind === "env" ? "from this daemon's environment" : "auth broker, injected per session"}`,
3349
+ );
3350
+
3256
3351
  const integrity: IntegrityGate = { baseline: packageManifest(), paged: false };
3257
3352
  log(`package integrity baseline: ${integrity.baseline.size} files under ${import.meta.dir}`);
3258
3353
 
@@ -3365,6 +3460,7 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
3365
3460
  denyReadRoots: [...credentialDenyRoots(), project.workspaceRoot, project.mirrorRoot],
3366
3461
  denyReadFiles: credentialDenyFiles(),
3367
3462
  ...(credentials.readToken === undefined ? {} : { readToken: credentials.readToken }),
3463
+ ...(model.credential === undefined ? {} : { model: model.credential }),
3368
3464
  });
3369
3465
  if (orchestratorBoundary.principal !== undefined) {
3370
3466
  const gid = probe.daemonGid;
@@ -3466,6 +3562,7 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
3466
3562
  project,
3467
3563
  caps,
3468
3564
  boundary,
3565
+ model,
3469
3566
  tracker,
3470
3567
  store,
3471
3568
  // Process-wide, so a `status` served off this daemon's own HTTP surface
@@ -62,6 +62,29 @@ 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
+ function startFailure(lastError: string | undefined): string | undefined {
82
+ if (lastError === undefined) return undefined;
83
+ const text = lastError.toLowerCase();
84
+ const hit = START_FAILURE_SIGNATURES.find((signature) => text.includes(signature));
85
+ return hit === undefined ? undefined : lastError.split("\n")[0]?.trim();
86
+ }
87
+
65
88
  export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classification {
66
89
  const hasArtifacts = run.prUrl !== undefined || run.headSha !== undefined || run.salvageSha !== undefined;
67
90
 
@@ -85,6 +108,22 @@ export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classificatio
85
108
  };
86
109
  }
87
110
 
111
+ // Turn zero with an explicit harness start error is the most classifiable
112
+ // failure there is, and the least deserving of an implementation attempt: the
113
+ // session never got as far as reading the issue. 27 recoveries on the reference
114
+ // fleet were this, logged as `unknown` while the session's own output carried
115
+ // the literal string `No model selected` (#152).
116
+ if ((run.state === "failed" || run.state === "killed") && run.turns === 0) {
117
+ const detail = startFailure(run.lastError);
118
+ if (detail !== undefined) {
119
+ return {
120
+ cls: "env-start-failure",
121
+ recovery: "escalate",
122
+ evidence: `the session never started: ${detail}`,
123
+ };
124
+ }
125
+ }
126
+
88
127
  if (run.state === "blocked") {
89
128
  return {
90
129
  cls: "question",
package/src/plugin.ts CHANGED
@@ -70,6 +70,7 @@ import {
70
70
  detectTelegram,
71
71
  formatGates,
72
72
  orchestratorBriefPath,
73
+ relocateBriefIfMoved,
73
74
  planLabels,
74
75
  renderBriefForProject,
75
76
  summariseAmend,
@@ -1166,9 +1167,20 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
1166
1167
  const telegram = detectTelegram();
1167
1168
  const nextConfig = buildConfig(answers, existing);
1168
1169
  const project = findProject(nextConfig, answers.projectName);
1170
+ // The same project as it is configured right now, so a moved `workspaceRoot`
1171
+ // can be told apart from a fleet that never had a brief. Switching
1172
+ // `credentials.isolation` moves that root (0700 state dir ⇄ shared root), which
1173
+ // relocates both briefs.
1174
+ const previous = existing?.projects.find((p) => p.name === answers.projectName);
1175
+ const relocating =
1176
+ previous !== undefined &&
1177
+ previous.workspaceRoot !== project.workspaceRoot &&
1178
+ existsSync(policyPathForProject(previous)) &&
1179
+ !existsSync(policyPathForProject(project));
1169
1180
  if (
1170
1181
  project.escalation.orchestrator === "external" &&
1171
1182
  !answers.writeOrchestratorBrief &&
1183
+ !relocating &&
1172
1184
  (!existsSync(briefPathForProject(project)) || !existsSync(policyPathForProject(project)))
1173
1185
  ) {
1174
1186
  ctx.ui.notify(
@@ -1231,6 +1243,10 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
1231
1243
  answers.writeOrchestratorBrief
1232
1244
  ? `Writes ${orchestratorBriefPath(answers)}, which is then yours to edit.`
1233
1245
  : "",
1246
+ relocating
1247
+ ? `Moves your ${POLICY_BRIEF_NAME} to ${policyPathForProject(project)} (the isolation change moves the workspace root) ` +
1248
+ `and recomposes ${ORCHESTRATOR_BRIEF_NAME} beside it. The copy under ${policyPathForProject(previous)} is left in place.`
1249
+ : "",
1234
1250
  project.escalation.orchestrator === "external"
1235
1251
  ? "Dispatch stays paused until the existing arm marker or a new inbound Telegram proof makes the heartbeat live."
1236
1252
  : "Dispatch resumes after the smoke succeeds.",
@@ -1249,6 +1265,13 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
1249
1265
  prepareConductor();
1250
1266
  const created = await createMissingLabels(answers.trackerRepo, labels);
1251
1267
  saveConfig(nextConfig);
1268
+ // Before the optional scaffold write, and deliberately: the scaffold renders a
1269
+ // fresh POLICY.md, so writing it first would replace the policy this is trying
1270
+ // to carry across.
1271
+ const moved = relocateBriefIfMoved(previous, project);
1272
+ if (moved !== undefined) {
1273
+ ctx.ui.notify(`Moved ${POLICY_BRIEF_NAME} to ${moved.to} — the previous copy is still at ${moved.from}.`, "info");
1274
+ }
1252
1275
  const briefPath = answers.writeOrchestratorBrief ? writeOrchestratorBrief(answers) : undefined;
1253
1276
  const runtimeFiles = writeHostRuntime(runtime);
1254
1277
  const smoke = await runSetupSmoke(project.name);
package/src/setup-host.ts CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  TICK_CONFIG_FILE,
19
19
  type TickConfig,
20
20
  } from "./orchestrator-tick.ts";
21
- import type { Caps, ProjectConfig } from "./types.ts";
21
+ import { CONDUCTOR_GROUPS, type Caps, type ProjectConfig } from "./types.ts";
22
22
 
23
23
  export const DEFAULT_TICK_INTERVAL_SECONDS = 900;
24
24
  export const STAGED_SERVICE_NAME = "omp-conductor.service";
@@ -125,9 +125,20 @@ export function renderDaemonService(
125
125
  // daemon for nothing. Without them the probe honestly reports `group-mode`
126
126
  // and a configured per-run fleet refuses every dispatch, which is the
127
127
  // failure an operator following setup would otherwise hit first.
128
+ //
129
+ // `SupplementaryGroups=` is declared rather than left to initgroups, and that
130
+ // is not belt-and-braces. Supplementary groups are fixed when a process
131
+ // starts, so a daemon that was running when the accounts were provisioned can
132
+ // never join them — and a unit with no `User=` gets no initgroups call at
133
+ // all, so it comes up with `Groups: 0` even after the group database is
134
+ // correct. `probeHost` then reports `none` forever, the wizard cannot offer
135
+ // `uid-pool`, and the fleet quietly keeps the fail-closed value (#153).
136
+ // Declaring the membership makes it a property of the unit instead of a
137
+ // property of how the unit happened to be written.
128
138
  ...(resolveCredentials(project).isolation === "per-run"
129
139
  ? [
130
140
  `Environment=${systemdQuote(`OMP_CONDUCTOR_SHARED=${sharedRoot()}`)}`,
141
+ `SupplementaryGroups=${CONDUCTOR_GROUPS.daemon} ${CONDUCTOR_GROUPS.runs}`,
131
142
  "AmbientCapabilities=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP",
132
143
  "CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP",
133
144
  ]
@@ -343,11 +354,18 @@ export interface EffectiveUnit {
343
354
  /** `systemctl show -p AmbientCapabilities`; empty string when unset. */
344
355
  ambientCapabilities: string;
345
356
  capabilityBoundingSet: string;
357
+ /** `systemctl show -p SupplementaryGroups`; empty string when unset. */
358
+ supplementaryGroups: string;
346
359
  /** `systemctl show -p Environment`, newline- or space-joined. */
347
360
  environment: string;
348
361
  needDaemonReload: boolean;
349
362
  }
350
363
 
364
+ /** Whitespace-separated names as a comparable set; systemd reorders them. */
365
+ function nameSet(text: string): Set<string> {
366
+ return new Set(text.split(/[\s,]+/).map((n) => n.trim()).filter((n) => n.length > 0));
367
+ }
368
+
351
369
  /** Capability names as a comparable set: systemd reports them lowercased, `cap_`-prefixed and reordered. */
352
370
  function capabilitySet(text: string): Set<string> {
353
371
  return new Set(
@@ -387,6 +405,21 @@ export function unitDriftReason(rendered: string, effective: EffectiveUnit | und
387
405
  if (effective === undefined) return undefined;
388
406
  const problems: string[] = [];
389
407
 
408
+ // Missing groups only, never extra: an operator may legitimately add their own,
409
+ // and unlike a leftover capability a spare group grants nothing this package
410
+ // relies on. Missing ones are load-bearing — without them the probe resolves to
411
+ // `none` and a configured per-run fleet refuses every dispatch (#153).
412
+ const wantGroups = nameSet(renderedDirective(rendered, "SupplementaryGroups") ?? "");
413
+ const haveGroups = nameSet(effective.supplementaryGroups);
414
+ const missingGroups = [...wantGroups].filter((g) => !haveGroups.has(g));
415
+ if (missingGroups.length > 0) {
416
+ problems.push(
417
+ `SupplementaryGroups is missing ${missingGroups.join(", ")} — supplementary groups are fixed at ` +
418
+ `process start, so the running daemon cannot be a member and the credential boundary probe ` +
419
+ `resolves to none until the unit is reloaded and the service restarted`,
420
+ );
421
+ }
422
+
390
423
  const wantAmbient = capabilitySet(renderedDirective(rendered, "AmbientCapabilities") ?? "");
391
424
  const haveAmbient = capabilitySet(effective.ambientCapabilities);
392
425
  const missingAmbient = [...wantAmbient].filter((c) => !haveAmbient.has(c));
@@ -440,6 +473,7 @@ export function parseEffectiveUnit(stdout: string): EffectiveUnit {
440
473
  return {
441
474
  ambientCapabilities: values.get("AmbientCapabilities") ?? "",
442
475
  capabilityBoundingSet: values.get("CapabilityBoundingSet") ?? "",
476
+ supplementaryGroups: values.get("SupplementaryGroups") ?? "",
443
477
  environment: values.get("Environment") ?? "",
444
478
  needDaemonReload: (values.get("NeedDaemonReload") ?? "no") === "yes",
445
479
  };
package/src/setup.ts CHANGED
@@ -808,6 +808,46 @@ export function renderOrchestratorBrief(a: SetupAnswers): string {
808
808
  * that dialog's answer un-actionable — an operator who says "yes, overwrite it"
809
809
  * must get an overwrite.
810
810
  */
811
+ /**
812
+ * Carry a fleet's own `POLICY.md` to a workspace root that just moved.
813
+ *
814
+ * `workspaceRoot` is derived from `credentials.isolation`: `none` puts it under
815
+ * the 0700 state directory, `per-run` under the shared root a slot principal can
816
+ * traverse. So switching a fleet to worker isolation *relocates* the briefs, and
817
+ * the operator's half must go with them.
818
+ *
819
+ * Without this, amending isolation on an external-orchestrator fleet was a
820
+ * deadlock with a data-loss escape hatch. The setup gate refused because neither
821
+ * file existed at the new root; the only way past it was approving a brief write
822
+ * in the same pass, and {@link writeOrchestratorBrief} renders a *fresh* POLICY.md
823
+ * scaffold — silently replacing the operator's release procedure and amendment
824
+ * log, and orphaning the real one at the old root.
825
+ *
826
+ * `ORCHESTRATOR.md` is recomposed rather than copied, because it is derived from
827
+ * the package floor plus that policy on every tick anyway. The source is left in
828
+ * place: this package does not delete an operator's file, and a stale copy under
829
+ * the old root is inert once nothing reads it.
830
+ *
831
+ * Returns the moved path, or undefined when nothing needed moving — the roots
832
+ * match, there was no policy to carry, or the destination already has one.
833
+ */
834
+ export function relocateBriefIfMoved(
835
+ previous: ProjectConfig | undefined,
836
+ next: ProjectConfig,
837
+ ): { from: string; to: string } | undefined {
838
+ if (previous === undefined) return undefined;
839
+ if (previous.workspaceRoot === next.workspaceRoot) return undefined;
840
+ const from = policyPathForProject(previous);
841
+ const to = policyPathForProject(next);
842
+ if (!existsSync(from) || existsSync(to)) return undefined;
843
+
844
+ const policy = readFileSync(from, "utf8");
845
+ mkdirSync(dirname(to), { recursive: true });
846
+ writeFileSync(to, policy);
847
+ writeFileSync(briefPathForProject(next), composeOrchestrator(renderFloorForProject(next), policy));
848
+ return { from, to };
849
+ }
850
+
811
851
  export function writeOrchestratorBrief(a: SetupAnswers): string {
812
852
  const project = buildProject(a);
813
853
  const policyPath = policyPathForProject(project);
package/src/store.ts CHANGED
@@ -725,12 +725,12 @@ export function openStore(dbPath: string): Store {
725
725
  const countFailures = db.query<{ n: number }, [string, number]>(
726
726
  `SELECT COUNT(*) AS n FROM runs
727
727
  WHERE project = ? AND issue = ? AND state = 'failed'
728
- AND (failureClass IS NULL OR failureClass NOT IN ('ci-infra', 'settlement-stuck'))`,
728
+ AND (failureClass IS NULL OR failureClass NOT IN ('ci-infra', 'settlement-stuck', 'env-start-failure'))`,
729
729
  );
730
730
  const countContinuations = db.query<{ n: number }, [string, number]>(
731
731
  `SELECT COUNT(*) AS n FROM runs
732
732
  WHERE project = ? AND issue = ? AND state IN ('killed', 'orphaned', 'blocked')
733
- AND (failureClass IS NULL OR failureClass NOT IN ('admin-kill', 'settlement-stuck'))`,
733
+ AND (failureClass IS NULL OR failureClass NOT IN ('admin-kill', 'settlement-stuck', 'env-start-failure'))`,
734
734
  );
735
735
  // Newest first, and bounded: every row this returns costs `gh` calls to gather
736
736
  // facts for, so a fleet with a long unclassified history classifies over
package/src/types.ts CHANGED
@@ -877,6 +877,7 @@ export interface Tracker {
877
877
  * answers instead of re-investigating.
878
878
  */
879
879
  export const FAILURE_CLASSES = [
880
+ "env-start-failure",
880
881
  "turn-cap-progress",
881
882
  "turn-cap-spinning",
882
883
  "admin-kill",
@@ -1005,6 +1006,11 @@ export type AdmissionHoldReason =
1005
1006
  * dispatch refuses rather than running unprotected under a config that says
1006
1007
  * otherwise (#125). */
1007
1008
  | "credential-boundary"
1009
+ /** No model credential is reachable from a session's redirected `$HOME`, so
1010
+ * every worker would die at turn 0 with "No model selected" and spend an issue
1011
+ * attempt doing it (#152). Held rather than dispatched: an environment fault
1012
+ * is not a failed implementation. */
1013
+ | "no-model-credential"
1008
1014
  | "unroutable:no-repo-label"
1009
1015
  | "unroutable:multiple-repo-labels"
1010
1016
  | "unroutable:unknown-repo";
package/src/upgrade.ts CHANGED
@@ -471,6 +471,8 @@ async function defaultUnitFiles(deps: UpgradeDeps): Promise<UnitFiles | undefine
471
471
  "-p",
472
472
  "CapabilityBoundingSet",
473
473
  "-p",
474
+ "SupplementaryGroups",
475
+ "-p",
474
476
  "Environment",
475
477
  "-p",
476
478
  "NeedDaemonReload",
@@ -121,6 +121,14 @@ Environment=PATH=/home/fleet/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin
121
121
  # launcher omits --bounding-set=-all, the child still ends with every other set
122
122
  # empty behind NoNewPrivs, and `status` reports the non-empty CapBnd as a named
123
123
  # residual rather than ignoring it.
124
+ # Supplementary groups are fixed when a process starts, so a daemon that was
125
+ # already running when these accounts were provisioned can never join them --
126
+ # and a unit with no User= gets no initgroups call at all, so it comes up with
127
+ # `Groups: 0` even once /etc/group is correct. Declared here so membership is a
128
+ # property of the unit rather than of how it happened to be started; after a
129
+ # `daemon-reload` and a restart, `omp-conductor status` reports the probed
130
+ # mechanism as uid-pool.
131
+ SupplementaryGroups=conductor-daemon conductor-runs
124
132
  AmbientCapabilities=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP
125
133
  CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP
126
134