Viewing files at commit 25881039a2bbCommit detailsView latest

Browse files
scripts/task_loop.py 1768 lines · 69.5 KB · python
1
#!/usr/bin/env python3
2
"""The full task loop, end to end — the product's signature motion.
3
4
oak:  mount -> edit -> commit -> push -> desc -> end, then REMOUNT for a
5
      follow-up task (the warm-cache story) and repeat edit/commit/push/end.
6
git:  worktree add -> branch edit -> add/commit -> push -> worktree remove,
7
      then a second worktree for the follow-up. The task description lives in
8
      the commit message git already paid for; oak pays a separate `desc`
9
      call. That asymmetry is the measurement, not a harness gap
10
      (docs/command-semantics.md): this is agent-default-track, whole-task
11
      evidence, never a step-by-step equivalence claim.
12
13
Transport honesty: oak rows always cross the network to a real oak server.
14
git runs against GitHub (--git-remote github, transport-comparable) or a
15
local bare repo (--git-remote local, the core-equivalent track). Rows carry
16
remote_transport/remote_server; latency comparisons across transports are
17
forbidden — cross-subject evidence at unlike transports is tool calls and
18
tokens only.
19
20
Setup:
21
  OAK_BENCH_SYNC_REPO=oak/bench-sync-tmp \\
22
  [email protected]:oakdotspace/bench-task-loop-tmp.git \\
23
  python3 scripts/task_loop.py --runs 3
24
25
Unconfigured remotes emit skip rows (returncode 77), never silent absence.
26
"""
27
28
from __future__ import annotations
29
30
import argparse
31
import hashlib
32
import json
33
import os
34
import platform
35
import random
36
import re
37
import shutil
38
import socket
39
import subprocess
40
import tempfile
41
import threading
42
import time
43
import urllib.error
44
import urllib.parse
45
import urllib.request
46
from datetime import datetime, timezone
47
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
48
from pathlib import Path
49
from typing import Any
50
51
from oakbench import remotes as oakbench_remotes
52
from oakbench import tokens as oakbench_tokens
53
from oakbench.diskprobe import bounded_tree_usage
54
from oakbench.environment import ENV_ISOLATION_VERSION, base_env as oakbench_base_env
55
from oakbench.environment import command_display
56
from oakbench.execution import run_timed, stop_owned_process
57
from oakbench.results import ResultsStore
58
from oakbench.rows import row_returncode
59
from oakbench.runlock import measurement_lock
60
from oakbench.subjects import (
61
    DEFAULT_OAK_REPO,
62
    Subject,
63
    load_subjects,
64
    source_metadata,
65
    subject_details,
66
    subject_versions,
67
)
68
69
ROOT = Path(__file__).resolve().parents[1]
70
DEFAULT_WORKDIR = Path(tempfile.gettempdir()) / "oak-task-loop"
71
SKIP_RETURNCODE = 77
72
VIRTUAL_BRANCH_RE = re.compile(r"virtual branch ([^\s)]+)")
73
PHASES = ("task", "followup")
74
TASK_LOOP_SCENARIO = "task_loop"
75
AGENT_TASK_SCENARIO = "agent_task_baseline_v1"
76
AGENT_TASK_OAK_LOCAL_SCENARIO = "agent_task_oak_local_v1"
77
INTERRUPTED_PUBLICATION_SCENARIO = "interrupted_publication_after_request_v1"
78
INTERRUPTED_PUBLICATION_REQUEST_TIMEOUT_S = 2.0
79
INTERRUPTED_PUBLICATION_PROCESS_TIMEOUT_S = 30.0
80
INTERRUPTED_PUBLICATION_MAX_REQUEST_BYTES = 64 * 1024 * 1024
81
LOCAL_OAK_SERVER_READY_TIMEOUT_S = 10.0
82
83
84
def base_env() -> dict[str, str]:
85
    return oakbench_base_env(
86
        author_name="Oak Task Loop",
87
        author_email="[email protected]",
88
        oak_author="oak-task-loop",
89
    )
90
91
92
def parse_args() -> argparse.Namespace:
93
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
94
    parser.add_argument("--subjects", help="Comma-separated subject names from config/subjects.toml")
95
    parser.add_argument("--config", type=Path, default=ROOT / "config" / "subjects.toml")
96
    parser.add_argument("--git-bin", type=Path)
97
    parser.add_argument("--oak-installed-bin", type=Path)
98
    parser.add_argument("--oak-local-bin", type=Path)
99
    parser.add_argument("--runs", type=int, default=3)
100
    parser.add_argument(
101
        "--scenarios",
102
        default=TASK_LOOP_SCENARIO,
103
        help=(
104
            "Comma-separated scenarios: task_loop, agent_task_baseline_v1, "
105
            "agent_task_oak_local_v1, and/or interrupted_publication_after_request_v1. "
106
            "The interruption "
107
            "scenario is a loopback-only Oak fixture and never contacts a remote service."
108
        ),
109
    )
110
    parser.add_argument(
111
        "--git-remote",
112
        choices=("github", "local"),
113
        default="github",
114
        help=(
115
            "github: agent-default track, real push to GIT_BENCH_REMOTE "
116
            "(transport-comparable to oak). local: core-equivalent track, "
117
            "local bare remote (no network in git's loop)."
118
        ),
119
    )
120
    parser.add_argument("--workdir", type=Path, default=DEFAULT_WORKDIR)
121
    parser.add_argument("--results", type=Path, default=ROOT / "results" / "task-loop")
122
    parser.add_argument("--oak-repo", type=Path, default=DEFAULT_OAK_REPO)
123
    parser.add_argument("--admitted-output-chars", type=int, default=20_000)
124
    parser.add_argument("--keep-workdirs", action="store_true")
125
    parser.add_argument(
126
        "--randomize-subject-order",
127
        action="store_true",
128
        help="Shuffle subject order independently in each run using --random-seed.",
129
    )
130
    parser.add_argument("--random-seed", type=int, default=20260908)
131
    return parser.parse_args()
132
133
134
def run_untimed(command: list[str], cwd: Path | None, timeout: float = 300.0) -> subprocess.CompletedProcess[str]:
135
    return subprocess.run(
136
        command,
137
        cwd=cwd,
138
        env=base_env(),
139
        text=True,
140
        stdout=subprocess.PIPE,
141
        stderr=subprocess.PIPE,
142
        timeout=timeout,
143
        check=False,
144
    )
145
146
147
def step_row(
148
    subject: Subject,
149
    scenario: str,
150
    operation: str,
151
    command: list[str],
152
    cwd: Path,
153
    run_index: int,
154
    metadata: dict[str, Any],
155
    admitted_output_chars: int,
156
    *,
157
    kind: str = "vcs",
158
    expected_returncodes: tuple[int, ...] = (0,),
159
    preserve_stdout: bool = False,
160
    preserve_stderr: bool = False,
161
    env: dict[str, str] | None = None,
162
    timeout_seconds: float | None = None,
163
) -> dict[str, Any]:
164
    capture = run_timed(
165
        command,
166
        cwd,
167
        env or base_env(),
168
        admitted_output_chars,
169
        timeout_seconds=timeout_seconds,
170
    )
171
    expected = not capture.timed_out and capture.returncode in expected_returncodes
172
    is_vcs = 1 if kind == "vcs" else 0
173
    row = {
174
        **metadata,
175
        "subject": subject.name,
176
        "subject_kind": subject.kind,
177
        "subject_label": subject.label,
178
        "scenario": scenario,
179
        "run": run_index,
180
        "operation": operation,
181
        "step_kind": kind,
182
        "elapsed_ms": round(capture.elapsed_ms, 3),
183
        "returncode": 0 if expected else (capture.returncode or 1),
184
        "process_returncode": capture.returncode,
185
        "timed_out": capture.timed_out,
186
        "expected_returncodes": list(expected_returncodes),
187
        "command": command,
188
        "tool_call_count": 1,
189
        "terminal_tool_call_count": 1,
190
        "vcs_tool_call_count": is_vcs,
191
        **oakbench_tokens.interaction_token_fields(
192
            command_display(command),
193
            capture.stdout_text,
194
            capture.stderr_text,
195
            capture.stdout_bytes,
196
            capture.stderr_bytes,
197
            capture.stdout_truncated,
198
            capture.stderr_truncated,
199
        ),
200
    }
201
    if not expected:
202
        row["stderr"] = capture.stderr_text[-4000:]
203
    if preserve_stdout:
204
        row["stdout"] = capture.stdout_text
205
    if preserve_stderr:
206
        row["stderr"] = capture.stderr_text
207
    return row
208
209
210
def skip_row(
211
    subject: Subject,
212
    scenario: str,
213
    run_index: int,
214
    metadata: dict[str, Any],
215
    reason: str,
216
) -> dict[str, Any]:
217
    return {
218
        **metadata,
219
        "subject": subject.name,
220
        "subject_kind": subject.kind,
221
        "subject_label": subject.label,
222
        "scenario": scenario,
223
        "run": run_index,
224
        "operation": "task_loop.skipped",
225
        "step_kind": "summary",
226
        "elapsed_ms": 0.0,
227
        "returncode": SKIP_RETURNCODE,
228
        "command": [],
229
        "skipped": True,
230
        "skip_reason": reason,
231
        "tool_call_count": 0,
232
        "terminal_tool_call_count": 0,
233
        "vcs_tool_call_count": 0,
234
    }
235
236
237
def unmeasured_step_row(
238
    subject: Subject,
239
    scenario: str,
240
    operation: str,
241
    run_index: int,
242
    metadata: dict[str, Any],
243
    reason: str,
244
) -> dict[str, Any]:
245
    row = skip_row(subject, scenario, run_index, metadata, reason)
