Viewing files at commit 63ed254f5df1Commit detailsView latest

Browse files
scripts/task_loop.py 573 lines · 22.3 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 platform
32
import re
33
import shutil
34
import subprocess
35
import tempfile
36
import time
37
from datetime import datetime, timezone
38
from pathlib import Path
39
from typing import Any
40
41
from oakbench import remotes as oakbench_remotes
42
from oakbench import tokens as oakbench_tokens
43
from oakbench.environment import ENV_ISOLATION_VERSION, base_env as oakbench_base_env
44
from oakbench.environment import command_display
45
from oakbench.execution import run_timed
46
from oakbench.results import ResultsStore
47
from oakbench.rows import row_returncode
48
from oakbench.runlock import measurement_lock
49
from oakbench.subjects import (
50
    DEFAULT_OAK_REPO,
51
    Subject,
52
    load_subjects,
53
    source_metadata,
54
    subject_details,
55
    subject_versions,
56
)
57
58
ROOT = Path(__file__).resolve().parents[1]
59
DEFAULT_WORKDIR = Path(tempfile.gettempdir()) / "oak-task-loop"
60
SKIP_RETURNCODE = 77
61
VIRTUAL_BRANCH_RE = re.compile(r"virtual branch ([^\s)]+)")
62
PHASES = ("task", "followup")
63
64
65
def base_env() -> dict[str, str]:
66
    return oakbench_base_env(
67
        author_name="Oak Task Loop",
68
        author_email="[email protected]",
69
        oak_author="oak-task-loop",
70
    )
71
72
73
def parse_args() -> argparse.Namespace:
74
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
75
    parser.add_argument("--subjects", help="Comma-separated subject names from config/subjects.toml")
76
    parser.add_argument("--config", type=Path, default=ROOT / "config" / "subjects.toml")
77
    parser.add_argument("--git-bin", type=Path)
78
    parser.add_argument("--oak-installed-bin", type=Path)
79
    parser.add_argument("--oak-local-bin", type=Path)
80
    parser.add_argument("--runs", type=int, default=3)
81
    parser.add_argument(
82
        "--git-remote",
83
        choices=("github", "local"),
84
        default="github",
85
        help=(
86
            "github: agent-default track, real push to GIT_BENCH_REMOTE "
87
            "(transport-comparable to oak). local: core-equivalent track, "
88
            "local bare remote (no network in git's loop)."
89
        ),
90
    )
91
    parser.add_argument("--workdir", type=Path, default=DEFAULT_WORKDIR)
92
    parser.add_argument("--results", type=Path, default=ROOT / "results" / "task-loop")
93
    parser.add_argument("--oak-repo", type=Path, default=DEFAULT_OAK_REPO)
94
    parser.add_argument("--admitted-output-chars", type=int, default=20_000)
95
    parser.add_argument("--keep-workdirs", action="store_true")
96
    return parser.parse_args()
97
98
99
def run_untimed(command: list[str], cwd: Path | None, timeout: float = 300.0) -> subprocess.CompletedProcess[str]:
100
    return subprocess.run(
101
        command,
102
        cwd=cwd,
103
        env=base_env(),
104
        text=True,
105
        stdout=subprocess.PIPE,
106
        stderr=subprocess.PIPE,
107
        timeout=timeout,
108
        check=False,
109
    )
110
111
112
def step_row(
113
    subject: Subject,
114
    scenario: str,
115
    operation: str,
116
    command: list[str],
117
    cwd: Path,
118
    run_index: int,
119
    metadata: dict[str, Any],
120
    admitted_output_chars: int,
121
    *,
122
    kind: str = "vcs",
123
    expected_returncodes: tuple[int, ...] = (0,),
124
) -> dict[str, Any]:
125
    capture = run_timed(command, cwd, base_env(), admitted_output_chars)
126
    expected = capture.returncode in expected_returncodes
127
    is_vcs = 1 if kind == "vcs" else 0
