/** * The fleet-owned omp settings overlay (#537). * * Workers inherit omp configuration from the daemon account's global * `~/.omp/agent/config.yml` and nothing else. This module is the channel for * saying "this project's workers run with *these* omp settings" without * editing that fleet-wide file: a project's `ompSettings` map (an opaque * settings tree the harness owns) is materialised to a YAML overlay *outside * the worktree* — under the run's own session directory, alongside the * transcript and the control socket — and threaded to the session through * omp's own `Settings.init({ configFiles: [] })` seam. * * Two properties make this channel distinct from the obvious alternative of * writing `/.omp/config.yml`: * * - The overlay is fleet-owned, never a file in the git checkout, so the * diff a worker eventually ships is untouched and nothing needs an ignore * entry. The worktree ignore block's standing rule — an ignored new file * is invisible to salvage — is exactly why an in-tree staging was refused. * - It is rewritten from config on every dispatch, so a config edit takes * effect on the next attempt (and a resumed attempt reuses the kept * session directory with the *current* config, not the one as of first * provision). * * Conductor validates YAML shape only — omp owns the schema. An unknown key or * a wrong-typed value is omp's to reject when it resolves the overlay, never * conductor's to interpret. */ import { unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { stringify } from "yaml"; import { stateDir } from "./config.ts"; import type { ProjectConfig } from "./types.ts"; /** The overlay filename inside a run's session directory. */ export const OMP_SETTINGS_FILE = "omp-settings.yml"; /** * The OMP model-role names one settings map declares — the keys of its * `modelRoles` stanza (#875). This is the one grammar every surface that * names an adjudicator role reads: omp's own `modelRoles` config, whether it * sits in the daemon account's global settings, a project's overlay, or both. * Anything that is not a flat string-map names no roles at all — an overlay * whose `modelRoles` is, say, a YAML list is omp's to reject, and offering * garbage role names here would only train the setup dialog on values OMP * would refuse. `null` and any other non-object input — including an empty * or comments-only settings file whose YAML parses to `null` — is zero * roles, never a fault. */ export function modelRolesIn(settings: unknown): readonly string[] { if (settings === null || typeof settings !== "object" || Array.isArray(settings)) return []; const roles = (settings as Record)["modelRoles"]; if (roles === null || typeof roles !== "object" || Array.isArray(roles)) return []; return Object.keys(roles as Record); } /** * The effective overlay map for a project: the opaque `ompSettings` map plus * the retry keys (`retry.modelFallback`, `retry.fallbackChains.default`) * derived from `modelFallbacks` — #539's staging half, moved onto this general * channel so there is one way to stage settings into a worker session, not one * seam per feature. * * `ompSettings.retry` is the operator's explicit word: when it is already a * mapping, the derived retry block is skipped entirely (the overlay then says * what it says, and the dispatch-failover chain #286 reads is unchanged — only * omp's *retry-time* model swap respects the explicit stanza). Otherwise the * derived block is merged in, preserving #539's behaviour exactly. * * Returns `undefined` when nothing is staged — a project with neither an * `ompSettings` map nor a model-fallback chain gets no overlay at all, so its * dispatch is byte-for-byte today's. */ export function ompSettingsOverlay( p: Pick, ): Record | undefined { const map: Record = { ...(p.ompSettings ?? {}) }; const chain = p.modelFallbacks; const explicitRetry = typeof map.retry === "object" && map.retry !== null && !Array.isArray(map.retry); if (chain !== undefined && chain.length > 0 && !explicitRetry) { map.retry = { modelFallback: true, fallbackChains: { default: [...chain] }, }; } return Object.keys(map).length === 0 ? undefined : map; } /** * The directory every run's session dir lives under — where the overlay is * materialised at dispatch. `doctor` probes its writability to report whether * the next dispatch's overlay can actually land. */ export function sessionRootDir(): string { return join(stateDir(), "sessions"); } /** * Materialises a project's effective overlay to `/omp-settings.yml` * and returns its absolute path, or `undefined` when nothing is staged. * * When nothing is staged, any overlay a previous attempt wrote is removed, so * a project that dropped its map leaves no stale file in a kept session dir — * the resume path reuses that directory, and an orphaned overlay with nobody * reading it would be the exact silent fake this channel exists to avoid. * * Re-runs every dispatch, so a kept worktree picks up a config change on the * next attempt rather than at the next worktree creation. A non-staged * project's dispatch degrades to the harness discovering settings exactly as * it always has. */ export function materializeOmpSettings( project: Pick, sessionDir: string, ): string | undefined { const path = join(sessionDir, OMP_SETTINGS_FILE); const overlay = ompSettingsOverlay(project); if (overlay === undefined) { try { unlinkSync(path); } catch { // Nothing written — a fresh session dir, or the absent case on first // dispatch. Either way there is no stale overlay to remove. } return undefined; } // 0600: the overlay can carry provider names and model selectors, and the // session dir already holds the run's private channels. writeFileSync(path, stringify(overlay), { mode: 0o600 }); return path; }