246
    row["operation"] = operation
247
    return row
248
249
250
def harness_step_row(
251
    subject: Subject,
252
    scenario: str,
253
    operation: str,
254
    run_index: int,
255
    metadata: dict[str, Any],
256
    elapsed_ms: float,
257
    *,
258
    returncode: int = 0,
259
    harness_command_count: int = 0,
260
) -> dict[str, Any]:
261
    return {
262
        **metadata,
263
        "subject": subject.name,
264
        "subject_kind": subject.kind,
265
        "subject_label": subject.label,
266
        "scenario": scenario,
267
        "run": run_index,
268
        "operation": operation,
269
        "step_kind": "harness",
270
        "elapsed_ms": round(elapsed_ms, 3),
271
        "returncode": returncode,
272
        "command": [],
273
        "tool_call_count": 0,
274
        "terminal_tool_call_count": 0,
275
        "vcs_tool_call_count": 0,
276
        "harness_command_count": harness_command_count,
277
        "stdout_bytes": 0,
278
        "stderr_bytes": 0,
279
        "retry_count": 0,
280
        "human_action_count": 0,
281
    }
282
283
284
def phase_total_row(
285
    rows: list[dict[str, Any]],
286
    subject: Subject,
287
    scenario: str,
288
    run_index: int,
289
    metadata: dict[str, Any],
290
    operation: str,
291
) -> dict[str, Any]:
292
    failures = [row for row in rows if row_returncode(row) not in (0, SKIP_RETURNCODE)]
293
    unmeasured = [
294
        str(row["operation"])
295
        for row in rows
296
        if row_returncode(row) == SKIP_RETURNCODE
297
    ]
298
    phase_elapsed_ms: dict[str, float] = {}
299
    for row in rows:
300
        phase = str(row.get("operation", "unknown")).split(".", 1)[0]
301
        phase_elapsed_ms[phase] = phase_elapsed_ms.get(phase, 0.0) + float(
302
            row.get("elapsed_ms", 0.0)
303
        )
304
    return {
305
        **metadata,
306
        "subject": subject.name,
307
        "subject_kind": subject.kind,
308
        "subject_label": subject.label,
309
        "scenario": scenario,
310
        "run": run_index,
311
        "operation": operation,
312
        "step_kind": "summary",
313
        "elapsed_ms": round(sum(float(row.get("elapsed_ms", 0)) for row in rows), 3),
314
        "returncode": 1 if failures else 0,
315
        "coverage_complete": not unmeasured,
316
        "unmeasured_operations": unmeasured,
317
        "command": [row["command"] for row in rows],
318
        "tool_call_count": sum(oakbench_tokens.int_or_zero(row.get("tool_call_count")) for row in rows),
319
        "terminal_tool_call_count": sum(
320
            oakbench_tokens.int_or_zero(row.get("terminal_tool_call_count")) for row in rows
321
        ),
322
        "vcs_tool_call_count": sum(oakbench_tokens.int_or_zero(row.get("vcs_tool_call_count")) for row in rows),
323
        "agent_tool_call_count": sum(
324
            oakbench_tokens.int_or_zero(row.get("tool_call_count")) for row in rows
325
        ),
326
        "harness_command_count": sum(
327
            oakbench_tokens.int_or_zero(row.get("harness_command_count")) for row in rows
328
        ),
329
        "retry_count": sum(oakbench_tokens.int_or_zero(row.get("retry_count")) for row in rows),
330
        "human_action_count": sum(
331
            oakbench_tokens.int_or_zero(row.get("human_action_count")) for row in rows
332
        ),
333
        "phase_elapsed_ms": {
334
            phase: round(elapsed, 3) for phase, elapsed in sorted(phase_elapsed_ms.items())
335
        },
336
        **oakbench_tokens.summed_token_fields(
337
            rows, "sum_of_task_loop_steps_command_plus_admitted_output_chars_div_4"
338
        ),
339
        "summarized_operations": [row["operation"] for row in rows],
340
    }
341
342
343
def has_lane_failures(rows: list[dict[str, Any]]) -> bool:
344
    return any(row_returncode(row) not in (0, SKIP_RETURNCODE) for row in rows)
345
346
347
def enforce_oracle_result(total: dict[str, Any], oracle_ok: bool | None) -> dict[str, Any]:
348
    if oracle_ok is False:
349
        total["returncode"] = 1
350
        total["stderr"] = "oracle_payload_missing_from_remote_branch"
351
    return total
352
353
354
def interrupted_publication_fields(
355
    *,
356
    request_count: int,
357
    request_bytes: int | None,
358
    declared_request_bytes: int | None,
359
    request_framing_valid: bool,
360
    request_body_timed_out: bool,
361
    local_head_before: str | None,
362
    local_head_after: str | None,
363
    status_after: dict[str, Any] | None,
364
    worktree_payload_before: str | None,
365
    worktree_payload_after: str | None,
366
    cli_observation: dict[str, str],
367
) -> dict[str, Any]:
368
    """Evidence fields for a single interrupted mutation attempt.
369
370
    The fixture's receipt of a full request is ground truth for the harness,
371
    not a server durability receipt available to the CLI.  A disconnect after
372
    that point is therefore unknown to the client and must never imply replay.
373
    """
374
    request_complete = bool(
375
        request_count == 1
376
        and request_framing_valid
377
        and declared_request_bytes is not None
378
        and request_bytes == declared_request_bytes
379
    )
380
    if request_count == 0:
381
        delivery_state = "no_request_observed"
382
        fixture_outcome = "not_sent_observed"
383
    elif not request_framing_valid:
384
        delivery_state = "invalid_request_framing"
385
        fixture_outcome = "indeterminate_after_invalid_request_disconnect"
386
    elif request_complete:
387
        delivery_state = "complete_request_observed"
388
        fixture_outcome = "unknown_after_complete_request_disconnect"
389
    else:
390
        delivery_state = "partial_request_observed"
391
        fixture_outcome = "indeterminate_after_partial_request_disconnect"
392
    payload_preserved = bool(
393
        worktree_payload_before
394
        and worktree_payload_before == worktree_payload_after
395
    )
396
    unpublished_state_preserved = bool(status_after and status_after.get("needs_push"))
397
    return {
398
        "publication_outcome": cli_observation["reported_outcome"],
399
        "cli_observation": cli_observation["observation"],
400
        "fixture_delivery_state": delivery_state,
401
        "fixture_outcome_knowledge": fixture_outcome,
402
        "fixture_received_complete_request_before_disconnect": request_complete,
403
        "fixture_request_framing_valid": request_framing_valid,
404
        "fixture_request_body_timed_out": request_body_timed_out,
405
        "fixture_declared_request_bytes": declared_request_bytes,
406
        "mutation_requests_observed": request_count,
407
        "blind_replay_requests": max(0, request_count - 1),
408
        "network_request_bytes_observed": request_bytes,
409
        "local_head_preserved": bool(local_head_before) and local_head_before == local_head_after,
410
        "worktree_payload_preserved": payload_preserved,
411
        "unpublished_state_preserved": unpublished_state_preserved,
412
        "local_work_preserved": payload_preserved and unpublished_state_preserved,
413
        "working_tree_dirty_after": None if status_after is None else status_after.get("dirty"),
414
    }
415
416
417
def cli_publication_observation(row: dict[str, Any], *, json_requested: bool) -> dict[str, str]:
418
    """Classify only what the CLI reported, never what the fault fixture knows."""
419
    if row.get("timed_out"):
420
        return {"reported_outcome": "unreported", "observation": "process_timeout"}
421
    stdout = str(row.get("stdout") or "")
422
    stderr = str(row.get("stderr") or "")
423
    value = _parse_json_object(stdout) if json_requested else None
424
    if value is not None and row.get("process_returncode") == 0 and value.get("published") is True:
425
        return {"reported_outcome": "published", "observation": "json_success_receipt"}
426
    if value is not None and isinstance(value.get("error"), dict):
427
        message = str(value["error"].get("message") or "").lower()
428
        if any(marker in message for marker in ("unconfirmed", "unknown", "may have")):
429
            return {"reported_outcome": "unknown", "observation": "json_error_unknown"}
430
        return {
431
            "reported_outcome": "unreported",
432
            "observation": "json_error_without_publication_outcome",
433
        }
434
    message = f"{stdout}\n{stderr}".lower()
435
    if any(marker in message for marker in ("unconfirmed", "unknown", "may have")):
436
        return {"reported_outcome": "unknown", "observation": "text_error_unknown"}
437
    return {"reported_outcome": "unreported", "observation": "text_error_without_publication_outcome"}
438
439
440
def file_sha256(path: Path) -> str | None:
441
    try:
442
        return hashlib.sha256(path.read_bytes()).hexdigest()
443
    except OSError:
444
        return None
445
446
447
class _DisconnectAfterPushHandler(BaseHTTPRequestHandler):
448
    """Minimal loopback fixture for a complete request with a lost reply."""
449
450
    server_version = "oakbench-interruption/1"
451
452
    def log_message(self, _format: str, *_args: object) -> None:
453
        return
454
455
    def _json(self, status: int, payload: dict[str, Any]) -> None:
456
        encoded = json.dumps(payload).encode()
457
        self.send_response(status)
458
        self.send_header("Content-Type", "application/json")
459
        self.send_header("Content-Length", str(len(encoded)))
460
        self.end_headers()
461
        self.wfile.write(encoded)
