Keep the model pin on a trial the timeout killed

3d9f40a5e9fb · Claude Fable 5 · · parent 15f1298bd640

Keep the model pin on a trial the timeout killed

The coder writes its ATIF export at the end of a session, so a trial that hit
the agent timeout leaves no trajectory — and the model id, the CLI version, and
every token count go with it. That is the wrong way round for the model: a run
whose lane failed is the run you most want to know the lane of, and a run made
entirely of timeouts was reporting `model unknown` and pricing every trial as
`unknown_model`, which reads as a pricing problem rather than as a dead lane.

Harbor records the model on each trial before the agent starts. The trial's
`config.json` is now the fallback, with the same provider-prefix mapping the
adapter applies, so a recovered id prices identically to one read from a
trajectory — an `ollama/…` run comes back as `unmetered_local_lane` rather than
as a model nobody has heard of. Keeping that mapping in step with
`_catalog_model` in the adapter is the cost; the alternative is a pin that
disappears exactly when it matters.

The CLI version stays unknown, because nothing on disk records it. Recoverable
and unrecoverable are different and inventing the second would be worse than
reporting it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
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 packages/coder-effectiveness/README.md
  • added packages/coder-effectiveness/fixtures/timed-out-lane/config.json
  • added packages/coder-effectiveness/fixtures/timed-out-lane/result.json
  • added packages/coder-effectiveness/fixtures/timed-out-lane/timed-out__c0c0c0c0/config.json
  • added packages/coder-effectiveness/fixtures/timed-out-lane/timed-out__c0c0c0c0/result.json
  • modified packages/coder-effectiveness/src/effectiveness.test.ts
  • modified packages/coder-effectiveness/src/harbor-job.ts

Diff

7 files changed, +112 -1

packages/coder-effectiveness/README.md modified +1

@@ -277,6 +277,7 @@ called, no Docker image runs, and no clock is read.

277 277
| `mixed-lane`       | A partly priced run withholds the number.                         |
278 278
| `crashed-verifier` | Ungraded trials stay out of both buckets.                         |
279 279
| `regressed-lane`   | A regression raises cost per accepted outcome and trips the gate. |
280
| `timed-out-lane`   | A killed session leaves no trajectory; the model pin survives it. |
280 281
281 282
The suite manifests beside them cover the pin and the smoke rule:
282 283
`fixture-suite` pins the four tasks the priced and regressed jobs ran,
packages/coder-effectiveness/fixtures/timed-out-lane/config.json added +3

@@ -0,0 +1,3 @@

1
{
2
  "dataset": "terminal-bench@2.0"
3
}
packages/coder-effectiveness/fixtures/timed-out-lane/result.json added +6

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

1
{
2
 "id": "job-timed-out-lane",
3
 "started_at": "2026-08-25T10:00:00.000000",
4
 "finished_at": "2026-08-25T10:30:00.000000",
5
 "n_total_trials": 1
6
}
packages/coder-effectiveness/fixtures/timed-out-lane/timed-out__c0c0c0c0/config.json added +10

@@ -0,0 +1,10 @@

1
{
2
 "task": {
3
  "path": "timed-out"
4
 },
5
 "trial_name": "timed-out__c0c0c0c0",
6
 "agent": {
7
  "import_path": "adapters.openagents_coder:OpenAgentsCoder",
8
  "model_name": "ollama/qwen3.8:27b-mtp-q8_0"
9
 }
10
}
packages/coder-effectiveness/fixtures/timed-out-lane/timed-out__c0c0c0c0/result.json added +17

@@ -0,0 +1,17 @@

1
{
2
 "id": "trial-timed-out",
3
 "task_name": "timed-out",
4
 "trial_name": "timed-out__c0c0c0c0",
5
 "exception_info": {
6
  "exception_type": "AgentTimeoutError"
7
 },
8
 "verifier_result": {
9
  "rewards": {
10
   "reward": 0.0
11
  }
12
 },
13
 "agent_execution": {
14
  "started_at": "2026-08-25T10:00:00.000000",
15
  "finished_at": "2026-08-25T10:30:00.000000"
16
 }
17
}
packages/coder-effectiveness/src/effectiveness.test.ts modified +37

@@ -210,3 +210,40 @@ describe("cost per accepted outcome", () => {

210 210
    expect(after.successRate!).toBeLessThan(before.successRate!);
211 211
  });