128
    row = {
129
        **metadata,
130
        "subject": subject.name,
131
        "subject_kind": subject.kind,
132
        "subject_label": subject.label,
133
        "scenario": scenario,
134
        "run": run_index,
135
        "operation": operation,
136
        "step_kind": kind,
137
        "elapsed_ms": round(capture.elapsed_ms, 3),
138
        "returncode": 0 if expected else (capture.returncode or 1),
139
        "process_returncode": capture.returncode,
140
        "expected_returncodes": list(expected_returncodes),
141
        "command": command,
142
        "tool_call_count": 1,
143
        "terminal_tool_call_count": 1,
144
        "vcs_tool_call_count": is_vcs,
145
        **oakbench_tokens.interaction_token_fields(
146
            command_display(command),
147
            capture.stdout_text,
148
            capture.stderr_text,
149
            capture.stdout_bytes,
150
            capture.stderr_bytes,
151
            capture.stdout_truncated,
152
            capture.stderr_truncated,
153
        ),
154
    }
155
    if not expected:
156
        row["stderr"] = capture.stderr_text[-4000:]
157
    return row
158
159
160
def skip_row(
161
    subject: Subject,
162
    scenario: str,
163
    run_index: int,
164
    metadata: dict[str, Any],
165
    reason: str,
166
) -> dict[str, Any]:
167
    return {
168
        **metadata,
169
        "subject": subject.name,
170
        "subject_kind": subject.kind,
171
        "subject_label": subject.label,
172
        "scenario": scenario,
173
        "run": run_index,
174
        "operation": "task_loop.skipped",
175
        "step_kind": "summary",
176
        "elapsed_ms": 0.0,
177
        "returncode": SKIP_RETURNCODE,
178
        "command": [],
179
        "skipped": True,
180
        "skip_reason": reason,
181
        "tool_call_count": 0,
182
        "terminal_tool_call_count": 0,
183
        "vcs_tool_call_count": 0,
184
    }
185
186
187
def phase_total_row(
188
    rows: list[dict[str, Any]],
189
    subject: Subject,
190
    scenario: str,
191
    run_index: int,
192
    metadata: dict[str, Any],
193
    operation: str,
194
) -> dict[str, Any]:
195
    failures = [row for row in rows if row_returncode(row) != 0]
196
    return {
197
        **metadata,
198
        "subject": subject.name,
199
        "subject_kind": subject.kind,
200
        "subject_label": subject.label,
201
        "scenario": scenario,
202
        "run": run_index,
203
        "operation": operation,
204
        "step_kind": "summary",
205
        "elapsed_ms": round(sum(float(row.get("elapsed_ms", 0)) for row in rows), 3),
206
        "returncode": 1 if failures else 0,
207
        "command": [row["command"] for row in rows],
208
        "tool_call_count": sum(oakbench_tokens.int_or_zero(row.get("tool_call_count")) for row in rows),
209
        "terminal_tool_call_count": sum(
210
            oakbench_tokens.int_or_zero(row.get("terminal_tool_call_count")) for row in rows
211
        ),
212
        "vcs_tool_call_count": sum(oakbench_tokens.int_or_zero(row.get("vcs_tool_call_count")) for row in rows),
213
        **oakbench_tokens.summed_token_fields(
214
            rows, "sum_of_task_loop_steps_command_plus_admitted_output_chars_div_4"
215
        ),
216
        "summarized_operations": [row["operation"] for row in rows],
217
    }
218
219
220
def has_lane_failures(rows: list[dict[str, Any]]) -> bool:
221
    return any(row_returncode(row) not in (0, SKIP_RETURNCODE) for row in rows)
222
223
224
def enforce_oracle_result(total: dict[str, Any], oracle_ok: bool | None) -> dict[str, Any]:
225
    if oracle_ok is False:
226
        total["returncode"] = 1
227
        total["stderr"] = "oracle_payload_missing_from_remote_branch"
228
    return total
229
230
231
def edit_step(
232
    subject: Subject,
233
    scenario: str,
234
    operation: str,
235
    workspace: Path,
236
    payload: str,
237
    run_index: int,
238
    metadata: dict[str, Any],
239
    admitted: int,
240
) -> dict[str, Any]:
241
    command = ["/bin/zsh", "-c", f"echo {payload!r} >> task-notes.md"]
