| 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
|