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.
- loopcaptain/__init__.py +0 -0
- loopcaptain/agents.py +162 -0
- loopcaptain/board/AGENTS.md +39 -0
- loopcaptain/board/__init__.py +0 -0
- loopcaptain/board/app.py +430 -0
- loopcaptain/board/discover.py +249 -0
- loopcaptain/board/files.py +94 -0
- loopcaptain/board/filters.py +48 -0
- loopcaptain/board/graph.py +139 -0
- loopcaptain/board/overview.py +59 -0
- loopcaptain/board/static/board.js +41 -0
- loopcaptain/board/static/fonts/OFL.txt +94 -0
- loopcaptain/board/static/fonts/inter-latin-wght-normal.woff2 +0 -0
- loopcaptain/board/static/fonts/inter-tight-latin-wght-normal.woff2 +0 -0
- loopcaptain/board/static/mark.svg +5 -0
- loopcaptain/board/static/style.css +425 -0
- loopcaptain/board/steer.py +207 -0
- loopcaptain/board/templates/base.html +27 -0
- loopcaptain/board/templates/connect.html +35 -0
- loopcaptain/board/templates/file.html +9 -0
- loopcaptain/board/templates/loop.html +357 -0
- loopcaptain/board/templates/overview.html +108 -0
- loopcaptain/board/templates/unreadable.html +17 -0
- loopcaptain/board/trend.py +185 -0
- loopcaptain/cli.py +204 -0
- loopcaptain/demo/__init__.py +59 -0
- loopcaptain/demo/loop.py +311 -0
- loopcaptain/demo/loops.py +76 -0
- loopcaptain/mcp_server/AGENTS.md +23 -0
- loopcaptain/mcp_server/__init__.py +0 -0
- loopcaptain/mcp_server/server.py +181 -0
- loopcaptain/prompt_texts/build-a-loop.md +6 -0
- loopcaptain/prompt_texts/watch-my-loops.md +6 -0
- loopcaptain/prompts.py +27 -0
- loopcaptain/protocol/AGENTS.md +24 -0
- loopcaptain/protocol/LOOPS.md +402 -0
- loopcaptain/protocol/__init__.py +17 -0
- loopcaptain/protocol/doctor.py +79 -0
- loopcaptain/protocol/models.py +496 -0
- loopcaptain/protocol/signing.py +105 -0
- loopcaptain/protocol/state.py +595 -0
- loopcaptain/protocol/store.py +275 -0
- loopcaptain/protocol/validate.py +199 -0
- loopcaptain-0.3.1.dist-info/METADATA +220 -0
- loopcaptain-0.3.1.dist-info/RECORD +50 -0
- loopcaptain-0.3.1.dist-info/WHEEL +4 -0
- loopcaptain-0.3.1.dist-info/entry_points.txt +2 -0
- loopcaptain-0.3.1.dist-info/licenses/LICENSE +202 -0
- loopcaptain-0.3.1.dist-info/licenses/NOTICE +11 -0
- 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
|