loopcaptain 0.3.1__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.
Files changed (50) hide show
  1. loopcaptain/__init__.py +0 -0
  2. loopcaptain/agents.py +162 -0
  3. loopcaptain/board/AGENTS.md +39 -0
  4. loopcaptain/board/__init__.py +0 -0
  5. loopcaptain/board/app.py +430 -0
  6. loopcaptain/board/discover.py +249 -0
  7. loopcaptain/board/files.py +94 -0
  8. loopcaptain/board/filters.py +48 -0
  9. loopcaptain/board/graph.py +139 -0
  10. loopcaptain/board/overview.py +59 -0
  11. loopcaptain/board/static/board.js +41 -0
  12. loopcaptain/board/static/fonts/OFL.txt +94 -0
  13. loopcaptain/board/static/fonts/inter-latin-wght-normal.woff2 +0 -0
  14. loopcaptain/board/static/fonts/inter-tight-latin-wght-normal.woff2 +0 -0
  15. loopcaptain/board/static/mark.svg +5 -0
  16. loopcaptain/board/static/style.css +425 -0
  17. loopcaptain/board/steer.py +207 -0
  18. loopcaptain/board/templates/base.html +27 -0
  19. loopcaptain/board/templates/connect.html +35 -0
  20. loopcaptain/board/templates/file.html +9 -0
  21. loopcaptain/board/templates/loop.html +357 -0
  22. loopcaptain/board/templates/overview.html +108 -0
  23. loopcaptain/board/templates/unreadable.html +17 -0
  24. loopcaptain/board/trend.py +185 -0
  25. loopcaptain/cli.py +204 -0
  26. loopcaptain/demo/__init__.py +59 -0
  27. loopcaptain/demo/loop.py +311 -0
  28. loopcaptain/demo/loops.py +76 -0
  29. loopcaptain/mcp_server/AGENTS.md +23 -0
  30. loopcaptain/mcp_server/__init__.py +0 -0
  31. loopcaptain/mcp_server/server.py +181 -0
  32. loopcaptain/prompt_texts/build-a-loop.md +6 -0
  33. loopcaptain/prompt_texts/watch-my-loops.md +6 -0
  34. loopcaptain/prompts.py +27 -0
  35. loopcaptain/protocol/AGENTS.md +24 -0
  36. loopcaptain/protocol/LOOPS.md +402 -0
  37. loopcaptain/protocol/__init__.py +17 -0
  38. loopcaptain/protocol/doctor.py +79 -0
  39. loopcaptain/protocol/models.py +496 -0
  40. loopcaptain/protocol/signing.py +105 -0
  41. loopcaptain/protocol/state.py +595 -0
  42. loopcaptain/protocol/store.py +275 -0
  43. loopcaptain/protocol/validate.py +199 -0
  44. loopcaptain-0.3.1.dist-info/METADATA +220 -0
  45. loopcaptain-0.3.1.dist-info/RECORD +50 -0
  46. loopcaptain-0.3.1.dist-info/WHEEL +4 -0
  47. loopcaptain-0.3.1.dist-info/entry_points.txt +2 -0
  48. loopcaptain-0.3.1.dist-info/licenses/LICENSE +202 -0
  49. loopcaptain-0.3.1.dist-info/licenses/NOTICE +11 -0
  50. loopcaptain-0.3.1.dist-info/licenses/src/loopcaptain/board/static/fonts/OFL.txt +94 -0