242
    return step_row(
243
        subject, scenario, operation, command, workspace, run_index, metadata, admitted, kind="edit"
244
    )
245
246
247
def oak_virtual_branch(subject: Subject, mount_dir: Path) -> str | None:
248
    proc = run_untimed([str(subject.bin), "status"], mount_dir)
249
    match = VIRTUAL_BRANCH_RE.search(proc.stdout + proc.stderr)
250
    return match.group(1) if match else None
251
252
253
def run_oak_loop(
254
    subject: Subject,
255
    remote: oakbench_remotes.RemoteResolution,
256
    run_root: Path,
257
    run_index: int,
258
    metadata: dict[str, Any],
259
    admitted: int,
260
) -> list[dict[str, Any]]:
261
    scenario = "task_loop"
262
    meta = {**metadata, **remote.row_fields()}
263
    rows: list[dict[str, Any]] = []
264
    branches: dict[str, str | None] = {}
265
    payloads: dict[str, str] = {}
266
    mounts: list[Path] = []
267
    vcs = str(subject.bin)
268
    try:
269
        for phase in PHASES:
270
            workspace = run_root / f"tl-{subject.name}-r{run_index}-{phase}"
271
            payload = f"payload {phase} run={run_index} bench={metadata['bench_id']}"
272
            payloads[phase] = payload
273
            acquire = step_row(
274
                subject, scenario, f"{phase}.acquire", [vcs, "mount", remote.repo or "", str(workspace)],
275
                run_root, run_index, meta, admitted,
276
            )
277
            rows.append(acquire)
278
            if int(acquire["returncode"]) != 0:
279
                return rows
280
            mounts.append(workspace)
281
            branches[phase] = oak_virtual_branch(subject, workspace)
282
            rows.append(edit_step(subject, scenario, f"{phase}.edit", workspace, payload, run_index, meta, admitted))
283
            rows.append(step_row(subject, scenario, f"{phase}.snapshot", [vcs, "commit", "--no-verify"], workspace, run_index, meta, admitted))
284
            rows.append(step_row(subject, scenario, f"{phase}.publish", [vcs, "push"], workspace, run_index, meta, admitted))
285
            if phase == "task":
286
                rows.append(
287
                    step_row(
288
                        subject, scenario, "task.describe",
289
                        [vcs, "desc", f"bench task loop {metadata['bench_id']} r{run_index}"],
290
                        workspace, run_index, meta, admitted,
291
                    )
292
                )
293
            end = step_row(
294
                subject, scenario, f"{phase}.end", [vcs, "mount", "end", str(workspace)],
295
                run_root, run_index, meta, admitted,
296
            )
297
            rows.append(end)
298
            if int(end["returncode"]) == 0 and workspace in mounts:
299
                mounts.remove(workspace)
300
301
        # Untimed oracle: each phase's payload must be on its pushed branch.
302
        verifier = run_root / f"tl-verify-{subject.name}-r{run_index}"
303
        clone = run_untimed([vcs, "clone", remote.repo or "", str(verifier)], run_root)
304
        oracle_ok: bool | None = None
305
        if clone.returncode == 0:
306
            oracle_ok = True
307
            for phase in PHASES:
308
                branch = branches.get(phase)
309
                if not branch:
310
                    oracle_ok = None
311
                    break
312
                switch = run_untimed([vcs, "switch", branch], verifier)
313
                content = (verifier / "task-notes.md")
314
                seen = content.read_text() if content.exists() else ""
315
                if switch.returncode != 0 or payloads[phase] not in seen:
316
                    oracle_ok = False
317
        total = {
318
            **phase_total_row(rows, subject, scenario, run_index, meta, "task_loop.total"),
319
            "oracle_payload_on_remote_branch": oracle_ok,
320
            "workspace_branches": branches,
321
            "measurement_source": "verifier_clone_switch_grep; null oracle means branch name unparsed",
322
        }