462
463
    def do_GET(self) -> None:  # noqa: N802 - BaseHTTPRequestHandler API
464
        self._json(404, {"error": "absent synthetic repo"})
465
466
    def do_POST(self) -> None:  # noqa: N802 - BaseHTTPRequestHandler API
467
        declared_size: int | None = None
468
        framing_valid = False
469
        body = b""
470
        body_timed_out = False
471
        raw_sizes = self.headers.get_all("Content-Length", failobj=[])
472
        try:
473
            raw_size = raw_sizes[0].strip(" \t") if len(raw_sizes) == 1 else ""
474
            parsed_size = int(raw_size) if raw_size.isascii() and raw_size.isdecimal() else -1
475
            framing_valid = (
476
                0 <= parsed_size <= INTERRUPTED_PUBLICATION_MAX_REQUEST_BYTES
477
                and self.headers.get("Transfer-Encoding") is None
478
            )
479
            if framing_valid:
480
                declared_size = parsed_size
481
                self.connection.settimeout(INTERRUPTED_PUBLICATION_REQUEST_TIMEOUT_S)
482
                while len(body) < parsed_size:
483
                    try:
484
                        chunk = self.rfile.read1(parsed_size - len(body))
485
                    except (socket.timeout, TimeoutError):
486
                        body_timed_out = True
487
                        break
488
                    except OSError:
489
                        break
490
                    if not chunk:
491
                        break
492
                    body += chunk
493
        except ValueError:
494
            framing_valid = False
495
        server = self.server
496
        if self.path == "/api/repos":
497
            self._json(200, {"name": "repo"})
498
            return
499
        if self.path == "/api/oak/repo/push":
500
            with server.receipt_lock:  # type: ignore[attr-defined]
501
                server.push_request_count += 1  # type: ignore[attr-defined]
502
                server.push_request_bytes += len(body)  # type: ignore[attr-defined]
503
                server.push_declared_request_bytes = declared_size  # type: ignore[attr-defined]
504
                server.push_request_framing_valid = framing_valid  # type: ignore[attr-defined]
505
                server.push_request_body_timed_out = body_timed_out  # type: ignore[attr-defined]
506
                server.request_complete_before_disconnect = (  # type: ignore[attr-defined]
507
                    framing_valid and len(body) == declared_size
508
                )
509
            self.close_connection = True
510
            try:
511
                self.connection.shutdown(socket.SHUT_RDWR)
512
            except OSError:
513
                pass
514
            self.connection.close()
515
            return
516
        self._json(404, {"error": "unexpected synthetic path"})
517
518
519
def start_interrupted_publication_server() -> tuple[ThreadingHTTPServer, threading.Thread]:
520
    server = ThreadingHTTPServer(("127.0.0.1", 0), _DisconnectAfterPushHandler)
521
    server.receipt_lock = threading.Lock()  # type: ignore[attr-defined]
522
    server.push_request_count = 0  # type: ignore[attr-defined]
523
    server.push_request_bytes = 0  # type: ignore[attr-defined]
524
    server.push_declared_request_bytes = None  # type: ignore[attr-defined]
525
    server.push_request_framing_valid = False  # type: ignore[attr-defined]
526
    server.push_request_body_timed_out = False  # type: ignore[attr-defined]
527
    server.request_complete_before_disconnect = False  # type: ignore[attr-defined]
528
    thread = threading.Thread(target=server.serve_forever, name="oakbench-interrupted-push", daemon=True)
529
    thread.start()
530
    return server, thread
531
532
533
def _parse_json_object(text: str | None) -> dict[str, Any] | None:
534
    try:
535
        value = json.loads(text or "")
536
    except (TypeError, json.JSONDecodeError):
537
        return None
538
    return value if isinstance(value, dict) else None
539
540
541
def run_interrupted_publication(
542
    subject: Subject,
543
    run_root: Path,
544
    run_index: int,
545
    metadata: dict[str, Any],
546
    admitted: int,
547
) -> list[dict[str, Any]]:
548
    """Run one real Oak push against a loopback server that drops its reply.
549
550
    The fixture observes request delivery but deliberately has no durable
551
    remote state. That makes the client's outcome unknown and lets the lane
552
    prove work preservation and absence of blind replay without production
553
    mutations or a fake success claim.
554
    """
555
    scenario = INTERRUPTED_PUBLICATION_SCENARIO
556
    metadata = {
557
        **metadata,
558
        "benchmark_track": "failure-evidence",
559
        "remote_transport": "loopback_fault_fixture",
560
        "remote_server": "127.0.0.1",
561
    }
562
    if subject.kind != "oak":
563
        return [
564
            skip_row(
565
                subject,
566
                scenario,
567
                run_index,
568
                metadata,
569
                "interrupted publication fixture currently exercises the actual Oak HTTP client only",
570
            )
571
        ]
572
573
    workspace = run_root / f"interrupted-{subject.name}-r{run_index}"
574
    workspace.mkdir(parents=True)
575
    rows: list[dict[str, Any]] = []
576
    vcs = str(subject.bin)
577
    rows.append(
578
        step_row(subject, scenario, "setup.init", [vcs, "init", "."], workspace, run_index, metadata, admitted)
579
    )
580
    if row_returncode(rows[-1]) != 0:
581
        return rows
582
    rows.append(
583
        edit_step(
584
            subject,
585
            scenario,
586
            "setup.edit",
587
            workspace,
588
            f"interrupted publication run={run_index}",
589
            run_index,
590
            metadata,
591
            admitted,
592
        )
593
    )
594
    rows.append(
595
        step_row(subject, scenario, "setup.checkpoint", [vcs, "commit"], workspace, run_index, metadata, admitted)
596
    )
597
    if row_returncode(rows[-1]) != 0:
598
        return rows
599
600
    before = run_untimed([vcs, "hash"], workspace)
601
    head_before = before.stdout.strip() if before.returncode == 0 else None
602
    payload_path = workspace / "task-notes.md"
603
    payload_before = file_sha256(payload_path)
604
    before_usage = bounded_tree_usage(workspace)
605
    push_help = run_untimed([vcs, "push", "--help"], workspace)
606
    push_supports_json = "--json" in (push_help.stdout + push_help.stderr)
607
    server, thread = start_interrupted_publication_server()
608
    host, port = server.server_address[:2]
609
    remote = f"http://{host}:{port}"
610
    fixture_env = base_env()
611
    fixture_env.pop("OAK_API_KEY", None)
612
    fixture_env.pop("OAK_REMOTE", None)
613
    fixture_env["OAK_NO_UPDATE_CHECK"] = "1"
614
    try:
615
        push_command = [vcs, "push", "--repo", "oak/repo", "--remote", remote]
616
        if push_supports_json:
617
            push_command.append("--json")
618
        publication_attempt = step_row(
619
            subject,
620
            scenario,
621
            "publication.attempt",
622
            push_command,
623
            workspace,
624
            run_index,
625
            metadata,
626
            admitted,
627
            expected_returncodes=tuple(range(1, 128)),
628
            preserve_stdout=True,
629
            preserve_stderr=True,
630
            env=fixture_env,
631
            timeout_seconds=INTERRUPTED_PUBLICATION_PROCESS_TIMEOUT_S,
632
        )
633
        rows.append(publication_attempt)
634
    finally:
635
        server.shutdown()
636
        server.server_close()
637
        thread.join(timeout=2.0)
638
639
    state_row = step_row(
640
        subject,
641
        scenario,
642
        "reconcile.local_state",
643
        [vcs, "agent", "state", "--json", "--compact"],
644
        workspace,
645
        run_index,
646
        metadata,
647
        admitted,
648
        preserve_stdout=True,
649
        env=fixture_env,
650
    )
651
    rows.append(state_row)
652
    after = run_untimed([vcs, "hash"], workspace)
653
    head_after = after.stdout.strip() if after.returncode == 0 else None
654
    payload_after = file_sha256(payload_path)
655
    status_after = _parse_json_object(state_row.get("stdout"))
656
    with server.receipt_lock:  # type: ignore[attr-defined]
657
        request_count = int(server.push_request_count)  # type: ignore[attr-defined]
658
        request_bytes = int(server.push_request_bytes)  # type: ignore[attr-defined]
659
        declared_request_bytes = server.push_declared_request_bytes  # type: ignore[attr-defined]
660
        request_framing_valid = bool(server.push_request_framing_valid)  # type: ignore[attr-defined]
661
        request_body_timed_out = bool(server.push_request_body_timed_out)  # type: ignore[attr-defined]
662
    cli_observation = cli_publication_observation(
663
        publication_attempt,
664
        json_requested=push_supports_json,
665
    )
666
    evidence = interrupted_publication_fields(
667
        request_count=request_count,
668
        request_bytes=request_bytes if request_count else None,
669
        declared_request_bytes=declared_request_bytes,
670
        request_framing_valid=request_framing_valid,
671
        request_body_timed_out=request_body_timed_out,
672
        local_head_before=head_before,
673
        local_head_after=head_after,
674
        status_after=status_after,
675
        worktree_payload_before=payload_before,
676
        worktree_payload_after=payload_after,
677
        cli_observation=cli_observation,
678
    )
679
    evidence["workspace_usage_before"] = before_usage
680
    evidence["workspace_usage_after"] = bounded_tree_usage(workspace)
681
    evidence["remote_transport"] = "loopback_fault_fixture"
682
    evidence["remote_server"] = str(host)
683
    evidence["human_action_count"] = 0
684
    evidence["compatibility_probe_calls"] = 1
685
    evidence["harness_probe_calls"] = 3
