Skip to content

care-loopd: SQLite as source of truth, HTTP service, React dashboard - #7

Open
Jacobjeevan wants to merge 31 commits into
care-loopfrom
loop-service
Open

care-loopd: SQLite as source of truth, HTTP service, React dashboard#7
Jacobjeevan wants to merge 31 commits into
care-loopfrom
loop-service

Conversation

@Jacobjeevan

Copy link
Copy Markdown
Contributor

Stacked on #4. Base is care-loop, so this diff is only the 14 commits after 68d472a.

Three layers, committed separately so they can be read in order.

DB — SQLite becomes the source of truth

The journal was authoritative; it is now a continuously-verified replica. Journal.read() queries the store, and append() writes the DB before the jsonl line, so a crash between the two leaves the DB correct and the replica merely lagging — never the reverse.

prev is the one field that stays replica-sourced: it is the file's own integrity checksum, not an ordering fact. Sourcing it from the DB broke every reindexed legacy run, because those lines carry a pre-ULID run_id while reindex backfills a ULID, so re-serializing the row reproduced a line the file never held.

Runs get a ULID primary key, minted once and cached in the run dir, with one deterministic backfill so legacy journals self-heal identically on live resume and on reindex. Parity between replica and DB is checked at run.end and run.resume with deliberately different policies — resume throws, run.end records — because at run.end both writes have already committed and refusing to finish cannot undo what it found.

Later commits add skill artifact bodies as jsonb (schema v3), sessions and users (v4), and the queue (v5).

Two bugs found while verifying against the real 6-run fleet:

  • started_at was seeded before the fold and then patched by the very event that seeds it. Invisible to both the parity and reindex checks, since both replay the same pipeline — it only surfaced from an independent fold of the journal.
  • requested_by was hardcoded null at all four seed sites.

BE — care-loopd serve

Express over loops.db, DB-only: no route touches a run directory. Ten routes built, the rest specified with the step they land in. Conventions frozen before the frontend existed — envelopes on lists, {error:{code,message}} with real statuses, and no route making an authorization decision.

Auth is a cookie session started by claiming a GitHub login. Nothing verifies it — the trust boundary is the network. What it buys is the shape real auth needs: adding OAuth replaces one handler's body. The DB stores a hash of the token, never the token, and logout revokes rather than deletes.

The queue mints run_id at enqueue so POST /api/runs can answer synchronously, validates with the loop's own field rules rather than a second copy of them, and queues behind a busy branch rather than rejecting — skipping it, so one busy branch cannot stall the queue head.

Backups land with the queue because that is the first data reindex cannot rebuild. VACUUM INTO, not a file copy, which can catch a torn page or miss the WAL.

FE — React dashboard

Vite + React 19 + TanStack Router/Query, at read parity with the vanilla dashboard, which this deletes along with 1,200 lines of hand-rolled HTML.

Themed with Care UI tokens. Its shadcn registry could not be used — every /r/<name>.json endpoint returns the docs SPA's HTML, so shadcn add fails on Unexpected token '<' (verified against the real CLI). The tokens were read off the live docs site instead; ui/primitives.tsx is written against the same token names, so the real components can replace that one file when the registry is fixed.

Filter state lives in the URL, so a filtered view is a shareable link. Filters are driven by the server's facet counts, so a dropdown never offers a value returning nothing.

Verification

376/376 tests green, tsc clean in both packages. Exercised live against a copy of the real fleet throughout — 7 runs, 1,351 events, 188 artifacts — including a full rm loops.db && reindex round trip to confirm it stays lossless.

Two bugs were caught only by driving the real app in a browser: unclassed <a> elements took the browser's default dark blue, near-invisible on the dark panel; and the SPA fallback answered 200-with-HTML for missing assets, which makes the browser execute a document as JavaScript.

Notes for review

  • PLAN-sqlite-run-store.md and PLAN-loop-service.md are force-added past care-loop/.gitignore's PLAN-*, following the precedent of PLAN-orchestrator-architecture.md. They are 1,476 of the added lines; skip them for code review.
  • Step 4 (supervisor: claim, spawn, cancel, reconcile) is next and not in this branch. Nothing spawns a child yet — the queue accepts requests and holds them.

🤖 Generated with Claude Code

Jacobjeevan and others added 30 commits August 20, 2026 01:57
The journal was the source of truth; it is now a continuously-verified
replica. `Journal.read()` queries the active RunStore, and `append()`
writes the DB BEFORE the jsonl line, so a crash between the two leaves
the DB correct and the replica merely lagging — never the reverse. Both
writes are fatal.

`prev` is the one field that stays replica-sourced. It is the file's own
integrity checksum, a property of the bytes on disk rather than an
ordering fact, and taking it from the DB broke every reindexed legacy
run: those lines carry a pre-ULID run_id while reindex backfills a ULID,
so re-serializing the DB row reproduced a line the file never held. `seq`
and `deltaMs` are ordering facts and are DB-owned.

Runs get a ULID primary key, minted once at run.start and cached in the
run dir. Legacy journals self-heal through one deterministic backfill in
validateState, so live resume and `reindex` land on the same id with no
special-casing — replacing four drifting `${repo}-${branch}` derive
sites, one of which (adopt) used a different formula than the rest.

Parity between replica and DB is checked at run.end and run.resume, with
deliberately different policies: resume throws, because that is the
moment the DB is trusted to reconstruct a run; run.end records to
`runs.parity_error`, because the work is already committed and refusing
to finish cannot undo the divergence. An unreadable replica warns at
both — it is a degraded backup, not a corrupt truth.

Two bugs found while verifying against the real 6-run fleet:

- `started_at` was seeded before the fold and then patched by the very
  event that seeds it — run.start carries a CareState built ~1ms before
  append() stamps the event ts. Re-asserted after the fold. This was
  invisible to both parity and reindex checks, which replay the same
  pipeline; it only surfaced from an independent fold of the journal.
- `requested_by` was hardcoded null at all four seed sites. Now one
  resolver reading CARE_REQUESTED_BY, with --requested-by as CLI sugar.
  Attribution only, never authorization.

Adds `care-loopd reindex` to rebuild loops.db from the journals, and
points the dashboard's fleet list at the index when a db is present.

Tracks the two design records this work is built against, force-added
past care-loop/.gitignore's blanket `PLAN-*`: PLAN-sqlite-run-store.md
(the plan of record, six passes) and PLAN-loop-service.md (the BE/FE
service this cutover unblocks). The other PLAN-* docs stay ignored.

