acumen 0.0.1.dev0__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.
acumen/logs.py ADDED
@@ -0,0 +1,255 @@
1
+ """Live agent logs for the meta-agents.
2
+
3
+ ``draft``/``improve``/``tasks``/``ship`` each spin up one long, autonomous agent whose transcript
4
+ today is written into a throwaway ``CLAUDE_CONFIG_DIR`` and ``rmtree``'d on return — a black box.
5
+ :class:`LiveLog` makes each run observable:
6
+
7
+ * **A live JSONL**, appended and flushed *one normalized event per SDK message, during the run*,
8
+ at ``logs/acumen-<command>-<datetime>.jsonl``. This is the contract for a master agent watching
9
+ a run by **reading the file** on its own schedule — never a flood of the command's stdout into
10
+ its context ("print, don't read"). The feed is deliberately compact: it does **not** inline huge
11
+ tool results (file dumps, command output), only their status and size.
12
+ * **A terminal mirror** when ``--stream`` is on (off by default): each turn's assistant text and a
13
+ one-line-per-tool-call summary printed live for a human watching.
14
+ * **An HTML log** rendered at the end from the SDK-native transcript (:func:`finalize`), via the
15
+ same ``claude-code-log`` path the benchmark runner uses. The rich post-hoc artifact; the JSONL
16
+ is the lightweight live feed.
17
+
18
+ The class is stateful (it correlates tool results back to the tool that produced them) but has no
19
+ agent dependency, so it is unit-testable on a synthetic message sequence — like ``cli._Progress``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from collections.abc import Callable
26
+ from datetime import datetime
27
+ from pathlib import Path
28
+ from typing import Any, TextIO
29
+
30
+ from claude_agent_sdk import (
31
+ AssistantMessage,
32
+ ResultMessage,
33
+ SystemMessage,
34
+ TextBlock,
35
+ ThinkingBlock,
36
+ ToolResultBlock,
37
+ ToolUseBlock,
38
+ UserMessage,
39
+ )
40
+
41
+ from acumen.transcript import locate_transcript, render_transcript
42
+
43
+ #: tool_input keys, in priority order, whose value best summarizes a tool call in one line.
44
+ _SUMMARY_KEYS = ("command", "file_path", "path", "notebook_path", "pattern", "url", "query", "description", "prompt")
45
+
46
+ #: Thinking blocks can be huge; the live feed keeps only a short preview of them.
47
+ _THINKING_CAP = 240
48
+ #: How long a one-line tool-input summary may get before it is truncated.
49
+ _SUMMARY_CAP = 160
50
+
51
+
52
+ def _truncate(text: str, cap: int) -> str:
53
+ text = text.strip()
54
+ return text if len(text) <= cap else text[:cap] + "…"
55
+
56
+
57
+ def _tool_summary(tool_input: Any) -> str:
58
+ """A one-line summary of a tool call's input — the salient argument, whitespace-collapsed."""
59
+ if not isinstance(tool_input, dict):
60
+ return ""
61
+ for key in _SUMMARY_KEYS:
62
+ value = tool_input.get(key)
63
+ if isinstance(value, str) and value.strip():
64
+ return _truncate(" ".join(value.split()), _SUMMARY_CAP)
65
+ parts = [f"{key}={tool_input[key]!r}" for key in list(tool_input)[:3]]
66
+ return _truncate(", ".join(parts), _SUMMARY_CAP)
67
+
68
+
69
+ def _content_len(content: Any) -> int:
70
+ """Character length of a tool result's content — recorded instead of inlining the content."""
71
+ if isinstance(content, str):
72
+ return len(content)
73
+ if isinstance(content, list):
74
+ total = 0
75
+ for block in content:
76
+ if isinstance(block, dict):
77
+ total += len(str(block.get("text", "")))
78
+ else:
79
+ total += len(str(block))
80
+ return total
81
+ return 0
82
+
83
+
84
+ class LiveLog:
85
+ """A live, append-only JSONL log of one meta-agent run, with optional terminal streaming.
86
+
87
+ Open one on ``logs/acumen-<command>-<datetime>.jsonl`` with :meth:`open`, feed every SDK
88
+ message to :meth:`append` inside the ``async for`` loop, and call :meth:`finalize` once the
89
+ run is done (before the throwaway config dir is deleted) to render the HTML log. Close it to
90
+ flush and release the file.
91
+ """
92
+
93
+ def __init__(
94
+ self,
95
+ jsonl_path: Path,
96
+ *,
97
+ stream: bool = False,
98
+ echo: Callable[[str], None] = print,
99
+ clock: Callable[[], str] | None = None,
100
+ ) -> None:
101
+ self.jsonl_path = Path(jsonl_path)
102
+ self.html_path = self.jsonl_path.with_suffix(".html")
103
+ self.stream = stream
104
+ self.html_rendered = False
105
+ self.n_events = 0
106
+ self._echo = echo
107
+ self._clock = clock or (lambda: datetime.now().isoformat(timespec="seconds"))
108
+ self._tool_names: dict[str, str] = {}
109
+ self.jsonl_path.parent.mkdir(parents=True, exist_ok=True)
110
+ self._fh: TextIO = self.jsonl_path.open("w", encoding="utf-8")
111
+
112
+ @classmethod
113
+ def open(
114
+ cls,
115
+ log_dir: Path,
116
+ command: str,
117
+ *,
118
+ stream: bool = False,
119
+ now: datetime | None = None,
120
+ echo: Callable[[str], None] = print,
121
+ ) -> LiveLog:
122
+ """Open a log for ``command`` under ``log_dir`` with the fixed filename pattern.
123
+
124
+ The filename is ``acumen-<command>-<YYYYMMDD-HHMMSS>.jsonl``; the directory is the only
125
+ thing a caller overrides.
126
+ """
127
+ stamp = (now or datetime.now()).strftime("%Y%m%d-%H%M%S")
128
+ return cls(Path(log_dir) / f"acumen-{command}-{stamp}.jsonl", stream=stream, echo=echo)
129
+
130
+ def append(self, message: Any) -> None:
131
+ """Normalize one SDK message to a compact event, append it (flushed), and maybe echo it."""
132
+ event = self._normalize(message)
133
+ if event is None:
134
+ return
135
+ record = {"ts": self._clock(), **event}
136
+ self._fh.write(json.dumps(record) + "\n")
137
+ self._fh.flush()
138
+ self.n_events += 1
139
+ if self.stream:
140
+ for line in self._render_lines(record):
141
+ self._echo(line)
142
+
143
+ def finalize(self, *, config_dir: Path, work_dir: Path, result: ResultMessage | None) -> bool:
144
+ """Locate the run's SDK-native transcript and render the HTML log from it.
145
+
146
+ Call before the throwaway config dir is removed — the native transcript still lives under
147
+ it at this point. A no-op that returns ``False`` if the run produced no result.
148
+ """
149
+ if result is None:
150
+ return False
151
+ native = locate_transcript(config_dir, work_dir, result.session_id)
152
+ if native is None or not native.is_file():
153
+ return False
154
+ self.html_rendered = render_transcript(native, self.html_path)
155
+ return self.html_rendered
156
+
157
+ def close(self) -> None:
158
+ """Flush and release the underlying JSONL file. Idempotent."""
159
+ if not self._fh.closed:
160
+ self._fh.close()
161
+
162
+ def __enter__(self) -> LiveLog:
163
+ return self
164
+
165
+ def __exit__(self, *exc: object) -> None:
166
+ self.close()
167
+
168
+ # ── Normalization ──────────────────────────────────────────────────────────────────
169
+
170
+ def _normalize(self, message: Any) -> dict[str, Any] | None:
171
+ if isinstance(message, AssistantMessage):
172
+ return self._assistant_event(message)
173
+ if isinstance(message, UserMessage):
174
+ return self._user_event(message)
175
+ if isinstance(message, SystemMessage):
176
+ return {"type": "system", "subtype": message.subtype}
177
+ if isinstance(message, ResultMessage):
178
+ return {
179
+ "type": "result",
180
+ "is_error": bool(message.is_error),
181
+ "subtype": message.subtype,
182
+ "turns": message.num_turns,
183
+ "cost_usd": round(message.total_cost_usd or 0.0, 4),
184
+ "duration_s": round((message.duration_ms or 0) / 1000, 2),
185
+ "session_id": message.session_id,
186
+ }
187
+ return None
188
+
189
+ def _assistant_event(self, message: AssistantMessage) -> dict[str, Any]:
190
+ text_parts: list[str] = []
191
+ thinking_parts: list[str] = []
192
+ tools: list[dict[str, Any]] = []
193
+ for block in message.content:
194
+ if isinstance(block, TextBlock):
195
+ if block.text.strip():
196
+ text_parts.append(block.text)
197
+ elif isinstance(block, ThinkingBlock):
198
+ thinking_parts.append(block.thinking)
199
+ elif isinstance(block, ToolUseBlock):
200
+ self._tool_names[block.id] = block.name
201
+ tools.append({"id": block.id, "name": block.name, "summary": _tool_summary(block.input)})
202
+ event: dict[str, Any] = {"type": "assistant"}
203
+ if message.model:
204
+ event["model"] = message.model
205
+ text = "\n".join(text_parts).strip()
206
+ if text:
207
+ event["text"] = text
208
+ if thinking_parts:
209
+ event["thinking"] = _truncate("\n".join(thinking_parts), _THINKING_CAP)
210
+ if tools:
211
+ event["tools"] = tools
212
+ return event
213
+
214
+ def _user_event(self, message: UserMessage) -> dict[str, Any] | None:
215
+ content = message.content
216
+ if not isinstance(content, list):
217
+ return None # a plain user turn (our own prompt) — nothing worth an event
218
+ results: list[dict[str, Any]] = []
219
+ for block in content:
220
+ if isinstance(block, ToolResultBlock):
221
+ results.append(
222
+ {
223
+ "tool_use_id": block.tool_use_id,
224
+ "name": self._tool_names.get(block.tool_use_id),
225
+ "status": "error" if block.is_error else "ok",
226
+ "chars": _content_len(block.content),
227
+ }
228
+ )
229
+ return {"type": "tool_result", "results": results} if results else None
230
+
231
+ # ── Terminal mirror (--stream) ─────────────────────────────────────────────────────
232
+
233
+ def _render_lines(self, event: dict[str, Any]) -> list[str]:
234
+ kind = event.get("type")
235
+ lines: list[str] = []
236
+ if kind == "assistant":
237
+ if event.get("thinking"):
238
+ lines.append(f" · thinking: {event['thinking']}")
239
+ if event.get("text"):
240
+ lines.append(str(event["text"]).strip())
241
+ for tool in event.get("tools", []):
242
+ summary = tool.get("summary") or ""
243
+ lines.append(f" → {tool['name']}: {summary}".rstrip())
244
+ elif kind == "tool_result":
245
+ for res in event["results"]:
246
+ mark = "✗" if res["status"] == "error" else "✓"
247
+ name = res.get("name") or "tool"
248
+ lines.append(f" {mark} {name} ({res['chars']} chars)")
249
+ elif kind == "result":
250
+ mark = "✗ error" if event["is_error"] else "● done"
251
+ lines.append(
252
+ f"{mark}: {event['subtype']} · {event['turns']} turns · "
253
+ f"${event['cost_usd']:.2f} · {event['duration_s']}s"
254
+ )
255
+ return lines
acumen/paths.py ADDED
@@ -0,0 +1,164 @@
1
+ """Run-directory layout — the single source of truth for path construction.
2
+
3
+ The layout is ``runs/{arm}/{split}/{model}/{task_id}/rep_{n}/`` and is consumed by the
4
+ runner, the resume check, the improver's allow/deny list, and the report. Anything that
5
+ needs to know where a run lives asks this module.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Literal
14
+
15
+ Split = Literal["train", "test"]
16
+
17
+ SPLITS: tuple[Split, ...] = ("train", "test")
18
+
19
+ NOSKILL_ARM = "noskill"
20
+
21
+ ANSWER_FILE = "answer.md"
22
+ SCRIPT_FILE = "script.py"
23
+ TRANSCRIPT_JSONL = "transcript.jsonl"
24
+ TRANSCRIPT_HTML = "transcript.html"
25
+ RESULT_FILE = "result.json"
26
+
27
+ #: Every file a completed run leaf is expected to contain.
28
+ RUN_FILES = (ANSWER_FILE, SCRIPT_FILE, TRANSCRIPT_JSONL, TRANSCRIPT_HTML, RESULT_FILE)
29
+
30
+ _UNSAFE = re.compile(r"[^A-Za-z0-9._-]+")
31
+ _ARM_RE = re.compile(r"^skill_v(\d+)$")
32
+ _REP_RE = re.compile(r"^rep_(\d+)$")
33
+
34
+
35
+ class PathError(ValueError):
36
+ """Raised when a path cannot be constructed or parsed."""
37
+
38
+
39
+ def slugify(text: str) -> str:
40
+ """Make ``text`` safe to use as a single path component.
41
+
42
+ Parameters
43
+ ----------
44
+ text
45
+ Arbitrary text, e.g. a model name such as ``anthropic/claude-opus-5``.
46
+
47
+ Returns
48
+ -------
49
+ A string containing only ``[A-Za-z0-9._-]``.
50
+ """
51
+ slug = _UNSAFE.sub("-", text).strip("-")
52
+ if not slug or slug in {".", ".."}:
53
+ raise PathError(f"{text!r} has no filesystem-safe representation")
54
+ return slug
55
+
56
+
57
+ def is_safe_component(text: str) -> bool:
58
+ """Return whether ``text`` is already usable as a path component verbatim."""
59
+ return bool(text) and text not in {".", ".."} and not _UNSAFE.search(text)
60
+
61
+
62
+ def arm_name(skill: str | None) -> str:
63
+ """Return the run-directory arm name for a skill version.
64
+
65
+ Parameters
66
+ ----------
67
+ skill
68
+ A skill version such as ``"v1"``, or ``None`` for the baseline arm.
69
+
70
+ Returns
71
+ -------
72
+ ``"noskill"`` for the baseline, otherwise ``"skill_v1"``-style.
73
+ """
74
+ if skill is None:
75
+ return NOSKILL_ARM
76
+ version = skill[1:] if skill.startswith("v") else skill
77
+ if not version.isdigit():
78
+ raise PathError(f"skill version must look like 'v1', got {skill!r}")
79
+ return f"skill_v{int(version)}"
80
+
81
+
82
+ def skill_from_arm(arm: str) -> str | None:
83
+ """Invert :func:`arm_name` — return the skill version an arm refers to."""
84
+ if arm == NOSKILL_ARM:
85
+ return None
86
+ match = _ARM_RE.match(arm)
87
+ if not match:
88
+ raise PathError(f"not an arm name: {arm!r}")
89
+ return f"v{int(match.group(1))}"
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class RunKey:
94
+ """Identifies exactly one benchmark run — one leaf of the run tree."""
95
+
96
+ arm: str
97
+ split: Split
98
+ model: str
99
+ task_id: str
100
+ rep: int
101
+
102
+ @property
103
+ def skill(self) -> str | None:
104
+ """The skill version this run exercises, or ``None`` for the baseline."""
105
+ return skill_from_arm(self.arm)
106
+
107
+
108
+ def run_dir(runs_root: Path, key: RunKey) -> Path:
109
+ """Build the directory for a single run.
110
+
111
+ Parameters
112
+ ----------
113
+ runs_root
114
+ The ``runs/`` root directory.
115
+ key
116
+ Which run to locate.
117
+
118
+ Returns
119
+ -------
120
+ ``runs_root/{arm}/{split}/{model}/{task_id}/rep_{n}``.
121
+ """
122
+ if key.split not in SPLITS:
123
+ raise PathError(f"split must be one of {SPLITS}, got {key.split!r}")
124
+ if key.rep < 1:
125
+ raise PathError(f"rep must be >= 1, got {key.rep}")
126
+ if not is_safe_component(key.task_id):
127
+ raise PathError(f"task id {key.task_id!r} is not filesystem-safe")
128
+ return runs_root / key.arm / key.split / slugify(key.model) / key.task_id / f"rep_{key.rep}"
129
+
130
+
131
+ def parse_run_dir(runs_root: Path, path: Path) -> RunKey:
132
+ """Recover a :class:`RunKey` from a run directory.
133
+
134
+ The model is returned in its slugified form, which is what the path carries; the
135
+ canonical model name lives in ``result.json``.
136
+ """
137
+ try:
138
+ rel = path.resolve().relative_to(runs_root.resolve())
139
+ except ValueError as err:
140
+ raise PathError(f"{path} is not under {runs_root}") from err
141
+ parts = rel.parts
142
+ if len(parts) != 5:
143
+ raise PathError(f"expected {runs_root}/arm/split/model/task/rep_n, got {rel}")
144
+ arm, split, model, task_id, rep = parts
145
+ skill_from_arm(arm) # validates the arm name
146
+ if split not in SPLITS:
147
+ raise PathError(f"split must be one of {SPLITS}, got {split!r}")
148
+ match = _REP_RE.match(rep)
149
+ if not match:
150
+ raise PathError(f"expected 'rep_<n>', got {rep!r}")
151
+ return RunKey(arm=arm, split=split, model=model, task_id=task_id, rep=int(match.group(1))) # type: ignore[arg-type]
152
+
153
+
154
+ def is_complete(directory: Path) -> bool:
155
+ """Return whether a run directory holds a complete run.
156
+
157
+ A readable ``result.json`` is what marks a run done — this is the check that makes
158
+ resuming a pass work.
159
+ """
160
+ result = directory / RESULT_FILE
161
+ try:
162
+ return result.is_file() and result.stat().st_size > 0
163
+ except OSError:
164
+ return False
acumen/procs.py ADDED
@@ -0,0 +1,195 @@
1
+ """Killing the processes an agent leaves behind.
2
+
3
+ An agent is a ``claude`` CLI subprocess, and that CLI spawns processes of its own: every
4
+ Bash tool call is a shell, and a shell can start a pytest, a fit that runs for an hour, or
5
+ something backgrounded with ``&``. The SDK terminates the CLI and nothing else — so does a
6
+ crash, a cap breach, or a Ctrl-C — and the descendants survive. They keep burning CPU
7
+ alongside the runs still going, and they keep writing into a directory that is about to be
8
+ deleted.
9
+
10
+ The parent link cannot find them, on any platform. On Unix an orphan is reparented to init,
11
+ so the tree that identified it is gone; on Windows it is not reparented at all and its
12
+ recorded parent id dangles, free to be reused by an unrelated process. What survives in
13
+ both cases is the environment: :func:`label_env` stamps every agent with :data:`RUN_MARKER`,
14
+ set to that run's private temp directory, and a process inherits its environment from
15
+ whatever started it. So the marker identifies a descendant even after the parent is gone,
16
+ the process has renamed itself, or it has changed directory. :func:`reap` finds everything
17
+ carrying a given run's marker and kills it; the sandbox and the meta-agent staging dirs call
18
+ it on the way out, before their directory is removed, on every exit path including
19
+ cancellation.
20
+
21
+ The marker is what makes this exact rather than a guess at which paths a run touches. The
22
+ benchmark sandboxes do put their throwaway ``HOME`` and ``TMPDIR`` under the same directory,
23
+ but ``ship``'s agent deliberately runs in the operator's real environment and real checkout,
24
+ where no such path appears — one explicit variable covers both.
25
+
26
+ Reading *another* process's environment is the one step with no portable stdlib spelling:
27
+ it is a file read on Linux, a ``KERN_PROCARGS2`` sysctl on macOS, and a ``ReadProcessMemory``
28
+ walk of the PEB on Windows. ``psutil`` implements all three natively, which is why it is a
29
+ dependency. :func:`supported` reports whether the running platform is one of them.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from collections.abc import Callable, Iterator
35
+ from pathlib import Path
36
+
37
+ import psutil
38
+
39
+ #: Environment variable naming the run a process belongs to, set to the run's temp directory.
40
+ #: Stamped on every agent by :func:`label_env` and inherited by everything the agent spawns.
41
+ #: Uppercase deliberately: Windows environment keys are case-insensitive and psutil upper-cases
42
+ #: them when reading a process block, so an all-caps name is the one spelling that round-trips
43
+ #: identically on every platform.
44
+ RUN_MARKER = "ACUMEN_RUN"
45
+
46
+ #: How long to let a signalled process exit before escalating to a hard kill. An upper bound,
47
+ #: not a fixed pause — the wait ends the moment the processes are gone, which is the common
48
+ #: case. It still needs to stay modest: the callers are synchronous teardown paths inside
49
+ #: async runs, so this blocks the event loop and every other run on it.
50
+ GRACE_S = 2.0
51
+
52
+ #: Raised by psutil when a process is gone, is another user's, or is unreadable for a
53
+ #: platform-specific reason (on Windows a 32-bit interpreter cannot read a 64-bit process's
54
+ #: environment). Every one of these means "not identifiable as ours", never "abort the scan".
55
+ _UNREADABLE = (psutil.Error, OSError, ValueError)
56
+
57
+
58
+ def supported() -> bool:
59
+ """Whether orphan reaping works on this platform.
60
+
61
+ True on Linux, macOS and Windows. False only where psutil cannot read another process's
62
+ environment, in which case :func:`reap` does nothing and orphans are left alone.
63
+ """
64
+ return hasattr(psutil.Process, "environ")
65
+
66
+
67
+ def label_env(env: dict[str, str], holder: Path) -> dict[str, str]:
68
+ """Stamp an agent's environment with the run it belongs to, and return it.
69
+
70
+ Call this on the mapping handed to the SDK, with the same ``holder`` later passed to
71
+ :func:`reap`. Without the stamp a run's orphans are unidentifiable and :func:`reap` has
72
+ nothing to match on.
73
+
74
+ Parameters
75
+ ----------
76
+ env
77
+ The agent's environment, modified in place.
78
+ holder
79
+ The run's private temp directory, which is unique per run and so serves as its id.
80
+
81
+ Returns
82
+ -------
83
+ The same mapping, for chaining onto the call that built it.
84
+ """
85
+ env[RUN_MARKER] = str(holder)
86
+ return env
87
+
88
+
89
+ def _protected() -> set[int]:
90
+ """Return acumen's own pid and every ancestor of it.
91
+
92
+ A belt-and-braces guard. Matching is on a variable acumen itself never has in its
93
+ environment, so these should never come up as candidates — but the cost of a false
94
+ positive here is killing the process doing the killing, or the operator's shell.
95
+ """
96
+ me = psutil.Process()
97
+ protected = {me.pid}
98
+ try:
99
+ protected.update(parent.pid for parent in me.parents())
100
+ except _UNREADABLE: # pragma: no cover - an ancestor exiting mid-walk
101
+ pass
102
+ return protected
103
+
104
+
105
+ def _labelled(proc: psutil.Process, holder: Path) -> bool:
106
+ """Whether ``proc`` belongs to the run rooted at ``holder``.
107
+
108
+ The environment psutil reports is the one the process was *executed* with, which is why
109
+ the marker still identifies a descendant whose parent has since died.
110
+
111
+ ``cwd`` is checked as a second signal, for the rare process started with a wiped
112
+ environment that is nonetheless running inside the run's directory. Path comparison is
113
+ case-insensitive on Windows, which :class:`~pathlib.Path` already handles.
114
+ """
115
+ try:
116
+ if proc.environ().get(RUN_MARKER) == str(holder):
117
+ return True
118
+ except _UNREADABLE:
119
+ pass
120
+ try:
121
+ cwd = proc.cwd()
122
+ except _UNREADABLE:
123
+ return False
124
+ return bool(cwd) and Path(cwd).is_relative_to(holder)
125
+
126
+
127
+ def _survivor_procs(holder: Path) -> Iterator[psutil.Process]:
128
+ """Yield live processes descended from the agent that ran in ``holder``."""
129
+ if not supported():
130
+ return
131
+ protected = _protected()
132
+ for proc in psutil.process_iter():
133
+ # Processes come and go mid-scan; one that vanished is simply not a survivor.
134
+ if proc.pid not in protected and _labelled(proc, holder):
135
+ yield proc
136
+
137
+
138
+ def survivors(holder: Path) -> list[int]:
139
+ """Return the pids of every live process descended from the agent that ran in ``holder``.
140
+
141
+ Parameters
142
+ ----------
143
+ holder
144
+ The run's temp directory, the same one passed to :func:`label_env`.
145
+
146
+ Returns
147
+ -------
148
+ The matching pids, excluding acumen and its ancestors. Empty on an unsupported platform.
149
+ """
150
+ return [proc.pid for proc in _survivor_procs(holder)]
151
+
152
+
153
+ def _quietly(action: Callable[[], None]) -> None:
154
+ """Run a kill that may lose its race with the process exiting on its own."""
155
+ try:
156
+ action()
157
+ except _UNREADABLE:
158
+ pass
159
+
160
+
161
+ def reap(holder: Path, *, grace: float = GRACE_S) -> list[int]:
162
+ """Kill everything the agent that ran in ``holder`` left behind.
163
+
164
+ Asks politely first (``SIGTERM``; on Windows, where there is no such signal, psutil
165
+ terminates outright), then kills whatever is still standing after ``grace``. The second
166
+ round re-scans rather than reusing the first round's list, so a process spawned while
167
+ the first signals were landing is caught too.
168
+
169
+ Safe to call when nothing leaked, when the agent exited cleanly, and on a platform
170
+ psutil cannot read — in each case it does nothing and returns an empty list.
171
+
172
+ Parameters
173
+ ----------
174
+ holder
175
+ The run's temp directory, as passed to :func:`survivors`.
176
+ grace
177
+ Seconds to wait before the hard kill. Blocks the caller, but only until the
178
+ signalled processes are actually gone.
179
+
180
+ Returns
181
+ -------
182
+ The pids that were signalled, for logging. An empty list means nothing was orphaned.
183
+ """
184
+ doomed = list(_survivor_procs(holder))
185
+ if not doomed:
186
+ return []
187
+ for proc in doomed:
188
+ _quietly(proc.terminate)
189
+ # Waiting on a process that is our own direct child consumes its exit status, which
190
+ # would otherwise be the SDK's to collect. Harmless here: callers reap only once the
191
+ # SDK has closed its transport, so the agent process has already been waited on.
192
+ psutil.wait_procs(doomed, timeout=grace)
193
+ for proc in _survivor_procs(holder):
194
+ _quietly(proc.kill)
195
+ return [proc.pid for proc in doomed]