mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-19 10:04:16 +00:00
Merge PR #1044: preserve Cowork budget and interrupted work
Integrate the reviewed Cowork changes with the current development branch. Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
commit
7e2cae933e
8 changed files with 738 additions and 52 deletions
|
|
@ -98,17 +98,25 @@ infrastructure retries. Concurrent ownership is locked. A changed key, changed
|
|||
ceiling, decreased usage counter or unsettled prior run requires reconciliation.
|
||||
Unrelated spending on the same key counts conservatively toward the campaign.
|
||||
|
||||
For the owner-approved example campaign, the requested total ceiling is **$1000**
|
||||
and an invocation's default spending bound is **$150**. The supervisor preserves
|
||||
an in-flight allowance equal to the larger of the configured reserve (default
|
||||
$100) and concurrency times the per-task bound (default $25). It stops when the
|
||||
The qualification example uses a total ceiling of **$1000** and an invocation's
|
||||
default spending bound of **$150**. The supervisor preserves
|
||||
the explicit nonnegative `--budget-reserve-usd` allowance (default $100) for
|
||||
unsettled provider charges. It is independent of concurrency and of the per-task
|
||||
lifetime bound (default $25), which remains unchanged. Select the allowance for
|
||||
billing delay and work actually in flight; multiplying full task budgets would
|
||||
prematurely stop a 32-task, $2000 campaign at $1200 spent. With a $100 reserve,
|
||||
the campaign stop boundary is $1900 spent instead. The supervisor stops when the
|
||||
campaign remainder reaches that allowance, when the invocation bound is reached,
|
||||
or when the meter or disk reserve becomes unavailable. These are configurable
|
||||
operator bounds. Billing can be delayed and paid calls may already be in flight;
|
||||
or when the meter or disk reserve becomes unavailable. A suspect meter read
|
||||
gets at most three observations requesting cache revalidation within one shared
|
||||
15-second window; only a finite, nonnegative, nondecreasing value may update the campaign. Rejected
|
||||
values and errors remain in the monitor and unit log, including after shutdown.
|
||||
This confirmation does not rebase spending or ignore known budget exhaustion.
|
||||
These are configurable operator bounds. Billing can be delayed and paid calls may already be in flight;
|
||||
the monitor is **not a provider-enforced hard dollar cap**.
|
||||
|
||||
Start qualification at concurrency 1; the approved campaign allows at most 2
|
||||
after measuring resource use. If smoke projects the full dataset above the
|
||||
Start qualification at concurrency 1 and choose full-run concurrency after
|
||||
measuring resource use. If smoke projects the full dataset above the
|
||||
remaining campaign budget, pause for an owner decision rather than changing the
|
||||
model, effort, configuration or budget. Reconcile delayed charges before another
|
||||
paid phase. Preserve every run in a new directory outside the source and live
|
||||
|
|
@ -158,7 +166,10 @@ unknown. `llm_usage` amounts are compatibility accounting evidence; the campaign
|
|||
meter is the spending check. A billing provider such as `openrouter` does not
|
||||
identify the upstream endpoint. `response_provider` observations are reported
|
||||
only when present, with incomplete coverage disclosed; successful-call endpoint
|
||||
evidence may be unavailable in the copied logs.
|
||||
evidence may be unavailable in the copied logs. Selected scrubbed logs are
|
||||
checkpointed before each task-status request and at finalization; a slow request
|
||||
can delay the next checkpoint. An interrupted run may therefore retain partial
|
||||
evidence, while raw request/response blobs remain unexported.
|
||||
|
||||
A result report therefore needs the exact seed and image/benchmark pins, selected
|
||||
IDs, applied settings, official evaluator outputs, complete denominator,
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ docker pull postgres:15
|
|||
Start with one task and concurrency 1. Choose the task IDs from the pinned
|
||||
benchmark, then expand to a representative smoke list only after the tool
|
||||
inventory and first task work. A smoke subset is not the 496-task benchmark
|
||||
score. The campaign described in the methodology permits concurrency 2 only
|
||||
after measuring the first task's resource use.
|
||||
score. Choose full-run concurrency after measuring the first task's resource
|
||||
use and confirming the campaign budget.
|
||||
|
||||
Create the storage directory first. Every invocation needs a **new** run root,
|
||||
including dry runs and infrastructure retries. The following dry run builds the
|
||||
|
|
@ -68,8 +68,7 @@ python3 "$COWORK_SEED/devtools/benchmarks/cowork_bench/run_cowork_bench.py" \
|
|||
|
||||
For the first paid qualification, use a new root, omit `--dry-run`, and add the
|
||||
shared campaign record and the agreed spending limits. The following $1000
|
||||
campaign is an example of the owner-approved campaign, not a universal spending
|
||||
policy:
|
||||
campaign is a qualification example, not a universal spending policy:
|
||||
|
||||
```bash
|
||||
COWORK_RUN="$COWORK_STORAGE/smoke-$(date +%s)"
|
||||
|
|
@ -89,14 +88,36 @@ Reuse the **same campaign file and key** for all paid phases. If earlier probes
|
|||
predate the first campaign baseline, account for them once with
|
||||
`--prior-spend-usd` when creating the campaign.
|
||||
|
||||
`--budget-reserve-usd` is an explicit nonnegative allowance for delayed/in-flight
|
||||
provider charges, independent of concurrency. Per-task lifetime limits remain
|
||||
separate: 32 tasks with a $25 task limit do not automatically reserve $800.
|
||||
Choose the reserve for the expected outstanding charges; delayed billing means
|
||||
it is not a provider-enforced spending cap.
|
||||
|
||||
## Monitor, recover and audit
|
||||
|
||||
A suspect budget-meter response is confirmed with at most three reads requesting
|
||||
cache revalidation inside one 15-second read window. Each request uses only the
|
||||
remaining time. Counters must still be finite, nonnegative and no lower than the last
|
||||
accepted value. `monitor.json` and the unit log retain rejected observations,
|
||||
the previous value, errors and any successful confirmation. Persistent meter
|
||||
failure stops the run; a known exhausted budget never waits for another poll.
|
||||
|
||||
Cleanup rechecks the exact run label after removing containers and networks.
|
||||
A competing cleanup's already-removed response succeeds only when that fresh
|
||||
query proves the label empty. Unknown state or surviving resources remains a
|
||||
failure and keeps the campaign's unsettled custody.
|
||||
|
||||
`monitor.json` records progress, key-meter spending, disk headroom and stop
|
||||
reasons. `run_manifest.json` records the seed, benchmark, immutable image ID,
|
||||
selected IDs, recovery ancestry and applied configuration; `result_index.jsonl` retains every selected task,
|
||||
including failures and tasks not started. Task artifacts live below
|
||||
`bench/dumps/`, with sanitized runtime logs in each task's `ouroboros/` folder.
|
||||
A launcher exit code is not a task score.
|
||||
A launcher exit code is not a task score. Task polling checkpoints the selected
|
||||
scrubbed logs before its status request, so an aborted run can retain partial
|
||||
evidence. HTTP delays affect that cadence; it is not a fixed two-second promise.
|
||||
Raw request/response blobs are not exported, so these copies do not constitute
|
||||
complete wire replay.
|
||||
|
||||
Send SIGINT or SIGTERM to the launcher to stop its runner and clean up resources
|
||||
with that run's exact Docker label. Never use broad container-name cleanup on a
|
||||
|
|
|
|||
|
|
@ -11,24 +11,79 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import pathlib
|
||||
import time
|
||||
import urllib.request
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
|
||||
from devtools.benchmarks.common.manifests import write_json
|
||||
_KEY_USAGE_URL = "https://openrouter.ai/api/v1/key"
|
||||
|
||||
|
||||
def key_usage(api_key: str) -> float:
|
||||
class UsageCounterError(ValueError):
|
||||
"""A rejected numeric observation, retained without making it budget truth."""
|
||||
|
||||
def __init__(self, observed_usage: float, previous_usage: float) -> None:
|
||||
self.observed_usage = observed_usage
|
||||
super().__init__(f"provider usage counter decreased or is invalid: "
|
||||
f"observed={observed_usage!r}, previous={previous_usage!r}")
|
||||
|
||||
|
||||
def validate_usage(usage: float, previous_usage: float) -> None:
|
||||
if not math.isfinite(usage) or usage < 0 or usage < previous_usage:
|
||||
raise UsageCounterError(usage, previous_usage)
|
||||
|
||||
|
||||
def _read_usage_http(api_key: str, url: str, *, timeout: float) -> float:
|
||||
"""The single HTTP reader; production runs it only in the disposable worker."""
|
||||
request = urllib.request.Request(
|
||||
"https://openrouter.ai/api/v1/key", headers={"Authorization": f"Bearer {api_key}"}
|
||||
url, headers={"Authorization": f"Bearer {api_key}", "Cache-Control": "no-cache"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
value = float(payload["data"]["usage"])
|
||||
if not math.isfinite(value) or value < 0:
|
||||
raise ValueError("provider returned an invalid usage counter")
|
||||
return float(payload["data"]["usage"])
|
||||
|
||||
|
||||
def _meter_worker() -> int:
|
||||
payload = json.load(sys.stdin)
|
||||
try:
|
||||
usage = _read_usage_http(payload["api_key"], payload["url"], timeout=payload["timeout"])
|
||||
except Exception as exc:
|
||||
# The key travels only through stdin, never the command or diagnostic text.
|
||||
message = str(exc).replace(payload["api_key"], "[redacted]")
|
||||
print(json.dumps({"error_type": type(exc).__name__, "error": message}))
|
||||
return 1
|
||||
print(json.dumps({"usage": usage}))
|
||||
return 0
|
||||
|
||||
|
||||
def key_usage(api_key: str, *, timeout: float = 15) -> float:
|
||||
"""Bound the whole HTTP read, including headers/body/DNS and worker startup."""
|
||||
deadline = time.monotonic() + timeout
|
||||
command = [sys.executable, "-I", "-S", str(pathlib.Path(__file__).resolve()), "--read-usage"]
|
||||
# Preserve the parent's isolated roots, but keep this credential on stdin only.
|
||||
env = {name: value for name, value in os.environ.items() if value != api_key}
|
||||
payload = json.dumps({"api_key": api_key, "url": _KEY_USAGE_URL, "timeout": timeout})
|
||||
with subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True, env=env) as worker:
|
||||
try:
|
||||
stdout, stderr = worker.communicate(payload, timeout=max(0.0, deadline - time.monotonic()))
|
||||
except subprocess.TimeoutExpired:
|
||||
worker.kill()
|
||||
worker.communicate() # Reap before returning; there is no orphan HTTP read.
|
||||
raise TimeoutError("meter HTTP read exceeded its wall-clock deadline") from None
|
||||
if time.monotonic() > deadline:
|
||||
raise TimeoutError("meter HTTP response arrived after its deadline")
|
||||
result = json.loads(stdout) if stdout else {}
|
||||
if worker.returncode:
|
||||
error = result.get("error") or stderr or "no worker diagnostic"
|
||||
raise RuntimeError(f"meter HTTP read failed ({result.get('error_type', worker.returncode)}): "
|
||||
+ str(error).replace(api_key, "[redacted]"))
|
||||
value = float(result["usage"])
|
||||
validate_usage(value, 0.0)
|
||||
return value
|
||||
|
||||
|
||||
|
|
@ -68,13 +123,14 @@ class CampaignBudget:
|
|||
return self.record["ceiling_usd"] - self.spent
|
||||
|
||||
def save(self) -> None:
|
||||
from devtools.benchmarks.common.manifests import write_json
|
||||
|
||||
self.record.update({"spent_usd": self.spent, "remaining_usd": self.remaining,
|
||||
"observed_at": time.time()})
|
||||
write_json(self.path, self.record)
|
||||
|
||||
def observe(self, usage: float) -> None:
|
||||
if not math.isfinite(usage) or usage < self.record["last_usage"]:
|
||||
raise ValueError("provider usage counter decreased or is invalid; spending is unknown")
|
||||
validate_usage(usage, self.record["last_usage"])
|
||||
self.record["last_usage"] = usage
|
||||
self.save()
|
||||
|
||||
|
|
@ -103,3 +159,9 @@ def campaign_lock(path: pathlib.Path) -> Iterator[None]:
|
|||
yield
|
||||
finally:
|
||||
release_exclusive_file_lock(lock_path, fd)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.argv[1:] != ["--read-usage"]:
|
||||
raise SystemExit("campaign.py is an internal meter worker")
|
||||
raise SystemExit(_meter_worker())
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import pathlib
|
|||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
|
|
@ -385,7 +386,9 @@ def collect_audit_artifacts(task_root: pathlib.Path, task_id: str, secrets: list
|
|||
"""Copy the scrubbed Ouroboros logs next to the benchmark artefacts.
|
||||
|
||||
The data root itself stays outside ``dumps/`` because it holds ``settings.json`` with the
|
||||
provider credential; only value-scrubbed text leaves the container.
|
||||
provider credential; only value-scrubbed text leaves the container. Polling
|
||||
checkpoints publish each file atomically, so an abrupt container removal
|
||||
leaves the previous snapshot intact even if final export never runs.
|
||||
"""
|
||||
target = task_root / "ouroboros"
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -397,7 +400,9 @@ def collect_audit_artifacts(task_root: pathlib.Path, task_id: str, secrets: list
|
|||
text = source.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
(target / source.name).write_text(scrub(text, secrets), encoding="utf-8")
|
||||
staged = target / f".{source.name}.tmp"
|
||||
staged.write_text(scrub(text, secrets), encoding="utf-8")
|
||||
staged.replace(target / source.name)
|
||||
|
||||
|
||||
def write_bench_logs(task_config, status: str, started: datetime, summary: Mapping[str, Any], final_answer: str) -> None:
|
||||
|
|
@ -575,6 +580,14 @@ def run_agent_phase(task_dir: str, max_steps: int) -> str:
|
|||
break
|
||||
if now >= outer_deadline:
|
||||
raise WallClockInterrupt()
|
||||
# dumps/ is host-bound; preserve available usage/tool evidence before
|
||||
# a host stop can remove this container without running our finally.
|
||||
try:
|
||||
collect_audit_artifacts(task_root, task_id, secrets)
|
||||
except OSError as exc:
|
||||
detail = scrub(f"{type(exc).__name__}: {exc}", secrets)
|
||||
print(f"[{ENGINE}] audit checkpoint unavailable; continuing task: {detail}",
|
||||
file=sys.stderr, flush=True)
|
||||
try:
|
||||
result = _http_json("GET", f"{base_url}/api/tasks/{urllib.parse.quote(task_id)}",
|
||||
timeout=min(30, outer_deadline - now))
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import argparse
|
|||
import hashlib
|
||||
import csv
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
|
|
@ -43,7 +44,7 @@ from devtools.benchmarks.common.result_index import (
|
|||
)
|
||||
from devtools.benchmarks.common.run_roots import assert_outside_repo, repo_root_from_devtools, run_root, timestamp_run_id
|
||||
from devtools.benchmarks.common.secrets import credential_fingerprint
|
||||
from devtools.benchmarks.cowork_bench.campaign import CampaignBudget, campaign_lock, key_usage
|
||||
from devtools.benchmarks.cowork_bench.campaign import CampaignBudget, campaign_lock, key_usage, validate_usage
|
||||
from devtools.benchmarks.cowork_bench.resource_limits import LABEL_KEY, prepare_resource_env
|
||||
from ouroboros.platform_layer import kill_process_group_id, terminate_process_group_id
|
||||
from ouroboros.process_custody import spawn_supervised
|
||||
|
|
@ -364,20 +365,39 @@ def select_tasks(bench_dir: pathlib.Path, args: argparse.Namespace, config: dict
|
|||
return remaining
|
||||
|
||||
def remove_run_containers(docker_host: str, run_label: str) -> None:
|
||||
"""Clean only resources labeled by this invocation, including anonymous helper containers."""
|
||||
"""Settle only this invocation's resources, including concurrent cleanup removals."""
|
||||
selector = f"label={LABEL_KEY}={run_label}"
|
||||
containers = _docker(docker_host, "ps", "-aq", "--filter", selector, timeout=60)
|
||||
if containers.returncode != 0:
|
||||
raise RuntimeError("cannot verify this run's container custody")
|
||||
ids = containers.stdout.split()
|
||||
if ids and _docker(docker_host, "rm", "-fv", *ids, timeout=120).returncode:
|
||||
raise RuntimeError("could not stop all containers owned by this run")
|
||||
networks = _docker(docker_host, "network", "ls", "-q", "--filter", selector, timeout=60)
|
||||
if networks.returncode != 0:
|
||||
raise RuntimeError("cannot verify this run's network custody")
|
||||
ids = networks.stdout.split()
|
||||
if ids and _docker(docker_host, "network", "rm", *ids, timeout=120).returncode:
|
||||
raise RuntimeError("could not remove all networks owned by this run")
|
||||
for kind, listing, removal in (
|
||||
("container", ("ps", "-aq"), ("rm", "-fv")),
|
||||
("network", ("network", "ls", "-q"), ("network", "rm")),
|
||||
):
|
||||
found = _docker(docker_host, *listing, "--filter", selector, timeout=60)
|
||||
if found.returncode:
|
||||
raise RuntimeError(f"cannot verify this run's {kind} custody")
|
||||
ids = found.stdout.split()
|
||||
if not ids:
|
||||
continue
|
||||
removal_notice = {}
|
||||
try:
|
||||
removed = _docker(docker_host, *removal, *ids, timeout=120)
|
||||
if removed.returncode or removed.stderr:
|
||||
removal_notice = {"returncode": removed.returncode, "stdout": removed.stdout or "",
|
||||
"stderr": removed.stderr or "", "exception": None}
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
# The daemon may have completed removal despite a client failure.
|
||||
removal_notice = {"returncode": None, "stdout": str(getattr(exc, "stdout", "") or ""),
|
||||
"stderr": str(getattr(exc, "stderr", "") or ""),
|
||||
"exception": {"type": type(exc).__name__, "message": str(exc)}}
|
||||
if removal_notice:
|
||||
print(json.dumps({"event": "cowork_cleanup_remove", "resource": kind,
|
||||
"run_label": run_label, "selected_ids": ids, **removal_notice}), flush=True)
|
||||
remaining = _docker(docker_host, *listing, "--filter", selector, timeout=60)
|
||||
if remaining.returncode:
|
||||
raise RuntimeError(f"cannot verify this run's {kind} custody after removal")
|
||||
if remaining.stdout.split():
|
||||
print(json.dumps({"event": "cowork_cleanup_remaining", "resource": kind,
|
||||
"run_label": run_label, "remaining_ids": remaining.stdout.split()}), flush=True)
|
||||
raise RuntimeError(f"could not remove all {kind}s owned by this run")
|
||||
|
||||
|
||||
def mark_stop(path: pathlib.Path, reason: str) -> None:
|
||||
|
|
@ -400,6 +420,51 @@ def stop_process_group(proc: subprocess.Popen) -> None:
|
|||
proc.wait(timeout=15)
|
||||
|
||||
|
||||
def observe_campaign_usage(api_key: str, campaign: CampaignBudget,
|
||||
diagnostics: list[dict[str, Any]], *, phase: str) -> None:
|
||||
"""Confirm suspect meter reads within one 15-second window, never accept a lower counter."""
|
||||
deadline = time.monotonic() + 15.0
|
||||
previous = campaign.record["last_usage"]
|
||||
last_error: Exception = TimeoutError("usage confirmation window expired")
|
||||
rejected = False
|
||||
for attempt in range(1, 4):
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
value = None
|
||||
try:
|
||||
value = key_usage(api_key, timeout=remaining)
|
||||
if time.monotonic() > deadline:
|
||||
raise TimeoutError("usage confirmation arrived after its shared deadline")
|
||||
validate_usage(value, previous)
|
||||
except Exception as exc: # Provider/transport/parse failures share the bounded confirmation.
|
||||
last_error = exc
|
||||
value = getattr(exc, "observed_usage", value)
|
||||
observation = {
|
||||
"observed_at": time.time(), "phase": phase, "attempt": attempt,
|
||||
"previous_usage": previous,
|
||||
"observed_usage": value if value is None or math.isfinite(value) else repr(value),
|
||||
"accepted": False, "error_type": type(exc).__name__, "error": str(exc),
|
||||
}
|
||||
diagnostics.append(observation)
|
||||
print(json.dumps({"event": "cowork_meter_observation", **observation}), flush=True)
|
||||
rejected = True
|
||||
else:
|
||||
# Persistence failures are not suspect provider reads and are never retried here.
|
||||
campaign.observe(value)
|
||||
if rejected:
|
||||
observation = {"observed_at": time.time(), "phase": phase, "attempt": attempt,
|
||||
"previous_usage": previous, "observed_usage": value, "accepted": True}
|
||||
diagnostics.append(observation)
|
||||
print(json.dumps({"event": "cowork_meter_observation", **observation}), flush=True)
|
||||
return
|
||||
if attempt < 3:
|
||||
delay = min(3.0, max(0.0, deadline - time.monotonic()))
|
||||
if delay:
|
||||
time.sleep(delay)
|
||||
raise last_error
|
||||
|
||||
|
||||
def supervise_run(args: argparse.Namespace, command: list[str], bench_dir: pathlib.Path,
|
||||
run_env: dict[str, str], api_key: str, campaign: CampaignBudget) -> dict[str, Any]:
|
||||
"""Own runner lifetime, budget meter and disk reserve until every owned resource is settled.
|
||||
|
|
@ -410,7 +475,9 @@ def supervise_run(args: argparse.Namespace, command: list[str], bench_dir: pathl
|
|||
"""
|
||||
root = bench_dir.parent
|
||||
stop_file = pathlib.Path(run_env["COWORK_STOP_FILE"])
|
||||
reserve = max(args.budget_reserve_usd, args.concurrency * args.per_task_cost_usd)
|
||||
# A task's lifetime budget is not a reservation of unsettled provider charges.
|
||||
# The CLI validates this explicit billing allowance as nonnegative.
|
||||
reserve = args.budget_reserve_usd
|
||||
initial_spent = campaign.spent
|
||||
if campaign.remaining <= reserve:
|
||||
raise ValueError("campaign remaining budget does not cover the in-flight reserve")
|
||||
|
|
@ -418,6 +485,7 @@ def supervise_run(args: argparse.Namespace, command: list[str], bench_dir: pathl
|
|||
proc = None
|
||||
reason = ""
|
||||
meter_error = ""
|
||||
meter_diagnostics: list[dict[str, Any]] = []
|
||||
code = 1
|
||||
campaign.start(root)
|
||||
|
||||
|
|
@ -436,7 +504,7 @@ def supervise_run(args: argparse.Namespace, command: list[str], bench_dir: pathl
|
|||
reason = stop_file.read_text(encoding="utf-8").strip()
|
||||
break
|
||||
try:
|
||||
campaign.observe(key_usage(api_key))
|
||||
observe_campaign_usage(api_key, campaign, meter_diagnostics, phase="poll")
|
||||
except Exception as exc:
|
||||
meter_error = type(exc).__name__
|
||||
reason = "budget_meter_unavailable"
|
||||
|
|
@ -455,6 +523,7 @@ def supervise_run(args: argparse.Namespace, command: list[str], bench_dir: pathl
|
|||
"run_spent_usd": campaign.spent - initial_spent, "inflight_reserve_usd": reserve,
|
||||
"disk_free_bytes": free, "root_free_bytes": root_free,
|
||||
"ledger_counts": counts, "stop_reason": reason, "meter_error": meter_error,
|
||||
"meter_diagnostics": meter_diagnostics,
|
||||
})
|
||||
if reason:
|
||||
mark_stop(stop_file, reason)
|
||||
|
|
@ -477,7 +546,7 @@ def supervise_run(args: argparse.Namespace, command: list[str], bench_dir: pathl
|
|||
for sig, handler in old_handlers.items():
|
||||
signal.signal(sig, handler)
|
||||
try:
|
||||
campaign.observe(key_usage(api_key))
|
||||
observe_campaign_usage(api_key, campaign, meter_diagnostics, phase="final")
|
||||
except Exception as exc:
|
||||
meter_error = type(exc).__name__
|
||||
campaign.finish(root, outcome=reason or "runner_finished", meter_error=meter_error)
|
||||
|
|
@ -485,10 +554,11 @@ def supervise_run(args: argparse.Namespace, command: list[str], bench_dir: pathl
|
|||
"observed_at": time.time(), "finished": True, "runner_exit_code": code,
|
||||
"campaign_spent_usd": campaign.spent, "campaign_remaining_usd": campaign.remaining,
|
||||
"run_spent_usd": campaign.spent - initial_spent, "stop_reason": reason,
|
||||
"meter_error": meter_error,
|
||||
"meter_error": meter_error, "meter_diagnostics": meter_diagnostics,
|
||||
"ledger_counts": write_ledger(root / "result_index.jsonl", bench_dir, args.model, args.selected_tasks),
|
||||
})
|
||||
return {"stop_reason": reason, "runner_exit_code": code, "meter_error": meter_error,
|
||||
"meter_diagnostics": meter_diagnostics,
|
||||
"key_spend_usd": campaign.spent - initial_spent, "campaign_spent_usd": campaign.spent,
|
||||
"campaign_remaining_usd": campaign.remaining, "inflight_reserve_usd": reserve}
|
||||
|
||||
|
|
|
|||
|
|
@ -84,8 +84,7 @@ def test_container_cost_mirror_keeps_canonical_amounts_and_every_openness_field(
|
|||
assert "cost_usd" not in adapter.COST_RESULT_FIELDS
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_episode(tmp_path, monkeypatch):
|
||||
def _make_agent_episode(tmp_path, monkeypatch):
|
||||
"""Run the real entrypoint lifecycle with only process/HTTP transport replaced."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
|
@ -146,6 +145,11 @@ def agent_episode(tmp_path, monkeypatch):
|
|||
return state
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_episode(tmp_path, monkeypatch):
|
||||
return _make_agent_episode(tmp_path, monkeypatch)
|
||||
|
||||
|
||||
@pytest.mark.serial
|
||||
def test_setup_timeout_is_infrastructure_without_a_solve_attempt(agent_episode):
|
||||
def interrupt():
|
||||
|
|
@ -279,3 +283,119 @@ def test_real_rotation_and_export_keep_early_cost_and_tool_evidence(agent_episod
|
|||
assert audit["manual_review"] == [{"source": "tools.jsonl", "line": 1,
|
||||
"reason": "answer_source_or_evaluator_reference"}]
|
||||
assert not (pathlib.Path(adapter.OUROBOROS_DATA) / "archive").exists()
|
||||
|
||||
|
||||
@pytest.mark.serial
|
||||
def test_poll_checkpoint_survives_abrupt_exit_without_terminal_export(tmp_path):
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
# Reuse the real-loop fixture in a child so os._exit really skips finally.
|
||||
# All four runtime roots are explicit; no server, Docker or model is started.
|
||||
child = r'''
|
||||
import json, os, pathlib, runpy, sys
|
||||
import pytest
|
||||
module = runpy.run_path(sys.argv[1])
|
||||
adapter = module["adapter"]
|
||||
state = module["_make_agent_episode"](pathlib.Path(sys.argv[2]), pytest.MonkeyPatch())
|
||||
secret = "fixture-secret-must-not-leave-runtime"
|
||||
adapter.load_json = lambda path: {"settings": {"OPENROUTER_API_KEY": secret}}
|
||||
names = ("events.jsonl", "tools.jsonl", "progress.jsonl")
|
||||
def record(round_number):
|
||||
for name in names:
|
||||
path = pathlib.Path(adapter.OUROBOROS_DATA) / "logs" / name
|
||||
with path.open("a", encoding="utf-8") as stream:
|
||||
stream.write(json.dumps({"type": "llm_usage" if name == "events.jsonl" else "tool_call",
|
||||
"round": round_number, "cost": 1.25, "text": secret}) + "\n")
|
||||
state.server_started = lambda: record(1)
|
||||
reads = 0
|
||||
def http(method, url, *args, **kwargs):
|
||||
global reads
|
||||
if method == "POST":
|
||||
return {"task_id": "fixture-task"}
|
||||
reads += 1
|
||||
if reads == 1:
|
||||
record(2)
|
||||
return {"status": "running", "prompt_tokens": 10}
|
||||
os._exit(73)
|
||||
adapter._http_json = http
|
||||
state.run()
|
||||
raise AssertionError("the fixture should exit before terminal export")
|
||||
'''
|
||||
app = tmp_path / "isolated-app"
|
||||
env = {**os.environ, "OUROBOROS_APP_ROOT": str(app),
|
||||
"OUROBOROS_REPO_DIR": str(pathlib.Path(__file__).resolve().parents[1]),
|
||||
"OUROBOROS_DATA_DIR": str(app / "data"),
|
||||
"OUROBOROS_SETTINGS_PATH": str(app / "data/settings.json")}
|
||||
peer = tmp_path / "other-task"
|
||||
peer.mkdir()
|
||||
(peer / "events.jsonl").write_text("unchanged\n", encoding="utf-8")
|
||||
proc = subprocess.run([sys.executable, "-c", child, str(pathlib.Path(__file__).resolve()), str(tmp_path)],
|
||||
cwd=pathlib.Path(__file__).resolve().parents[1], env=env,
|
||||
capture_output=True, text=True, timeout=20)
|
||||
assert proc.returncode == 73, proc.stderr
|
||||
# Simulate removal of the private container filesystem after its hard exit.
|
||||
shutil.rmtree(tmp_path / "data")
|
||||
shutil.rmtree(tmp_path / "runtime")
|
||||
dump = tmp_path / "dump"
|
||||
assert not (dump / "ouroboros_summary.json").exists()
|
||||
assert not (dump / "traj_log.json").exists()
|
||||
for name in ("events.jsonl", "tools.jsonl", "progress.jsonl"):
|
||||
text = (dump / "ouroboros" / name).read_text(encoding="utf-8")
|
||||
assert [row["round"] for row in map(json.loads, text.splitlines())] == [1, 2]
|
||||
assert "fixture-secret-must-not-leave-runtime" not in text
|
||||
assert "***REDACTED***" in text
|
||||
assert not list(dump.rglob("settings.json"))
|
||||
assert (peer / "events.jsonl").read_text(encoding="utf-8") == "unchanged\n"
|
||||
|
||||
|
||||
@pytest.mark.serial
|
||||
def test_interrupted_checkpoint_publish_keeps_last_host_snapshot(tmp_path, monkeypatch):
|
||||
data = tmp_path / "private-runtime"
|
||||
(data / "logs").mkdir(parents=True)
|
||||
source = data / "logs/events.jsonl"
|
||||
source.write_text('{"round":1}\n', encoding="utf-8")
|
||||
monkeypatch.setattr(adapter, "OUROBOROS_DATA", str(data))
|
||||
dump = tmp_path / "host-dump"
|
||||
adapter.collect_audit_artifacts(dump, "", [])
|
||||
original = (dump / "ouroboros/events.jsonl").read_bytes()
|
||||
source.write_text('{"round":1}\n{"round":2}\n', encoding="utf-8")
|
||||
def interrupted_publish(_source, _target):
|
||||
raise OSError("interrupted before atomic publication")
|
||||
monkeypatch.setattr(pathlib.Path, "replace", interrupted_publish)
|
||||
with pytest.raises(OSError, match="atomic publication"):
|
||||
adapter.collect_audit_artifacts(dump, "", [])
|
||||
assert (dump / "ouroboros/events.jsonl").read_bytes() == original
|
||||
|
||||
|
||||
@pytest.mark.serial
|
||||
def test_poll_checkpoint_write_failure_does_not_abort_task_or_final_export(agent_episode, monkeypatch, capsys):
|
||||
secret = "checkpoint-fixture-secret"
|
||||
monkeypatch.setattr(adapter, "load_json", lambda path: {"settings": {"OPENROUTER_API_KEY": secret}})
|
||||
def initial_log():
|
||||
path = pathlib.Path(adapter.OUROBOROS_DATA) / "logs/events.jsonl"
|
||||
path.write_text(json.dumps({"type": "llm_usage", "prompt_tokens": 10, "text": secret}) + "\n",
|
||||
encoding="utf-8")
|
||||
agent_episode.server_started = initial_log
|
||||
agent_episode.results[:] = [{"status": "running"}, {"status": "completed", "final_answer": "done"}]
|
||||
write = pathlib.Path.write_text
|
||||
writes = []
|
||||
def flaky_write(path, *args, **kwargs):
|
||||
if path == agent_episode.dump / "ouroboros/.events.jsonl.tmp":
|
||||
writes.append(path)
|
||||
if len(writes) == 1:
|
||||
raise OSError(f"temporary export failure involving {secret}")
|
||||
return write(path, *args, **kwargs)
|
||||
monkeypatch.setattr(pathlib.Path, "write_text", flaky_write)
|
||||
summary = agent_episode.run()
|
||||
assert summary["bench_status"] == "success"
|
||||
assert agent_episode.calls == ["POST", "GET", "GET", "terminate", "terminate"]
|
||||
assert len(writes) == 3 # failed checkpoint, successful next poll, final export
|
||||
captured = capsys.readouterr()
|
||||
assert "audit checkpoint unavailable; continuing task" in captured.err
|
||||
assert "***REDACTED***" in captured.err
|
||||
assert secret not in captured.err
|
||||
exported = (agent_episode.dump / "ouroboros/events.jsonl").read_text(encoding="utf-8")
|
||||
assert json.loads(exported)["prompt_tokens"] == 10
|
||||
assert secret not in exported
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import argparse
|
||||
import json
|
||||
import io
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
|
|
@ -261,7 +262,9 @@ def supervised(tmp_path, monkeypatch):
|
|||
monkeypatch.setattr(launcher, "spawn_supervised", launch)
|
||||
monkeypatch.setattr(launcher, "stop_process_group", stop)
|
||||
monkeypatch.setattr(launcher, "remove_run_containers", cleanup)
|
||||
monkeypatch.setattr(launcher, "key_usage", lambda _key: 100)
|
||||
monkeypatch.setattr(launcher, "key_usage", lambda _key, **_kwargs: 100)
|
||||
monkeypatch.setattr(launcher, "time", SimpleNamespace(
|
||||
time=time.time, monotonic=time.monotonic, sleep=lambda _delay: None))
|
||||
monkeypatch.setattr(launcher.shutil, "disk_usage", lambda _path: SimpleNamespace(free=1024**4))
|
||||
return args, bench, env, budget, events, handlers, proc
|
||||
|
||||
|
|
@ -273,14 +276,14 @@ def supervised(tmp_path, monkeypatch):
|
|||
def test_supervisor_stops_owned_work_on_real_boundaries(supervised, monkeypatch, trigger, expected):
|
||||
args, bench, env, budget, events, handlers, _proc = supervised
|
||||
if trigger == "meter-loss":
|
||||
def offline(_key):
|
||||
def offline(_key, **_kwargs):
|
||||
raise OSError("meter unavailable")
|
||||
monkeypatch.setattr(launcher, "key_usage", offline)
|
||||
elif trigger == "disk":
|
||||
args.min_free_gib = 200
|
||||
monkeypatch.setattr(launcher.shutil, "disk_usage", lambda _path: SimpleNamespace(free=199 * 1024**3))
|
||||
else:
|
||||
monkeypatch.setattr(launcher, "key_usage", lambda _key: 1000 if trigger == "campaign" else 250)
|
||||
monkeypatch.setattr(launcher, "key_usage", lambda _key, **_kwargs: 1000 if trigger == "campaign" else 250)
|
||||
result = launcher.supervise_run(args, ["fake-runner"], bench, env, "not-a-real-key", budget)
|
||||
assert result["stop_reason"] == expected
|
||||
assert events == ["stop-group", "cleanup-owned"]
|
||||
|
|
@ -292,6 +295,40 @@ def test_supervisor_stops_owned_work_on_real_boundaries(supervised, monkeypatch,
|
|||
assert result["meter_error"] == "OSError"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reserve", [0.0, 100.0, 250.0])
|
||||
def test_parallel_campaign_spends_past_lifetime_caps_then_stops_at_explicit_reserve(supervised, monkeypatch, reserve):
|
||||
args, bench, env, _old_budget, events, _handlers, proc = supervised
|
||||
args.concurrency = 32
|
||||
args.per_task_cost_usd = 25
|
||||
args.budget_usd = 2000
|
||||
args.budget_reserve_usd = reserve
|
||||
budget = budgets.CampaignBudget(bench.parent / "full-campaign.json", fingerprint="key-a",
|
||||
ceiling=2000, usage=100)
|
||||
# Meter values are cumulative key usage. Continue after $1200 (the old
|
||||
# 32*$25 reserve stopped here) and $1600; stop only at the selected margin.
|
||||
usage = iter([1300, 1700, 2100 - reserve, 2100 - reserve])
|
||||
monkeypatch.setattr(launcher, "key_usage", lambda _key, **_kwargs: next(usage))
|
||||
continued_at = []
|
||||
def still_running(**kwargs):
|
||||
continued_at.append(budget.spent)
|
||||
raise subprocess.TimeoutExpired("fake-runner", kwargs["timeout"])
|
||||
proc.wait = still_running
|
||||
result = launcher.supervise_run(args, ["fake-runner"], bench, env, "not-a-real-key", budget)
|
||||
assert continued_at == [1200, 1600]
|
||||
assert result["stop_reason"] == "campaign_budget_reserve"
|
||||
assert result["campaign_spent_usd"] == 2000 - reserve
|
||||
assert result["campaign_remaining_usd"] == reserve
|
||||
assert result["inflight_reserve_usd"] == reserve
|
||||
assert events == ["stop-group", "cleanup-owned"]
|
||||
assert "active_run" not in budget.record
|
||||
|
||||
|
||||
def test_negative_explicit_billing_reserve_is_rejected():
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
launcher.parse_args(["--budget-reserve-usd", "-1"])
|
||||
assert exc.value.code == 2
|
||||
|
||||
|
||||
def test_supervisor_handles_sigterm_then_cleans_only_owned_resources(supervised, monkeypatch):
|
||||
args, bench, env, budget, events, handlers, proc = supervised
|
||||
def launch(*_args, **kwargs):
|
||||
|
|
@ -323,15 +360,20 @@ def test_cleanup_failure_keeps_campaign_custody_unsettled(supervised, monkeypatc
|
|||
|
||||
def test_cleanup_selection_cannot_include_a_peer_run(monkeypatch):
|
||||
calls = []
|
||||
remaining = {"containers": "own-a\nown-b\n", "networks": "own-net\n"}
|
||||
def docker(host, *argv, **_kwargs):
|
||||
assert host == "unix:///owned.sock"
|
||||
calls.append(argv)
|
||||
if argv[:2] == ("ps", "-aq"):
|
||||
assert argv[-1] == f"label={launcher.LABEL_KEY}=owned-run"
|
||||
return subprocess.CompletedProcess(argv, 0, "own-a\nown-b\n")
|
||||
return subprocess.CompletedProcess(argv, 0, remaining["containers"])
|
||||
if argv[:3] == ("network", "ls", "-q"):
|
||||
assert argv[-1] == f"label={launcher.LABEL_KEY}=owned-run"
|
||||
return subprocess.CompletedProcess(argv, 0, "own-net\n")
|
||||
return subprocess.CompletedProcess(argv, 0, remaining["networks"])
|
||||
if argv[:2] == ("rm", "-fv"):
|
||||
remaining["containers"] = ""
|
||||
if argv[:2] == ("network", "rm"):
|
||||
remaining["networks"] = ""
|
||||
return subprocess.CompletedProcess(argv, 0, "")
|
||||
monkeypatch.setattr(launcher, "_docker", docker)
|
||||
launcher.remove_run_containers("unix:///owned.sock", "owned-run")
|
||||
|
|
@ -573,8 +615,8 @@ def test_paid_runner_uses_immutable_image_and_scrubs_ambient_alternate_keys(dry_
|
|||
monkeypatch.setenv("OPENROUTER_API_KEY", "test-selected-key")
|
||||
monkeypatch.setenv("LLM_API_KEY", "test-unrelated-llm-key")
|
||||
monkeypatch.setenv("MODEL_API_KEY", "test-unrelated-model-key")
|
||||
monkeypatch.setattr(launcher, "key_headroom", lambda _key: {"effective": 1000})
|
||||
monkeypatch.setattr(launcher, "key_usage", lambda _key: 100)
|
||||
monkeypatch.setattr(launcher, "key_headroom", lambda _key, **_kwargs: {"effective": 1000})
|
||||
monkeypatch.setattr(launcher, "key_usage", lambda _key, **_kwargs: 100)
|
||||
monkeypatch.setattr(launcher, "prepare_resource_env", lambda env, **_kwargs: dict(env))
|
||||
def supervise(args, command, bench, env, api_key, campaign):
|
||||
assert command[-1] == "one"
|
||||
|
|
@ -592,3 +634,231 @@ def test_paid_runner_uses_immutable_image_and_scrubs_ambient_alternate_keys(dry_
|
|||
assert launcher.main([*paid_argv, "--campaign-file", str(out.parent / "campaign.json")]) == 0
|
||||
assert (out / "bench" / "configs" / launcher.SECRET_NAME).read_text(encoding="utf-8") == "{}"
|
||||
assert "test-selected-key" not in (out / "run_manifest.json").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_meter_http_read_requests_uncached_data_with_remaining_timeout(monkeypatch):
|
||||
received = []
|
||||
def response(request, *, timeout):
|
||||
received.append((dict(request.header_items()), timeout))
|
||||
return io.BytesIO(b'{"data":{"usage":123.5}}')
|
||||
monkeypatch.setattr(budgets.urllib.request, "urlopen", response)
|
||||
assert budgets._read_usage_http("not-a-real-key", budgets._KEY_USAGE_URL, timeout=2.25) == 123.5
|
||||
assert received[0][0]["Cache-control"] == "no-cache"
|
||||
assert received[0][1] == 2.25
|
||||
|
||||
|
||||
def test_backward_meter_read_is_confirmed_without_stopping_the_run(supervised, monkeypatch, capsys):
|
||||
args, bench, env, budget, events, _handlers, proc = supervised
|
||||
observations = iter([99.0, 101.0, 102.0])
|
||||
monkeypatch.setattr(launcher, "key_usage", lambda _key, **_kwargs: next(observations))
|
||||
def completed(**_kwargs):
|
||||
proc.returncode = 0
|
||||
return 0
|
||||
proc.wait = completed
|
||||
result = launcher.supervise_run(args, ["fake-runner"], bench, env, "not-a-real-key", budget)
|
||||
assert result["stop_reason"] == ""
|
||||
assert result["meter_error"] == ""
|
||||
assert budget.record["last_usage"] == 102.0
|
||||
assert budget.spent == 2.0
|
||||
diagnostics = result["meter_diagnostics"]
|
||||
assert [(row["observed_usage"], row["accepted"]) for row in diagnostics] == [(99.0, False), (101.0, True)]
|
||||
assert diagnostics[0]["previous_usage"] == 100
|
||||
final = json.loads((bench.parent / "monitor.json").read_text(encoding="utf-8"))
|
||||
assert final["finished"] is True and final["meter_diagnostics"] == diagnostics
|
||||
assert '"observed_usage": 99.0' in capsys.readouterr().out
|
||||
assert events == ["stop-group", "cleanup-owned"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid", [-1.0, float("nan"), float("inf")])
|
||||
def test_confirmation_never_accepts_an_invalid_numeric_usage(supervised, monkeypatch, invalid):
|
||||
_args, _bench, _env, budget, _events, _handlers, _proc = supervised
|
||||
values = iter([invalid, 101.0])
|
||||
monkeypatch.setattr(launcher, "key_usage", lambda _key, **_kwargs: next(values))
|
||||
diagnostics = []
|
||||
launcher.observe_campaign_usage("not-a-real-key", budget, diagnostics, phase="poll")
|
||||
assert budget.record["last_usage"] == 101
|
||||
assert diagnostics[0]["accepted"] is False
|
||||
assert diagnostics[0]["previous_usage"] == 100
|
||||
# Non-finite rejected observations remain printable JSON evidence, never NaN budget truth.
|
||||
json.dumps(diagnostics, allow_nan=False)
|
||||
|
||||
|
||||
def test_persistent_bad_counter_stops_and_keeps_rejected_values_in_final_monitor(supervised, monkeypatch):
|
||||
args, bench, env, budget, _events, _handlers, _proc = supervised
|
||||
calls = []
|
||||
def stale(_key, *, timeout):
|
||||
calls.append(timeout)
|
||||
return 99.0
|
||||
monkeypatch.setattr(launcher, "key_usage", stale)
|
||||
result = launcher.supervise_run(args, ["fake-runner"], bench, env, "not-a-real-key", budget)
|
||||
assert result["stop_reason"] == "budget_meter_unavailable"
|
||||
assert budget.record["last_usage"] == 100
|
||||
assert len(calls) == 6 # One bounded confirmation in the loop and one final settlement read.
|
||||
assert {row["phase"] for row in result["meter_diagnostics"]} == {"poll", "final"}
|
||||
assert all(row["observed_usage"] == 99.0 and not row["accepted"] for row in result["meter_diagnostics"])
|
||||
final = json.loads((bench.parent / "monitor.json").read_text(encoding="utf-8"))
|
||||
assert final["meter_diagnostics"] == result["meter_diagnostics"]
|
||||
assert final["meter_error"] == "UsageCounterError"
|
||||
|
||||
|
||||
def test_confirmation_shares_one_timeout_window_instead_of_three_full_timeouts(supervised, monkeypatch):
|
||||
_args, _bench, _env, budget, _events, _handlers, _proc = supervised
|
||||
clock = SimpleNamespace(now=0.0)
|
||||
def advance(delay):
|
||||
clock.now += delay
|
||||
monkeypatch.setattr(launcher, "time", SimpleNamespace(
|
||||
monotonic=lambda: clock.now, time=lambda: clock.now, sleep=advance))
|
||||
timeouts = []
|
||||
def unavailable(_key, *, timeout):
|
||||
timeouts.append(timeout)
|
||||
advance(min(7.0, timeout))
|
||||
raise OSError("meter unavailable")
|
||||
monkeypatch.setattr(launcher, "key_usage", unavailable)
|
||||
original = budget.path.read_bytes()
|
||||
diagnostics = []
|
||||
with pytest.raises(OSError, match="meter unavailable"):
|
||||
launcher.observe_campaign_usage("not-a-real-key", budget, diagnostics, phase="poll")
|
||||
assert timeouts == [15.0, 5.0]
|
||||
assert clock.now == 15.0
|
||||
assert len(diagnostics) == 2
|
||||
assert budget.path.read_bytes() == original
|
||||
|
||||
|
||||
def test_confirmed_exhausted_budget_stops_without_waiting_for_another_poll(supervised, monkeypatch):
|
||||
args, bench, env, budget, _events, _handlers, proc = supervised
|
||||
calls = []
|
||||
def exhausted(_key, *, timeout):
|
||||
calls.append(timeout)
|
||||
return 1000.0
|
||||
monkeypatch.setattr(launcher, "key_usage", exhausted)
|
||||
proc.wait = lambda **_kwargs: pytest.fail("known exhausted budget must not continue work")
|
||||
result = launcher.supervise_run(args, ["fake-runner"], bench, env, "not-a-real-key", budget)
|
||||
assert result["stop_reason"] == "campaign_budget_reserve"
|
||||
assert len(calls) == 2 # Poll plus final settlement, with no confirmation retry.
|
||||
assert result["meter_diagnostics"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["container", "network"])
|
||||
def test_cleanup_racing_404_is_success_only_after_exact_label_is_empty(monkeypatch, kind):
|
||||
seen = []
|
||||
removed = False
|
||||
def docker(host, *argv, **_kwargs):
|
||||
nonlocal removed
|
||||
assert host == "unix:///owned.sock"
|
||||
seen.append(argv)
|
||||
selected = argv[:2] == ("ps", "-aq") if kind == "container" else argv[:3] == ("network", "ls", "-q")
|
||||
if selected:
|
||||
assert argv[-1] == f"label={launcher.LABEL_KEY}=owned-run"
|
||||
return subprocess.CompletedProcess(argv, 0, "" if removed else "own-id\n")
|
||||
if argv[:2] in (("rm", "-fv"), ("network", "rm")):
|
||||
removed = True # A competing cleanup got there first.
|
||||
return subprocess.CompletedProcess(argv, 1, "", "No such resource")
|
||||
return subprocess.CompletedProcess(argv, 0, "")
|
||||
monkeypatch.setattr(launcher, "_docker", docker)
|
||||
launcher.remove_run_containers("unix:///owned.sock", "owned-run")
|
||||
assert removed
|
||||
assert sum(argv[-1] == f"label={launcher.LABEL_KEY}=owned-run" for argv in seen) == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("remove_exit", [0, 1])
|
||||
def test_cleanup_never_claims_surviving_containers_are_gone(monkeypatch, remove_exit):
|
||||
def docker(_host, *argv, **_kwargs):
|
||||
if argv[:2] == ("ps", "-aq"):
|
||||
return subprocess.CompletedProcess(argv, 0, "still-owned\n")
|
||||
return subprocess.CompletedProcess(argv, remove_exit, "")
|
||||
monkeypatch.setattr(launcher, "_docker", docker)
|
||||
with pytest.raises(RuntimeError, match="could not remove all containers"):
|
||||
launcher.remove_run_containers("unix:///owned.sock", "owned-run")
|
||||
|
||||
|
||||
def test_cleanup_failed_relist_is_unknown_even_when_stdout_is_empty(monkeypatch):
|
||||
lists = 0
|
||||
def docker(_host, *argv, **_kwargs):
|
||||
nonlocal lists
|
||||
if argv[:2] == ("ps", "-aq"):
|
||||
lists += 1
|
||||
return subprocess.CompletedProcess(argv, 0 if lists == 1 else 1, "own-id\n" if lists == 1 else "")
|
||||
return subprocess.CompletedProcess(argv, 1, "", "No such resource")
|
||||
monkeypatch.setattr(launcher, "_docker", docker)
|
||||
with pytest.raises(RuntimeError, match="cannot verify.*custody after removal"):
|
||||
launcher.remove_run_containers("unix:///owned.sock", "owned-run")
|
||||
|
||||
|
||||
def test_lagging_counter_gets_time_to_catch_up_without_weakening_monotonicity(supervised, monkeypatch):
|
||||
_args, _bench, _env, budget, _events, _handlers, _proc = supervised
|
||||
clock = SimpleNamespace(now=0.0)
|
||||
def advance(delay):
|
||||
clock.now += delay
|
||||
monkeypatch.setattr(launcher, "time", SimpleNamespace(
|
||||
monotonic=lambda: clock.now, time=lambda: clock.now, sleep=advance))
|
||||
reads = []
|
||||
def provider(_key, *, timeout):
|
||||
reads.append((clock.now, timeout))
|
||||
# Neither the stale value nor the waiting period may lower durable spending.
|
||||
assert budget.record["last_usage"] == 100
|
||||
assert json.loads(budget.path.read_text(encoding="utf-8"))["last_usage"] == 100
|
||||
return 99.0 if clock.now < 3.0 else 101.0
|
||||
monkeypatch.setattr(launcher, "key_usage", provider)
|
||||
diagnostics = []
|
||||
launcher.observe_campaign_usage("not-a-real-key", budget, diagnostics, phase="poll")
|
||||
assert reads == [(0.0, 15.0), (3.0, 12.0)]
|
||||
assert budget.record["last_usage"] == 101
|
||||
assert [(row["observed_usage"], row["accepted"]) for row in diagnostics] == [(99.0, False), (101.0, True)]
|
||||
assert clock.now == 3.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("removal", ["stderr-with-zero", "nonzero", "exception"])
|
||||
def test_cleanup_retains_actual_removal_diagnostics_without_requiring_nonzero(monkeypatch, capsys, removal):
|
||||
listed = 0
|
||||
def docker(_host, *argv, **_kwargs):
|
||||
nonlocal listed
|
||||
if argv[:2] == ("ps", "-aq"):
|
||||
listed += 1
|
||||
return subprocess.CompletedProcess(argv, 0, "own-id\n" if listed == 1 else "")
|
||||
if argv[:2] == ("rm", "-fv"):
|
||||
if removal == "exception":
|
||||
raise OSError("daemon connection reset")
|
||||
return subprocess.CompletedProcess(argv, int(removal == "nonzero"), "", "No such container: own-id")
|
||||
return subprocess.CompletedProcess(argv, 0, "")
|
||||
monkeypatch.setattr(launcher, "_docker", docker)
|
||||
launcher.remove_run_containers("unix:///owned.sock", "owned-run")
|
||||
record = json.loads(capsys.readouterr().out)
|
||||
assert record["event"] == "cowork_cleanup_remove"
|
||||
assert record["run_label"] == "owned-run"
|
||||
assert record["selected_ids"] == ["own-id"]
|
||||
if removal == "exception":
|
||||
assert record["exception"] == {"type": "OSError", "message": "daemon connection reset"}
|
||||
assert record["returncode"] is None
|
||||
else:
|
||||
assert record["returncode"] == int(removal == "nonzero")
|
||||
assert record["stderr"] == "No such container: own-id"
|
||||
assert record["stdout"] == ""
|
||||
|
||||
|
||||
def test_cleanup_logs_survivor_ids_and_still_refuses_custody_release(monkeypatch, capsys):
|
||||
def docker(_host, *argv, **_kwargs):
|
||||
return subprocess.CompletedProcess(argv, 0, "owned-survivor\n" if argv[:2] == ("ps", "-aq") else "")
|
||||
monkeypatch.setattr(launcher, "_docker", docker)
|
||||
with pytest.raises(RuntimeError, match="could not remove all containers"):
|
||||
launcher.remove_run_containers("unix:///owned.sock", "owned-run")
|
||||
record = json.loads(capsys.readouterr().out)
|
||||
assert record == {"event": "cowork_cleanup_remaining", "resource": "container",
|
||||
"run_label": "owned-run", "remaining_ids": ["owned-survivor"]}
|
||||
|
||||
|
||||
def test_late_valid_confirmation_is_not_accepted_even_if_reader_returns_it(supervised, monkeypatch):
|
||||
_args, _bench, _env, budget, _events, _handlers, _proc = supervised
|
||||
clock = SimpleNamespace(now=0.0)
|
||||
monkeypatch.setattr(launcher, "time", SimpleNamespace(
|
||||
monotonic=lambda: clock.now, time=lambda: clock.now,
|
||||
sleep=lambda seconds: setattr(clock, "now", clock.now + seconds)))
|
||||
def late(_key, *, timeout):
|
||||
clock.now += timeout + 1
|
||||
return 101.0
|
||||
monkeypatch.setattr(launcher, "key_usage", late)
|
||||
diagnostics = []
|
||||
with pytest.raises(TimeoutError, match="shared deadline"):
|
||||
launcher.observe_campaign_usage("not-a-real-key", budget, diagnostics, phase="poll")
|
||||
assert budget.record["last_usage"] == 100
|
||||
assert diagnostics[0]["observed_usage"] == 101
|
||||
assert diagnostics[0]["accepted"] is False
|
||||
|
|
|
|||
119
tests/test_cowork_bench_meter_transport.py
Normal file
119
tests/test_cowork_bench_meter_transport.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""Real loopback HTTP confirms the meter's wall-clock bound without a provider."""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from devtools.benchmarks.cowork_bench import campaign
|
||||
|
||||
pytestmark = pytest.mark.serial
|
||||
|
||||
|
||||
@contextmanager
|
||||
def meter_endpoint(header_delay, body_delay, usage):
|
||||
seen = []
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
seen.append(dict(self.headers))
|
||||
body = json.dumps({"data": {"usage": usage}}).encode("utf-8")
|
||||
try:
|
||||
time.sleep(header_delay)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
time.sleep(body_delay)
|
||||
self.wfile.write(body)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass # The expired client's worker was killed and reaped.
|
||||
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=lambda: server.serve_forever(poll_interval=0.01), daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{server.server_port}/key", seen
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=3)
|
||||
assert not thread.is_alive()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def meter_worker(monkeypatch, tmp_path):
|
||||
roots = {
|
||||
"OUROBOROS_APP_ROOT": tmp_path,
|
||||
"OUROBOROS_REPO_DIR": tmp_path / "repo",
|
||||
"OUROBOROS_DATA_DIR": tmp_path / "data",
|
||||
"OUROBOROS_SETTINGS_PATH": tmp_path / "data" / "settings.json",
|
||||
}
|
||||
# Exercise CI's absent outer environment after safe module imports. The
|
||||
# fixture supplies the isolated roots whose worker inheritance we assert.
|
||||
for name in roots:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
for name, path in roots.items():
|
||||
monkeypatch.setenv(name, str(path))
|
||||
key = "synthetic-meter-key-never-in-argv"
|
||||
monkeypatch.setenv("TEST_METER_SECRET", key)
|
||||
monkeypatch.setenv("NO_PROXY", "127.0.0.1,localhost")
|
||||
real_popen = subprocess.Popen
|
||||
workers = []
|
||||
def capture(command, **kwargs):
|
||||
assert key not in " ".join(command)
|
||||
assert key not in kwargs["env"].values()
|
||||
assert command[1:3] == ["-I", "-S"] # No editable finder or live imports.
|
||||
for name in roots:
|
||||
assert kwargs["env"][name] == os.environ[name]
|
||||
worker = real_popen(command, **kwargs)
|
||||
workers.append(worker)
|
||||
return worker
|
||||
monkeypatch.setattr(campaign.subprocess, "Popen", capture)
|
||||
yield key, workers
|
||||
assert len(workers) == 1
|
||||
assert workers[0].poll() is not None
|
||||
if os.name != "nt":
|
||||
with pytest.raises(ChildProcessError):
|
||||
os.waitpid(workers[0].pid, os.WNOHANG)
|
||||
|
||||
|
||||
def test_delayed_headers_and_body_complete_inside_one_deadline(monkeypatch, meter_worker):
|
||||
key, workers = meter_worker
|
||||
with meter_endpoint(0.1, 0.15, 123.5) as (url, seen):
|
||||
monkeypatch.setattr(campaign, "_KEY_USAGE_URL", url)
|
||||
assert campaign.key_usage(key, timeout=1.5) == 123.5
|
||||
assert seen[0]["Authorization"] == "Bearer " + key
|
||||
assert seen[0]["Cache-Control"] == "no-cache"
|
||||
assert workers[0].returncode == 0
|
||||
|
||||
|
||||
def test_headers_and_body_cannot_each_consume_the_full_timeout(monkeypatch, meter_worker):
|
||||
key, workers = meter_worker
|
||||
# Each delay is below 1.5 s; their sum is above it, reproducing the socket-timeout gap.
|
||||
with meter_endpoint(0.9, 0.9, 123.5) as (url, _seen):
|
||||
monkeypatch.setattr(campaign, "_KEY_USAGE_URL", url)
|
||||
started = time.monotonic()
|
||||
with pytest.raises(TimeoutError, match="wall-clock deadline") as caught:
|
||||
campaign.key_usage(key, timeout=1.5)
|
||||
elapsed = time.monotonic() - started
|
||||
assert elapsed < 1.75
|
||||
assert workers[0].poll() is not None
|
||||
assert key not in str(caught.value)
|
||||
|
||||
|
||||
def test_meter_worker_error_never_exposes_the_stdin_credential(monkeypatch, meter_worker):
|
||||
key, _workers = meter_worker
|
||||
with meter_endpoint(0, 0, key) as (url, _seen):
|
||||
monkeypatch.setattr(campaign, "_KEY_USAGE_URL", url)
|
||||
with pytest.raises(RuntimeError) as caught:
|
||||
campaign.key_usage(key, timeout=1.5)
|
||||
assert key not in str(caught.value)
|
||||
assert "[redacted]" in str(caught.value)
|
||||
Loading…
Add table
Add a link
Reference in a new issue