Verified: 307/307 tests green, tsc clean, and reindex over the real
fleet (7 runs, 1351 events) is deterministic across repeated runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two prerequisites for the loop-service bring-up, plus a latent bug the
second one surfaced.

`CARE_RUN_ID` lets a caller supply the run id instead of having one
minted in the child. The service needs this because `POST /api/runs`
must answer `{ run_id }` synchronously, while the child starts long
afterwards — possibly never, if the row sits pending or the spawn
fails. Precedence is "established beats pinned": the pin may name a run
dir that has no id yet, and throws rather than rebinding one that does.
Silently preferring either side would be worse than failing — taking the
pin hijacks a live run's identity, taking the existing id makes the
service's POST response a lie. A supervisor restart re-spawning the same
row with the same id is therefore idempotent.

The dashboard's detail route now keys on run_id, falling back to the
directory slug for the no-db path and existing bookmarks. The two are
unambiguous by shape, so this dispatches on the value rather than
guessing. `slug` was never a key: it has no unique constraint and is
derived from repo+branch, so a reused branch collides on it
deterministically. RunSummary carries runId for the same reason — the
frontend cannot build a detail link without it.

Fixes a latent bug found while wiring that route: RunIndex.get builds
`new Journal(path, slug)` and then read() it, but read() is DB-backed
and queries by run_id, so it matched no rows and returned an EMPTY
timeline with no error. A detail page that silently renders nothing.
Switched to readReplica(), which is what a slug can address. Currently
unreached — the dashboard uses its own scan path — but it is the exact
call the service's detail route was going to make.

Branch admission control deliberately does NOT go in the loop. The loop
already owns the invariant that matters: run dir is derived from
repo+branch, so a second run on the same branch IS the first one, and
withLock(runDir) refuses it. Whether to queue behind or reject is
scheduling policy, and belongs to the thing that owns the queue.
Building it in both would mean two implementations of one policy, which
is how run_id drifted into three formulas. PLAN-loop-service.md §12
records the split, and the one seam the service will need from the loop
(a non-destructive inspectLock, since a `running` queue row is a lie
after a crash).

Verified: 316/316 green, tsc clean. Both new guards were confirmed to
fail without their fix — the slug/run_id one by asserting a populated
timeline, which returns empty and error-free under the old read().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`care-loopd serve` exposes the fleet over HTTP for the coming React
frontend. Three layers with exactly one thing below each: the service
reads the database and nothing else, and the frontend will talk only to
the service.

Making that true meant rewriting `run-index.ts`. Its `get` took a
directory slug and read journal.jsonl; it now takes a run_id and reads
`run_events`, which mirrors the journal completely, so the filesystem
has nothing to add. The port gained `count`, `events` (seq-cursor
pagination), and composable list filters. Reading files put the
filesystem back under an API with no other reason to know it exists —
and slug was never a key, having no unique constraint.

Routes built: /api/health, /api/me, /api/runs, /api/runs/:id,
/api/runs/:id/events. The full surface through step 5 is now specified
in PLAN-loop-service.md §6, with the conventions frozen before the FE is
written: envelopes on lists, `{error:{code,message}}` with real statuses,
ISO timestamps, and no route making an authorization decision.

Deliberate choices worth naming:

- Query values are parsed and validated at the edge (query.ts), not
  coerced inline. `?active=false` is a non-empty string and therefore
  truthy — that class of silent wrongness is a 400 here instead.
- A malformed run id is 400, an unknown one 404. The frontend needs to
  tell a broken link from a deleted run.
- `/events` checks the run exists first, so an empty timeline reads as
  an empty timeline rather than a missing run — that is the normal state
  of a run that just started.
- `/health` actually touches the db. "The process is up" is not the
  question anyone asks health for.
- `serve` opens the db readOnly and binds loopback. The child that owns
  a run is the only writer, and there is no authentication.

One thing DB-only cannot serve: skill artifact bodies live in sidecar
files, with only bounded fields and a {path,sha256} ref in the journal.
So the API can report that the reviewer returned 3 findings, not what it
wrote. §6 records the three options; recommendation is to ship v1
without bodies and add a `run_artifacts` table if the FE wants
drill-down, rather than putting the filesystem back under the service.

The vanilla dashboard.html reads a different shape, so dashboard.ts now
adapts index rows back into it — the old page keeps working until step 2
deletes it, and that adapter goes with it.

Verified: 329/329 green, tsc clean, and smoke-tested against a copy of
the real fleet db (7 runs, 1351 events) — list, detail, event
pagination, and both error shapes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The service reads the database and nothing else, but skill OUTPUT was
not in it: the journal spine deliberately carries only bounded fields
plus a {path,sha256} ref, with the envelope in a sidecar file. So the
API could report that the reviewer returned 3 findings without being
able to show what it wrote. Fixed by storing the bodies, not by letting
the service read files.

`run_artifacts` holds content as jsonb in a BLOB column. Every artifact
is a serialized JSON value by construction now — `SkillLogger.artifact`
takes a value and does the serializing, so "valid JSON" is structural
rather than a convention each caller honours, and the sidecar text, the
hash, and the db encoding all derive from one serialization. Measured on
the real fleet: 643 KB of text becomes 568 KB of jsonb, ~12% smaller,
and json_extract runs on it with no reparse.

Note jsonb is a function and an encoding, NOT a column type. Declaring a
column JSONB is accepted but matches no affinity rule — it does not
contain "BLOB" — so it lands on NUMERIC affinity and coerces
numeric-looking strings. BLOB is the correct declaration.

PK is (run_id, path), not (run_id, sha256): path is unique within a run,
content is not, and an unchanged input recurring across two rounds would
otherwise collapse two artifacts into one row.

I had argued against re-encoding on the grounds that it breaks the
content address. That was overstated: nothing in the codebase re-hashes
an artifact and compares. The only verified hashes are journal.ts's prev
chain and run-id's backfill seed, and the doctor opens sidecars by path.
sha256 is a handle, not a digest anyone checks — which is exactly what
makes it the right key for the API to serve bodies by, since the journal
ref already carries it.