323
        rows.append(enforce_oracle_result(total, oracle_ok))
324
        return rows
325
    finally:
326
        for mount_dir in mounts:
327
            run_untimed([vcs, "mount", "end", str(mount_dir), "-f"], run_root)
328
329
330
def ensure_git_remote_seeded(subject: Subject, remote: oakbench_remotes.RemoteResolution, scratch: Path) -> str | None:
331
    """Untimed: make sure the git remote has a main with a seed commit."""
332
    vcs = str(subject.bin)
333
    probe = run_untimed([vcs, "ls-remote", str(remote.repo), "refs/heads/main"], None)
334
    if probe.returncode == 0 and probe.stdout.strip():
335
        return None
336
    seed = scratch / "git-seed"
337
    if seed.exists():
338
        shutil.rmtree(seed)
339
    seed.mkdir(parents=True)
340
    (seed / "README.md").write_text("Task-loop benchmark repo (disposable).\n")
341
    (seed / "task-notes.md").write_text("# task notes\n")
342
    for command in (
343
        [vcs, "init", "-q", "-b", "main"],
344
        [vcs, "add", "."],
345
        [vcs, "commit", "-q", "-m", "seed"],
346
        [vcs, "push", "-q", str(remote.repo), "main:main"],
347
    ):
348
        proc = run_untimed(command, seed)
349
        if proc.returncode != 0:
350
            return f"git remote seed `{' '.join(command[1:3])}` failed: {proc.stderr.strip()[:200]}"
351
    return None
352
353
354
def run_git_loop(
355
    subject: Subject,
356
    remote: oakbench_remotes.RemoteResolution,
357
    run_root: Path,
358
    run_index: int,
359
    metadata: dict[str, Any],
360
    admitted: int,
361
) -> list[dict[str, Any]]:
362
    scenario = "task_loop"
363
    meta = {**metadata, **remote.row_fields()}
364
    vcs = str(subject.bin)
365
    rows: list[dict[str, Any]] = []
366
367
    seed_error = ensure_git_remote_seeded(subject, remote, run_root)
368
    if seed_error:
369
        return [skip_row(subject, scenario, run_index, meta, seed_error)]
370
371
    # Untimed: the base clone is the long-lived checkout worktrees hang off;
372
    # an agent fleet pays it once, not per task.
373
    base = run_root / f"tl-git-base-r{run_index}"
374
    clone = run_untimed([vcs, "clone", "-q", str(remote.repo), str(base)], run_root)
375
    if clone.returncode != 0:
376
        return [skip_row(subject, scenario, run_index, meta, f"base clone failed: {clone.stderr.strip()[:200]}")]
377
378
    branches: dict[str, str] = {}
379
    payloads: dict[str, str] = {}
380
    for phase in PHASES:
381
        workspace = run_root / f"tl-{subject.name}-r{run_index}-{phase}"
382
        branch = oakbench_remotes.disposable_branch(
383
            str(metadata["bench_id"]), f"taskloop-{phase}", subject.name, run_index
384
        )
385
        branches[phase] = branch
386
        payload = f"payload {phase} run={run_index} bench={metadata['bench_id']}"
387
        payloads[phase] = payload
388
        acquire = step_row(
389
            subject, scenario, f"{phase}.acquire",
390
            [vcs, "worktree", "add", str(workspace), "-b", branch], base, run_index, meta, admitted,
391
        )
392
        rows.append(acquire)
393
        if int(acquire["returncode"]) != 0:
394
            return rows
395
        rows.append(edit_step(subject, scenario, f"{phase}.edit", workspace, payload, run_index, meta, admitted))
396
        # Agent-default snapshot: git pays stage+commit as two calls.
397
        rows.append(step_row(subject, scenario, f"{phase}.snapshot.add", [vcs, "add", "."], workspace, run_index, meta, admitted))
398
        rows.append(
399
            step_row(
400
                subject, scenario, f"{phase}.snapshot.commit",
401
                [vcs, "commit", "-q", "-m", f"bench task loop {metadata['bench_id']} r{run_index} {phase}"],
402
                workspace, run_index, meta, admitted,
403
            )
404
        )
