alissa-tools-github-devloop 0.8.46__py3-none-any.whl → 0.8.48__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.
@@ -132,6 +132,36 @@ ENV_REVERT_LABEL = "ALISSA_REVERT_LABEL"
132
132
  # worker's worktree as part of the session; the daemon never executes it.
133
133
  ENV_REGENERATE = "ALISSA_DEV_REGENERATE"
134
134
 
135
+ # The hung-test reaper's window (issue #174): minutes a worker's test-runner
136
+ # tree may sit at under `hungtests.MIN_PROGRESS_SECONDS` of CPU growth before
137
+ # the daemon kills it. Env-only, read straight from the environment like
138
+ # ENV_REGENERATE: not a config key, not a build ARG. Default 20; 0 turns the
139
+ # reaper off; blank falls through to the default; anything that is not a
140
+ # non-negative integer is a config error at boot, by name -- a typo silently
141
+ # read as "off" would leave a 55 h hung suite exactly where it was.
142
+ ENV_HUNG_TEST_MINUTES = "ALISSA_DEV_HUNG_TEST_MINUTES"
143
+ DEFAULT_HUNG_TEST_MINUTES = 20
144
+
145
+
146
+ def parse_hung_test_minutes(raw: str) -> int:
147
+ """The ALISSA_DEV_HUNG_TEST_MINUTES value as minutes (0 = off)."""
148
+ text = (raw or "").strip()
149
+ if not text:
150
+ return DEFAULT_HUNG_TEST_MINUTES
151
+ try:
152
+ minutes = int(text)
153
+ except ValueError:
154
+ raise ValueError(
155
+ f"{ENV_HUNG_TEST_MINUTES} must be a whole number of minutes "
156
+ f"(0 = off), got {raw!r}"
157
+ ) from None
158
+ if minutes < 0:
159
+ raise ValueError(
160
+ f"{ENV_HUNG_TEST_MINUTES} must be >= 0 (0 = off), got {minutes}"
161
+ )
162
+ return minutes
163
+
164
+
135
165
  # `owner/repo` for a regenerate entry: exactly one `/`, no whitespace, and no
136
166
  # `:` (the separator that ends it). Shape only, like the actor-id check.
137
167
  _REGENERATE_REPO_RE = re.compile(r"^[^\s/:]+/[^\s/:]+\Z")
@@ -857,6 +887,12 @@ class Config:
857
887
  # Read through `regenerate_for(owner, repo)`, never executed here.
858
888
  regenerate: tuple[RegenerateRule, ...] = ()
859
889
 
890
+ # The hung-test reaper's window in minutes (issue #174), from
891
+ # ALISSA_DEV_HUNG_TEST_MINUTES only (no config key, no CLI flag). A
892
+ # worker's `npm test` / `tsx --test` tree whose CPU grows by under 5 s
893
+ # across this window is killed; 0 turns the reaper off.
894
+ hung_test_minutes: int = DEFAULT_HUNG_TEST_MINUTES
895
+
860
896
  dry_run: bool = False
861
897
 
862
898
  # Derived at build time: `repos` casefolded for matching. GitHub names are
@@ -1118,6 +1154,10 @@ class Config:
1118
1154
  # The regenerable-paths map (issue #138): env-only, validated here so
1119
1155
  # a malformed entry fails the boot by name instead of a directive.
1120
1156
  regenerate = parse_regenerate(env_map.get(ENV_REGENERATE, ""))
1157
+ # The hung-test window (issue #174): env-only, validated here.
1158
+ hung_test_minutes = parse_hung_test_minutes(
1159
+ env_map.get(ENV_HUNG_TEST_MINUTES, "")
1160
+ )
1121
1161
 
1122
1162
  bow_owners = normalize_bow_owners(raw.get("bow_owners", cls.bow_owners))
1123
1163
 
@@ -1430,6 +1470,7 @@ class Config:
1430
1470
  ),
1431
1471
  disk_warn_percent=warn_percent,
1432
1472
  regenerate=regenerate,
1473
+ hung_test_minutes=hung_test_minutes,
1433
1474
  dry_run=bool(raw.get("dry_run", cls.dry_run)),
1434
1475
  )
1435
1476
 
