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.
383 lines (382 loc) • 21.3 kB
JavaScript
/**
* # session/controller — the transport-neutral incremental Session controller (kestrel-djm.4)
*
* The ONE programmatic, in-process interaction controller over {@link SessionCore}: it replaces the
* file-polling author handshake ({@link import("./day.ts").runDaySession} / `fileHandshakeAgent`) with a
* pure pull API — no filesystem, no directory, no transport — that a caller drives verb by verb:
*
* start → advance → revise → resume → finalize (+ submit — the explicit-binding wire path)
*
* It is the L1 (pure core: derive/admit/seal) + L2 (ergonomic surface) of djm.2's protocol v0.2 stack; L0
* (the {@link SessionMessage} union, the {@link TurnEntry} pentad, {@link reconcileTurn}) already landed in
* `src/protocol/session.ts`. Each response BINDS the transcript pentad `(sessionId, ordinal, parentHash,
* frameRoot, authoredSha256)`, and every admit/refuse routes the idempotency + fail-closed ladder through
* the SAME {@link reconcileTurn} primitive — never a bespoke check.
*
* ## NO-REIMPLEMENT — one truth (the non-negotiable)
* The controller does not fork a second Session progression. It WRAPS {@link runSimulateSession} exactly
* once via a **channel/coroutine {@link Agent}** whose `open`/`decide` suspend, hand the frozen Frame out to
* the caller, and resume with the caller's answer. The driver's own WakeFrontier (`popNext` / `armStaleness`
* / MIN_WAKE_SPACING / the staleness backstop) decides Wake eligibility, and the SAME `core.settle()` /
* `core.blotter()` finalizes. So a controller-driven agent-day is BYTE-IDENTICAL to the one-shot/file path
* over the same authored transcript (no golden churn): the coroutine emits the identical {@link AgentTurn}s
* that a scripted `Agent` would, at the identical wake ts, onto the identical graded Bus.
*
* ## DETERMINISM
* The genesis seals the pure-data spec, so `SessionId = sha256(genesis)` is a host-independent function of
* `(bus, config, wakes, fillModel, rUsd, …)` — a different config axis ⇒ a different Session. Nothing on the
* record path reads a wall clock or an unseeded RNG (the coroutine handoff is above the determinism line;
* the returned turn is the only value that crosses). Identical Bus + identical authored transcript ⇒
* byte-identical Blotter + graded-bus sha256, and `resume` IS re-derivation (nothing to restore).
*
* ## FAIL CLOSED — typed refusals, state untouched (djm.2 reuse)
* An ineligible advance (`sealed` after finalize, `not-pending` at settle), a cursor mismatch
* (`broken-chain`), or a changed-bytes-same-slot revision (`turn-conflict`) each REFUSE with a typed
* {@link SessionDiagnostic}; a byte-identical re-submit is idempotent (content-addressing IS idempotency).
* An explicit STAND_DOWN records a gradable `stand-down` body — FOREVER distinct from a host `failure`.
*
* ## 7dv.4 soft-sequencing note (bd note deferred to the orchestrator — hard rules forbid `.beads` writes)
* The PUBLIC surface here is PROTOCOL-DEFINED (start/advance/revise/resume/finalize + submit, over the
* SessionMessage/TurnEntry shapes of protocol v0.2), NOT today's internal driver shape. The INTERNAL wiring
* to {@link runSimulateSession}/{@link SessionCore} is an implementation detail the later 7dv.4
* session-unbundle may re-home WITHOUT changing this interface — the tests assert only the observable
* contract (byte-identity, pentad binding, typed refusals), never a driver internal.
*/
import { readBus } from "../bus/index.js";
import { sha256 } from "../crypto/sha256.js";
import { jsonHash } from "../canonical/json.js";
import { OPEN_ORDINAL, reconcileTurn, } from "../protocol/session.js";
import { busSha256Of } from "./sim.js";
import { runSimulateSession } from "./simulate.js";
/** Mint a live controller over `spec`: the caller authors each turn incrementally. */
export function openSession(spec) {
return new IncrementalSession(spec, undefined);
}
/**
* Re-derive a controller from a committed `transcript` (resume IS re-derivation). Construction is
* synchronous (the {@link SessionController.sessionId} is available at once); {@link SessionController.finalize}
* replays the transcript's authored turns through the SAME driver to reconstruct a byte-identical Blotter.
*/
export function resumeSession(spec, transcript) {
return new IncrementalSession(spec, transcript);
}
// ─────────────────────────────────────────────────────────────────────────────
// Deterministic identity + entry sealing (pure — no wall clock, no RNG)
// ─────────────────────────────────────────────────────────────────────────────
// The content address of a structured record — sha256 over the canonical JSON of a value (recursively
// sorted keys, `undefined` dropped). ONE owner in {@link ../canonical/json.ts} (kestrel-zs2f): `jsonHash`
// was `deepSort` + `sha256(JSON.stringify(...))` copy-pasted here, byte-identical to the Blotter/receipt/
// config canonicalizers, and hashes through the SAME portable {@link sha256} seam (native under bun /
// pure-JS in a Worker isolate, never `Bun` direct — the genesis stays host-independent, zs2f.1).
/** The events a spec runs on: its in-memory `events`, else the recorded bus at `busPath`. Read ONCE for the
* genesis; {@link runSimulateSession} reads its own copy at drive time (both deterministic). */
function eventsOf(spec) {
if (spec.events !== undefined)
return spec.events;
if (spec.busPath !== undefined)
return [...readBus(spec.busPath)];
throw new Error("session/controller: SessionSpec needs either `events` or `busPath`");
}
/** Seal the pure-data spec into a genesis and derive `SessionId = sha256(genesis)`. Deterministic and
* host-independent: the bus is folded as its canonical content hash, the config verbatim (so a different
* config axis ⇒ a different Session); the non-data knobs (`fairTauYears`, a function) are excluded. */
function sessionIdOf(spec) {
const genesis = {
bus: busSha256Of(eventsOf(spec)),
config: spec.config,
wakes: spec.wakes ?? [],
fillModel: spec.fillModel,
rUsd: spec.rUsd,
...(spec.authorCutoff !== undefined ? { authorCutoff: spec.authorCutoff } : {}),
...(spec.structural !== undefined ? { structural: spec.structural } : {}),
...(spec.instruments !== undefined ? { instruments: spec.instruments } : {}),
...(spec.makerFairParams !== undefined ? { makerFairParams: spec.makerFairParams } : {}),
};
return sha256(jsonHash(genesis));
}
/** The idempotency address of a turn body: the exact authored bytes for `authored`, else the body's own
* content (never empty — the {@link reconcileTurn} malformed guard rejects an empty authored address). */
function authoredSha256Of(body) {
switch (body.kind) {
case "authored":
return sha256(body.bytes);
case "stand-down":
return sha256(body.reason);
case "failure":
return sha256(body.failureClass);
}
}
/** Seal a committed {@link TurnEntry}: bind the pentad `(sessionId, ordinal, parentHash, frameRoot,
* authoredSha256)` + body, then content-address the whole entry as `entryHash` (the next turn's cursor).
* `entryHash` is deterministic in the entry's content, so a byte-identical turn re-seals to the same address
* (the substrate {@link reconcileTurn} idempotency rides on). */
function sealEntry(sessionId, ordinal, parentHash, frameRoot, body) {
const core = {
kind: "turn",
sessionId,
ordinal,
parentHash,
frameRoot,
authoredSha256: authoredSha256Of(body),
body,
};
return { ...core, entryHash: jsonHash(core) };
}
// ─────────────────────────────────────────────────────────────────────────────
// Response ⇄ turn/body mapping (the ergonomic L2 ↔ the driver seam ↔ the transcript record)
// ─────────────────────────────────────────────────────────────────────────────
/** Map an authored response to the driver's {@link AgentTurn} — the value that crosses the determinism
* line. IDENTICAL to what a scripted `Agent` would return (a supersede document / an empty pass / a
* standDown), which is precisely why a controller-driven day is byte-identical to the one-shot path. */
function responseToTurn(r) {
switch (r.kind) {
case "authored":
return { actions: [{ kind: "supersede", document: r.document }] };
case "pass":
return { actions: [] };
case "stand-down":
return { actions: [{ kind: "standDown", reason: r.reason }] };
}
}
/** Map an authored response to its recorded {@link TurnBody}. A `pass` is an empty authored body (the author
* produced no new document) — kept distinct from a `stand-down` (an explicit de-arm) so the record never
* conflates the two. */
function responseToBody(r) {
switch (r.kind) {
case "authored":
return { kind: "authored", bytes: r.document };
case "pass":
return { kind: "authored", bytes: "" };
case "stand-down":
return { kind: "stand-down", reason: r.reason };
}
}
/** Re-derive the authored response from a recorded {@link TurnBody} (the resume path). An empty authored
* body re-derives to a `pass` (faithful — an empty document was recorded for a no-op continuation), a
* `failure` body to a best-effort `pass` (a host fault has no re-authorable content). */
function bodyToResponse(body) {
switch (body.kind) {
case "authored":
return body.bytes === "" ? { kind: "pass" } : { kind: "authored", document: body.bytes };
case "stand-down":
return { kind: "stand-down", reason: body.reason };
case "failure":
return { kind: "pass" };
}
}
/** The disposition a response earns given whether the standing document was already armed. */
function dispositionOf(r, hasArmed) {
switch (r.kind) {
case "authored":
return hasArmed ? "revised" : "armed";
case "pass":
return "pass";
case "stand-down":
return "stood-down";
}
}
// ─────────────────────────────────────────────────────────────────────────────
class IncrementalSession {
spec;
replay;
sessionId;
phase = "idle";
cursor; // the prior-cursor for the next delivery (genesis, then each committed entryHash)
hasArmed = false;
committed = [];
// The coroutine rendezvous handles.
resolveEvent = null;
pending = null;
pendingRespond = null;
driverDone = null;
result = null;
finalized = null;
constructor(spec, replay) {
this.spec = spec;
this.replay = replay;
this.sessionId = sessionIdOf(spec);
this.cursor = this.sessionId; // the OPEN turn's prior cursor IS the genesis (= the SessionId)
}
// ── START ──────────────────────────────────────────────────────────────────
async start() {
if (this.phase !== "idle")
throw new Error("session/controller: start() may only be called once, first");
const first = this.nextEvent();
// Kick off the ONE wrapped progression. The coroutine agent suspends at agent.open synchronously within
// this call, resolving `first` with the OPEN Frame before runSimulateSession's promise even returns.
this.driverDone = runSimulateSession(this.buildOpts());
this.driverDone.then((result) => this.resolveEvent?.({ type: "done", result }), (error) => this.resolveEvent?.({ type: "error", error }));
const ev = await first;
if (ev.type === "error") {
this.phase = "sealed";
throw ev.error instanceof Error ? ev.error : new Error(String(ev.error));
}
if (ev.type === "done") {
// The driver settled without delivering an OPEN Frame — structurally impossible for the sim driver
// (it always calls agent.open first), but fail loud rather than return an undefined delivery.
this.phase = "settled";
this.result = ev.result;
throw new Error("session/controller: driver settled without delivering an OPEN Frame");
}
return this.receiveFrame(ev);
}
// ── ADVANCE / REVISE — commit the pending slot, deliver the next eligible Wake ─────────────────────────
advance(response) {
return this.commit(response);
}
revise(response) {
return this.commit(response);
}
async commit(response) {
if (this.phase === "sealed")
return { ok: false, diagnostic: "sealed" };
if (this.phase !== "pending" || this.pending === null || this.pendingRespond === null) {
return { ok: false, diagnostic: "not-pending" };
}
const slot = this.pending;
const disposition = dispositionOf(response, this.hasArmed);
const entry = sealEntry(this.sessionId, slot.ordinal, slot.parentHash, slot.frameRoot, responseToBody(response));
const receipt = { entry, disposition };
this.committed.push(receipt);
this.cursor = entry.entryHash;
if (response.kind === "authored")
this.hasArmed = true;
// Hand the mapped turn to the suspended driver and rendezvous on its next event (the next Wake Frame, or
// the terminal settle). Arm the next rendezvous BEFORE responding so the driver's continuation can't
// outrun us.
const respond = this.pendingRespond;
this.pending = null;
this.pendingRespond = null;
const nextEv = this.nextEvent();
respond(responseToTurn(response));
const ev = await nextEv;
let next;
if (ev.type === "error") {
this.phase = "sealed";
throw ev.error instanceof Error ? ev.error : new Error(String(ev.error));
}
if (ev.type === "done") {
this.phase = "settled";
this.result = ev.result;
next = null;
}
else {
next = this.receiveFrame(ev);
}
return { ok: true, receipt, next };
}
// ── SUBMIT — the explicit-binding, reconcile-against-the-record wire path ─────────────────────────────
async submit(response) {
if (this.phase === "sealed")
return { ok: false, diagnostic: "sealed" };
const incoming = sealEntry(response.sessionId, response.ordinal, response.parentHash, response.frameRoot, response.body);
// An already-committed slot ⇒ reconcile via the SAME primitive: an idempotent duplicate returns the
// settled receipt, a conflict/mismatch fails closed with the typed diagnostic (state untouched).
const prior = this.committed.find((c) => c.entry.ordinal === response.ordinal);
if (prior !== undefined) {
const res = reconcileTurn(prior.entry, incoming);
if (res.ok)
return { ok: true, receipt: prior, next: this.pending };
return { ok: false, diagnostic: res.diagnostic };
}
// Not yet committed: a fresh bound turn at the current pending slot must bind to the delivered Frame.
if (this.phase === "pending" && this.pending !== null && this.pending.ordinal === response.ordinal) {
if (response.sessionId !== this.sessionId)
return { ok: false, diagnostic: "wrong-session" };
if (response.parentHash !== this.pending.parentHash)
return { ok: false, diagnostic: "broken-chain" };
if (response.frameRoot !== this.pending.frameRoot)
return { ok: false, diagnostic: "stale-frame" };
return this.commit(bodyToResponse(response.body));
}
if (this.phase === "settled")
return { ok: false, diagnostic: "not-pending" };
return { ok: false, diagnostic: "broken-chain" };
}
// ── RESUME — re-derive the committed transcript (pure; nothing to restore) ────────────────────────────
resume(_after) {
return { sessionId: this.sessionId, entries: this.committed.map((c) => c.entry) };
}
// ── FINALIZE — drive to settle through the SAME path, seal the chain ──────────────────────────────────
async finalize() {
if (this.finalized !== null)
return this.finalized;
if (this.phase === "idle") {
await this.start();
if (this.replay !== undefined) {
// resume IS re-derivation: replay the committed transcript's authored turns through the SAME driver.
for (const entry of this.replay) {
if (this.phaseNow() !== "pending")
break;
await this.commit(bodyToResponse(entry.body));
}
}
}
// Drive any remaining eligible Wakes to settle (a legitimate pass keeps the standing Plans managing).
while (this.phaseNow() === "pending") {
const r = await this.commit({ kind: "pass" });
if (!r.ok)
break;
}
if (this.phaseNow() !== "settled" || this.result === null) {
throw new Error(`session/controller: cannot finalize (phase ${this.phaseNow()})`);
}
this.phase = "sealed";
const finalized = {
blotter: this.result.blotter,
captured: this.result.captured,
config: this.result.config,
sessionId: this.sessionId,
tipHash: this.cursor, // the tail of the pentad chain — the receipt root a CertifiedGrade can pin
};
this.finalized = finalized;
return finalized;
}
// ── Internals ────────────────────────────────────────────────────────────────
/** Read the current phase at its declared type — defeats TS's over-eager literal narrowing across the
* async `start()`/`commit()` calls, which mutate `this.phase` invisibly to the caller's flow analysis. */
phaseNow() {
return this.phase;
}
/** Arm the next rendezvous promise, storing its resolver for the coroutine agent (or the driver-done
* handler) to fire. */
nextEvent() {
return new Promise((resolve) => {
this.resolveEvent = resolve;
});
}
/** Turn a delivered Frame event into the pending {@link Delivery}, binding its prior cursor. */
receiveFrame(ev) {
this.pendingRespond = ev.respond;
const delivery = {
sessionId: this.sessionId,
ordinal: ev.ordinal,
parentHash: this.cursor, // genesis for OPEN, then the just-committed entryHash for each Wake
frameRoot: ev.frameRoot,
frame: ev.frame,
};
this.pending = delivery;
this.phase = "pending";
return delivery;
}
/** The channel/coroutine {@link Agent}: `open`/`decide` suspend, hand the frozen Frame out through the
* rendezvous, and resume with the caller's authored turn. The delivered Frame is content-addressed into
* `frameRoot` here (binding the pentad's Frame leg); rendering identity stays on the AgentConfig — content
* addressing stops at the determinism line. */
coroutineAgent() {
const deliver = (ordinal, frame) => new Promise((respond) => {
this.resolveEvent?.({ type: "frame", ordinal, frameRoot: jsonHash(frame), frame, respond });
});
return {
config: this.spec.config,
open: (briefing) => deliver(OPEN_ORDINAL, briefing),
decide: (frame) => deliver(frame.wakeOrdinal, frame),
};
}
/** Assemble the {@link RunSimulateOptions} for the wrapped driver: the spec's pure knobs + the coroutine
* agent. The `config` axis rides on `agent.config`, so it is stripped from the pass-through knobs. */
buildOpts() {
const { config: _config, ...rest } = this.spec;
return { ...rest, agent: this.coroutineAgent() };
}
}