Viewing files at commit ebd567552629Commit detailsView latest

Browse files
scripts/workflow_ab.py 1747 lines · 70.0 KB · python
1
#!/usr/bin/env python3
2
"""Run deterministic agent-shaped workflow A/B tests across Git and Oak.
3
4
This complements scripts/bench.py. The core benchmark times isolated VCS
5
commands; this script runs whole task recipes that look more like common agent
6
work: search, read, edit, test/validate, status, diff, and snapshot. The
7
fixtures and edits are deterministic so different subjects only vary by VCS.
8
"""
9
10
from __future__ import annotations
11
12
import argparse
13
import json
14
import platform
15
import random
16
import shlex
17
import shutil
18
import statistics
19
import subprocess
20
import tempfile
21
import time
22
from dataclasses import dataclass
23
from datetime import datetime, timezone
24
from pathlib import Path
25
from typing import Any, Callable
26
27
from oakbench import environment as oakbench_environment
28
from oakbench import envwatch as oakbench_envwatch
29
from oakbench import fixtures as oakbench_fixtures
30
from oakbench import loadgen as oakbench_loadgen
31
from oakbench import integrity as oakbench_integrity
32
from oakbench import remotes as oakbench_remotes
33
from oakbench import tokens as oakbench_tokens
34
from oakbench.command_semantics import semantics
35
from oakbench.environment import command_display
36
from oakbench.execution import run_timed
37
from oakbench.reporting import fmt_delta_lower_better, fmt_num
38
from oakbench.results import ResultsStore
39
from oakbench.rows import SKIP_RETURNCODE, row_returncode
40
from oakbench.runlock import measurement_lock
41
from oakbench.subjects import (
42
    DEFAULT_OAK_REPO,
43
    Subject,
44
    load_subjects,
45
    source_metadata,
46
    subject_details,
47
    subject_version,
48
)
49
50
ROOT = Path(__file__).resolve().parents[1]
51
DEFAULT_WORKDIR = Path(tempfile.gettempdir()) / "oak-workflow-ab"
52
DEFAULT_ADMITTED_OUTPUT_CHARS = 20_000
53
FIXTURE_VERSION = "2026-06-09.2"
54
TRACKS = ("agent-default", "core-equivalent")
55
56
# Compatibility re-exports: agent_workflow.py and tests reference these
57
# through this module. The definitions live in oakbench (the measurement core).
58
COST_WEIGHTS_NOTE = oakbench_tokens.COST_WEIGHTS_NOTE
59
COMMAND_SEMANTICS_VERSION = semantics().version
60
_OAK_COMMIT_PUSH_SUPPORT_CACHE: dict[str, str | None] = {}
61
62
63
def base_env() -> dict[str, str]:
64
    return oakbench_environment.base_env(
65
        author_name="Oak Workflow Bench",
66
        author_email="[email protected]",
67
        oak_author="oak-workflow-bench",
68
    )
69
70
71
def python_no_bytecode_env(base: dict[str, str] | None = None) -> dict[str, str]:
72
    env = dict(base) if base is not None else base_env()
73
    env["PYTHONDONTWRITEBYTECODE"] = "1"
74
    return env
75
76
77
def step_env(step: "Step", base: dict[str, str] | None = None) -> dict[str, str]:
78
    if step.kind == "test":
79
        return python_no_bytecode_env(base)
80
    return dict(base) if base is not None else base_env()
81
82
83
def oak_commit_push_skip_reason(subject: Subject) -> str | None:
84
    if subject.kind != "oak":
85
        return None
86
    key = str(subject.bin)
87
    if key in _OAK_COMMIT_PUSH_SUPPORT_CACHE:
88
        return _OAK_COMMIT_PUSH_SUPPORT_CACHE[key]
89
    try:
90
        probe = subprocess.run(
91
            [key, "commit", "--help"],
92
            capture_output=True,
93
            text=True,
94
            env=base_env(),
95
            timeout=15,
96
            check=False,
97
        )
98
    except FileNotFoundError:
99
        reason = f"missing_binary:{key}"
100
        _OAK_COMMIT_PUSH_SUPPORT_CACHE[key] = reason
101
        return reason
102
    except subprocess.TimeoutExpired:
103
        reason = "oak_commit_push_capability_timeout"
104
        _OAK_COMMIT_PUSH_SUPPORT_CACHE[key] = reason
105
        return reason
106
    help_text = (probe.stdout or "") + "\n" + (probe.stderr or "")
107
    if probe.returncode != 0:
108
        reason = "oak_commit_push_capability_failed"
109
    elif "--push" not in help_text:
110
        reason = "oak_commit_push_not_supported:requires_current_oak"
111
    else:
112
        reason = None
113
    _OAK_COMMIT_PUSH_SUPPORT_CACHE[key] = reason
114
    return reason
115
116
117
@dataclass(frozen=True)
118
class Workflow:
119
    name: str
120
    description: str
121
    make_fixture: Callable[[Path], None]
122
    steps: Callable[..., list["Step"]]
123
    # Optional untimed-phase steps (run in the setup phase, after repo init)
124
    # for workflows that need pre-built history or pre-broken state.
125
    prepare: Callable[..., list["Step"]] | None = None
126
    # Workflows that exercise a remote set the oakbench.remotes purpose here.
127
    # Their steps/prepare callables take (subject, track, ctx); ctx carries the
128
    # per-run disposable branch and remote/clone paths. Oak subjects without
129
    # the purpose env var configured emit a skip row (returncode 77).
130
    remote_purpose: str | None = None
131
132
133
@dataclass(frozen=True)
134
class Step:
135
    operation: str
136
    kind: str
137
    command: list[str]
138
    expected_returncodes: tuple[int, ...] = (0,)
139
    # For expected-failure steps: the literal recovery command the workflow
140
    # runs next. The row records whether the error text actually mentions it —
141
    # the "error output is a prompt" actionability measurement. A misleading
142
    # hint (error suggests something other than the real fix) is a finding,
143
    # not a harness bug.
144
    recovery_hint: str | None = None
145
146
147
def parse_args() -> argparse.Namespace:
148
    parser = argparse.ArgumentParser(description=__doc__)
149
    parser.add_argument(
150
        "--workflows",
151
        default="bugfix_test_loop,wide_config_refactor,large_asset_manifest",
152
        help="Comma-separated workflow names, or 'all'.",
153
    )
154
    parser.add_argument("--subjects", help="Comma-separated subject names")
155
    parser.add_argument("--runs", type=int, default=1)
156
    parser.add_argument("--config", type=Path, default=ROOT / "config" / "subjects.toml")
157
    parser.add_argument("--workdir", type=Path, default=DEFAULT_WORKDIR)
158
    parser.add_argument("--results", type=Path, default=ROOT / "results" / "workflow-ab")
159
    parser.add_argument(
160
        "--oak-repo",
161
        type=Path,
162
        default=DEFAULT_OAK_REPO,
163
        help="Optional Oak source checkout for provenance metadata. Defaults to $OAK_REPO or ../oak.",
164
    )
165
    parser.add_argument("--keep-workdirs", action="store_true")
166
    parser.add_argument("--git-bin", type=Path)
167
    parser.add_argument("--oak-installed-bin", type=Path)
168
    parser.add_argument("--oak-local-bin", type=Path)
169
    parser.add_argument(
170
        "--track",
171
        choices=TRACKS,
172
        default="agent-default",
173
        help=(
174
            "agent-default measures natural current commands; core-equivalent "
175
            "uses the closest matching semantic output level across VCSs."
176
        ),
177
    )
178
    parser.add_argument(
179
        "--randomize-subject-order",
180
        action="store_true",
181
        help="Shuffle subject order per workflow run to reduce cache/order bias.",
182
    )
183
    parser.add_argument(
184
        "--admitted-output-chars",
185
        type=int,
186
        default=DEFAULT_ADMITTED_OUTPUT_CHARS,
187
        help="Per-command stdout/stderr characters counted as agent-visible output.",
188
    )
189
    parser.add_argument(
190
        "--load-tier",
191
        choices=sorted(oakbench_loadgen.LOAD_TIERS),
192
        default="none",
193
        help=(
194
            "Inject a machine-independent stress-ng background load tier while "
195
            "measuring (noisy-neighbor realism). Requires the optional stress-ng "
196
            "binary; when it is missing under a non-none tier every row becomes "
197
            "an explicit skip row — load that was not applied is never claimed."
198
        ),
199
    )
200
    return parser.parse_args()
201
202
203
def shell(script: str) -> list[str]:
204
    return ["/bin/zsh", "-c", script]
205
206
207
def search_command(pattern: str, target: str) -> str:
208
    # rg is not guaranteed on benchmark runners; grep -rnE has the same exit
209
    # contract (0 match, 1 no match). The row's command field records which ran.
210
    # rg skips hidden directories by default; grep must be told to skip VCS
211
    # internals or its match scope (and token counts) diverge.
212
    if shutil.which("rg"):
213
        return f"rg -n {shlex.quote(pattern)} {target}"
214
    return f"grep -rnE --exclude-dir=.git --exclude-dir=.oak {shlex.quote(pattern)} {target}"
215
216
217
def snapshot_steps(subject: Subject, operation_prefix: str, message: str) -> list[Step]:
218
    """Snapshot via the command-semantics contract: git pays stage+commit as two
219
    tool calls, oak one. Operation names follow the historical .add/.commit split."""
220
    sem = semantics()
221
    vcs = str(subject.bin)
222
    stage = sem.stage_args(subject.kind)
223
    if stage is not None:
224
        return [
225
            Step(f"{operation_prefix}.add", "vcs", [vcs, *stage]),
226
            Step(f"{operation_prefix}.commit", "vcs", [vcs, *sem.commit_args(subject.kind, message)]),
227
        ]
228
    return [Step(operation_prefix, "vcs", [vcs, *sem.commit_args(subject.kind, message)])]