405
        rows.append(
406
            step_row(
407
                subject, scenario, f"{phase}.publish",
408
                [vcs, "push", "-q", "-u", "origin", branch], workspace, run_index, meta, admitted,
409
            )
410
        )
411
        # No task.describe: git's description is the commit message already
412
        # paid in {phase}.snapshot.commit (asymmetry documented in the lane doc).
413
        rows.append(
414
            step_row(
415
                subject, scenario, f"{phase}.end",
416
                [vcs, "worktree", "remove", str(workspace)], base, run_index, meta, admitted,
417
            )
418
        )
419
420
    # Untimed oracle: payloads visible on the remote branches.
421
    oracle_ok: bool | None = True
422
    for phase in PHASES:
423
        fetch = run_untimed([vcs, "fetch", "-q", "origin", branches[phase]], base)
424
        show = run_untimed([vcs, "show", f"FETCH_HEAD:task-notes.md"], base)
425
        if fetch.returncode != 0 or payloads[phase] not in show.stdout:
426
            oracle_ok = False
427
    rows.append(
428
        {
429
            **phase_total_row(rows, subject, scenario, run_index, meta, "task_loop.total"),
430
            "oracle_payload_on_remote_branch": oracle_ok,
431
            "workspace_branches": branches,
432
            "measurement_source": "fetch_plus_show_grep",
433
        }
434
    )
435
    return rows
436
437
438
def loop_summary_fields(rows: list[dict[str, Any]]) -> dict[str, Any]:
439
    """followup-vs-task warm deltas computed from this run's step rows."""
440
    def op_ms(operation: str) -> float | None:
441
        for row in rows:
442
            if row.get("operation") == operation:
443
                return float(row.get("elapsed_ms", 0.0))
444
        return None
445
446
    def phase_ms(prefix: str) -> float:
447
        return sum(
448
            float(row.get("elapsed_ms", 0.0))
449
            for row in rows
450
            if str(row.get("operation", "")).startswith(prefix + ".")
451
        )
452
453
    task_acquire = op_ms("task.acquire")
454
    followup_acquire = op_ms("followup.acquire")
455
    fields: dict[str, Any] = {
456
        "task_phase_ms": round(phase_ms("task"), 3),
457
        "followup_phase_ms": round(phase_ms("followup"), 3),
458
    }
459
    if task_acquire is not None and followup_acquire is not None:
460
        fields["followup_warm_delta_ms"] = round(followup_acquire - task_acquire, 3)
461
        fields["acquire_ms_task"] = round(task_acquire, 3)
462
        fields["acquire_ms_followup"] = round(followup_acquire, 3)
463
    else:
464
        fields["followup_warm_delta_ms"] = None
465
    return fields
466
467
468
def summary_text(rows: list[dict[str, Any]]) -> str:
469
    totals = [row for row in rows if row.get("operation") == "task_loop.total"]
470
    lines = [
471
        "# Task Loop Summary",
472
        "",
473
        "Whole-task cost of the signature motion: acquire workspace -> edit -> snapshot ->",
474
        "publish -> describe -> end, then a follow-up task in a fresh workspace.",
475
        "Agent-default track: command sequences are NOT step-equivalent across subjects",
476
        "(oak pays desc; git's description is its commit message). Latency across unlike",
477
        "transports is never comparable; tool calls and tokens are.",
478
        "",
479
        "| Subject | Run | Transport | Wall ms | Tool calls | Cost-wt tokens (env) | Warm acquire delta ms | Oracle |",
480
        "| --- | ---: | --- | ---: | ---: | ---: | ---: | --- |",
481
    ]
482
    for row in totals:
483
        oracle = row.get("oracle_payload_on_remote_branch")