686
    evidence["push_json_supported"] = push_supports_json
687
    total = phase_total_row(rows, subject, scenario, run_index, metadata, "interrupted_publication.total")
688
    evidence["harness_command_count"] = total["harness_command_count"] + 3
689
    total.update(evidence)
690
    if not (
691
        evidence["fixture_received_complete_request_before_disconnect"]
692
        and request_count == 1
693
        and evidence["local_head_preserved"]
694
        and evidence["local_work_preserved"]
695
        and not publication_attempt["timed_out"]
696
        and row_returncode(rows[-1]) == 0
697
    ):
698
        total["returncode"] = 1
699
        total["stderr"] = "interrupted_publication_evidence_incomplete"
700
    rows.append(total)
701
    return rows
702
703
704
def edit_step(
705
    subject: Subject,
706
    scenario: str,
707
    operation: str,
708
    workspace: Path,
709
    payload: str,
710
    run_index: int,
711
    metadata: dict[str, Any],
712
    admitted: int,
713
) -> dict[str, Any]:
714
    command = ["/bin/zsh", "-c", f"echo {payload!r} >> task-notes.md"]
715
    return step_row(
716
        subject, scenario, operation, command, workspace, run_index, metadata, admitted, kind="edit"
717
    )
718
719
720
def oak_virtual_branch(subject: Subject, mount_dir: Path) -> str | None:
721
    proc = run_untimed([str(subject.bin), "status"], mount_dir)
722
    match = VIRTUAL_BRANCH_RE.search(proc.stdout + proc.stderr)
723
    return match.group(1) if match else None
724
725
726
def run_oak_loop(
727
    subject: Subject,
728
    remote: oakbench_remotes.RemoteResolution,
729
    run_root: Path,
730
    run_index: int,
731
    metadata: dict[str, Any],
732
    admitted: int,
733
) -> list[dict[str, Any]]:
734
    scenario = "task_loop"
735
    meta = {**metadata, **remote.row_fields()}
736
    rows: list[dict[str, Any]] = []
737
    branches: dict[str, str | None] = {}
738
    payloads: dict[str, str] = {}
739
    mounts: list[Path] = []
740
    vcs = str(subject.bin)
741
    try:
742
        for phase in PHASES:
743
            workspace = run_root / f"tl-{subject.name}-r{run_index}-{phase}"
744
            payload = f"payload {phase} run={run_index} bench={metadata['bench_id']}"
745
            payloads[phase] = payload
746
            acquire = step_row(
747
                subject, scenario, f"{phase}.acquire", [vcs, "mount", remote.repo or "", str(workspace)],
748
                run_root, run_index, meta, admitted,
749
            )
750
            rows.append(acquire)
751
            if int(acquire["returncode"]) != 0:
752
                return rows
753
            mounts.append(workspace)
754
            branches[phase] = oak_virtual_branch(subject, workspace)
755
            rows.append(edit_step(subject, scenario, f"{phase}.edit", workspace, payload, run_index, meta, admitted))
756
            rows.append(step_row(subject, scenario, f"{phase}.snapshot", [vcs, "commit", "--no-verify"], workspace, run_index, meta, admitted))
757
            rows.append(step_row(subject, scenario, f"{phase}.publish", [vcs, "push"], workspace, run_index, meta, admitted))
758
            if phase == "task":
759
                rows.append(
760
                    step_row(
761
                        subject, scenario, "task.describe",
762
                        [vcs, "desc", f"bench task loop {metadata['bench_id']} r{run_index}"],
763
                        workspace, run_index, meta, admitted,
764
                    )
765
                )
766
            end = step_row(
767
                subject, scenario, f"{phase}.end", [vcs, "mount", "end", str(workspace)],
768
                run_root, run_index, meta, admitted,
769
            )
770
            rows.append(end)
771
            if int(end["returncode"]) == 0 and workspace in mounts:
772
                mounts.remove(workspace)
773
774
        # Untimed oracle: each phase's payload must be on its pushed branch.
775
        verifier = run_root / f"tl-verify-{subject.name}-r{run_index}"
776
        clone = run_untimed([vcs, "clone", remote.repo or "", str(verifier)], run_root)
777
        oracle_ok: bool | None = None
778
        if clone.returncode == 0:
779
            oracle_ok = True
780
            for phase in PHASES:
781
                branch = branches.get(phase)
782
                if not branch:
783
                    oracle_ok = None
784
                    break
785
                switch = run_untimed([vcs, "switch", branch], verifier)
786
                content = (verifier / "task-notes.md")
787
                seen = content.read_text() if content.exists() else ""
788
                if switch.returncode != 0 or payloads[phase] not in seen:
789
                    oracle_ok = False
790
        total = {
791
            **phase_total_row(rows, subject, scenario, run_index, meta, "task_loop.total"),
792
            "oracle_payload_on_remote_branch": oracle_ok,
793
            "workspace_branches": branches,
794
            "measurement_source": "verifier_clone_switch_grep; null oracle means branch name unparsed",
795
        }
796
        rows.append(enforce_oracle_result(total, oracle_ok))
797
        return rows
798
    finally:
799
        for mount_dir in mounts:
800
            run_untimed([vcs, "mount", "end", str(mount_dir), "-f"], run_root)
801
802
803
def ensure_git_remote_seeded(subject: Subject, remote: oakbench_remotes.RemoteResolution, scratch: Path) -> str | None:
804
    """Untimed: make sure the git remote has a main with a seed commit."""
805
    vcs = str(subject.bin)
806
    probe = run_untimed([vcs, "ls-remote", str(remote.repo), "refs/heads/main"], None)
807
    if probe.returncode == 0 and probe.stdout.strip():
808
        if remote.transport == oakbench_remotes.TRANSPORT_LOCAL_FILE:
809
            head = run_untimed([vcs, "symbolic-ref", "HEAD", "refs/heads/main"], Path(str(remote.repo)))
810
            if head.returncode != 0:
811
                return f"git remote HEAD setup failed: {head.stderr.strip()[:200]}"
812
        return None
813
    seed = scratch / "git-seed"
814
    if seed.exists():
815
        shutil.rmtree(seed)
816
    seed.mkdir(parents=True)
817
    (seed / "README.md").write_text("Task-loop benchmark repo (disposable).\n")
818
    (seed / "task-notes.md").write_text("# task notes\n")
819
    for command in (
820
        [vcs, "init", "-q", "-b", "main"],
821
        [vcs, "add", "."],
822
        [vcs, "commit", "-q", "-m", "seed"],
823
        [vcs, "push", "-q", str(remote.repo), "main:main"],
824
    ):
825
        proc = run_untimed(command, seed)
826
        if proc.returncode != 0:
827
            return f"git remote seed `{' '.join(command[1:3])}` failed: {proc.stderr.strip()[:200]}"
828
    if remote.transport == oakbench_remotes.TRANSPORT_LOCAL_FILE:
829
        head = run_untimed([vcs, "symbolic-ref", "HEAD", "refs/heads/main"], Path(str(remote.repo)))
830
        if head.returncode != 0:
831
            return f"git remote HEAD setup failed: {head.stderr.strip()[:200]}"
832
    return None
833
834
835
def run_git_agent_task_baseline(
836
    subject: Subject,
837
    remote: oakbench_remotes.RemoteResolution,
838
    run_root: Path,
839
    run_index: int,
840
    metadata: dict[str, Any],
841
    admitted: int,
842
    remote_create_ms: float = 0.0,
843
) -> list[dict[str, Any]]:
844
    """Measure a cold clone and a warm isolated worktree on one local remote.
845
846
    This is the core-equivalent, local-file cell. Hosted CI is deliberately a
847
    skip row: a local commit oracle is not relabelled as provider CI.
848
    """
849
    scenario = AGENT_TASK_SCENARIO
850
    meta = {
851
        **metadata,
852
        **remote.row_fields(),
853
        "benchmark_track": (
854
            "core-equivalent"
855
            if remote.transport == oakbench_remotes.TRANSPORT_LOCAL_FILE
856
            else "agent-default"
857
        ),
858
    }
859
    vcs = str(subject.bin)
860
    rows: list[dict[str, Any]] = [
861
        harness_step_row(
862
            subject,
863
            scenario,
864
            "setup.remote_container",
865
            run_index,
866
            meta,
867
            remote_create_ms,
868
            harness_command_count=1,
869
        )
870
    ]
871
    setup_start = time.perf_counter()
872
    seed_error = ensure_git_remote_seeded(subject, remote, run_root)
873
    rows.append(
874
        harness_step_row(
875
            subject,
876
            scenario,
877
            "setup.remote_seed",
878
            run_index,
879
            meta,
880
            (time.perf_counter() - setup_start) * 1000,
881
            returncode=1 if seed_error else 0,
882
            harness_command_count=6,
883
        )
884
    )
885
    if seed_error:
886
        rows[-1]["stderr"] = seed_error
887
        return rows
888
889
    base = run_root / f"agent-task-{subject.name}-r{run_index}-cold"
890
    cold_branch = oakbench_remotes.disposable_branch(
891
        str(metadata["bench_id"]), "agent-task-cold", subject.name, run_index
892
    )
893
    warm_branch = oakbench_remotes.disposable_branch(
894
        str(metadata["bench_id"]), "agent-task-warm", subject.name, run_index
895
    )
896
    cold_payload = f"cold task run={run_index} bench={metadata['bench_id']}"
897
    warm_payload = f"warm task run={run_index} bench={metadata['bench_id']}"