229
230
231
def vcs_init_steps(subject: Subject) -> list[Step]:
232
    sem = semantics()
233
    vcs = str(subject.bin)
234
    return [
235
        Step("setup.repo_init", "vcs", [vcs, *sem.init_args(subject.kind)]),
236
        *snapshot_steps(subject, "setup.snapshot", "initial"),
237
    ]
238
239
240
def vcs_workflow_steps(subject: Subject, track: str) -> list[Step]:
241
    sem = semantics()
242
    vcs = str(subject.bin)
243
    return [
244
        Step("vcs.branch", "vcs", [vcs, *sem.branch_create_args(subject.kind)]),
245
        Step("vcs.status", "vcs", [vcs, *sem.status_args(subject.kind, track)]),
246
        Step("vcs.diff", "vcs", [vcs, *sem.diff_args(subject.kind, track)]),
247
        *snapshot_steps(subject, "vcs.snapshot", "agent task"),
248
    ]
249
250
251
def make_bugfix_fixture(path: Path) -> None:
252
    (path / "app").mkdir(parents=True)
253
    (path / "tests").mkdir()
254
    (path / "app" / "__init__.py").write_text("")
255
    (path / "app" / "pricing.py").write_text(
256
        "\n".join(
257
            [
258
                "def apply_discount(cents: int, percent: int) -> int:",
259
                "    \"\"\"Return discounted cents rounded down.\"\"\"",
260
                "    # BUG: this subtracts the percent value, not the percentage.",
261
                "    return cents - percent",
262
                "",
263
            ]
264
        )
265
    )
266
    (path / "tests" / "test_pricing.py").write_text(
267
        "\n".join(
268
            [
269
                "import unittest",
270
                "from app.pricing import apply_discount",
271
                "",
272
                "",
273
                "class PricingTest(unittest.TestCase):",
274
                "    def test_apply_discount(self):",
275
                "        self.assertEqual(apply_discount(1000, 25), 750)",
276
                "        self.assertEqual(apply_discount(999, 10), 900)",
277
                "",
278
                "",
279
                "if __name__ == '__main__':",
280
                "    unittest.main()",
281
                "",
282
            ]
283
        )
284
    )
285
    (path / "README.md").write_text("Pricing service fixture for agent workflow benchmarks.\n")
286
287
288
def bugfix_steps(subject: Subject, track: str) -> list[Step]:
289
    return [
290
        Step("search.locate_symbol", "search", shell(search_command("apply_discount|BUG|discount", "."))),
291
        Step("read.source", "read", shell("sed -n '1,120p' app/pricing.py")),
292
        Step("read.tests", "read", shell("sed -n '1,160p' tests/test_pricing.py")),
293
        Step("test.red", "test", shell("python3 -m unittest discover -s tests"), (1,)),
294
        Step(
295
            "edit.fix_bug",
296
            "edit",
297
            shell(
298
                "python3 - <<'PY'\n"
299
                "from pathlib import Path\n"
300
                "p = Path('app/pricing.py')\n"
301
                "text = p.read_text()\n"
302
                "text = text.replace('return cents - percent', 'return cents - ((cents * percent) // 100)')\n"
303
                "p.write_text(text)\n"
304
                "PY"
305
            ),
306
        ),
307
        Step("test.green", "test", shell("python3 -m unittest discover -s tests")),
308
        *vcs_workflow_steps(subject, track),
309
    ]
310
311
312
def make_wide_config_fixture(path: Path) -> None:
313
    root = path / "services"
314
    for index in range(600):
315
        service = root / f"service-{index:04d}"
316
        service.mkdir(parents=True, exist_ok=True)
317
        timeout = 2500 if index % 3 == 0 else 1800
318
        service.joinpath("config.toml").write_text(
319
            "\n".join(
320
                [
321
                    f'name = "service-{index:04d}"',
322
                    f"timeout_ms = {timeout}",
323
                    "retries = 2",
324
                    "",
325
                ]
326
            )
327
        )
328
    (path / "validate_configs.py").write_text(
329
        "\n".join(
330
            [
331
                "from pathlib import Path",
332
                "bad = []",
333
                "for p in Path('services').glob('*/config.toml'):",
334
                "    text = p.read_text()",
335
                "    if 'timeout_ms = 2500' in text:",
336
                "        bad.append(str(p))",
337
                "print(f'bad={len(bad)}')",
338
                "raise SystemExit(1 if bad else 0)",
339
                "",
340
            ]
341
        )
342
    )
343
344
345
def wide_config_steps(subject: Subject, track: str) -> list[Step]:
346
    return [
347
        Step(
348
            "search.deprecated_timeout",
349
            "search",
350
            shell(search_command("timeout_ms = 2500", "services") + " | head -40"),
351
        ),
352
        Step("read.sample_config", "read", shell("sed -n '1,80p' services/service-0000/config.toml")),
353
        Step(
354
            "edit.rewrite_configs",
355
            "edit",
356
            shell(
357
                "python3 - <<'PY'\n"
358
                "from pathlib import Path\n"
359
                "for p in Path('services').glob('*/config.toml'):\n"
360
                "    text = p.read_text()\n"
361
                "    if 'timeout_ms = 2500' in text:\n"
362
                "        p.write_text(text.replace('timeout_ms = 2500', 'timeout_ms = 3000'))\n"
363
                "PY"
364
            ),
365
        ),
366
        Step("search.verify_none_left", "search", shell("! " + search_command("timeout_ms = 2500", "services"))),
367
        Step("test.validate_configs", "test", shell("python3 validate_configs.py")),
368
        *vcs_workflow_steps(subject, track),
369
    ]
370
371
372
def write_pattern_file(path: Path, size: int, seed: str) -> None:
373
    oakbench_fixtures.write_pattern_file(path, size, seed, binary=True, prefix="oak-workflow")
374
375
376
def make_large_asset_fixture(path: Path) -> None:
377
    assets = path / "assets"
378
    assets.mkdir(parents=True)
379
    manifest: list[dict[str, Any]] = []
380
    for index in range(4):
381
        asset_path = assets / f"asset-{index:02d}.bin"
382
        write_pattern_file(asset_path, 16 * 1024 * 1024, f"asset-{index}")
383
        manifest.append({"path": str(asset_path.relative_to(path)), "bytes": asset_path.stat().st_size, "reviewed": False})
384
    (path / "manifest.json").write_text(json.dumps({"assets": manifest}, indent=2) + "\n")
385
    (path / "validate_manifest.py").write_text(
386
        "\n".join(
387
            [
388
                "import json",
389
                "from pathlib import Path",
390
                "data = json.loads(Path('manifest.json').read_text())",
391
                "for item in data['assets']:",
392
                "    p = Path(item['path'])",
393
                "    assert p.exists(), p",
394
                "    assert p.stat().st_size == item['bytes'], p",
395
                "    assert item['reviewed'] is True, p",
396
                "print('manifest ok')",
397
                "",
398
            ]
399
        )
400
    )
401
402
403
def large_asset_steps(subject: Subject, track: str) -> list[Step]:
404
    return [
405
        Step("search.assets", "search", shell("find assets -type f -maxdepth 1 | sort")),
406
        Step("read.manifest", "read", shell("sed -n '1,120p' manifest.json")),
407
        Step(
408
            "read.binary_prefix",
409
            "read",
410
            shell("python3 - <<'PY'\nfrom pathlib import Path\nprint(Path('assets/asset-00.bin').read_bytes()[:64].hex())\nPY"),
411
        ),
412
        Step(
413
            "edit.mark_reviewed",
414
            "edit",
415
            shell(
416
                "python3 - <<'PY'\n"
417
                "import json\n"
418
                "from pathlib import Path\n"
419
                "p = Path('manifest.json')\n"
420
                "data = json.loads(p.read_text())\n"
421
                "for item in data['assets']:\n"
422
                "    item['reviewed'] = True\n"
423
                "p.write_text(json.dumps(data, indent=2) + '\\n')\n"
424
                "PY"
425
            ),
426
        ),
427
        Step("test.validate_manifest", "test", shell("python3 validate_manifest.py")),
428
        *vcs_workflow_steps(subject, track),
429
    ]
430
431
432
def make_history_fixture(path: Path) -> None:
433
    (path / "app").mkdir(parents=True)
434
    (path / "app" / "pricing.py").write_text(
435
        "\n".join(
436
            [
437
                "def apply_discount(cents: int, percent: int) -> int:",
438
                "    return cents - ((cents * percent) // 100)",
439
                "",
440
            ]
441
        )
442
    )
443
    (path / "app" / "totals.py").write_text("def total(items):\n    return sum(items)\n")
444
    (path / "README.md").write_text("History archaeology fixture for agent workflow benchmarks.\n")
445
446
447
def history_prepare_steps(subject: Subject, track: str) -> list[Step]:
448
    vcs = shlex.quote(str(subject.bin))
449
    steps: list[Step] = []
450
    edits = [
451
        ("app/pricing.py", "support negative-percent surcharges", "# rev: surcharge support"),
452
        ("app/totals.py", "guard empty item lists", "# rev: empty-list guard"),
453
        ("app/pricing.py", "clamp discount to 100 percent", "# rev: discount clamp"),
454
    ]
455
    for index, (target, message, marker) in enumerate(edits):
456
        if subject.kind == "git":
457
            script = (
458
                f"echo {shlex.quote(marker)} >> {shlex.quote(target)} && "
459
                f"{vcs} add . && {vcs} commit -m {shlex.quote(message)}"
460
            )
461
        else:
462
            script = f"echo {shlex.quote(marker)} >> {shlex.quote(target)} && {vcs} commit --no-verify"
463
        steps.append(Step(f"setup.history.commit_{index}", "vcs", shell(script)))
464
    return steps
465
466
467
def history_archaeology_steps(subject: Subject, track: str) -> list[Step]:
468
    # Agents constantly ask "what changed recently, in this file, and why".
469
    # Oak lacks show/blame/pickaxe equivalents today, so its rows use the
470
    # closest available command (oak log); the token/capability gap is the
471
    # measurement, and the command field records what actually ran.
472
    vcs = str(subject.bin)
473
    if subject.kind == "git":
474
        return [
475
            Step("history.recent", "vcs", [vcs, "log", "--oneline", "-15"]),
476
            Step("history.show_last", "vcs", [vcs, "show", "--stat", "HEAD"]),
477
            Step("history.file_history", "vcs", [vcs, "log", "--oneline", "-5", "--", "app/pricing.py"]),
478
            Step("history.pickaxe", "vcs", [vcs, "log", "-S", "surcharge", "--oneline"], (0, 1, 2)),
479
        ]
480
    return [
481
        Step("history.recent", "vcs", [vcs, "log", "-n", "15"]),
482
        Step("history.show_last", "vcs", [vcs, "log", "-n", "1", "-v"]),
483
        Step("history.file_history", "vcs", [vcs, "log", "-v"], (0, 1, 2)),
484
        Step("history.pickaxe", "vcs", [vcs, "log", "-v"], (0, 1, 2)),
485
    ]
486
487
488
def error_recovery_steps(subject: Subject, track: str) -> list[Step]:
489
    # Error output is a prompt: its size and clarity set how many tokens an
490
    # agent burns recovering. Failure-path rows are tolerated (wide expected
491
    # returncodes) so each subject's error text cost is recorded, not skipped.
492
    sem = semantics()
493
    vcs = str(subject.bin)
494
    if subject.kind == "git":
495
        commit_nothing = [vcs, "commit", "-m", "empty"]
496
    else:
497
        commit_nothing = [vcs, *sem.commit_args(subject.kind, "empty")]
498
    return [
499
        Step("error.commit_nothing", "vcs", commit_nothing, (0, 1, 2)),
500
        Step("error.unknown_subcommand", "vcs", [vcs, "stage-all-now"], (0, 1, 2, 101)),
501
        Step("error.status_after", "vcs", [vcs, "status"]),
502
        Step(
503
            "edit.recovery_change",
504
            "edit",
505
            shell("echo '# recovered' >> README.md"),
506
        ),
507
        *snapshot_steps(subject, "vcs.snapshot", "recovered"),
508
    ]
509
510
511
def make_sync_fixture(path: Path) -> None:
512
    (path / "work.txt").write_text("base line\n")
513
    (path / "notes.txt").write_text("shared notes\n")
514
    (path / "README.md").write_text("Sync/divergence recovery fixture for agent workflow benchmarks.\n")
515
516
517
def sync_prepare_steps(subject: Subject, track: str, ctx: dict[str, str]) -> list[Step]:
518
    """Untimed topology: link the repo to a remote, stand up a second checkout
519
    B, and move upstream from B so the measured checkout is behind.
520
521
    Git uses a local bare remote (local_file transport); oak uses the real
522
    server (network transport). Latency rows across subjects in sync workflows
523
    are therefore never comparable — the cross-subject evidence is error-output
524
    tokens, tool calls, and recovery step counts, which transport cannot change.
525
    """
526
    vcs = shlex.quote(str(subject.bin))
527
    bdir = shlex.quote(ctx["clone_b"])
528
    if subject.kind == "git":
529
        rdir = shlex.quote(ctx["remote_dir"])
530
        return [
531
            Step("setup.remote.create", "vcs", shell(f"{vcs} init -q --bare {rdir}")),
532
            Step(
533
                "setup.remote.link",
534
                "vcs",
535
                shell(f"{vcs} remote add origin {rdir} && {vcs} push -q -u origin HEAD"),
536
            ),
537
            Step("setup.remote.clone_b", "vcs", shell(f"{vcs} clone -q {rdir} {bdir}")),
538
            Step(
539
                "setup.upstream.move",
540
                "vcs",
541
                shell(
542
                    f"cd {bdir} && echo 'upstream change' >> notes.txt && "
543
                    f"{vcs} add . && {vcs} commit -q -m upstream && {vcs} push -q origin HEAD"
544
                ),
545
            ),
546
        ]
547
    return [
548
        *oak_clone_a_steps(subject, ctx),
549
        Step("setup.remote.clone_b", "vcs", shell(f"{vcs} clone {shlex.quote(ctx['oak_repo'])} {bdir}")),
550
        # B joins the shared branch (it descends from seeded main, so switch
551
        # pulls it) and moves it. Commit is local-only in current Oak, so setup
552
        # explicitly uses --push to publish the upstream actor's change.
553
        Step(
554
            "setup.upstream.move",
555
            "vcs",
556
            shell(
557
                f"cd {bdir} && {vcs} switch {shlex.quote(ctx['branch'])} && "
558
                f"echo 'upstream change' >> notes.txt && "
559
                f"{vcs} commit --no-verify --push"
560
            ),
561
        ),
562
    ]
563
564
565
# Reads the auto-generated current branch name so it can be renamed to the
566
# run's collision-safe bench branch (rename syncs to the server).
567
OAK_CURRENT_BRANCH_SNIPPET = (
568
    "branch list --json | /usr/bin/env python3 -c "
569
    '\'import json,sys; print(next(b["name"] for b in json.load(sys.stdin) if b.get("current")))\''
570
)
571
572
573
def oak_clone_a_steps(subject: Subject, ctx: dict[str, str]) -> list[Step]:
574
    """Untimed oak topology: clone A, seed the disposable repo's main with the
575
    static fixture once (idempotent — identical bytes merge as a no-op), and
576
    publish the run's shared bench branch via rename + explicit commit --push."""
577
    vcs = shlex.quote(str(subject.bin))
578
    adir = shlex.quote(ctx["clone_a"])
579
    repo = shlex.quote(ctx["oak_repo"])
580
    branch = shlex.quote(ctx["branch"])
581
    fixture_dir = shlex.quote(f"../{ctx['repo_dir']}")
582
    return [
583
        Step("setup.remote.clone_a", "vcs", shell(f"{vcs} clone {repo} {adir}")),
584
        Step(
585
            "setup.remote.seed_main",
586
            "vcs",
587
            shell(
588
                f"cd {adir} && if [ ! -f work.txt ]; then "
589
                f"cp {fixture_dir}/work.txt {fixture_dir}/notes.txt {fixture_dir}/README.md . && "
590
                f"{vcs} commit --no-verify --push && {vcs} merge; fi"
591
            ),
592
        ),
593
        # Commit and publish the auto-named branch, then rename: rename only
594
        # resolves a branch that exists server-side.
595
        Step(
596
            "setup.remote.link",
597
            "vcs",
598
            shell(
599
                f"cd {adir} && echo 'link line' >> README.md && {vcs} commit --no-verify --push && "
600
                f"cur=$({vcs} {OAK_CURRENT_BRANCH_SNIPPET}) && "
601
                f"{vcs} branch rename \"$cur\" {branch}"
602
            ),
603
        ),
604
    ]
605
606
607
def sync_push_divergence_steps(subject: Subject, track: str, ctx: dict[str, str]) -> list[Step]:
608
    # The trap an agent hits when any other actor pushed first. Expected
609
    # returncodes encode each subject's observed remote-failure contract; a
610
    # step that "fails to fail" still converges at verify.payload. Error
611
    # output is a prompt: the rejected push's text is what the agent reasons
612
    # from, and its cost is the measurement.
613
    vcs = str(subject.bin)
614
    verify = Step(
615
        "verify.payload",
616
        "read",
617
        shell("grep -q 'local change' work.txt && grep -q 'upstream change' notes.txt"),
618
    )
619
    if subject.kind == "git":
620
        return [
621
            Step("edit.local_change", "edit", shell("echo 'local change' >> work.txt")),
622
            *snapshot_steps(subject, "vcs.snapshot", "local change"),
623
            # Rejected non-fast-forward push; stderr says to pull.
624
            Step("error.push_diverged", "vcs", [vcs, "push"], (1,), recovery_hint="git pull"),
625
            # Following the hint verbatim fails again until a strategy is
626
            # chosen: fatal "Need to specify how to reconcile divergent
627
            # branches" (exit 128).
628
            Step("error.pull_unconfigured", "vcs", [vcs, "pull"], (128,), recovery_hint="--rebase"),
629
            Step("recovery.pull_rebase", "vcs", [vcs, "pull", "--rebase"]),
630
            Step("recovery.push_after", "vcs", [vcs, "push"]),
631
            verify,
632
        ]
633
    return [
634
        Step("edit.local_change", "edit", shell("echo 'local change' >> work.txt")),
635
        # oak commit is a local checkpoint. The publish failure is measured in
636
        # the explicit push row below.
637
        Step("vcs.snapshot", "vcs", [vcs, "commit", "--no-verify"], (0,)),
638
        Step("error.push_diverged", "vcs", [vcs, "push"], (0, 1, 5), recovery_hint="pull"),
639
        # The push error says "Pull changes first" — but pull also exits 5 on
640
        # a diverged branch and suggests only the destructive pull --force.
641
        Step("error.pull_diverged", "vcs", [vcs, "pull"], (5,), recovery_hint="pull --force"),
642
        # pull --force discards the local commit (observed: committed content
643
        # reverts). Recovery therefore includes redoing the lost work.
644
        Step("recovery.pull_force", "vcs", [vcs, "pull", "--force"]),
645
        Step("edit.redo_change", "edit", shell("echo 'local change' >> work.txt")),
646
        *snapshot_steps(subject, "vcs.snapshot.redo", "local change redo"),
647
        Step("recovery.push_after", "vcs", [vcs, "push"]),
648
        verify,
649
    ]
650
651
652
def sync_pull_upstream_steps(subject: Subject, track: str, ctx: dict[str, str]) -> list[Step]:
653
    # The everyday task-start motion: upstream moved, local tree clean.
654
    vcs = str(subject.bin)
655
    sem = semantics()
656
    return [
657
        Step("vcs.status", "vcs", [vcs, *sem.status_args(subject.kind, track)]),
658
        Step("sync.pull_upstream", "vcs", [vcs, "pull"]),
659
        Step("verify.payload", "read", shell("grep -q 'upstream change' notes.txt")),
660
    ]
661
662
663
def sync_amend_prepare_steps(subject: Subject, track: str, ctx: dict[str, str]) -> list[Step]:
664
    """Untimed: link the repo to a remote and push — no second actor. The
665
    divergence in this workflow is self-inflicted (history rewrite after
666
    push), so upstream never moves."""
667
    vcs = shlex.quote(str(subject.bin))
668
    if subject.kind == "git":
669
        rdir = shlex.quote(ctx["remote_dir"])
670
        return [
671
            Step("setup.remote.create", "vcs", shell(f"{vcs} init -q --bare {rdir}")),
672
            Step(
673
                "setup.remote.link",
674
                "vcs",
675
                shell(f"{vcs} remote add origin {rdir} && {vcs} push -q -u origin HEAD"),
676
            ),
677
        ]
678
    return oak_clone_a_steps(subject, ctx)
679
680
681
def sync_non_ff_after_amend_steps(subject: Subject, track: str, ctx: dict[str, str]) -> list[Step]:
682
    # The self-inflicted trap: fix the last commit after it was pushed. Git
683
    # reproduces it exactly (amend rewrites the pushed tip; push is rejected;
684
    # the error text suggests pull — which would merge the old tip back — not
685
    # the --force-with-lease an agent actually needs, so the actionability
686
    # check is expected to FAIL and that failure is the finding). Oak cannot
687
    # rewrite history (commits carry no messages; `oak desc` edits the branch
688
    # description), so the same intent is a follow-up commit with no trap:
689
    # zero error rows and zero recovery steps IS oak's measurement here.
690
    vcs = str(subject.bin)
691
    if subject.kind == "git":
692
        return [
693
            Step("edit.fix_last_commit", "edit", shell("echo 'amended line' >> work.txt")),
694
            Step(
695
                "vcs.amend",
696
                "vcs",
697
                shell(f"{shlex.quote(vcs)} add . && {shlex.quote(vcs)} commit -q --amend --no-edit"),
698
            ),
699
            Step("error.push_non_ff", "vcs", [vcs, "push"], (1,), recovery_hint="--force-with-lease"),
700
            Step("recovery.push_force_lease", "vcs", [vcs, "push", "--force-with-lease"]),
701
            Step("verify.payload", "read", shell("grep -q 'amended line' work.txt")),
702
        ]
703
    return [
704
        Step("edit.fix_last_commit", "edit", shell("echo 'amended line' >> work.txt")),
705
        *snapshot_steps(subject, "vcs.snapshot", "amended line"),
706
        Step("recovery_free.desc_update", "vcs", [vcs, "desc", "amended: follow-up fix"]),
707
        Step("recovery_free.push_after", "vcs", [vcs, "push"], (0,)),
708
        Step("verify.payload", "read", shell("grep -q 'amended line' work.txt")),
709
    ]
710
711
712
def sync_pull_dirty_steps(subject: Subject, track: str, ctx: dict[str, str]) -> list[Step]:
713
    # Same motion with uncommitted local edits to a different file — the
714
    # working state agents are usually in when they remember to sync.
715
    # The oracle is split so a failure names what was lost. The pull row
716
    # accepts both refusal and success contracts; the follow-up payload checks
717
    # report whether upstream and local dirty content survived.
718
    vcs = str(subject.bin)
719
    sem = semantics()
720
    return [
721
        Step("edit.dirty_change", "edit", shell("echo 'dirty local edit' >> work.txt")),
722
        Step("vcs.status", "vcs", [vcs, *sem.status_args(subject.kind, track)]),
723
        Step("sync.pull_dirty", "vcs", [vcs, "pull"], (0, 1, 2, 4, 5)),
724
        Step("verify.upstream_payload", "read", shell("grep -q 'upstream change' notes.txt")),
725
        Step("verify.local_payload", "read", shell("grep -q 'dirty local edit' work.txt")),
726
    ]
727
728
729
WORKFLOWS: dict[str, Workflow] = {
730
    "bugfix_test_loop": Workflow(
731
        "bugfix_test_loop",
732
        "Search, reproduce failing test, patch one source file, rerun tests, inspect and snapshot.",
733
        make_bugfix_fixture,
734
        bugfix_steps,
735
    ),
736
    "wide_config_refactor": Workflow(
737
        "wide_config_refactor",
738
        "Search and rewrite hundreds of small config files, validate, inspect and snapshot.",
739
        make_wide_config_fixture,
740
        wide_config_steps,
741
    ),
742
    "large_asset_manifest": Workflow(
743
        "large_asset_manifest",
744
        "Inspect large binary asset repo, edit small manifest only, validate, inspect and snapshot.",
745
        make_large_asset_fixture,
746
        large_asset_steps,
747
    ),
748
    "history_archaeology": Workflow(
749
        "history_archaeology",
750
        "Answer common history questions: recent commits, last change detail, file history, term search.",
751
        make_history_fixture,
752
        history_archaeology_steps,
753
        prepare=history_prepare_steps,
754
    ),
755
    "vcs_error_recovery": Workflow(
756
        "vcs_error_recovery",
757
        "Hit common VCS errors (empty commit, unknown subcommand), then recover; measures error-output token cost.",
758
        make_history_fixture,
759
        error_recovery_steps,
760
    ),
761
    "sync_push_divergence": Workflow(
762
        "sync_push_divergence",
763
        "Push rejected because upstream moved, then the full recovery arc. "
764
        "Cross-subject evidence is tokens/calls/steps, never latency: git syncs a "
765
        "local bare remote, oak a real server.",
766
        make_sync_fixture,
767
        sync_push_divergence_steps,
768
        prepare=sync_prepare_steps,
769
        remote_purpose="sync",
770
    ),
771
    "sync_pull_upstream": Workflow(
772
        "sync_pull_upstream",
773
        "Clean-tree pull with upstream changes — the task-start sync motion. "
774
        "Latency is transport-dependent; compare tokens and calls only.",
775
        make_sync_fixture,
776
        sync_pull_upstream_steps,
777
        prepare=sync_prepare_steps,
778
        remote_purpose="sync",
779
    ),
780
    "sync_pull_dirty": Workflow(
781
        "sync_pull_dirty",
782
        "Pull with upstream changes while holding uncommitted local edits. "
783
        "Latency is transport-dependent; compare tokens and calls only.",
784
        make_sync_fixture,
785
        sync_pull_dirty_steps,
786
        prepare=sync_prepare_steps,
787
        remote_purpose="sync",
788
    ),
789
    "sync_non_ff_after_amend": Workflow(
790
        "sync_non_ff_after_amend",
791
        "Fix the last commit after pushing. Git: amend rewrites the pushed tip, "
792
        "push is rejected, recovery needs --force-with-lease (which the error "
793
        "text does not suggest). Oak: history rewrite does not exist, so the "
794
        "same intent is a trap-free follow-up commit — zero recovery steps is "
795
        "the measurement, not a missing row.",
796
        make_sync_fixture,
797
        sync_non_ff_after_amend_steps,
798
        prepare=sync_amend_prepare_steps,
799
        remote_purpose="sync",
800
    ),
801
}
802
803
804
def sync_context(
805
    subject: Subject,
806
    workflow: Workflow,
807
    run_index: int,
808
    metadata: dict[str, Any],
809
) -> tuple[dict[str, str] | None, str | None, dict[str, Any]]:
810
    """Per-run remote context for remote_purpose workflows.
811
812
    Returns (ctx, skip_reason, remote_row_fields). ctx is None when the
813
    subject's remote is not configured; the lane emits a skip row instead.
814
    """
815
    branch = oakbench_remotes.disposable_branch(
816
        str(metadata.get("bench_id", "bench")), workflow.name, subject.name, run_index
817
    )
818
    ctx: dict[str, str] = {
819
        "branch": branch,
820
        "remote_dir": f"../remote-run-{run_index}.git",
821
        "clone_b": f"../clone-b-run-{run_index}",
822
    }
823
    if subject.kind != "git":
824
        resolution = oakbench_remotes.resolve_oak_remote(workflow.remote_purpose or "default")
825
        if not resolution.resolved:
826
            return None, resolution.skip_reason, {}
827
        capability_skip = oak_commit_push_skip_reason(subject)
828
        if capability_skip is not None:
829
            return None, capability_skip, resolution.row_fields()
830
        ctx["oak_repo"] = resolution.repo or ""
831
        # Oak topology is clone-based: every branch must descend from the
832
        # disposable repo's seeded main (locally-init'ed history is invisible
833
        # to other clones). Measured steps run inside clone A, the checkout
834
        # that hits the trap; the local fixture copy only seeds main.
835
        ctx["clone_a"] = f"../clone-a-run-{run_index}"
836
        ctx["repo_dir"] = f"run-{run_index}"
837
        ctx["measure_dir"] = ctx["clone_a"]
838
        return ctx, None, resolution.row_fields()
839
    return ctx, None, {
840
        "remote_repo": ctx["remote_dir"],
841
        "remote_transport": oakbench_remotes.TRANSPORT_LOCAL_FILE,
842
        "remote_server": "local",
843
    }
844
845
846
def workflow_skip_row(
847
    subject: Subject,
848
    workflow: Workflow,
849
    run_index: int,
850
    metadata: dict[str, Any],
851
    reason: str,
852
) -> dict[str, Any]:
853
    return {
854
        **metadata,
855
        "subject": subject.name,
856
        "subject_kind": subject.kind,
857
        "subject_label": subject.label,
858
        "scenario": workflow.name,
859
        "workflow": workflow.name,
860
        "workflow_description": workflow.description,
861
        "run": run_index,
862
        "phase": "workflow",
863
        "operation": "workflow.skipped",
864
        "step_kind": "summary",
865
        "elapsed_ms": 0.0,
866
        "returncode": 77,
867
        "process_returncode": 77,
868
        "command": [],
869
        "skipped": True,
870
        "skip_reason": reason,
871
        "tool_call_count": 0,
872
        "terminal_tool_call_count": 0,
873
        "vcs_tool_call_count": 0,
874
        "test_tool_call_count": 0,
875
    }
876
877
878
def load_tier_skip_rows(
879
    subjects: list[Subject],
880
    workflows: list[Workflow],
881
    runs: int,
882
    metadata: dict[str, Any],
883
    reason: str,
884
    load_tier: str,
885
) -> list[dict[str, Any]]:
886
    """Explicit skip rows for a load tier the host cannot apply.
887
888
    One row per workflow x run x subject; environment_suspect is None because
889
    the suspicion machinery never ran (ADR-0002), and load_tier records the
890
    REQUESTED tier — load that was not applied is never claimed.
891
    """
892
    rows: list[dict[str, Any]] = []
893
    for workflow in workflows:
894
        for run_index in range(runs):
895
            for subject in subjects:
896
                row = workflow_skip_row(subject, workflow, run_index, metadata, reason)
897
                row["load_tier"] = load_tier
898
                row["environment_suspect"] = None
899
                rows.append(row)
900
    return rows
901
902
903
def interaction_metrics(
904
    command: list[str],
905
    stdout_text: str,
906
    stderr_text: str,
907
    stdout_bytes: int,
908
    stderr_bytes: int,
909
    stdout_truncated: bool,
910
    stderr_truncated: bool,
911
    step: Step,
912
) -> dict[str, Any]:
913
    is_vcs = 1 if step.kind == "vcs" else 0
914
    is_test = 1 if step.kind == "test" else 0
915
    return {
916
        "tool_call_count": 1,
917
        "terminal_tool_call_count": 1,
918
        "vcs_tool_call_count": is_vcs,
919
        "test_tool_call_count": is_test,
920
        **oakbench_tokens.interaction_token_fields(
921
            command_display(command),
922
            stdout_text,
923
            stderr_text,
924
            stdout_bytes,
925
            stderr_bytes,
926
            stdout_truncated,
927
            stderr_truncated,
928
        ),
929
        "tool_calls": {
930
            "total": 1,
931
            "terminal": 1,
932
            "vcs": is_vcs,
933
            "test": is_test,
934
            step.kind: 1,
935
        },
936
    }
937
938
939
def run_step(
940
    subject: Subject,
941
    workflow: Workflow,
942
    step: Step,
943
    cwd: Path,
944
    run_index: int,
945
    phase: str,
946
    metadata: dict[str, Any],
947
    admitted_output_chars: int,
948
) -> dict[str, Any]:
949
    capture = run_timed(step.command, cwd, step_env(step), admitted_output_chars)
950
    expected = capture.returncode in step.expected_returncodes
951
    row = {
952
        **metadata,
953
        "subject": subject.name,
954
        "subject_kind": subject.kind,
955
        "subject_label": subject.label,
956
        "scenario": workflow.name,
957
        "workflow": workflow.name,
958
        "workflow_description": workflow.description,
959
        "run": run_index,
960
        "phase": phase,
961
        "operation": step.operation,
962
        "step_kind": step.kind,
963
        "elapsed_ms": round(capture.elapsed_ms, 3),
964
        "returncode": 0 if expected else (capture.returncode or 1),
965
        "process_returncode": capture.returncode,
966
        "expected_returncodes": list(step.expected_returncodes),
967
        "command": step.command,
968
        **interaction_metrics(
969
            step.command,
970
            capture.stdout_text,
971
            capture.stderr_text,
972
            capture.stdout_bytes,
973
            capture.stderr_bytes,
974
            capture.stdout_truncated,
975
            capture.stderr_truncated,
976
            step,
977
        ),
978
    }
979
    if not expected:
980
        row["stderr"] = capture.stderr_text[-4000:]
981
    if step.recovery_hint is not None:
982
        combined = (capture.stdout_text + capture.stderr_text).lower()
983
        row["recovery_hint"] = step.recovery_hint
984
        row["error_mentions_recovery_command"] = step.recovery_hint.lower() in combined
985
        row["error_actionability_source"] = "output_contains_recovery_command"
986
    return row
987
988
989
def snapshot_step_requires_state_change(operation: str) -> bool:
990
    return operation in {"setup.snapshot", "vcs.snapshot", "vcs.snapshot.redo"} or (
991
        ".snapshot" in operation and operation.endswith(".commit")
992
    )
993
994
995
def branch_step_requires_state_change(operation: str) -> bool:
996
    return operation == "vcs.branch"
997
998
999
def apply_state_change_attestation(
1000
    row: dict[str, Any],
1001
    repo: Path,
1002
    before_fingerprint: str | None,
1003
    operation: str,
1004
    *,
1005
    expected_hashes: dict[str, str] | None = None,
1006
    expected_payloads: dict[str, str | bytes] | None = None,
1007
) -> dict[str, Any]:
1008
    raw_process_returncode = row.get("process_returncode")
1009
    if raw_process_returncode is None:
1010
        process_returncode = row_returncode(row)
1011
    else:
1012
        try:
1013
            process_returncode = int(raw_process_returncode)
1014
        except (TypeError, ValueError):
1015
            process_returncode = 1
1016
    fields = oakbench_integrity.state_change_fields(
1017
        repo,
1018
        before_fingerprint,
1019
        operation=operation,
1020
        process_returncode=process_returncode,
1021
    )
1022
    row.update(fields)
1023
    failure_reason = fields["integrity_failure_reason"] if not fields["integrity_check_passed"] else None
1024
    if expected_hashes or expected_payloads:
1025
        content_fields = oakbench_integrity.content_integrity_fields(
1026
            repo,
1027
            operation=operation,
1028
            process_returncode=process_returncode,
1029
            expected_hashes=expected_hashes,
1030
            expected_payloads=expected_payloads,
1031
        )
1032
        row.update(content_fields)
1033
        if not content_fields["content_integrity_check_passed"] and failure_reason is None:
1034
            failure_reason = content_fields["content_integrity_failure_reason"]
1035
    if process_returncode == 0 and failure_reason is not None:
1036
        row["returncode"] = 1
1037
        row["stderr"] = str(failure_reason or "integrity_check_failed")
1038
    return row
1039
1040
1041
def workflow_step_payload_requirements(workflow: Workflow, operation: str) -> list[tuple[str, str]]:
1042
    """Content payloads required at an intermediate step.
1043
1044
    Final workflow integrity can require the fully converged payload. Snapshot
1045
    steps must only require payloads that are expected to exist in that phase.
1046
    """
1047
    if workflow.name == "sync_push_divergence" and operation in {
1048
        "vcs.snapshot",
1049
        "vcs.snapshot.commit",
1050
        "vcs.snapshot.redo",
1051
        "vcs.snapshot.redo.commit",
1052
    }:
1053
        return [("work.txt", "local change")]
1054
    return []
1055
1056
1057
def apply_content_payload_attestation(
1058
    row: dict[str, Any],
1059
    repo: Path,
1060
    expected_payloads: list[tuple[str, str]],
1061
) -> dict[str, Any]:
1062
    if not expected_payloads:
1063
        return row
1064
    fields = oakbench_integrity.content_payload_fields(repo, expected_payloads)
1065
    row.update(fields)
1066
    if row_returncode(row) == 0 and not fields["content_payload_check_passed"]:
1067
        reason = str(fields["content_payload_failure_reason"] or "content_payload_check_failed")
1068
        row["returncode"] = 1
1069
        row["process_returncode"] = 1
1070
        row["stderr"] = reason
1071
        row["integrity_check_passed"] = False
1072
        row["integrity_failure_reason"] = reason
1073
        row["integrity_check_source"] = "content_payload_attestation"
1074
    return row
1075
1076
1077
def run_attested_step(
1078
    subject: Subject,
1079
    workflow: Workflow,
1080
    step: Step,
1081
    cwd: Path,
1082
    run_index: int,
1083
    phase: str,
1084
    metadata: dict[str, Any],
1085
    admitted_output_chars: int,
1086
) -> dict[str, Any]:
1087
    requires_state_change = snapshot_step_requires_state_change(step.operation) or branch_step_requires_state_change(
1088
        step.operation
1089
    )
1090
    before = oakbench_integrity.repository_state_fingerprint(cwd) if requires_state_change else None
1091
    expected_hashes = None
1092
    expected_payloads = None
1093
    if snapshot_step_requires_state_change(step.operation):
1094
        expected_hashes = oakbench_integrity.sample_worktree_payload_hashes(cwd)
1095
        if phase == "workflow":
1096
            expected_payloads = workflow_snapshot_payload_expectations(workflow)
1097
    row = run_step(subject, workflow, step, cwd, run_index, phase, metadata, admitted_output_chars)
1098
    if requires_state_change:
1099
        apply_state_change_attestation(
1100
            row,
1101
            cwd,
1102
            before,
1103
            step.operation,
1104
            expected_hashes=expected_hashes,
1105
            expected_payloads=expected_payloads,
1106
        )
1107
    apply_content_payload_attestation(
1108
        row,
1109
        cwd,
1110
        workflow_step_payload_requirements(workflow, step.operation),
1111
    )
1112
    return row
1113
1114
1115
def _read_text(path: Path) -> str | None:
1116
    try:
1117
        return path.read_text(errors="replace")
1118
    except OSError:
1119
        return None
1120
1121
1122
def workflow_snapshot_payload_expectations(workflow: Workflow) -> dict[str, str]:
1123
    expectations: dict[str, dict[str, str]] = {
1124
        "bugfix_test_loop": {"app/pricing.py": "return cents - ((cents * percent) // 100)"},
1125
        "wide_config_refactor": {"services/service-0000/config.toml": "timeout_ms = 3000"},
1126
        "large_asset_manifest": {"manifest.json": '"reviewed": true'},
1127
        "vcs_error_recovery": {"README.md": "# recovered"},
1128
        "sync_push_divergence": {"work.txt": "local change", "notes.txt": "upstream change"},
1129
        "sync_pull_upstream": {"notes.txt": "upstream change"},
1130
        "sync_pull_dirty": {"notes.txt": "upstream change"},
1131
        "sync_non_ff_after_amend": {"work.txt": "amended line"},
1132
    }
1133
    return expectations.get(workflow.name, {})
1134
1135
1136
def workflow_worktree_payload_failure(workflow: Workflow, repo: Path) -> str | None:
1137
    name = workflow.name
1138
    if name == "bugfix_test_loop":
1139
        text = _read_text(repo / "app" / "pricing.py")
1140
        expected = "return cents - ((cents * percent) // 100)"
1141
        return None if text is not None and expected in text else "missing bugfix payload in app/pricing.py"
1142
    if name == "wide_config_refactor":
1143
        configs = list((repo / "services").glob("*/config.toml"))
1144
        if not configs:
1145
            return "missing services config files"
1146
        if any("timeout_ms = 2500" in (path.read_text(errors="replace")) for path in configs):
1147
            return "deprecated timeout payload still present"
1148
        sample = _read_text(repo / "services" / "service-0000" / "config.toml")
1149
        return None if sample is not None and "timeout_ms = 3000" in sample else "missing rewritten timeout payload"
1150
    if name == "large_asset_manifest":
1151
        try:
1152
            data = json.loads((repo / "manifest.json").read_text())
1153
        except (OSError, json.JSONDecodeError):
1154
            return "manifest payload missing or invalid"
1155
        return None if all(item.get("reviewed") is True for item in data.get("assets", [])) else "manifest reviewed payload missing"
1156
    if name == "vcs_error_recovery":
1157
        text = _read_text(repo / "README.md")
1158
        return None if text is not None and "# recovered" in text else "recovery payload missing in README.md"
1159
    if name == "sync_push_divergence":
1160
        work = _read_text(repo / "work.txt") or ""
1161
        notes = _read_text(repo / "notes.txt") or ""
1162
        if "local change" not in work:
1163
            return "local pushed payload missing from work.txt"
1164
        if "upstream change" not in notes:
1165
            return "upstream pulled payload missing from notes.txt"
1166
        return None
1167
    if name == "sync_pull_upstream":
1168
        notes = _read_text(repo / "notes.txt") or ""
1169
        return None if "upstream change" in notes else "upstream pulled payload missing from notes.txt"
1170
    if name == "sync_pull_dirty":
1171
        work = _read_text(repo / "work.txt") or ""
1172
        notes = _read_text(repo / "notes.txt") or ""
1173
        if "dirty local edit" not in work:
1174
            return "dirty local payload missing from work.txt"
1175
        if "upstream change" not in notes:
1176
            return "upstream pulled payload missing from notes.txt"
1177
        return None
1178
    if name == "sync_non_ff_after_amend":
1179
        work = _read_text(repo / "work.txt") or ""
1180
        return None if "amended line" in work else "amended payload missing from work.txt"
1181
    return None
1182
1183
1184
def _git_head_text(repo: Path, path: str) -> tuple[str | None, str | None]:
1185
    git = shutil.which("git")
1186
    if git is None:
1187
        return None, "known_good_git_unavailable_for_head_payload_check"
1188
    proc = subprocess.run(
1189
        [git, "-C", str(repo), "show", f"HEAD:{path}"],
1190
        stdout=subprocess.PIPE,
1191
        stderr=subprocess.PIPE,
1192
        text=True,
1193
        errors="replace",
1194
        check=False,
1195
    )
1196
    if proc.returncode != 0:
1197
        return None, f"known_good_git_head_read_failed:{path}"
1198
    return proc.stdout, None
1199
1200
1201
def workflow_git_head_payload_failure(workflow: Workflow, repo: Path) -> str | None:
1202
    checks = workflow_snapshot_payload_expectations(workflow)
1203
    if not checks:
1204
        return None
1205
    for path, expected in checks.items():
1206
        text, error = _git_head_text(repo, path)
1207
        if error is not None:
1208
            return error
1209
        if text is None or expected not in text:
1210
            return f"committed_payload_missing:{path}"
1211
    return None
1212
1213
1214
def workflow_integrity_fields(
1215
    subject: Subject,
1216
    workflow: Workflow,
1217
    repo: Path,
1218
    workflow_rows: list[dict[str, Any]],
1219
) -> dict[str, Any]:
1220
    failures = [row for row in workflow_rows if row_returncode(row) != 0]
1221
    snapshot_failures = [
1222
        row
1223
        for row in workflow_rows
1224
        if row.get("integrity_check_passed") is False or row.get("content_integrity_check_passed") is False
1225
    ]
1226
    reason: str | None = None
1227
    source = "worktree_payload_oracle"
1228
    if failures:
1229
        first = failures[0]
1230
        reason = str(
1231
            first.get("integrity_failure_reason")
1232
            or first.get("content_integrity_failure_reason")
1233
            or f"workflow_step_failed:{first.get('operation')}"
1234
        )
1235
    elif snapshot_failures:
1236
        first = snapshot_failures[0]
1237
        reason = str(
1238
            first.get("integrity_failure_reason")
1239
            or first.get("content_integrity_failure_reason")
1240
            or f"snapshot_integrity_failed:{first.get('operation')}"
1241
        )
1242
1243
    if reason is None:
1244
        reason = workflow_worktree_payload_failure(workflow, repo)
1245
1246
    if reason is None and subject.kind == "git":
1247
        reason = workflow_git_head_payload_failure(workflow, repo)
1248
        source = "worktree_payload_oracle;known_good_git_head_tree"
1249
    elif reason is None and any(row.get("integrity_check_passed") is True for row in workflow_rows):
1250
        source = "worktree_payload_oracle;direct_repo_metadata_fingerprint"
1251
1252
    passed = reason is None
1253
    return {
1254
        "workflow_integrity_passed": passed,
1255
        "workflow_integrity_failure_reason": reason,
1256
        "workflow_integrity_source": source,
1257
        "correctness_passed": passed,
1258
        "correctness_failure_reason": reason,
1259
    }
1260
1261
1262
def recovery_metrics_row(
1263
    workflow_rows: list[dict[str, Any]],
1264
    metadata: dict[str, Any],
1265
    subject: Subject,
1266
    workflow: Workflow,
1267
    run_index: int,
1268
) -> dict[str, Any] | None:
1269
    """Derived recovery-cost row for workflows that hit expected failures.
1270
1271
    Error output is a prompt: the rejected command's text is what the agent
1272
    reasons from. This row prices that text (model-input tokens) and the
1273
    recovery arc that follows it. tool_call_count is 0 — the row performs no
1274
    calls and must not perturb tool-call or token deltas (bench.py probe-row
1275
    precedent).
1276
    """
1277
    error_rows = [r for r in workflow_rows if str(r.get("operation", "")).startswith("error.")]
1278
    recovery_prefixes = ("recovery.", "edit.redo", "vcs.snapshot.redo")
1279
    recovery_free_prefixes = ("recovery_free.",)
1280
    recovery_rows = [
1281
        r for r in workflow_rows if str(r.get("operation", "")).startswith(recovery_prefixes)
1282
    ]
1283
    recovery_free_rows = [
1284
        r for r in workflow_rows if str(r.get("operation", "")).startswith(recovery_free_prefixes)
1285
    ]
1286
    if not error_rows and not recovery_rows and not recovery_free_rows:
1287
        return None
1288
    hinted = [r for r in error_rows if "error_mentions_recovery_command" in r]
1289
    actionable = [r for r in hinted if r.get("error_mentions_recovery_command")]
1290
    return {
1291
        **metadata,
1292
        "subject": subject.name,
1293
        "subject_kind": subject.kind,
1294
        "subject_label": subject.label,
1295
        "scenario": workflow.name,
1296
        "workflow": workflow.name,
1297
        "workflow_description": workflow.description,
1298
        "run": run_index,
1299
        "phase": "workflow",
1300
        "operation": "sync.recovery_metrics",
1301
        "step_kind": "summary",
1302
        "elapsed_ms": 0.0,
1303
        "returncode": 0,
1304
        "process_returncode": 0,
1305
        "command": [],
1306
        "tool_call_count": 0,
1307
        "terminal_tool_call_count": 0,
1308
        "vcs_tool_call_count": 0,
1309
        "test_tool_call_count": 0,
1310
        "error_steps_count": len(error_rows),
1311
        # The failure text an agent must ingest before it can act, priced as
1312
        # model input (output of the tool, input to the model).
1313
        "error_output_tokens": sum(int(r.get("estimated_tokens_output", 0)) for r in error_rows),
1314
        "recovery_steps_count": len(recovery_rows),
1315
        "recovery_wall_ms": round(sum(float(r.get("elapsed_ms", 0)) for r in recovery_rows), 3),
1316
        "recovery_tokens_total": round(
1317
            sum(float(r.get("estimated_cost_weighted_tokens_with_envelope", 0)) for r in recovery_rows), 1
1318
        ),
1319
        "error_steps_with_hint": len(hinted),
1320
        "error_steps_hint_actionable": len(actionable),
1321
        # Null when no step declared a hint: unmeasured, never a fabricated pass.
1322
        "error_actionability": (len(actionable) == len(hinted)) if hinted else None,
1323
        "measurement_source": "derived_from_workflow_rows; hint check is output_contains_recovery_command",
1324
        "summarized_operations": [r["operation"] for r in error_rows + recovery_rows + recovery_free_rows],
1325
    }
1326
1327
1328
def sum_rows(
1329
    rows: list[dict[str, Any]],
1330
    metadata: dict[str, Any],
1331
    subject: Subject,
1332
    workflow: Workflow,
1333
    run_index: int,
1334
    phase: str,
1335
    operation: str,
1336
    extra_fields: dict[str, Any] | None = None,
1337
) -> dict[str, Any]:
1338
    elapsed_ms = sum(float(row.get("elapsed_ms", 0)) for row in rows)
1339
    tool_calls = sum(oakbench_tokens.int_or_zero(row.get("tool_call_count")) for row in rows)
1340
    terminal_calls = sum(oakbench_tokens.int_or_zero(row.get("terminal_tool_call_count")) for row in rows)
1341
    vcs_calls = sum(oakbench_tokens.int_or_zero(row.get("vcs_tool_call_count")) for row in rows)
1342
    test_calls = sum(oakbench_tokens.int_or_zero(row.get("test_tool_call_count")) for row in rows)
1343
    failures = [row for row in rows if row_returncode(row) != 0]
1344
    steps_total = len(rows)
1345
    steps_failed = len(failures)
1346
    result = {
1347
        **metadata,
1348
        "subject": subject.name,
1349
        "subject_kind": subject.kind,
1350
        "subject_label": subject.label,
1351
        "scenario": workflow.name,
1352
        "workflow": workflow.name,
1353
        "workflow_description": workflow.description,
1354
        "run": run_index,
1355
        "phase": phase,
1356
        "operation": operation,
1357
        "step_kind": "summary",
1358
        "elapsed_ms": round(elapsed_ms, 3),
1359
        "returncode": 1 if failures else 0,
1360
        "process_returncode": 1 if failures else 0,
1361
        "command": [row["command"] for row in rows],
1362
        "tool_call_count": tool_calls,
1363
        "terminal_tool_call_count": terminal_calls,
1364
        "vcs_tool_call_count": vcs_calls,
1365
        "test_tool_call_count": test_calls,
1366
        **oakbench_tokens.summed_token_fields(
1367
            rows, "sum_of_workflow_steps_command_plus_admitted_output_chars_div_4"
1368
        ),
1369
        "tool_calls": {
1370
            "total": tool_calls,
1371
            "terminal": terminal_calls,
1372
            "vcs": vcs_calls,
1373
            "test": test_calls,
1374
        },
1375
        "steps_total": steps_total,
1376
        "steps_succeeded": steps_total - steps_failed,
1377
        "steps_failed": steps_failed,
1378
        "step_failure_rate": (steps_failed / steps_total) if steps_total else None,
1379
        "summarized_operations": [row["operation"] for row in rows],
1380
    }
1381
    if extra_fields:
1382
        result.update(extra_fields)
1383
        if extra_fields.get("workflow_integrity_passed") is False:
1384
            result["returncode"] = 1
1385
            result["process_returncode"] = 1
1386
    return result
1387
1388
1389
def copy_fixture(src: Path, dest: Path) -> None:
1390
    oakbench_fixtures.copy_fixture(src, dest)
1391
1392
1393
def prepare_fixture(root: Path, workflow: Workflow) -> Path:
1394
    fixture = root / "fixtures" / workflow.name
1395
    marker = fixture / ".fixture-ready"
1396
    if marker.exists() and marker.read_text().strip() == FIXTURE_VERSION:
1397
        return fixture
1398
    if fixture.exists():
1399
        shutil.rmtree(fixture)
1400
    fixture.mkdir(parents=True)
1401
    workflow.make_fixture(fixture)
1402
    marker.write_text(FIXTURE_VERSION + "\n")
1403
    return fixture
1404
1405
1406
def run_subject_workflow(
1407
    subject: Subject,
1408
    workflow: Workflow,
1409
    fixture: Path,
1410
    run_index: int,
1411
    run_root: Path,
1412
    metadata: dict[str, Any],
1413
    admitted_output_chars: int,
1414
    track: str,
1415
) -> list[dict[str, Any]]:
1416
    repo = run_root / workflow.name / subject.name / f"run-{run_index}"
1417
    copy_fixture(fixture, repo)
1418
1419
    ctx: dict[str, str] | None = None
1420
    if workflow.remote_purpose is not None:
1421
        ctx, skip_reason, remote_fields = sync_context(subject, workflow, run_index, metadata)
1422
        if ctx is None:
1423
            return [workflow_skip_row(subject, workflow, run_index, metadata, skip_reason or "")]
1424
        metadata = {**metadata, **remote_fields}
1425
1426
    setup_steps = list(vcs_init_steps(subject))
1427
    if workflow.prepare is not None:
1428
        setup_steps.extend(
1429
            workflow.prepare(subject, track) if ctx is None else workflow.prepare(subject, track, ctx)
1430
        )
1431
    setup_rows = [
1432
        run_attested_step(subject, workflow, step, repo, run_index, "setup", metadata, admitted_output_chars)
1433
        for step in setup_steps
1434
    ]
1435
1436
    workflow_rows: list[dict[str, Any]] = []
1437
    if any(int(row["returncode"]) != 0 for row in setup_rows):
1438
        return setup_rows + [
1439
            sum_rows(setup_rows, metadata, subject, workflow, run_index, "setup", "setup.total"),
1440
        ]
1441
1442
    # Oak remote workflows measure inside clone A (the trap-hitting checkout);
1443
    # everything else measures in the fixture repo itself.
1444
    measure_dir = repo
1445
    if ctx is not None and "measure_dir" in ctx:
1446
        measure_dir = (repo / ctx["measure_dir"]).resolve()
1447
    for step in workflow.steps(subject, track) if ctx is None else workflow.steps(subject, track, ctx):
1448
        row = run_attested_step(
1449
            subject, workflow, step, measure_dir, run_index, "workflow", metadata, admitted_output_chars
1450
        )
1451
        workflow_rows.append(row)
1452
        if int(row["returncode"]) != 0:
1453
            break
1454
1455
    derived = recovery_metrics_row(workflow_rows, metadata, subject, workflow, run_index)
1456
    integrity = workflow_integrity_fields(subject, workflow, measure_dir, workflow_rows)
1457
    return [
1458
        *setup_rows,
1459
        sum_rows(setup_rows, metadata, subject, workflow, run_index, "setup", "setup.total"),
1460
        *workflow_rows,
1461
        sum_rows(
1462
            workflow_rows,
1463
            metadata,
1464
            subject,
1465
            workflow,
1466
            run_index,
1467
            "workflow",
1468
            "workflow.total",
1469
            integrity,
1470
        ),
1471
        *([derived] if derived else []),
1472
    ]
1473
1474
1475
def select_workflows(raw: str) -> list[Workflow]:
1476
    if raw == "all":
1477
        return list(WORKFLOWS.values())
1478
    names = [item.strip() for item in raw.split(",") if item.strip()]
1479
    missing = [name for name in names if name not in WORKFLOWS]
1480
    if missing:
1481
        raise SystemExit("Unknown workflows: " + ", ".join(missing))
1482
    return [WORKFLOWS[name] for name in names]
1483
1484
1485
def remote_preflight_requirements(
1486
    subjects: list[Subject],
1487
    workflows: list[Workflow],
1488
) -> list[oakbench_remotes.RemoteRequirement]:
1489
    if not any(subject.kind == "oak" for subject in subjects):
1490
        return []
1491
    grouped: dict[str, list[str]] = {}
1492
    for workflow in workflows:
1493
        if workflow.remote_purpose is None:
1494
            continue
1495
        grouped.setdefault(workflow.remote_purpose, []).append(f"{workflow.name}/workflow.skipped")
1496
    return [
1497
        oakbench_remotes.RemoteRequirement(
1498
            purpose=purpose,
1499
            lane="workflow",
1500
            operations=tuple(operations),
1501
        )
1502
        for purpose, operations in sorted(grouped.items())
1503
    ]
1504
1505
1506
def metric_average(rows: list[dict[str, Any]], metric: str) -> dict[tuple[str, str], float]:
1507
    grouped: dict[tuple[str, str], list[float]] = {}
1508
    for row in rows:
1509
        if row.get("operation") != "workflow.total" or row_returncode(row) != 0:
1510
            continue
1511
        try:
1512
            value = float(row[metric])
1513
        except (KeyError, TypeError, ValueError):
1514
            continue
1515
        grouped.setdefault((str(row["subject"]), str(row["workflow"])), []).append(value)
1516
    return {key: statistics.mean(values) for key, values in grouped.items()}
1517
1518
1519
def metric_transport(rows: list[dict[str, Any]]) -> dict[tuple[str, str], tuple[str | None, str | None]]:
1520
    transports: dict[tuple[str, str], set[tuple[str | None, str | None]]] = {}
1521
    for row in rows:
1522
        if row.get("operation") != "workflow.total" or row_returncode(row) != 0:
1523
            continue
1524
        key = (str(row["subject"]), str(row["workflow"]))
1525
        transport = row.get("remote_transport") or row.get("workspace_transport")
1526
        server = row.get("remote_server") or row.get("workspace_server")
1527
        transports.setdefault(key, set()).add(
1528
            (
1529
                str(transport) if transport is not None else None,
1530
                str(server) if server is not None else None,
1531
            )
1532
        )
1533
1534
    result: dict[tuple[str, str], tuple[str | None, str | None]] = {}
1535
    for key, values in transports.items():
1536
        if len(values) == 1:
1537
            result[key] = next(iter(values))
1538
        else:
1539
            result[key] = ("mixed", "mixed")
1540
    return result
1541
1542
1543
def workflow_total_stats(rows: list[dict[str, Any]]) -> dict[tuple[str, str], dict[str, int | float]]:
1544
    grouped: dict[tuple[str, str], list[dict[str, Any]]] = {}
1545
    for row in rows:
1546
        if row.get("operation") != "workflow.total":
1547
            continue
1548
        grouped.setdefault((str(row["subject"]), str(row["workflow"])), []).append(row)
1549
    stats: dict[tuple[str, str], dict[str, int | float]] = {}
1550
    for key, total_rows in grouped.items():
1551
        runs = len(total_rows)
1552
        failures = sum(1 for row in total_rows if row_returncode(row) != 0)
