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