212 212
});
213
214
/**
215
 * A trial that hit the agent timeout, which is what a degraded lane's run is
216
 * mostly made of.
217
 *
218
 * The coder writes its ATIF export at the end of a session, so a killed session
219
 * leaves none — and every figure this suite reads from a trajectory goes with
220
 * it. What must not go with it is the model, because a run whose lane failed is
221
 * the run you most want to know the lane of.
222
 */
223
describe("a trial with no trajectory", () => {
224
  test("recovers the model from Harbor's own trial config", () => {
225
    const result = report("timed-out-lane", "local");
226
227
    expect(result.models).toEqual(["ollama:qwen3.8:27b-mtp-q8_0"]);
228
    // Harbor spells it `ollama/…`; the catalog id the adapter sends is
229
    // `ollama:…`, and a recovered id has to price like a read one.
230
    expect(result.perTrial[0]!.disposition).toBe("unmetered_local_lane");
231
    expect(result.perTrial[0]!.disposition).not.toBe("unknown_model");
232
  });
233
234
  test("still reports the verifier's decision and leaves the token counts unknown", () => {
235
    const result = report("timed-out-lane", "local");
236
237
    expect(result.rejected).toBe(1);
238
    expect(result.accepted).toBe(0);
239
    expect(result.promptTokens).toBeNull();
240
    expect(result.toolCalls).toBeNull();
241
    expect(result.wallClockSeconds).toBe(1800);
242
  });
243
244
  test("leaves the CLI version unknown, because nothing on disk records it", () => {
245
    // Recoverable and unrecoverable are different, and inventing the second
246
    // would be worse than reporting it.
247
    expect(report("timed-out-lane", "local").agentVersions).toEqual([]);
248
  });
249
});
packages/coder-effectiveness/src/harbor-job.ts modified +38 -1

@@ -114,7 +114,9 @@ const readTrial = (dirName: string, trialDir: string, trialResult: unknown): Tri

114 114
  return {
115 115
    task: dirName.includes("__") ? dirName.slice(0, dirName.lastIndexOf("__")) : dirName,
116 116
    outcome: outcomeOf(trialResult),
117
    modelId: readString(readField(agent, "model_name")),
117
    modelId:
118
      readString(readField(agent, "model_name")) ??
119
      modelFromTrialConfig(readJson(join(trialDir, "config.json"))),
118 120
    agentVersion: readString(readField(agent, "version")),
119 121
    promptTokens: usage.promptTokens,
120 122
    completionTokens: usage.completionTokens,

@@ -126,6 +128,41 @@ const readTrial = (dirName: string, trialDir: string, trialResult: unknown): Tri

126 128
  };
127 129
};
128 130
131
/**
132
 * The model, recovered from Harbor's own trial config when the trajectory is
133
 * missing.
134
 *
135
 * The coder writes its ATIF export at the end of a session, so a trial that hit
136
 * the agent timeout or crashed leaves no trajectory and no model id — exactly
137
 * the trials a regression run is full of. Losing the model pin there is the
138
 * wrong way round: a run whose lane failed is the run you most want to know the
139
 * lane of, and a whole run of timeouts would otherwise report `model unknown`
140
 * and price as `unknown_model` for a reason that has nothing to do with pricing.
141
 *
142
 * Harbor records the model on every trial before the agent starts, spelled the
143
 * way its `--model` flag takes it. The adapter maps that spelling onto the
144
 * coder's catalog id, and this repeats that mapping so a recovered id prices
145
 * the same as one read from a trajectory. It is a second copy of a two-line
146
 * rule; the alternative is a model pin that disappears precisely when it
147
 * matters. Keep it in step with `_catalog_model` in
148
 * `bench/adapters/openagents_coder.py`.
149
 */
150
const modelFromTrialConfig = (trialConfig: unknown): string | null => {
151
  // The trial's own `config.json` holds the agent block at the top level; the
152
  // same block appears one level down inside a trial `result.json`. Both are
153
  // read so a caller does not have to know which file it handed over.
154
  const spelled =
155
    readString(readField(readField(trialConfig, "agent"), "model_name")) ??
156
    readString(readField(readField(readField(trialConfig, "config"), "agent"), "model_name"));
157
  if (spelled === null) return null;
158
  const separator = spelled.indexOf("/");
159
  if (separator === -1) return spelled;
160
  const provider = spelled.slice(0, separator);
161
  const name = spelled.slice(separator + 1);
162
  if (provider === "ollama") return `ollama:${name}`;
163
  return name === "" ? provider : name;
164
};
165
129 166
/**
130 167
 * A verifier that never ran leaves no `verifier_result`. That is `ungraded`.
131 168
 * Where one ran, any positive reward is an accepted outcome; Harbor writes the

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