1553
        stats[key] = {
1554
            "runs": runs,
1555
            "successes": runs - failures,
1556
            "failures": failures,
1557
            "failure_rate": failures / runs if runs else 0.0,
1558
        }
1559
    return stats
1560
1561
1562
def lower_is_better_pct(base: float | None, value: float | None) -> str:
1563
    return fmt_delta_lower_better(base, value)
1564
1565
1566
def comparable_time_delta(
1567
    base: float | None,
1568
    value: float | None,
1569
    base_transport: tuple[str | None, str | None] | None,
1570
    value_transport: tuple[str | None, str | None] | None,
1571
) -> str:
1572
    if base is None or value is None:
1573
        return ""
1574
    if base_transport != value_transport:
1575
        return "n/c transport"
1576
    return lower_is_better_pct(base, value)
1577
1578
1579
def fmt(value: float | None, precision: int = 1) -> str:
1580
    return fmt_num(value, precision)
1581
1582
1583
def summary_text(rows: list[dict[str, Any]], subjects: list[Subject], workflows: list[Workflow]) -> str:
1584
    avg_ms = metric_average(rows, "elapsed_ms")
1585
    avg_tokens = metric_average(rows, "estimated_tokens_total")
1586
    avg_tools = metric_average(rows, "tool_call_count")
1587
    avg_output = metric_average(rows, "raw_output_bytes")
1588
    transports = metric_transport(rows)
1589
    total_stats = workflow_total_stats(rows)
1590
    git_name = next((subject.name for subject in subjects if subject.kind == "git"), None)
1591
    oak_baseline = next((subject.name for subject in subjects if subject.name in {"oak_installed", "oak_main"}), None)
1592
1593
    lines = [
1594
        "# Oak Workflow A/B Summary",
1595
        "",
1596
        f"Track: `{rows[0].get('benchmark_track', 'unknown') if rows else 'unknown'}`.",
1597
        "Rows compare whole deterministic agent-shaped workflows. Positive deltas mean the row subject is lower/better.",
1598
        "Time deltas are not comparable across different remote/workspace transports; token and tool-call deltas remain comparable.",
1599
        "Output bytes are full stdout/stderr bytes, even when admitted token text was capped.",
1600
        "",
1601
        "| Workflow | Subject | Runs | Successes | Failures | Failure rate | Avg ms | Avg est tokens | Avg output bytes | Avg tool calls | vs Git time | vs Git tokens | vs Git tools | vs Oak time | vs Oak tokens | vs Oak tools |",
1602
        "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
1603
    ]
1604
    for workflow in workflows:
1605
        git_ms = avg_ms.get((git_name, workflow.name)) if git_name else None
1606
        git_tokens = avg_tokens.get((git_name, workflow.name)) if git_name else None
1607
        git_tools = avg_tools.get((git_name, workflow.name)) if git_name else None
1608
        git_transport = transports.get((git_name, workflow.name)) if git_name else None
1609
        oak_ms = avg_ms.get((oak_baseline, workflow.name)) if oak_baseline else None
1610
        oak_tokens = avg_tokens.get((oak_baseline, workflow.name)) if oak_baseline else None
1611
        oak_tools = avg_tools.get((oak_baseline, workflow.name)) if oak_baseline else None
1612
        oak_transport = transports.get((oak_baseline, workflow.name)) if oak_baseline else None
1613
        for subject in subjects:
1614
            key = (subject.name, workflow.name)
1615
            value_ms = avg_ms.get(key)
1616
            stats = total_stats.get(key)
1617
            if value_ms is None and stats is None:
1618
                continue
1619
            value_tokens = avg_tokens.get(key)
1620
            value_tools = avg_tools.get(key)
1621
            value_output = avg_output.get(key)
1622
            value_transport = transports.get(key)
1623
            runs = int(stats["runs"]) if stats else 0
1624
            successes = int(stats["successes"]) if stats else 0
1625
            failures = int(stats["failures"]) if stats else 0
1626
            failure_rate = float(stats["failure_rate"]) if stats else 0.0
1627
            skip_git = subject.name == git_name
1628
            skip_oak = subject.name == oak_baseline or subject.kind == "git"
1629
            lines.append(
1630
                f"| `{workflow.name}` | `{subject.name}` | {runs} | {successes} | {failures} | "
1631
                f"{failure_rate:.0%} | {fmt(value_ms)} | {fmt(value_tokens)} | "
1632
                f"{fmt(value_output, 0)} | {fmt(value_tools, 2)} | "
1633
                f"{'' if skip_git else comparable_time_delta(git_ms, value_ms, git_transport, value_transport)} | "
1634
                f"{'' if skip_git else lower_is_better_pct(git_tokens, value_tokens)} | "
1635
                f"{'' if skip_git else lower_is_better_pct(git_tools, value_tools)} | "
1636
                f"{'' if skip_oak else comparable_time_delta(oak_ms, value_ms, oak_transport, value_transport)} | "
1637
                f"{'' if skip_oak else lower_is_better_pct(oak_tokens, value_tokens)} | "
1638
                f"{'' if skip_oak else lower_is_better_pct(oak_tools, value_tools)} |"
1639
            )
1640
    return "\n".join(lines) + "\n"
1641
1642
1643
def has_lane_failures(rows: list[dict[str, Any]]) -> bool:
1644
    return any(row_returncode(row) not in (0, SKIP_RETURNCODE) for row in rows)
1645
1646
1647
def main() -> int:
1648
    args = parse_args()
1649
    subjects = load_subjects(args)
1650
    workflows = select_workflows(args.workflows)
1651
    oakbench_remotes.print_remote_preflight_warnings(
1652
        oakbench_remotes.oak_remote_preflight_warnings(
1653
            remote_preflight_requirements(subjects, workflows)
1654
        )
1655
    )
1656
1657
    timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
1658
    args.results.mkdir(parents=True, exist_ok=True)
1659
    args.workdir.mkdir(parents=True, exist_ok=True)
1660
    run_root = args.workdir / "runs" / timestamp
1661
    fixture_root = args.workdir
1662
1663
    metadata = {
1664
        "bench_id": timestamp,
1665
        "profile": "workflow-ab",
1666
        "benchmark_track": args.track,
1667
        "command_semantics_version": COMMAND_SEMANTICS_VERSION,
1668
        "timestamp_utc": timestamp,
1669
        "host": platform.node(),
1670
        "platform": platform.platform(),
1671
        "machine": platform.machine(),
1672
        "python": platform.python_version(),
1673
        "env_isolation_version": oakbench_environment.ENV_ISOLATION_VERSION,
1674
        "subject_versions": {subject.name: subject_version(subject) for subject in subjects},
1675
        "subject_details": subject_details(subjects),
1676
        "source": source_metadata(args.oak_repo),
1677
    }
1678
1679
    metadata["load_tier"] = args.load_tier
1680
1681
    rows: list[dict[str, Any]] = []
1682
    # Background load (untimed start/stop). Failing to start under a non-none
1683
    # tier converts the whole invocation to skip rows: load that was not
1684
    # applied is never claimed.
1685
    load_generator: oakbench_loadgen.LoadGenerator | None = None
1686
    if args.load_tier != "none":
1687
        load_generator = oakbench_loadgen.LoadGenerator(
1688
            args.load_tier, args.workdir / "loadgen-temp"
1689
        )
1690
        load_start = load_generator.start()
1691
        if not load_start["started"]:
1692
            reason = oakbench_loadgen.load_tier_unavailable_reason(str(load_start["reason"]))
1693
            rows = load_tier_skip_rows(subjects, workflows, args.runs, metadata, reason, args.load_tier)
1694
            load_generator = None
1695
    try:
1696
        if not rows:
1697
            for workflow in workflows:
1698
                fixture = prepare_fixture(fixture_root, workflow)
1699
                print(f"[fixture] {workflow.name}: {workflow.description}", flush=True)
1700
                with measurement_lock("workflow_ab") as lock_info:
1701
                    metadata["measurement_lock_wait_ms"] = lock_info.wait_ms
1702
                    metadata["measurement_lock"] = "held" if lock_info.enabled else "disabled"
1703
                    for run_index in range(args.runs):
1704
                        run_subjects = list(subjects)
1705
                        if args.randomize_subject_order:
1706
                            random.Random(f"{timestamp}:{workflow.name}:{run_index}").shuffle(run_subjects)
1707
                        for subject in run_subjects:
1708
                            print(f"[run] {workflow.name} run={run_index} subject={subject.name}", flush=True)
1709
                            env_before = oakbench_envwatch.sample_environment()
1710
                            subject_rows = run_subject_workflow(
1711
                                subject,
1712
                                workflow,
1713
                                fixture,
1714
                                run_index,
1715
                                run_root,
1716
                                metadata,
1717
                                args.admitted_output_chars,
1718
                                args.track,
1719
                            )
1720
                            env_after = oakbench_envwatch.sample_environment()
1721
                            boundary = oakbench_loadgen.environment_boundary_fields(
1722
                                args.load_tier, env_before, env_after
1723
                            )
1724
                            for row in subject_rows:
1725
                                row.update(boundary)
1726
                            rows.extend(subject_rows)
1727
    finally:
1728
        if load_generator is not None:
1729
            load_stop = load_generator.stop()
1730
            # bogo-ops parsed ONLY as did-the-load-run verification, never
1731
            # reported as a benchmark number.
1732
            for row in rows:
1733
                row["load_verified"] = load_stop.get("verified")
1734
1735
    store = ResultsStore(args.results, lane="workflow")
1736
    raw_path, summary_path = store.write(timestamp, rows, summary_text(rows, subjects, workflows))
1737
1738
    if not args.keep_workdirs and run_root.exists():
1739
        shutil.rmtree(run_root)
1740
1741
    print(f"[result] {raw_path}")
1742
    print(f"[summary] {summary_path}")
1743
    return 1 if has_lane_failures(rows) else 0
1744
1745
1746
if __name__ == "__main__":
1747
    raise SystemExit(main())