The sidecar files stay, deliberately. care-loop-doctor reads
skills/*.json by path off the run dir, and `reindex` rebuilds
run_artifacts from them — which keeps artifacts out of the
queue/gate_asks category of data no rebuild can restore. Verified
against a copy of the real fleet: 7 runs, 188 artifacts, rebuilt from
disk alone with bodies intact.

Routes: GET /api/runs/:id/artifacts (metadata only — a timeline wants
links, not 160 KB of envelopes) and GET /api/runs/:id/artifacts/:sha
(one body, content parsed so the client gets a real JSON value rather
than a string containing JSON).

Also parameterizes two migration assertions that hardcoded
user_version 2, so the next schema bump does not break them.

Recorded but NOT built, per discussion: soft deletes. No call site
exists yet. When it lands, clearAll() and the CASCADE chain must stay
hard — they are reindex's truncate-before-rebuild, and soft-deleting
there would turn a rebuild into an append. The place it would help today
is `stale`, currently derived by string-matching the directory name.

Verified: 338/338 green, tsc clean, and smoke-tested over HTTP against
the reindexed real fleet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The artifact-bodies decision kept the sidecar files for two reasons, and
they have different lifetimes. The doctor reading skills/*.json by path
goes away when the doctor loop is reworked — run_artifacts already holds
every body as queryable jsonb, so nothing needs adding to the schema for
it. Reindex rebuilding artifacts from disk does NOT go away: it is what
keeps "rm loops.db && reindex is lossless" true.

Recorded so a later change proposing to drop the files argues with the
right one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Permanent" overstated the disaster-recovery argument. It narrows at
step 3, when queue/gate_asks arrive with no journal behind them and
backups become the real answer to a lost db — at which point artifact
rebuildability is cheap insurance rather than a necessity.

The durable reason is schema evolution: reindex is how existing runs
acquire data a new schema version projects. run_artifacts is the worked
example — v3 gave every existing db the table and zero rows, and the 188
artifacts exist only because reindex read them off disk. A future column
derived from artifact content can only be backfilled if that content is
on disk independent of the db being rebuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Withdraws the schema-evolution argument for keeping artifact sidecars.
It was one-time and is now spent: the content is in the database, so a
later version deriving something from artifact content reads
run_artifacts.content directly. Disk is only needed to backfill what the
db does not already hold.

Both remaining reasons expire — the doctor reading them by path at the
doctor rework, disaster recovery at step 3 backups. The sidecars are a
transitional artifact; nothing should be built assuming they persist.

Separates the journal.jsonl replica out, which had been getting lumped
in with them. It is the independent witness the parity check compares
the db against, so retiring it is a question about assurance rather than
about storage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found while checking whether anything blocked step 2 — and it did. The
list response echoed the REQUESTED paging, not the effective paging:

  no limit  -> reported null, applied 50
  limit=999 -> reported 999, applied 200
  limit=0   -> reported 0,   applied 1

The second is the damaging one. A frontend doing `offset += limit` on
the reported value would skip 799 rows per page and never see an error —
exactly the kind of thing that gets built on before it gets noticed, and
this contract is about to have a frontend written against it.

`resolvePaging` is now the single resolver, used by both the query and
the envelope, so the two cannot disagree. `?limit=0` is a 400 rather
than being silently clamped up to 1: asking for zero rows is a caller
bug, and answering with one row is stranger than an error.

Verified: 339/339 green, tsc clean, all three re-checked live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Freezes the API before the frontend is written against it. Ten routes
built, seven specified with the step they land in.

Auth is the new piece. POST /auth/login takes a GitHub login and starts
a cookie session; /auth/logout revokes it; /auth/me answers who the
caller is. Nothing verifies the login — the boundary is still the
network — so what this buys is the SHAPE real auth needs: adding GitHub
OAuth replaces one handler's body and leaves /auth/me, /auth/logout, the
middleware, and every other route untouched. Cookies rather than bearer
tokens precisely because OAuth's redirect lands on a cookie anyway.

Two habits taken now because they are cheap now and awkward later: the
db stores a hash of the session token, never the token, so a leaked
database is not a set of live logins; and logout revokes rather than
deletes, consistent with the standing soft-delete preference and keeping
"who was signed in when".

X-Care-User still works for curl and the CLI, which have no cookie jar,
with the session winning when both are present. That header is exactly
what to delete when real auth lands — a trusted header beside a verified
session is a bypass — so it stays confined to the one middleware.

Deliberate: /auth/me answers 200-with-null rather than 401, so the FE
makes one unconditional call and branches on the answer instead of
treating an error as a state. An unresolvable cookie falls through to
the header rather than 401ing — the middleware identifies, it does not
gate. And ?requested_by=me 400s when nobody is signed in rather than
401ing, because it is a filter that cannot be expanded, not a permission
being refused. No route makes an authorization decision.

List filters now cover requested_by (with `me`), repo, branch, step,
ticket, pr, free-text q, since/until, active, stale, order, dir, limit,
offset — joined through run_detail so a filter on task or ticket means
the same thing in the page and in its total. /runs/facets answers the
same filters with what is left, so narrowing to one repo offers only
that repo's branches.

Two things worth not re-learning: `order` is whitelisted rather than
interpolated, being the one parameter that would otherwise reach SQL as
syntax; and /runs/facets is declared before /runs/:id, since Express
matches in order and "facets" would otherwise be rejected as a malformed
run id.

Schema v4 adds users and sessions. The service connection is now
read-write, which step 1 had as read-only — correct then, since the API
only read. It does not weaken "one writer per run": that is scoped to
the run tables, and these are service-owned.

Verified: 352/352 green, tsc clean, and exercised live against the
reindexed real fleet including injection-shaped order values.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Vite + React 19 + TanStack Router/Query at care-loop/web, at read parity
with the vanilla dashboard: fleet table (Run · Step · Pipeline · Round ·
Tier · Age · Cost · Duration), run detail with metadata, and the full
event timeline with lazily-fetched skill artifacts.

Care UI (careui.ohc.network) is the design system. Its shadcn registry
could NOT be used: every /r/<name>.json endpoint returns the docs SPA's
HTML, so `shadcn add` fails on `Unexpected token '<'` — verified against
the real CLI, not inferred. What was adopted instead is the substance,
the tokens, read off the live docs site's computed styles: the full
semantic set for both themes, --radius, the chart ramp, Figtree and
Geist Mono. Care UI's --primary is emerald, not blue, which my
hand-rolled palette had wrong on the most visible thing.

ui/primitives.tsx holds the few primitives needed, written against
exactly the token names Care UI's components consume — so when the
registry is fixed, the real components replace that one file and nothing
else changes. Dark mode is a `.dark` class rather than a media query,
because Care UI ships five modes and a media query expresses two.

Filter state lives in the URL, so a filtered view is a link and browser
back behaves. Filters are driven by the server's facet counts, so a
dropdown never offers a value returning nothing. Query keys mirror URLs,
making back/forward instant.

`serve` now also serves web/dist when built, putting the API and the app
on ONE origin — which is what lets the session cookie be plain
same-origin with no CORS anywhere, in dev (Vite proxy) and prod alike.

Two bugs found by driving the real thing in a browser rather than
assuming: unclassed <a> elements took the browser's default dark blue,
near-invisible on the dark panel; and the SPA fallback answered
200-with-HTML for a missing asset, which makes the browser execute a
document as JavaScript and report a MIME error that says nothing about
the real cause — a stale asset reference after a redeploy. Paths with a
file extension are now excluded from the fallback, with a regression
test.

Verified against the reindexed real fleet: signed in, listed 7 runs,
opened one, confirmed all 327 events and 61 step dividers rendered, and
expanded an artifact to see its body arrive from the db.

Verified: 353/353 green, tsc clean in both packages, web builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Superseded by care-loop/web, which reached read parity in step 2. Removes
dashboard.ts, dashboard.html, the `care-loopd dashboard` command, and the
npm script. `care-loopd serve` replaces it.

Three things go with it, all deliberately:

- the second API response shape. dashboard.html read `{name, state}`
  while the service returns flat rows, so dashboard.ts carried an
  adapter between them. One endpoint serving two shapes was always a
  transitional state.
- the no-loops.db full-journal scan. It existed for a runs/ tree that
  predated the projection; `care-loopd reindex` rebuilds the db from the
  journals in under a second, so the fallback was covering a case that
  no longer costs anything to fix properly.
- 1200 lines of hand-rolled server-rendered HTML and vanilla JS.

Also corrects two now-stale lines of usage text: `serve` still described
itself as read-only, which stopped being true when sessions landed, and
`reindex` referred to the dashboard as a reader.

Verified: 353/353 green, tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Its only caller was the vanilla dashboard's detail route, which mapped a
run_id back to a directory so it could read that run's journal off disk.
Nothing does that any more: the service is DB-only and reads run_events,
and the doctor opens sidecars by path without going through this port.

Removed rather than kept for a future caller. It is one query, trivially
re-addable if the doctor rework wants run_id → directory — and a read
port that still exposes on-disk location invites exactly the filesystem
coupling the DB-only rule exists to prevent.

`slug` stays on RunSummary as a display label, which is all it ever was:
no unique constraint, and a reused branch collides on it
deterministically.

Verified: 352/352 green, tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds `queue`, the service-owned request table. Two shape decisions are
load-bearing and easy to get wrong later:

`run_id` is minted at ENQUEUE and is deliberately NOT a foreign key to
runs(run_id). The queue row exists before any run row does — POST
/api/runs must answer { run_id } synchronously while the child starts
long afterwards, or never, if the row is cancelled or the spawn fails —
so a FK would reject every insert.

That absence is also what protects the queue from reindex. `clearAll()`
is `DELETE FROM runs`, and with no FK there is no cascade reaching the
queue. This matters asymmetrically: a runs row is rebuildable from its
journal, a queue row is not. The scoping is now stated in clearAll's
own comment rather than left as a property of the schema.

Indexed for the two reads the supervisor makes: pending-oldest-first,
and "is this repo+branch already live".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`POST /api/runs` inserts a pending row and returns the run id it minted;
`GET /api/queue` and `GET /api/stats` show what the queue is doing; the
run detail route now carries its queue row.

Enqueue validates with the LOOP's own rules — front-terminal.ts's
`validateSeed`, not a second copy of them. A ticket that would fail the
[ENG-###] PR-title assert, or a branch `git worktree add` would reject,
fails while a human is looking at the form rather than hours later
inside a spawned child. `repo` is an allowlist, since it reaches both
`git worktree add` and a GitHub API call.

The claim is the risky part and is written accordingly: BEGIN IMMEDIATE
takes the write lock up front so two supervisors cannot both read the
same pending row, and a conditional UPDATE with a changes() check is the
belt to that braces. Getting it wrong means two orchestrators writing
one journal.

Queue-behind, not reject. A pending row whose repo+branch already has a
RUNNING row is SKIPPED rather than failed — and skipped rather than
blocking the head of the queue, so one busy branch cannot stall
everything. It becomes claimable the moment the first finishes. The
caller is told which run they are behind, because a request that will
not start immediately should say so.

Backups land here rather than earlier because this is the first data a
reindex cannot rebuild: a pending request has no journal behind it.
VACUUM INTO rather than copying the file — it snapshots a live database
through SQLite, safe with WAL and open connections, where `cp` can catch
a torn page or miss the WAL entirely. Pruning orders by the ISO stamp in
the name, not mtime, which a restore would rewrite. PRAGMA
integrity_check runs before the first request and REPORTS rather than
refusing to boot: corruption found weeks later, after backups have
rotated past the last good snapshot, is what this prevents.

Verified: 376/376 green, tsc clean, and exercised live against the real
fleet — enqueued, saw queue-behind reported, watched a bad ticket
rejected with the loop's own message, and confirmed the boot snapshot on
disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It was defined three times — run-index.ts, web/format.ts, and implicitly
by STEP_VOCAB — which is the same drift that put run_id in three
formulas. state.ts now owns it, with `satisfies readonly Step[]` so a
terminal step that is not in STEP_VOCAB fails to compile rather than
silently becoming a string that matches nothing. Verified by making it
fail.

RunSummary carries `terminal`, so the frontend renders live-vs-finished
from the server's answer instead of keeping its own copy of a vocabulary
that belongs to the orchestrator. The frontend's PIPELINE list stays,
because that is a presentation choice — STEP_VOCAB also holds sub-states
that would make ten pips into seventeen without saying more.

pipelineIndex now returns null rather than -1 for a step it does not
know. A step the frontend predates used to render as a strip of
all-future pips, indistinguishable from a run that had not started — a
wrong answer told confidently. It shows the step name instead.

Verified: 377/377 green, tsc clean in both packages, and the flag
confirmed over the wire against the real fleet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reviewing what shipped against PLAN-loop-service found one genuine bug,
which §10 had named as a test I had not written.

BUG: the service ran with busy_timeout = 0. `busy_timeout`,
`foreign_keys`, and `synchronous` are PER-CONNECTION pragmas that are
not stored in the database file — only journal_mode = WAL is. They lived
inside the schema string, which only SqliteRunStore's constructor
executes, and serve.ts opens a bare DatabaseSync. So the child waited
five seconds on a lock while the service failed instantly. Measured
before the fix: 42 of 300 service writes took SQLITE_BUSY against a
concurrently-writing child; after, 300/300 on both sides.

WAL is what hid it. Readers do not contend, so a read-only service
looked healthy — the bug only became reachable when sessions and queue
rows made the service a writer, which happened two commits later and
nowhere near the pragma.

Both paths now call applyConnectionPragmas. test/concurrency.test.ts
covers the interleaved-writers case §10 asked for, and asserts that a
bare connection really is unconfigured — so the helper cannot quietly
become decorative if a default changes.

Plan reconciled where the code had moved past it:

- the queue DDL was stale: ticket is NOT NULL (the PR-title assert
  requires it), summary exists at all (the loop needs all four seeds),
  and run_id is UNIQUE.
- §8 specified a render-diff against the vanilla page before deleting
  it. That was SKIPPED. The plan now says so, says what covered the risk
  instead — parity is checked continuously rather than once — and where
  the deleted page lives if anyone wants the comparison.
- non-goals claimed cli.ts stays "unchanged", which stopped being true
  when it gained serve/reindex/--requested-by and lost dashboard. The
  claim that actually matters is restated: terminal and service run the
  same code, and the CLI is not a compatibility shim.

Verified: 380/380 green, tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
queue.test.ts claims the same row twice sequentially, which proves the
status guard but not the locking — both calls run in one transaction
context. §10 asks for two claim loops, and this drives two independent
connections, which is what actually exercises BEGIN IMMEDIATE.

Worth testing properly because the failure is remote from its cause: two
supervisors claiming one row means two orchestrators on one journal, and
that surfaces hours later as the loop's own lockfile refusing the second
one, long after the queue has moved on.

Also drains a 20-row queue by alternating between the two connections
and asserts no row is claimed twice.

Verified: 382/382 green, tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verified every [confirmed] claim by running it before changing anything.
All three reproduced.

BLOCKER 1.1 — a branch could be run exactly once, ever. Run dirs are
keyed by ${repo}-${branch}, so the second run of a branch lands on the
first run's directory; with ids minted at enqueue, resolveRunId threw
RunIdConflictError every time. Guaranteed, not racy. It would have
surfaced as three failed spawns and an error about run ids to whoever
asked for the run.

The missing distinction was whether anything is actually DRIVING the old
run. A live lock means rebinding would hijack a run in flight — still
refused, which is what the guard was written for. A finished or crashed
run holds no live lock and its directory is merely in the way: it is
archived as <dir>.stale-<ts> and the new run starts clean.

Archiving at START, not on exit: a crashed run never reaches an exit
path, and that is precisely the run whose directory would otherwise
block its own retry. It also makes `.stale-` a convention code produces
— the read-side filters were matching a naming scheme nothing wrote.

BLOCKER 1.2 — every authenticated request was a write. identity() calls
sessions.resolve() on every request, which unconditionally UPDATEd
last_seen_at, including on /api/health and every static asset. Measured
under a child's write lock: 5438ms then `database is locked`, reaching
the error handler as an unmodelled error — HTTP 500 on every route for
every signed-in user for as long as the lock was held. A pure read cost
0ms; WAL was doing its job and the bookkeeping write was undoing it.

Now throttled to 60s, non-fatal on failure (a timestamp nobody reads at
second granularity must not cost a caller their identity), and
/api/health is registered BEFORE identity — health is precisely the
route that must answer when the database is unhappy. Same measurement
after: 0ms.

1.3 POST /api/runs is gated on a supervisor and 503s `no_supervisor`
    without one, and /health reports it. Accepting work nothing will
    ever run is a silent black hole to the person who asked.
1.4 reindex refuses while a run is live — its DELETE FROM runs cascades
    to run_events and kills a running child with an FK error. Liveness
    is the LOCK plus a running queue row, deliberately not
    `step NOT IN (terminal)`, which flags month-old crashed runs
    forever. --force added; the "safe at any time" usage string
    corrected, since it described the design reindex was written for and
    stopped being true at the cutover.
2.1 malformed JSON is 400 bad_json, not 500. 2.2 X-Care-User is held to
    the same rule /auth/login enforces — same column, two standards.
    2.3 use the changes .run() already returns. 2.4 --port validated;
    EADDRINUSE reported instead of an unhandled event.
3.5 --secure-cookies/--static/--repos/--backup-dir wired; they existed
    on ServeOptions with no way to set them, and step 6 needs
    secure-cookies.
4.1 shutdown calls closeAllConnections with a hard backstop — an idle
    keep-alive socket from one open dashboard was enough for SIGTERM
    never to complete. 4.5 documented. 4.7 @types/express moved to dev.
5.1 the timeline paginates via next_seq instead of silently stopping at
    2000 events. 5.2 the validateSearch comment corrected — it
    normalises, it does not whitelist, and pagination depends on that.

Also builds inspectLock (the §12 seam), which 1.1 and 1.4 both need.

Verified: 393/393 green, tsc clean in both packages, web builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Spawning care-loopd from the loop-service supervisor is the first thing that
ever ran it with a foreign cwd and no stdin. Both went badly, and neither
failure was the service's.

**The launcher only worked from inside its own package.** The shebang was
`node --import tsx`, and node resolves that bare specifier against the WORKING
DIRECTORY — so `care-loopd` died with "Cannot find package 'tsx'" from
anywhere else. That is every `npm link` user, and every child the supervisor
spawns, whose cwd is the run directory by design. It now registers tsx
programmatically, resolved relative to the launcher file.

**The plan gate hung forever on a closed stdin.** `rl.question` against an
ended stream never resolves — not EOF, not an empty string, just a promise
that sits there. The child printed the approval prompt and stopped: alive,
idle, holding its lock, with no error and no exit. Racing the question against
the interface's own `close` turns that into a message naming the two places a
run CAN be approved. `question` on an already-closed interface throws
synchronously instead (`echo "a" | care-loopd`), so that is converted too.

Both terminal dialogs share one `askOrFail` — two copies of this fix would
mean the second one is the one nobody remembers. The CLI already advertised a
non-interactive (CI/bot) mode, so this hazard predates the service; the
service is just the first caller to hit it every single time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 4 of PLAN-loop-service. The only part of the service that starts a
process: everything above it writes rows, this turns rows into children and
children's exits back into rows.

`--supervise` is opt-in. Two `serve` processes on one db must not both claim,
and a read-only dashboard is a reasonable thing to run. `POST /api/runs`
already refuses while it is off, so the failure mode is a clear 503 rather
than rows nothing consumes.

**The claim now consults the filesystem.** `QueueStore.claim` takes a
`startable` predicate evaluated INSIDE the `BEGIN IMMEDIATE` transaction, and
the supervisor backs it with `inspectLock`. Without it the queue is blind to a
run someone launched from a terminal — which holds the very same lockfile,
since the run dir is `${repo}-${branch}` either way — and the service would
claim, spawn, set up a worktree, and die in `withLock` minutes later, reported
as a spawn failure. The candidate query returns the oldest pending row PER
BRANCH so fifty rows queued on one blocked branch cannot fill the scan window
and starve every branch behind them. `runSlug` is now shared with
`derivePaths`, because two copies of that rule would mean the service
inspecting one path's lock while the child takes another's.

`stop()` does NOT kill the children. They are independent processes holding
their own locks and journals; a service restart aborting every teammate's run
would be far worse than a few unsupervised minutes.

**Reconciliation is one path, not two.** The plan had a dead child "resumed in
place, staying running". Returning it to `pending` reaches the same
destination through machinery that already exists: the claim/spawn cycle
re-spawns it with the same `CARE_RUN_ID` onto the same run dir, and
`run-context` adopts that id rather than rebinding it. The alternative was a
second code path that runs only after a crash — the least-tested kind there
is. A re-adopted child does need something a spawned one doesn't: there is no
exit event for a process you did not fork, so adopted pids are swept each
tick, and the lock recovers the status the exit code can't (released in
`withLock`'s finally ⇒ clean, still present ⇒ died where it stood). Without
that sweep the row stays `running` forever and its slot stays spent.

Settles the three paper decisions REVIEW-loop-service left open:

- §3.1, the §4/§5 contradiction: §4 wins, the child never sees the queue.
  Cancel is SIGTERM with a SIGKILL escalation. Keeping the child free of DB
  coupling is what makes it the same binary you run locally, and crash-only
  `resume` already handles a terminated run — one mechanism, already tested.
- §3.3, `queued_behind` reported the wrong thing: now `blocked_by_branch` AND
  `queue_position`. With a cap of 2 and five queued branches, the old field
  told three of five callers nothing was in their way while they waited on the
  cap — the common reason a run doesn't start.
- §3.4, `GET /queue` broke the plan's own envelope rule: now
  `{items, total, limit, offset}` on the same 50/200 as every other list
  route. `/stats` was counting a PAGE of rows, so its totals quietly stopped
  being totals once the queue outgrew one page; it counts in SQL now.

Verified against a real service with a real spawned child: enqueue → claim →
spawn → the planner actually ran → the gate → exit → `failed` written with the
reason. 418 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things in §7 would have been expensive to retrofit, so they are settled
now, on paper, against the real code.

**Cancel travels the channel the child is already blocked on.** A run parked
at its gate is the one moment the child is definitionally idle AND already
polling a row the service can write. SIGTERM would work — crash-only makes it
safe — but it is strictly worse than saying so in the row: a signalled child
skips its `finally`, leaving its lockfile behind, writing no `run.end`, with
the run's last journal event a question nobody will answer. So a parked run is
cancelled cooperatively (stamp `cancelled_at`, short grace) and signalled only
on escalation; a non-parked run still gets SIGTERM immediately, and which
branch applies is one indexed query.

This does not breach §4's "the child never sees the queue". The child reads
its own gate row for its own run — the transport it was handed — and never
learns a queue exists. §4 protects the child's independence from the
SCHEDULER, not an embargo on the gate transport discussing its own gate.

**`ask_id` must be per ATTEMPT.** §7 specified the literal `'approve'` for the
consolidated ask. That is an infinite loop: `amend` re-drafts and asks again,
the second ask finds the first row — already answered `amend` — and returns it
immediately, so the planner amends forever against an answer nobody re-gave.
`plan.ts:134` is a bare `for (;;)` whose own comment says amend re-drafts
unbounded, and every iteration is a real planner call. `runPlan` already has
the counter for this: `spawn` is monotonic across the stage, so the ask is
`approve:<that draft's round>` — tied to the exact draft the human is looking
at rather than to a second counter that could drift from it.

Also settled: the child polls SQLite directly rather than the HTTP API, so a
gate survives the service restarting; `expires_at` (24h), because a parked run
holds a concurrency slot and a cap of 2 means one forgotten plan halves the
fleet; and `answered_by`, because the person who approves is not always the
person who requested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Parked" means the plan is FINISHED and waiting on a human — not waiting to
start (that is `pending`, no process yet), not waiting to plan (recon, the
interview, and the draft have all already run). The child has done every
expensive thing it will do before approval and is sitting on the answer.

The previous draft had it hold that position for 24h and called the
concurrency slot an acceptable cost. It is not: with the cap at 2, one plan
left unanswered overnight halves the fleet and two stop it, on a shared box
where the requester may simply have gone home. A cap two unanswered plans can
exhaust is not a cap, it is a deadlock with a countdown.

So the child waits briefly (~10 min, "someone is probably looking at it right
now") and then EXITS, and the run resumes at the gate when the answer arrives.
Two timers doing different jobs: `wait_ms` bounds an expensive wait so it is
short; `expires_at` bounds a free one so it can be 7 days.

Resuming costs no model calls, because everything the approval path needs is
already durable — verified, not assumed:

  - writeArtifacts runs BEFORE the ask, so criteria/baseline/decisions.md are
    on disk before the human sees the question
  - gate_asks.payload IS the ConsolidatedAsk, and the two fields plan.approved
    reads off the draft — plannedBy, classification — are both in it
  - ticket/summary arrive as seed flags the supervisor already passes
  - the worktree is provisioned by runStart, AFTER the gate, so a suspended
    run holds no worktree either

`approve` resumes having called no model at all; `reject` is one journal
write; only `amend` re-invokes the planner, which is the work the human just
asked for.

Adds one status, `awaiting_gate` — live but not claimable, re-admitted to
`pending` by the answer in the same transaction that writes it. Distinct from
`pending` because "waiting on a human" and "waiting on capacity" are different
states, the FE must render them differently, and `queue_position` is
meaningless for the first.

The child still never sees the queue: it signals suspension with exit code 75
(EX_TEMPFAIL), "I am not done and nothing is wrong". A terminal-started run
uses the readline gate, never suspends, and never emits it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The plan gate as rows ([[PLAN-loop-service]] §7). Ask and answer are both
committed, and the two sides never talk to each other, only to this table —
which is what lets a gate survive the service restarting AND the child
exiting, since neither holds state the other needs.

`payload`/`answer` are BLOB written through `jsonb()`, matching run_artifacts:
smaller than text, and `json_extract` works without a reparse.

`ask_id` is documented as per-attempt in the DDL itself, because the bare
`approve` it replaces is an infinite loop, not a style preference: amend
re-drafts, re-asks, finds the previous row already answered `amend`, and the
planner amends forever at one real planner call per lap.

The pending index is partial. A pending ask is a tiny minority of rows the
moment the fleet has any history, and three different callers ask "does this
run have an open question?" — the claim path, the cancel path, and the FE's
needs-you list.

`clearAll` now names gate_asks alongside queue/users/sessions as the tables a
reindex must not touch: losing a gate ask means a human re-approves a plan,
and no journal can rebuild it. `gate_asks.run_id` is deliberately not a
foreign key, so the cascade cannot reach it either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 5 of PLAN-loop-service, minus the FE. A gate is now a suspend point
rather than a blocking wait.

**The child polls SQLite, not the API.** The plan called this `HttpPlanGate`;
HTTP is how the human answers, not how the child listens. It already opens the
database to write every run event, so this needs no HTTP client, no service
URL, and no credentials in the child — and a gate survives the service being
restarted or redeployed, because neither side holds state the other needs.

**Suspension.** An unanswered gate raises `GateSuspendedError` after ~10 min;
`runPlan` journals `gate.suspended` and returns `suspended`; the CLI exits 75
(EX_TEMPFAIL); the supervisor writes `awaiting_gate` instead of `done` and the
slot goes to the next branch. No `run.end` — it is a pause, not a terminus.

**Resume costs no model calls**, because everything the approval path needs
was already durable: the artifacts are written before the ask, and `plannedBy`
/ `classification` — the only two fields the approval path reads off the
in-memory draft — are in the ask itself. So approve journals `plan.approved`
having called no planner, reject is one journal write, and only amend
re-invokes it, exactly once, which is the work the human asked for. Verified
by counting planner phases in the tests.

`runPlan` gains this without gaining a dependency on the service: `PlanRestore`
is plain data assembled by the caller, and the three gate outcomes live in
`plan-gate.ts` as part of the CONTRACT — any transport may be revoked or run
out of patience, and the readline adapter simply never raises them.

**Cancel is cooperative first.** The supervisor revokes the ask before
reaching for a signal, and only escalates if the child does not unwind. A
signalled child skips its `finally`: lockfile left behind, no `run.end`, and
the run's last journal event a question nobody will answer.

**`awaiting_gate` is live but unclaimable.** It blocks its branch — a
suspended run still owns its run dir — and `answerAndReadmit` flips it back to
`pending` in the SAME transaction as the answer. Splitting them would leave a
window where the gate is settled and the run is still awaiting_gate, waiting
on a question already answered.

Ask ids are content-derived rather than counted: a counter lives in memory and
a re-spawned child restarts it at 1, colliding a different second draft with
the first draft's answer. A hash gets identical content (idempotent re-ask,
what crash-only needs) and different content (amend) right at once, with an
in-process guard for the one case it cannot see — a re-draft that comes back
byte-identical.

Routes: `GET/POST /api/runs/:id/gate` and `GET /api/gates`, the needs-you
list — a gate nobody sees is a gate that expires, and expiry is the one
outcome that throws away finished planning work. Answers are validated because
`approve` authorizes a push to origin: an interview must answer every question
it was asked, and an empty amendment is refused rather than sent to the
planner to re-draft against no instruction.

446 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`GET /api/runs/:id` 404'd for a run that had been enqueued but not started.
Found by driving the FE: the new-run form navigates to the run it just
created, and landed on "No such run".

Minting the run id at enqueue is exactly what lets POST /api/runs answer
synchronously — so the id is a valid address BEFORE any process exists to
write a journal. 404 made a successful enqueue look like a failure for the
first few seconds of every run's life, and for the whole time a run sits
behind a busy branch.

Both halves of `{run, queue}` are now nullable, which is the honest shape:
`queue` is null for a CLI-started run that never went through the service,
`run` is null for a queued one. Neither is the record and the other a
decoration. 404 is reserved for an id where neither exists, which is a
genuinely unknown run.

An existing test asserted the old behaviour with a comment calling it
intended; it now covers the CLI half and points at the new test for the
mirror case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two views §8 was still missing, which together close the loop: a run can
now be requested, and its one human gate answered, without a terminal.

**New run** replaces `terminalFront`'s questionnaire with the same four seed
fields, validated by the same rules — server-side, by `validateSeed`, so a
ticket that would fail the [ENG-###] PR-title assert fails while a person is
looking at the form rather than hours later inside a spawned child. The
server names the offending field in `error.code`, so the message lands under
the input it is about instead of as a banner the eye has to correlate.

**The gate view** renders the ask in full — every acceptance criterion, the
test plan, and the mandated `Planned by:` line — rather than summarising it
into a yes/no. This is the one place in the app where a click authorizes
something irreversible, and a gate easier to skim than to read is a rubber
stamp. Amend collects its free text before sending, and the empty case is
disabled locally as well as refused server-side: it would send the planner off
to re-draft against no instruction.

It renders in EVERY run state, above everything else, because it is the only
thing on the page waiting on the reader — including for a run whose journal
has not reached the database yet.

A "N waiting on a human" badge sits in the header on every page and links to
the filtered fleet. A gate nobody notices is a gate that expires, and expiry
is the one outcome that throws away planning work already finished and paid
for.

Cancel is wired in both the full and the not-yet-started views, for the three
non-terminal statuses.

Driven end-to-end against a real service with a real suspended run: sign in →
the badge appears → open the run → the gate renders → amend with text → the
ask leaves the needs-you list and the row goes awaiting_gate → pending. Then
new-run with a bad ticket (inline under Ticket), with no supervisor (the 503
explained), and valid (201 → the run page, showing the queued state).

That last one also turned up two 404s per poll: the timeline and artifact
queries fired for a run with no journal. Both are now gated on the run
existing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The route table now matches what is served, including `GET /gates`, which was
not in the original surface — the needs-you list earns its place because an
unnoticed gate expires, and expiry is the one outcome that discards finished
planning work.

Also records the two corrections the FE turned up: a queued run 404ing on its
own detail page, and the journal-backed queries polling 404s for a run that
has no journal yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 6, corrected for the actual box. The plan said "systemd unit"; on NixOS a
hand-written /etc/systemd/system/care-loopd.service plus `systemctl enable` is
outside the generation — it survives no rebuild, appears in no rollback, and
is invisible to anyone reading configuration.nix to find out what the machine
runs. `deploy/care-loopd.nix` is a normal NixOS module.

Three things a ported unit would get wrong, all of which fail late rather than
loudly:

  - `path` must be explicit — there is no /usr/bin. The loop shells out to git
    for every worktree, and the opencode SDK launches a BARE `opencode` from
    PATH for every judgment spawn (@opencode-ai/sdk cross-spawns it). Both
    fail at first use, not at startup.
  - `opencode` must come from nixpkgs. The upstream install script drops a
    dynamically-linked ELF in ~/.opencode/bin that cannot run on NixOS without
    an FHS shim.
  - The secrets file must live outside the Nix store. /nix/store is
    world-readable, so a token written from a Nix expression is a token
    published to every user on the box. The module asserts against a store
    path rather than trusting the reader to know that; it also asserts stateDir
    is under /var/lib, since StateDirectory is derived from its basename and
    the two silently disagreeing is worse than a build error.

`KillMode=process` is the setting that matters most. The default kills the
whole cgroup on restart, aborting every teammate's run and leaving a stale
lockfile and no `run.end` on every deploy — undoing §4's reconciliation
exactly when it is needed. Restarts now stop the supervisor claiming and leave
live children alone, which is what reconcile-at-boot was built for.

Also fixes a real gap for headless running: `derivePaths` hardcoded
`~/Desktop`, a fine guess on a laptop and nonsense on a server whose service
user's home is a state directory. Now flag → `CARE_MAIN_REPO` /
`CARE_WORKTREE_ROOT` → the laptop default, so the unit sets layout once
instead of the supervisor passing two more flags per spawn. These are
properties of the machine, not of a run.

Exposure is LAN now (--host 0.0.0.0, firewall port open, plain HTTP over an
unverified login) with the Tailscale path written down as a three-line change
plus --secure-cookies.

Verified the runbook's bootstrap on an empty directory: reindex creates the
db, serve starts against it. 449 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit wrote the deployment as a 250-line NixOS module, which
over-fitted it. Almost everything in there was plain systemd.

What is actually deployed is ONE process: `care-loopd serve --supervise`
serves the API, serves the built web app from the same origin and port, and
runs the supervisor that spawns a `care-loopd run` child per queued run. No
second daemon, no worker pool, no queue broker. One command, one env file, one
port.

So `deploy/care-loopd.service` is now the reference — usable directly on any
systemd distro, and readable as documentation of what to set anywhere else
(launchd, supervisord, a tmux session while trying it out). `care-loopd.nix`
declares the same unit natively, because copying a file into
/etc/systemd/system on NixOS puts it outside the generation.

Only two settings genuinely differ there, and the trimmed module says so at
the top rather than restating systemd: PATH must be built from packages (no
/usr/bin, and `opencode` must come from nixpkgs rather than the install
script's dynamically-linked ELF), and the secrets file must not be a store
path, since /nix/store is world-readable.

The README now leads with the shape rather than with a platform, including the
one non-obvious consequence: the CHILDREN inherit the service's environment,
which is why the unit lists tools the service itself never calls. The service
does not run git or spawn opencode; every child does.

`KillMode=process` — the most important line in either file — was never
NixOS-specific and now reads that way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two passes over the loop-service branch, both subtractive.

CUT (schema v7)

The jsonl log was being diffed against the db on every run.end/run.resume — a
standing invariant guarding a single-writer local SQLite file, which bought
nothing that VACUUM INTO backups do not, while costing a per-run check, a
parity_error column, an API field, and two frontend surfaces. It never fired
once: parity_error was NULL across all 9 runs in the live db.

Deletes parity.ts, its 12 tests, the check in Journal.append, the column, and
the fleet/detail badges. journal.jsonl stays exactly as it was — fsync'd,
hash-chained, what reindex rebuilds from and what care-loop-doctor greps. Only
the verification layer on top of it is gone.

Also drops run_rounds: declared schema-complete for per-round analytics that
was never built, with no INSERT, no SELECT, and zero rows anywhere. Re-add it
alongside the feature that needs it, when its columns can be chosen against a
real query rather than guessed.

Both drops migrate in place; migrate() checks column and table presence rather
than trusting user_version, so an older db upgrades cleanly. Verified on a copy
of the live db: 14 columns, 7 runs / 1351 events intact, integrity_check ok.
The two migrate() tests move to run-store.test.ts, where they belong — they
exercise the migration hook, not the parity check that was its first customer.

COMMENTS

Comment density in the branch's own files drops 38% (1474 -> 914 lines; 17% ->
12% overall). Removed: every plan-doc section reference, historical narrative
that git already holds, restatements of the signature below them, and
paragraphs defending choices against alternatives nobody proposed.

Kept, compressed, the facts code cannot express and that would otherwise be
silently re-broken: busy_timeout must be per-connection and WAL masks its
absence; `prev` must come from the file, since sourcing it from the db broke
every reindexed legacy run; rl.question never resolves on an ended stream;
the SPA fallback must exclude extensioned paths; server.close waits on idle
keep-alive sockets.

Where a comment was propping up an unclear expression, the fix is a name:
encodeRandom() shared by both run-id encoders, Supervisor#lockOptions,
looksLikeAsset(), clientErrorStatus()/isMalformedJson(), SessionStore#touch().
Also moves an orphaned doc comment in app.ts onto runIdParam, which it had
drifted away from and stopped describing.

439 tests pass; orchestrator and web both typecheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One conflicting file: ci-round.ts, where care-loop broke runCiRounds into four
step functions (4c8f08a) while this branch had rewired its journal for the
SQLite cutover (56d7981).

Resolved by taking the refactor wholesale and re-applying this branch's three
changes onto it — they are orthogonal to the restructuring, and small:

  - `openRun(o.runDir)` replaces the slug-derived runId and the bare Journal.
    Since the cutover the journal is DB-backed and keyed by a real ULID, so
    `${repo}-${branch}` is a display label, not an identity.
  - `seedJournal` takes `isNew` instead of testing for an empty event list —
    a DB-backed read does not answer that question the same way.
  - The seed carries run_id / requested_by / ticket / summary / started_at,
    the columns `runs` projects. A CI-only run was adopted from a PR rather
    than planned, so ticket and summary are explicitly null.

The eight golden journal streams that arrived with the refactor pass
UNCHANGED, which is the useful confirmation: the resolution alters the seed's
state fields and not one event in the sequence.

Two fixes were needed in the incoming golden test itself, both because it was
written where `Journal` was still file-backed:

  - it needs `useRealStore()`, or the seed never lands and every scenario dies
    on "cannot project state from an empty journal";
  - the trace must use `readReplica()`, not `read()` — the latter queries the
    DB by run_id, and the literal "x" placeholder matches nothing, so all
    eight goldens would have compared an empty stream against itself once the
    store was installed.

456 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant