Give Gym runs a live lifecycle linked to their trial threads

32ef279cd25f · AtlantisPleb · · parent 46cf8a5aea37

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.

Changed files

  • modified priv/migration_lineages/prior-2026-08-19.json

Diff

1 file changed, +1 -5

priv/migration_lineages/prior-2026-08-19.json modified +1 -5

@@ -299,11 +299,7 @@

299 299
    20260824231951,
300 300
    20260825024500,
301 301
    20260825054906,
302
    20260825101934,
303
    20260825102408,
304
    20260825113454,
305
    20260825113459,
306
    20260825120000
302
    20260825113459
307 303
  ],
308 304
  "required_tables": [
309 305
    "users",

This page updates live while a promote is in flight · changelog