484
        lines.append(
485
            "| `{}` | {} | {} | {} | {} | {} | {} | {} |".format(
486
                row["subject"],
487
                row["run"],
488
                row.get("remote_transport", "unmeasured"),
489
                fmt_ms(row.get("elapsed_ms")),
490
                row.get("tool_call_count", "unmeasured"),
491
                row.get("estimated_cost_weighted_tokens_with_envelope", "unmeasured"),
492
                row.get("followup_warm_delta_ms", "unmeasured"),
493
                "pass" if oracle else ("FAIL" if oracle is False else "unmeasured"),
494
            )
495
        )
496
    skips = [row for row in rows if row.get("skipped")]
497
    for row in skips:
498
        lines.append(f"| `{row['subject']}` | {row['run']} | skipped | | | | | {row['skip_reason'][:70]} |")
499
    return "\n".join(lines) + "\n"
500
501
502
def fmt_ms(value: Any) -> str:
503
    try:
504
        return f"{float(value):.0f}"
505
    except (TypeError, ValueError):
506
        return "unmeasured"
507
508
509
def main() -> int:
510
    args = parse_args()
511
    subjects = load_subjects(args)
512
    timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
513
    args.results.mkdir(parents=True, exist_ok=True)
514
    args.workdir.mkdir(parents=True, exist_ok=True)
515
    run_root = args.workdir / "runs" / timestamp
516
    run_root.mkdir(parents=True, exist_ok=True)
517
518
    metadata = {
519
        "bench_id": timestamp,
520
        "profile": "task-loop",
521
        "benchmark_track": "agent-default",
522
        "timestamp_utc": timestamp,
523
        "host": platform.node(),
524
        "platform": platform.platform(),
525
        "machine": platform.machine(),
526
        "python": platform.python_version(),
527
        "env_isolation_version": ENV_ISOLATION_VERSION,
528
        "subject_versions": subject_versions(subjects),
529
        "subject_details": subject_details(subjects),
530
        "source": source_metadata(args.oak_repo),
531
        "git_remote_mode": args.git_remote,
532
    }
533
534
    rows: list[dict[str, Any]] = []
535
    with measurement_lock("task_loop") as lock_info:
536
        metadata["measurement_lock_wait_ms"] = lock_info.wait_ms
537
        metadata["measurement_lock"] = "held" if lock_info.enabled else "disabled"
538
        for run_index in range(args.runs):
539
            for subject in subjects:
540
                print(f"[run] task_loop run={run_index} subject={subject.name}", flush=True)
541
                if subject.kind == "git":
542
                    if args.git_remote == "github":
543
                        remote = oakbench_remotes.resolve_git_github_remote()
544
                    else:
545
                        remote = oakbench_remotes.make_git_bare_remote(
546
                            subject.bin, run_root, f"task-loop-remote-r{run_index}.git"
547
                        )
548
                    if not remote.resolved:
549
                        rows.append(skip_row(subject, "task_loop", run_index, metadata, remote.skip_reason or ""))
550
                        continue
551
                    subject_rows = run_git_loop(subject, remote, run_root, run_index, metadata, args.admitted_output_chars)
552
                else:
553
                    remote = oakbench_remotes.resolve_oak_remote("sync")
554
                    if not remote.resolved:
555
                        rows.append(skip_row(subject, "task_loop", run_index, metadata, remote.skip_reason or ""))
556
                        continue
557
                    subject_rows = run_oak_loop(subject, remote, run_root, run_index, metadata, args.admitted_output_chars)
558
                total = next((row for row in subject_rows if row.get("operation") == "task_loop.total"), None)
559
                if total is not None:
560
                    total.update(loop_summary_fields(subject_rows))
561
                rows.extend(subject_rows)
562
563
    store = ResultsStore(args.results, lane="task-loop")
564
    raw_path, summary_path = store.write(timestamp, rows, summary_text(rows))
565
    if not args.keep_workdirs:
566
        shutil.rmtree(run_root, ignore_errors=True)
567
    print(f"[result] {raw_path}")
568
    print(f"[summary] {summary_path}")
569
    return 1 if has_lane_failures(rows) else 0
570
571
572
if __name__ == "__main__":
573
    raise SystemExit(main())