doesitfire 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
doesitfire/runner.py ADDED
@@ -0,0 +1,283 @@
1
+ """Run the activation matrix: skills x prompts x agents x repeats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import platform
7
+ import tempfile
8
+ import time
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+
12
+ from . import __version__
13
+ from .agents import CLI_VERSIONS, EXPERIMENTAL, Agent, Observation, make_agent
14
+ from .config import Config
15
+ from .errors import BudgetExceeded, DoesItFireError
16
+ from .providers import Provider, get_provider, resolve_key
17
+ from .sandbox import IMAGE, Sandbox, build_image, check_docker, image_exists, stage_skills
18
+
19
+ # Used for budgeting before the first real usage figure is known (doesitfire's plan
20
+ # assumed ~20k input tokens per CLI run).
21
+ EST_INPUT_TOKENS = 20_000
22
+ EST_OUTPUT_TOKENS = 1_000
23
+
24
+
25
+ def run_cost(provider: Provider, agent_cfg, obs: Observation) -> float:
26
+ if provider.local:
27
+ return 0.0
28
+ pin, pout = agent_cfg.price_input or 0.0, agent_cfg.price_output or 0.0
29
+ computed = obs.input_tokens * pin / 1e6 + obs.output_tokens * pout / 1e6
30
+ if not obs.usage_seen: # killed before reporting usage: charge the estimate, never zero
31
+ computed = max(computed, EST_INPUT_TOKENS * pin / 1e6 + EST_OUTPUT_TOKENS * pout / 1e6)
32
+ return round(max(computed, obs.reported_cost or 0.0), 6)
33
+
34
+
35
+ def preflight(cfg: Config) -> dict[str, Provider]:
36
+ """Validate keys, prices and model availability before any container starts."""
37
+ providers: dict[str, Provider] = {}
38
+ for a in cfg.agents:
39
+ p = get_provider(a.provider)
40
+ resolve_key(a.name, p) # raises a clear error naming the missing variable
41
+ if p.paid and (a.price_input is None or a.price_output is None):
42
+ raise DoesItFireError(
43
+ f"Agent '{a.name}' uses paid provider '{p.name}' but has no price. Add "
44
+ "'price: {input: <USD per 1M tokens>, output: <USD per 1M tokens>}' to it in "
45
+ "doesitfire.yaml so --max-cost can be enforced."
46
+ )
47
+ if p.name == "ollama":
48
+ check_ollama_model(p, a.model)
49
+ providers[a.name] = p
50
+ return providers
51
+
52
+
53
+ def check_ollama_model(p: Provider, model: str) -> None:
54
+ import openai
55
+
56
+ client = openai.OpenAI(base_url=p.openai_base, api_key="ollama", timeout=10, max_retries=0)
57
+ try:
58
+ ids = {m.id for m in client.models.list()}
59
+ except (openai.APIConnectionError, openai.APITimeoutError):
60
+ raise DoesItFireError(
61
+ f"Ollama is not reachable at {p.openai_base.removesuffix('/v1')}. Start it with 'ollama serve' "
62
+ "or set OLLAMA_HOST. On Linux, make it listen on all interfaces (OLLAMA_HOST=0.0.0.0) so "
63
+ "containers can reach it."
64
+ ) from None
65
+ except openai.APIError as exc:
66
+ raise DoesItFireError(f"Ollama answered with an error while listing models: {exc}") from None
67
+ if model not in ids and f"{model}:latest" not in ids:
68
+ raise DoesItFireError(
69
+ f"Model '{model}' not found in Ollama. Available: {', '.join(sorted(ids)) or 'none'}. "
70
+ "Agent prompts need a 16k context; create the model with: "
71
+ "ollama pull qwen3:4b && ollama create qwen3:4b-16k -f ollama/Modelfile"
72
+ )
73
+
74
+
75
+ def hardware() -> str | None:
76
+ """A short CPU and memory label, e.g. 'Apple M4, 16 GB'. Best effort, no network."""
77
+ import os
78
+ import subprocess
79
+
80
+ cpu, mem = None, None
81
+ try:
82
+ if platform.system() == "Darwin":
83
+ cpu = subprocess.run(
84
+ ["sysctl", "-n", "machdep.cpu.brand_string"], capture_output=True, text=True, timeout=5
85
+ ).stdout.strip()
86
+ mem = int(
87
+ subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=5).stdout.strip()
88
+ or 0
89
+ )
90
+ elif platform.system() == "Linux":
91
+ with open("/proc/cpuinfo", encoding="utf-8") as fh:
92
+ for line in fh:
93
+ if line.lower().startswith("model name"):
94
+ cpu = line.split(":", 1)[1].strip()
95
+ break
96
+ mem = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
97
+ except (OSError, ValueError, subprocess.SubprocessError):
98
+ pass
99
+ parts = [p for p in (cpu, f"{round(mem / 2**30)} GB" if mem else None) if p]
100
+ return ", ".join(parts) or None
101
+
102
+
103
+ def host_info(docker_version: str | None) -> dict:
104
+ return {
105
+ "os": platform.system(),
106
+ "arch": platform.machine(),
107
+ "hardware": hardware(),
108
+ "python": platform.python_version(),
109
+ "docker": docker_version,
110
+ }
111
+
112
+
113
+ def run_matrix(
114
+ cfg: Config,
115
+ max_cost: float = 1.0,
116
+ log: Callable[[str], None] = print,
117
+ progress: Callable[[dict], None] | None = None,
118
+ checkpoint: Callable[[dict], None] | None = None,
119
+ logs_dir: Path | None = None,
120
+ resume: dict | None = None,
121
+ image: str = IMAGE,
122
+ rebuild: bool = False,
123
+ ) -> dict:
124
+ providers = preflight(cfg)
125
+ docker_version = check_docker()
126
+ if rebuild or not image_exists(image):
127
+ build_image(image, log)
128
+
129
+ agents: list[Agent] = [make_agent(a, providers[a.name], cfg.max_turns) for a in cfg.agents]
130
+ skill_names = {s.name for s in cfg.skills}
131
+ started = dt.datetime.now(dt.UTC)
132
+ results: dict = {
133
+ "schema": 1,
134
+ "tool": "doesitfire",
135
+ "version": __version__,
136
+ "started_at": started.isoformat(timespec="seconds"),
137
+ "finished_at": None,
138
+ "duration_s": None,
139
+ "repeats": cfg.repeats,
140
+ "max_turns": cfg.max_turns,
141
+ "host": host_info(docker_version),
142
+ "agents": [
143
+ {
144
+ "name": a.cfg.name,
145
+ "cli_version": CLI_VERSIONS[a.cfg.name],
146
+ "provider": a.provider.name,
147
+ "model": a.cfg.model,
148
+ "experimental": a.cfg.name in EXPERIMENTAL,
149
+ "price": (
150
+ {"input": a.cfg.price_input, "output": a.cfg.price_output}
151
+ if a.cfg.price_input is not None
152
+ else None
153
+ ),
154
+ }
155
+ for a in agents
156
+ ],
157
+ "skills": [{"name": s.name, "description": s.description} for s in cfg.skills],
158
+ "trials": [],
159
+ "cost_usd": 0.0,
160
+ "partial": False,
161
+ "stop_reason": None,
162
+ "sessions": 1,
163
+ "recorded": False,
164
+ }
165
+
166
+ plan = [
167
+ (rep, agent, skill, prompt, expect)
168
+ for rep in range(cfg.repeats)
169
+ for agent in agents
170
+ for skill in cfg.skills
171
+ for expect, prompts in (("fire", skill.should_fire), ("no_fire", skill.should_not_fire))
172
+ for prompt in prompts
173
+ ]
174
+ previous_duration = 0.0
175
+ if resume:
176
+ # Keep finished trials of an interrupted run with the same plan, run only the rest.
177
+ keys = {(a.cfg.name, s.name, p, e, r) for r, a, s, p, e in plan}
178
+ kept = [
179
+ t
180
+ for t in resume.get("trials", [])
181
+ if (t["agent"], t["skill"], t["prompt"], t["expect"], t["repeat"]) in keys
182
+ ]
183
+ done = {(t["agent"], t["skill"], t["prompt"], t["expect"], t["repeat"]) for t in kept}
184
+ plan = [x for x in plan if (x[1].cfg.name, x[2].name, x[3], x[4], x[0]) not in done]
185
+ results["trials"] = kept
186
+ results["cost_usd"] = round(sum(t.get("cost_usd") or 0.0 for t in kept), 6)
187
+ results["started_at"] = resume.get("started_at") or results["started_at"]
188
+ results["sessions"] = int(resume.get("sessions") or 1) + 1
189
+ previous_duration = float(resume.get("duration_s") or 0.0)
190
+ log(f"Resuming: {len(kept)} runs already done, {len(plan)} to go.")
191
+ max_seen: dict[str, float] = {}
192
+ t0 = time.monotonic()
193
+ with tempfile.TemporaryDirectory(prefix="doesitfire-") as tmp:
194
+ staged = stage_skills([(s.name, s.path) for s in cfg.skills], Path(tmp) / "skills")
195
+ allow = [u for a in agents for u in a.build("x").allow]
196
+ developer_as_system = any(a.cfg.name == "codex" and a.provider.name == "ollama" for a in agents)
197
+ with Sandbox(allow, image=image, developer_as_system=developer_as_system) as box:
198
+ for i, (rep, agent, skill, prompt, expect) in enumerate(plan, 1):
199
+ spent = results["cost_usd"]
200
+ if agent.provider.paid:
201
+ pin, pout = agent.cfg.price_input or 0, agent.cfg.price_output or 0
202
+ estimate = max_seen.get(
203
+ agent.cfg.name, EST_INPUT_TOKENS * pin / 1e6 + EST_OUTPUT_TOKENS * pout / 1e6
204
+ )
205
+ if spent + estimate > max_cost:
206
+ results["partial"] = True
207
+ results["stop_reason"] = (
208
+ f"budget: spent {spent:.4f} USD, next {agent.cfg.name} run would cost about "
209
+ f"{estimate:.4f} USD, --max-cost is {max_cost:.2f} USD"
210
+ )
211
+ break
212
+ trial = run_trial(box, agent, skill.name, prompt, expect, rep, staged, cfg, skill_names)
213
+ raw = trial.pop("_raw")
214
+ if logs_dir is not None:
215
+ logs_dir.mkdir(parents=True, exist_ok=True)
216
+ n = len(results["trials"]) + 1
217
+ name = f"{n:04d}-{agent.cfg.name}-{skill.name}-{expect}-r{rep}.jsonl"
218
+ (logs_dir / name).write_text(raw, encoding="utf-8")
219
+ trial["log"] = f"logs/{name}"
220
+ cost = run_cost(agent.provider, agent.cfg, trial.pop("_obs"))
221
+ trial["cost_usd"] = cost
222
+ max_seen[agent.cfg.name] = max(max_seen.get(agent.cfg.name, 0.0), cost)
223
+ results["cost_usd"] = round(results["cost_usd"] + cost, 6)
224
+ results["trials"].append(trial)
225
+ if progress:
226
+ progress({"done": i, "total": len(plan), "trial": trial})
227
+ if checkpoint:
228
+ results["duration_s"] = round(previous_duration + time.monotonic() - t0, 1)
229
+ checkpoint(results)
230
+ denials = box.proxy_denials()
231
+ results["blocked_egress"] = sorted(set(denials))
232
+ finished = dt.datetime.now(dt.UTC)
233
+ results["finished_at"] = finished.isoformat(timespec="seconds")
234
+ results["duration_s"] = round(previous_duration + time.monotonic() - t0, 1)
235
+ if results["partial"]:
236
+ raise BudgetExceeded(results["stop_reason"], results)
237
+ return results
238
+
239
+
240
+ def run_trial(
241
+ box: Sandbox,
242
+ agent: Agent,
243
+ skill: str,
244
+ prompt: str,
245
+ expect: str,
246
+ rep: int,
247
+ staged: Path,
248
+ cfg: Config,
249
+ skill_names: set[str],
250
+ ) -> dict:
251
+ inv = agent.build(prompt)
252
+ obs = Observation()
253
+
254
+ def on_line(line: str) -> bool:
255
+ agent.feed(line, obs, skill_names)
256
+ return agent.should_stop(obs, skill_names)
257
+
258
+ out = box.run(inv, agent.skills_dir, staged, cfg.timeout, cfg.cpus, cfg.memory, on_line)
259
+ fired = skill in obs.activated
260
+ error = None
261
+ if not fired:
262
+ if out.timed_out:
263
+ error = f"timeout after {cfg.timeout}s"
264
+ elif obs.error:
265
+ error = obs.error
266
+ elif out.returncode not in (0,) and not out.stopped_early and not out.stdout.strip():
267
+ tail = out.stderr.strip().splitlines()[-3:]
268
+ error = f"exit {out.returncode}: {' | '.join(tail)[:300]}" if tail else f"exit {out.returncode}"
269
+ return {
270
+ "agent": agent.cfg.name,
271
+ "skill": skill,
272
+ "prompt": prompt,
273
+ "expect": expect,
274
+ "repeat": rep,
275
+ "fired": fired,
276
+ "activated": obs.activated,
277
+ "error": error,
278
+ "duration_s": out.duration_s,
279
+ "input_tokens": obs.input_tokens,
280
+ "output_tokens": obs.output_tokens,
281
+ "_obs": obs,
282
+ "_raw": out.stdout + (f"\n# stderr\n{out.stderr[-4000:]}" if out.stderr.strip() else ""),
283
+ }
doesitfire/sandbox.py ADDED
@@ -0,0 +1,276 @@
1
+ """Docker isolation: every agent run happens in a throwaway container.
2
+
3
+ Layout per `doesitfire run`:
4
+
5
+ [agent container] --internal network--> [egress proxy] --bridge--> model API only
6
+
7
+ - The internal network has no route out. The only peer is the egress proxy,
8
+ which allows exactly the model API host:port and returns 403 for anything else.
9
+ - Agent containers: non-root, read-only root filesystem, tmpfs home, all Linux
10
+ capabilities dropped, no-new-privileges, CPU/memory/pids limits, hard timeout.
11
+ - Skills are mounted read-only and copied into the agent's discovery directory.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import shutil
18
+ import subprocess
19
+ import threading
20
+ import time
21
+ import uuid
22
+ from collections.abc import Callable
23
+ from contextlib import ExitStack
24
+ from dataclasses import dataclass
25
+ from importlib import resources
26
+ from pathlib import Path
27
+
28
+ from . import __version__
29
+ from .agents import Invocation
30
+ from .errors import DoesItFireError
31
+ from .providers import host_port
32
+
33
+ IMAGE = f"doesitfire-agents:{__version__}"
34
+ LABEL = "io.github.doesitfire.run"
35
+
36
+
37
+ def _docker(*args: str, timeout: float = 120, check: bool = True) -> subprocess.CompletedProcess:
38
+ try:
39
+ proc = subprocess.run(["docker", *args], capture_output=True, text=True, timeout=timeout)
40
+ except FileNotFoundError:
41
+ raise DoesItFireError(
42
+ "Docker is not installed. doesitfire runs every agent inside a container and refuses to run "
43
+ "without one. Install Docker Desktop (macOS/Windows) or Docker Engine (Linux)."
44
+ ) from None
45
+ except subprocess.TimeoutExpired:
46
+ raise DoesItFireError(
47
+ f"'docker {args[0]}' timed out after {timeout:.0f}s. Is the Docker daemon healthy?"
48
+ ) from None
49
+ if check and proc.returncode != 0:
50
+ raise DoesItFireError(f"'docker {' '.join(args[:2])}' failed: {proc.stderr.strip()[:400]}")
51
+ return proc
52
+
53
+
54
+ def check_docker() -> str:
55
+ """Return the Docker server version, or raise a clear error if Docker is not usable."""
56
+ proc = _docker("info", "--format", "{{.ServerVersion}}", timeout=30, check=False)
57
+ if proc.returncode != 0 or not proc.stdout.strip():
58
+ raise DoesItFireError(
59
+ "Docker is not running. Start Docker Desktop (or the Docker daemon) and try again. "
60
+ "doesitfire runs every agent inside a container and will not run without it."
61
+ )
62
+ return proc.stdout.strip()
63
+
64
+
65
+ def image_exists(image: str = IMAGE) -> bool:
66
+ return _docker("image", "inspect", image, check=False).returncode == 0
67
+
68
+
69
+ def build_image(image: str = IMAGE, log: Callable[[str], None] = print) -> None:
70
+ log(f"Building the agent image {image} (first run only, a few minutes)...")
71
+ with ExitStack() as stack:
72
+ ctx = stack.enter_context(resources.as_file(resources.files("doesitfire") / "docker"))
73
+ proc = subprocess.run(["docker", "build", "-t", image, str(ctx)], capture_output=True, text=True)
74
+ if proc.returncode != 0:
75
+ raise DoesItFireError(f"Building {image} failed:\n{proc.stderr.strip()[-1500:]}")
76
+
77
+
78
+ def stage_skills(skill_dirs: list[tuple[str, Path]], dest: Path) -> Path:
79
+ """Copy each skill directory to dest/<name> (what gets mounted read-only at /skills)."""
80
+ dest.mkdir(parents=True, exist_ok=True)
81
+ for name, src in skill_dirs:
82
+ shutil.copytree(
83
+ src,
84
+ dest / name,
85
+ dirs_exist_ok=True,
86
+ ignore=shutil.ignore_patterns(".git", "node_modules", "__pycache__", ".venv"),
87
+ )
88
+ return dest
89
+
90
+
91
+ @dataclass
92
+ class RunOutput:
93
+ stdout: str
94
+ stderr: str
95
+ returncode: int
96
+ timed_out: bool
97
+ stopped_early: bool
98
+ duration_s: float
99
+
100
+
101
+ class Sandbox:
102
+ """An internal Docker network plus an egress proxy, for one `doesitfire run`."""
103
+
104
+ def __init__(self, allow_urls: list[str], image: str = IMAGE, developer_as_system: bool = False):
105
+ self.run_id = uuid.uuid4().hex[:10]
106
+ self.image = image
107
+ self.network = f"doesitfire-{self.run_id}"
108
+ self.proxy = f"doesitfire-{self.run_id}-egress"
109
+ self.allow = sorted({host_port(u) for u in allow_urls})
110
+ self.developer_as_system = developer_as_system
111
+
112
+ def __enter__(self) -> Sandbox:
113
+ _docker("network", "create", "--internal", "--label", f"{LABEL}={self.run_id}", self.network)
114
+ try:
115
+ _docker(
116
+ "run",
117
+ "-d",
118
+ "--name",
119
+ self.proxy,
120
+ "--label",
121
+ f"{LABEL}={self.run_id}",
122
+ "--network",
123
+ "bridge",
124
+ "--add-host",
125
+ "host.docker.internal:host-gateway",
126
+ "--read-only",
127
+ "--cap-drop",
128
+ "ALL",
129
+ "--security-opt",
130
+ "no-new-privileges",
131
+ "--memory",
132
+ "128m",
133
+ "--cpus",
134
+ "0.5",
135
+ "--pids-limit",
136
+ "64",
137
+ "-e",
138
+ f"DOESITFIRE_ALLOW={','.join(self.allow)}",
139
+ "-e",
140
+ f"DOESITFIRE_DEVELOPER_AS_SYSTEM={'1' if self.developer_as_system else '0'}",
141
+ "--entrypoint",
142
+ "node",
143
+ self.image,
144
+ "/opt/doesitfire/egress-proxy.mjs",
145
+ )
146
+ _docker("network", "connect", "--alias", "egress", self.network, self.proxy)
147
+ deadline = time.monotonic() + 20
148
+ while time.monotonic() < deadline:
149
+ logs = _docker("logs", self.proxy, check=False)
150
+ if "egress proxy on" in logs.stderr + logs.stdout:
151
+ break
152
+ time.sleep(0.3)
153
+ else:
154
+ raise DoesItFireError("The egress proxy container did not start.")
155
+ except BaseException:
156
+ self.close()
157
+ raise
158
+ return self
159
+
160
+ def __exit__(self, *exc) -> None:
161
+ self.close()
162
+
163
+ def close(self) -> None:
164
+ ids = _docker("ps", "-aq", "--filter", f"label={LABEL}={self.run_id}", check=False).stdout.split()
165
+ if ids:
166
+ _docker("rm", "-f", *ids, check=False)
167
+ _docker("network", "rm", self.network, check=False)
168
+
169
+ def proxy_denials(self) -> list[str]:
170
+ logs = _docker("logs", self.proxy, check=False)
171
+ return [ln for ln in (logs.stderr + logs.stdout).splitlines() if ln.startswith("deny ")]
172
+
173
+ def run(
174
+ self,
175
+ inv: Invocation,
176
+ skills_dir: str,
177
+ staged: Path,
178
+ timeout: int,
179
+ cpus: float = 1.0,
180
+ memory: str = "1g",
181
+ on_line: Callable[[str], bool] | None = None,
182
+ ) -> RunOutput:
183
+ name = f"doesitfire-{self.run_id}-{uuid.uuid4().hex[:8]}"
184
+ proxy = "http://egress:8888"
185
+ args = [
186
+ "docker",
187
+ "run",
188
+ "--rm",
189
+ "-i",
190
+ "--name",
191
+ name,
192
+ "--label",
193
+ f"{LABEL}={self.run_id}",
194
+ "--network",
195
+ self.network,
196
+ "--read-only",
197
+ "--tmpfs",
198
+ "/home/node:rw,exec,uid=1000,gid=1000,size=512m",
199
+ "--tmpfs",
200
+ "/tmp:rw,exec,size=256m",
201
+ "--cap-drop",
202
+ "ALL",
203
+ "--security-opt",
204
+ "no-new-privileges",
205
+ "--cpus",
206
+ str(cpus),
207
+ "--memory",
208
+ memory,
209
+ "--pids-limit",
210
+ "512",
211
+ "-v",
212
+ f"{staged}:/skills:ro",
213
+ "-e",
214
+ f"DOESITFIRE_SKILLS_DIR={skills_dir}",
215
+ ]
216
+ for var in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
217
+ args += ["-e", f"{var}={proxy}"]
218
+ args += ["-e", "NO_PROXY=", "-e", "no_proxy="]
219
+ for key, value in inv.env.items():
220
+ args += ["-e", f"{key}={value}"]
221
+ child_env = dict(os.environ)
222
+ for key, value in inv.secret_env.items():
223
+ args += ["-e", key] # value travels through the environment, never argv
224
+ child_env[key] = value
225
+ args += [self.image, "timeout", "-s", "KILL", str(timeout + 5), *inv.argv]
226
+
227
+ start = time.monotonic()
228
+ proc = subprocess.Popen(
229
+ args,
230
+ stdin=subprocess.DEVNULL,
231
+ stdout=subprocess.PIPE,
232
+ stderr=subprocess.PIPE,
233
+ text=True,
234
+ encoding="utf-8",
235
+ errors="replace",
236
+ env=child_env,
237
+ )
238
+ out_lines: list[str] = []
239
+ err_chunks: list[str] = []
240
+ stopped = threading.Event()
241
+
242
+ def read_stderr() -> None:
243
+ assert proc.stderr is not None
244
+ err_chunks.append(proc.stderr.read())
245
+
246
+ def kill() -> None:
247
+ _docker("kill", name, check=False, timeout=30)
248
+
249
+ t_err = threading.Thread(target=read_stderr, daemon=True)
250
+ t_err.start()
251
+ timer = threading.Timer(timeout, kill)
252
+ timer.start()
253
+ try:
254
+ assert proc.stdout is not None
255
+ for line in proc.stdout:
256
+ out_lines.append(line)
257
+ if on_line and not stopped.is_set() and on_line(line):
258
+ stopped.set()
259
+ kill()
260
+ proc.wait()
261
+ except KeyboardInterrupt:
262
+ kill()
263
+ raise
264
+ finally:
265
+ timed_out = not timer.is_alive() and not stopped.is_set() and proc.returncode not in (0,)
266
+ timer.cancel()
267
+ t_err.join(timeout=5)
268
+ duration = time.monotonic() - start
269
+ return RunOutput(
270
+ stdout="".join(out_lines),
271
+ stderr="".join(err_chunks),
272
+ returncode=proc.returncode,
273
+ timed_out=timed_out and duration >= timeout,
274
+ stopped_early=stopped.is_set(),
275
+ duration_s=round(duration, 2),
276
+ )