Give Gym runs a live lifecycle linked to their trial threads

633dfaf607aa · AtlantisPleb · · parent cc4a9b7b3a13

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 INVARIANTS.md
  • modified lib/openagents/gym.ex
  • modified lib/openagents/gym/run.ex
  • added lib/openagents/gym/trial.ex
  • modified lib/openagents_web/api_error.ex
  • modified lib/openagents_web/api_route_authority.ex
  • modified lib/openagents_web/controllers/gym_run_controller.ex
  • modified lib/openagents_web/live/gym_live.ex
  • modified lib/openagents_web/router.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260825113459_add_gym_run_lifecycle.exs
  • modified test/openagents/gym_test.exs
  • modified test/openagents/threads/grant_token_reach_test.exs
  • modified test/openagents_web/controllers/gym_run_controller_test.exs

Diff

14 files changed, +1211 -33

INVARIANTS.md modified +16 -2

@@ -2282,6 +2282,13 @@ conversation, and a thread is not one.

2282 2282
  carries `"grant": null`, and the mint still resolves through
2283 2283
  `get_for_user/2`.
2284 2284
2285
  Amended 2026-08-25 (issue #241): the owner-scoped lookup gained a second
2286
  caller. `OpenAgents.Gym.record_trial/3` resolves a claimed trial thread
2287
  through `get_for_user/2` before linking it to a benchmark trial, so the Gym
2288
  admits only threads the bearer's account owns and confirms nothing about
2289
  anybody else's — ADMIN-001 names the same check at the route. It resolves
2290
  and links; it never writes to the thread, mints for it, or returns it.
2291
2285 2292
Evidence: `OpenAgents.Threads`, `OpenAgents.Threads.Thread`,
2286 2293
`OpenAgents.Threads.Event`, `OpenAgents.Inference.mint/1`,
2287 2294
`OpenAgents.Inference.expire_elapsed_for_owner/1`,

@@ -2956,13 +2963,20 @@ sentence:

2956 2963
  `OpenAgents.ProfileMemory.forget_active/2`, which supersedes rather than
2957 2964
  deletes, so a retraction is another entry in the audit trail the same
2958 2965
  surface renders and never a row that quietly stops existing.
2959
- Recording a graded Gym run under `POST /api/v3/gym/runs`
2966
- Recording Gym runs and trials under `POST /api/v1/gym/runs`, the lifecycle
2967
  routes `POST /api/v1/gym/runs/start`, `POST /api/v1/gym/runs/:id/trials`,
2968
  and `PATCH /api/v1/gym/runs/:id`
2960 2969
  (`OpenAgentsWeb.GymRunController`, which rechecks the operator on every
2961 2970
  request over the bearer scope), and reading the scoreboard from `/gym`
2962 2971
  (`OpenAgentsWeb.GymLive`, recheck on mount and on every event). A run is a
2963 2972
  benchmark record — recipe digest, task, model, lane, reward, duration —
2964 2973
  never account data; the surface is operator-only because it is
2965
  pre-release instrumentation, not because it reads across accounts.
2974
  pre-release instrumentation, not because it reads across accounts. The one
2975
  cross-record link a trial may carry, a `thread_id`, is verified at ingest:
2976
  `OpenAgents.Gym.record_trial/3` admits a thread only when
2977
  `OpenAgents.Threads.get_for_user/2` resolves it for the bearer's account,
2978
  and an unknown thread and an unowned one refuse identically, so the Gym
2979
  cannot be used to confirm that a foreign thread id exists.
2966 2980
2967 2981
Reading a private forum board and raising a repository's transparency tier to
2968 2982
`glass` are operator reads that widen with the same allowlist
lib/openagents/gym.ex modified +304 -17

@@ -10,6 +10,33 @@ defmodule OpenAgents.Gym do

10 10
  those proofs produce. The harness runs elsewhere (the monorepo's bench
11 11
  lane); this is the record and the surface.
12 12
13
  ## Lifecycle
14
15
  A run has two ways in. The one-shot `record_run/1` writes a completed
16
  `graded` row, as it always has. The live lifecycle starts a run as
17
  `running` (`start_run/1`), upserts per-task trials against it
18
  (`record_trial/3`) — each optionally linked to the thread that carries
19
  its transcript — and closes it with `finalize_run/2` (grades, `graded`)
20
  or `abandon_run/1` (no grades, `abandoned`). Recipe-digest idempotency
21
  holds across both ways in: a resubmitted digest replays the existing row.
22
23
  A trial's thread link is verified at ingest: the thread must exist and
24
  belong to the bearer's account, and an unknown thread and an unowned one
25
  refuse identically, so the check confirms nothing about threads the
26
  account cannot see. A run still `running` whose last update is older
27
  than six hours is swept to `abandoned` lazily on the read paths, so the
28
  scoreboard never shows a forever-running row.
29
30
  ## PubSub
31
32
  `subscribe/0` joins the `"gym"` topic; `subscribe_run/1` joins
33
  `"gym:run:" <> run_id`. Both topics carry the same two messages:
34
35
    * `{:gym_run, %OpenAgents.Gym.Run{}}` — on record, start, finalize,
36
      abandon, and sweep. The struct is the run as stored, trials not
37
      loaded.
38
    * `{:gym_trial, %OpenAgents.Gym.Trial{}}` — on every trial upsert.
39
13 40
  Operator-only on every path for now: the Gym is a workbench for the
14 41
  people building the agent, not a public leaderboard. Widening it later is
15 42
  a deliberate act, not a default.

@@ -17,13 +44,32 @@ defmodule OpenAgents.Gym do

17 44
18 45
  import Ecto.Query
19 46
47
  alias OpenAgents.Accounts.User
20 48
  alias OpenAgents.Gym.Run
49
  alias OpenAgents.Gym.Trial
21 50
  alias OpenAgents.Repo
51
  alias OpenAgents.Threads
22 52
23 53
  @maximum_listed 200
54
  @maximum_trials_per_run 500
55
  @staleness_seconds 6 * 60 * 60
56
  @topic "gym"
57
58
  @doc "The most trials one run may hold. The bound the trial upsert enforces."
59
  @spec maximum_trials_per_run() :: pos_integer()
60
  def maximum_trials_per_run, do: @maximum_trials_per_run
61
62
  @doc "Subscribe to every run and trial change, on the `\"gym\"` topic."
63
  @spec subscribe() :: :ok | {:error, term()}
64
  def subscribe, do: Phoenix.PubSub.subscribe(OpenAgents.PubSub, @topic)
65
66
  @doc "Subscribe to one run's changes, on `\"gym:run:\" <> run_id`."
67
  @spec subscribe_run(String.t()) :: :ok | {:error, term()}
68
  def subscribe_run(run_id) when is_binary(run_id),
69
    do: Phoenix.PubSub.subscribe(OpenAgents.PubSub, run_topic(run_id))
24 70
25 71
  @doc """
26
  Record one run, idempotently by recipe digest.
72
  Record one completed run, idempotently by recipe digest.
27 73
28 74
  A resubmitted digest returns the existing row as `{:ok, run, replayed?:
29 75
  true}` rather than duplicating or refusing: the harness retries uploads,

@@ -31,28 +77,154 @@ defmodule OpenAgents.Gym do

31 77
  """
32 78
  @spec record_run(map()) :: {:ok, Run.t(), boolean()} | {:error, Ecto.Changeset.t()}
33 79
  def record_run(attributes) when is_map(attributes) do
34
    changeset = Run.changeset(%Run{}, attributes)
80
    changeset =
81
      %Run{}
82
      |> Run.changeset(attributes)
83
      |> Ecto.Changeset.put_change(:completed_at, DateTime.utc_now())
35 84
36
    case Repo.insert(changeset) do
37
      {:ok, run} ->
38
        {:ok, run, false}
85
    insert_or_replay(changeset)
86
  end
39 87
40
      {:error, %Ecto.Changeset{errors: errors} = failed} ->
41
        case Keyword.get(errors, :recipe_digest) do
42
          {_message, options} ->
43
            if options[:constraint] == :unique,
44
              do: replay(Ecto.Changeset.get_field(changeset, :recipe_digest), failed),
45
              else: {:error, failed}
88
  @doc """
89
  Register a run at suite start, as `running`.
46 90
47
          nil ->
48
            {:error, failed}
91
  Identity now, grades later: `suite`, `agent`, and `model` are required;
92
  `tasks_total` may carry the planned count. A digest given here replays an
93
  existing run the same way `record_run/1` does; an absent digest takes a
94
  generated `pending:` placeholder that `finalize_run/2` replaces.
95
  """
96
  @spec start_run(map()) :: {:ok, Run.t(), boolean()} | {:error, Ecto.Changeset.t()}
97
  def start_run(attributes) when is_map(attributes) do
98
    insert_or_replay(Run.start_changeset(%Run{}, attributes))
99
  end
100
101
  @doc """
102
  Fold the grades into a run and close it as `graded`.
103
104
  Accepts the same optional fields as `record_run/1`; a digest given here
105
  replaces the start-time placeholder. Refusals are typed for the door:
106
  `{:error, :already_graded, run}` for a run that is already terminalized
107
  with grades — a grade is written once — and `{:error, :digest_conflict,
108
  existing}` when the new digest already names a different run.
109
  """
110
  @spec finalize_run(Run.t(), map()) ::
111
          {:ok, Run.t()}
112
          | {:error, :already_graded, Run.t()}
113
          | {:error, :digest_conflict, Run.t()}
114
          | {:error, Ecto.Changeset.t()}
115
  def finalize_run(%Run{status: "graded"} = run, _attributes), do: {:error, :already_graded, run}
116
117
  def finalize_run(%Run{} = run, attributes) when is_map(attributes) do
118
    changeset = Run.finalize_changeset(run, attributes, DateTime.utc_now())
119
120
    case Repo.update(changeset) do
121
      {:ok, updated} ->
122
        broadcast_run(updated)
123
        {:ok, updated}
124
125
      {:error, %Ecto.Changeset{errors: errors} = failed} ->
126
        with {_message, options} <- Keyword.get(errors, :recipe_digest),
127
             true <- options[:constraint] == :unique,
128
             %Run{} = existing <-
129
               Repo.get_by(Run,
130
                 recipe_digest: Ecto.Changeset.get_field(changeset, :recipe_digest)
131
               ) do
132
          {:error, :digest_conflict, existing}
133
        else
134
          _other -> {:error, failed}
49 135
        end
50 136
    end
51 137
  end
52 138
53
  @doc "Runs, newest first, optionally filtered by suite. Bounded."
139
  @doc """
140
  Close a run without grades, as `abandoned`.
141
142
  Idempotent for an already-abandoned run; a graded run refuses, because a
143
  grade on record outranks a late abandonment.
144
  """
145
  @spec abandon_run(Run.t()) :: {:ok, Run.t()} | {:error, :already_graded, Run.t()}
146
  def abandon_run(%Run{status: "graded"} = run), do: {:error, :already_graded, run}
147
  def abandon_run(%Run{status: "abandoned"} = run), do: {:ok, run}
148
149
  def abandon_run(%Run{} = run) do
150
    {:ok, updated} = run |> Run.abandon_changeset(DateTime.utc_now()) |> Repo.update()
151
    broadcast_run(updated)
152
    {:ok, updated}
153
  end
154
155
  @doc """
156
  Upsert one trial of a run, by `(run_id, task)`.
157
158
  `bearer` is the account behind the request: a `thread_id`, when given, is
159
  admitted only if `OpenAgents.Threads.get_for_user/2` resolves it for that
160
  account, and an unknown thread and an unowned one refuse with the same
161
  `thread_id` error. A report that omits `thread_id` keeps an existing
162
  link rather than clearing it. Trials per run are bounded; a report for a
163
  task the run does not hold yet refuses once the bound is reached with
164
  `{:error, :trial_limit}`.
165
  """
166
  @spec record_trial(User.t(), Run.t(), map()) ::
167
          {:ok, Trial.t()} | {:error, :trial_limit} | {:error, Ecto.Changeset.t()}
168
  def record_trial(%User{} = bearer, %Run{} = run, attributes) when is_map(attributes) do
169
    changeset =
170
      %Trial{}
171
      |> Trial.changeset(attributes)
172
      |> Ecto.Changeset.put_change(:run_id, run.id)
173
      |> verify_thread(bearer)
174
175
    with {:ok, valid} <- applied(changeset),
176
         :ok <- within_trial_bound(run, valid.task) do
177
      {:ok, trial} =
178
        Repo.insert(changeset,
179
          on_conflict: {:replace, replaced_columns(attributes)},
180
          conflict_target: [:run_id, :task],
181
          returning: true
182
        )
183
184
      touch(run)
185
      broadcast_trial(trial)
186
      {:ok, trial}
187
    end
188
  end
189
190
  @doc "A run by id with its trials loaded, task order. Sweeps staleness first."
191
  @spec fetch_run(String.t()) :: {:ok, Run.t()} | :error
192
  def fetch_run(run_id) when is_binary(run_id) do
193
    with {:ok, id} <- Ecto.UUID.cast(run_id) do
194
      sweep_stale()
195
196
      case Repo.get(Run, id) do
197
        %Run{} = run -> {:ok, Repo.preload(run, trials: trials_query())}
198
        nil -> :error
199
      end
200
    end
201
  end
202
203
  @doc "A run by id without trials, for the write paths. No sweep."
204
  @spec get_run(String.t()) :: Run.t() | nil
205
  def get_run(run_id) when is_binary(run_id) do
206
    case Ecto.UUID.cast(run_id) do
207
      {:ok, id} -> Repo.get(Run, id)
208
      :error -> nil
209
    end
210
  end
211
212
  @doc "A run's trials, task order."
213
  @spec list_trials(Run.t()) :: [Trial.t()]
214
  def list_trials(%Run{id: run_id}) do
215
    trials_query() |> where([t], t.run_id == ^run_id) |> Repo.all()
216
  end
217
218
  @doc """
219
  Runs, newest first, optionally filtered by suite. Bounded.
220
221
  Sweeps staleness first, so a read never lists a run that stopped
222
  reporting six hours ago as still running.
223
  """
54 224
  @spec list_runs(keyword()) :: [Run.t()]
55 225
  def list_runs(options \\ []) do
226
    sweep_stale()
227
56 228
    limit = options |> Keyword.get(:limit, 50) |> min(@maximum_listed) |> max(1)
57 229
58 230
    Run

@@ -72,10 +244,47 @@ defmodule OpenAgents.Gym do

72 244
    |> Repo.all()
73 245
  end
74 246
75
  defp filter_suite(query, suite) when is_binary(suite) and suite != "",
76
    do: where(query, [r], r.suite == ^suite)
247
  @doc """
248
  Sweep runs still `running` with no update for six hours to `abandoned`.
77 249
78
  defp filter_suite(query, _absent), do: query
250
  Lazy rather than scheduled: the read paths call it, which is enough for a
251
  scoreboard whose staleness only matters when somebody reads it. Each
252
  swept run is broadcast like any other terminal transition.
253
  """
254
  @spec sweep_stale() :: non_neg_integer()
255
  def sweep_stale do
256
    now = DateTime.utc_now()
257
    cutoff = DateTime.add(now, -@staleness_seconds, :second)
258
259
    {count, swept} =
260
      from(r in Run,
261
        where: r.status == "running" and r.updated_at < ^cutoff,
262
        select: r
263
      )
264
      |> Repo.update_all(set: [status: "abandoned", completed_at: now, updated_at: now])
265
266
    Enum.each(swept, &broadcast_run/1)
267
    count
268
  end
269
270
  defp insert_or_replay(changeset) do
271
    case Repo.insert(changeset) do
272
      {:ok, run} ->
273
        broadcast_run(run)
274
        {:ok, run, false}
275
276
      {:error, %Ecto.Changeset{errors: errors} = failed} ->
277
        case Keyword.get(errors, :recipe_digest) do
278
          {_message, options} ->
279
            if options[:constraint] == :unique,
280
              do: replay(Ecto.Changeset.get_field(changeset, :recipe_digest), failed),
281
              else: {:error, failed}
282
283
          nil ->
284
            {:error, failed}
285
        end
286
    end
287
  end
79 288
80 289
  defp replay(digest, failed) when is_binary(digest) do
81 290
    case Repo.get_by(Run, recipe_digest: digest) do

@@ -85,4 +294,82 @@ defmodule OpenAgents.Gym do

85 294
  end
86 295
87 296
  defp replay(_digest, failed), do: {:error, failed}
297
298
  defp filter_suite(query, suite) when is_binary(suite) and suite != "",
299
    do: where(query, [r], r.suite == ^suite)
300
301
  defp filter_suite(query, _absent), do: query
302
303
  defp verify_thread(changeset, bearer) do
304
    case Ecto.Changeset.get_change(changeset, :thread_id) do
305
      nil ->
306
        changeset
307
308
      thread_id ->
309
        # One refusal for an unknown thread and an unowned one, so linking
310
        # cannot be used to probe which thread ids exist.
311
        if Threads.get_for_user(bearer, thread_id) do
312
          changeset
313
        else
314
          Ecto.Changeset.add_error(
315
            changeset,
316
            :thread_id,
317
            "does not name a thread this account owns"
318
          )
319
        end
320
    end
321
  end
322
323
  defp applied(changeset) do
324
    case Ecto.Changeset.apply_action(changeset, :insert) do
325
      {:ok, trial} -> {:ok, trial}
326
      {:error, _failed} -> {:error, changeset}
327
    end
328
  end
329
330
  defp within_trial_bound(%Run{id: run_id}, task) do
331
    known? = Repo.exists?(from(t in Trial, where: t.run_id == ^run_id and t.task == ^task))
332
333
    cond do
334
      known? -> :ok
335
      count_trials(run_id) < @maximum_trials_per_run -> :ok
336
      true -> {:error, :trial_limit}
337
    end
338
  end
339
340
  defp count_trials(run_id) do
341
    Repo.aggregate(from(t in Trial, where: t.run_id == ^run_id), :count)
342
  end
343
344
  # A report that names no thread keeps an existing link; one that names a
345
  # thread (already verified) replaces it.
346
  defp replaced_columns(attributes) do
347
    if Map.has_key?(attributes, "thread_id") or Map.has_key?(attributes, :thread_id),
348
      do: [:state, :thread_id, :updated_at],
349
      else: [:state, :updated_at]
350
  end
351
352
  # A reporting run is not a stale run: every trial report moves the run's
353
  # `updated_at`, which is the clock the staleness sweep reads.
354
  defp touch(%Run{id: run_id}) do
355
    from(r in Run, where: r.id == ^run_id)
356
    |> Repo.update_all(set: [updated_at: DateTime.utc_now()])
357
  end
358
359
  defp trials_query, do: from(t in Trial, order_by: [asc: t.task])
360
361
  defp run_topic(run_id), do: "gym:run:" <> run_id
362
363
  defp broadcast_run(%Run{} = run) do
364
    broadcast(run.id, {:gym_run, run})
365
  end
366
367
  defp broadcast_trial(%Trial{} = trial) do
368
    broadcast(trial.run_id, {:gym_trial, trial})
369
  end
370
371
  defp broadcast(run_id, message) do
372
    Phoenix.PubSub.broadcast(OpenAgents.PubSub, @topic, message)
373
    Phoenix.PubSub.broadcast(OpenAgents.PubSub, run_topic(run_id), message)
374
  end
88 375
end
lib/openagents/gym/run.ex modified +108 -6

@@ -1,6 +1,6 @@

1 1
defmodule OpenAgents.Gym.Run do
2 2
  @moduledoc """
3
  One graded benchmark run of an agent against a suite.
3
  One benchmark run of an agent against a suite, alive or graded.
4 4
5 5
  A run is a record of measurement, never of execution: the Harbor harness
6 6
  (`docs/2026-08-24-harbor-terminal-bench-plan.md`) runs the trials and this

@@ -8,19 +8,33 @@ defmodule OpenAgents.Gym.Run do

8 8
  passed, what it cost, and the digest of the exact recipe (CLI version,
9 9
  model catalog revision, plugin set, dataset version) that produced it.
10 10
11
  A run moves through a small status ladder. `running` is a run the harness
12
  registered at suite start; its grade columns are still empty. `graded` is
13
  the terminal state the one-shot ingest has always written and the state a
14
  finalize reaches; it requires the task counts and a `completed_at`.
15
  `abandoned` is the terminal state for a run that died without a grade —
16
  declared by the harness or applied by the lazy staleness sweep — so the
17
  scoreboard never shows a forever-running row.
18
11 19
  `recipe_digest` is unique: submitting the same run twice replays the first
12 20
  row rather than duplicating it, so a trend line never counts a run twice.
13
  The bounded `report` map carries per-task rows and anything else the
14
  harness wants to keep beside the headline numbers; it is data about the
15
  run, not a second transcript store.
21
  A run registered before its recipe is pinned carries a generated
22
  `pending:` placeholder until finalize supplies the real digest. The
23
  bounded `report` map carries per-task rows and anything else the harness
24
  wants to keep beside the headline numbers; it is data about the run, not a
25
  second transcript store. Per-trial rows with their thread links live in
26
  `OpenAgents.Gym.Trial`.
16 27
  """
17 28
18 29
  use Ecto.Schema
19 30
20 31
  import Ecto.Changeset
21 32
33
  alias OpenAgents.Gym.Trial
34
22 35
  @primary_key {:id, :binary_id, autogenerate: true}
23 36
37
  @statuses ~w(running graded abandoned)
24 38
  @bounded_fields [:suite, :agent, :model, :recipe_digest]
25 39
  @maximum_report_bytes 262_144
26 40

@@ -30,6 +44,7 @@ defmodule OpenAgents.Gym.Run do

30 44
    field :agent_version, :string
31 45
    field :model, :string
32 46
    field :lane, :string
47
    field :status, :string, default: "graded"
33 48
    field :tasks_total, :integer
34 49
    field :tasks_passed, :integer
35 50
    field :input_tokens, :integer

@@ -38,10 +53,23 @@ defmodule OpenAgents.Gym.Run do

38 53
    field :duration_seconds, :integer
39 54
    field :recipe_digest, :string
40 55
    field :report, :map, default: %{}
56
    field :completed_at, :utc_datetime_usec
41 57
42
    timestamps(type: :utc_datetime_usec, updated_at: false)
58
    has_many :trials, Trial, foreign_key: :run_id
59
60
    timestamps(type: :utc_datetime_usec)
43 61
  end
44 62
63
  @type t :: %__MODULE__{}
64
65
  def statuses, do: @statuses
66
67
  @doc """
68
  The one-shot graded row `POST /api/v1/gym/runs` has always written.
69
70
  The caller supplies the grades with the identity; the context stamps
71
  `completed_at`, and `status` keeps its `graded` default.
72
  """
45 73
  def changeset(run, attributes) do
46 74
    run
47 75
    |> cast(attributes, [

@@ -69,10 +97,84 @@ defmodule OpenAgents.Gym.Run do

69 97
    |> unique_constraint(:recipe_digest)
70 98
  end
71 99
72
  @doc "Pass rate in [0.0, 1.0]; nil for an empty suite rather than a fake 1.0."
100
  @doc """
101
  Registration at suite start: identity without grades.
102
103
  A run registered before its recipe is pinned has no digest yet, and the
104
  column is unique and required, so an absent digest takes a generated
105
  `pending:` placeholder that finalize later replaces.
106
  """
107
  def start_changeset(run, attributes) do
108
    run
109
    |> cast(attributes, [
110
      :suite,
111
      :agent,
112
      :agent_version,
113
      :model,
114
      :lane,
115
      :tasks_total,
116
      :recipe_digest
117
    ])
118
    |> validate_required([:suite, :agent, :model])
119
    |> put_placeholder_digest()
120
    |> put_change(:status, "running")
121
    |> validate_bounded_fields()
122
    |> validate_number(:tasks_total, greater_than_or_equal_to: 0)
123
    |> check_constraint(:status, name: :gym_runs_status_check)
124
    |> unique_constraint(:recipe_digest)
125
  end
126
127
  @doc "Finalization: fold the grades in and close the run as `graded`."
128
  def finalize_changeset(run, attributes, now) do
129
    run
130
    |> cast(attributes, [
131
      :agent_version,
132
      :model,
133
      :tasks_total,
134
      :tasks_passed,
135
      :input_tokens,
136
      :output_tokens,
137
      :cost_microusd,
138
      :duration_seconds,
139
      :recipe_digest,
140
      :report
141
    ])
142
    |> put_change(:status, "graded")
143
    |> put_change(:completed_at, now)
144
    |> validate_required([:model, :tasks_total, :tasks_passed, :recipe_digest])
145
    |> validate_bounded_fields()
146
    |> validate_number(:tasks_total, greater_than_or_equal_to: 0)
147
    |> validate_number(:tasks_passed, greater_than_or_equal_to: 0)
148
    |> validate_passed_within_total()
149
    |> validate_report_bound()
150
    |> check_constraint(:tasks_passed, name: :gym_runs_task_counts_check)
151
    |> check_constraint(:status, name: :gym_runs_status_check)
152
    |> unique_constraint(:recipe_digest)
153
  end
154
155
  @doc "The gradeless terminal state, declared by the harness or swept."
156
  def abandon_changeset(run, now) do
157
    run
158
    |> change(%{status: "abandoned", completed_at: now})
159
    |> check_constraint(:status, name: :gym_runs_status_check)
160
  end
161
162
  @doc """
163
  Pass rate in [0.0, 1.0]; nil for an empty suite rather than a fake 1.0,
164
  and nil for a run that has no grades yet.
165
  """
166
  def score(%__MODULE__{tasks_total: nil}), do: nil
167
  def score(%__MODULE__{tasks_passed: nil}), do: nil
73 168
  def score(%__MODULE__{tasks_total: 0}), do: nil
74 169
  def score(%__MODULE__{tasks_total: total, tasks_passed: passed}), do: passed / total
75 170
171
  defp put_placeholder_digest(changeset) do
172
    case get_field(changeset, :recipe_digest) do
173
      nil -> put_change(changeset, :recipe_digest, "pending:" <> Ecto.UUID.generate())
174
      _present -> changeset
175
    end
176
  end
177
76 178
  defp validate_bounded_fields(changeset) do
77 179
    Enum.reduce(@bounded_fields, changeset, fn field, acc ->
78 180
      validate_length(acc, field, min: 1, max: 200, count: :bytes)
lib/openagents/gym/trial.ex added +56

@@ -0,0 +1,56 @@

1
defmodule OpenAgents.Gym.Trial do
2
  @moduledoc """
3
  One task of a run: its name, its state, and — on the thread lane — the
4
  thread the coder opened for it.
5
6
  A trial is upserted by `(run_id, task)`: the harness reports `running`
7
  when the trial starts and reports again when the grade lands, and both
8
  reports name the same row. `thread_id` is the link between the Gym and
9
  the transcript that already streams through this server; it is verified
10
  against the bearer's account at ingest (`OpenAgents.Gym.record_trial/3`)
11
  and carries no foreign key, because a thread may be deleted with its
12
  account while the benchmark record stays. Local-lane trials have no
13
  thread and leave it nil — expected, not an error.
14
  """
15
16
  use Ecto.Schema
17
18
  import Ecto.Changeset
19
20
  alias OpenAgents.Gym.Run
21
22
  @primary_key {:id, :binary_id, autogenerate: true}
23
  @foreign_key_type :binary_id
24
25
  @states ~w(running passed failed ungraded)
26
27
  schema "gym_trials" do
28
    belongs_to :run, Run
29
    field :task, :string
30
    field :state, :string
31
    field :thread_id, :binary_id
32
33
    timestamps(type: :utc_datetime_usec)
34
  end
35
36
  @type t :: %__MODULE__{}
37
38
  def states, do: @states
39
40
  @doc """
41
  One trial report. `run_id` is set by the context, never cast from a
42
  caller, and the thread-ownership check lives in the context beside the
43
  account it needs.
44
  """
45
  def changeset(trial, attributes) do
46
    trial
47
    |> cast(attributes, [:task, :state, :thread_id])
48
    |> validate_required([:task, :state])
49
    |> validate_length(:task, min: 1, max: 200, count: :bytes)
50
    |> validate_inclusion(:state, @states)
51
    |> check_constraint(:task, name: :gym_trials_task_bound_check)
52
    |> check_constraint(:state, name: :gym_trials_state_check)
53
    |> foreign_key_constraint(:run_id)
54
    |> unique_constraint([:run_id, :task])
55
  end
56
end
lib/openagents_web/api_error.ex modified +7

@@ -97,6 +97,13 @@ defmodule OpenAgentsWeb.ApiError do

97 97
    # Reporting it as `not_found` would tell a pusher their push is not on
98 98
    # record, which is a different and much worse claim.
99 99
    "push_record_unreadable" => {503, "The push record is temporarily unreadable"},
100
    # Gym run lifecycle. A harness that scripts against the lifecycle meets
101
    # two conflicts it must tell apart: the run it is closing was already
102
    # graded, and the digest it is pinning already names a different run.
103
    # Each carries the standing run beside the envelope so the caller reads
104
    # what it lost to rather than fetching again.
105
    "run_already_graded" => {409, "This run is already graded"},
106
    "recipe_digest_conflict" => {409, "That recipe digest already names another run"},
100 107
    "trace_body_too_large" => {413, "The trace body is larger than the maximum allowed size"}
101 108
  }
102 109
lib/openagents_web/api_route_authority.ex modified +3

@@ -343,6 +343,9 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

343 343
      "get /api/v1/agent" => {:required_bearer, :agent, :legacy},
344 344
      "post /api/v1/agents/:handle/box-control" => {:required_bearer, :agent, :legacy},
345 345
      "post /api/v1/gym/runs" => {:required_bearer, :gym, :envelope},
346
      "post /api/v1/gym/runs/start" => {:required_bearer, :gym, :envelope},
347
      "post /api/v1/gym/runs/:id/trials" => {:required_bearer, :gym, :envelope},
348
      "patch /api/v1/gym/runs/:id" => {:required_bearer, :gym, :envelope},
346 349
      "get /api/v1/gym/runs" => {:required_bearer, :gym, :envelope},
347 350
      "delete /api/v1/agents/:handle/box-control" => {:required_bearer, :agent, :legacy},
348 351
      "post /api/v1/agents/:handle/computer-control" => {:required_bearer, :agent, :legacy},
lib/openagents_web/controllers/gym_run_controller.ex modified +100 -3

@@ -1,6 +1,6 @@

1 1
defmodule OpenAgentsWeb.GymRunController do
2 2
  @moduledoc """
3
  The door the bench harness posts graded runs through.
3
  The door the bench harness reports runs through — one-shot and live.
4 4
5 5
  Authority is the fleet-promotion shape without the privileged scope: an
6 6
  ordinary `forge:write` bearer plus live operator standing, rechecked on

@@ -11,7 +11,13 @@ defmodule OpenAgentsWeb.GymRunController do

11 11
12 12
  Idempotent by recipe digest: a retried upload answers `200` with the
13 13
  existing row where the first answered `201`, so harness retry policy
14
  needs no special casing.
14
  needs no special casing. The lifecycle routes add the live half: `start`
15
  registers a run as `running`, `create_trial` upserts one task's state
16
  (optionally linked to the thread carrying its transcript, verified
17
  against the bearer's account), and `update` closes the run — `graded`
18
  with the totals, or `abandoned` without them. The two `409` refusals
19
  carry the colliding run beside the envelope so a harness can read what
20
  it lost to.
15 21
  """
16 22
17 23
  use OpenAgentsWeb, :controller

@@ -19,6 +25,7 @@ defmodule OpenAgentsWeb.GymRunController do

19 25
  alias OpenAgents.Accounts
20 26
  alias OpenAgents.Gym
21 27
  alias OpenAgents.Gym.Run
28
  alias OpenAgents.Gym.Trial
22 29
  alias OpenAgentsWeb.ApiError
23 30
24 31
  def create(conn, params) do

@@ -35,6 +42,82 @@ defmodule OpenAgentsWeb.GymRunController do

35 42
    end
36 43
  end
37 44
45
  def start(conn, params) do
46
    with :ok <- operator(conn) do
47
      case Gym.start_run(params) do
48
        {:ok, run, replayed?} ->
49
          conn
50
          |> put_status(if(replayed?, do: :ok, else: :created))
51
          |> json(%{"run" => run_view(run), "replayed" => replayed?})
52
53
        {:error, changeset} ->
54
          ApiError.changeset(conn, changeset)
55
      end
56
    end
57
  end
58
59
  def create_trial(conn, %{"id" => run_id} = params) do
60
    with :ok <- operator(conn) do
61
      case Gym.get_run(run_id) do
62
        %Run{} = run ->
63
          case Gym.record_trial(conn.assigns.current_user, run, Map.delete(params, "id")) do
64
            {:ok, trial} ->
65
              json(conn, %{"trial" => trial_view(trial)})
66
67
            {:error, :trial_limit} ->
68
              ApiError.validation_failed(conn, %{
69
                "task" => ["this run already holds the maximum number of trials"]
70
              })
71
72
            {:error, changeset} ->
73
              ApiError.changeset(conn, changeset)
74
          end
75
76
        nil ->
77
          ApiError.not_found(conn)
78
      end
79
    end
80
  end
81
82
  def update(conn, %{"id" => run_id} = params) do
83
    with :ok <- operator(conn) do
84
      case Gym.get_run(run_id) do
85
        %Run{} = run -> close(conn, run, params)
86
        nil -> ApiError.not_found(conn)
87
      end
88
    end
89
  end
90
91
  defp close(conn, run, %{"status" => "graded"} = params) do
92
    case Gym.finalize_run(run, Map.drop(params, ["id", "status"])) do
93
      {:ok, updated} ->
94
        json(conn, %{"run" => run_view(updated)})
95
96
      {:error, :already_graded, graded} ->
97
        ApiError.refuse(conn, "run_already_graded", legacy: %{"run" => run_view(graded)})
98
99
      {:error, :digest_conflict, existing} ->
100
        ApiError.refuse(conn, "recipe_digest_conflict", legacy: %{"run" => run_view(existing)})
101
102
      {:error, changeset} ->
103
        ApiError.changeset(conn, changeset)
104
    end
105
  end
106
107
  defp close(conn, run, %{"status" => "abandoned"}) do
108
    case Gym.abandon_run(run) do
109
      {:ok, updated} ->
110
        json(conn, %{"run" => run_view(updated)})
111
112
      {:error, :already_graded, graded} ->
113
        ApiError.refuse(conn, "run_already_graded", legacy: %{"run" => run_view(graded)})
114
    end
115
  end
116
117
  defp close(conn, _run, _params) do
118
    ApiError.validation_failed(conn, %{"status" => ["must be graded or abandoned"]})
119
  end
120
38 121
  def index(conn, params) do
39 122
    with :ok <- operator(conn) do
40 123
      runs = Gym.list_runs(suite: params["suite"])

@@ -58,6 +141,7 @@ defmodule OpenAgentsWeb.GymRunController do

58 141
      "agent_version" => run.agent_version,
59 142
      "model" => run.model,
60 143
      "lane" => run.lane,
144
      "status" => run.status,
61 145
      "tasks_total" => run.tasks_total,
62 146
      "tasks_passed" => run.tasks_passed,
63 147
      "score" => Run.score(run),

@@ -66,7 +150,20 @@ defmodule OpenAgentsWeb.GymRunController do

66 150
      "cost_microusd" => run.cost_microusd,
67 151
      "duration_seconds" => run.duration_seconds,
68 152
      "recipe_digest" => run.recipe_digest,
69
      "recorded_at" => run.inserted_at
153
      "recorded_at" => run.inserted_at,
154
      "completed_at" => run.completed_at
155
    }
156
  end
157
158
  defp trial_view(%Trial{} = trial) do
159
    %{
160
      "id" => trial.id,
161
      "run_id" => trial.run_id,
162
      "task" => trial.task,
163
      "state" => trial.state,
164
      "thread_id" => trial.thread_id,
165
      "recorded_at" => trial.inserted_at,
166
      "updated_at" => trial.updated_at
70 167
    }
71 168
  end
72 169
end
lib/openagents_web/live/gym_live.ex modified +7 -1

@@ -64,6 +64,12 @@ defmodule OpenAgentsWeb.GymLive do

64 64
    if(rest == 0, do: "#{minutes}m", else: "#{minutes}m #{rest}s")
65 65
  end
66 66
67
  # A run still `running` (or swept to `abandoned`) has no grades yet.
68
  defp counts(passed, total) when is_integer(passed) and is_integer(total),
69
    do: "#{passed}/#{total}"
70
71
  defp counts(_passed, _total), do: "—"
72
67 73
  defp tokens(nil, nil), do: "—"
68 74
  defp tokens(input, output), do: "#{format_count(input)} in / #{format_count(output)} out"
69 75

@@ -137,7 +143,7 @@ defmodule OpenAgentsWeb.GymLive do

137 143
                <td class="font-mono text-sm">{run.model}</td>
138 144
                <td>{run.lane || "—"}</td>
139 145
                <td class="font-semibold">{percent(Run.score(run))}</td>
140
                <td>{run.tasks_passed}/{run.tasks_total}</td>
146
                <td>{counts(run.tasks_passed, run.tasks_total)}</td>
141 147
                <td class="whitespace-nowrap">{elapsed(run.duration_seconds)}</td>
142 148
                <td class="whitespace-nowrap text-sm">
143 149
                  {tokens(run.input_tokens, run.output_tokens)}
lib/openagents_web/router.ex modified +6 -1

@@ -518,8 +518,13 @@ defmodule OpenAgentsWeb.Router do

518 518
    # The Gym's ingest and read: an ordinary forge:write bearer carries the
519 519
    # request, and the controller rechecks live operator standing on every
520 520
    # call — the fleet-promotion shape without a privileged scope, because
521
    # recording a benchmark row moves no money and deploys nothing.
521
    # recording a benchmark row moves no money and deploys nothing. The
522
    # lifecycle routes (start, trial upsert, finalize) share the exact same
523
    # posture.
522 524
    post "/gym/runs", GymRunController, :create
525
    post "/gym/runs/start", GymRunController, :start
526
    post "/gym/runs/:id/trials", GymRunController, :create_trial
527
    patch "/gym/runs/:id", GymRunController, :update
523 528
    get "/gym/runs", GymRunController, :index
524 529
  end
525 530
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -300,7 +300,8 @@

300 300
    20260825024500,
301 301
    20260825054906,
302 302
    20260825101934,
303
    20260825102408
303
    20260825102408,
304
    20260825113459
304 305
  ],
305 306
  "required_tables": [
306 307
    "users",
priv/repo/migrations/20260825113459_add_gym_run_lifecycle.exs added +77

@@ -0,0 +1,77 @@

1
defmodule OpenAgents.Repo.Migrations.AddGymRunLifecycle do
2
  use Ecto.Migration
3
4
  def up do
5
    # A run now exists while it runs. `status` defaults to `graded` so every
6
    # legacy row — recorded only after the fact — keeps the meaning it had,
7
    # and `updated_at`/`completed_at` are backfilled from `inserted_at` for
8
    # the same reason: a one-shot row was complete the moment it was written.
9
    alter table(:gym_runs) do
10
      add :status, :text, null: false, default: "graded"
11
      add :completed_at, :utc_datetime_usec
12
      add :updated_at, :utc_datetime_usec
13
    end
14
15
    execute "UPDATE gym_runs SET updated_at = inserted_at, completed_at = inserted_at"
16
17
    execute "ALTER TABLE gym_runs ALTER COLUMN updated_at SET NOT NULL"
18
19
    # A running row has no grades yet; the changeset requires them by status
20
    # and the constraint below keeps a graded row honest at the database.
21
    execute "ALTER TABLE gym_runs ALTER COLUMN tasks_total DROP NOT NULL"
22
    execute "ALTER TABLE gym_runs ALTER COLUMN tasks_passed DROP NOT NULL"
23
24
    create constraint(:gym_runs, :gym_runs_status_check,
25
             check: "status IN ('running', 'graded', 'abandoned')"
26
           )
27
28
    create constraint(:gym_runs, :gym_runs_graded_shape_check,
29
             check:
30
               "status <> 'graded' OR (tasks_total IS NOT NULL AND tasks_passed IS NOT NULL AND completed_at IS NOT NULL)"
31
           )
32
33
    # The lazy staleness sweep reads exactly this slice.
34
    create index(:gym_runs, [:updated_at], where: "status = 'running'")
35
36
    create table(:gym_trials, primary_key: false) do
37
      add :id, :binary_id, primary_key: true
38
39
      add :run_id, references(:gym_runs, type: :binary_id, on_delete: :delete_all), null: false
40
41
      add :task, :text, null: false
42
      add :state, :text, null: false
43
      add :thread_id, :binary_id
44
45
      timestamps(type: :utc_datetime_usec)
46
    end
47
48
    create constraint(:gym_trials, :gym_trials_task_bound_check,
49
             check: "octet_length(task) BETWEEN 1 AND 200"
50
           )
51
52
    create constraint(:gym_trials, :gym_trials_state_check,
53
             check: "state IN ('running', 'passed', 'failed', 'ungraded')"
54
           )
55
56
    # One row per task per run: a re-reported trial updates in place.
57
    create unique_index(:gym_trials, [:run_id, :task])
58
  end
59
60
  def down do
61
    drop table(:gym_trials)
62
63
    drop index(:gym_runs, [:updated_at], where: "status = 'running'")
64
    drop constraint(:gym_runs, :gym_runs_graded_shape_check)
65
    drop constraint(:gym_runs, :gym_runs_status_check)
66
67
    execute "DELETE FROM gym_runs WHERE tasks_total IS NULL OR tasks_passed IS NULL"
68
    execute "ALTER TABLE gym_runs ALTER COLUMN tasks_total SET NOT NULL"
69
    execute "ALTER TABLE gym_runs ALTER COLUMN tasks_passed SET NOT NULL"
70
71
    alter table(:gym_runs) do
72
      remove :status
73
      remove :completed_at
74
      remove :updated_at
75
    end
76
  end
77
end
test/openagents/gym_test.exs modified +253

@@ -1,8 +1,14 @@

1 1
defmodule OpenAgents.GymTest do
2 2
  use OpenAgents.DataCase, async: true
3 3
4
  import Ecto.Query
5
  import OpenAgentsWeb.ConnCase, only: [github_user: 1]
6
4 7
  alias OpenAgents.Gym
5 8
  alias OpenAgents.Gym.Run
9
  alias OpenAgents.Gym.Trial
10
  alias OpenAgents.Repo
11
  alias OpenAgents.Threads
6 12
7 13
  defp attributes(overrides \\ %{}) do
8 14
    Map.merge(

@@ -78,4 +84,251 @@ defmodule OpenAgents.GymTest do

78 84
    assert {:error, changeset} = Gym.record_run(attributes(%{"report" => huge}))
79 85
    assert %{report: [_message]} = errors_on(changeset)
80 86
  end
87
88
  defp start_attributes(overrides \\ %{}) do
89
    Map.merge(
90
      %{
91
        "suite" => "terminal-bench@2.0",
92
        "agent" => "openagents-coder",
93
        "model" => "ox-alpha",
94
        "lane" => "proxy",
95
        "tasks_total" => 5
96
      },
97
      overrides
98
    )
99
  end
100
101
  describe "start_run/1" do
102
    test "registers a running run with a placeholder digest and broadcasts it" do
103
      :ok = Gym.subscribe()
104
105
      assert {:ok, %Run{} = run, false} = Gym.start_run(start_attributes())
106
107
      assert run.status == "running"
108
      assert run.tasks_passed == nil
109
      assert run.completed_at == nil
110
      assert String.starts_with?(run.recipe_digest, "pending:")
111
      assert Run.score(run) == nil
112
113
      run_id = run.id
114
      assert_receive {:gym_run, %Run{id: ^run_id, status: "running"}}
115
    end
116
117
    test "a resubmitted digest replays the running row" do
118
      digest = "sha256:" <> String.duplicate("f", 64)
119
120
      {:ok, first, false} = Gym.start_run(start_attributes(%{"recipe_digest" => digest}))
121
      {:ok, second, true} = Gym.start_run(start_attributes(%{"recipe_digest" => digest}))
122
123
      assert second.id == first.id
124
      assert length(Gym.list_runs()) == 1
125
    end
126
127
    test "identity is required" do
128
      assert {:error, changeset} = Gym.start_run(%{"suite" => "terminal-bench@2.0"})
129
      assert %{agent: [_agent], model: [_model]} = errors_on(changeset)
130
    end
131
  end
132
133
  describe "finalize_run/2 and abandon_run/1" do
134
    test "folds the grades in, pins the digest, and broadcasts" do
135
      {:ok, run, false} = Gym.start_run(start_attributes())
136
      :ok = Gym.subscribe_run(run.id)
137
138
      digest = "sha256:" <> String.duplicate("1", 64)
139
140
      assert {:ok, graded} =
141
               Gym.finalize_run(run, %{
142
                 "tasks_total" => 5,
143
                 "tasks_passed" => 4,
144
                 "duration_seconds" => 90,
145
                 "recipe_digest" => digest
146
               })
147
148
      assert graded.status == "graded"
149
      assert graded.recipe_digest == digest
150
      assert graded.completed_at != nil
151
      assert Run.score(graded) == 4 / 5
152
153
      run_id = run.id
154
      assert_receive {:gym_run, %Run{id: ^run_id, status: "graded"}}
155
    end
156
157
    test "a graded run refuses a second finalize and an abandonment" do
158
      {:ok, run, false} = Gym.record_run(attributes())
159
160
      assert {:error, :already_graded, ^run} =
161
               Gym.finalize_run(run, %{"tasks_total" => 1, "tasks_passed" => 1})
162
163
      assert {:error, :already_graded, ^run} = Gym.abandon_run(run)
164
    end
165
166
    test "a digest that names another run is a conflict carrying that run" do
167
      {:ok, existing, false} = Gym.record_run(attributes())
168
      {:ok, run, false} = Gym.start_run(start_attributes())
169
170
      assert {:error, :digest_conflict, conflicting} =
171
               Gym.finalize_run(run, %{
172
                 "tasks_total" => 5,
173
                 "tasks_passed" => 5,
174
                 "recipe_digest" => existing.recipe_digest
175
               })
176
177
      assert conflicting.id == existing.id
178
    end
179
180
    test "abandoning a running run is terminal, broadcast, and idempotent" do
181
      {:ok, run, false} = Gym.start_run(start_attributes())
182
      :ok = Gym.subscribe()
183
184
      assert {:ok, abandoned} = Gym.abandon_run(run)
185
      assert abandoned.status == "abandoned"
186
      assert abandoned.completed_at != nil
187
188
      run_id = run.id
189
      assert_receive {:gym_run, %Run{id: ^run_id, status: "abandoned"}}
190
191
      assert {:ok, %Run{status: "abandoned"}} = Gym.abandon_run(abandoned)
192
    end
193
  end
194
195
  describe "record_trial/3" do
196
    defp running_run do
197
      {:ok, run, false} = Gym.start_run(start_attributes())
198
      run
199
    end
200
201
    test "upserts by task and broadcasts each report" do
202
      bearer = github_user("gym-trial-bearer")
203
      run = running_run()
204
      :ok = Gym.subscribe_run(run.id)
205
206
      assert {:ok, trial} =
207
               Gym.record_trial(bearer, run, %{"task" => "hello-world", "state" => "running"})
208
209
      assert trial.state == "running"
210
211
      assert {:ok, updated} =
212
               Gym.record_trial(bearer, run, %{"task" => "hello-world", "state" => "passed"})
213
214
      assert updated.id == trial.id
215
      assert updated.state == "passed"
216
      assert Gym.list_trials(run) |> length() == 1
217
218
      trial_id = trial.id
219
      assert_receive {:gym_trial, %Trial{id: ^trial_id, state: "running"}}
220
      assert_receive {:gym_trial, %Trial{id: ^trial_id, state: "passed"}}
221
    end
222
223
    test "a report that omits the thread keeps an existing link" do
224
      bearer = github_user("gym-trial-keeper")
225
      {:ok, thread} = Threads.open(bearer, "Run the hello-world trial")
226
      run = running_run()
227
228
      {:ok, linked} =
229
        Gym.record_trial(bearer, run, %{
230
          "task" => "hello-world",
231
          "state" => "running",
232
          "thread_id" => thread.id
233
        })
234
235
      assert linked.thread_id == thread.id
236
237
      {:ok, graded} =
238
        Gym.record_trial(bearer, run, %{"task" => "hello-world", "state" => "passed"})
239
240
      assert graded.thread_id == thread.id
241
    end
242
243
    test "an unknown thread and an unowned one refuse identically" do
244
      bearer = github_user("gym-trial-mine")
245
      stranger = github_user("gym-trial-theirs")
246
      {:ok, foreign} = Threads.open(stranger, "Somebody else's trial")
247
      run = running_run()
248
249
      assert {:error, unknown} =
250
               Gym.record_trial(bearer, run, %{
251
                 "task" => "a",
252
                 "state" => "running",
253
                 "thread_id" => Ecto.UUID.generate()
254
               })
255
256
      assert {:error, unowned} =
257
               Gym.record_trial(bearer, run, %{
258
                 "task" => "a",
259
                 "state" => "running",
260
                 "thread_id" => foreign.id
261
               })
262
263
      assert errors_on(unknown)[:thread_id] == errors_on(unowned)[:thread_id]
264
      assert Gym.list_trials(run) == []
265
    end
266
267
    test "trials per run are bounded" do
268
      bearer = github_user("gym-trial-bound")
269
      run = running_run()
270
      now = DateTime.utc_now()
271
272
      rows =
273
        for index <- 1..Gym.maximum_trials_per_run() do
274
          %{
275
            id: Ecto.UUID.generate(),
276
            run_id: run.id,
277
            task: "task-#{index}",
278
            state: "ungraded",
279
            inserted_at: now,
280
            updated_at: now
281
          }
282
        end
283
284
      Repo.insert_all(Trial, rows)
285
286
      assert {:error, :trial_limit} =
287
               Gym.record_trial(bearer, run, %{"task" => "one-too-many", "state" => "running"})
288
289
      # A task the run already holds still updates under the bound.
290
      assert {:ok, %Trial{state: "passed"}} =
291
               Gym.record_trial(bearer, run, %{"task" => "task-1", "state" => "passed"})
292
    end
293
  end
294
295
  describe "staleness" do
296
    test "a running run with no update for six hours is swept on read" do
297
      {:ok, run, false} = Gym.start_run(start_attributes())
298
      :ok = Gym.subscribe()
299
300
      stale = DateTime.add(DateTime.utc_now(), -7 * 60 * 60, :second)
301
302
      from(r in Run, where: r.id == ^run.id)
303
      |> Repo.update_all(set: [updated_at: stale])
304
305
      assert [%Run{status: "abandoned", completed_at: completed_at}] = Gym.list_runs()
306
      assert completed_at != nil
307
308
      run_id = run.id
309
      assert_receive {:gym_run, %Run{id: ^run_id, status: "abandoned"}}
310
    end
311
312
    test "a freshly reporting run is not swept" do
313
      {:ok, run, false} = Gym.start_run(start_attributes())
314
315
      assert {:ok, %Run{status: "running"}} = Gym.fetch_run(run.id)
316
    end
317
  end
318
319
  describe "fetch_run/1" do
320
    test "loads a run with its trials in task order" do
321
      bearer = github_user("gym-fetch-bearer")
322
      run = running_run()
323
324
      {:ok, _b} = Gym.record_trial(bearer, run, %{"task" => "b-task", "state" => "passed"})
325
      {:ok, _a} = Gym.record_trial(bearer, run, %{"task" => "a-task", "state" => "failed"})
326
327
      assert {:ok, %Run{trials: [%Trial{task: "a-task"}, %Trial{task: "b-task"}]}} =
328
               Gym.fetch_run(run.id)
329
330
      assert Gym.fetch_run(Ecto.UUID.generate()) == :error
331
      assert Gym.fetch_run("not-a-uuid") == :error
332
    end
333
  end
81 334
end
test/openagents/threads/grant_token_reach_test.exs modified +4 -2

@@ -94,8 +94,10 @@ defmodule OpenAgents.Threads.GrantTokenReachTest do

94 94
  # its authority resolves through the owner-scoped lookup, so widening a
95 95
  # transcript for reading can never widen what may be done to it. The one
96 96
  # controller serves both, because it serves both the reads and the writes;
97
  # the web viewer only reads.
98
  @owner_resolver_callers [OpenAgentsWeb.ThreadController]
97
  # the web viewer only reads. The Gym resolves owner-scoped to verify a
98
  # trial's claimed thread belongs to the bearer before linking it (#241);
99
  # it never writes to the thread or mints for it.
100
  @owner_resolver_callers [OpenAgents.Gym, OpenAgentsWeb.ThreadController]
99 101
  @tier_resolver_callers [OpenAgentsWeb.ThreadController, OpenAgentsWeb.ThreadShowLive]
100 102
101 103
  test "the modules that mint a grant token are exactly the set THREAD-001 accounts for" do
test/openagents_web/controllers/gym_run_controller_test.exs modified +268

@@ -98,4 +98,272 @@ defmodule OpenAgentsWeb.GymRunControllerTest do

98 98
99 99
    assert refused["code"] == "not_operator"
100 100
  end
101
102
  defp start_payload(overrides \\ %{}) do
103
    Map.merge(
104
      %{
105
        "suite" => "terminal-bench@2.0",
106
        "agent" => "openagents-coder",
107
        "model" => "ox-alpha",
108
        "lane" => "proxy",
109
        "tasks_total" => 5
110
      },
111
      overrides
112
    )
113
  end
114
115
  defp start_run(authenticated, overrides \\ %{}) do
116
    authenticated
117
    |> post(~p"/api/v1/gym/runs/start", start_payload(overrides))
118
    |> json_response(201)
119
    |> Map.fetch!("run")
120
  end
121
122
  describe "POST /api/v1/gym/runs/start" do
123
    test "registers a running run and a digest retry replays it", %{conn: conn} do
124
      authenticated = operator_token(conn, "gym-start")
125
126
      started =
127
        authenticated
128
        |> post(~p"/api/v1/gym/runs/start", start_payload())
129
        |> json_response(201)
130
131
      assert started["replayed"] == false
132
      assert started["run"]["status"] == "running"
133
      assert started["run"]["id"]
134
      assert started["run"]["tasks_passed"] == nil
135
      assert started["run"]["score"] == nil
136
      assert String.starts_with?(started["run"]["recipe_digest"], "pending:")
137
138
      digest = "sha256:" <> String.duplicate("a", 64)
139
140
      _first =
141
        authenticated
142
        |> post(~p"/api/v1/gym/runs/start", start_payload(%{"recipe_digest" => digest}))
143
        |> json_response(201)
144
145
      replayed =
146
        authenticated
147
        |> post(~p"/api/v1/gym/runs/start", start_payload(%{"recipe_digest" => digest}))
148
        |> json_response(200)
149
150
      assert replayed["replayed"] == true
151
    end
152
153
    test "identity is required", %{conn: conn} do
154
      refused =
155
        conn
156
        |> operator_token("gym-start-invalid")
157
        |> post(~p"/api/v1/gym/runs/start", %{"suite" => "terminal-bench@2.0"})
158
        |> json_response(422)
159
160
      assert refused["errors"]["agent"]
161
      assert refused["errors"]["model"]
162
    end
163
164
    test "an ordinary forge:write token is refused", %{conn: conn} do
165
      refused =
166
        conn
167
        |> put_forge_api_token("gym-start-ordinary")
168
        |> post(~p"/api/v1/gym/runs/start", start_payload())
169
        |> json_response(403)
170
171
      assert refused["code"] == "not_operator"
172
      assert Gym.list_runs() == []
173
    end
174
  end
175
176
  describe "POST /api/v1/gym/runs/:id/trials" do
177
    test "upserts a trial and links the bearer's own thread", %{conn: conn} do
178
      authenticated = operator_token(conn, "gym-trials")
179
      bearer = github_user("api-token-gym-trials")
180
      {:ok, thread} = OpenAgents.Threads.open(bearer, "Run the hello-world trial")
181
182
      run = start_run(authenticated)
183
184
      reported =
185
        authenticated
186
        |> post(~p"/api/v1/gym/runs/#{run["id"]}/trials", %{
187
          "task" => "hello-world",
188
          "state" => "running",
189
          "thread_id" => thread.id
190
        })
191
        |> json_response(200)
192
193
      assert reported["trial"]["task"] == "hello-world"
194
      assert reported["trial"]["state"] == "running"
195
      assert reported["trial"]["thread_id"] == thread.id
196
197
      graded =
198
        authenticated
199
        |> post(~p"/api/v1/gym/runs/#{run["id"]}/trials", %{
200
          "task" => "hello-world",
201
          "state" => "passed"
202
        })
203
        |> json_response(200)
204
205
      assert graded["trial"]["id"] == reported["trial"]["id"]
206
      assert graded["trial"]["state"] == "passed"
207
      assert graded["trial"]["thread_id"] == thread.id
208
    end
209
210
    test "an unknown thread and an unowned one refuse identically", %{conn: conn} do
211
      authenticated = operator_token(conn, "gym-trials-refuse")
212
      stranger = github_user("gym-trials-stranger")
213
      {:ok, foreign} = OpenAgents.Threads.open(stranger, "Somebody else's trial")
214
215
      run = start_run(authenticated)
216
217
      unknown =
218
        authenticated
219
        |> post(~p"/api/v1/gym/runs/#{run["id"]}/trials", %{
220
          "task" => "a",
221
          "state" => "running",
222
          "thread_id" => Ecto.UUID.generate()
223
        })
224
        |> json_response(422)
225
226
      unowned =
227
        authenticated
228
        |> post(~p"/api/v1/gym/runs/#{run["id"]}/trials", %{
229
          "task" => "a",
230
          "state" => "running",
231
          "thread_id" => foreign.id
232
        })
233
        |> json_response(422)
234
235
      assert unknown["errors"]["thread_id"] == unowned["errors"]["thread_id"]
236
    end
237
238
    test "an unknown run is not found", %{conn: conn} do
239
      authenticated = operator_token(conn, "gym-trials-missing")
240
241
      refused =
242
        authenticated
243
        |> post(~p"/api/v1/gym/runs/#{Ecto.UUID.generate()}/trials", %{
244
          "task" => "a",
245
          "state" => "running"
246
        })
247
        |> json_response(404)
248
249
      assert refused["code"] == "not_found"
250
    end
251
252
    test "an ordinary forge:write token is refused", %{conn: conn} do
253
      refused =
254
        conn
255
        |> put_forge_api_token("gym-trials-ordinary")
256
        |> post(~p"/api/v1/gym/runs/#{Ecto.UUID.generate()}/trials", %{
257
          "task" => "a",
258
          "state" => "running"
259
        })
260
        |> json_response(403)
261
262
      assert refused["code"] == "not_operator"
263
    end
264
  end
265
266
  describe "PATCH /api/v1/gym/runs/:id" do
267
    test "finalizes with the grades and refuses a second grade", %{conn: conn} do
268
      authenticated = operator_token(conn, "gym-finalize")
269
      run = start_run(authenticated)
270
      digest = "sha256:" <> String.duplicate("b", 64)
271
272
      graded =
273
        authenticated
274
        |> patch(~p"/api/v1/gym/runs/#{run["id"]}", %{
275
          "status" => "graded",
276
          "tasks_total" => 5,
277
          "tasks_passed" => 4,
278
          "duration_seconds" => 90,
279
          "recipe_digest" => digest
280
        })
281
        |> json_response(200)
282
283
      assert graded["run"]["status"] == "graded"
284
      assert graded["run"]["score"] == 0.8
285
      assert graded["run"]["recipe_digest"] == digest
286
      assert graded["run"]["completed_at"]
287
288
      refused =
289
        authenticated
290
        |> patch(~p"/api/v1/gym/runs/#{run["id"]}", %{
291
          "status" => "graded",
292
          "tasks_total" => 5,
293
          "tasks_passed" => 5
294
        })
295
        |> json_response(409)
296
297
      assert refused["code"] == "run_already_graded"
298
      assert refused["run"]["id"] == run["id"]
299
    end
300
301
    test "a digest that names another run conflicts with that run in the body", %{conn: conn} do
302
      authenticated = operator_token(conn, "gym-conflict")
303
304
      existing =
305
        authenticated
306
        |> post(~p"/api/v1/gym/runs", payload())
307
        |> json_response(201)
308
        |> Map.fetch!("run")
309
310
      run = start_run(authenticated)
311
312
      refused =
313
        authenticated
314
        |> patch(~p"/api/v1/gym/runs/#{run["id"]}", %{
315
          "status" => "graded",
316
          "tasks_total" => 5,
317
          "tasks_passed" => 5,
318
          "recipe_digest" => existing["recipe_digest"]
319
        })
320
        |> json_response(409)
321
322
      assert refused["code"] == "recipe_digest_conflict"
323
      assert refused["run"]["id"] == existing["id"]
324
    end
325
326
    test "abandons a run without grades", %{conn: conn} do
327
      authenticated = operator_token(conn, "gym-abandon")
328
      run = start_run(authenticated)
329
330
      abandoned =
331
        authenticated
332
        |> patch(~p"/api/v1/gym/runs/#{run["id"]}", %{"status" => "abandoned"})
333
        |> json_response(200)
334
335
      assert abandoned["run"]["status"] == "abandoned"
336
      assert abandoned["run"]["tasks_passed"] == nil
337
    end
338
339
    test "an unknown run and an unknown status refuse", %{conn: conn} do
340
      authenticated = operator_token(conn, "gym-patch-refusals")
341
342
      missing =
343
        authenticated
344
        |> patch(~p"/api/v1/gym/runs/#{Ecto.UUID.generate()}", %{"status" => "abandoned"})
345
        |> json_response(404)
346
347
      assert missing["code"] == "not_found"
348
349
      run = start_run(authenticated)
350
351
      sideways =
352
        authenticated
353
        |> patch(~p"/api/v1/gym/runs/#{run["id"]}", %{"status" => "sideways"})
354
        |> json_response(422)
355
356
      assert sideways["errors"]["status"]
357
    end
358
359
    test "an ordinary forge:write token is refused", %{conn: conn} do
360
      refused =
361
        conn
362
        |> put_forge_api_token("gym-patch-ordinary")
363
        |> patch(~p"/api/v1/gym/runs/#{Ecto.UUID.generate()}", %{"status" => "abandoned"})
364
        |> json_response(403)
365
366
      assert refused["code"] == "not_operator"
367
    end
368
  end
101 369
end

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