898
899
    rows.append(
900
        step_row(
901
            subject,
902
            scenario,
903
            "cold.acquire",
904
            [vcs, "clone", "-q", str(remote.repo), str(base)],
905
            run_root,
906
            run_index,
907
            meta,
908
            admitted,
909
        )
910
    )
911
    if row_returncode(rows[-1]) != 0:
912
        return rows
913
    rows.append(
914
        step_row(
915
            subject, scenario, "cold.branch", [vcs, "switch", "-q", "-c", cold_branch],
916
            base, run_index, meta, admitted,
917
        )
918
    )
919
    rows.append(
920
        step_row(
921
            subject, scenario, "cold.inspect", [vcs, "status", "--porcelain=v1"],
922
            base, run_index, meta, admitted,
923
        )
924
    )
925
    rows.append(edit_step(subject, scenario, "cold.edit", base, cold_payload, run_index, meta, admitted))
926
    rows.append(step_row(subject, scenario, "cold.checkpoint.add", [vcs, "add", "."], base, run_index, meta, admitted))
927
    rows.append(
928
        step_row(
929
            subject, scenario, "cold.checkpoint.commit",
930
            [vcs, "commit", "-q", "-m", f"agent task cold {metadata['bench_id']} r{run_index}"],
931
            base, run_index, meta, admitted,
932
        )
933
    )
934
    rows.append(
935
        step_row(
936
            subject, scenario, "cold.publish", [vcs, "push", "-q", "-u", "origin", cold_branch],
937
            base, run_index, meta, admitted,
938
        )
939
    )
940
    rows.append(
941
        unmeasured_step_row(
942
            subject, scenario, "cold.ci.observe", run_index, meta,
943
            "local-file transport has no hosted CI provider; exact CI observation is unmeasured",
944
        )
945
    )
946
    rows.append(
947
        step_row(
948
            subject, scenario, "cold.review_handoff",
949
            [vcs, "show", "--format=%H%n%s", "--stat", "--oneline", "HEAD"],
950
            base, run_index, meta, admitted,
951
        )
952
    )
953
954
    warm = run_root / f"agent-task-{subject.name}-r{run_index}-warm"
955
    rows.append(
956
        step_row(
957
            subject, scenario, "warm.acquire",
958
            [vcs, "worktree", "add", "-q", str(warm), "-b", warm_branch, "origin/main"],
959
            base, run_index, meta, admitted,
960
        )
961
    )
962
    if row_returncode(rows[-1]) == 0:
963
        rows.append(
964
            step_row(
965
                subject, scenario, "warm.inspect", [vcs, "status", "--porcelain=v1"],
966
                warm, run_index, meta, admitted,
967
            )
968
        )
969
        rows.append(edit_step(subject, scenario, "warm.edit", warm, warm_payload, run_index, meta, admitted))
970
        rows.append(step_row(subject, scenario, "warm.checkpoint.add", [vcs, "add", "."], warm, run_index, meta, admitted))
971
        rows.append(
972
            step_row(
973
                subject, scenario, "warm.checkpoint.commit",
974
                [vcs, "commit", "-q", "-m", f"agent task warm {metadata['bench_id']} r{run_index}"],
975
                warm, run_index, meta, admitted,
976
            )
977
        )
978
        rows.append(
979
            step_row(
980
                subject, scenario, "warm.publish", [vcs, "push", "-q", "-u", "origin", warm_branch],
981
                warm, run_index, meta, admitted,
982
            )
983
        )
984
        rows.append(
985
            unmeasured_step_row(
986
                subject, scenario, "warm.ci.observe", run_index, meta,
987
                "local-file transport has no hosted CI provider; exact CI observation is unmeasured",
988
            )
989
        )
990
        rows.append(
991
            step_row(
992
                subject, scenario, "warm.review_handoff",
993
                [vcs, "show", "--format=%H%n%s", "--stat", "--oneline", "HEAD"],
994
                warm, run_index, meta, admitted,
995
            )
996
        )
997
998
    usage_before_cleanup = {
999
        "cold": bounded_tree_usage(base),
1000
        "warm": bounded_tree_usage(warm),
1001
    }
1002
    oracle_ok = True
1003
    for branch, payload in ((cold_branch, cold_payload), (warm_branch, warm_payload)):
1004
        show = run_untimed([vcs, "--git-dir", str(remote.repo), "show", f"{branch}:task-notes.md"], run_root)
1005
        if show.returncode != 0 or payload not in show.stdout:
1006
            oracle_ok = False
1007
1008
    if warm.exists():
1009
        rows.append(
1010
            step_row(
1011
                subject, scenario, "cleanup.warm",
1012
                [vcs, "worktree", "remove", str(warm)], base, run_index, meta, admitted,
1013
            )
1014
        )
1015
    cleanup_start = time.perf_counter()
1016
    shutil.rmtree(base, ignore_errors=True)
1017
    rows.append(
1018
        harness_step_row(
1019
            subject, scenario, "cleanup.cold", run_index, meta,
1020
            (time.perf_counter() - cleanup_start) * 1000,
1021
        )
1022
    )
1023
1024
    total = phase_total_row(rows, subject, scenario, run_index, meta, "agent_task.total")
1025
    total.update(
1026
        {
1027
            "oracle_payload_on_remote_branch": oracle_ok,
1028
            "acquisition_models": {
1029
                "cold": "fresh_full_clone",
1030
                "warm": "shared_object_worktree",
1031
            },
1032
            "repository_cache_state": {
1033
                "cold": "fresh_destination_no_shared_object_store",
1034
                "warm": "explicit_shared_object_store",
1035
            },
1036
            "os_cache_state": "unknown",
1037
            "os_cache_state_reason": "page_cache_not_purged_or_instrumented",
1038
            "workspace_usage_before_cleanup": usage_before_cleanup,
1039
            "network_bytes": None,
1040
            "network_bytes_reason": "local_file_transport_not_observed_by_byte_proxy",
1041
            "partial_clone_comparator": None,
1042
            "partial_clone_reason": "local file transport does not establish a network partial-clone comparison",
1043
            "human_action_count": 0,
1044
            "parallel_evidence_lane": "parallel_contention.py/workspace_per_task",
1045
            "oracle_probe_calls": 2,
1046
            "harness_command_count": total["harness_command_count"] + 2,
1047
        }
1048
    )
1049
    if not oracle_ok:
1050
        total["returncode"] = 1
1051
        total["stderr"] = "agent_task_remote_payload_oracle_failed"
1052
    rows.append(total)
1053
    return rows
1054
1055
1056
def reserve_loopback_port() -> int:
1057
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
1058
        listener.bind(("127.0.0.1", 0))
1059
        return int(listener.getsockname()[1])
1060
1061
1062
def wait_for_loopback_port(
1063
    process: subprocess.Popen[Any], port: int, timeout_s: float = LOCAL_OAK_SERVER_READY_TIMEOUT_S
1064
) -> bool:
1065
    deadline = time.monotonic() + timeout_s
1066
    while time.monotonic() < deadline:
1067
        if process.poll() is not None:
1068
            return False
1069
        try:
1070
            with socket.create_connection(("127.0.0.1", port), timeout=0.2):
1071
                return True
1072
        except OSError:
1073
            time.sleep(0.05)
1074
    return False
1075
1076
1077
def harness_command_row(
1078
    subject: Subject,
1079
    scenario: str,
1080
    operation: str,
1081
    command: list[str],
1082
    cwd: Path,
1083
    run_index: int,
1084
    metadata: dict[str, Any],
1085
) -> tuple[dict[str, Any], subprocess.CompletedProcess[str]]:
1086
    started = time.perf_counter()
1087
    result = run_bounded_harness_command(command, cwd)
1088
    row = harness_step_row(
1089
        subject,
1090
        scenario,
1091
        operation,
1092
        run_index,
1093
        metadata,
1094
        (time.perf_counter() - started) * 1000,
1095
        returncode=0 if result.returncode == 0 else result.returncode,
1096
        harness_command_count=1,
1097
    )
1098
    if result.returncode != 0:
1099
        row["stderr"] = str(result.stderr)[-4000:]
1100
    return row, result
1101
1102
1103
def run_bounded_harness_command(
1104
    command: list[str], cwd: Path
1105
) -> subprocess.CompletedProcess[str]:
1106
    capture = run_timed(
1107
        command,
1108
        cwd,
1109
        base_env(),
1110
        admitted_output_chars=4_000,
1111
        timeout_seconds=INTERRUPTED_PUBLICATION_PROCESS_TIMEOUT_S,
1112
    )
1113
    return subprocess.CompletedProcess(
1114
        command,
1115
        capture.returncode,
1116
        capture.stdout_text,
1117
        capture.stderr_text,
1118
    )
1119
1120
1121
def remote_branch_head(remote: str, repo_spec: str, branch: str) -> str | None:
1122
    owner, repo = repo_spec.split("/", 1)
1123
    branch_segment = urllib.parse.quote(branch, safe="")
1124
    url = f"{remote}/api/{owner}/{repo}/branches/{branch_segment}"
1125
    try:
1126
        with urllib.request.urlopen(url, timeout=2.0) as response:
1127
            value = json.loads(response.read())
1128
    except (OSError, ValueError, urllib.error.URLError):
1129
        return None
1130
    head = value.get("head") if isinstance(value, dict) else None
1131
    return head if isinstance(head, str) else None
1132
1133
1134
def run_oak_local_agent_task_baseline(
1135
    subject: Subject,
1136
    run_root: Path,
1137
    run_index: int,
1138
    metadata: dict[str, Any],
1139
    admitted: int,
1140
) -> list[dict[str, Any]]:
1141
    """Exercise two fresh Oak checkouts against an owned loopback Oak server."""