@@ -0,0 +1,595 @@
1
+ """Where a loop stands, folded from its event lines.
2
+
3
+ Pure: the same manifest, events and inbox always give the same state. The clock comes in as an
4
+ argument so "running or stalled" can be tested.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field, replace
10
+ from datetime import datetime, timedelta
11
+ from typing import Literal
12
+
13
+ from loopcaptain.protocol.models import (
14
+ AnyEvent,
15
+ Check,
16
+ Cost,
17
+ FindingClosed,
18
+ Heartbeat,
19
+ InboxEntry,
20
+ InboxRead,
21
+ LoopStopped,
22
+ Manifest,
23
+ Metric,
24
+ Option,
25
+ Question,
26
+ QuestionClosed,
27
+ RoundFinished,
28
+ RoundStarted,
29
+ StepError,
30
+ StepFinished,
31
+ StepStarted,
32
+ )
33
+
34
+ #: A round with no new line for this long counts as stalled, not running. A research step with
35
+ #: many agents can be quiet for a while; an hour of silence is a stopped process.
36
+ STALE_AFTER = timedelta(minutes=45)
37
+
38
+ #: Stop reasons that mean a limit of the loop's own was reached.
39
+ CAP_REASONS = frozenset({"round_cap", "cost_cap", "runtime_cap"})
40
+
41
+ Status = Literal[
42
+ "new", "running", "sleeping", "stalled", "waiting", "idle", "done", "stopped", "capped", "failed"
43
+ ]
44
+ RunStatus = Literal["running", "ok", "failed", "skipped"]
45
+
46
+ #: What each status means, shown as a tooltip and in the legend.
47
+ STATUS_TEXT: dict[str, str] = {
48
+ "new": "registered, no round yet",
49
+ "running": "a round is under way",
50
+ "sleeping": "between rounds, and said when it is back",
51
+ "stalled": "a round began and has been silent for a while",
52
+ "waiting": "waiting for your answer",
53
+ "idle": "the last round said continue; nothing is running",
54
+ "done": "the loop says it is done",
55
+ "stopped": "stopped on request",
56
+ "capped": "reached one of its caps: rounds, runtime or cost",
57
+ "failed": "the last round failed",
58
+ }
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class FindingState:
63
+ id: str
64
+ text: str
65
+ severity: str
66
+ closed: bool = False
67
+ resolution: str | None = None
68
+ reason: str = ""
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class StepRun:
73
+ """One run of one step in one round. A fan-out step has several, told apart by `label`."""
74
+
75
+ step: str
76
+ label: str = ""
77
+ status: RunStatus = "running"
78
+ started: datetime | None = None
79
+ finished: datetime | None = None
80
+ summary: str = ""
81
+ artifact: str | None = None
82
+ findings: tuple[FindingState, ...] = ()
83
+ cost: Cost | None = None
84
+ metrics: tuple[Metric, ...] = ()
85
+ attempt: int | None = None
86
+ error: StepError | None = None
87
+ session: str | None = None
88
+ model: str | None = None
89
+
90
+ @property
91
+ def open_findings(self) -> tuple[FindingState, ...]:
92
+ return tuple(f for f in self.findings if not f.closed)
93
+
94
+ @property
95
+ def material_open(self) -> int:
96
+ return sum(1 for f in self.open_findings if f.severity == "material")
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class RoundState:
101
+ number: int
102
+ started: datetime | None = None
103
+ finished: datetime | None = None
104
+ plan: str = ""
105
+ plan_artifact: str | None = None
106
+ runs: tuple[StepRun, ...] = ()
107
+ verdict: str | None = None
108
+ stop_reason: str | None = None
109
+ summary: str = ""
110
+ next: str = ""
111
+ commit: str | None = None
112
+ verdict_artifact: str | None = None
113
+ questions: tuple[str, ...] = ()
114
+ #: The round's whole cost where the loop reports only the sum; it replaces the steps' costs.
115
+ round_cost: Cost | None = None
116
+ metrics: tuple[Metric, ...] = ()
117
+ outcome: str | None = None
118
+ next_at: datetime | None = None
119
+
120
+ @property
121
+ def running(self) -> bool:
122
+ return self.verdict is None
123
+
124
+ @property
125
+ def costs(self) -> tuple[Cost, ...]:
126
+ if self.round_cost is not None:
127
+ return (self.round_cost,)
128
+ return tuple(run.cost for run in self.runs if run.cost)
129
+
130
+ @property
131
+ def cost_usd(self) -> float:
132
+ return sum(cost.usd for cost in self.costs)
133
+
134
+ @property
135
+ def tokens(self) -> int:
136
+ return sum(cost.tokens for cost in self.costs)
137
+
138
+ @property
139
+ def all_metrics(self) -> tuple[Metric, ...]:
140
+ """The round's own numbers, then the last value of each that only its steps reported."""
141
+ named = {metric.name: metric for run in self.runs for metric in run.metrics}
142
+ named.update({metric.name: metric for metric in self.metrics})
143
+ return tuple(named.values())
144
+
145
+ @property
146
+ def minutes(self) -> int | None:
147
+ if not (self.started and self.finished):
148
+ return None
149
+ return max(0, round((self.finished - self.started).total_seconds() / 60))
150
+
151
+ @property
152
+ def findings_open(self) -> int:
153
+ return sum(len(run.open_findings) for run in self.runs)
154
+
155
+ @property
156
+ def findings_closed(self) -> int:
157
+ return sum(len(run.findings) - len(run.open_findings) for run in self.runs)
158
+
159
+ def runs_of(self, step: str) -> tuple[StepRun, ...]:
160
+ return tuple(run for run in self.runs if run.step == step)
161
+
162
+
163
+ @dataclass(frozen=True)
164
+ class OpenQuestion:
165
+ text: str
166
+ asked: datetime
167
+ round: int | None
168
+ blocks: bool = False
169
+ timeout_s: int | None = None
170
+ id: str | None = None
171
+ options: tuple[Option, ...] = ()
172
+ #: Not a question the loop asked but a step that failed in a way only the person can fix.
173
+ needs_person: bool = False
174
+
175
+ @property
176
+ def gives_up(self) -> datetime | None:
177
+ """When a blocking loop stops waiting and goes on without the answer."""
178
+ return self.asked + timedelta(seconds=self.timeout_s) if self.blocks and self.timeout_s else None
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class InboxState:
183
+ entry: InboxEntry
184
+ read: bool
185
+ #: False where the signature failed: shown, but it steers nothing.
186
+ trusted: bool = True
187
+
188
+
189
+ @dataclass(frozen=True)
190
+ class CheckState:
191
+ kind: str
192
+ result: str
193
+ at: datetime
194
+ who: str = ""
195
+ finding: str = ""
196
+ evidence: str | None = None
197
+ round: int | None = None
198
+
199
+
200
+ @dataclass(frozen=True)
201
+ class ItemState:
202
+ name: str
203
+ checks: tuple[CheckState, ...] = ()
204
+
205
+ def latest(self, kind: str) -> CheckState | None:
206
+ of_kind = [check for check in self.checks if check.kind == kind]
207
+ return of_kind[-1] if of_kind else None
208
+
209
+ def history(self, kind: str) -> tuple[CheckState, ...]:
210
+ return tuple(check for check in self.checks if check.kind == kind)
211
+
212
+
213
+ @dataclass(frozen=True)
214
+ class LoopState:
215
+ manifest: Manifest
216
+ rounds: tuple[RoundState, ...] = ()
217
+ status: Status = "new"
218
+ question: OpenQuestion | None = None
219
+ inbox: tuple[InboxState, ...] = ()
220
+ items: tuple[ItemState, ...] = ()
221
+ last_event: datetime | None = None
222
+ #: Step ids that appear in events and not in the manifest, in order of first appearance.
223
+ undeclared_steps: tuple[str, ...] = field(default=())
224
+ #: The loop ended between rounds, after its last round finished.
225
+ ended: LoopStopped | None = None
226
+ #: When the loop said it would show a sign of life next.
227
+ expected: datetime | None = None
228
+ #: The last line in words, for a loop that went silent.
229
+ last_line: str = ""
230
+
231
+ @property
232
+ def last_round(self) -> RoundState | None:
233
+ return self.rounds[-1] if self.rounds else None
234
+
235
+ @property
236
+ def cost_usd(self) -> float:
237
+ return sum(r.cost_usd for r in self.rounds)
238
+
239
+ @property
240
+ def tokens(self) -> int:
241
+ return sum(r.tokens for r in self.rounds)
242
+
243
+ @property
244
+ def minutes(self) -> int:
245
+ return sum(r.minutes or 0 for r in self.rounds)
246
+
247
+ @property
248
+ def open_findings(self) -> tuple[tuple[StepRun, FindingState], ...]:
249
+ """What is open now: the open findings of the last round that reported any review at all.
250
+
251
+ Reviews are fresh every round, so an earlier round's finding that is not repeated is gone.
252
+ """
253
+ for round_ in reversed(self.rounds):
254
+ if any(run.findings for run in round_.runs) or not round_.running:
255
+ return tuple((run, f) for run in round_.runs for f in run.open_findings)
256
+ return ()
257
+
258
+ @property
259
+ def stop_requested(self) -> bool:
260
+ return any(item.entry.kind == "stop" and not item.read and item.trusted for item in self.inbox)
261
+
262
+ @property
263
+ def check_kinds(self) -> tuple[str, ...]:
264
+ kinds: list[str] = []
265
+ for item in self.items:
266
+ for check in item.checks:
267
+ if check.kind not in kinds:
268
+ kinds.append(check.kind)
269
+ return tuple(kinds)
270
+
271
+ @property
272
+ def step_ids(self) -> tuple[str, ...]:
273
+ """Every step that has a row: the manifest's, then any the events name beyond it."""
274
+ return (*(step.id for step in self.manifest.steps), *self.undeclared_steps)
275
+
276
+
277
+ def fold(
278
+ manifest: Manifest,
279
+ events: list[AnyEvent],
280
+ inbox: list[InboxEntry],
281
+ at: datetime,
282
+ process_alive: bool = False,
283
+ untrusted: frozenset[str] = frozenset(),
284
+ ) -> LoopState:
285
+ """The state of a loop at time `at`. `process_alive`: the board itself started it and it still runs.
286
+
287
+ `untrusted`: ids of inbox entries whose signature failed. They are shown, but they answer no
288
+ question and request no stop: an agent of the loop may have written them.
289
+ """
290
+ rounds: dict[int, RoundState] = {}
291
+ items: dict[str, ItemState] = {}
292
+ read_ids: set[str] = set()
293
+ last_question: Question | None = None
294
+ ended: LoopStopped | None = None
295
+ expected: datetime | None = None
296
+
297
+ for event in events:
298
+ if isinstance(event, RoundStarted):
299
+ ended = None
300
+ expected = None # a new round is its own sign of life; an older promise is spent
301
+ if last_question is not None and last_question.blocks:
302
+ # The loop stood still for this question and has now started a round: it went on,
303
+ # with an answer or without one.
304
+ last_question = None
305
+ # A round that starts again starts clean: its earlier runs and verdict are history.
306
+ rounds[event.round] = RoundState(
307
+ event.round, started=event.when, plan=event.plan, plan_artifact=event.artifact
308
+ )
309
+ elif isinstance(event, StepStarted | StepFinished | FindingClosed | RoundFinished):
310
+ current = rounds.get(event.round, RoundState(event.round, started=event.when))
311
+ rounds[event.round] = apply_to_round(current, event)
312
+ if isinstance(event, RoundFinished):
313
+ expected = promise(event.next_at, event.ts)
314
+ elif isinstance(event, Question):
315
+ last_question = event
316
+ if event.round is not None and event.round in rounds:
317
+ asked = rounds[event.round]
318
+ rounds[event.round] = replace(asked, questions=(*asked.questions, event.text))
319
+ elif isinstance(event, QuestionClosed):
320
+ if last_question is not None and event.question in (None, last_question.id):
321
+ last_question = None
322
+ elif isinstance(event, Check):
323
+ item = items.get(event.item, ItemState(event.item))
324
+ check = CheckState(
325
+ event.kind, event.result, event.when, event.who, event.finding, event.evidence, event.round
326
+ )
327
+ items[event.item] = replace(item, checks=(*item.checks, check))
328
+ elif isinstance(event, InboxRead):
329
+ read_ids.update(event.ids)
330
+ elif isinstance(event, LoopStopped):
331
+ ended = event
332
+ elif isinstance(event, Heartbeat):
333
+ expected = promise(event.next_at, event.ts) or expected
334
+
335
+ ordered = tuple(rounds[n] for n in sorted(rounds))
336
+ trusted = [entry for entry in inbox if entry.id not in untrusted]
337
+ question = open_question(last_question, trusted, at) or needs_person(ordered, trusted)
338
+ last_event = max((event.ts for event in events), default=None)
339
+ declared = {step.id for step in manifest.steps}
340
+ undeclared: list[str] = []
341
+ for round_ in ordered:
342
+ for run in round_.runs:
343
+ if run.step not in declared and run.step not in undeclared:
344
+ undeclared.append(run.step)
345
+ status = status_of(manifest, ordered, question, last_event, at, process_alive, expected)
346
+ if ended is not None and not process_alive and not (ordered and ordered[-1].running):
347
+ status = ended_status(ended)
348
+ return LoopState(
349
+ manifest=manifest,
350
+ rounds=ordered,
351
+ status=status,
352
+ question=question,
353
+ inbox=tuple(InboxState(entry, entry.id in read_ids, entry.id not in untrusted) for entry in inbox),
354
+ items=tuple(items.values()),
355
+ last_event=last_event,
356
+ undeclared_steps=tuple(undeclared),
357
+ ended=ended,
358
+ expected=expected,
359
+ last_line=describe(events[-1]) if events else "",
360
+ )
361
+
362
+
363
+ def describe(event: AnyEvent) -> str:
364
+ """One event as a person reads it: what, in which round, by which step."""
365
+ parts = [event.event.replace("_", " ")]
366
+ number = getattr(event, "round", None)
367
+ if number:
368
+ parts.append(f"round {number}")
369
+ step = getattr(event, "step", None)
370
+ label = getattr(event, "label", "")
371
+ if step:
372
+ parts.append(f"step {step}" + (f" ({label})" if label else ""))
373
+ return ", ".join(parts)
374
+
375
+
376
+ #: The furthest a loop may promise its next sign of life: past this it is stalled, whatever it said.
377
+ LONGEST_PROMISE = timedelta(days=7)
378
+
379
+
380
+ def promise(next_at: datetime | None, written: datetime) -> datetime | None:
381
+ """When the loop said it is back, capped: a `next_at` in 2099 must not hide a dead loop forever."""
382
+ if next_at is None:
383
+ return None
384
+ return min(next_at, written + LONGEST_PROMISE)
385
+
386
+
387
+ def ended_status(ended: LoopStopped) -> Status:
388
+ if ended.stop_reason in CAP_REASONS:
389
+ return "capped"
390
+ if ended.stop_reason == "goal_met":
391
+ return "done"
392
+ if ended.stop_reason in ("error", "impossible"):
393
+ return "failed"
394
+ return "stopped"
395
+
396
+
397
+ def needs_person(rounds: tuple[RoundState, ...], inbox: list[InboxEntry]) -> OpenQuestion | None:
398
+ """A step of the last round that failed in a way only the person can fix, until they reply."""
399
+ if not rounds:
400
+ return None
401
+ for run in reversed(rounds[-1].runs):
402
+ if run.error is None or run.error.kind != "needs_person" or run.finished is None:
403
+ continue
404
+ if any(entry.kind == "reply" and entry.ts >= run.finished for entry in inbox):
405
+ return None
406
+ text = f"{run.step}{f' ({run.label})' if run.label else ''}: {run.error.text or run.summary}"
407
+ return OpenQuestion(text, run.finished, rounds[-1].number, blocks=True, needs_person=True)
408
+ return None
409
+
410
+
411
+ def apply_to_round(
412
+ round_: RoundState, event: StepStarted | StepFinished | FindingClosed | RoundFinished
413
+ ) -> RoundState:
414
+ if isinstance(event, RoundFinished):
415
+ return replace(
416
+ round_,
417
+ finished=event.when,
418
+ verdict=event.verdict,
419
+ stop_reason=event.stop_reason,
420
+ summary=event.summary,
421
+ next=event.next,
422
+ commit=event.commit,
423
+ verdict_artifact=event.artifact,
424
+ round_cost=event.cost,
425
+ metrics=tuple(event.metrics),
426
+ outcome=event.outcome,
427
+ next_at=event.next_at,
428
+ )
429
+ if isinstance(event, FindingClosed):
430
+ return replace(round_, runs=tuple(close_finding(run, event) for run in round_.runs))
431
+
432
+ index = next(
433
+ (i for i, run in enumerate(round_.runs) if run.step == event.step and run.label == event.label), None
434
+ )
435
+ if index is None and isinstance(event, StepFinished) and event.label:
436
+ # Started without a label, finished with one: the same run, not a second one left running.
437
+ index = next(
438
+ (
439
+ i
440
+ for i, run in enumerate(round_.runs)
441
+ if run.step == event.step and not run.label and run.status == "running"
442
+ ),
443
+ None,
444
+ )
445
+ if isinstance(event, StepStarted):
446
+ started = StepRun(event.step, event.label, started=event.when)
447
+ runs = (*round_.runs, started) if index is None else replace_at(round_.runs, index, started)
448
+ return replace(round_, runs=runs)
449
+ before = round_.runs[index] if index is not None else StepRun(event.step, event.label)
450
+ finished = replace(
451
+ before,
452
+ label=event.label,
453
+ status=event.status,
454
+ finished=event.when,
455
+ summary=event.summary,
456
+ artifact=event.artifact,
457
+ findings=tuple(FindingState(f.id, f.text, f.severity) for f in event.findings),
458
+ cost=event.cost,
459
+ metrics=tuple(event.metrics),
460
+ attempt=event.attempt,
461
+ error=event.error,
462
+ session=event.session,
463
+ model=event.model,
464
+ )
465
+ runs = (*round_.runs, finished) if index is None else replace_at(round_.runs, index, finished)
466
+ return replace(round_, runs=runs)
467
+
468
+
469
+ def close_finding(run: StepRun, event: FindingClosed) -> StepRun:
470
+ """Close the finding in the step's run with the event's label, or in all its runs when none is given."""
471
+ if run.step != event.step or (event.label and run.label != event.label):
472
+ return run
473
+ findings = tuple(
474
+ replace(finding, closed=True, resolution=event.resolution, reason=event.reason)
475
+ if finding.id == event.id
476
+ else finding
477
+ for finding in run.findings
478
+ )
479
+ return replace(run, findings=findings)
480
+
481
+
482
+ def replace_at(runs: tuple[StepRun, ...], index: int, run: StepRun) -> tuple[StepRun, ...]:
483
+ return (*runs[:index], run, *runs[index + 1 :])
484
+
485
+
486
+ def open_question(last: Question | None, inbox: list[InboxEntry], at: datetime) -> OpenQuestion | None:
487
+ """The loop's last question, unless the person has replied since, or the loop stopped waiting.
488
+
489
+ A blocking question with a timeout is over once the timeout has passed: the loop went on.
490
+ """
491
+ if last is None:
492
+ return None
493
+ if any(entry.kind == "reply" and entry.ts >= last.ts for entry in inbox):
494
+ return None
495
+ question = OpenQuestion(
496
+ last.text, last.ts, last.round, last.blocks, last.timeout_s, last.id, tuple(last.options)
497
+ )
498
+ return None if question.gives_up and question.gives_up < at else question
499
+
500
+
501
+ def status_of(
502
+ manifest: Manifest,
503
+ rounds: tuple[RoundState, ...],
504
+ question: OpenQuestion | None,
505
+ last_event: datetime | None,
506
+ at: datetime,
507
+ process_alive: bool,
508
+ expected: datetime | None = None,
509
+ ) -> Status:
510
+ if not rounds:
511
+ return "running" if process_alive else "new"
512
+ last = rounds[-1]
513
+ # Silence is fine until the loop's own word for when it is back, and for a while after any line.
514
+ promised = expected is not None and at <= expected + STALE_AFTER
515
+ if last.running:
516
+ # A round under way waits only where the loop said it stands still, or where it has gone
517
+ # silent with a question open. A question the loop asked in passing does not stop it.
518
+ fresh = process_alive or promised or (last_event is not None and at - last_event <= STALE_AFTER)
519
+ if question is not None and (question.blocks or not fresh):
520
+ return "waiting"
521
+ return "running" if fresh else "stalled"
522
+ if process_alive:
523
+ return "running"
524
+ if last.verdict == "done":
525
+ return "done"
526
+ if last.verdict == "failed":
527
+ return "failed"
528
+ if question is not None or last.verdict == "wait":
529
+ return "waiting"
530
+ if last.stop_reason in CAP_REASONS:
531
+ return "capped"
532
+ if last.verdict == "stopped":
533
+ return "stopped"
534
+ if cap_reached(manifest, rounds):
535
+ return "capped"
536
+ if expected is not None:
537
+ return "sleeping" if promised else "stalled"
538
+ return "idle"
539
+
540
+
541
+ def cap_reached(manifest: Manifest, rounds: tuple[RoundState, ...]) -> str:
542
+ """Which of the loop's own limits its finished rounds have reached, in words; "" for none."""
543
+ if manifest.round_cap is not None and len(rounds) and rounds[-1].number >= manifest.round_cap:
544
+ return f"{manifest.round_cap} rounds"
545
+ if (
546
+ manifest.runtime_cap_minutes is not None
547
+ and sum(r.minutes or 0 for r in rounds) >= manifest.runtime_cap_minutes
548
+ ):
549
+ return f"{manifest.runtime_cap_minutes} minutes"
550
+ if manifest.cost_cap_usd is not None and sum(r.cost_usd for r in rounds) >= manifest.cost_cap_usd:
551
+ return f"${manifest.cost_cap_usd:,.2f}"
552
+ return ""
553
+
554
+
555
+ #: How many finished rounds a measure may stay flat, or open findings fail to fall, before it shows.
556
+ FLAT_ROUNDS = 3
557
+
558
+
559
+ def no_progress_signs(state: LoopState) -> list[str]:
560
+ """The signs that a loop is going round without getting closer, in words; empty when it moves.
561
+
562
+ The board shows them before the loop says `no_progress` itself, by the rules the guide names:
563
+ the same material findings open two rounds running, a measure that has not moved for
564
+ FLAT_ROUNDS rounds, open material findings that did not fall over FLAT_ROUNDS rounds.
565
+ """
566
+ if state.status in ("done", "stopped", "new"):
567
+ return []
568
+ finished = [r for r in state.rounds if not r.running and r.outcome != "discarded"]
569
+ signs: list[str] = []
570
+
571
+ def open_material(round_: RoundState) -> frozenset[str]:
572
+ return frozenset(f.text for run in round_.runs for f in run.open_findings if f.severity == "material")
573
+
574
+ if len(finished) >= 2:
575
+ before, last = open_material(finished[-2]), open_material(finished[-1])
576
+ if last and last == before:
577
+ signs.append(
578
+ f"the same {len(last)} material finding{'s' if len(last) != 1 else ''} stayed open "
579
+ f"in rounds {finished[-2].number} and {finished[-1].number}"
580
+ )
581
+ recent = finished[-FLAT_ROUNDS:]
582
+ if len(recent) == FLAT_ROUNDS:
583
+ counts = [len(open_material(r)) for r in recent]
584
+ if counts[0] and counts[-1] >= counts[0] and not any(s.startswith("the same") for s in signs):
585
+ signs.append(
586
+ f"open material findings did not fall over rounds {recent[0].number} to {recent[-1].number}"
587
+ )
588
+ names = {m.name for r in recent for m in r.all_metrics}
589
+ for name in sorted(names):
590
+ values = [next((m for m in r.all_metrics if m.name == name), None) for r in recent]
591
+ if all(v is not None for v in values) and len({v.value for v in values if v}) == 1:
592
+ last_metric = values[-1]
593
+ if last_metric is not None and last_metric.met is not True:
594
+ signs.append(f"{name} has not moved for {FLAT_ROUNDS} rounds")
595
+ return signs