manager-cockpit¶
Provenance¶
- Source:
.spec/spexcode/spec-cli/sessions/manager-cockpit/spec.md - Source SHA-256:
38930e68678f3259068e6ad0f83f050f0582715ca8e8542a97cd36d070236271
manager-cockpit¶
raw source¶
A manager — human or agent — shouldn't have to cd into a worktree and hand-run git to decide what to do
with a session, NOR to land it. The server does that work and hands back one ready-made answer. The
cockpit is the set of such verbs. review decides ("should I merge this session?") in a single payload;
merge is its sequel — it hands the work back to the session's OWN agent to land. Both are thin-called by
the dashboard and spex. capture / prompt / close / dispatch remain monitor + lifecycle actions on the
same surface.
expanded spec¶
reviewPayload(id) (in [[state]]'s sessions.ts) computes ONE bundle for a session, served at
GET /api/sessions/:id/review and printed by spex review <id> (--json for the raw payload). Unknown id
→ null → HTTP 404 / a non-zero CLI exit. The reads run in parallel, all against the source-of-truth base
branch (mainBranch(), auto-detected — never a hardcoded main). The payload carries:
- branchHead / baseHead — the exact immutable Git object ids whose branch/base generation every committed
review fact below describes. Review resolves both branch refs in one Git ref snapshot, then uses ONLY those
two object ids for ahead, merge-base diff, and conflict projection; a moving branch name,
HEAD, or canonical base name never rides a later review command. After assembling those facts it re-reads the pair and refuses the review if either ref moved, so no response combines facts from one pair with a later visible generation. A caller can therefore bind a decision to the pair and reject either work authored after review or canonical movement after review. Both ids ride the merge authority and prompt: the session agent re-proves its symbolic branch, worktree HEAD, stored branch ref, and canonical base ref before syncing, then freezes and merges the tested post-sync object. There is no second provenance model: the pair is exactly Git's own object identity at the review boundary. - ahead — commits the node branch is ahead of the base.
- dirtyNonRuntime — uncommitted files; SpexCode writes no runtime files into the worktree ([[runtime]]), so every dirty path is genuine spec/code work — the basis [[state]]'s commit gate uses.
- diff — the worker's REAL changes, anchored at the merge-base (
mergeBaseDiffin [[source-of-truth]]'sgit.ts): per-file status + added/deleted line counts. A two-dotbase..HEADdiff would show the base's post-fork commits as phantom edits, so the fork point is the only honest base. - gates —
conflictsWithMain(a dry-run merge computed in the object store viagit merge-tree --write-tree— no checkout, nothing to abort, the SAFE form of "would this conflict");lint(the [[spec-lint]] module's error / warning counts); andevals, the measured-loss READOUT. conflict/ahead/dirty are session-specific; the lint gate reflects the CLI package's own tree, where the command runs, so it is memoized on that tree's fingerprint (an unchanged tree skips the re-lint on repeated manager reviews / exports).
This location-wide verdict belongs to explicit manager review and the self-contained session export. The
interactive paged Eval list carries no manager gate strip and does not synchronously run this gate: a
whole-repository lint is not a property of one selected page. Within
the manager/export consumers, the memo only covers the case where nothing moved. When the fingerprint moves —
a trunk commit, one dirty edit — the verdict is
recomputed, and a second verdict IN THE SAME PROCESS costs what MOVED, because the anchor engine reuses the
hunks whose IMAGE IDENTITY it has already read under a pinned diff interpretation ([[code-anchor]] owns that
identity — ordered result/parent images, not a commit id, which refs/replace, a graft or an unshallow can
reinterpret) instead of re-probing every anchored window.
Measured here, and this is the whole claim, no wider: an empty-scope manager review and a ten-file-scope review each
pay the same 48 git children on a COLD process — 22 of them one log --patch per anchored path — and that
first touch is UNCHANGED. What the reuse removes is paying it AGAIN on every later fingerprint move: 38
children with 22 such queries, argv byte-identical to the previous run, down to 15 with none; a commit that
moves ONE anchored path, 42 with 22 down to 21 with one. So a warm backend's manager re-verdicts no longer cost
the corpus. The cold gate remains real when a caller asks for it; the paged Eval list no longer asks.
The reusable hunk facts are durable across processes in [[source-of-truth]]'s existing on-disk event ledger,
keyed by the SAME ordered image identity and pinned range-semantics schema [[code-anchor]] uses in process.
A fresh backend therefore replays known facts after a restart; an image the ledger has never seen still pays
one Git derivation and joins the gate's existing build-local ledger snapshot, lock, and one writer rather
than opening a second ledger transaction. This is not a second cache of
the lint verdict: a moved fingerprint still recomputes the verdict from its current tree, and no selector
outcome, window, or reachability judgment is persisted. A trunk commit that replaces the backend child is
consequently a first gate only for its newly introduced images, not a corpus-wide first touch. Proportionality is
bought the one way described and no other: the gate keeps NO second cache of its verdict and narrows
nothing — the counts are exactly what spex spec lint reports for that tree with its dirty files included, a
moved fingerprint always recomputes instead of serving last-known, and a rejected run is never cached.
There is deliberately
NO build/typecheck/test gate here: whether a change is SOUND is proven by the node's eval scenarios, measured
through the real product ([[session-eval]] shows that evidence) — not by a language-specific automated
checker baked into the cockpit. So the gates stay language-agnostic (git + the spec↔code graph), correct
for any governed project, TS or Python or otherwise, rather than a tsc that only ever spoke TypeScript.
The evals entry is that same principle turned outward: since soundness is proven by MEASUREMENT, the
cockpit hands the manager the measurement beside the git facts — [[session-eval]]'s four mutually exclusive
scenario categories, {freshPass, freshFail, needReview, blind}. It REPORTS and grades nothing: no
threshold, no ok/not-ok, no block, and no unknown-coverage or measured/total aggregate riding along (that
decomposition belongs to the toolbar that already renders it). It reads the session-eval projection that
ALREADY exists — a cache read, never a build, because buildSessionEvals calls this very payload and a
build here would recurse — so the readout costs the review nothing. Its phase is part of the fact: only
ready carries numbers; an absent, loading, updating, or failed projection reports that phase and carries
NO numbers, because "nothing measured" and "not measured yet" are different facts and four zeros would
read as the clean one. Last-known is never dressed up as current, and this readout adds no row to the
session gates strip.
- proposal — the session's standing proposal kind + note, read from its global record.
mergeSession(id) is the ACT verb, served at POST /api/sessions/:id/merge and run by spex session merge <id> —
but it is a DISPATCH, not a server merge: the SESSION'S OWN agent lands the work, the server NEVER touches
main's tree (it carries no git merge logic). It reopens the session (--resumes via [[state]]'s reopen
when tmux died, which waits for the rendezvous socket so the dispatch hits a live agent), then sends
mergePrompt through the socket. That prompt is the human's merge INTENT and the one place the merge STYLE
lives: a --no-ff commit merge <branch>: <reason> from the main checkout (reason = the branch's latest
commit subject minus a leading spec:), with the agent told to resolve conflicts, VERIFY the base's HEAD
advanced with no half-merge, then propose CLOSE (not merge — the commit gate exempts propose-close) for the
human. Async + fail-loud: {dispatched:true} once the prompt is appended, else {dispatched:false, reason}
(HTTP 409 / non-zero) only when the record rejects it. Landing is thus the
agent's verified act, never a server merge. The dispatch boundary nevertheless admits only a governed session
currently declaring awaiting + proposal=merge, with an Idempotency-Key, the exact branchHead /
baseHead pair, and the durable reviewEpoch returned by review. Every explicit renewed merge declaration
advances that epoch even if the pair did not move. It validates the record declaration epoch and both Git refs before reopening or appending
anything. Acceptance is one existing durable timeline receipt carrying the exact owed transport form, reconciled
with the existing delivery queue under its lock; only after that recoverable receipt exists does
the server ensure the original agent is live and drain the debt. Native CLI and dashboard clients derive their
retry key from the exact session route, reviewed head pair, and review epoch instead of volatile client state, so the
same decision replays the same acceptance even after either client restarts. A renewed declaration therefore creates
a fresh authorized request even when its heads match a settled earlier merge dispatch. Key ownership is deliberately
route-local: the same raw key may authorize an independent decision on another session, while the same route/key
with another pair or epoch is a loud 409. Before handover, same-route/same-key/same-pair-and-epoch retries reconstruct any queue debt lost after the receipt
write and resume delivery; after the adapter accepts it, a private settlement in that same timeline makes every later
replay response-only, with no resume, unarchive, lifecycle, or queue mutation. The raw key is never persisted. Thus
review SHOWS a stable declaration generation and Git decision, merge binds exactly that decision, and the agent still ENFORCES the tested landing
itself.
Two read verbs round out the manager surface, both backend-computed so a client (incl. a REMOTE one over
SPEXCODE_API_URL) can monitor an agent without the binary terminal socket: capture
(captureSessionResult, GET …/capture) returns the live pane as text, keeping "couldn't read" distinct
from "blank pane" — empty pane → 200, unknown id → 404, offline → 409, capture error → 502; prompt
(GET …/prompt) returns a session's originating ask (404 if none). Paths resolve from the CLI package's OWN
location, never a hardcoded layout, so the cockpit works wherever the package lives. Every cockpit verb only
READS or DISPATCHES — none mutates main directly. The cockpit's stake in the shared cli.ts/index.ts hubs is just the thin
review/merge/capture/prompt routes; the eval reframe's churn there — its rewritten verb line and
its eval-blob comment — is that feature's, not the cockpit's drift.
where the answer is assembled¶
The cockpit's review is composed in cockpit.ts, a module that sits ABOVE both the session layer and the eval
layer and may import either. That is the point: a value made of both halves has no honest home inside either
one. sessions.ts cannot hold it, because the eval package imports sessions.ts — reaching back from there is
a cycle, and it used to be paid for with a deferred dynamic import whose own comment explained why it had to be
wrong. reviews.ts cannot hold it either: that file is [[paged-review]]'s Issues/Evals paging server, and
parking a session-cockpit concern beneath a node about paging would make that node's body false. It imports the
eval package for its own reasons — the same direction, a different reason, and "already imports it" is not a
claim to ownership.
The eval readout therefore has exactly one producer, and every entry point calls it. The cockpit review is reachable two ways: the HTTP route, and the client's local answer when no backend resolves and none was named. If each composed its own gates, the same verb would return different SHAPES depending on whether a backend happened to be running — reintroducing precisely the asymmetry the remote-client role split removed — and a later drift between the two readouts would be caught by no gate that exists. One composition is what makes that failure unavailable rather than merely unlikely.
The session-side payload consequently returns the session gates only. It never needed to carry the eval readout: the eval package's own consumer reads lint, conflict, ahead and dirty, and never that field. Removing it makes the recursion the old comment guarded against structurally impossible — the eval model builder calls the session payload, and there is no longer an eval-shaped field for that call to re-enter through.