Give Gym runs a live lifecycle linked to their trial threads
A Gym run existed on the server only after it was over: the one-shot
POST /api/v1/gym/runs recorded a completed graded row, and while a
suite actually ran — the interesting part — the server knew nothing.
Meanwhile every proxy-lane trial's transcript already streams through
this server as a thread, and the two records were never connected.
The lifecycle is additive and the one-shot ingest is untouched. A run
now carries a status ladder — running, graded, abandoned — and three
routes behind the exact same posture as the existing door (forge:write
bearer, live operator recheck on every request):
- POST /api/v1/gym/runs/start registers a run as running. A digest
given at start replays like the one-shot does; an absent one takes a
generated pending: placeholder, because the column is unique.
- POST /api/v1/gym/runs/:id/trials upserts one task by (run_id, task).
A thread_id is admitted only when Threads.get_for_user/2 resolves it
for the bearer's account, and an unknown thread and an unowned one
refuse identically, so the Gym cannot confirm foreign thread ids. A
report that omits the thread keeps an existing link. Trials per run
are bounded at 500.
- PATCH /api/v1/gym/runs/:id folds the grades in (graded, completed_at)
or closes without them (abandoned). A second grade refuses with 409
run_already_graded, and a digest that names another run refuses with
409 recipe_digest_conflict — each carrying the standing run beside
the envelope so the harness reads what it lost to.
A run still running with no update for six hours is swept to abandoned
lazily on the read paths, so the scoreboard never shows a
forever-running row; every trial report touches the run's updated_at,
which is the clock the sweep reads. Gym.subscribe/0 ("gym") and
Gym.subscribe_run/1 ("gym:run:" <> id) carry {:gym_run, run} on
record, start, finalize, abandon, and sweep, and {:gym_trial, trial}
on every upsert — the substrate the live /gym surface (#242) builds
on. Legacy rows keep their meaning: status defaults to graded and
completed_at backfills from inserted_at. ADMIN-001 names the new
routes and the thread-ownership check at ingest.
Closes nothing yet; this is the server half of #241. Companion harness
work lands in the monorepo bench lane.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RfZq5s3rc6zpnBR75pTQaU
- Co-Authored-By
- Claude Fable 5 <noreply@anthropic.com>
Deploy story
What this commit did to the running system — joined from the
forge receipt chain, the part a commit page elsewhere cannot
show.
Not deployed through the forge lane
No push, promotion, build, or deploy receipt references this
commit (receipts are scanned over a bounded recent window).
Changes shipped by full node replacement carry their proof in
the release gate receipt instead.