@@ -0,0 +1,481 @@
1
+ """The hung-test reaper's mechanics (issue #174).
2
+
3
+ On 2026-10-18 the studio devloop container ran at load ~15 while four worker
4
+ `npm test` runs sat hung, each at about 10 CPU-seconds in total -- one of them
5
+ for 55 hours, its lane long merged. Their test workers had died (esbuild's
6
+ "The service was stopped", EAGAIN with the box out of processes), the
7
+ `tsx --test` runner then waited forever, and the sessions waited on it in an
8
+ `until grep -q '^EXIT=' suite.log` loop. Nothing in the fleet noticed: a
9
+ session waiting on a log reads ALIVE to every check the daemon has.
10
+
11
+ What lives here is the janitor's split again -- only the MECHANICS: which
12
+ process trees are worker test runs (`find_test_trees`), whether one has made
13
+ CPU progress across a window (`CpuWatch`), how to stop one (`kill_tree`), and
14
+ how to tell its session (`message_session`). The POLICY -- which hung or
15
+ orphaned tree to kill, and when -- lives in `loop.py` next to the janitor
16
+ whose predicates it shares.
17
+
18
+ The properties everything here is written around:
19
+
20
+ * Nothing outside a task workspace is ever a candidate. A tree is admitted
21
+ only when its cwd or one of its args sits inside a watched hub's task
22
+ workspace, judged by the janitor's own grammar (`parse_task_workspace`), so
23
+ `main/`, `.source/`, an operator's shell and the daemon itself are never
24
+ CONSIDERED -- the property the tests pin, rather than "checked and spared".
25
+ * CPU progress, not wall-clock, is the hang signal. A full suite that runs for
26
+ an hour is fine; a runner that burned under `MIN_PROGRESS_SECONDS` of CPU
27
+ over the whole window is waiting on something that is not coming back.
28
+ * Nothing raises past its own concern. A process that exits mid-read, an
29
+ unreadable `/proc` entry, a signal to a pid that is already gone -- all are
30
+ skipped, because this runs inside a poll pass.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import logging
37
+ import os
38
+ import signal
39
+ import time
40
+ from dataclasses import dataclass, field
41
+ from pathlib import Path
42
+ from typing import Callable, Iterable, Mapping, Sequence
43
+
44
+ from .janitor import TaskWorkspace, parse_task_workspace
45
+ from .proc import CommandError, run
46
+
47
+ log = logging.getLogger(__name__)
48
+
49
+ # Where the process table is read. A module attribute (read at call time,
50
+ # never bound as a default) so the tests point it at a fake tree.
51
+ PROC_ROOT = Path("/proc")
52
+
53
+ # A tree whose summed CPU time grows by less than this across the window is
54
+ # hung. The incident's trees sat at ~10 CPU-seconds TOTAL over hours; a live
55
+ # runner burns this much in seconds.
56
+ MIN_PROGRESS_SECONDS = 5.0
57
+
58
+ # TERM, then KILL whatever is still alive this long after.
59
+ KILL_GRACE_SECONDS = 10.0
60
+
61
+ # How often the TERM→KILL wait re-reads the tree.
62
+ _KILL_POLL_SECONDS = 0.5
63
+
64
+ # The env a managed seat exports (alissa CLI): the tmux session it runs in and
65
+ # the Alissa session id the session-message path addresses. Inherited by every
66
+ # process the seat starts, detached ones included -- which is why they are
67
+ # read off the tree rather than its ancestry: a `nohup npm test &` whose shell
68
+ # exited is reparented to init and has no ancestor pane at all.
69
+ ENV_TMUX_SESSION = "ALISSA_TMUX_SESSION"
70
+ ENV_SESSION_ID = "ALISSA_SESSION_ID"
71
+
72
+ # The prefix the CLI puts on a managed session's real tmux name; the roster
73
+ # (`alissa tmux ls --json`'s `name`) carries it bare.
74
+ TMUX_PREFIX = "ali-"
75
+
76
+ # `npm test` and its aliases, plus `npm run test` / `npm run-script test`.
77
+ _NPM_BINARIES = frozenset({"npm", "npm-cli.js"})
78
+ _NPM_TEST = frozenset({"test", "t", "tst"})
79
+ _NPM_RUN = frozenset({"run", "run-script", "rum", "urn"})
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class Proc:
84
+ """One process, as `/proc/<pid>` described it at read time.
85
+
86
+ `cpu` is utime+stime PLUS cutime+cstime, in seconds: the reaped children's
87
+ time is the runner's progress too. A runner that executes one test file
88
+ after another reaps each child as it finishes; without the children's
89
+ share the tree's sum would DROP between samples while it works."""
90
+
91
+ pid: int
92
+ ppid: int
93
+ state: str
94
+ start: int
95
+ cpu: float
96
+ argv: "tuple[str, ...]"
97
+ cwd: "str | None"
98
+
99
+ @property
100
+ def alive(self) -> bool:
101
+ return self.state not in ("Z", "X")
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class TestTree:
106
+ """One worker test run: the topmost runner process and every descendant,
107
+ with the task workspace it was admitted under."""
108
+
109
+ root: Proc
110
+ members: "tuple[Proc, ...]"
111
+ workspace: TaskWorkspace
112
+ tmux_session: "str | None" = None
113
+ session_id: "str | None" = None
114
+ age: float = 0.0
115
+
116
+ @property
117
+ def key(self) -> "tuple[int, int]":
118
+ """(pid, start ticks): a pid alone is reused; the pair is not."""
119
+ return (self.root.pid, self.root.start)
120
+
121
+ @property
122
+ def cpu(self) -> float:
123
+ return sum(member.cpu for member in self.members)
124
+
125
+ @property
126
+ def session(self) -> "str | None":
127
+ """The owning session's roster name (no `ali-` prefix), or None."""
128
+ name = self.tmux_session
129
+ if not name:
130
+ return None
131
+ return name[len(TMUX_PREFIX):] if name.startswith(TMUX_PREFIX) else name
132
+
133
+ @property
134
+ def command(self) -> str:
135
+ return " ".join(self.root.argv)
136
+
137
+
138
+ def _clock_ticks() -> int:
139
+ try:
140
+ return int(os.sysconf("SC_CLK_TCK")) or 100
141
+ except (ValueError, OSError, AttributeError): # pragma: no cover
142
+ return 100
143
+
144
+
145
+ def read_proc(pid: int, root: "Path | None" = None) -> "Proc | None":
146
+ """`/proc/<pid>` as a Proc, or None when it cannot be read (gone, a
147
+ kernel thread with no cmdline, permission)."""
148
+ base = (PROC_ROOT if root is None else root) / str(pid)
149
+ try:
150
+ stat = (base / "stat").read_text()
151
+ raw = (base / "cmdline").read_bytes()
152
+ except OSError:
153
+ return None
154
+ # The comm field is parenthesised and may itself hold spaces or `)`;
155
+ # everything after the LAST `)` is the fixed-position tail.
156
+ _, _, tail = stat.rpartition(")")
157
+ fields = tail.split()
158
+ try:
159
+ state, ppid = fields[0], int(fields[1])
160
+ ticks = sum(int(value) for value in fields[11:15])
161
+ start = int(fields[19])
162
+ except (IndexError, ValueError):
163
+ return None
164
+ argv = tuple(part.decode("utf-8", "replace") for part in raw.split(b"\0") if part)
165
+ if not argv:
166
+ return None
167
+ try:
168
+ cwd: "str | None" = os.readlink(base / "cwd")
169
+ except OSError:
170
+ cwd = None
171
+ return Proc(
172
+ pid=pid, ppid=ppid, state=state, start=start,
173
+ cpu=ticks / _clock_ticks(), argv=argv, cwd=cwd,
174
+ )
175
+
176
+
177
+ def snapshot(root: "Path | None" = None) -> "dict[int, Proc]":
178
+ """Every readable process, by pid. An unreadable table yields nothing."""
179
+ base = PROC_ROOT if root is None else root
180
+ try:
181
+ names = [entry.name for entry in base.iterdir()]
182
+ except OSError as exc:
183
+ log.warning("hung tests: cannot list %s (%s) — skipped this pass", base, exc)
184
+ return {}
185
+ procs = {}
186
+ for name in names:
187
+ if not name.isdigit():
188
+ continue
189
+ proc = read_proc(int(name), base)
190
+ if proc is not None:
191
+ procs[proc.pid] = proc
192
+ return procs
193
+
194
+
195
+ def uptime(root: "Path | None" = None) -> "float | None":
196
+ """Seconds since boot (`/proc/uptime`), or None."""
197
+ base = PROC_ROOT if root is None else root
198
+ try:
199
+ return float((base / "uptime").read_text().split()[0])
200
+ except (OSError, IndexError, ValueError):
201
+ return None
202
+
203
+
204
+ def is_test_runner(argv: "Sequence[str]") -> bool:
205
+ """Is this command line a test runner: `npm test` (or `npm run test`),
206
+ or anything carrying a bare `--test` argument -- `node --test`,
207
+ `tsx --test`, `npx tsx --test`, and a `timeout … --test` wrapper around
208
+ one. Exact tokens only: a `bash -c '… --test …'` is one argument holding
209
+ the words, and the shell is not the runner."""
210
+ if "--test" in argv:
211
+ return True
212
+ for index, token in enumerate(argv):
213
+ if os.path.basename(token) not in _NPM_BINARIES:
214
+ continue
215
+ rest = [arg for arg in argv[index + 1:] if not arg.startswith("-")]
216
+ if rest and rest[0] in _NPM_TEST:
217
+ return True
218
+ if len(rest) >= 2 and rest[0] in _NPM_RUN and rest[1] == "test":
219
+ return True
220
+ return False
221
+
222
+
223
+ def _path_in(text: str) -> "str | None":
224
+ if text.startswith("file://"):
225
+ text = text[len("file://"):]
226
+ return text if text.startswith("/") else None
227
+
228
+
229
+ def workspace_of(
230
+ proc: Proc, hubs: "Sequence[Path]"
231
+ ) -> "TaskWorkspace | None":
232
+ """The task workspace `proc`'s cwd or one of its path args sits in, or
233
+ None. The hub's direct child that holds the path must parse as a task
234
+ workspace under the janitor's grammar -- `main/`, `.source/` and anything
235
+ else under a hub never qualify."""
236
+ candidates = [proc.cwd] if proc.cwd else []
237
+ candidates += [p for p in (_path_in(arg) for arg in proc.argv) if p]
238
+ for text in candidates:
239
+ path = Path(os.path.normpath(text))
240
+ for hub in hubs:
241
+ try:
242
+ relative = path.relative_to(hub)
243
+ except ValueError:
244
+ continue
245
+ if not relative.parts:
246
+ continue
247
+ workspace = parse_task_workspace(hub / relative.parts[0], hub)
248
+ if workspace is not None:
249
+ return workspace
250
+ return None
251
+
252
+
253
+ def session_env(pid: int, root: "Path | None" = None) -> "tuple[str | None, str | None]":
254
+ """(ALISSA_TMUX_SESSION, ALISSA_SESSION_ID) from the process's env."""
255
+ base = (PROC_ROOT if root is None else root) / str(pid)
256
+ try:
257
+ raw = (base / "environ").read_bytes()
258
+ except OSError:
259
+ return None, None
260
+ found: "dict[str, str]" = {}
261
+ for part in raw.split(b"\0"):
262
+ name, sep, value = part.decode("utf-8", "replace").partition("=")
263
+ if sep and name in (ENV_TMUX_SESSION, ENV_SESSION_ID) and value:
264
+ found[name] = value
265
+ return found.get(ENV_TMUX_SESSION), found.get(ENV_SESSION_ID)
266
+
267
+
268
+ def _children(procs: "Mapping[int, Proc]") -> "dict[int, list[int]]":
269
+ children: "dict[int, list[int]]" = {}
270
+ for proc in procs.values():
271
+ children.setdefault(proc.ppid, []).append(proc.pid)
272
+ return children
273
+
274
+
275
+ def descendants(procs: "Mapping[int, Proc]", pid: int) -> "list[Proc]":
276
+ """`pid` and every process below it, root first."""
277
+ children = _children(procs)
278
+ order: "list[Proc]" = []
279
+ stack = [pid]
280
+ seen: "set[int]" = set()
281
+ while stack:
282
+ current = stack.pop()
283
+ if current in seen or current not in procs:
284
+ continue
285
+ seen.add(current)
286
+ order.append(procs[current])
287
+ stack.extend(sorted(children.get(current, ()), reverse=True))
288
+ return order
289
+
290
+
291
+ def _has_runner_ancestor(procs: "Mapping[int, Proc]", proc: Proc) -> bool:
292
+ """Is any live ancestor of `proc` itself a runner? A `sh -c 'tsx --test'`
293
+ between two runners is one argument and not a runner, so the parent alone
294
+ cannot answer -- the whole chain up to init is read."""
295
+ seen = {proc.pid}
296
+ current = procs.get(proc.ppid)
297
+ while current is not None and current.pid not in seen:
298
+ if current.alive and is_test_runner(current.argv):
299
+ return True
300
+ seen.add(current.pid)
301
+ current = procs.get(current.ppid)
302
+ return False
303
+
304
+
305
+ def find_test_trees(
306
+ procs: "Mapping[int, Proc]",
307
+ hubs: "Sequence[Path]",
308
+ root: "Path | None" = None,
309
+ ) -> "list[TestTree]":
310
+ """Every worker test run inside a task workspace of one of `hubs`.
311
+
312
+ A tree is rooted at its TOPMOST runner (a runner with no runner above it),
313
+ so `timeout 900 npx tsx --test` and the `npm exec` / `tsx` / `node --test`
314
+ chain under it are one tree, killed together. The workspace is read off
315
+ the first member, root first, whose cwd or args name one; a tree none of
316
+ whose members does is not ours and is never returned."""
317
+ resolved = []
318
+ for hub in hubs:
319
+ try:
320
+ resolved.append(Path(hub).resolve())
321
+ except OSError:
322
+ continue
323
+ boot = uptime(root)
324
+ ticks = _clock_ticks()
325
+ trees = []
326
+ for proc in sorted(procs.values(), key=lambda p: p.pid):
327
+ if not proc.alive or not is_test_runner(proc.argv):
328
+ continue
329
+ if _has_runner_ancestor(procs, proc):
330
+ continue
331
+ members = tuple(m for m in descendants(procs, proc.pid) if m.alive)
332
+ workspace = None
333
+ for member in members:
334
+ workspace = workspace_of(member, resolved)
335
+ if workspace is not None:
336
+ break
337
+ if workspace is None:
338
+ continue
339
+ tmux_session, session_id = session_env(proc.pid, root)
340
+ age = 0.0 if boot is None else max(0.0, boot - proc.start / ticks)
341
+ trees.append(TestTree(
342
+ root=proc, members=members, workspace=workspace,
343
+ tmux_session=tmux_session, session_id=session_id, age=age,
344
+ ))
345
+ return trees
346
+
347
+
348
+ @dataclass
349
+ class CpuWatch:
350
+ """The last CPU sample per tree, and the hang verdict across a window.
351
+
352
+ A tree is sampled the first time it is seen; once `window` seconds have
353
+ passed since its sample it is judged: hung when it is at least `window`
354
+ old and its CPU grew by under `MIN_PROGRESS_SECONDS`, otherwise the
355
+ sample moves forward and a new window starts. A tree that burns CPU is
356
+ therefore re-judged every window, and a tree younger than one never is.
357
+ Samples of trees that are no longer seen are dropped."""
358
+
359
+ window: float
360
+ clock: "Callable[[], float] | None" = None
361
+ samples: "dict[tuple[int, int], tuple[float, float]]" = field(default_factory=dict)
362
+
363
+ def observe(self, trees: "Iterable[TestTree]") -> "list[TestTree]":
364
+ now = (self.clock or monotonic)()
365
+ hung = []
366
+ seen = set()
367
+ for tree in trees:
368
+ seen.add(tree.key)
369
+ sample = self.samples.get(tree.key)
370
+ if sample is None:
371
+ self.samples[tree.key] = (now, tree.cpu)
372
+ continue
373
+ at, cpu = sample
374
+ if now - at < self.window:
375
+ continue
376
+ if tree.age >= self.window and tree.cpu - cpu < MIN_PROGRESS_SECONDS:
377
+ hung.append(tree)
378
+ continue
379
+ self.samples[tree.key] = (now, tree.cpu)
380
+ for key in list(self.samples):
381
+ if key not in seen:
382
+ del self.samples[key]
383
+ return hung
384
+
385
+
386
+ def send_signal(pid: int, signum: int) -> None:
387
+ """`os.kill`, swallowing a pid that is already gone or not ours."""
388
+ try:
389
+ os.kill(pid, signum)
390
+ except (ProcessLookupError, PermissionError):
391
+ pass
392
+
393
+
394
+ def sleep(seconds: float) -> None:
395
+ """`time.sleep`, behind a module seam the tests replace."""
396
+ time.sleep(seconds)
397
+
398
+
399
+ def monotonic() -> float:
400
+ """`time.monotonic`, behind a module seam the tests replace."""
401
+ return time.monotonic()
402
+
403
+
404
+ def _still_alive(member: Proc, root: "Path | None") -> bool:
405
+ now = read_proc(member.pid, root)
406
+ return now is not None and now.start == member.start and now.alive
407
+
408
+
409
+ def kill_tree(
410
+ tree: TestTree,
411
+ *,
412
+ root: "Path | None" = None,
413
+ grace: float = KILL_GRACE_SECONDS,
414
+ ) -> "tuple[int, int]":
415
+ """TERM every member of `tree`, wait up to `grace` seconds, then KILL
416
+ whatever is still alive. Returns (termed, killed).
417
+
418
+ The member list is re-read first, so a child the runner spawned since the
419
+ snapshot is signalled too; every pid is matched on its start time before
420
+ each signal, so a pid the kernel reused for something else is never hit.
421
+ """
422
+ fresh = snapshot(root)
423
+ current = fresh.get(tree.root.pid)
424
+ if current is None or current.start != tree.root.start:
425
+ return 0, 0
426
+ members = [m for m in descendants(fresh, tree.root.pid) if m.alive]
427
+ for member in members:
428
+ send_signal(member.pid, signal.SIGTERM)
429
+ deadline = monotonic() + grace
430
+ survivors = [m for m in members if _still_alive(m, root)]
431
+ while survivors and monotonic() < deadline:
432
+ sleep(_KILL_POLL_SECONDS)
433
+ survivors = [m for m in survivors if _still_alive(m, root)]
434
+ for member in survivors:
435
+ send_signal(member.pid, signal.SIGKILL)
436
+ return len(members), len(survivors)
437
+
438
+
439
+ def session_id_for(tmux_session: str, runner: "Callable[..., str]" = run) -> "str | None":
440
+ """The Alissa session id behind a managed tmux session, read off
441
+ `alissa session list --json`'s `tmuxSession`, or None."""
442
+ try:
443
+ out = runner(["alissa", "session", "list", "--json", "--limit", "50"], timeout=60)
444
+ data = json.loads(out)
445
+ except (CommandError, OSError, ValueError) as exc:
446
+ log.debug("hung tests: could not list sessions (%s)", exc)
447
+ return None
448
+ if isinstance(data, dict):
449
+ data = data.get("sessions", [])
450
+ wanted = {tmux_session, TMUX_PREFIX + tmux_session}
451
+ for entry in data if isinstance(data, list) else []:
452
+ if isinstance(entry, dict) and entry.get("tmuxSession") in wanted:
453
+ value = entry.get("sessionId")
454
+ if isinstance(value, str) and value:
455
+ return value
456
+ return None
457
+
458
+
459
+ def message_session(
460
+ session_id: str, text: str, runner: "Callable[..., str]" = run
461
+ ) -> bool:
462
+ """Post `text` to one session through the session-message path
463
+ (`alissa session msg send -s <id>`). True when the CLI accepted it."""
464
+ try:
465
+ runner(["alissa", "session", "msg", "send", "-s", session_id, text], timeout=60)
466
+ except (CommandError, OSError) as exc:
467
+ log.warning("hung tests: could not message session %s (%s)", session_id, exc)
468
+ return False
469
+ return True
470
+
471
+
472
+ def hung_message(tree: TestTree, minutes: int, reason: str) -> str:
473
+ """The one message a live session gets after its test run was stopped."""
474
+ return (
475
+ f"devloop: your test run in {tree.workspace.path.name} "
476
+ f"(`{tree.command}`, pid {tree.root.pid}) hung and was stopped "
477
+ f"({reason}). Do not rerun the full suite and never wait on one for "
478
+ f"more than {minutes} minutes: run only the test files your diff "
479
+ f"touches, each on its own, one file per command. CI on the PR runs "
480
+ f"the full suite."
481
+ )