UNPKG

kestrel.markets

Version:

A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.

416 lines (386 loc) 20.5 kB
/** * # session/live-lock — the per-host live singleton lock (kestrel-7o2, Wave A / A6) * * Two live pumps against ONE broker account = doubled clips. Every other safety layer in this repo * is per-process — the kill-switch latches in one process, the L0 clamp bounds one process's orders, * WALL 5 ceilings one process's preflight — so NONE of them can see a second process arming the same * account. This module is the one guard whose subject is the HOST, not the process: an advisory, * owner-only lock file under the existing `~/.kestrel` home, keyed on (venue, account). * * ## Where the floor ends and the lease begins (kestrel-7o2.2) * This is the SPIKE FLOOR, deliberately less than the control-plane lease, and it must not be * mistaken for it. What is HERE: * - one host, one filesystem, one supervised operator; * - mutual exclusion keyed on (venue, account); * - fail-closed acquisition and a pid-death-proven recovery. * What is NOT here, and stays in **kestrel-7o2.2** (control-plane lease, ADR-0007 / ADR-0034 §7): * - **cross-host** exclusion — two machines cannot see each other's lock files, so this module * REFUSES rather than reasons about a foreign host ({@link LiveLockFailure} `foreign-host`); * - **pod-lineage** scope — the lease's subject is the pod lineage, not (venue, account); * - **TTL / auto-expiry at market close**, and the arm-survives-restart question — both need a * trusted clock this module deliberately does not have (see "Time" below); * - **fencing tokens** — the lease hands the broker a monotonic token so a resumed zombie's writes * are rejected AT THE VENUE. That is the only thing that truly closes the recovery race below; * a file lock cannot, because the broker does not participate in it. * A file on a local disk is not a distributed lock. Do not let this module's existence retire 7o2.2. * * ## Fail closed * Every path that cannot PROVE the account is free refuses, with a typed {@link LiveLockFailure} and * a logged, secret-free reason. There is no "proceed unlocked" branch and no force/override flag: an * unreadable lock, a foreign host, a live holder, and an I/O error all refuse identically. The cost * is that a corrupt lock file needs a human `rm` — the refusal names the path so the operator can. * That is the intended trade: an outage is recoverable, a double clip is not. * * ## Recovery: pid-death, never age * A dead holder's lock is recoverable. The recovery predicate is exactly one thing — **the holder's * pid is provably gone on THIS host** — and it is deliberately NOT a TTL. A TTL steals from a slow * holder (a GC pause, an SIGSTOP, a long reconcile) and stealing from a LIVE holder is the precise * failure this module exists to prevent, so age never grants a takeover. The direction of the error * matters: a recycled pid reads as ALIVE and we refuse (annoying, safe), never as dead (silent, * catastrophic). Recovery is OBSERVABLE two ways: it announces itself through {@link LiveLockDeps.notify} * and the new record carries {@link LiveLockRecord.recovered_from}, so a takeover is durable evidence * rather than a line that scrolled past. A silent takeover would be indistinguishable from a bug. * * **The residual race, stated honestly:** between the unlink of a dead holder's file and our * exclusive re-create, a second recoverer can interleave. `O_EXCL` decides the winner (not * last-writer-wins) and {@link LiveLockHandle.held} re-reads the disk, so the loser learns it lost — * but only when it next asks. A driver MUST therefore call `held()` before each submit rather than * trusting the handle it acquired at open. Closing this window for real needs the venue-side fencing * token in 7o2.2. * * ## Time * No wall clock is read here. `now` is injected ({@link LiveLockDeps.now}, the repo's `NowFn` seam — * same shape as `IbkrTransportDeps.now` / `PaperHooks.now`) and is used ONLY to STAMP the record for * observability; no decision branches on it. That is what lets the recovery rule stay pid-death: * with no time in the predicate there is no clock to skew, and nothing on the deterministic Session * path is load-bearing on this control-plane record (OSS-ADR-0010). * * The lock is ADVISORY: it binds processes that ask. It is the floor for a supervised, single-operator * spike — not a mechanism that can stop a determined or unaware writer. */ import { homedir, hostname } from "node:os"; import { join } from "node:path"; import { createHash } from "node:crypto"; import { mkdirSync, openSync, writeSync, closeSync, readFileSync, unlinkSync, chmodSync } from "node:fs"; import { redactAccount } from "../adapters/broker/ibkr/config.ts"; /** A monotone-enough wall clock in epoch ms — injected, never ambient (mirrors `IbkrTransportDeps.now`). */ export type NowFn = () => number; /** The singleton's SUBJECT: one live pump per (venue, account) per host. Two different accounts are * two different singletons; the same account at two venues likewise (a venue is a distinct book). */ export interface LiveLockScope { /** The venue key — the registry's `IBKR_PAPER_VENUE`-style id (e.g. `"ibkr"`). */ readonly venue: string; /** The broker account id. NEVER written to disk in the clear — hashed into the path, redacted in * the record (a lock file that names a live account is a credential-adjacent leak, cf. 7o2.23). */ readonly account: string; } /** Why an acquisition refused. Closed vocabulary — an unknown state is not representable. */ export type LiveLockFailure = /** A live process on this host holds the lock. The ordinary, correct refusal. */ | "held" /** The lock is held by a record from ANOTHER host: we cannot prove its liveness, so we refuse. */ | "foreign-host" /** The lock file exists but is not a lock file we understand (corrupt / wrong schema / newer * version). We refuse rather than interpret — and never delete what we cannot read. */ | "unreadable" /** The filesystem refused (perms, missing home, disk). Fail closed: no lock, no trading. */ | "io"; /** A refusal to grant the live singleton. Carries the typed {@link LiveLockFailure} and a secret-free * reason (the account is redacted) — mirrors `PaperSessionRefused`'s shape so a driver can log both * the same way. */ export class LiveLockRefused extends Error { override readonly name = "LiveLockRefused"; readonly failure: LiveLockFailure; readonly reason: string; constructor(failure: LiveLockFailure, reason: string, options: { cause?: unknown } = {}) { super(`live singleton lock REFUSED (${failure}): ${reason}`, options); this.failure = failure; this.reason = reason; } } /** The on-disk lock record (v1) — a control-plane authority record (OSS-ADR-0010), not a projection: * it derives from no text truth and nothing on the graded fold reads it. */ export interface LiveLockRecord { readonly version: 1; /** The venue key (plaintext — not a secret). */ readonly venue: string; /** The REDACTED account (`U***67`) — enough to identify the lock in a log, never enough to leak it. */ readonly account: string; /** The host that minted this record. A record from another host is never recovered. */ readonly host: string; /** The holder's pid ON `host`. The liveness subject — meaningless off that host. */ readonly pid: number; /** `sha256(host|pid|acquired_at)` — the holder's identity, so `held()` distinguishes US from a * later holder that happens to share our pid. Derived, NOT random: no unseeded RNG (RUNTIME §0). */ readonly token: string; /** The injected `now` at acquisition (epoch ms). OBSERVABILITY ONLY — nothing branches on it. */ readonly acquired_at: number; /** Present iff this lock was RECOVERED from a dead holder — the durable evidence of a takeover. */ readonly recovered_from?: { readonly pid: number; readonly acquired_at: number; }; } /** The injected environment. Every ambient read (clock, pid, hostname, process liveness, home) is a * seam so the fixtures state facts instead of racing them. */ export interface LiveLockDeps { /** Base dir for `.kestrel/`. Defaults to `KESTREL_HOME` then `homedir()` — same rule as * `cli/credentials.ts`, deliberately reusing that home rather than inventing a second one. */ readonly home?: string; /** Wall clock (epoch ms) for the record stamp. Defaults to `Date.now`. Never a decision input. */ readonly now?: NowFn; /** This host's identity. Defaults to `os.hostname()`. */ readonly hostId?: string; /** This process's pid. Defaults to `process.pid`. */ readonly pid?: number; /** * The pid-liveness oracle — THE recovery predicate. Defaults to `process.kill(pid, 0)`, which * throws `ESRCH` iff no such process exists. Note `EPERM` means the process EXISTS but is owned by * another user ⇒ ALIVE ⇒ refuse. Anything we cannot disprove is treated as alive (fail closed). */ readonly isAlive?: (pid: number) => boolean; /** Announce a takeover. Defaults to `process.stderr` — a recovery must never be silent. */ readonly notify?: (line: string) => void; } /** A held lock. `held()` is the still-holder re-check a driver MUST call before each submit. */ export interface LiveLockHandle { /** The lock file this handle owns. */ readonly path: string; /** The record we wrote when we won it. */ readonly record: LiveLockRecord; /** * Re-READ the disk and answer whether WE still hold the lock. Never cached — the whole point is to * catch the case where a peer legitimately recovered us after a false death. Fail-closed: a * missing/corrupt/foreign file answers `false`, and it never throws (a driver calls this on the * hot path; a throw here would be an outage, and "I don't know" must read as "I don't hold it"). */ held(): boolean; /** Release the lock, idempotently. NEVER deletes a file whose token is not ours — a stale handle * must not evict the process that legitimately took over from it. */ release(): void; } const LIVE_LOCK_DIR = "live"; /** Resolve the base home the same way `cli/credentials.ts` does — `KESTREL_HOME` then `homedir()`. */ function resolveHome(home: string | undefined): string { return home ?? process.env.KESTREL_HOME ?? homedir(); } /** * The lock file for a scope: `$KESTREL_HOME/.kestrel/live/<sha256(venue,account)>.lock`. * * The basename is a HASH, not `ibkr-U1234567.lock`: a live account id must not sit in a path that * ends up in a shell history, a backup, or a stack trace. `JSON.stringify([venue, account])` is the * pre-image, which is unambiguous without needing the `\x00` delimiter trick used elsewhere in the * engine (and so keeps this file out of the grep-blind NUL set — see AGENTS.md). */ export function liveLockPath(scope: LiveLockScope, home?: string): string { const digest = createHash("sha256").update(JSON.stringify([scope.venue, scope.account])).digest("hex"); return join(resolveHome(home), ".kestrel", LIVE_LOCK_DIR, `${digest}.lock`); } /** Mint the holder token — a pure function of the holder's identity, so it is stable under replay. */ function tokenOf(host: string, pid: number, acquiredAt: number): string { return createHash("sha256").update(JSON.stringify([host, pid, acquiredAt])).digest("hex"); } /** The default liveness oracle. `kill(pid, 0)` sends no signal — it only probes existence. * `ESRCH` = no such process = DEAD (the only case we may recover). `EPERM` = exists, other user = * ALIVE. Any other error is unknown ⇒ ALIVE, because "unknown" must never authorize a takeover. */ function defaultIsAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (e) { const code = (e as NodeJS.ErrnoException).code; if (code === "ESRCH") return false; return true; // EPERM (alive, foreign owner) or anything we cannot interpret — fail closed. } } /** Parse + VALIDATE a lock record. Returns `undefined` for anything we do not positively recognize — * the caller turns that into an `unreadable` refusal. Validation is whitelist-shaped on purpose: an * unknown `version` is refused, so a future format is never half-interpreted by an old binary. */ function parseRecord(bytes: string): LiveLockRecord | undefined { try { const p = JSON.parse(bytes) as Partial<LiveLockRecord>; if (p?.version !== 1) return undefined; if (typeof p.host !== "string" || p.host.length === 0) return undefined; if (typeof p.pid !== "number" || !Number.isInteger(p.pid) || p.pid <= 0) return undefined; if (typeof p.token !== "string" || p.token.length === 0) return undefined; if (typeof p.acquired_at !== "number" || !Number.isFinite(p.acquired_at)) return undefined; if (typeof p.venue !== "string" || p.venue.length === 0) return undefined; return p as LiveLockRecord; } catch { return undefined; } } /** Read the record at `path`, distinguishing ABSENT (`null`) from PRESENT-BUT-UNREADABLE * (`undefined`) — the two lead to opposite outcomes (acquire vs refuse), so they must not collapse. */ function readRecord(path: string): LiveLockRecord | undefined | null { let bytes: string; try { bytes = readFileSync(path, "utf8"); } catch (e) { if ((e as NodeJS.ErrnoException).code === "ENOENT") return null; throw e; } return parseRecord(bytes); } /** Exclusively CREATE the lock file (`O_CREAT|O_EXCL`), 0600. Returns false iff it already exists. * `wx` is what makes the winner atomic — never "check then write", which two processes both pass. */ function createExclusive(path: string, record: LiveLockRecord): boolean { let fd: number; try { fd = openSync(path, "wx", 0o600); } catch (e) { if ((e as NodeJS.ErrnoException).code === "EEXIST") return false; throw e; } try { writeSync(fd, JSON.stringify(record, null, 2) + "\n"); } finally { closeSync(fd); } chmodSync(path, 0o600); // belt-and-braces against a permissive umask return true; } /** * Acquire the per-host live singleton for `scope`, or THROW {@link LiveLockRefused}. * * The ladder, fail-closed at every rung: * 1. exclusive create wins ⇒ held; * 2. it exists but is unreadable ⇒ `unreadable` (never interpreted, never deleted); * 3. it is from another host ⇒ `foreign-host` (we cannot prove a foreign pid's liveness); * 4. its pid is ALIVE (or not provably dead) ⇒ `held`; * 5. its pid is provably DEAD ⇒ recover, announced + recorded, then re-verify we won the race. * * There is no branch that returns without a lock. */ export function acquireLiveLock(scope: LiveLockScope, deps: LiveLockDeps = {}): LiveLockHandle { const path = liveLockPath(scope, deps.home); const host = deps.hostId ?? hostname(); const pid = deps.pid ?? process.pid; const now = deps.now ?? Date.now; const isAlive = deps.isAlive ?? defaultIsAlive; const notify = deps.notify ?? ((line: string) => void process.stderr.write(line + "\n")); const acquiredAt = now(); const mint = (recoveredFrom?: LiveLockRecord): LiveLockRecord => ({ version: 1, venue: scope.venue, account: redactAccount(scope.account), host, pid, token: tokenOf(host, pid, acquiredAt), acquired_at: acquiredAt, ...(recoveredFrom === undefined ? {} : { recovered_from: { pid: recoveredFrom.pid, acquired_at: recoveredFrom.acquired_at } }), }); try { mkdirSync(join(resolveHome(deps.home), ".kestrel", LIVE_LOCK_DIR), { recursive: true, mode: 0o700 }); } catch (e) { throw new LiveLockRefused("io", `cannot create the lock dir for ${path}: ${String(e)}`, { cause: e }); } // Rung 1 — the uncontended win. const fresh = mint(); try { if (createExclusive(path, fresh)) return handleFor(path, fresh); } catch (e) { throw new LiveLockRefused("io", `cannot write the lock file ${path}: ${String(e)}`, { cause: e }); } // Contended: someone's record is on disk. let incumbent: LiveLockRecord | undefined | null; try { incumbent = readRecord(path); } catch (e) { throw new LiveLockRefused("io", `cannot read the lock file ${path}: ${String(e)}`, { cause: e }); } // It vanished between our create and our read — the holder released. One retry, then refuse: we // never loop, because an unbounded retry against a flapping peer is a busy-wait, not a guard. if (incumbent === null) { try { if (createExclusive(path, fresh)) return handleFor(path, fresh); } catch (e) { throw new LiveLockRefused("io", `cannot write the lock file ${path}: ${String(e)}`, { cause: e }); } throw new LiveLockRefused("held", `the lock at ${path} was taken by a peer while acquiring it`); } // Rung 2 — present but not ours to interpret. Refuse; name the path so a human can clear it. if (incumbent === undefined) { throw new LiveLockRefused( "unreadable", `the lock file ${path} is not a readable v1 live-lock record — REFUSING to interpret or ` + `remove it. If no live session is running on this host, delete the file by hand and retry.`, ); } // Rung 3 — a foreign host's record. Our process table says nothing about their pid. if (incumbent.host !== host) { throw new LiveLockRefused( "foreign-host", `the lock at ${path} is held by pid ${incumbent.pid} on host ${incumbent.host} (this host is ` + `${host}) — a foreign pid's liveness is not provable from here, so this REFUSES rather than ` + `recovers. Cross-host exclusion is the control-plane lease (kestrel-7o2.2), not this lock.`, ); } // Rung 4 — the holder is alive (or we cannot prove otherwise). The ordinary refusal. if (isAlive(incumbent.pid)) { throw new LiveLockRefused( "held", `a live session (pid ${incumbent.pid} on ${incumbent.host}, acquired_at ${incumbent.acquired_at}) ` + `already holds ${incumbent.venue}/${incumbent.account} — a second live pump on one account ` + `would double every clip. REFUSING.`, ); } // Rung 5 — the holder is PROVABLY dead. Recover, loudly. const recovered = mint(incumbent); notify( `live-lock: RECOVERED a stale lock at ${path} — holder pid ${incumbent.pid} on ${incumbent.host} ` + `(acquired_at ${incumbent.acquired_at}) is gone from this host's process table; pid ${pid} is ` + `taking ${incumbent.venue}/${incumbent.account}. If that process is in fact trading, KILL IT NOW.`, ); try { unlinkSync(path); } catch (e) { if ((e as NodeJS.ErrnoException).code !== "ENOENT") { throw new LiveLockRefused("io", `cannot clear the stale lock ${path}: ${String(e)}`, { cause: e }); } // ENOENT: a peer recoverer got there first. Fall through — our exclusive create decides it. } try { if (!createExclusive(path, recovered)) { // A peer recoverer won the exclusive create. THEY hold it; we refuse. This is why recovery // uses unlink+O_EXCL rather than an overwriting rename: last-writer-wins would let BOTH // recoverers believe they hold the account. throw new LiveLockRefused( "held", `a peer recovered the stale lock at ${path} first — REFUSING rather than racing it.`, ); } } catch (e) { if (e instanceof LiveLockRefused) throw e; throw new LiveLockRefused("io", `cannot write the recovered lock ${path}: ${String(e)}`, { cause: e }); } return handleFor(path, recovered); } /** Build the handle. `held()` re-reads the disk every call — the check exists precisely for the case * where our cached belief is the thing that is wrong. */ function handleFor(path: string, record: LiveLockRecord): LiveLockHandle { const stillOurs = (): boolean => { try { const cur = readRecord(path); return cur !== null && cur !== undefined && cur.token === record.token; } catch { return false; // unreadable ⇒ we do not hold it. "I don't know" is never "I hold it". } }; return { path, record, held: stillOurs, release: (): void => { if (!stillOurs()) return; // idempotent, and never evicts a legitimate successor try { unlinkSync(path); } catch { // Best-effort: a release that cannot unlink leaves a lock whose pid is about to die, and the // next acquirer recovers it observably through rung 5. Never throw on the way out. } }, }; }