diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index a3cad456f..6feb090a5 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -40,7 +40,7 @@ export function statusCommand(depsOverride?: StatusDeps): Command { // JSON mode: one object, same keys on every path; sections the command did not // (or could not) read are null rather than absent. const emit = (daemon: Record, rest: Record = {}): void => { - console.log(JSON.stringify({ daemon, kernel: null, workspaceRoot: null, rigs: null, cmux: null, ...rest }, null, 2)); + console.log(JSON.stringify({ daemon: { ...daemon, ...(status.databaseWarnings ? { databaseWarnings: status.databaseWarnings } : {}) }, kernel: null, workspaceRoot: null, rigs: null, cmux: null, ...rest }, null, 2)); }; const deps = depsOverride ?? { @@ -49,6 +49,7 @@ export function statusCommand(depsOverride?: StatusDeps): Command { }; const status = await getDaemonStatus(deps.lifecycleDeps); + if (!json) for (const warning of status.databaseWarnings ?? []) console.log(`Warning: ${warning}`); if (json && status.state !== "running") { // reachable: false = positively down (stopped, stale); null = not confirmed (unverified). diff --git a/packages/cli/src/daemon-lifecycle.ts b/packages/cli/src/daemon-lifecycle.ts index ad2f32737..ae69cf612 100644 --- a/packages/cli/src/daemon-lifecycle.ts +++ b/packages/cli/src/daemon-lifecycle.ts @@ -39,6 +39,7 @@ export interface DaemonEventLoopEvidence { } export interface DaemonStatus { + databaseWarnings?: string[]; /** RULING 1ae863d2 — C3 3-state semantics (canonical: daemon crash-cart-detect.ts, kept in * lockstep): "stopped" requires POSITIVE evidence (connection refused); a probe TIMEOUT or * other non-refusal failure is "unverified" — never a down assertion. */ @@ -732,7 +733,7 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D if (hasExited()) throw new Error(`Daemon child ${pid} exited before startup completed`); }; const healthzUrl = `http://${formatDaemonHostForUrl(probeHost)}:${port}/healthz`; - type StartHealth = { pid?: unknown; bind?: { mode: "explicit" | "default"; hosts: string[]; tailscaleDetected: boolean } }; + type StartHealth = { databaseWarnings?: unknown; pid?: unknown; bind?: { mode: "explicit" | "default"; hosts: string[]; tailscaleDetected: boolean } }; const readOwnedHealth = async (url: string): Promise => { assertChild(); let timer: ReturnType | undefined; @@ -764,6 +765,7 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D assertChild(); lock.recordChild(pid!); let healthy = false; + let databaseWarnings: string[] = []; let lastProbe = "health endpoint not responding"; let unverified: string[] | null = null; for (let i = 0; i < HEALTHZ_RETRIES; i++) { @@ -771,6 +773,7 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D try { const body = await readOwnedHealth(healthzUrl); if (body) { + databaseWarnings = readDatabaseWarnings(body.databaseWarnings); const bind = body.bind; if (!bind || !["explicit", "default"].includes(bind.mode) || !Array.isArray(bind.hosts) || bind.hosts.length === 0 || !bind.hosts.every((host) => typeof host === "string" && host.length > 0) @@ -821,6 +824,7 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D removeMatchingState(deps, STATE_FILE, state); throw error; } + for (const warning of databaseWarnings) console.error(`Warning: ${warning}`); if (unverifiedSecondary) { console.error(`Warning: daemon started (pid ${pid}), but its listener on ${unverified!.join(", ")} could not be verified: the check got no clear answer, not a refusal. Check /healthz on that address before relying on it.`); } @@ -1016,7 +1020,7 @@ export async function getDaemonStatus( const res = await probeHealthzWithSettle(deps, `${openrigUrl}/healthz`); const ev = await readHealthEvidence(res); const url = new URL(openrigUrl); - return { state: "running", port: Number(url.port) || DEFAULT_PORT, host: url.hostname || DEFAULT_HOST, healthy: ev.healthy, reason: ev.reason, eventLoop: ev.eventLoop }; + return { state: "running", port: Number(url.port) || DEFAULT_PORT, host: url.hostname || DEFAULT_HOST, healthy: ev.healthy, reason: ev.reason, eventLoop: ev.eventLoop, ...(ev.databaseWarnings ? { databaseWarnings: ev.databaseWarnings } : {}) }; } catch (err) { // 1ae863d2: refusal = positive down; anything else (timeout/wedged/blocked) = unverified. return isRefusedError(err) ? { state: "stopped" } : { state: "unverified", ...endpoint, ...probeErrorCodeOf(err) }; @@ -1036,6 +1040,7 @@ export async function getDaemonStatus( healthy: ev.healthy, reason: ev.reason, eventLoop: ev.eventLoop, + ...(ev.databaseWarnings ? { databaseWarnings: ev.databaseWarnings } : {}), }; } catch (err) { // 1ae863d2: the resolved home has NO daemon state — before asserting anything, @@ -1068,7 +1073,7 @@ export async function getDaemonStatus( try { const res = await probeHealthzWithSettle(deps, `http://${formatDaemonHostForUrl(host)}:${state.port}/healthz`); const ev = await readHealthEvidence(res); - return { state: "running", port: state.port, host, pid: state.pid, healthy: ev.healthy, reason: ev.reason, eventLoop: ev.eventLoop }; + return { state: "running", port: state.port, host, pid: state.pid, healthy: ev.healthy, reason: ev.reason, eventLoop: ev.eventLoop, ...(ev.databaseWarnings ? { databaseWarnings: ev.databaseWarnings } : {}) }; } catch (err) { // 1ae863d2: refusal = positive down; anything else (timeout/wedged/blocked) = unverified. return isRefusedError(err) ? { state: "stopped" } : { state: "unverified", pid: state.pid, port: state.port, host, ...probeErrorCodeOf(err) }; @@ -1079,6 +1084,7 @@ export async function getDaemonStatus( let healthy = false; let reason: DaemonStatus["reason"]; let eventLoop: DaemonEventLoopEvidence | undefined; + let databaseWarnings: string[] | undefined; let probeFailure: Pick = {}; try { const res = await probeHealthzWithSettle(deps, `http://${formatDaemonHostForUrl(host)}:${state.port}/healthz`); @@ -1086,6 +1092,7 @@ export async function getDaemonStatus( healthy = ev.healthy; reason = ev.reason; eventLoop = ev.eventLoop; + databaseWarnings = ev.databaseWarnings; } catch (err) { // OPR.0.4.3.21 — pid alive but healthz timed out: the honest wedged-loop // signal. Report process-present/unhealthy (NOT "stopped") with the @@ -1095,7 +1102,7 @@ export async function getDaemonStatus( } // pid alive = running (state file preserved either way) - return { state: "running", port: state.port, host, pid: state.pid, healthy, reason, eventLoop, ...probeFailure }; + return { state: "running", port: state.port, host, pid: state.pid, healthy, reason, eventLoop, ...(databaseWarnings ? { databaseWarnings } : {}), ...probeFailure }; } /** #275 — keep the OS code of a failed health probe (absent for a timeout). */ @@ -1123,6 +1130,10 @@ export function tailLogs(deps: LifecycleDeps, opts: { follow: boolean }): void { child.unref(); } +function readDatabaseWarnings(value: unknown): string[] { + return Array.isArray(value) && value.every(item => typeof item === "string") ? value : []; +} + /** * OPR.0.4.3.21 — read event-loop wedge evidence from a healthz probe result. * A plain `{ ok: true }` (mocks, or a monitor-less daemon) → healthy with no @@ -1132,11 +1143,13 @@ export function tailLogs(deps: LifecycleDeps, opts: { follow: boolean }): void { */ async function readHealthEvidence( res: { ok: boolean; json?: () => Promise }, -): Promise<{ healthy: boolean; reason?: DaemonStatus["reason"]; eventLoop?: DaemonEventLoopEvidence }> { +): Promise<{ healthy: boolean; reason?: DaemonStatus["reason"]; eventLoop?: DaemonEventLoopEvidence; databaseWarnings?: string[] }> { if (!res.ok) return { healthy: false }; if (!res.json) return { healthy: true }; try { - const body = (await res.json()) as { eventLoop?: Partial } | null; + const body = (await res.json()) as { eventLoop?: Partial; databaseWarnings?: unknown } | null; + const warnings = readDatabaseWarnings(body?.databaseWarnings); + const context = warnings.length ? { databaseWarnings: warnings } : {}; const el = body?.eventLoop; if (el && typeof el.healthy === "boolean" && typeof el.lagMeanMs === "number") { const eventLoop: DaemonEventLoopEvidence = { @@ -1147,9 +1160,10 @@ async function readHealthEvidence( healthy: el.healthy, }; return el.healthy - ? { healthy: true, eventLoop } - : { healthy: false, reason: "event-loop-starved", eventLoop }; + ? { healthy: true, eventLoop, ...context } + : { healthy: false, reason: "event-loop-starved", eventLoop, ...context }; } + return { healthy: true, ...context }; } catch { // Body absent / not JSON — treat as plain healthy (res.ok already true). } diff --git a/packages/daemon/src/daemon-db-path.ts b/packages/daemon/src/daemon-db-path.ts index f1fc943a4..ef29d2f87 100644 --- a/packages/daemon/src/daemon-db-path.ts +++ b/packages/daemon/src/daemon-db-path.ts @@ -1,4 +1,6 @@ -import { lstatSync, readlinkSync, realpathSync } from "node:fs"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { lstatSync, readFileSync, readlinkSync, realpathSync, statSync } from "node:fs"; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; /** Resolve existing path components, including a dangling final symlink, while @@ -66,3 +68,52 @@ export function resolveDaemonDbPath(explicitDb: string | undefined | null, openr } return dbPath; } + +const execFileAsync = promisify(execFile); +async function lsof(args: string[]): Promise { + try { + return (await execFileAsync("lsof", args, { timeout: 1000, maxBuffer: 64 * 1024 })).stdout; + } catch { return ""; } +} + +/** Boot-time diagnostics only, never exclusion or permission to share a DB. + * Missing tools, inaccessible records and races leave ownership unknown. */ +export async function daemonDatabaseWarnings(dbPath: string, home: string, selectedBy?: string): Promise { + if (dbPath === ":memory:") return []; + const warnings: string[] = []; + let db: string; + let resolvedHome: string; + try { + db = resolveForContainment(dbPath); + resolvedHome = resolveForContainment(home); + } catch { return warnings; } + if (selectedBy && !isWithin(resolvedHome, db)) { + warnings.push(`${selectedBy} selects database ${db} outside OPENRIG_HOME ${resolvedHome}. This explicit path is honoured. The effective daemon environment can come from --db, db.path, OPENRIG_DB or RIGGED_DB. Remove or correct the selecting override to use this home's database.`); + } + // The OS supplies actual open-file holders; daemon.json and the listener must + // independently agree on the PID before a holder is described as a daemon. + const holders = await lsof(["-t", "--", db]); + const pids = [...new Set(holders.trim().split(/\s+/).map(Number))] + .filter(pid => Number.isSafeInteger(pid) && pid > 0 && pid !== process.pid).slice(0, 4); + const conflicts = await Promise.all(pids.map(async (pid) => { + const files = await lsof(["-a", "-p", String(pid), "-d", "1,2", "-Fn"]); + const homes = new Set([dirname(db)]); + for (const line of files.split("\n")) { + if (line.startsWith("n") && basename(line.slice(1)) === "daemon.log") homes.add(dirname(line.slice(1))); + } + for (const candidate of homes) { + try { + const recordPath = join(candidate, "daemon.json"); + const record = JSON.parse(readFileSync(recordPath, "utf8")); + const modifiedAt = statSync(recordPath).mtime.toISOString(); + if (record.pid !== pid || typeof record.db !== "string" || resolveForContainment(record.db) !== db + || !Number.isInteger(record.port) || record.port < 1 || record.port > 65535) continue; + const response = await fetch(`http://127.0.0.1:${record.port}/healthz`, { signal: AbortSignal.timeout(500) }); + if (!response.ok || (await response.json() as { pid?: unknown }).pid !== pid) continue; + return `At startup: Another live daemon (pid ${pid}) had database ${db} open. Matching record ${recordPath}, modified ${modifiedAt}: recorded home ${resolveForContainment(candidate)} (current home unconfirmed). This daemon's OPENRIG_HOME is ${resolvedHome}. Startup and database writes continue; use separate database paths for independent instances.`; + } catch { /* A stale/unreadable record or failed probe is not a live-daemon claim. */ } + } + return undefined; + })); + return [...warnings, ...conflicts.filter((message): message is string => message !== undefined)]; +} diff --git a/packages/daemon/src/server.ts b/packages/daemon/src/server.ts index a1a85ba40..60f22c116 100644 --- a/packages/daemon/src/server.ts +++ b/packages/daemon/src/server.ts @@ -145,6 +145,8 @@ import { createRouteTimingMiddleware } from "./domain/route-timing-recorder.js"; import { browserBoundary, type BrowserBoundaryOptions } from "./middleware/browser-boundary.js"; export interface AppDeps { + /** Boot-time DB context observations, not a live exclusion guarantee. */ + databaseWarnings?: string[]; proofSourceWatch?: import("./domain/proof/source-watch.js").ProofSourceWatch; /** S20 — effective bind plan for the health surface (absent = legacy body). */ bindPlan?: { mode: "explicit" | "default"; hosts: string[]; tailscaleDetected: boolean; ignoredRoutingHost?: string }; @@ -673,7 +675,8 @@ export function createApp(deps: AppDeps): Hono { const stamp = stampFields(); // Self-report for the optional CLI effective-user check, not authentication. const euid = process.geteuid?.(); - const processIdentity = { pid: process.pid, ...(euid === undefined ? {} : { euid }) }; + const databaseContext = deps.databaseWarnings?.length ? { databaseWarnings: deps.databaseWarnings } : {}; + const processIdentity = { ...databaseContext, pid: process.pid, ...(euid === undefined ? {} : { euid }) }; // 51-09 increment 3 (arch ruling 2e1b737f; local always-suffix rendering since // superseded by the 2026-08-27 root invariant) — expose the daemon's boot-reconciled // self-host id so the CLI edge can detect cross-host targets, self-resolve a diff --git a/packages/daemon/src/startup.ts b/packages/daemon/src/startup.ts index 2603368e7..b4fc0c658 100644 --- a/packages/daemon/src/startup.ts +++ b/packages/daemon/src/startup.ts @@ -1,3 +1,4 @@ +import { daemonDatabaseWarnings } from "./daemon-db-path.js"; import { physicalSessionName } from "./domain/session-registry.js"; import { makeWatchdogDeliver } from "./domain/watchdog-delivery.js"; import { fileURLToPath } from "node:url"; @@ -317,6 +318,9 @@ export async function createDaemon(opts?: DaemonOptions): Promise // Only the explicit selection overrides a pane rc or the legacy PID-home // reader. The resolved default remains the writer/network-reader/session root. const dbPath = opts?.dbPath ?? ":memory:"; + const databaseWarnings = await daemonDatabaseWarnings(dbPath, OPENRIG_HOME, + process.env.OPENRIG_DB === dbPath ? "OPENRIG_DB" : process.env.RIGGED_DB === dbPath ? "RIGGED_DB" : undefined); + for (const warning of databaseWarnings) console.warn(`Warning: ${warning}`); const db = createDb(dbPath); migrate(db, ALL_MIGRATIONS); @@ -1308,6 +1312,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise const deps: AppDeps = { + databaseWarnings, rigRepo, sessionRegistry, daemonLifecycleStore, diff --git a/packages/daemon/test/daemon-db-context.integration.test.ts b/packages/daemon/test/daemon-db-context.integration.test.ts new file mode 100644 index 000000000..f9574d5f3 --- /dev/null +++ b/packages/daemon/test/daemon-db-context.integration.test.ts @@ -0,0 +1,80 @@ +import { expect, it } from "vitest"; +import { copyFileSync, existsSync, readFileSync, realpathSync, utimesSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { prepareHermeticEnv, type HermeticScaffold } from "./helpers/hermetic-env.js"; +import { findFreePort, runRig } from "./helpers/scenario-daemon.js"; + +const rigBin = resolve(dirname(fileURLToPath(import.meta.url)), "../../cli/dist/bin-wrapper.js"); +const fixture = () => prepareHermeticEnv({ baseEnv: { PATH: process.env.PATH, TERM: "xterm-256color" } }); +async function start(home: HermeticScaffold, db: string) { + home.env.OPENRIG_DB = db; + const result = await runRig(["daemon", "start", "--port", String(await findFreePort()), "--no-kernel"], home.env, rigBin, 60_000); + expect(result.code, result.stdout + result.stderr).toBe(0); + return result.stdout + result.stderr; +} +async function stop(home: HermeticScaffold) { + await runRig(["daemon", "stop"], home.env, rigBin); + home.cleanup(); +} + +it("honours an outside OPENRIG_DB and explains it at start and in text/JSON status", async () => { + const home = fixture(); + const db = join(home.stateDir, "outside.sqlite"); + try { + const started = await start(home, db); + expect(started).toContain("OPENRIG_DB"); + expect(started).not.toContain("Another live daemon"); + expect(started).toContain(realpathSync(home.openrigHome)); + expect(started).toContain(realpathSync(db)); + expect(existsSync(join(home.openrigHome, "openrig.sqlite"))).toBe(false); + // Status describes the serving daemon, even if this shell selects a different DB. + home.env.OPENRIG_DB = join(home.stateDir, "unused.sqlite"); + const text = await runRig(["status"], home.env, rigBin); + expect(text.code, text.stdout + text.stderr).toBe(0); + expect(text.stdout).toContain("OPENRIG_DB"); + expect(text.stdout).toContain(realpathSync(db)); + const json = await runRig(["status", "--json"], home.env, rigBin); + expect(json.code, json.stdout + json.stderr).toBe(0); + expect(JSON.parse(json.stdout).daemon.databaseWarnings.join("\n")).toContain(realpathSync(home.openrigHome)); + } finally { await stop(home); } +}, 120_000); + +it("warns when another live daemon has the same database open and still starts", async () => { + const first = fixture(); + const second = fixture(); + const db = join(first.stateDir, "shared.sqlite"); + try { + expect(await start(first, db)).not.toContain("Another live daemon"); + const pid = JSON.parse(readFileSync(join(first.openrigHome, "daemon.json"), "utf8")).pid; + // A matching stale record beside the DB does not attest the live holder's home. + const staleRecord = join(first.stateDir, "daemon.json"); + copyFileSync(join(first.openrigHome, "daemon.json"), staleRecord); + const oldTime = new Date("2020-01-01T00:00:00Z"); + utimesSync(staleRecord, oldTime, oldTime); + const started = await start(second, db); + expect(started).toContain("Another live daemon"); + expect(started).toContain(String(pid)); + expect(started).toContain("current home unconfirmed"); + expect(started).toContain("2020-01-01T00:00:00.000Z"); + expect(started).toContain(staleRecord); + expect(started).toContain(realpathSync(db)); + const status = await runRig(["status", "--json"], second.env, rigBin); + expect(status.code, status.stdout + status.stderr).toBe(0); + expect(JSON.parse(status.stdout).daemon.databaseWarnings.join("\n")).toContain("Another live daemon"); + } finally { await stop(second); await stop(first); } +}, 120_000); + +it("explains a --db override without promising that unsetting OPENRIG_DB resets it", async () => { + const home = fixture(); + delete home.env.OPENRIG_DB; + delete home.env.RIGGED_DB; + const db = join(home.stateDir, "explicit.sqlite"); + try { + const result = await runRig(["daemon", "start", "--port", String(await findFreePort()), "--no-kernel", "--db", db], home.env, rigBin, 60_000); + expect(result.code, result.stdout + result.stderr).toBe(0); + expect(result.stderr).toContain("--db, db.path, OPENRIG_DB or RIGGED_DB"); + expect(result.stderr).toContain("Remove or correct the selecting override"); + expect(result.stderr).not.toContain("Unset OPENRIG_DB to use"); + } finally { await stop(home); } +}, 120_000);