1142
    run_root = run_root.resolve()
1143
    scenario = AGENT_TASK_OAK_LOCAL_SCENARIO
1144
    meta = {
1145
        **metadata,
1146
        "benchmark_track": "oak-local-loopback",
1147
        "remote_transport": "loopback_http",
1148
        "remote_server": "127.0.0.1",
1149
    }
1150
    rows: list[dict[str, Any]] = []
1151
    vcs = str(subject.bin)
1152
    server_root = run_root / f"oak-local-server-{subject.name}-r{run_index}"
1153
    seed = run_root / f"oak-local-seed-{subject.name}-r{run_index}"
1154
    cold = run_root / f"oak-local-cold-{subject.name}-r{run_index}"
1155
    followup = run_root / f"oak-local-followup-{subject.name}-r{run_index}"
1156
    verifier = run_root / f"oak-local-verifier-{subject.name}-r{run_index}"
1157
    repo_spec = f"qa/task-loop-{subject.name}-r{run_index}"
1158
    port = reserve_loopback_port()
1159
    remote = f"http://127.0.0.1:{port}"
1160
    server_root.mkdir(parents=True)
1161
    server_started = time.perf_counter()
1162
    server: subprocess.Popen[Any] | None = None
1163
    server_start_error: str | None = None
1164
    try:
1165
        server = subprocess.Popen(
1166
            [vcs, "serve", "--dir", str(server_root), "--host", "127.0.0.1", "--port", str(port)],
1167
            cwd=run_root,
1168
            env=base_env(),
1169
            stdout=subprocess.DEVNULL,
1170
            stderr=subprocess.DEVNULL,
1171
            start_new_session=os.name == "posix",
1172
        )
1173
    except OSError as error:
1174
        server_start_error = type(error).__name__
1175
    ready = server is not None and wait_for_loopback_port(server, port)
1176
    rows.append(
1177
        harness_step_row(
1178
            subject,
1179
            scenario,
1180
            "setup.server",
1181
            run_index,
1182
            meta,
1183
            (time.perf_counter() - server_started) * 1000,
1184
            returncode=0 if ready else 1,
1185
            harness_command_count=1,
1186
        )
1187
    )
1188
    branch: str | None = None
1189
    expected_head: str | None = None
1190
    expected_payload = "# task notes\n"
1191
    failure: str | None = None if ready else (
1192
        f"local_oak_server_start_failed:{server_start_error}"
1193
        if server_start_error else "local_oak_server_not_ready"
1194
    )
1195
    try:
1196
        if failure is None:
1197
            seed.mkdir(parents=True)
1198
            (seed / "task-notes.md").write_text(expected_payload)
1199
            for operation, command in (
1200
                ("setup.seed_init", [vcs, "init", "."]),
1201
                ("setup.seed_commit", [vcs, "commit"]),
1202
                ("setup.seed_push", [vcs, "push", "--repo", repo_spec, "--remote", remote]),
1203
            ):
1204
                row, result = harness_command_row(
1205
                    subject, scenario, operation, command, seed, run_index, meta
1206
                )
1207
                rows.append(row)
1208
                if result.returncode != 0:
1209
                    failure = f"{operation}_failed"
1210
                    break
1211
1212
        def agent_step(operation: str, command: list[str], cwd: Path, *, preserve: bool = False) -> dict[str, Any]:
1213
            row = step_row(
1214
                subject,
1215
                scenario,
1216
                operation,
1217
                command,
1218
                cwd,
1219
                run_index,
1220
                meta,
1221
                admitted,
1222
                preserve_stdout=preserve,
1223
                timeout_seconds=INTERRUPTED_PUBLICATION_PROCESS_TIMEOUT_S,
1224
            )
1225
            rows.append(row)
1226
            return row
1227
1228
        if failure is None:
1229
            row = agent_step(
1230
                "cold.acquire",
1231
                [vcs, "clone", repo_spec, str(cold), "--remote", remote, "--allow-legacy-scope"],
1232
                run_root,
1233
            )
1234
            if row_returncode(row) != 0:
1235
                failure = "cold.acquire_failed"
1236
        if failure is None:
1237
            status = agent_step("cold.inspect", [vcs, "status", "--json"], cold, preserve=True)
1238
            status_value = _parse_json_object(status.get("stdout"))
1239
            branch_value = status_value.get("branch") if status_value else None
1240
            branch = branch_value if isinstance(branch_value, str) else None
1241
            if not branch:
1242
                failure = "cold.branch_identity_missing"
1243
        if failure is None:
1244
            cold_payload = f"cold task run={run_index} bench={metadata['bench_id']}\n"
1245
            expected_payload += cold_payload
1246
            rows.append(edit_step(subject, scenario, "cold.edit", cold, cold_payload.strip(), run_index, meta, admitted))
1247
            if row_returncode(agent_step("cold.checkpoint", [vcs, "commit"], cold)) != 0:
1248
                failure = "cold.checkpoint_failed"
1249
            elif row_returncode(
1250
                agent_step("cold.publish", [vcs, "push", "--repo", repo_spec, "--remote", remote], cold)
1251
            ) != 0:
1252
                failure = "cold.publish_failed"
1253
            rows.append(
1254
                unmeasured_step_row(
1255
                    subject, scenario, "cold.ci.observe", run_index, meta,
1256
                    "owned local oak serve has no configured CI provider",
1257
                )
1258
            )
1259
            rows.append(
1260
                unmeasured_step_row(
1261
                    subject, scenario, "cold.review_handoff", run_index, meta,
1262
                    "owned local oak serve does not provide hosted review semantics",
1263
                )
1264
            )
1265
        if failure is None and branch is not None:
1266
            row = agent_step(
1267
                "followup.acquire",
1268
                [
1269
                    vcs, "clone", repo_spec, str(followup), "--remote", remote,
1270
                    "--branch", branch, "--allow-legacy-scope",
1271
                ],
1272
                run_root,
1273
            )
1274
            if row_returncode(row) != 0:
1275
                failure = "followup.acquire_failed"
1276
        if failure is None:
1277
            agent_step("followup.inspect", [vcs, "status", "--json"], followup)
1278
            followup_payload = f"followup task run={run_index} bench={metadata['bench_id']}\n"
1279
            expected_payload += followup_payload
1280
            rows.append(
1281
                edit_step(
1282
                    subject, scenario, "followup.edit", followup,
1283
                    followup_payload.strip(), run_index, meta, admitted,
1284
                )
1285
            )
1286
            if row_returncode(agent_step("followup.checkpoint", [vcs, "commit"], followup)) != 0:
1287
                failure = "followup.checkpoint_failed"
1288
            else:
1289
                head_result = run_bounded_harness_command([vcs, "hash"], followup)
1290
                expected_head = head_result.stdout.strip() if head_result.returncode == 0 else None
1291
                if row_returncode(
1292
                    agent_step(
1293
                        "followup.publish",
1294
                        [vcs, "push", "--repo", repo_spec, "--remote", remote],
1295
                        followup,
1296
                    )
1297
                ) != 0:
1298
                    failure = "followup.publish_failed"
1299
            rows.append(
1300
                unmeasured_step_row(
1301
                    subject, scenario, "followup.ci.observe", run_index, meta,
1302
                    "owned local oak serve has no configured CI provider",
1303
                )
1304
            )
1305
            rows.append(
1306
                unmeasured_step_row(
1307
                    subject, scenario, "followup.review_handoff", run_index, meta,
1308
                    "owned local oak serve does not provide hosted review semantics",
1309
                )
1310
            )
1311
1312
        payload_ok = False
1313
        verifier_head: str | None = None
1314
        advertised_head: str | None = None
1315
        if failure is None and branch is not None:
1316
            clone = run_bounded_harness_command(
1317
                [
1318
                    vcs, "clone", repo_spec, str(verifier), "--remote", remote,
1319
                    "--branch", branch, "--allow-legacy-scope",
1320
                ],
1321
                run_root,
1322
            )
1323
            if clone.returncode == 0:
1324
                payload_path = verifier / "task-notes.md"
1325
                payload_ok = payload_path.exists() and payload_path.read_text() == expected_payload
1326
                verifier_result = run_bounded_harness_command([vcs, "hash"], verifier)
1327
                verifier_head = verifier_result.stdout.strip() if verifier_result.returncode == 0 else None
1328
                advertised_head = remote_branch_head(remote, repo_spec, branch)
1329
            else:
1330
                failure = "verifier_clone_failed"
1331
        head_ok = bool(expected_head and expected_head == verifier_head == advertised_head)
1332
        if not payload_ok:
1333
            failure = failure or "local_oak_payload_oracle_failed"
1334
        if not head_ok:
1335
            failure = failure or "local_oak_head_oracle_failed"
1336
    finally:
1337
        if server is not None:
1338
            stop_owned_process(server)
1339
        server_stopped = server is None or server.poll() is not None
1340
        cleanup_started = time.perf_counter()
1341
        for path in (seed, cold, followup, verifier, server_root):
1342
            shutil.rmtree(path, ignore_errors=True)
1343
        rows.append(
1344
            harness_step_row(
1345
                subject,
1346
                scenario,
1347
                "cleanup.local_server_and_checkouts",
1348
                run_index,
1349
                meta,
1350
                (time.perf_counter() - cleanup_started) * 1000,
1351
            )
1352
        )
1353
1354
    total = phase_total_row(rows, subject, scenario, run_index, meta, "agent_task_oak_local.total")
