Viewing files at commit 369435870869Commit detailsView latest

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