Register the Gym run at start and report each trial's thread

d0afa03f9f3c · AtlantisPleb · · parent ebbed207b783

Register the Gym run at start and report each trial's thread

bench/run-suite.sh ran a suite and posted one summary row after everything
finished, so /gym could not show a run until it was over (#38). Now the
runner registers the run against the lifecycle API (openagents.com#241)
before Harbor starts — POST /api/v1/gym/runs/start with the suite, agent,
catalog model, lane, and planned task count — and exports
OPENAGENTS_GYM_RUN_ID plus the host-side OPENAGENTS_GYM_API_URL into the
Harbor run.

The adapter reports each trial from the host side: state `running` at
agent-phase start, then again with the thread id parsed from the coder's
captured output with `\[oa:thread ([0-9a-fA-F-]{36})\]` — the line the
coder prints in --plain mode on both lanes (#39). Reporting never fails a
trial; a refused or unreachable Gym is logged and the trial continues.

post_gym_run.py becomes the finalize step when a run id exists (--run-id,
or OPENAGENTS_GYM_RUN_ID): it upserts each trial's final state — passed,
failed, or ungraded where the verifier never ran — and patches the run to
graded, carrying the digest, totals, and report. A run no verifier graded
is patched abandoned rather than left running, a 409 on the PATCH is a
replay and reported as success, and without a run id the one-shot
POST /api/v3/gym/runs path is unchanged. Suite failure paths in
run-suite.sh patch the run to abandoned; without a token (ollama dry
runs) registration is skipped and everything degrades to the post-hoc
path.

Verified against a stub Gym server: registration payload, env export into
the Harbor process, abandon on Harbor failure, trial upserts with states
and thread ids, the abandon-on-ungraded and 409-replay finalize paths, and
the unchanged legacy one-shot path. Dry runs print the new calls.

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 bench/README.md
  • modified bench/adapters/openagents_coder.py
  • modified bench/post_gym_run.py
  • modified bench/run-suite.sh

Diff

4 files changed, +399 -46

bench/README.md modified +36 -4

@@ -24,9 +24,13 @@ PYTHONPATH=bench OPENAGENTS_TOKEN=... harbor run \

24 24
  -i fix-git --n-concurrent 1 --jobs-dir /tmp/gym-jobs
25 25
```
26 26
27
`bench/post_gym_run.py` posts a completed job's graded result to
28
`POST /api/v3/gym/runs`. Post only runs whose verifier actually ran: a
29
score is a claim, and a crashed grader is not a grade.
27
`bench/post_gym_run.py` posts a completed job's graded result to the Gym.
28
Without a run id it posts one summary row to `POST /api/v3/gym/runs`. With
29
`--run-id` (or `OPENAGENTS_GYM_RUN_ID` in the environment) it finalizes a
30
run that `run-suite.sh` registered against the lifecycle API: it upserts
31
each trial's final state and patches the run to `graded`, or to `abandoned`
32
when no verifier ran. Post only runs whose verifier actually ran: a score
33
is a claim, and a crashed grader is not a grade.
30 34
31 35
If the dev server binds loopback only (another session started it
32 36
without `PHX_LISTEN_ALL`), bridge instead of fighting over the port: run

@@ -42,7 +46,8 @@ for scored runs. The agent phase itself runs fine under qemu.

42 46
43 47
## Suite runner
44 48
45
`bench/run-suite.sh` runs a suite file through Harbor and posts the result.
49
`bench/run-suite.sh` runs a suite file through Harbor and reports the run
50
to the Gym.
46 51
47 52
```sh
48 53
bench/run-suite.sh <suite-file> --model <harbor-model> [options]

@@ -61,3 +66,30 @@ bench/run-suite.sh bench/suites/tb2-cross-section.txt \

61 66
Options include `--lane`, `--api-url`, `--jobs-dir`, `--n-concurrent`,
62 67
`--timeout-multiplier`, and `--dry-run`. Set `OPENAGENTS_TOKEN` unless the
63 68
model starts with `ollama/`.
69
70
### Run lifecycle
71
72
When you set `OPENAGENTS_TOKEN`, the runner registers the run with
73
`POST /api/v1/gym/runs/start` before Harbor starts, so `/gym` shows the run
74
while its trials are still executing. The runner exports
75
`OPENAGENTS_GYM_RUN_ID` and `OPENAGENTS_GYM_API_URL` — the host-side API
76
URL, not the container-rewritten one — into the Harbor run, and the adapter
77
reports each trial to `POST /api/v1/gym/runs/{id}/trials` from the host:
78
state `running` when the agent phase starts, then again with the thread id
79
it parses from the coder's `[oa:thread <uuid>]` line in the trial's
80
`coder.txt`. The coder prints that line in `--plain` mode on both lanes
81
when it has a server thread; a trial whose coder ran without one is
82
registered and graded without linkage. A failed trial report never fails
83
the trial.
84
85
After grading, the runner finalizes through `post_gym_run.py --run-id`,
86
which upserts each trial's final state — `passed`, `failed`, or `ungraded`
87
when the verifier never ran — and patches the run to `graded`. When no
88
verifier ran at all, it patches the run to `abandoned` instead: a crashed
89
grader is not a grade, but it must not stay a forever-running row. When the
90
suite fails before grading, `run-suite.sh` patches the run to `abandoned`.
91
92
Without a token, for example an `ollama/...` dry run, the runner skips
93
registration and the trials run without live reporting. If registration
94
fails, the suite still runs and the post-hoc one-shot path posts the graded
95
result as before.
bench/adapters/openagents_coder.py modified +77 -5

@@ -22,10 +22,22 @@ The CLI installs from a tarball packed beside this file (`npm pack` in

22 22
published npm version. The trajectory is the coder's own ATIF export: the
23 23
run pipes `/export` after the instruction and copies the newest export to
24 24
the trial's `trajectory.json`.
25
26
When `OPENAGENTS_GYM_RUN_ID` is set (`bench/run-suite.sh` registers the run
27
against the Gym lifecycle API, OpenAgentsInc/openagents#38), the adapter
28
reports each trial to the run from the host side: state `running` at
29
agent-phase start, and again after the agent phase with the thread id parsed
30
from the captured coder output (`coder.txt`, the `[oa:thread <uuid>]` line
31
the coder prints in `--plain` mode). Reporting never fails a trial: a
32
refused or unreachable Gym is logged and the trial continues.
25 33
"""
26 34
35
import json
27 36
import os
37
import re
28 38
import shlex
39
import sys
40
import urllib.request
29 41
from pathlib import Path
30 42
from typing import override
31 43

@@ -56,6 +68,10 @@ _REMOTE_TARBALL = "/installed-agent/openagents-cli.tgz"

56 68
_DEFAULT_API_URL = "http://host.docker.internal:4000"
57 69
_EXPORT_DIR = "$HOME/.openagents/exports"
58 70
71
# The coder's --plain thread announcement (OpenAgentsInc/openagents#39). The
72
# format is a contract between the CLI and this adapter; do not loosen it.
73
_THREAD_LINE = re.compile(r"\[oa:thread ([0-9a-fA-F-]{36})\]")
74
59 75
60 76
class OpenAgentsCoder(BaseInstalledAgent):
61 77
    SUPPORTS_ATIF = True

@@ -100,6 +116,53 @@ class OpenAgentsCoder(BaseInstalledAgent):

100 116
    def _local_lane(self) -> bool:
101 117
        return bool(self.model_name) and self.model_name.startswith("ollama/")
102 118
119
    @property
120
    def _task_name(self) -> str:
121
        # `logs_dir` is the host-side `<job>/<trial>/agent` directory, and
122
        # Harbor names the trial directory `<task>__<shortuuid>`. The task
123
        # half is the upsert key the run's trials endpoint keeps per task.
124
        trial = Path(self.logs_dir).parent.name
125
        return trial.rsplit("__", 1)[0] or trial
126
127
    def _thread_id_from_log(self) -> str | None:
128
        """The thread id the coder announced, or None when it ran offline."""
129
        try:
130
            text = (Path(self.logs_dir) / "coder.txt").read_text(errors="replace")
131
        except OSError:
132
            return None
133
        match = _THREAD_LINE.search(text)
134
        return match.group(1) if match else None
135
136
    def _report_trial(self, state: str, thread_id: str | None = None) -> None:
137
        """Report this trial to the registered Gym run, from the host side.
138
139
        `run()` executes on the host, so the POST goes to the host-side
140
        `OPENAGENTS_GYM_API_URL` on `OPENAGENTS_TOKEN`. Reporting must never
141
        fail the trial: every failure is logged and swallowed.
142
        """
143
        run_id = os.environ.get("OPENAGENTS_GYM_RUN_ID", "")
144
        api_url = os.environ.get("OPENAGENTS_GYM_API_URL", "")
145
        token = os.environ.get("OPENAGENTS_TOKEN", "")
146
        if not run_id or not api_url or not token:
147
            return
148
        payload: dict[str, str] = {"task": self._task_name, "state": state}
149
        if thread_id:
150
            payload["thread_id"] = thread_id
151
        request = urllib.request.Request(
152
            f"{api_url}/api/v1/gym/runs/{run_id}/trials",
153
            data=json.dumps(payload).encode(),
154
            headers={
155
                "Content-Type": "application/json",
156
                "Authorization": f"Bearer {token}",
157
            },
158
            method="POST",
159
        )
160
        try:
161
            with urllib.request.urlopen(request, timeout=10):
162
                pass
163
        except Exception as error:  # noqa: BLE001 - reporting never fails the trial
164
            print(f"gym trial report failed for {self._task_name}: {error}", file=sys.stderr)
165
103 166
    @override
104 167
    async def install(self, environment: BaseEnvironment) -> None:
105 168
        if not _TARBALL.exists():

@@ -170,8 +233,17 @@ class OpenAgentsCoder(BaseInstalledAgent):

170 233
                "OPENAGENTS_CODER_OLLAMA_HOST", "http://host.docker.internal:11434"
171 234
            )
172 235
173
        await self.exec_as_agent(
174
            environment,
175
            command=command,
176
            env=env,
177
        )
236
        # The trial exists before the agent phase does anything, and after it
237
        # the coder's captured output names the thread the session opened —
238
        # on both lanes now that the local lane reports too (#39). The second
239
        # report runs in `finally` so a failed agent phase still links its
240
        # trial before the exception continues to Harbor.
241
        self._report_trial("running")
242
        try:
243
            await self.exec_as_agent(
244
                environment,
245
                command=command,
246
                env=env,
247
            )
248
        finally:
249
            self._report_trial("running", self._thread_id_from_log())
bench/post_gym_run.py modified +143 -31

@@ -3,22 +3,45 @@

3 3
Usage:
4 4
5 5
    OPENAGENTS_TOKEN=... python3 bench/post_gym_run.py \
6
      <job-dir> [--api-url http://localhost:4000] [--lane proxy]
6
      <job-dir> [--api-url http://localhost:4000] [--lane proxy] \
7
      [--run-id <uuid>]
7 8
8 9
Reads the job's `result.json` and each trial's ATIF `trajectory.json` (for
9 10
token totals), derives the recipe digest from the job's `config.json` plus
10
the agent tarball digest, and POSTs to `/api/v3/gym/runs`. Idempotent: the
11
server replays a repeated digest rather than duplicating the row.
11
the agent tarball digest, and posts to the Gym.
12
13
With `--run-id` (or `OPENAGENTS_GYM_RUN_ID` in the environment) this is the
14
finalize step of a run `bench/run-suite.sh` registered against the lifecycle
15
API (OpenAgentsInc/openagents#38): each trial's final state is upserted to
16
`POST /api/v1/gym/runs/{id}/trials` — `passed` or `failed` where the
17
verifier ran, `ungraded` where it never did — and the run is closed with
18
`PATCH /api/v1/gym/runs/{id}`. A run whose verifier never ran on any trial
19
is patched `abandoned`: a crashed grader is not a grade, but it should not
20
be a forever-running row either. A 409 on the PATCH means another run
21
already holds the digest; that is a replay, reported and treated as success.
22
23
Without a run id, this keeps its original shape: one POST to
24
`/api/v3/gym/runs`, idempotent by recipe digest. Post only runs whose
25
verifier actually ran: a score is a claim, and a crashed grader is not a
26
grade.
12 27
"""
13 28
29
from __future__ import annotations
30
14 31
import argparse
15 32
import hashlib
16 33
import json
17 34
import os
35
import re
18 36
import sys
37
import urllib.error
19 38
import urllib.request
20 39
from pathlib import Path
21 40
41
# The coder's --plain thread announcement (OpenAgentsInc/openagents#39),
42
# the same contract the adapter parses live.
43
_THREAD_LINE = re.compile(r"\[oa:thread ([0-9a-fA-F-]{36})\]")
44
22 45
23 46
def sha256_file(path: Path) -> str:
24 47
    digest = hashlib.sha256()

@@ -28,12 +51,43 @@ def sha256_file(path: Path) -> str:

28 51
    return digest.hexdigest()
29 52
30 53
54
def request_json(url: str, token: str, payload: dict, method: str) -> tuple[int, dict]:
55
    request = urllib.request.Request(
56
        url,
57
        data=json.dumps(payload).encode(),
58
        headers={
59
            "Content-Type": "application/json",
60
            "Authorization": f"Bearer {token}",
61
        },
62
        method=method,
63
    )
64
    with urllib.request.urlopen(request) as response:
65
        return response.status, json.loads(response.read() or b"{}")
66
67
68
def thread_id_of(trial_dir: Path) -> str | None:
69
    """The thread id the coder announced in this trial's captured output."""
70
    coder_log = trial_dir / "agent" / "coder.txt"
71
    try:
72
        text = coder_log.read_text(errors="replace")
73
    except OSError:
74
        return None
75
    match = _THREAD_LINE.search(text)
76
    return match.group(1) if match else None
77
78
31 79
def main() -> int:
32 80
    parser = argparse.ArgumentParser()
33 81
    parser.add_argument("job_dir", type=Path)
34 82
    parser.add_argument("--api-url", default="http://localhost:4000")
35 83
    parser.add_argument("--lane", default="proxy")
36 84
    parser.add_argument("--suite", default="terminal-bench@2.0")
85
    parser.add_argument(
86
        "--run-id",
87
        default=os.environ.get("OPENAGENTS_GYM_RUN_ID") or None,
88
        help="Finalize this registered Gym run instead of the one-shot POST. "
89
        "Defaults to OPENAGENTS_GYM_RUN_ID when set.",
90
    )
37 91
    arguments = parser.parse_args()
38 92
39 93
    token = os.environ.get("OPENAGENTS_TOKEN", "")

@@ -46,6 +100,7 @@ def main() -> int:

46 100
    config = json.loads((job_dir / "config.json").read_text())
47 101
48 102
    trials = []
103
    trial_entries = []
49 104
    input_tokens = 0
50 105
    output_tokens = 0
51 106
    duration_seconds = 0.0

@@ -57,6 +112,7 @@ def main() -> int:

57 112
            continue
58 113
        trial = json.loads(trial_result.read_text())
59 114
        trials.append(trial)
115
        trial_entries.append((trial, trial_dir))
60 116
        execution = trial.get("agent_execution") or {}
61 117
        started = execution.get("started_at")
62 118
        finished = execution.get("finished_at")

@@ -75,6 +131,9 @@ def main() -> int:

75 131
            agent_version = agent.get("version") or agent_version
76 132
            model = agent.get("model_name") or model
77 133
134
    def verifier_ran(trial: dict) -> bool:
135
        return trial.get("verifier_result") is not None
136
78 137
    def passed(trial: dict) -> bool:
79 138
        verifier = trial.get("verifier_result") or {}
80 139
        rewards = verifier.get("rewards") or trial.get("rewards") or {}

@@ -83,6 +142,15 @@ def main() -> int:

83 142
            reward = next(iter(rewards.values()))
84 143
        return bool(reward) and float(reward) > 0
85 144
145
    def task_name(trial: dict) -> str:
146
        return trial.get("task_name") or trial.get("trial_name") or "?"
147
148
    def task_key(trial_dir: Path) -> str:
149
        # The same key the adapter reports live: the task half of Harbor's
150
        # `<task>__<shortuuid>` trial directory name, so the finalize upserts
151
        # the rows the live reports created rather than writing new ones.
152
        return trial_dir.name.rsplit("__", 1)[0] or trial_dir.name
153
86 154
    tasks_total = len(trials)
87 155
    tasks_passed = sum(1 for trial in trials if passed(trial))
88 156

@@ -97,6 +165,72 @@ def main() -> int:

97 165
    ).encode()
98 166
    recipe_digest = "harbor:" + hashlib.sha256(recipe_source).hexdigest()
99 167
168
    report = {
169
        "job_id": result.get("id"),
170
        "trials": [
171
            {
172
                "task": task_name(trial),
173
                "passed": passed(trial),
174
                "exception": (trial.get("exception_info") or {}).get("exception_type")
175
                if trial.get("exception_info")
176
                else None,
177
            }
178
            for trial in trials
179
        ],
180
    }
181
182
    if arguments.run_id:
183
        run_url = f"{arguments.api_url}/api/v1/gym/runs/{arguments.run_id}"
184
185
        # Each trial's final state, upserted by task. A trial whose verifier
186
        # never ran stays a claimless `ungraded` rather than a grade.
187
        for trial, trial_dir in trial_entries:
188
            state = ("passed" if passed(trial) else "failed") if verifier_ran(trial) else "ungraded"
189
            trial_payload = {"task": task_key(trial_dir), "state": state}
190
            thread_id = thread_id_of(trial_dir)
191
            if thread_id:
192
                trial_payload["thread_id"] = thread_id
193
            try:
194
                request_json(f"{run_url}/trials", token, trial_payload, "POST")
195
            except (urllib.error.URLError, OSError) as error:
196
                print(f"trial upsert failed for {task_key(trial_dir)}: {error}", file=sys.stderr)
197
198
        if not any(verifier_ran(trial) for trial in trials):
199
            # A crashed grader is not a grade, and a run nobody will grade
200
            # should not stay a forever-running row.
201
            request_json(run_url, token, {"status": "abandoned"}, "PATCH")
202
            print(f"run {arguments.run_id} abandoned: no trial's verifier ran")
203
            return 0
204
205
        finalize = {
206
            "status": "graded",
207
            "tasks_total": tasks_total,
208
            "tasks_passed": tasks_passed,
209
            "input_tokens": input_tokens or None,
210
            "output_tokens": output_tokens or None,
211
            "duration_seconds": int(duration_seconds) or None,
212
            "recipe_digest": recipe_digest,
213
            "report": report,
214
            "model": model,
215
            "agent_version": agent_version,
216
        }
217
        try:
218
            status, body = request_json(run_url, token, finalize, "PATCH")
219
        except urllib.error.HTTPError as error:
220
            if error.code == 409:
221
                # Another run already holds this digest: a replay, not a
222
                # failure. The registered row is closed by the run that owns
223
                # the digest.
224
                print(f"409: run {arguments.run_id} replayed an existing digest")
225
                return 0
226
            raise
227
        run = body.get("run") or {}
228
        print(
229
            f"{status}: run {run.get('id') or arguments.run_id} "
230
            f"status={run.get('status')} passed={tasks_passed}/{tasks_total}"
231
        )
232
        return 0
233
100 234
    payload = {
101 235
        "suite": arguments.suite,
102 236
        "agent": "openagents-coder",

@@ -109,37 +243,15 @@ def main() -> int:

109 243
        "output_tokens": output_tokens or None,
110 244
        "duration_seconds": int(duration_seconds) or None,
111 245
        "recipe_digest": recipe_digest,
112
        "report": {
113
            "job_id": result.get("id"),
114
            "trials": [
115
                {
116
                    "task": (trial.get("task_name") or trial.get("trial_name") or "?"),
117
                    "passed": passed(trial),
118
                    "exception": (trial.get("exception_info") or {}).get("exception_type")
119
                    if trial.get("exception_info")
120
                    else None,
121
                }
122
                for trial in trials
123
            ],
124
        },
246
        "report": report,
125 247
    }
126 248
127
    request = urllib.request.Request(
128
        f"{arguments.api_url}/api/v3/gym/runs",
129
        data=json.dumps(payload).encode(),
130
        headers={
131
            "Content-Type": "application/json",
132
            "Authorization": f"Bearer {token}",
133
        },
134
        method="POST",
249
    status, body = request_json(f"{arguments.api_url}/api/v3/gym/runs", token, payload, "POST")
250
    run = body.get("run") or {}
251
    print(
252
        f"{status}: run {run.get('id')} score={run.get('score')} "
253
        f"replayed={body.get('replayed')}"
135 254
    )
136
    with urllib.request.urlopen(request) as response:
137
        body = json.loads(response.read())
138
        run = body.get("run") or {}
139
        print(
140
            f"{response.status}: run {run.get('id')} score={run.get('score')} "
141
            f"replayed={body.get('replayed')}"
142
        )
143 255
    return 0
144 256
145 257
bench/run-suite.sh modified +143 -6

@@ -37,9 +37,17 @@ Optional options:

37 37
38 38
Environment:
39 39
  OPENAGENTS_TOKEN        Required for the Harbor run unless --model starts
40
                          with ollama/. Required for posting results.
40
                          with ollama/. Required for posting results and for
41
                          registering the run against the Gym lifecycle API;
42
                          without it registration is skipped.
41 43
  OPENAGENTS_CODER_API_URL Overrides the coder container API URL.
42 44
45
With OPENAGENTS_TOKEN set, the runner registers the run at
46
POST <api-url>/api/v1/gym/runs/start before Harbor starts, exports
47
OPENAGENTS_GYM_RUN_ID and OPENAGENTS_GYM_API_URL into the Harbor run so the
48
adapter reports each trial live, and finalizes through post_gym_run.py
49
--run-id. A suite that fails before grading patches the run to abandoned.
50
43 51
Examples:
44 52
  bench/run-suite.sh bench/suites/tb2-cross-section.txt \
45 53
    --model openai/gpt-5.6-luna --lane proxy --n-concurrent 2

@@ -60,6 +68,67 @@ coder_api_url_for() {

60 68
  echo "$url" | sed -E 's#(https?://)(localhost|127\.0\.0\.1)(:[0-9]+)?#\1host.docker.internal\3#'
61 69
}
62 70
71
# Register the run against the Gym lifecycle API and print its id
72
# (OpenAgentsInc/openagents#38). Prints nothing on failure: the suite still
73
# runs, and the post-hoc one-shot path still posts the graded result.
74
register_gym_run() {
75
  python3 - "$API_URL" "$SUITE_NAME" "$CATALOG_MODEL" "$LANE" "$1" <<'PY'
76
import json, os, sys, urllib.request
77
78
api_url, suite, model, lane, tasks_total = sys.argv[1:6]
79
payload = {
80
    "suite": suite,
81
    "agent": "openagents-coder",
82
    "model": model,
83
    "lane": lane,
84
    "tasks_total": int(tasks_total),
85
}
86
request = urllib.request.Request(
87
    f"{api_url}/api/v1/gym/runs/start",
88
    data=json.dumps(payload).encode(),
89
    headers={
90
        "Content-Type": "application/json",
91
        "Authorization": f"Bearer {os.environ.get('OPENAGENTS_TOKEN', '')}",
92
    },
93
    method="POST",
94
)
95
try:
96
    with urllib.request.urlopen(request, timeout=15) as response:
97
        body = json.loads(response.read() or b"{}")
98
        run_id = (body.get("run") or {}).get("id")
99
        if run_id:
100
            print(run_id)
101
except Exception as error:
102
    print(f"gym run registration failed: {error}", file=sys.stderr)
103
PY
104
}
105
106
# Close a registered run without grades. A crashed suite is not a grade, but
107
# it should not be a forever-running row on /gym either.
108
abandon_gym_run() {
109
  [ -n "${GYM_RUN_ID:-}" ] || return 0
110
  log "Marking Gym run $GYM_RUN_ID abandoned..."
111
  python3 - "$API_URL" "$GYM_RUN_ID" <<'PY'
112
import json, os, sys, urllib.request
113
114
api_url, run_id = sys.argv[1:3]
115
request = urllib.request.Request(
116
    f"{api_url}/api/v1/gym/runs/{run_id}",
117
    data=json.dumps({"status": "abandoned"}).encode(),
118
    headers={
119
        "Content-Type": "application/json",
120
        "Authorization": f"Bearer {os.environ.get('OPENAGENTS_TOKEN', '')}",
121
    },
122
    method="PATCH",
123
)
124
try:
125
    with urllib.request.urlopen(request, timeout=15):
126
        pass
127
except Exception as error:
128
    print(f"gym run abandon failed: {error}", file=sys.stderr)
129
PY
130
}
131
63 132
# Argument defaults
64 133
SUITE_FILE=""
65 134
MODEL=""

@@ -192,6 +261,16 @@ done

192 261
SUITE_NAME="$(basename "$SUITE_FILE" .txt)"
193 262
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
194 263
264
# The catalog name the run registers under: Harbor spells models
265
# provider/name, the catalog id is the name, and an ollama/ model is the
266
# coder's `ollama:<name>` local-lane shape — the same mapping the adapter
267
# applies.
268
if [[ "$MODEL" == ollama/* ]]; then
269
  CATALOG_MODEL="ollama:${MODEL#ollama/}"
270
else
271
  CATALOG_MODEL="${MODEL#*/}"
272
fi
273
195 274
if [ -z "$JOBS_DIR" ]; then
196 275
  JOBS_DIR="/tmp/gym-jobs-${TIMESTAMP}"
197 276
fi

@@ -228,18 +307,38 @@ if [ "$DRY_RUN" -eq 1 ]; then

228 307
  echo "[dry-run] Pack command:"
229 308
  echo "[dry-run]   (cd $(printf '%q' packages/openagents-cli) && pnpm build && pnpm pack --pack-destination $(printf '%q' ../../bench))"
230 309
  echo
310
  if [ -n "$OPENAGENTS_TOKEN" ]; then
311
    echo "[dry-run] Register command:"
312
    echo "[dry-run]   POST $API_URL/api/v1/gym/runs/start {\"suite\": \"$SUITE_NAME\", \"agent\": \"openagents-coder\", \"model\": \"$CATALOG_MODEL\", \"lane\": \"$LANE\", \"tasks_total\": ${#TASKS[@]}}"
313
    echo "[dry-run]   (exports OPENAGENTS_GYM_RUN_ID=<run-id> and OPENAGENTS_GYM_API_URL=$API_URL into the Harbor run)"
314
  else
315
    echo "[dry-run] Register command: skipped (OPENAGENTS_TOKEN is not set)"
316
  fi
317
  GYM_ENV_ARGS=()
318
  RUN_ID_SUFFIX=""
319
  if [ -n "$OPENAGENTS_TOKEN" ]; then
320
    GYM_ENV_ARGS=("OPENAGENTS_GYM_RUN_ID=<run-id>" "OPENAGENTS_GYM_API_URL=$API_URL")
321
    RUN_ID_SUFFIX=" --run-id '<run-id>'"
322
  fi
323
  echo
231 324
  echo "[dry-run] Harbor command:"
232 325
  echo -n "[dry-run]   "
233 326
  printf '%q ' \
234 327
    "PYTHONPATH=$BENCH_DIR" \
235 328
    "OPENAGENTS_TOKEN=$TOKEN_DISPLAY" \
236 329
    "OPENAGENTS_CODER_API_URL=$CANDIDATE_CODER_API_URL" \
330
    ${GYM_ENV_ARGS[@]+"${GYM_ENV_ARGS[@]}"} \
237 331
    harbor run \
238 332
    "${HARBOR_ARGS[@]}"
239 333
  echo
240 334
  echo
241 335
  echo "[dry-run] Post command (after locating the job directory under $JOBS_DIR):"
242
  echo "[dry-run]   python3 $(printf '%q' "$BENCH_DIR/post_gym_run.py") <job-dir> --api-url $(printf '%q' "$API_URL") --lane $(printf '%q' "$LANE") --suite $(printf '%q' "$SUITE_NAME")"
336
  echo "[dry-run]   python3 $(printf '%q' "$BENCH_DIR/post_gym_run.py") <job-dir> --api-url $(printf '%q' "$API_URL") --lane $(printf '%q' "$LANE") --suite $(printf '%q' "$SUITE_NAME")$RUN_ID_SUFFIX"
337
  if [ -n "$OPENAGENTS_TOKEN" ]; then
338
    echo
339
    echo "[dry-run] On a suite failure before grading:"
340
    echo "[dry-run]   PATCH $API_URL/api/v1/gym/runs/<run-id> {\"status\": \"abandoned\"}"
341
  fi
243 342
  exit 0
244 343
fi
245 344

@@ -262,8 +361,24 @@ if ! ls "$BENCH_DIR"/openagentsinc-cli-*.tgz >/dev/null 2>&1; then

262 361
  exit 1
263 362
fi
264 363
364
# Register the run so /gym shows it while the trials are still executing.
365
# Without a token (ollama dry runs) there is nothing to register against;
366
# a failed registration degrades to the post-hoc one-shot path.
367
GYM_RUN_ID=""
368
if [ -n "$OPENAGENTS_TOKEN" ]; then
369
  log "Registering Gym run at $API_URL..."
370
  GYM_RUN_ID="$(register_gym_run "${#TASKS[@]}" || true)"
371
  if [ -n "$GYM_RUN_ID" ]; then
372
    log "Gym run id: $GYM_RUN_ID"
373
  else
374
    log "Gym run registration failed; continuing without live run reporting."
375
  fi
376
else
377
  log "OPENAGENTS_TOKEN is not set; skipping Gym run registration."
378
fi
379
265 380
log "Running Harbor suite: $SUITE_NAME (${#TASKS[@]} tasks)..."
266
(
381
if ! (
267 382
  export PYTHONPATH="$BENCH_DIR"
268 383
  export OPENAGENTS_TOKEN
269 384
  if [ -n "${OPENAGENTS_CODER_API_URL:-}" ]; then

@@ -271,8 +386,18 @@ log "Running Harbor suite: $SUITE_NAME (${#TASKS[@]} tasks)..."

271 386
  else
272 387
    export OPENAGENTS_CODER_API_URL="$CANDIDATE_CODER_API_URL"
273 388
  fi
389
  if [ -n "$GYM_RUN_ID" ]; then
390
    # The adapter reports trials host-side, so it takes the host api url,
391
    # not the container-rewritten OPENAGENTS_CODER_API_URL.
392
    export OPENAGENTS_GYM_RUN_ID="$GYM_RUN_ID"
393
    export OPENAGENTS_GYM_API_URL="$API_URL"
394
  fi
274 395
  harbor run "${HARBOR_ARGS[@]}"
275
)
396
); then
397
  log "Harbor run failed."
398
  abandon_gym_run
399
  exit 1
400
fi
276 401
277 402
# Locate the completed job directory. Harbor creates a child directory under
278 403
# --jobs-dir, but if it writes directly into the directory, use that.

@@ -305,12 +430,15 @@ fi

305 430
306 431
if [ -z "$JOB_DIR" ] || [ ! -d "$JOB_DIR" ]; then
307 432
  log "Could not locate Harbor job directory under $JOBS_DIR"
433
  abandon_gym_run
308 434
  exit 1
309 435
fi
310 436
311 437
log "Harbor job directory: $JOB_DIR"
312 438
313
# Post the graded result.
439
# Post the graded result. With a registered run this is the finalize step:
440
# post_gym_run.py upserts each trial's final state and patches the run to
441
# graded, or to abandoned when no verifier ran.
314 442
if [ -z "$OPENAGENTS_TOKEN" ]; then
315 443
  log "OPENAGENTS_TOKEN is not set; skipping result post."
316 444
  log "To post later, run:"

@@ -318,7 +446,16 @@ if [ -z "$OPENAGENTS_TOKEN" ]; then

318 446
  exit 0
319 447
fi
320 448
449
RUN_ID_ARGS=()
450
if [ -n "$GYM_RUN_ID" ]; then
451
  RUN_ID_ARGS=(--run-id "$GYM_RUN_ID")
452
fi
453
321 454
log "Posting result to $API_URL..."
322
python3 "$BENCH_DIR/post_gym_run.py" "$JOB_DIR" --api-url "$API_URL" --lane "$LANE" --suite "$SUITE_NAME"
455
if ! python3 "$BENCH_DIR/post_gym_run.py" "$JOB_DIR" --api-url "$API_URL" --lane "$LANE" --suite "$SUITE_NAME" ${RUN_ID_ARGS[@]+"${RUN_ID_ARGS[@]}"}; then
456
  log "Result post failed."
457
  abandon_gym_run
458
  exit 1
459
fi
323 460
324 461
log "Suite run complete: $SUITE_NAME"

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