1355
    total.update(
1356
        {
1357
            "oracle_payload_on_remote_branch": payload_ok and head_ok,
1358
            "oracle_payload_matches": payload_ok,
1359
            "oracle_branch_head_matches": head_ok,
1360
            "server_process_stopped": server_stopped,
1361
            "acquisition_models": {
1362
                "cold": "fresh_full_clone",
1363
                "followup": "second_fresh_full_clone",
1364
            },
1365
            "repository_cache_state": {
1366
                "cold": "fresh_destination_no_shared_object_store",
1367
                "followup": "no_shared_object_store",
1368
            },
1369
            "os_cache_state": "unknown",
1370
            "os_cache_state_reason": "page_cache_not_purged_or_instrumented",
1371
            "network_bytes": None,
1372
            "network_bytes_reason": "loopback transport not observed by byte proxy",
1373
            "hosted_ci_configured": False,
1374
            "oracle_probe_calls": 4,
1375
            # The expected-head hash plus verifier clone/hash/HTTP head probe
1376
            # are oracle/setup commands, not agent-facing tool calls.
1377
            "harness_command_count": total["harness_command_count"] + 4,
1378
        }
1379
    )
1380
    if failure or not server_stopped:
1381
        total["returncode"] = 1
1382
        total["stderr"] = failure or "local_oak_server_cleanup_failed"
1383
    rows.append(total)
1384
    return rows
1385
1386
1387
def run_git_loop(
1388
    subject: Subject,
1389
    remote: oakbench_remotes.RemoteResolution,
1390
    run_root: Path,
1391
    run_index: int,
1392
    metadata: dict[str, Any],
1393
    admitted: int,
1394
) -> list[dict[str, Any]]:
1395
    scenario = "task_loop"
1396
    meta = {
1397
        **metadata,
1398
        **remote.row_fields(),
1399
        "benchmark_track": (
1400
            "core-equivalent"
1401
            if remote.transport == oakbench_remotes.TRANSPORT_LOCAL_FILE
1402
            else "agent-default"
1403
        ),
1404
    }
1405
    vcs = str(subject.bin)
1406
    rows: list[dict[str, Any]] = []
1407
1408
    seed_error = ensure_git_remote_seeded(subject, remote, run_root)
1409
    if seed_error:
1410
        return [skip_row(subject, scenario, run_index, meta, seed_error)]
1411
1412
    # Untimed: the base clone is the long-lived checkout worktrees hang off;
1413
    # an agent fleet pays it once, not per task.
1414
    base = run_root / f"tl-git-base-r{run_index}"
1415
    clone = run_untimed([vcs, "clone", "-q", str(remote.repo), str(base)], run_root)
1416
    if clone.returncode != 0:
1417
        return [skip_row(subject, scenario, run_index, meta, f"base clone failed: {clone.stderr.strip()[:200]}")]
1418
1419
    branches: dict[str, str] = {}
1420
    payloads: dict[str, str] = {}
1421
    for phase in PHASES:
1422
        workspace = run_root / f"tl-{subject.name}-r{run_index}-{phase}"
1423
        branch = oakbench_remotes.disposable_branch(
1424
            str(metadata["bench_id"]), f"taskloop-{phase}", subject.name, run_index
1425
        )
1426
        branches[phase] = branch
1427
        payload = f"payload {phase} run={run_index} bench={metadata['bench_id']}"
1428
        payloads[phase] = payload
1429
        acquire = step_row(
1430
            subject, scenario, f"{phase}.acquire",
1431
            [vcs, "worktree", "add", str(workspace), "-b", branch], base, run_index, meta, admitted,
1432
        )
1433
        rows.append(acquire)
1434
        if int(acquire["returncode"]) != 0:
1435
            return rows
1436
        rows.append(edit_step(subject, scenario, f"{phase}.edit", workspace, payload, run_index, meta, admitted))
1437
        # Agent-default snapshot: git pays stage+commit as two calls.
1438
        rows.append(step_row(subject, scenario, f"{phase}.snapshot.add", [vcs, "add", "."], workspace, run_index, meta, admitted))
1439
        rows.append(
1440
            step_row(
1441
                subject, scenario, f"{phase}.snapshot.commit",
1442
                [vcs, "commit", "-q", "-m", f"bench task loop {metadata['bench_id']} r{run_index} {phase}"],
1443
                workspace, run_index, meta, admitted,
1444
            )
1445
        )
1446
        rows.append(
1447
            step_row(
1448
                subject, scenario, f"{phase}.publish",
1449
                [vcs, "push", "-q", "-u", "origin", branch], workspace, run_index, meta, admitted,
1450
            )
1451
        )
1452
        # No task.describe: git's description is the commit message already
1453
        # paid in {phase}.snapshot.commit (asymmetry documented in the lane doc).
1454
        rows.append(
1455
            step_row(
1456
                subject, scenario, f"{phase}.end",
1457
                [vcs, "worktree", "remove", str(workspace)], base, run_index, meta, admitted,
1458
            )
1459
        )
1460
1461
    # Untimed oracle: payloads visible on the remote branches.
1462
    oracle_ok: bool | None = True
1463
    for phase in PHASES:
1464
        fetch = run_untimed([vcs, "fetch", "-q", "origin", branches[phase]], base)
1465
        show = run_untimed([vcs, "show", f"FETCH_HEAD:task-notes.md"], base)
1466
        if fetch.returncode != 0 or payloads[phase] not in show.stdout:
1467
            oracle_ok = False
1468
    rows.append(
1469
        {
1470
            **phase_total_row(rows, subject, scenario, run_index, meta, "task_loop.total"),
1471
            "oracle_payload_on_remote_branch": oracle_ok,
1472
            "workspace_branches": branches,
1473
            "measurement_source": "fetch_plus_show_grep",
1474
        }
1475
    )
1476
    return rows
1477
1478
1479
def loop_summary_fields(rows: list[dict[str, Any]]) -> dict[str, Any]:
1480
    """followup-vs-task warm deltas computed from this run's step rows."""
1481
    def op_ms(operation: str) -> float | None:
1482
        for row in rows:
1483
            if row.get("operation") == operation:
1484
                return float(row.get("elapsed_ms", 0.0))
1485
        return None
1486
1487
    def phase_ms(prefix: str) -> float:
1488
        return sum(
1489
            float(row.get("elapsed_ms", 0.0))
1490
            for row in rows
1491
            if str(row.get("operation", "")).startswith(prefix + ".")
1492
        )
1493
1494
    task_acquire = op_ms("task.acquire")
1495
    followup_acquire = op_ms("followup.acquire")
1496
    fields: dict[str, Any] = {
1497
        "task_phase_ms": round(phase_ms("task"), 3),
1498
        "followup_phase_ms": round(phase_ms("followup"), 3),
1499
    }
1500
    if task_acquire is not None and followup_acquire is not None:
1501
        fields["followup_warm_delta_ms"] = round(followup_acquire - task_acquire, 3)
1502
        fields["acquire_ms_task"] = round(task_acquire, 3)
1503
        fields["acquire_ms_followup"] = round(followup_acquire, 3)
1504
    else:
1505
        fields["followup_warm_delta_ms"] = None
1506
    return fields
1507
1508
1509
def summary_text(rows: list[dict[str, Any]]) -> str:
1510
    totals = [row for row in rows if row.get("operation") == "task_loop.total"]
1511
    lines = [
1512
        "# Task Loop Summary",
1513
        "",
1514
        "Whole-task cost of the signature motion: acquire workspace -> edit -> snapshot ->",
1515
        "publish -> describe -> end, then a follow-up task in a fresh workspace.",
1516
        "Command sequences are NOT step-equivalent across subjects",
1517
        "(oak pays desc; git's description is its commit message). Latency across unlike",
1518
        "transports is never comparable; scripted call equivalents and estimated token proxies are.",
1519
        "",
1520
        "| Subject | Run | Transport | Wall ms | Scripted call equivalents | Cost-wt tokens (env) | Warm acquire delta ms | Oracle |",
1521
        "| --- | ---: | --- | ---: | ---: | ---: | ---: | --- |",
1522
    ]
1523
    for row in totals:
1524
        oracle = row.get("oracle_payload_on_remote_branch")
1525
        lines.append(
1526
            "| `{}` | {} | {} | {} | {} | {} | {} | {} |".format(
1527
                row["subject"],
1528
                row["run"],
1529
                row.get("remote_transport", "unmeasured"),
1530
                fmt_ms(row.get("elapsed_ms")),
1531
                row.get("tool_call_count", "unmeasured"),
1532
                row.get("estimated_cost_weighted_tokens_with_envelope", "unmeasured"),
1533
                row.get("followup_warm_delta_ms", "unmeasured"),
1534
                "pass" if oracle else ("FAIL" if oracle is False else "unmeasured"),
1535
            )
1536
        )
1537
    skips = [
1538
        row for row in rows
1539
        if row.get("skipped") and row.get("scenario") == TASK_LOOP_SCENARIO
1540
    ]
1541
    for row in skips:
1542
        lines.append(f"| `{row['subject']}` | {row['run']} | skipped | | | | | {row['skip_reason'][:70]} |")
1543
1544
    agent_totals = [
1545
        row
1546
        for row in rows
1547
        if row.get("operation") in ("agent_task.total", "agent_task_oak_local.total")
1548
    ]
1549
    if agent_totals:
1550
        lines.extend(
1551
            [
1552
                "",
1553
                "## Cold clone and warm isolated task",
1554
                "",
1555
                "Local-file Git rows compare a fresh full clone with a worktree sharing",
1556
                "the first clone's object store. Oak local rows use two independent fresh",
1557
                "clones against an owned loopback server; they do not claim a shared cache.",
1558
                "OS cache and network bytes remain unknown. Hosted CI/review are skips.",
1559
                "",
1560
                "| Subject | Run | Wall ms | Scripted call equivalents | Output bytes | Coverage | Oracle |",
1561
                "| --- | ---: | ---: | ---: | ---: | --- | --- |",
1562
            ]
1563
        )
1564
        for row in agent_totals:
1565
            lines.append(
1566
                "| `{}` | {} | {} | {} | {} | {} | {} |".format(
1567
                    row["subject"],
1568
                    row["run"],
1569
                    fmt_ms(row.get("elapsed_ms")),
1570
                    row.get("tool_call_count", "unmeasured"),
1571
                    row.get("raw_output_bytes", "unmeasured"),
1572
                    "complete" if row.get("coverage_complete") else "partial",
1573
                    "pass" if row.get("oracle_payload_on_remote_branch") else "FAIL",
1574
                )
1575
            )
1576
1577
    interrupted_totals = [
1578
        row for row in rows if row.get("operation") == "interrupted_publication.total"
1579
    ]
1580
    if interrupted_totals:
1581
        lines.extend(
1582
            [
1583
                "",
1584
                "## Interrupted publication",
1585
                "",
1586
                "A loopback fixture validates the request framing and drops the response.",
1587
                "Fixture delivery knowledge is reported separately from the CLI's own outcome",
1588
                "claim. Request receipt is not durability proof; the harness never retries.",
1589
                "",
1590
                "| Subject | Run | Wall ms | Scripted call equivalents | Output bytes | CLI outcome | Fixture delivery | POSTs | Request bytes | Head/work preserved |",
1591
                "| --- | ---: | ---: | ---: | ---: | --- | --- | ---: | ---: | --- |",
1592
            ]
1593
        )
1594
        for row in interrupted_totals:
1595
            lines.append(
1596
                "| `{}` | {} | {} | {} | {} | `{}` | `{}` | {} | {} | {}/{} |".format(
1597
                    row["subject"],
1598
                    row["run"],
1599
                    fmt_ms(row.get("elapsed_ms")),
1600
                    row.get("tool_call_count", "unmeasured"),
1601
                    row.get("raw_output_bytes", "unmeasured"),
1602
                    row.get("publication_outcome", "unmeasured"),
1603
                    row.get("fixture_delivery_state", "unmeasured"),
1604
                    row.get("mutation_requests_observed", "unmeasured"),
1605
                    row.get("network_request_bytes_observed", "unmeasured"),
1606
                    "yes" if row.get("local_head_preserved") else "no",
1607
                    "yes" if row.get("local_work_preserved") else "no",
1608
                )
1609
            )
1610
    return "\n".join(lines) + "\n"
1611
1612
1613
def fmt_ms(value: Any) -> str:
1614
    try:
1615
        return f"{float(value):.0f}"
1616
    except (TypeError, ValueError):
1617
        return "unmeasured"
1618
1619
1620
def main() -> int:
1621
    args = parse_args()
1622
    scenarios = [item.strip() for item in args.scenarios.split(",") if item.strip()]
1623
    known_scenarios = {
1624
        TASK_LOOP_SCENARIO,
1625
        AGENT_TASK_SCENARIO,
1626
        AGENT_TASK_OAK_LOCAL_SCENARIO,
1627
        INTERRUPTED_PUBLICATION_SCENARIO,
1628
    }
1629
    unknown_scenarios = sorted(set(scenarios) - known_scenarios)
1630
    if unknown_scenarios:
1631
        raise SystemExit(
1632
            "Unknown scenarios: " + ", ".join(unknown_scenarios)
1633
            + "; valid: " + ", ".join(sorted(known_scenarios))
1634
        )
1635
    subjects = load_subjects(args)
1636
    timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
1637
    args.results.mkdir(parents=True, exist_ok=True)
1638
    args.workdir.mkdir(parents=True, exist_ok=True)
1639
    run_root = args.workdir / "runs" / timestamp
1640
    run_root.mkdir(parents=True, exist_ok=True)
1641
1642
    metadata = {
1643
        "bench_id": timestamp,
1644
        "profile": "task-loop",
1645
        "benchmark_track": "agent-default",
1646
        "timestamp_utc": timestamp,
1647
        "host": platform.node(),
1648
        "platform": platform.platform(),
1649
        "machine": platform.machine(),
1650
        "python": platform.python_version(),
1651
        "env_isolation_version": ENV_ISOLATION_VERSION,
1652
        "subject_versions": subject_versions(subjects),
1653
        "subject_details": subject_details(subjects),
1654
        "source": source_metadata(args.oak_repo),
1655
        "git_remote_mode": args.git_remote,
1656
        "trial_order_randomized": args.randomize_subject_order,
1657
        "trial_order_seed": args.random_seed if args.randomize_subject_order else None,
1658
    }
1659
1660
    rows: list[dict[str, Any]] = []
1661
    with measurement_lock("task_loop") as lock_info:
1662
        metadata["measurement_lock_wait_ms"] = lock_info.wait_ms
1663
        metadata["measurement_lock"] = "held" if lock_info.enabled else "disabled"
1664
        order_rng = random.Random(args.random_seed)
1665
        for run_index in range(args.runs):
1666
            run_subjects = list(subjects)
1667
            if args.randomize_subject_order:
1668
                order_rng.shuffle(run_subjects)
1669
            run_metadata = {
1670
                **metadata,
1671
                "run_subject_order": [subject.name for subject in run_subjects],
1672
            }
1673
            for subject in run_subjects:
1674
                for scenario in scenarios:
1675
                    print(f"[run] {scenario} run={run_index} subject={subject.name}", flush=True)
1676
                    if scenario == INTERRUPTED_PUBLICATION_SCENARIO:
1677
                        rows.extend(
1678
                            run_interrupted_publication(
1679
                                subject, run_root, run_index, run_metadata, args.admitted_output_chars
1680
                            )
1681
                        )
1682
                        continue
1683
                    if scenario == AGENT_TASK_SCENARIO:
1684
                        if subject.kind != "git":
1685
                            rows.append(
1686
                                unmeasured_step_row(
1687
                                    subject,
1688
                                    scenario,
1689
                                    "agent_task.skipped",
1690
                                    run_index,
1691
                                    run_metadata,
1692
                                    "Oak end-to-end publication and exact CI require an explicitly configured disposable staging lane; production mutation is forbidden",
1693
                                )
1694
                            )
1695
                            continue
1696
                        remote_create_start = time.perf_counter()
1697
                        remote = oakbench_remotes.make_git_bare_remote(
1698
                            subject.bin, run_root, f"agent-task-remote-r{run_index}.git"
1699
                        )
1700
                        remote_create_ms = (time.perf_counter() - remote_create_start) * 1000
1701
                        rows.extend(
1702
                            run_git_agent_task_baseline(
1703
                                subject,
1704
                                remote,
1705
                                run_root,
1706
                                run_index,
1707
                                run_metadata,
1708
                                args.admitted_output_chars,
1709
                                remote_create_ms,
1710
                            )
1711
                        )
1712
                        continue
1713
                    if scenario == AGENT_TASK_OAK_LOCAL_SCENARIO:
1714
                        if subject.kind != "oak":
1715
                            rows.append(
1716
                                unmeasured_step_row(
1717
                                    subject,
1718
                                    scenario,
1719
                                    "agent_task_oak_local.skipped",
1720
                                    run_index,
1721
                                    run_metadata,
1722
                                    "owned local oak serve scenario applies only to Oak subjects",
1723
                                )
1724
                            )
1725
                            continue
1726
                        rows.extend(
1727
                            run_oak_local_agent_task_baseline(
1728
                                subject,
1729
                                run_root,
1730
                                run_index,
1731
                                run_metadata,
1732
                                args.admitted_output_chars,
1733
                            )
1734
                        )
1735
                        continue
1736
                    if subject.kind == "git":
1737
                        if args.git_remote == "github":
1738
                            remote = oakbench_remotes.resolve_git_github_remote()
1739
                        else:
1740
                            remote = oakbench_remotes.make_git_bare_remote(
1741
                                subject.bin, run_root, f"task-loop-remote-r{run_index}.git"
1742
                            )
1743
                        if not remote.resolved:
1744
                            rows.append(skip_row(subject, TASK_LOOP_SCENARIO, run_index, run_metadata, remote.skip_reason or ""))
1745
                            continue
1746
                        subject_rows = run_git_loop(subject, remote, run_root, run_index, run_metadata, args.admitted_output_chars)
1747
                    else:
1748
                        remote = oakbench_remotes.resolve_oak_remote("sync")
1749
                        if not remote.resolved:
1750
                            rows.append(skip_row(subject, TASK_LOOP_SCENARIO, run_index, run_metadata, remote.skip_reason or ""))
1751
                            continue
1752
                        subject_rows = run_oak_loop(subject, remote, run_root, run_index, run_metadata, args.admitted_output_chars)
1753
                    total = next((row for row in subject_rows if row.get("operation") == "task_loop.total"), None)
1754
                    if total is not None:
1755
                        total.update(loop_summary_fields(subject_rows))
1756
                    rows.extend(subject_rows)
1757
1758
    store = ResultsStore(args.results, lane="task-loop")
1759
    raw_path, summary_path = store.write(timestamp, rows, summary_text(rows))
1760
    if not args.keep_workdirs:
1761
        shutil.rmtree(run_root, ignore_errors=True)
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())