syncmill 0.2.0__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.
- syncmill/__init__.py +11 -0
- syncmill/agents.py +2064 -0
- syncmill/artifact_io.py +201 -0
- syncmill/artifacts.py +1946 -0
- syncmill/board.py +2060 -0
- syncmill/breaker.py +327 -0
- syncmill/cli.py +4986 -0
- syncmill/command_errors.py +32 -0
- syncmill/config.py +1118 -0
- syncmill/dashboard.py +935 -0
- syncmill/gates.py +179 -0
- syncmill/gateway.py +234 -0
- syncmill/git_env.py +99 -0
- syncmill/isolated_session.py +431 -0
- syncmill/matcher.py +127 -0
- syncmill/memory.py +1067 -0
- syncmill/memory_backend.py +1907 -0
- syncmill/memory_migration.py +962 -0
- syncmill/model_routing.py +482 -0
- syncmill/models.py +244 -0
- syncmill/monitor.py +775 -0
- syncmill/phoenix_exporter.py +339 -0
- syncmill/planning.py +512 -0
- syncmill/platform_support.py +14 -0
- syncmill/preflight.py +1129 -0
- syncmill/project.py +1067 -0
- syncmill/report.py +502 -0
- syncmill/review_candidates.py +107 -0
- syncmill/room_compare.py +2380 -0
- syncmill/room_raw.py +484 -0
- syncmill/room_viewer.py +604 -0
- syncmill/rooms.py +4044 -0
- syncmill/session.py +444 -0
- syncmill/strategies/__init__.py +82 -0
- syncmill/strategies/base.py +910 -0
- syncmill/strategies/compete.py +233 -0
- syncmill/strategies/council.py +429 -0
- syncmill/strategies/decompose.py +570 -0
- syncmill/strategies/pipeline.py +221 -0
- syncmill/strategies/route.py +130 -0
- syncmill/supervisor.py +1423 -0
- syncmill/teamfile.py +571 -0
- syncmill/telemetry_hook.py +231 -0
- syncmill/terminal.py +245 -0
- syncmill/tracer.py +548 -0
- syncmill/tui.py +3537 -0
- syncmill/tui_collaboration.py +499 -0
- syncmill/tui_jobs.py +1407 -0
- syncmill/tui_render.py +156 -0
- syncmill/workflow.py +3746 -0
- syncmill/worktree.py +1045 -0
- syncmill-0.2.0.dist-info/METADATA +853 -0
- syncmill-0.2.0.dist-info/RECORD +56 -0
- syncmill-0.2.0.dist-info/WHEEL +4 -0
- syncmill-0.2.0.dist-info/entry_points.txt +2 -0
- syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
syncmill/room_compare.py
ADDED
|
@@ -0,0 +1,2380 @@
|
|
|
1
|
+
"""Read-only comparison of room candidates (#172, PR 1).
|
|
2
|
+
|
|
3
|
+
One read model, :func:`build_room_comparison`, feeds every surface: the
|
|
4
|
+
``room compare`` text table, its ``--json`` form, and (later) the TUI table,
|
|
5
|
+
candidate Details and review-summary export. The model only reports what is
|
|
6
|
+
recorded and never upgrades an absence into a verdict:
|
|
7
|
+
|
|
8
|
+
- **The round is the comparison unit.** A room keeps one source fingerprint
|
|
9
|
+
(a change makes the room stale) and ``run_round`` builds one gate per round,
|
|
10
|
+
so the candidates of one round share their source and gate settings by
|
|
11
|
+
construction. Round 1 always starts from a fresh snapshot; the lineage of
|
|
12
|
+
later rounds is not persisted, so their starting tree reads ``not recorded``.
|
|
13
|
+
- **A missing gate report is not a pass, and missing usage is not zero.** A
|
|
14
|
+
manifest is mutable display data, so its gate is read only from a recognized
|
|
15
|
+
record and cross-checked against the authoritative SQLite ``eligible``
|
|
16
|
+
column; a contradiction reads ``unknown`` instead of being believed.
|
|
17
|
+
- **Damage stays visible.** A candidate whose artifact fails its bindings or
|
|
18
|
+
digest keeps its row (and an apply blocker); a member with no candidate is
|
|
19
|
+
listed with whatever progress evidence exists.
|
|
20
|
+
|
|
21
|
+
Nothing here writes: the caller owns the open :class:`RoomStore`. A flat
|
|
22
|
+
top-level module; it imports ``rooms`` and ``artifacts`` and nothing imports it
|
|
23
|
+
back except the CLI.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import math
|
|
29
|
+
import time
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
from dataclasses import dataclass, field, replace
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from syncmill.artifacts import printable_name
|
|
35
|
+
from syncmill.models import TokenUsage
|
|
36
|
+
from syncmill.rooms import (
|
|
37
|
+
_ROOM_CANDIDATE_CONTEXT_MAX_BYTES,
|
|
38
|
+
_SELECTABLE_ROOM_STATES,
|
|
39
|
+
ProvenanceCheck,
|
|
40
|
+
RoomError,
|
|
41
|
+
RoomStore,
|
|
42
|
+
SelectionCheck,
|
|
43
|
+
round_conversation_status,
|
|
44
|
+
round_provenance_status,
|
|
45
|
+
selection_record_status,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
SCHEMA_VERSION = 1
|
|
49
|
+
KIND = "syncmill.room-compare"
|
|
50
|
+
|
|
51
|
+
#: A patch above this size is flagged ``large`` (display only).
|
|
52
|
+
LARGE_PATCH_BYTES = 1024 * 1024
|
|
53
|
+
|
|
54
|
+
_ATTEMPT_STATES = frozenset({"completed", "failed", "timeout"})
|
|
55
|
+
_IN_PROGRESS_MEMBER_STATES = frozenset({"queued", "running"})
|
|
56
|
+
|
|
57
|
+
#: The same union the TUI room poll and ``room candidates`` treat as a damaged
|
|
58
|
+
#: record rather than a crash.
|
|
59
|
+
_ROW_ERRORS = (RoomError, KeyError, TypeError, AttributeError, OSError, ValueError)
|
|
60
|
+
|
|
61
|
+
SourceCheck = Callable[[], str]
|
|
62
|
+
|
|
63
|
+
_UNDECIDABLE_REASON = {
|
|
64
|
+
"no_report": "no gate report recorded",
|
|
65
|
+
"unknown": "no completed candidate with a usable record",
|
|
66
|
+
"mixed": "gate records disagree within the round",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class UnknownRoundError(RoomError):
|
|
71
|
+
"""The requested round number does not exist in the room (a bad argument,
|
|
72
|
+
not a damaged store)."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class GateCheckView:
|
|
77
|
+
command: str
|
|
78
|
+
passed: bool
|
|
79
|
+
exit_code: int | None = None
|
|
80
|
+
duration_seconds: float | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class GateView:
|
|
85
|
+
#: passed | failed | not_run | no_report | unknown
|
|
86
|
+
verdict: str
|
|
87
|
+
reason: str | None = None
|
|
88
|
+
checks: tuple[GateCheckView, ...] = ()
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def commands(self) -> tuple[str, ...]:
|
|
92
|
+
return tuple(check.command for check in self.checks)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class CandidateRow:
|
|
97
|
+
candidate_id: str
|
|
98
|
+
member_id: str
|
|
99
|
+
provider_id: str
|
|
100
|
+
#: None only on a damaged row whose stored attempt is malformed.
|
|
101
|
+
attempt: int | None
|
|
102
|
+
state: str
|
|
103
|
+
eligible: bool
|
|
104
|
+
selected: bool
|
|
105
|
+
patch_sha256: str
|
|
106
|
+
#: Display only: a per-snapshot synthetic commit that can differ between
|
|
107
|
+
#: rounds with identical source, so never a comparability key.
|
|
108
|
+
base_commit: str
|
|
109
|
+
readable: bool
|
|
110
|
+
unreadable_reason: str | None
|
|
111
|
+
gate: GateView
|
|
112
|
+
duration_seconds: float | None
|
|
113
|
+
usage: TokenUsage | None
|
|
114
|
+
#: None = the file set is unknown (unreadable or malformed), never "empty".
|
|
115
|
+
files_changed: tuple[str, ...] | None
|
|
116
|
+
patch_bytes: int | None
|
|
117
|
+
patch_binary: bool | None
|
|
118
|
+
patch_large: bool | None
|
|
119
|
+
not_eligible_reason: str | None
|
|
120
|
+
apply_blockers: tuple[str, ...]
|
|
121
|
+
warnings: tuple[str, ...]
|
|
122
|
+
# Display metadata for the candidate Details view. Identifiers and numbers
|
|
123
|
+
# reach the JSON; ``error`` does not (it can quote agent output).
|
|
124
|
+
created_at: str | None = None
|
|
125
|
+
output_sha256: str | None = None
|
|
126
|
+
exit_code: int | None = None
|
|
127
|
+
output_bytes: int | None = None
|
|
128
|
+
output_truncated: bool | None = None
|
|
129
|
+
provider_session_id: str | None = None
|
|
130
|
+
error: str | None = None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class MissingMember:
|
|
135
|
+
member_id: str
|
|
136
|
+
provider_id: str
|
|
137
|
+
#: in_progress | no_candidate
|
|
138
|
+
status: str
|
|
139
|
+
evidence: tuple[str, ...]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class GateProfile:
|
|
144
|
+
#: commands | no_report | unknown | mixed
|
|
145
|
+
kind: str
|
|
146
|
+
commands: tuple[str, ...] = ()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class FileOverlap:
|
|
151
|
+
compared: tuple[str, ...]
|
|
152
|
+
common: tuple[str, ...]
|
|
153
|
+
only: dict[str, tuple[str, ...]]
|
|
154
|
+
partial: dict[str, tuple[str, ...]]
|
|
155
|
+
no_changes: tuple[str, ...]
|
|
156
|
+
unknown: tuple[str, ...]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class LineageView:
|
|
161
|
+
"""A round's recorded effective starting tree (``rounds.provenance_json``).
|
|
162
|
+
|
|
163
|
+
Values are shown only when the record is ``recorded``: a malformed record
|
|
164
|
+
is reported by its reason (a round warning), never believed."""
|
|
165
|
+
|
|
166
|
+
#: recorded | not_recorded | malformed
|
|
167
|
+
status: str
|
|
168
|
+
reason: str | None = None
|
|
169
|
+
#: the lineage the user asked for: continue-own | fresh | branch-from
|
|
170
|
+
requested: str | None = None
|
|
171
|
+
#: snapshot | branch | continued
|
|
172
|
+
tree: str | None = None
|
|
173
|
+
base_candidate_id: str | None = None
|
|
174
|
+
base_patch_sha256: str | None = None
|
|
175
|
+
base_round_number: int | None = None
|
|
176
|
+
#: continued only: member id -> (parent candidate id, parent round), or
|
|
177
|
+
#: None when the previous round held no single candidate for the member.
|
|
178
|
+
parents: dict[str, tuple[str, int] | None] = field(default_factory=dict)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True)
|
|
182
|
+
class GateSettingsView:
|
|
183
|
+
"""The gate settings a round recorded, kept apart from the gate REPORTS
|
|
184
|
+
(what actually ran) and cross-checked against them."""
|
|
185
|
+
|
|
186
|
+
#: recorded | not_recorded | malformed
|
|
187
|
+
status: str
|
|
188
|
+
#: commands | none (recorded only)
|
|
189
|
+
kind: str | None = None
|
|
190
|
+
commands: tuple[str, ...] = ()
|
|
191
|
+
timeout_seconds: int | None = None
|
|
192
|
+
#: consistent | contradicted | indeterminate | n/a
|
|
193
|
+
consistency: str = "n/a"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(frozen=True)
|
|
197
|
+
class MemberConversation:
|
|
198
|
+
#: cold | resume | fork — what the provider was handed, not what it did
|
|
199
|
+
session: str
|
|
200
|
+
#: full | delta
|
|
201
|
+
context: str
|
|
202
|
+
since_round: int | None
|
|
203
|
+
#: candidate ids whose output excerpts entered the prompt, oldest first
|
|
204
|
+
candidates: tuple[str, ...]
|
|
205
|
+
#: older eligible candidates were left out for the prompt size limit
|
|
206
|
+
omitted: bool
|
|
207
|
+
#: included | dropped | none
|
|
208
|
+
memory: str
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass(frozen=True)
|
|
212
|
+
class ConversationView:
|
|
213
|
+
"""A round's recorded per-member conversations
|
|
214
|
+
(``rounds.conversation_json``); values only when ``recorded``."""
|
|
215
|
+
|
|
216
|
+
#: recorded | not_recorded | malformed
|
|
217
|
+
status: str
|
|
218
|
+
reason: str | None = None
|
|
219
|
+
members: dict[str, MemberConversation] = field(default_factory=dict)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass(frozen=True)
|
|
223
|
+
class RoundGroup:
|
|
224
|
+
number: int
|
|
225
|
+
round_id: str
|
|
226
|
+
state: str
|
|
227
|
+
#: Report-era v1 field, unchanged: ``snapshot`` for round 1, else
|
|
228
|
+
#: ``not_recorded``. The recorded tree is :attr:`lineage`.
|
|
229
|
+
starting_tree: str
|
|
230
|
+
#: Report-derived (what the candidates' gate reports show); the recorded
|
|
231
|
+
#: configuration is :attr:`gate_settings`.
|
|
232
|
+
gate_profile: GateProfile
|
|
233
|
+
rows: tuple[CandidateRow, ...]
|
|
234
|
+
missing: tuple[MissingMember, ...]
|
|
235
|
+
overlap: FileOverlap
|
|
236
|
+
warnings: tuple[str, ...]
|
|
237
|
+
lineage: LineageView = field(default_factory=lambda: LineageView("not_recorded"))
|
|
238
|
+
gate_settings: GateSettingsView = field(
|
|
239
|
+
default_factory=lambda: GateSettingsView("not_recorded")
|
|
240
|
+
)
|
|
241
|
+
conversation: ConversationView = field(
|
|
242
|
+
default_factory=lambda: ConversationView("not_recorded")
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass(frozen=True)
|
|
247
|
+
class SelectionView:
|
|
248
|
+
"""The room's current human selection and how its record holds up.
|
|
249
|
+
|
|
250
|
+
Record values are shown only when the record is ``bound``: a mismatched
|
|
251
|
+
record is reported by its reason, never believed."""
|
|
252
|
+
|
|
253
|
+
#: bound | mismatch | not_recorded
|
|
254
|
+
status: str
|
|
255
|
+
candidate_id: str | None
|
|
256
|
+
reason: str | None = None
|
|
257
|
+
patch_sha256: str | None = None
|
|
258
|
+
confirmed_revision: int | None = None
|
|
259
|
+
revision: int | None = None
|
|
260
|
+
rationale: str | None = None
|
|
261
|
+
surface: str | None = None
|
|
262
|
+
selected_at: str | None = None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass(frozen=True)
|
|
266
|
+
class RoomComparison:
|
|
267
|
+
room_id: str
|
|
268
|
+
room_state: str
|
|
269
|
+
revision: int
|
|
270
|
+
selected_candidate_id: str | None
|
|
271
|
+
#: current | changed | unavailable | not_recorded
|
|
272
|
+
source_status: str
|
|
273
|
+
source_reason: str | None
|
|
274
|
+
round_count: int
|
|
275
|
+
groups: tuple[RoundGroup, ...]
|
|
276
|
+
#: Only with ``all_rounds``: rounds grouped by an equal gate profile.
|
|
277
|
+
cross_round: tuple[dict[str, Any], ...] = field(default=())
|
|
278
|
+
#: Only with ``all_rounds``: rounds grouped by equal RECORDED gate settings
|
|
279
|
+
#: (commands and timeout); rounds without a usable record stand alone.
|
|
280
|
+
cross_round_settings: tuple[dict[str, Any], ...] = field(default=())
|
|
281
|
+
#: Rounds whose own record could not be projected; always listed, in every
|
|
282
|
+
#: mode, so damage is never presented as absence.
|
|
283
|
+
damaged_rounds: tuple[dict[str, str], ...] = field(default=())
|
|
284
|
+
#: None when nothing is selected and no selection record exists.
|
|
285
|
+
selection: SelectionView | None = None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _is_int(value: object) -> bool:
|
|
289
|
+
return type(value) is int # bool is an int subclass; True must not pass for 1
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _is_number(value: object) -> bool:
|
|
293
|
+
return type(value) in (int, float)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _str(value: object) -> str | None:
|
|
297
|
+
return value if isinstance(value, str) else None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _count(value: object) -> int | None:
|
|
301
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
302
|
+
return None
|
|
303
|
+
return value
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _seconds(value: object) -> float | None:
|
|
307
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
308
|
+
return None
|
|
309
|
+
try:
|
|
310
|
+
if not math.isfinite(value) or value < 0:
|
|
311
|
+
return None
|
|
312
|
+
except OverflowError:
|
|
313
|
+
return None
|
|
314
|
+
return float(value)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _positive(value: object) -> int | None:
|
|
318
|
+
count = _count(value)
|
|
319
|
+
return count if count else None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _attempt(value: object) -> int:
|
|
323
|
+
attempt = _positive(value)
|
|
324
|
+
if attempt is None:
|
|
325
|
+
raise ValueError(f"malformed attempt {value!r}")
|
|
326
|
+
return attempt
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _files(value: object) -> tuple[str, ...] | None:
|
|
330
|
+
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
|
331
|
+
return None
|
|
332
|
+
return tuple(sorted(set(value)))
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _parse_gate(value: object) -> tuple[bool, GateView | None]:
|
|
336
|
+
"""``(recognized, view)``; ``view`` is None for an explicit ``null`` gate.
|
|
337
|
+
|
|
338
|
+
Recognized means exactly the ``_gate_json`` shape ``RoomCandidateStore.save``
|
|
339
|
+
writes: ``passed``/``score``/``checks``, each check carrying ``command``,
|
|
340
|
+
``passed``, ``exit_code``, ``output`` and ``duration_seconds``, and a
|
|
341
|
+
``passed`` flag that agrees with the checks (GateReport.passed is "every
|
|
342
|
+
check passed").
|
|
343
|
+
"""
|
|
344
|
+
if value is None:
|
|
345
|
+
return True, None
|
|
346
|
+
if (
|
|
347
|
+
not isinstance(value, dict)
|
|
348
|
+
or type(value.get("passed")) is not bool
|
|
349
|
+
or not _is_number(value.get("score"))
|
|
350
|
+
or not isinstance(value.get("checks"), list)
|
|
351
|
+
):
|
|
352
|
+
return False, None
|
|
353
|
+
parsed: list[GateCheckView] = []
|
|
354
|
+
for check in value["checks"]:
|
|
355
|
+
if (
|
|
356
|
+
not isinstance(check, dict)
|
|
357
|
+
or not isinstance(check.get("command"), str)
|
|
358
|
+
or type(check.get("passed")) is not bool
|
|
359
|
+
or not _is_int(check.get("exit_code"))
|
|
360
|
+
or not isinstance(check.get("output"), str)
|
|
361
|
+
or not _is_number(check.get("duration_seconds"))
|
|
362
|
+
):
|
|
363
|
+
return False, None
|
|
364
|
+
parsed.append(
|
|
365
|
+
GateCheckView(
|
|
366
|
+
check["command"],
|
|
367
|
+
check["passed"],
|
|
368
|
+
exit_code=check["exit_code"],
|
|
369
|
+
duration_seconds=_seconds(check["duration_seconds"]),
|
|
370
|
+
)
|
|
371
|
+
)
|
|
372
|
+
if value["passed"] != all(check.passed for check in parsed):
|
|
373
|
+
return False, None
|
|
374
|
+
return True, GateView(
|
|
375
|
+
"passed" if value["passed"] else "failed", checks=tuple(parsed)
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _gate_verdict(
|
|
380
|
+
*,
|
|
381
|
+
state: str,
|
|
382
|
+
eligible: bool,
|
|
383
|
+
manifest: dict[str, Any] | None,
|
|
384
|
+
patch_nonempty: bool | None,
|
|
385
|
+
) -> GateView:
|
|
386
|
+
if state != "completed":
|
|
387
|
+
return GateView("not_run", "agent did not complete")
|
|
388
|
+
if manifest is None:
|
|
389
|
+
return GateView("unknown", "artifact unreadable")
|
|
390
|
+
version = manifest.get("schema_version")
|
|
391
|
+
if not (_is_int(version) and version == 1) or manifest.get("state") != state:
|
|
392
|
+
return GateView("unknown", "unrecognized record")
|
|
393
|
+
# save() always writes the key; a missing one is not an explicit null.
|
|
394
|
+
if "gate" not in manifest:
|
|
395
|
+
return GateView("unknown", "unrecognized record")
|
|
396
|
+
recognized, view = _parse_gate(manifest["gate"])
|
|
397
|
+
if not recognized:
|
|
398
|
+
return GateView("unknown", "unrecognized record")
|
|
399
|
+
# eligible = completed AND non-empty patch AND (gate null OR gate passed)
|
|
400
|
+
# (RoomCandidateStore.save). With a verified non-empty patch the stored
|
|
401
|
+
# eligibility therefore pins the gate outcome; an empty patch pins nothing.
|
|
402
|
+
if patch_nonempty:
|
|
403
|
+
gate_ok = view is None or view.verdict == "passed"
|
|
404
|
+
if gate_ok != eligible:
|
|
405
|
+
return GateView("unknown", "record contradicts stored eligibility")
|
|
406
|
+
return view if view is not None else GateView("no_report")
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
#: The manifest fields ``RoomCoordinator.apply`` converts when it builds the
|
|
410
|
+
#: run artifact, with the exact conversion it applies.
|
|
411
|
+
_APPLY_CONSUMED_FIELDS = (
|
|
412
|
+
("exit_code", int),
|
|
413
|
+
("duration_seconds", float),
|
|
414
|
+
("files_changed", list),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _apply_unusable_fields(manifest: dict[str, Any]) -> list[str]:
|
|
419
|
+
"""Fields apply would fail to convert. Mirrors apply's own conversions
|
|
420
|
+
rather than this module's stricter display validation, so a blocker is
|
|
421
|
+
reported exactly when apply would raise."""
|
|
422
|
+
bad: list[str] = []
|
|
423
|
+
for key, convert in _APPLY_CONSUMED_FIELDS:
|
|
424
|
+
try:
|
|
425
|
+
convert(manifest[key])
|
|
426
|
+
except (KeyError, TypeError, ValueError, OverflowError):
|
|
427
|
+
bad.append(key)
|
|
428
|
+
return bad
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _not_eligible_reason(
|
|
432
|
+
*, state: str, patch_nonempty: bool | None, gate: GateView
|
|
433
|
+
) -> str:
|
|
434
|
+
if state != "completed":
|
|
435
|
+
return "agent did not complete"
|
|
436
|
+
if patch_nonempty is False:
|
|
437
|
+
return "no patch captured"
|
|
438
|
+
if gate.verdict == "failed":
|
|
439
|
+
return "quality gate failed"
|
|
440
|
+
return "reason not determinable from the record"
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
@dataclass(frozen=True)
|
|
444
|
+
class GateRead:
|
|
445
|
+
"""A candidate's gate as the comparison decides it, with the bound
|
|
446
|
+
manifest the verdict was read from (None when the artifact is unreadable)."""
|
|
447
|
+
|
|
448
|
+
gate: GateView
|
|
449
|
+
manifest: dict[str, Any] | None
|
|
450
|
+
unreadable: str | None
|
|
451
|
+
patch_total: int | None
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def read_candidate_gate(
|
|
455
|
+
store: RoomStore, candidate_id: str, *, state: str, eligible: bool
|
|
456
|
+
) -> GateRead:
|
|
457
|
+
"""Verify a candidate's artifact and decide its gate: the one path both
|
|
458
|
+
the comparison rows and raw gate-output export take, so they never
|
|
459
|
+
disagree about which checks exist. ``state``/``eligible`` come from the
|
|
460
|
+
authoritative SQLite row."""
|
|
461
|
+
manifest: dict[str, Any] | None = None
|
|
462
|
+
unreadable: str | None = None
|
|
463
|
+
try:
|
|
464
|
+
manifest = store.load_candidate_patch_head(candidate_id, 0)
|
|
465
|
+
# Apply loads the whole artifact, so a missing or altered output file
|
|
466
|
+
# blocks it too. Verify the output digest without keeping the body.
|
|
467
|
+
store.load_candidate_checkpoint(candidate_id, 0)
|
|
468
|
+
except _ROW_ERRORS as exc:
|
|
469
|
+
manifest = None
|
|
470
|
+
unreadable = str(exc) or type(exc).__name__
|
|
471
|
+
|
|
472
|
+
patch_total = _count(manifest.get("patch_total_bytes")) if manifest else None
|
|
473
|
+
patch_nonempty = None if patch_total is None else patch_total > 0
|
|
474
|
+
gate = _gate_verdict(
|
|
475
|
+
state=state, eligible=eligible, manifest=manifest, patch_nonempty=patch_nonempty
|
|
476
|
+
)
|
|
477
|
+
return GateRead(gate, manifest, unreadable, patch_total)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _project_row(
|
|
481
|
+
store: RoomStore,
|
|
482
|
+
candidate: dict[str, Any],
|
|
483
|
+
*,
|
|
484
|
+
selected_id: str | None,
|
|
485
|
+
room_state: str,
|
|
486
|
+
source_status: str,
|
|
487
|
+
source_reason: str | None,
|
|
488
|
+
) -> CandidateRow:
|
|
489
|
+
candidate_id = str(candidate["id"])
|
|
490
|
+
state = str(candidate["state"])
|
|
491
|
+
eligible = bool(candidate["eligible"])
|
|
492
|
+
warnings: list[str] = []
|
|
493
|
+
read = read_candidate_gate(store, candidate_id, state=state, eligible=eligible)
|
|
494
|
+
manifest, unreadable, patch_total, gate = (
|
|
495
|
+
read.manifest, read.unreadable, read.patch_total, read.gate
|
|
496
|
+
)
|
|
497
|
+
patch_nonempty = None if patch_total is None else patch_total > 0
|
|
498
|
+
if state not in _ATTEMPT_STATES:
|
|
499
|
+
warnings.append(f"unknown attempt state {state!r}")
|
|
500
|
+
if eligible and (state != "completed" or patch_nonempty is False):
|
|
501
|
+
warnings.append("stored eligibility contradicts the recorded attempt")
|
|
502
|
+
|
|
503
|
+
files = duration = usage = binary = None
|
|
504
|
+
exit_code = output_bytes = truncated = session = error = None
|
|
505
|
+
if manifest is not None:
|
|
506
|
+
raw_exit = manifest.get("exit_code")
|
|
507
|
+
exit_code = raw_exit if _is_int(raw_exit) else None
|
|
508
|
+
output_bytes = _count(manifest.get("output_bytes"))
|
|
509
|
+
raw_truncated = manifest.get("output_truncated")
|
|
510
|
+
truncated = raw_truncated if type(raw_truncated) is bool else None
|
|
511
|
+
session = _str(manifest.get("provider_session_id"))
|
|
512
|
+
error = _str(manifest.get("error"))
|
|
513
|
+
files = _files(manifest.get("files_changed"))
|
|
514
|
+
if files is None:
|
|
515
|
+
warnings.append("files_changed is malformed; file set unknown")
|
|
516
|
+
duration = _seconds(manifest.get("duration_seconds"))
|
|
517
|
+
usage = TokenUsage.from_mapping(manifest.get("usage"))
|
|
518
|
+
raw_binary = manifest.get("patch_binary")
|
|
519
|
+
binary = raw_binary if isinstance(raw_binary, bool) else None
|
|
520
|
+
|
|
521
|
+
blockers: list[str] = []
|
|
522
|
+
if unreadable is not None:
|
|
523
|
+
blockers.append(f"artifact unreadable: {unreadable}")
|
|
524
|
+
elif manifest is not None and (unusable := _apply_unusable_fields(manifest)):
|
|
525
|
+
blockers.append(
|
|
526
|
+
"artifact metadata apply cannot use: " + ", ".join(unusable)
|
|
527
|
+
)
|
|
528
|
+
not_eligible = None
|
|
529
|
+
if not eligible:
|
|
530
|
+
not_eligible = _not_eligible_reason(
|
|
531
|
+
state=state, patch_nonempty=patch_nonempty, gate=gate
|
|
532
|
+
)
|
|
533
|
+
blockers.append(f"not eligible: {not_eligible}")
|
|
534
|
+
blockers.extend(_room_blockers(room_state, source_status, source_reason))
|
|
535
|
+
|
|
536
|
+
return CandidateRow(
|
|
537
|
+
candidate_id=candidate_id,
|
|
538
|
+
member_id=str(candidate["member_id"]),
|
|
539
|
+
provider_id=str(candidate["provider_id"]),
|
|
540
|
+
attempt=_attempt(candidate["attempt"]),
|
|
541
|
+
state=state,
|
|
542
|
+
eligible=eligible,
|
|
543
|
+
selected=candidate_id == selected_id,
|
|
544
|
+
patch_sha256=str(candidate["patch_sha256"]),
|
|
545
|
+
base_commit=str(candidate["base_commit"]),
|
|
546
|
+
readable=manifest is not None,
|
|
547
|
+
unreadable_reason=unreadable,
|
|
548
|
+
gate=gate,
|
|
549
|
+
duration_seconds=duration,
|
|
550
|
+
usage=usage,
|
|
551
|
+
files_changed=files,
|
|
552
|
+
patch_bytes=patch_total,
|
|
553
|
+
patch_binary=binary,
|
|
554
|
+
patch_large=None if patch_total is None else patch_total > LARGE_PATCH_BYTES,
|
|
555
|
+
not_eligible_reason=not_eligible,
|
|
556
|
+
apply_blockers=tuple(blockers),
|
|
557
|
+
warnings=tuple(warnings),
|
|
558
|
+
created_at=_str(candidate.get("created_at")),
|
|
559
|
+
output_sha256=_str(candidate.get("output_sha256")),
|
|
560
|
+
exit_code=exit_code,
|
|
561
|
+
output_bytes=output_bytes,
|
|
562
|
+
output_truncated=truncated,
|
|
563
|
+
provider_session_id=session,
|
|
564
|
+
error=error,
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _room_blockers(
|
|
569
|
+
room_state: str, source_status: str, source_reason: str | None
|
|
570
|
+
) -> list[str]:
|
|
571
|
+
blockers: list[str] = []
|
|
572
|
+
if source_status == "changed":
|
|
573
|
+
blockers.append("source changed since the room started")
|
|
574
|
+
elif source_status == "unavailable":
|
|
575
|
+
blockers.append(f"source check unavailable: {source_reason}")
|
|
576
|
+
if room_state not in _SELECTABLE_ROOM_STATES:
|
|
577
|
+
blockers.append(f"room is {room_state}")
|
|
578
|
+
return blockers
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _selection_view(selected_id: object, check: SelectionCheck) -> SelectionView:
|
|
582
|
+
candidate_id = selected_id if isinstance(selected_id, str) else None
|
|
583
|
+
record = check.record
|
|
584
|
+
if check.status != "bound" or record is None:
|
|
585
|
+
return SelectionView(check.status, candidate_id, check.reason)
|
|
586
|
+
return SelectionView(
|
|
587
|
+
"bound",
|
|
588
|
+
candidate_id,
|
|
589
|
+
patch_sha256=record["patch_sha256"],
|
|
590
|
+
confirmed_revision=record["confirmed_revision"],
|
|
591
|
+
revision=record["revision"],
|
|
592
|
+
rationale=record["rationale"],
|
|
593
|
+
surface=record["surface"],
|
|
594
|
+
selected_at=record["selected_at"],
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _selection_blockers(room: dict[str, Any], candidate: dict[str, Any]) -> list[str]:
|
|
599
|
+
"""Apply refuses an inconsistent selection record; say so on the selected
|
|
600
|
+
row (the same validator apply runs, on the same candidate row)."""
|
|
601
|
+
selected_id = room.get("selected_candidate_id")
|
|
602
|
+
if not selected_id or candidate.get("id") != selected_id:
|
|
603
|
+
return []
|
|
604
|
+
check = selection_record_status(
|
|
605
|
+
room.get("selection_json"),
|
|
606
|
+
selected_candidate_id=selected_id,
|
|
607
|
+
candidate_row=candidate,
|
|
608
|
+
)
|
|
609
|
+
if check.status != "mismatch":
|
|
610
|
+
return []
|
|
611
|
+
return [f"selection record inconsistent: {check.reason}; select it again"]
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _damaged_row(
|
|
615
|
+
candidate: dict[str, Any], reason: str, *, selected_id: str | None, room_blockers: list[str]
|
|
616
|
+
) -> CandidateRow:
|
|
617
|
+
"""A candidate whose own SQLite row could not be projected. Every field is
|
|
618
|
+
taken defensively; the row stays in the comparison as unreadable."""
|
|
619
|
+
candidate_id = str(candidate.get("id"))
|
|
620
|
+
stored = candidate.get("eligible")
|
|
621
|
+
warnings = () if stored in (0, 1) else ("stored eligibility is unreadable",)
|
|
622
|
+
return CandidateRow(
|
|
623
|
+
candidate_id=candidate_id,
|
|
624
|
+
member_id=str(candidate.get("member_id")),
|
|
625
|
+
provider_id=str(candidate.get("provider_id")),
|
|
626
|
+
attempt=_positive(candidate.get("attempt")),
|
|
627
|
+
state=str(candidate.get("state")),
|
|
628
|
+
# Keep the stored verdict when it is readable; the unreadable blocker
|
|
629
|
+
# below already prevents it from reading as ready.
|
|
630
|
+
eligible=stored == 1 and not isinstance(stored, bool),
|
|
631
|
+
selected=candidate_id == selected_id,
|
|
632
|
+
patch_sha256=str(candidate.get("patch_sha256")),
|
|
633
|
+
base_commit=str(candidate.get("base_commit")),
|
|
634
|
+
readable=False,
|
|
635
|
+
unreadable_reason=f"candidate record unreadable: {reason}",
|
|
636
|
+
gate=GateView("unknown", "candidate record unreadable"),
|
|
637
|
+
duration_seconds=None,
|
|
638
|
+
usage=None,
|
|
639
|
+
files_changed=None,
|
|
640
|
+
patch_bytes=None,
|
|
641
|
+
patch_binary=None,
|
|
642
|
+
patch_large=None,
|
|
643
|
+
not_eligible_reason=None,
|
|
644
|
+
apply_blockers=(f"artifact unreadable: candidate record unreadable: {reason}", *room_blockers),
|
|
645
|
+
warnings=warnings,
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def project_candidate(
|
|
650
|
+
store: RoomStore,
|
|
651
|
+
candidate: dict[str, Any],
|
|
652
|
+
*,
|
|
653
|
+
room: dict[str, Any],
|
|
654
|
+
source_status: str,
|
|
655
|
+
source_reason: str | None,
|
|
656
|
+
) -> CandidateRow:
|
|
657
|
+
"""One candidate as a :class:`CandidateRow` — the single projection the
|
|
658
|
+
CLI, the TUI Compare view and candidate Details all read. Never raises for
|
|
659
|
+
damage inside the candidate's own record: that becomes an unreadable row."""
|
|
660
|
+
room_state = str(room["state"])
|
|
661
|
+
selected_id = room.get("selected_candidate_id")
|
|
662
|
+
try:
|
|
663
|
+
row = _project_row(
|
|
664
|
+
store,
|
|
665
|
+
candidate,
|
|
666
|
+
selected_id=selected_id,
|
|
667
|
+
room_state=room_state,
|
|
668
|
+
source_status=source_status,
|
|
669
|
+
source_reason=source_reason,
|
|
670
|
+
)
|
|
671
|
+
except _ROW_ERRORS as exc:
|
|
672
|
+
row = _damaged_row(
|
|
673
|
+
candidate,
|
|
674
|
+
str(exc) or type(exc).__name__,
|
|
675
|
+
selected_id=selected_id,
|
|
676
|
+
room_blockers=_room_blockers(room_state, source_status, source_reason),
|
|
677
|
+
)
|
|
678
|
+
if blockers := _selection_blockers(room, candidate):
|
|
679
|
+
row = replace(row, apply_blockers=(*row.apply_blockers, *blockers))
|
|
680
|
+
return row
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _gate_profile(rows: list[CandidateRow]) -> tuple[GateProfile, list[str]]:
|
|
684
|
+
usable = [r for r in rows if r.state == "completed" and r.gate.verdict != "unknown"]
|
|
685
|
+
if not usable:
|
|
686
|
+
return GateProfile("unknown"), []
|
|
687
|
+
gated = [r for r in usable if r.gate.verdict in {"passed", "failed"}]
|
|
688
|
+
report_less = [r for r in usable if r.gate.verdict == "no_report"]
|
|
689
|
+
command_sets = {r.gate.commands for r in gated}
|
|
690
|
+
if (gated and report_less) or len(command_sets) > 1:
|
|
691
|
+
# One gate is built per round, so this only arises from a damaged or
|
|
692
|
+
# edited record.
|
|
693
|
+
names = ", ".join(r.candidate_id for r in usable)
|
|
694
|
+
return GateProfile("mixed"), [
|
|
695
|
+
f"gate records disagree within the round (candidates: {names})"
|
|
696
|
+
]
|
|
697
|
+
if gated:
|
|
698
|
+
return GateProfile("commands", next(iter(command_sets))), []
|
|
699
|
+
return GateProfile("no_report"), []
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _provenance_views(
|
|
703
|
+
check: ProvenanceCheck, rows: list[CandidateRow]
|
|
704
|
+
) -> tuple[LineageView, GateSettingsView, list[str]]:
|
|
705
|
+
if check.status != "recorded" or check.record is None:
|
|
706
|
+
warnings = (
|
|
707
|
+
[f"round provenance record unreadable: {check.reason}"]
|
|
708
|
+
if check.status == "malformed"
|
|
709
|
+
else []
|
|
710
|
+
)
|
|
711
|
+
return (
|
|
712
|
+
LineageView(check.status, reason=check.reason),
|
|
713
|
+
GateSettingsView(check.status),
|
|
714
|
+
warnings,
|
|
715
|
+
)
|
|
716
|
+
record = check.record
|
|
717
|
+
tree = record["tree"]
|
|
718
|
+
parents: dict[str, tuple[str, int] | None] = {}
|
|
719
|
+
if tree["kind"] == "continued":
|
|
720
|
+
parents = {
|
|
721
|
+
member_id: (
|
|
722
|
+
None
|
|
723
|
+
if parent is None
|
|
724
|
+
else (parent["parent_candidate_id"], parent["parent_round_number"])
|
|
725
|
+
)
|
|
726
|
+
for member_id, parent in tree["members"].items()
|
|
727
|
+
}
|
|
728
|
+
lineage = LineageView(
|
|
729
|
+
"recorded",
|
|
730
|
+
requested=record["requested_lineage"],
|
|
731
|
+
tree=tree["kind"],
|
|
732
|
+
base_candidate_id=tree.get("base_candidate_id"),
|
|
733
|
+
base_patch_sha256=tree.get("base_patch_sha256"),
|
|
734
|
+
base_round_number=tree.get("base_round_number"),
|
|
735
|
+
parents=parents,
|
|
736
|
+
)
|
|
737
|
+
gate = record["gate"]
|
|
738
|
+
commands = tuple(gate.get("commands", ()))
|
|
739
|
+
consistency, warnings = _gate_consistency(gate["kind"], commands, rows)
|
|
740
|
+
settings = GateSettingsView(
|
|
741
|
+
"recorded",
|
|
742
|
+
kind=gate["kind"],
|
|
743
|
+
commands=commands,
|
|
744
|
+
timeout_seconds=gate.get("timeout_seconds"),
|
|
745
|
+
consistency=consistency,
|
|
746
|
+
)
|
|
747
|
+
return lineage, settings, warnings
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _conversation_view(check: ProvenanceCheck) -> tuple[ConversationView, list[str]]:
|
|
751
|
+
if check.status != "recorded" or check.record is None:
|
|
752
|
+
warnings = (
|
|
753
|
+
[f"round conversation record unreadable: {check.reason}"]
|
|
754
|
+
if check.status == "malformed"
|
|
755
|
+
else []
|
|
756
|
+
)
|
|
757
|
+
return ConversationView(check.status, reason=check.reason), warnings
|
|
758
|
+
return (
|
|
759
|
+
ConversationView(
|
|
760
|
+
"recorded",
|
|
761
|
+
members={
|
|
762
|
+
member_id: MemberConversation(
|
|
763
|
+
session=entry["session"],
|
|
764
|
+
context=entry["context"],
|
|
765
|
+
since_round=entry["since_round"],
|
|
766
|
+
candidates=tuple(entry["candidates"]),
|
|
767
|
+
omitted=entry["omitted"],
|
|
768
|
+
memory=entry["memory"],
|
|
769
|
+
)
|
|
770
|
+
for member_id, entry in check.record["members"].items()
|
|
771
|
+
},
|
|
772
|
+
),
|
|
773
|
+
[],
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _gate_consistency(
|
|
778
|
+
kind: str, commands: tuple[str, ...], rows: list[CandidateRow]
|
|
779
|
+
) -> tuple[str, list[str]]:
|
|
780
|
+
"""Cross-check a round's recorded gate settings against what its
|
|
781
|
+
completed candidates' reports show. A report that cannot be read makes the
|
|
782
|
+
result ``indeterminate`` — never folded into ``consistent``."""
|
|
783
|
+
contradicted: list[str] = []
|
|
784
|
+
indeterminate: list[str] = []
|
|
785
|
+
for row in rows:
|
|
786
|
+
if row.state != "completed":
|
|
787
|
+
continue
|
|
788
|
+
verdict = row.gate.verdict
|
|
789
|
+
if verdict == "unknown":
|
|
790
|
+
indeterminate.append(row.candidate_id)
|
|
791
|
+
elif kind == "commands" and (
|
|
792
|
+
verdict == "no_report" or row.gate.commands != commands
|
|
793
|
+
):
|
|
794
|
+
contradicted.append(row.candidate_id)
|
|
795
|
+
elif kind == "none" and verdict in {"passed", "failed"}:
|
|
796
|
+
contradicted.append(row.candidate_id)
|
|
797
|
+
warnings = []
|
|
798
|
+
if contradicted:
|
|
799
|
+
warnings.append(
|
|
800
|
+
"gate reports contradict the recorded gate settings (candidates: "
|
|
801
|
+
+ ", ".join(contradicted)
|
|
802
|
+
+ ")"
|
|
803
|
+
)
|
|
804
|
+
if indeterminate:
|
|
805
|
+
warnings.append(
|
|
806
|
+
"gate reports could not be checked against the recorded gate settings "
|
|
807
|
+
"(candidates: " + ", ".join(indeterminate) + ")"
|
|
808
|
+
)
|
|
809
|
+
if contradicted:
|
|
810
|
+
return "contradicted", warnings
|
|
811
|
+
if indeterminate:
|
|
812
|
+
return "indeterminate", warnings
|
|
813
|
+
return "consistent", warnings
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _cross_round_settings(groups: list[RoundGroup]) -> list[dict[str, Any]]:
|
|
817
|
+
"""Rounds grouped by equal recorded gate settings. Equality is
|
|
818
|
+
established only by two records; a round without a usable one is listed
|
|
819
|
+
alone, never grouped with a recorded round."""
|
|
820
|
+
keyed: dict[tuple[Any, ...], list[RoundGroup]] = {}
|
|
821
|
+
alone: list[dict[str, Any]] = []
|
|
822
|
+
for group in groups:
|
|
823
|
+
settings = group.gate_settings
|
|
824
|
+
if settings.status != "recorded":
|
|
825
|
+
alone.append(
|
|
826
|
+
{
|
|
827
|
+
"rounds": [group.number],
|
|
828
|
+
"settings": "undecidable",
|
|
829
|
+
"commands": [],
|
|
830
|
+
"timeout_seconds": None,
|
|
831
|
+
"reason": (
|
|
832
|
+
"gate settings record unreadable"
|
|
833
|
+
if settings.status == "malformed"
|
|
834
|
+
else "gate settings not recorded"
|
|
835
|
+
),
|
|
836
|
+
"contradicted_rounds": [],
|
|
837
|
+
"indeterminate_rounds": [],
|
|
838
|
+
}
|
|
839
|
+
)
|
|
840
|
+
continue
|
|
841
|
+
key = (settings.kind, settings.commands, settings.timeout_seconds)
|
|
842
|
+
keyed.setdefault(key, []).append(group)
|
|
843
|
+
entries = [
|
|
844
|
+
{
|
|
845
|
+
"rounds": [g.number for g in members],
|
|
846
|
+
"settings": kind,
|
|
847
|
+
"commands": list(commands),
|
|
848
|
+
"timeout_seconds": timeout,
|
|
849
|
+
"reason": None,
|
|
850
|
+
"contradicted_rounds": [
|
|
851
|
+
g.number for g in members if g.gate_settings.consistency == "contradicted"
|
|
852
|
+
],
|
|
853
|
+
"indeterminate_rounds": [
|
|
854
|
+
g.number for g in members if g.gate_settings.consistency == "indeterminate"
|
|
855
|
+
],
|
|
856
|
+
}
|
|
857
|
+
for (kind, commands, timeout), members in keyed.items()
|
|
858
|
+
]
|
|
859
|
+
return entries + alone
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _overlap(rows: list[CandidateRow]) -> FileOverlap:
|
|
863
|
+
unknown = tuple(r.candidate_id for r in rows if r.files_changed is None)
|
|
864
|
+
no_changes = tuple(r.candidate_id for r in rows if r.files_changed == ())
|
|
865
|
+
# A known-empty file set is part of the denominator: a file is common to
|
|
866
|
+
# all only if every candidate with a known set changed it.
|
|
867
|
+
compared = [r for r in rows if r.files_changed is not None]
|
|
868
|
+
ids = tuple(r.candidate_id for r in compared)
|
|
869
|
+
owners: dict[str, list[str]] = {}
|
|
870
|
+
for row in compared:
|
|
871
|
+
for name in row.files_changed or ():
|
|
872
|
+
owners.setdefault(name, []).append(row.candidate_id)
|
|
873
|
+
if len(compared) < 2:
|
|
874
|
+
common: tuple[str, ...] = ()
|
|
875
|
+
else:
|
|
876
|
+
common = tuple(sorted(n for n, o in owners.items() if len(o) == len(compared)))
|
|
877
|
+
only = {
|
|
878
|
+
row.candidate_id: tuple(
|
|
879
|
+
sorted(n for n in row.files_changed or () if owners[n] == [row.candidate_id])
|
|
880
|
+
)
|
|
881
|
+
for row in compared
|
|
882
|
+
}
|
|
883
|
+
partial = {
|
|
884
|
+
n: tuple(o)
|
|
885
|
+
for n, o in sorted(owners.items())
|
|
886
|
+
if 1 < len(o) < len(compared)
|
|
887
|
+
}
|
|
888
|
+
return FileOverlap(ids, common, only, partial, no_changes, unknown)
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _missing_members(
|
|
892
|
+
store: RoomStore,
|
|
893
|
+
room: dict[str, Any],
|
|
894
|
+
round_row: dict[str, Any],
|
|
895
|
+
rows: list[CandidateRow],
|
|
896
|
+
now: float,
|
|
897
|
+
) -> tuple[list[MissingMember], list[str]]:
|
|
898
|
+
warnings: list[str] = []
|
|
899
|
+
members = room.get("members")
|
|
900
|
+
expected: list[tuple[str, str]] = []
|
|
901
|
+
if isinstance(members, list):
|
|
902
|
+
for member in members:
|
|
903
|
+
if isinstance(member, dict) and isinstance(member.get("member_id"), str):
|
|
904
|
+
provider = member.get("provider_id")
|
|
905
|
+
expected.append(
|
|
906
|
+
(member["member_id"], provider if isinstance(provider, str) else "?")
|
|
907
|
+
)
|
|
908
|
+
else:
|
|
909
|
+
warnings.append("room member list holds a malformed entry")
|
|
910
|
+
else:
|
|
911
|
+
warnings.append("room member list is malformed")
|
|
912
|
+
progress = {
|
|
913
|
+
str(m["member_id"]): str(m["state"])
|
|
914
|
+
for m in store.list_round_members(str(round_row["id"]))
|
|
915
|
+
}
|
|
916
|
+
have = {r.member_id for r in rows}
|
|
917
|
+
lease = round_row.get("lease_expires")
|
|
918
|
+
lease_live = isinstance(lease, (int, float)) and lease > now
|
|
919
|
+
missing: list[MissingMember] = []
|
|
920
|
+
for member_id, provider_id in expected:
|
|
921
|
+
if member_id in have:
|
|
922
|
+
continue
|
|
923
|
+
member_state = progress.get(member_id)
|
|
924
|
+
if (
|
|
925
|
+
room.get("state") == "running"
|
|
926
|
+
and round_row.get("state") == "running"
|
|
927
|
+
and lease_live
|
|
928
|
+
and (member_state is None or member_state in _IN_PROGRESS_MEMBER_STATES)
|
|
929
|
+
):
|
|
930
|
+
missing.append(MissingMember(member_id, provider_id, "in_progress", ()))
|
|
931
|
+
continue
|
|
932
|
+
evidence = [f"member state: {member_state or 'not recorded'}"]
|
|
933
|
+
if room.get("state") == "recovery_required":
|
|
934
|
+
evidence.append("room needs recovery")
|
|
935
|
+
if round_row.get("state") == "running" and not lease_live:
|
|
936
|
+
evidence.append("lease expired")
|
|
937
|
+
missing.append(
|
|
938
|
+
MissingMember(member_id, provider_id, "no_candidate", tuple(evidence))
|
|
939
|
+
)
|
|
940
|
+
return missing, warnings
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def build_room_comparison(
|
|
944
|
+
store: RoomStore,
|
|
945
|
+
room_id: str,
|
|
946
|
+
*,
|
|
947
|
+
round_number: int | None = None,
|
|
948
|
+
all_rounds: bool = False,
|
|
949
|
+
source_check: SourceCheck | None = None,
|
|
950
|
+
now: float | None = None,
|
|
951
|
+
) -> RoomComparison:
|
|
952
|
+
"""Project one room's candidates into the comparison read model.
|
|
953
|
+
|
|
954
|
+
``source_check`` returns the live repository fingerprint; any exception it
|
|
955
|
+
raises is reported as ``unavailable`` and never as ``current``. Raises
|
|
956
|
+
:class:`RoomError` for an unknown room or round number.
|
|
957
|
+
"""
|
|
958
|
+
if round_number is not None and all_rounds:
|
|
959
|
+
raise ValueError("round_number and all_rounds are mutually exclusive")
|
|
960
|
+
now = time.time() if now is None else now
|
|
961
|
+
room = store.get_room(room_id)
|
|
962
|
+
recorded = room.get("source_fingerprint")
|
|
963
|
+
source_reason: str | None = None
|
|
964
|
+
if not recorded:
|
|
965
|
+
source_status = "not_recorded"
|
|
966
|
+
elif source_check is None:
|
|
967
|
+
source_status, source_reason = "unavailable", "not checked"
|
|
968
|
+
else:
|
|
969
|
+
try:
|
|
970
|
+
source_status = "current" if source_check() == recorded else "changed"
|
|
971
|
+
# Any failure (a non-zero git exit, git missing, an unreadable cwd)
|
|
972
|
+
# means the source is unverified — never "current".
|
|
973
|
+
except Exception as exc:
|
|
974
|
+
source_status = "unavailable"
|
|
975
|
+
source_reason = str(exc) or type(exc).__name__
|
|
976
|
+
|
|
977
|
+
rounds = store.list_rounds(room_id)
|
|
978
|
+
damaged: list[dict[str, str]] = []
|
|
979
|
+
valid: list[dict[str, Any]] = []
|
|
980
|
+
for round_row in rounds:
|
|
981
|
+
if _positive(round_row.get("number")) is None:
|
|
982
|
+
damaged.append(
|
|
983
|
+
{
|
|
984
|
+
"round_id": str(round_row.get("id")),
|
|
985
|
+
"reason": f"malformed round number {round_row.get('number')!r}",
|
|
986
|
+
}
|
|
987
|
+
)
|
|
988
|
+
else:
|
|
989
|
+
valid.append(round_row)
|
|
990
|
+
if round_number is not None:
|
|
991
|
+
chosen = [r for r in valid if r["number"] == round_number]
|
|
992
|
+
if not chosen:
|
|
993
|
+
numbers = ", ".join(str(r["number"]) for r in valid) or "none"
|
|
994
|
+
raise UnknownRoundError(
|
|
995
|
+
f"room {room_id} has no round {round_number} (rounds: {numbers})"
|
|
996
|
+
)
|
|
997
|
+
elif all_rounds:
|
|
998
|
+
chosen = valid
|
|
999
|
+
else:
|
|
1000
|
+
chosen = valid[-1:]
|
|
1001
|
+
|
|
1002
|
+
by_round: dict[str, list[dict[str, Any]]] = {}
|
|
1003
|
+
candidate_rows: dict[str, dict[str, Any]] = {}
|
|
1004
|
+
for candidate in store.list_candidates(room_id):
|
|
1005
|
+
by_round.setdefault(str(candidate["round_id"]), []).append(candidate)
|
|
1006
|
+
candidate_rows[str(candidate["id"])] = candidate
|
|
1007
|
+
members = room.get("members")
|
|
1008
|
+
member_ids = (
|
|
1009
|
+
[m["member_id"] for m in members if isinstance(m, dict) and "member_id" in m]
|
|
1010
|
+
if isinstance(members, list)
|
|
1011
|
+
else []
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
selected_id = room.get("selected_candidate_id")
|
|
1015
|
+
selection: SelectionView | None = None
|
|
1016
|
+
if selected_id:
|
|
1017
|
+
try:
|
|
1018
|
+
selected_row: dict[str, Any] | None = store.get_candidate(selected_id)
|
|
1019
|
+
except RoomError:
|
|
1020
|
+
selected_row = None
|
|
1021
|
+
selection = _selection_view(
|
|
1022
|
+
selected_id,
|
|
1023
|
+
selection_record_status(
|
|
1024
|
+
room.get("selection_json"),
|
|
1025
|
+
selected_candidate_id=selected_id,
|
|
1026
|
+
candidate_row=selected_row,
|
|
1027
|
+
),
|
|
1028
|
+
)
|
|
1029
|
+
elif room.get("selection_json") is not None:
|
|
1030
|
+
selection = SelectionView(
|
|
1031
|
+
"mismatch", None, "a selection record exists but no candidate is selected"
|
|
1032
|
+
)
|
|
1033
|
+
groups: list[RoundGroup] = []
|
|
1034
|
+
for round_row in chosen:
|
|
1035
|
+
rows = [
|
|
1036
|
+
project_candidate(
|
|
1037
|
+
store,
|
|
1038
|
+
candidate,
|
|
1039
|
+
room=room,
|
|
1040
|
+
source_status=source_status,
|
|
1041
|
+
source_reason=source_reason,
|
|
1042
|
+
)
|
|
1043
|
+
for candidate in by_round.get(str(round_row["id"]), [])
|
|
1044
|
+
]
|
|
1045
|
+
try:
|
|
1046
|
+
profile, warnings = _gate_profile(rows)
|
|
1047
|
+
lineage, settings, provenance_warnings = _provenance_views(
|
|
1048
|
+
round_provenance_status(
|
|
1049
|
+
round_row.get("provenance_json"),
|
|
1050
|
+
round_number=int(round_row["number"]),
|
|
1051
|
+
member_ids=member_ids,
|
|
1052
|
+
candidates=candidate_rows,
|
|
1053
|
+
),
|
|
1054
|
+
rows,
|
|
1055
|
+
)
|
|
1056
|
+
conversation, conversation_warnings = _conversation_view(
|
|
1057
|
+
round_conversation_status(
|
|
1058
|
+
round_row.get("conversation_json"),
|
|
1059
|
+
round_number=int(round_row["number"]),
|
|
1060
|
+
members=members if isinstance(members, list) else [],
|
|
1061
|
+
candidates=candidate_rows,
|
|
1062
|
+
)
|
|
1063
|
+
)
|
|
1064
|
+
missing, member_warnings = _missing_members(
|
|
1065
|
+
store, room, round_row, rows, now
|
|
1066
|
+
)
|
|
1067
|
+
groups.append(
|
|
1068
|
+
RoundGroup(
|
|
1069
|
+
number=int(round_row["number"]),
|
|
1070
|
+
round_id=str(round_row["id"]),
|
|
1071
|
+
state=str(round_row["state"]),
|
|
1072
|
+
starting_tree=(
|
|
1073
|
+
"snapshot" if round_row["number"] == 1 else "not_recorded"
|
|
1074
|
+
),
|
|
1075
|
+
gate_profile=profile,
|
|
1076
|
+
rows=tuple(rows),
|
|
1077
|
+
missing=tuple(missing),
|
|
1078
|
+
overlap=_overlap(rows),
|
|
1079
|
+
warnings=tuple(
|
|
1080
|
+
warnings + provenance_warnings + conversation_warnings + member_warnings
|
|
1081
|
+
),
|
|
1082
|
+
lineage=lineage,
|
|
1083
|
+
gate_settings=settings,
|
|
1084
|
+
conversation=conversation,
|
|
1085
|
+
)
|
|
1086
|
+
)
|
|
1087
|
+
except _ROW_ERRORS as exc:
|
|
1088
|
+
damaged.append(
|
|
1089
|
+
{
|
|
1090
|
+
"round_id": str(round_row.get("id")),
|
|
1091
|
+
"reason": str(exc) or type(exc).__name__,
|
|
1092
|
+
}
|
|
1093
|
+
)
|
|
1094
|
+
|
|
1095
|
+
cross: list[dict[str, Any]] = []
|
|
1096
|
+
cross_settings: list[dict[str, Any]] = []
|
|
1097
|
+
if all_rounds and len(groups) > 1:
|
|
1098
|
+
cross_settings = _cross_round_settings(groups)
|
|
1099
|
+
# Rounds are grouped by equality of their recorded gate commands (an
|
|
1100
|
+
# equivalence relation, so the groups carry the same information as
|
|
1101
|
+
# every pairwise answer). Only recorded commands establish equality: a
|
|
1102
|
+
# round with no gate report, an unknown or a mixed profile says nothing
|
|
1103
|
+
# about its settings and is listed on its own with the reason.
|
|
1104
|
+
by_commands: dict[tuple[str, ...], list[int]] = {}
|
|
1105
|
+
for group in groups:
|
|
1106
|
+
if group.gate_profile.kind == "commands":
|
|
1107
|
+
by_commands.setdefault(group.gate_profile.commands, []).append(
|
|
1108
|
+
group.number
|
|
1109
|
+
)
|
|
1110
|
+
for commands, numbers in by_commands.items():
|
|
1111
|
+
cross.append(
|
|
1112
|
+
{
|
|
1113
|
+
"rounds": numbers,
|
|
1114
|
+
"gate_profile": "commands",
|
|
1115
|
+
"commands": list(commands),
|
|
1116
|
+
"reason": None,
|
|
1117
|
+
}
|
|
1118
|
+
)
|
|
1119
|
+
for group in groups:
|
|
1120
|
+
if group.gate_profile.kind != "commands":
|
|
1121
|
+
cross.append(
|
|
1122
|
+
{
|
|
1123
|
+
"rounds": [group.number],
|
|
1124
|
+
"gate_profile": "undecidable",
|
|
1125
|
+
"commands": [],
|
|
1126
|
+
"reason": _UNDECIDABLE_REASON.get(
|
|
1127
|
+
group.gate_profile.kind, group.gate_profile.kind
|
|
1128
|
+
),
|
|
1129
|
+
}
|
|
1130
|
+
)
|
|
1131
|
+
|
|
1132
|
+
return RoomComparison(
|
|
1133
|
+
room_id=room_id,
|
|
1134
|
+
room_state=str(room["state"]),
|
|
1135
|
+
revision=int(room["revision"]),
|
|
1136
|
+
selected_candidate_id=selected_id,
|
|
1137
|
+
source_status=source_status,
|
|
1138
|
+
source_reason=source_reason,
|
|
1139
|
+
round_count=len(rounds),
|
|
1140
|
+
groups=tuple(groups),
|
|
1141
|
+
cross_round=tuple(cross),
|
|
1142
|
+
cross_round_settings=tuple(cross_settings),
|
|
1143
|
+
damaged_rounds=tuple(damaged),
|
|
1144
|
+
selection=selection,
|
|
1145
|
+
)
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
# --------------------------------------------------------------------- JSON
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def _usage_dict(usage: TokenUsage | None) -> dict[str, Any] | None:
|
|
1152
|
+
if usage is None:
|
|
1153
|
+
return None
|
|
1154
|
+
return {
|
|
1155
|
+
"total_input_tokens": usage.total_input_tokens,
|
|
1156
|
+
"output_tokens": usage.output_tokens,
|
|
1157
|
+
"cache_read_tokens": usage.cache_read_tokens,
|
|
1158
|
+
"cache_creation_tokens": usage.cache_creation_tokens,
|
|
1159
|
+
"total_cost_usd": usage.total_cost_usd,
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def _row_dict(row: CandidateRow) -> dict[str, Any]:
|
|
1164
|
+
return {
|
|
1165
|
+
"candidate_id": row.candidate_id,
|
|
1166
|
+
"member_id": row.member_id,
|
|
1167
|
+
"provider_id": row.provider_id,
|
|
1168
|
+
"attempt": row.attempt,
|
|
1169
|
+
"state": row.state,
|
|
1170
|
+
"eligible": row.eligible,
|
|
1171
|
+
"not_eligible_reason": row.not_eligible_reason,
|
|
1172
|
+
"selected": row.selected,
|
|
1173
|
+
"base_commit": row.base_commit,
|
|
1174
|
+
"readable": row.readable,
|
|
1175
|
+
"unreadable_reason": row.unreadable_reason,
|
|
1176
|
+
"gate": {
|
|
1177
|
+
"verdict": row.gate.verdict,
|
|
1178
|
+
"reason": row.gate.reason,
|
|
1179
|
+
"checks": [
|
|
1180
|
+
{
|
|
1181
|
+
"command": c.command,
|
|
1182
|
+
"passed": c.passed,
|
|
1183
|
+
"exit_code": c.exit_code,
|
|
1184
|
+
"duration_seconds": c.duration_seconds,
|
|
1185
|
+
}
|
|
1186
|
+
for c in row.gate.checks
|
|
1187
|
+
],
|
|
1188
|
+
},
|
|
1189
|
+
"duration_seconds": row.duration_seconds,
|
|
1190
|
+
"usage": _usage_dict(row.usage),
|
|
1191
|
+
"files_changed": (
|
|
1192
|
+
None if row.files_changed is None else list(row.files_changed)
|
|
1193
|
+
),
|
|
1194
|
+
"patch": {
|
|
1195
|
+
"sha256": row.patch_sha256,
|
|
1196
|
+
"bytes": row.patch_bytes,
|
|
1197
|
+
"binary": row.patch_binary,
|
|
1198
|
+
"large": row.patch_large,
|
|
1199
|
+
},
|
|
1200
|
+
"apply_blockers": list(row.apply_blockers),
|
|
1201
|
+
"warnings": list(row.warnings),
|
|
1202
|
+
"created_at": row.created_at,
|
|
1203
|
+
"exit_code": row.exit_code,
|
|
1204
|
+
"output": {
|
|
1205
|
+
"sha256": row.output_sha256,
|
|
1206
|
+
"bytes": row.output_bytes,
|
|
1207
|
+
"truncated": row.output_truncated,
|
|
1208
|
+
},
|
|
1209
|
+
"provider_session_id": row.provider_session_id,
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def _selection_dict(selection: SelectionView | None) -> dict[str, Any] | None:
|
|
1214
|
+
if selection is None:
|
|
1215
|
+
return None
|
|
1216
|
+
return {
|
|
1217
|
+
"status": selection.status,
|
|
1218
|
+
"candidate_id": selection.candidate_id,
|
|
1219
|
+
"reason": selection.reason,
|
|
1220
|
+
"patch_sha256": selection.patch_sha256,
|
|
1221
|
+
"confirmed_revision": selection.confirmed_revision,
|
|
1222
|
+
"revision": selection.revision,
|
|
1223
|
+
"rationale": selection.rationale,
|
|
1224
|
+
"surface": selection.surface,
|
|
1225
|
+
"selected_at": selection.selected_at,
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
def _lineage_dict(lineage: LineageView) -> dict[str, Any]:
|
|
1230
|
+
return {
|
|
1231
|
+
"status": lineage.status,
|
|
1232
|
+
"reason": lineage.reason,
|
|
1233
|
+
"requested": lineage.requested,
|
|
1234
|
+
"tree": lineage.tree,
|
|
1235
|
+
"base_candidate_id": lineage.base_candidate_id,
|
|
1236
|
+
"base_patch_sha256": lineage.base_patch_sha256,
|
|
1237
|
+
"base_round_number": lineage.base_round_number,
|
|
1238
|
+
"members": {
|
|
1239
|
+
member_id: (
|
|
1240
|
+
None
|
|
1241
|
+
if parent is None
|
|
1242
|
+
else {"parent_candidate_id": parent[0], "parent_round_number": parent[1]}
|
|
1243
|
+
)
|
|
1244
|
+
for member_id, parent in lineage.parents.items()
|
|
1245
|
+
},
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def _gate_settings_dict(settings: GateSettingsView) -> dict[str, Any]:
|
|
1250
|
+
return {
|
|
1251
|
+
"status": settings.status,
|
|
1252
|
+
"kind": settings.kind,
|
|
1253
|
+
"commands": list(settings.commands),
|
|
1254
|
+
"timeout_seconds": settings.timeout_seconds,
|
|
1255
|
+
"consistency": settings.consistency,
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
def _member_conversation_dict(entry: MemberConversation | None) -> dict[str, Any] | None:
|
|
1260
|
+
if entry is None:
|
|
1261
|
+
return None
|
|
1262
|
+
return {
|
|
1263
|
+
"session": entry.session,
|
|
1264
|
+
"context": entry.context,
|
|
1265
|
+
"since_round": entry.since_round,
|
|
1266
|
+
"candidates": list(entry.candidates),
|
|
1267
|
+
"omitted": entry.omitted,
|
|
1268
|
+
"memory": entry.memory,
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def _conversation_dict(view: ConversationView) -> dict[str, Any]:
|
|
1273
|
+
return {
|
|
1274
|
+
"status": view.status,
|
|
1275
|
+
"reason": view.reason,
|
|
1276
|
+
"members": {
|
|
1277
|
+
member_id: _member_conversation_dict(entry)
|
|
1278
|
+
for member_id, entry in view.members.items()
|
|
1279
|
+
},
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def starts_from(group: RoundGroup, row: CandidateRow) -> dict[str, Any] | None:
|
|
1284
|
+
"""Where one candidate's worktree started, from the round's record, or
|
|
1285
|
+
None when the round has no usable record. Differs between the candidates
|
|
1286
|
+
of a round only when the round continued each member's own worktree."""
|
|
1287
|
+
lineage = group.lineage
|
|
1288
|
+
if lineage.status != "recorded":
|
|
1289
|
+
return None
|
|
1290
|
+
if lineage.tree == "continued":
|
|
1291
|
+
parent = lineage.parents.get(row.member_id)
|
|
1292
|
+
return {
|
|
1293
|
+
"kind": "continued",
|
|
1294
|
+
"parent_candidate_id": None if parent is None else parent[0],
|
|
1295
|
+
"parent_round_number": None if parent is None else parent[1],
|
|
1296
|
+
}
|
|
1297
|
+
if lineage.tree == "branch":
|
|
1298
|
+
return {
|
|
1299
|
+
"kind": "branch",
|
|
1300
|
+
"base_candidate_id": lineage.base_candidate_id,
|
|
1301
|
+
"base_patch_sha256": lineage.base_patch_sha256,
|
|
1302
|
+
}
|
|
1303
|
+
return {"kind": "snapshot"}
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def comparison_to_dict(cmp: RoomComparison) -> dict[str, Any]:
|
|
1307
|
+
"""The versioned JSON form. Additive fields keep ``schema_version`` 1;
|
|
1308
|
+
removing, renaming or re-typing a field bumps it. Body-free: ids, patch
|
|
1309
|
+
digests, sizes, verdicts, file names and gate commands — never prompt,
|
|
1310
|
+
output, patch or gate-check output text. The one free-text field is the
|
|
1311
|
+
selection rationale, which is the human reviewer's own words."""
|
|
1312
|
+
return {
|
|
1313
|
+
"schema_version": SCHEMA_VERSION,
|
|
1314
|
+
"kind": KIND,
|
|
1315
|
+
"room": {
|
|
1316
|
+
"room_id": cmp.room_id,
|
|
1317
|
+
"state": cmp.room_state,
|
|
1318
|
+
"revision": cmp.revision,
|
|
1319
|
+
"selected_candidate_id": cmp.selected_candidate_id,
|
|
1320
|
+
"round_count": cmp.round_count,
|
|
1321
|
+
"selection": _selection_dict(cmp.selection),
|
|
1322
|
+
},
|
|
1323
|
+
"source": {"status": cmp.source_status, "reason": cmp.source_reason},
|
|
1324
|
+
"rounds": [
|
|
1325
|
+
{
|
|
1326
|
+
"number": g.number,
|
|
1327
|
+
"round_id": g.round_id,
|
|
1328
|
+
"state": g.state,
|
|
1329
|
+
"starting_tree": g.starting_tree,
|
|
1330
|
+
"gate_profile": {
|
|
1331
|
+
"kind": g.gate_profile.kind,
|
|
1332
|
+
"commands": list(g.gate_profile.commands),
|
|
1333
|
+
},
|
|
1334
|
+
"candidates": [
|
|
1335
|
+
{
|
|
1336
|
+
**_row_dict(r),
|
|
1337
|
+
"starts_from": starts_from(g, r),
|
|
1338
|
+
"conversation": _member_conversation_dict(
|
|
1339
|
+
g.conversation.members.get(r.member_id)
|
|
1340
|
+
),
|
|
1341
|
+
}
|
|
1342
|
+
for r in g.rows
|
|
1343
|
+
],
|
|
1344
|
+
"missing_members": [
|
|
1345
|
+
{
|
|
1346
|
+
"member_id": m.member_id,
|
|
1347
|
+
"provider_id": m.provider_id,
|
|
1348
|
+
"status": m.status,
|
|
1349
|
+
"evidence": list(m.evidence),
|
|
1350
|
+
}
|
|
1351
|
+
for m in g.missing
|
|
1352
|
+
],
|
|
1353
|
+
"files": {
|
|
1354
|
+
"compared": list(g.overlap.compared),
|
|
1355
|
+
"common": list(g.overlap.common),
|
|
1356
|
+
"only": {k: list(v) for k, v in g.overlap.only.items()},
|
|
1357
|
+
"partial": {k: list(v) for k, v in g.overlap.partial.items()},
|
|
1358
|
+
"no_changes": list(g.overlap.no_changes),
|
|
1359
|
+
"unknown": list(g.overlap.unknown),
|
|
1360
|
+
},
|
|
1361
|
+
"warnings": list(g.warnings),
|
|
1362
|
+
"lineage": _lineage_dict(g.lineage),
|
|
1363
|
+
"gate_settings": _gate_settings_dict(g.gate_settings),
|
|
1364
|
+
"conversation": _conversation_dict(g.conversation),
|
|
1365
|
+
}
|
|
1366
|
+
for g in cmp.groups
|
|
1367
|
+
],
|
|
1368
|
+
"cross_round": [dict(entry) for entry in cmp.cross_round],
|
|
1369
|
+
"cross_round_settings": [dict(entry) for entry in cmp.cross_round_settings],
|
|
1370
|
+
"damaged_rounds": [dict(entry) for entry in cmp.damaged_rounds],
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
# --------------------------------------------------------------------- text
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
def _p(value: object) -> str:
|
|
1378
|
+
"""Every stored string is untrusted display data: escape controls and line
|
|
1379
|
+
breaks so nothing can drive the terminal or forge a line."""
|
|
1380
|
+
return printable_name(str(value))
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
_GATE_TEXT = {
|
|
1384
|
+
"passed": "passed",
|
|
1385
|
+
"failed": "failed",
|
|
1386
|
+
"not_run": "not run",
|
|
1387
|
+
"no_report": "no gate report",
|
|
1388
|
+
"unknown": "unknown",
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
_SOURCE_TEXT = {
|
|
1392
|
+
"current": "unchanged since the room started",
|
|
1393
|
+
"changed": "CHANGED since the room started — apply would be rejected",
|
|
1394
|
+
"not_recorded": "not recorded (no round has run)",
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
def gate_label(gate: GateView) -> str:
|
|
1399
|
+
"""The one wording of a gate verdict, shared by every surface."""
|
|
1400
|
+
text = _GATE_TEXT.get(gate.verdict, gate.verdict)
|
|
1401
|
+
if gate.reason:
|
|
1402
|
+
text += f" ({_p(gate.reason)})"
|
|
1403
|
+
return text
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
def usage_label(usage: TokenUsage | None) -> str:
|
|
1407
|
+
"""The one long-form wording of reported usage; ``not reported`` when the
|
|
1408
|
+
provider sent none (never 0)."""
|
|
1409
|
+
return _usage_text(usage)
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
def _usage_text(usage: TokenUsage | None) -> str:
|
|
1413
|
+
if usage is None:
|
|
1414
|
+
return "not reported"
|
|
1415
|
+
parts = []
|
|
1416
|
+
if usage.total_input_tokens is not None:
|
|
1417
|
+
parts.append(f"input {usage.total_input_tokens:,}")
|
|
1418
|
+
if usage.output_tokens is not None:
|
|
1419
|
+
parts.append(f"output {usage.output_tokens:,}")
|
|
1420
|
+
if usage.cache_read_tokens is not None:
|
|
1421
|
+
parts.append(f"cache read {usage.cache_read_tokens:,}")
|
|
1422
|
+
if usage.cache_creation_tokens is not None:
|
|
1423
|
+
parts.append(f"cache write {usage.cache_creation_tokens:,}")
|
|
1424
|
+
if usage.total_cost_usd is not None:
|
|
1425
|
+
parts.append(f"cost ${usage.total_cost_usd:.4f}")
|
|
1426
|
+
return ", ".join(parts) if parts else "not reported"
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
_SURFACE_TEXT = {"cli": "CLI", "tui": "TUI", "api": "API"}
|
|
1430
|
+
|
|
1431
|
+
_UNREADABLE_RECORD = "unknown (round provenance record unreadable, see warnings)"
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
def starting_tree_label(group: RoundGroup) -> str:
|
|
1435
|
+
"""The one wording of where a round's candidates started, shared by every
|
|
1436
|
+
surface. It names ids and digests but no free text."""
|
|
1437
|
+
lineage = group.lineage
|
|
1438
|
+
if lineage.status == "malformed":
|
|
1439
|
+
return _UNREADABLE_RECORD
|
|
1440
|
+
if lineage.status != "recorded":
|
|
1441
|
+
# Round 1 can only snapshot (branch-from needs an earlier eligible
|
|
1442
|
+
# candidate; continue-own has no workspaces yet).
|
|
1443
|
+
if group.number == 1:
|
|
1444
|
+
return "snapshot"
|
|
1445
|
+
return "not recorded (this round predates lineage records)"
|
|
1446
|
+
requested = f" (requested lineage: {_p(lineage.requested)})"
|
|
1447
|
+
if lineage.tree == "branch":
|
|
1448
|
+
return (
|
|
1449
|
+
f"snapshot plus candidate {_p(lineage.base_candidate_id)}'s patch "
|
|
1450
|
+
f"(round {lineage.base_round_number}, sha256 {_p(lineage.base_patch_sha256)}), "
|
|
1451
|
+
"seeded into every member" + requested
|
|
1452
|
+
)
|
|
1453
|
+
if lineage.tree == "continued":
|
|
1454
|
+
return (
|
|
1455
|
+
"each member continues its own retained worktree; "
|
|
1456
|
+
"a common starting tree is not established" + requested
|
|
1457
|
+
)
|
|
1458
|
+
return "snapshot" + requested
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
def row_start_label(group: RoundGroup, row: CandidateRow) -> str | None:
|
|
1462
|
+
"""Where one candidate's worktree started, when that differs per candidate
|
|
1463
|
+
(a round that continued each member's own worktree); else None."""
|
|
1464
|
+
if group.lineage.status != "recorded" or group.lineage.tree != "continued":
|
|
1465
|
+
return None
|
|
1466
|
+
parent = group.lineage.parents.get(row.member_id)
|
|
1467
|
+
if parent is None:
|
|
1468
|
+
return "parent not recorded"
|
|
1469
|
+
return f"the worktree left by candidate {_p(parent[0])} (round {parent[1]})"
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
def gate_settings_label(settings: GateSettingsView) -> str:
|
|
1473
|
+
"""The one wording of a round's recorded gate settings. It never includes
|
|
1474
|
+
the commands themselves (free text each surface places its own way)."""
|
|
1475
|
+
if settings.status == "malformed":
|
|
1476
|
+
return _UNREADABLE_RECORD
|
|
1477
|
+
if settings.status != "recorded":
|
|
1478
|
+
return "not recorded (timeout not recorded)"
|
|
1479
|
+
if settings.kind == "none":
|
|
1480
|
+
text = "none configured"
|
|
1481
|
+
else:
|
|
1482
|
+
text = f"commands, timeout {settings.timeout_seconds}s"
|
|
1483
|
+
if settings.consistency == "contradicted":
|
|
1484
|
+
text += "; gate reports CONTRADICT these settings (see warnings)"
|
|
1485
|
+
elif settings.consistency == "indeterminate":
|
|
1486
|
+
text += "; some gate reports could not be checked (see warnings)"
|
|
1487
|
+
return text
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
_SESSION_TEXT = {
|
|
1491
|
+
"cold": "cold session",
|
|
1492
|
+
"resume": "resume of its stored session requested",
|
|
1493
|
+
"fork": "fork of its stored session requested",
|
|
1494
|
+
}
|
|
1495
|
+
_MEMORY_TEXT = {
|
|
1496
|
+
"included": "memory context included",
|
|
1497
|
+
"dropped": "memory context dropped (prompt size limit)",
|
|
1498
|
+
"none": "no memory context found",
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
|
|
1502
|
+
def member_conversation(group: RoundGroup, row: CandidateRow) -> MemberConversation | None:
|
|
1503
|
+
if group.conversation.status != "recorded":
|
|
1504
|
+
return None
|
|
1505
|
+
return group.conversation.members.get(row.member_id)
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
def conversation_label(group: RoundGroup, row: CandidateRow) -> str | None:
|
|
1509
|
+
"""The one wording of the conversation one candidate's provider was
|
|
1510
|
+
handed, or None for a round without a usable record (its round label
|
|
1511
|
+
says why). Counts only; the ids are in the JSON and the Details."""
|
|
1512
|
+
entry = member_conversation(group, row)
|
|
1513
|
+
if entry is None:
|
|
1514
|
+
return None
|
|
1515
|
+
count = len(entry.candidates)
|
|
1516
|
+
excerpts = f"{count} candidate excerpt{'' if count == 1 else 's'}"
|
|
1517
|
+
if entry.context == "delta":
|
|
1518
|
+
checkpoint = f"peers' outputs since round {entry.since_round}: {excerpts}"
|
|
1519
|
+
elif count or entry.omitted:
|
|
1520
|
+
checkpoint = f"full checkpoint: {excerpts}"
|
|
1521
|
+
else:
|
|
1522
|
+
checkpoint = "no earlier candidates"
|
|
1523
|
+
if entry.omitted:
|
|
1524
|
+
checkpoint += (
|
|
1525
|
+
", older ones omitted for size" if count else " (all omitted for size)"
|
|
1526
|
+
)
|
|
1527
|
+
return f"{_SESSION_TEXT[entry.session]}; {checkpoint}; {_MEMORY_TEXT[entry.memory]}"
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
def round_conversation_label(group: RoundGroup) -> str:
|
|
1531
|
+
"""Counts of the recorded modes, never a claim that members shared one
|
|
1532
|
+
context: each member has its own provider conversation, and two delta
|
|
1533
|
+
checkpoints exclude different members' candidates."""
|
|
1534
|
+
view = group.conversation
|
|
1535
|
+
if view.status == "malformed":
|
|
1536
|
+
return "unknown (round conversation record unreadable, see warnings)"
|
|
1537
|
+
if view.status != "recorded":
|
|
1538
|
+
# NULL is a round from before these records, or one whose record write
|
|
1539
|
+
# failed and stopped it before any provider ran — never assert which.
|
|
1540
|
+
return "not recorded (an older round, or one that stopped before any provider ran)"
|
|
1541
|
+
entries = list(view.members.values())
|
|
1542
|
+
|
|
1543
|
+
def counts(values: list[str], order: tuple[str, ...]) -> str:
|
|
1544
|
+
return ", ".join(f"{v} {values.count(v)}" for v in order if v in values)
|
|
1545
|
+
|
|
1546
|
+
memory = [e.memory for e in entries]
|
|
1547
|
+
memory_text = (
|
|
1548
|
+
"none found"
|
|
1549
|
+
if set(memory) == {"none"}
|
|
1550
|
+
else counts(memory, ("included", "dropped"))
|
|
1551
|
+
)
|
|
1552
|
+
return (
|
|
1553
|
+
"each member has its own; sessions: "
|
|
1554
|
+
+ counts([e.session for e in entries], ("resume", "fork", "cold"))
|
|
1555
|
+
+ f"; memory context: {memory_text}"
|
|
1556
|
+
+ f"; checkpoint excerpts are capped at {_ROOM_CANDIDATE_CONTEXT_MAX_BYTES:,} bytes each"
|
|
1557
|
+
)
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
def _timeout_note(group: RoundGroup) -> bool:
|
|
1561
|
+
"""Whether the report-derived gate profile still needs its ``timeout not
|
|
1562
|
+
recorded`` qualification (no usable settings record for the round)."""
|
|
1563
|
+
return group.gate_profile.kind == "commands" and group.gate_settings.status != "recorded"
|
|
1564
|
+
|
|
1565
|
+
|
|
1566
|
+
def selection_lines(selection: SelectionView | None) -> list[str]:
|
|
1567
|
+
"""The selection block every text surface shows (``room compare``, the TUI
|
|
1568
|
+
Compare view and the selected candidate's Details)."""
|
|
1569
|
+
if selection is None:
|
|
1570
|
+
return ["Selected: none"]
|
|
1571
|
+
who = _p(selection.candidate_id) if selection.candidate_id else "none"
|
|
1572
|
+
if selection.status == "not_recorded":
|
|
1573
|
+
return [
|
|
1574
|
+
f"Selected: {who} (human)",
|
|
1575
|
+
" selection record: not recorded (selected before records were kept)",
|
|
1576
|
+
]
|
|
1577
|
+
if selection.status != "bound":
|
|
1578
|
+
return [
|
|
1579
|
+
f"Selected: {who} (human)",
|
|
1580
|
+
f" selection record: INCONSISTENT — {_p(selection.reason)}",
|
|
1581
|
+
]
|
|
1582
|
+
confirmed = (
|
|
1583
|
+
f"revision {selection.confirmed_revision} confirmed"
|
|
1584
|
+
if selection.confirmed_revision is not None
|
|
1585
|
+
else "no revision confirmed (selected against the state at that moment)"
|
|
1586
|
+
)
|
|
1587
|
+
lines = [
|
|
1588
|
+
f"Selected: {who} (human)",
|
|
1589
|
+
f" patch sha256: {_p(selection.patch_sha256)}",
|
|
1590
|
+
f" {confirmed}; recorded as revision {selection.revision} via "
|
|
1591
|
+
f"{_SURFACE_TEXT.get(selection.surface or '', _p(selection.surface))} "
|
|
1592
|
+
f"at {_p(selection.selected_at)}",
|
|
1593
|
+
]
|
|
1594
|
+
if selection.rationale is None:
|
|
1595
|
+
lines.append(" rationale: none recorded")
|
|
1596
|
+
else:
|
|
1597
|
+
lines.append(" rationale:")
|
|
1598
|
+
lines.extend(f" {_p(line)}" for line in selection.rationale.split("\n"))
|
|
1599
|
+
return lines
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
def _row_lines(row: CandidateRow, group: RoundGroup) -> list[str]:
|
|
1603
|
+
attempt = "?" if row.attempt is None else row.attempt
|
|
1604
|
+
head = f" {_p(row.candidate_id)} {_p(row.member_id)} ({_p(row.provider_id)}) #{attempt} {_p(row.state)}"
|
|
1605
|
+
if row.selected:
|
|
1606
|
+
head += " [selected (human)]"
|
|
1607
|
+
lines = [head]
|
|
1608
|
+
if (start := row_start_label(group, row)) is not None:
|
|
1609
|
+
lines.append(f" starts from: {start}")
|
|
1610
|
+
if (conversation := conversation_label(group, row)) is not None:
|
|
1611
|
+
lines.append(f" conversation: {conversation}")
|
|
1612
|
+
if not row.readable:
|
|
1613
|
+
lines.append(f" unreadable: {_p(row.unreadable_reason)}")
|
|
1614
|
+
lines.append(f" gate: {gate_label(row.gate)}")
|
|
1615
|
+
for check in row.gate.checks:
|
|
1616
|
+
lines.append(f" {'✓' if check.passed else '×'} {_p(check.command)}")
|
|
1617
|
+
duration = (
|
|
1618
|
+
"not recorded" if row.duration_seconds is None else f"{row.duration_seconds:.1f}s"
|
|
1619
|
+
)
|
|
1620
|
+
lines.append(f" duration: {duration} | usage: {_usage_text(row.usage)}")
|
|
1621
|
+
if row.patch_bytes is None:
|
|
1622
|
+
patch = "unknown"
|
|
1623
|
+
else:
|
|
1624
|
+
patch = f"{row.patch_bytes:,} bytes"
|
|
1625
|
+
if row.patch_binary:
|
|
1626
|
+
patch += ", binary"
|
|
1627
|
+
if row.patch_large:
|
|
1628
|
+
patch += ", large"
|
|
1629
|
+
files = (
|
|
1630
|
+
"unknown" if row.files_changed is None else str(len(row.files_changed))
|
|
1631
|
+
)
|
|
1632
|
+
lines.append(f" patch: {patch} | files: {files} | base: {_p(row.base_commit)}")
|
|
1633
|
+
if row.eligible:
|
|
1634
|
+
lines.append(" eligible: yes")
|
|
1635
|
+
elif row.not_eligible_reason:
|
|
1636
|
+
lines.append(f" eligible: no ({_p(row.not_eligible_reason)})")
|
|
1637
|
+
else:
|
|
1638
|
+
lines.append(" eligible: no")
|
|
1639
|
+
if row.apply_blockers:
|
|
1640
|
+
lines.append(" apply blockers:")
|
|
1641
|
+
lines.extend(f" - {_p(b)}" for b in row.apply_blockers)
|
|
1642
|
+
else:
|
|
1643
|
+
lines.append(
|
|
1644
|
+
" apply blockers: none known (select/apply still revalidate)"
|
|
1645
|
+
)
|
|
1646
|
+
for warning in row.warnings:
|
|
1647
|
+
lines.append(f" warning: {_p(warning)}")
|
|
1648
|
+
if row.readable:
|
|
1649
|
+
lines.append(f" diff: syncmill room diff {_p(row.candidate_id)}")
|
|
1650
|
+
return lines
|
|
1651
|
+
|
|
1652
|
+
|
|
1653
|
+
def _profile_text(profile: GateProfile) -> str:
|
|
1654
|
+
if profile.kind == "commands":
|
|
1655
|
+
return "commands: " + "; ".join(_p(c) for c in profile.commands)
|
|
1656
|
+
return {
|
|
1657
|
+
"no_report": "no gate report on any completed candidate",
|
|
1658
|
+
"unknown": "unknown (no completed candidate with a usable record)",
|
|
1659
|
+
"mixed": "mixed (records disagree — see warnings)",
|
|
1660
|
+
}.get(profile.kind, profile.kind)
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
def _overlap_lines(overlap: FileOverlap) -> list[str]:
|
|
1664
|
+
lines = [" files:"]
|
|
1665
|
+
if len(overlap.compared) >= 2:
|
|
1666
|
+
lines.append(
|
|
1667
|
+
f" common to all ({len(overlap.common)}): "
|
|
1668
|
+
+ (", ".join(_p(n) for n in overlap.common) or "-")
|
|
1669
|
+
)
|
|
1670
|
+
for candidate_id, names in overlap.only.items():
|
|
1671
|
+
lines.append(
|
|
1672
|
+
f" only {_p(candidate_id)} ({len(names)}): "
|
|
1673
|
+
+ (", ".join(_p(n) for n in names) or "-")
|
|
1674
|
+
)
|
|
1675
|
+
if overlap.partial:
|
|
1676
|
+
lines.append(" changed by some, not all:")
|
|
1677
|
+
for name, owners in overlap.partial.items():
|
|
1678
|
+
lines.append(f" {_p(name)}: {', '.join(_p(o) for o in owners)}")
|
|
1679
|
+
if overlap.no_changes:
|
|
1680
|
+
lines.append(
|
|
1681
|
+
" no files changed: " + ", ".join(_p(c) for c in overlap.no_changes)
|
|
1682
|
+
)
|
|
1683
|
+
if overlap.unknown:
|
|
1684
|
+
lines.append(
|
|
1685
|
+
" file set unknown: " + ", ".join(_p(c) for c in overlap.unknown)
|
|
1686
|
+
)
|
|
1687
|
+
return lines
|
|
1688
|
+
|
|
1689
|
+
|
|
1690
|
+
def render_room_comparison(cmp: RoomComparison) -> str:
|
|
1691
|
+
source = _SOURCE_TEXT.get(cmp.source_status)
|
|
1692
|
+
if source is None:
|
|
1693
|
+
source = f"check unavailable ({_p(cmp.source_reason)})"
|
|
1694
|
+
lines = [
|
|
1695
|
+
f"Room {_p(cmp.room_id)} state: {_p(cmp.room_state)} revision: {cmp.revision}",
|
|
1696
|
+
f"Source: {source}",
|
|
1697
|
+
*selection_lines(cmp.selection),
|
|
1698
|
+
]
|
|
1699
|
+
for entry in cmp.damaged_rounds:
|
|
1700
|
+
lines.append(
|
|
1701
|
+
f"Damaged round {_p(entry['round_id'])}: {_p(entry['reason'])}"
|
|
1702
|
+
)
|
|
1703
|
+
if not cmp.groups:
|
|
1704
|
+
lines.append("")
|
|
1705
|
+
lines.append(
|
|
1706
|
+
"No readable rounds in this room."
|
|
1707
|
+
if cmp.damaged_rounds
|
|
1708
|
+
else "No rounds have run in this room yet."
|
|
1709
|
+
)
|
|
1710
|
+
return "\n".join(lines)
|
|
1711
|
+
for group in cmp.groups:
|
|
1712
|
+
settings = group.gate_settings
|
|
1713
|
+
lines.extend(
|
|
1714
|
+
[
|
|
1715
|
+
"",
|
|
1716
|
+
f"Round {group.number} of {cmp.round_count} ({_p(group.state)}) — "
|
|
1717
|
+
f"starting tree: {starting_tree_label(group)}",
|
|
1718
|
+
f" gate settings: {gate_settings_label(settings)}"
|
|
1719
|
+
+ (
|
|
1720
|
+
": " + "; ".join(_p(c) for c in settings.commands)
|
|
1721
|
+
if settings.status == "recorded" and settings.kind == "commands"
|
|
1722
|
+
else ""
|
|
1723
|
+
),
|
|
1724
|
+
f" gate reports: {_profile_text(group.gate_profile)}"
|
|
1725
|
+
+ ("; timeout not recorded" if _timeout_note(group) else ""),
|
|
1726
|
+
f" conversations: {round_conversation_label(group)}",
|
|
1727
|
+
]
|
|
1728
|
+
)
|
|
1729
|
+
if not group.rows and not group.missing:
|
|
1730
|
+
lines.append(" (no candidates)")
|
|
1731
|
+
for row in group.rows:
|
|
1732
|
+
lines.extend(_row_lines(row, group))
|
|
1733
|
+
for member in group.missing:
|
|
1734
|
+
if member.status == "in_progress":
|
|
1735
|
+
lines.append(f" {_p(member.member_id)} ({_p(member.provider_id)}): in progress")
|
|
1736
|
+
else:
|
|
1737
|
+
lines.append(
|
|
1738
|
+
f" {_p(member.member_id)} ({_p(member.provider_id)}): no candidate retained ("
|
|
1739
|
+
+ "; ".join(_p(e) for e in member.evidence)
|
|
1740
|
+
+ ")"
|
|
1741
|
+
)
|
|
1742
|
+
if group.rows:
|
|
1743
|
+
lines.extend(_overlap_lines(group.overlap))
|
|
1744
|
+
for warning in group.warnings:
|
|
1745
|
+
lines.append(f" warning: {_p(warning)}")
|
|
1746
|
+
if cmp.cross_round or cmp.cross_round_settings:
|
|
1747
|
+
lines.extend(["", "Across rounds:"])
|
|
1748
|
+
lines.extend(f" {line}" for line in cross_round_lines(cmp, _p))
|
|
1749
|
+
return "\n".join(lines)
|
|
1750
|
+
|
|
1751
|
+
|
|
1752
|
+
def cross_round_lines(cmp: RoomComparison, esc: Callable[[object], str]) -> list[str]:
|
|
1753
|
+
"""The cross-round summary every surface shows: recorded gate settings,
|
|
1754
|
+
report-observed commands, and each round's starting tree. ``esc`` escapes
|
|
1755
|
+
the free-text parts (commands, reasons) for the target surface."""
|
|
1756
|
+
lines: list[str] = []
|
|
1757
|
+
if cmp.cross_round_settings:
|
|
1758
|
+
lines.append("recorded gate settings:")
|
|
1759
|
+
for entry in cmp.cross_round_settings:
|
|
1760
|
+
rounds = ", ".join(str(n) for n in entry["rounds"])
|
|
1761
|
+
if entry["settings"] == "undecidable":
|
|
1762
|
+
lines.append(f" round {rounds}: cannot establish equality ({esc(entry['reason'])})")
|
|
1763
|
+
continue
|
|
1764
|
+
what = (
|
|
1765
|
+
"no gate configured"
|
|
1766
|
+
if entry["settings"] == "none"
|
|
1767
|
+
else f"timeout {entry['timeout_seconds']}s, commands: "
|
|
1768
|
+
+ "; ".join(esc(c) for c in entry["commands"])
|
|
1769
|
+
)
|
|
1770
|
+
line = f" rounds {rounds}: same settings: {what}"
|
|
1771
|
+
notes = []
|
|
1772
|
+
if entry["contradicted_rounds"]:
|
|
1773
|
+
notes.append(
|
|
1774
|
+
"reports contradict them in round "
|
|
1775
|
+
+ ", ".join(str(n) for n in entry["contradicted_rounds"])
|
|
1776
|
+
)
|
|
1777
|
+
if entry["indeterminate_rounds"]:
|
|
1778
|
+
notes.append(
|
|
1779
|
+
"reports could not all be checked in round "
|
|
1780
|
+
+ ", ".join(str(n) for n in entry["indeterminate_rounds"])
|
|
1781
|
+
)
|
|
1782
|
+
if notes:
|
|
1783
|
+
line += " (" + "; ".join(notes) + ")"
|
|
1784
|
+
lines.append(line)
|
|
1785
|
+
if cmp.cross_round:
|
|
1786
|
+
lines.append("gate commands observed in reports (reports carry no timeout):")
|
|
1787
|
+
for entry in cmp.cross_round:
|
|
1788
|
+
rounds = ", ".join(str(n) for n in entry["rounds"])
|
|
1789
|
+
if entry["gate_profile"] == "undecidable":
|
|
1790
|
+
lines.append(f" round {rounds}: cannot establish equality ({esc(entry['reason'])})")
|
|
1791
|
+
else:
|
|
1792
|
+
lines.append(
|
|
1793
|
+
f" rounds {rounds}: same commands: "
|
|
1794
|
+
+ "; ".join(esc(c) for c in entry["commands"])
|
|
1795
|
+
)
|
|
1796
|
+
lines.append("starting tree:")
|
|
1797
|
+
lines.extend(f" round {g.number}: {starting_tree_label(g)}" for g in cmp.groups)
|
|
1798
|
+
return lines
|
|
1799
|
+
|
|
1800
|
+
|
|
1801
|
+
# ------------------------------------------------------------ compact table
|
|
1802
|
+
|
|
1803
|
+
#: Rendered file names per overlap section in the compact table; a large patch
|
|
1804
|
+
#: set must not turn one render into an unbounded string build.
|
|
1805
|
+
TABLE_FILE_CAP = 200
|
|
1806
|
+
|
|
1807
|
+
|
|
1808
|
+
def _clip(value: object, width: int) -> str:
|
|
1809
|
+
text = _p(value)
|
|
1810
|
+
return text if len(text) <= width else text[: width - 1] + "…"
|
|
1811
|
+
|
|
1812
|
+
|
|
1813
|
+
def _size(n: int | None) -> str:
|
|
1814
|
+
if n is None:
|
|
1815
|
+
return "?"
|
|
1816
|
+
if n < 1024:
|
|
1817
|
+
return f"{n} B"
|
|
1818
|
+
if n < 1024 * 1024:
|
|
1819
|
+
return f"{n / 1024:.1f} KB"
|
|
1820
|
+
return f"{n / (1024 * 1024):.1f} MB"
|
|
1821
|
+
|
|
1822
|
+
|
|
1823
|
+
def _usage_short(usage: TokenUsage | None) -> str:
|
|
1824
|
+
if usage is None:
|
|
1825
|
+
return "not reported"
|
|
1826
|
+
|
|
1827
|
+
def k(n: int) -> str:
|
|
1828
|
+
# Integer arithmetic: a huge (but valid) count from a corrupt manifest
|
|
1829
|
+
# must not overflow a float division.
|
|
1830
|
+
return f"{n // 1000}.{n % 1000 // 100}k" if n >= 1000 else str(n)
|
|
1831
|
+
|
|
1832
|
+
parts = []
|
|
1833
|
+
if usage.total_input_tokens is not None:
|
|
1834
|
+
parts.append(f"in {k(usage.total_input_tokens)}")
|
|
1835
|
+
if usage.output_tokens is not None:
|
|
1836
|
+
parts.append(f"out {k(usage.output_tokens)}")
|
|
1837
|
+
if usage.total_cost_usd is not None:
|
|
1838
|
+
parts.append(f"${usage.total_cost_usd:.2f}")
|
|
1839
|
+
if not parts and usage.cache_read_tokens is not None:
|
|
1840
|
+
parts.append(f"cache {k(usage.cache_read_tokens)}")
|
|
1841
|
+
if not parts and usage.cache_creation_tokens is not None:
|
|
1842
|
+
parts.append(f"cache write {k(usage.cache_creation_tokens)}")
|
|
1843
|
+
return " ".join(parts) or "not reported"
|
|
1844
|
+
|
|
1845
|
+
|
|
1846
|
+
def _capped(names: tuple[str, ...]) -> str:
|
|
1847
|
+
shown = ", ".join(_p(n) for n in names[:TABLE_FILE_CAP]) or "-"
|
|
1848
|
+
if len(names) > TABLE_FILE_CAP:
|
|
1849
|
+
shown += f", ... {len(names) - TABLE_FILE_CAP} more"
|
|
1850
|
+
return shown
|
|
1851
|
+
|
|
1852
|
+
|
|
1853
|
+
def find_round(cmp: RoomComparison, round_number: int) -> RoundGroup | None:
|
|
1854
|
+
for group in cmp.groups:
|
|
1855
|
+
if group.number == round_number:
|
|
1856
|
+
return group
|
|
1857
|
+
return None
|
|
1858
|
+
|
|
1859
|
+
|
|
1860
|
+
def find_row(cmp: RoomComparison, candidate_id: str) -> tuple[RoundGroup, CandidateRow] | None:
|
|
1861
|
+
for group in cmp.groups:
|
|
1862
|
+
for row in group.rows:
|
|
1863
|
+
if row.candidate_id == candidate_id:
|
|
1864
|
+
return group, row
|
|
1865
|
+
return None
|
|
1866
|
+
|
|
1867
|
+
|
|
1868
|
+
def render_round_table(
|
|
1869
|
+
cmp: RoomComparison,
|
|
1870
|
+
round_number: int,
|
|
1871
|
+
*,
|
|
1872
|
+
highlighted_id: str | None = None,
|
|
1873
|
+
as_of: str | None = None,
|
|
1874
|
+
) -> str:
|
|
1875
|
+
"""One round of a comparison as a compact, width-bounded table for the TUI
|
|
1876
|
+
Compare view. The highlighted (UI cursor) row is marked ``>``; the
|
|
1877
|
+
persisted human choice is marked ``selected`` — they are different things."""
|
|
1878
|
+
group = find_round(cmp, round_number)
|
|
1879
|
+
source = _SOURCE_TEXT.get(cmp.source_status)
|
|
1880
|
+
if source is None:
|
|
1881
|
+
source = f"check unavailable ({_p(cmp.source_reason)})"
|
|
1882
|
+
lines = [f"[Compare] room {_p(cmp.room_id[:8])} ({_p(cmp.room_state)})"]
|
|
1883
|
+
for entry in cmp.damaged_rounds:
|
|
1884
|
+
lines.append(f"Damaged round {_p(entry['round_id'])}: {_p(entry['reason'])}")
|
|
1885
|
+
if group is None:
|
|
1886
|
+
lines.append(f"Round {round_number} is not in this comparison.")
|
|
1887
|
+
return "\n".join(lines)
|
|
1888
|
+
settings = group.gate_settings
|
|
1889
|
+
lines.extend(
|
|
1890
|
+
[
|
|
1891
|
+
f"Round {group.number} of {cmp.round_count} ({_p(group.state)})",
|
|
1892
|
+
f"starting tree: {starting_tree_label(group)}",
|
|
1893
|
+
f"gate settings: {gate_settings_label(settings)}"
|
|
1894
|
+
+ (
|
|
1895
|
+
": " + "; ".join(_p(c) for c in settings.commands)
|
|
1896
|
+
if settings.status == "recorded" and settings.kind == "commands"
|
|
1897
|
+
else ""
|
|
1898
|
+
),
|
|
1899
|
+
f"gate reports: {_profile_text(group.gate_profile)}"
|
|
1900
|
+
+ (" (timeout not recorded)" if _timeout_note(group) else ""),
|
|
1901
|
+
f"conversations: {round_conversation_label(group)}",
|
|
1902
|
+
f"source: {source}",
|
|
1903
|
+
*selection_lines(cmp.selection),
|
|
1904
|
+
*([f"evidence as of {as_of}"] if as_of else []),
|
|
1905
|
+
"",
|
|
1906
|
+
]
|
|
1907
|
+
)
|
|
1908
|
+
# Stacked, not columnar: the TUI candidate pane is ~45 columns wide, so
|
|
1909
|
+
# each candidate is three short lines instead of one wrapped table row.
|
|
1910
|
+
for row in group.rows:
|
|
1911
|
+
head = (
|
|
1912
|
+
(">" if row.candidate_id == highlighted_id else " ")
|
|
1913
|
+
+ f" {_clip(row.candidate_id[:8], 8)} {_clip(row.member_id, 12)} {_p(row.state)}"
|
|
1914
|
+
)
|
|
1915
|
+
if not row.readable:
|
|
1916
|
+
head += " UNREADABLE"
|
|
1917
|
+
if row.selected:
|
|
1918
|
+
head += " [selected]"
|
|
1919
|
+
time_text = "?" if row.duration_seconds is None else f"{row.duration_seconds:.1f}s"
|
|
1920
|
+
patch = _size(row.patch_bytes)
|
|
1921
|
+
if row.patch_binary:
|
|
1922
|
+
patch += " bin"
|
|
1923
|
+
if row.patch_large:
|
|
1924
|
+
patch += " large"
|
|
1925
|
+
files = "? files" if row.files_changed is None else f"{len(row.files_changed)} files"
|
|
1926
|
+
start = []
|
|
1927
|
+
if group.lineage.status == "recorded" and group.lineage.tree == "continued":
|
|
1928
|
+
parent = group.lineage.parents.get(row.member_id)
|
|
1929
|
+
start = [
|
|
1930
|
+
" from: parent not recorded"
|
|
1931
|
+
if parent is None
|
|
1932
|
+
else f" from: {_clip(parent[0][:8], 8)} (round {parent[1]})"
|
|
1933
|
+
]
|
|
1934
|
+
entry = member_conversation(group, row)
|
|
1935
|
+
conv = []
|
|
1936
|
+
if entry is not None:
|
|
1937
|
+
conv = [
|
|
1938
|
+
f" conv: {entry.session}, "
|
|
1939
|
+
+ (f"delta since r{entry.since_round}" if entry.context == "delta" else "full")
|
|
1940
|
+
+ f", {len(entry.candidates)} cand"
|
|
1941
|
+
+ (" +omitted" if entry.omitted else "")
|
|
1942
|
+
+ f", mem {entry.memory}"
|
|
1943
|
+
]
|
|
1944
|
+
lines.extend(
|
|
1945
|
+
[
|
|
1946
|
+
head,
|
|
1947
|
+
*start,
|
|
1948
|
+
*conv,
|
|
1949
|
+
f" gate {_GATE_TEXT.get(row.gate.verdict, row.gate.verdict)}"
|
|
1950
|
+
f" · {time_text} · {_usage_short(row.usage)}",
|
|
1951
|
+
f" {patch} · {files} · "
|
|
1952
|
+
+ ("eligible" if row.eligible else "ineligible")
|
|
1953
|
+
+ f" · blockers {len(row.apply_blockers)}",
|
|
1954
|
+
]
|
|
1955
|
+
)
|
|
1956
|
+
if not group.rows:
|
|
1957
|
+
lines.append(" (no candidates retained in this round)")
|
|
1958
|
+
for member in group.missing:
|
|
1959
|
+
if member.status == "in_progress":
|
|
1960
|
+
lines.append(f" {_p(member.member_id)}: in progress")
|
|
1961
|
+
else:
|
|
1962
|
+
lines.append(
|
|
1963
|
+
f" {_p(member.member_id)}: no candidate retained ("
|
|
1964
|
+
+ "; ".join(_p(e) for e in member.evidence)
|
|
1965
|
+
+ ")"
|
|
1966
|
+
)
|
|
1967
|
+
overlap = group.overlap
|
|
1968
|
+
if group.rows:
|
|
1969
|
+
lines.append("")
|
|
1970
|
+
lines.append("files:")
|
|
1971
|
+
if len(overlap.compared) >= 2:
|
|
1972
|
+
lines.append(f" common to all ({len(overlap.common)}): {_capped(overlap.common)}")
|
|
1973
|
+
for candidate_id, names in overlap.only.items():
|
|
1974
|
+
lines.append(f" only {_p(candidate_id[:8])} ({len(names)}): {_capped(names)}")
|
|
1975
|
+
if overlap.partial:
|
|
1976
|
+
partial = tuple(overlap.partial)
|
|
1977
|
+
lines.append(f" changed by some, not all ({len(partial)}): {_capped(partial)}")
|
|
1978
|
+
if overlap.no_changes:
|
|
1979
|
+
lines.append(
|
|
1980
|
+
" no files changed: " + ", ".join(_p(c[:8]) for c in overlap.no_changes)
|
|
1981
|
+
)
|
|
1982
|
+
if overlap.unknown:
|
|
1983
|
+
lines.append(
|
|
1984
|
+
" file set unknown: " + ", ".join(_p(c[:8]) for c in overlap.unknown)
|
|
1985
|
+
)
|
|
1986
|
+
for warning in group.warnings:
|
|
1987
|
+
lines.append(f"warning: {_p(warning)}")
|
|
1988
|
+
for row in group.rows:
|
|
1989
|
+
for warning in row.warnings:
|
|
1990
|
+
lines.append(f"warning ({_p(row.candidate_id[:8])}): {_p(warning)}")
|
|
1991
|
+
lines.append("")
|
|
1992
|
+
lines.append("d/o/m on a row for its diff/output/details; r rebuilds this snapshot")
|
|
1993
|
+
return "\n".join(lines)
|
|
1994
|
+
|
|
1995
|
+
|
|
1996
|
+
# ------------------------------------------------------------ review export
|
|
1997
|
+
|
|
1998
|
+
#: Fixed last line of every review summary.
|
|
1999
|
+
REVIEW_FOOTER = (
|
|
2000
|
+
"Recorded evidence only. Selection is not apply; apply re-verifies the source and patch."
|
|
2001
|
+
)
|
|
2002
|
+
|
|
2003
|
+
_ASCII_PUNCTUATION = frozenset("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~")
|
|
2004
|
+
|
|
2005
|
+
|
|
2006
|
+
def _md(value: object) -> str:
|
|
2007
|
+
"""An inline Markdown value: control characters escaped, then EVERY ASCII
|
|
2008
|
+
punctuation character backslash-escaped, so no heading, list, link,
|
|
2009
|
+
autolink, HTML or table cell boundary can form from it. Only ids, numbers
|
|
2010
|
+
and closed-vocabulary values are placed inline; free text goes through
|
|
2011
|
+
:func:`_md_fence`."""
|
|
2012
|
+
return "".join(
|
|
2013
|
+
"\\" + ch if ch in _ASCII_PUNCTUATION else ch for ch in _p(value)
|
|
2014
|
+
)
|
|
2015
|
+
|
|
2016
|
+
|
|
2017
|
+
def _md_fence(lines: list[str]) -> list[str]:
|
|
2018
|
+
"""Free-form stored text as a fenced code block, literal in every
|
|
2019
|
+
CommonMark/GFM renderer: the fence is a backtick run longer than any run
|
|
2020
|
+
in the content (minimum 3), so no content line can close it."""
|
|
2021
|
+
body = [_p(line) for line in lines]
|
|
2022
|
+
longest = 0
|
|
2023
|
+
for line in body:
|
|
2024
|
+
run = 0
|
|
2025
|
+
for ch in line:
|
|
2026
|
+
run = run + 1 if ch == "`" else 0
|
|
2027
|
+
longest = max(longest, run)
|
|
2028
|
+
fence = "`" * max(3, longest + 1)
|
|
2029
|
+
return ["", fence, *body, fence, ""]
|
|
2030
|
+
|
|
2031
|
+
|
|
2032
|
+
def _md_table(header: list[str], rows: list[list[str]]) -> list[str]:
|
|
2033
|
+
"""A GFM table; every cell must already be :func:`_md` output (or fixed
|
|
2034
|
+
template text)."""
|
|
2035
|
+
return [
|
|
2036
|
+
"",
|
|
2037
|
+
"| " + " | ".join(header) + " |",
|
|
2038
|
+
"|" + "|".join("---" for _ in header) + "|",
|
|
2039
|
+
*("| " + " | ".join(row) + " |" for row in rows),
|
|
2040
|
+
"",
|
|
2041
|
+
]
|
|
2042
|
+
|
|
2043
|
+
|
|
2044
|
+
def _md_capped(names: tuple[str, ...]) -> list[str]:
|
|
2045
|
+
shown = [f" {name}" for name in names[:TABLE_FILE_CAP]]
|
|
2046
|
+
if len(names) > TABLE_FILE_CAP:
|
|
2047
|
+
shown.append(f" ... {len(names) - TABLE_FILE_CAP} more")
|
|
2048
|
+
return shown
|
|
2049
|
+
|
|
2050
|
+
|
|
2051
|
+
def _md_selection(selection: SelectionView | None) -> list[str]:
|
|
2052
|
+
lines = ["", "## Selection", ""]
|
|
2053
|
+
if selection is None:
|
|
2054
|
+
return [*lines, "Selected: none"]
|
|
2055
|
+
who = _md(selection.candidate_id) if selection.candidate_id else "none"
|
|
2056
|
+
if selection.status == "not_recorded":
|
|
2057
|
+
return [
|
|
2058
|
+
*lines,
|
|
2059
|
+
f"Selected (human): {who}",
|
|
2060
|
+
"",
|
|
2061
|
+
"Selection record: not recorded (selected before records were kept)",
|
|
2062
|
+
]
|
|
2063
|
+
if selection.status != "bound":
|
|
2064
|
+
return [
|
|
2065
|
+
*lines,
|
|
2066
|
+
f"Selected (human): {who}",
|
|
2067
|
+
"",
|
|
2068
|
+
"Selection record: INCONSISTENT, reason:",
|
|
2069
|
+
*_md_fence([selection.reason or ""]),
|
|
2070
|
+
]
|
|
2071
|
+
confirmed = (
|
|
2072
|
+
_md(selection.confirmed_revision)
|
|
2073
|
+
if selection.confirmed_revision is not None
|
|
2074
|
+
else "none (selected against the state at that moment)"
|
|
2075
|
+
)
|
|
2076
|
+
surface = _SURFACE_TEXT.get(selection.surface or "")
|
|
2077
|
+
lines.extend(
|
|
2078
|
+
_md_table(
|
|
2079
|
+
["Field", "Value"],
|
|
2080
|
+
[
|
|
2081
|
+
["Candidate (human choice)", who],
|
|
2082
|
+
["Patch sha256", _md(selection.patch_sha256)],
|
|
2083
|
+
["Confirmed revision", confirmed],
|
|
2084
|
+
["Recorded as revision", _md(selection.revision)],
|
|
2085
|
+
["Surface", surface or _md(selection.surface)],
|
|
2086
|
+
["Selected at", _md(selection.selected_at)],
|
|
2087
|
+
],
|
|
2088
|
+
)
|
|
2089
|
+
)
|
|
2090
|
+
if selection.rationale is None:
|
|
2091
|
+
lines.append("Rationale: none recorded")
|
|
2092
|
+
else:
|
|
2093
|
+
lines.append("Rationale:")
|
|
2094
|
+
lines.extend(_md_fence(selection.rationale.split("\n")))
|
|
2095
|
+
return lines
|
|
2096
|
+
|
|
2097
|
+
|
|
2098
|
+
def _md_patch(row: CandidateRow) -> str:
|
|
2099
|
+
if row.patch_bytes is None:
|
|
2100
|
+
return "unknown"
|
|
2101
|
+
patch = f"{row.patch_bytes:,} bytes"
|
|
2102
|
+
if row.patch_binary:
|
|
2103
|
+
patch += ", binary"
|
|
2104
|
+
if row.patch_large:
|
|
2105
|
+
patch += ", large"
|
|
2106
|
+
return _md(patch)
|
|
2107
|
+
|
|
2108
|
+
|
|
2109
|
+
def _md_candidate(
|
|
2110
|
+
row: CandidateRow,
|
|
2111
|
+
start: str | None = None,
|
|
2112
|
+
conversation: str | None = None,
|
|
2113
|
+
entry: MemberConversation | None = None,
|
|
2114
|
+
) -> list[str]:
|
|
2115
|
+
attempt = "unknown" if row.attempt is None else _md(row.attempt)
|
|
2116
|
+
lines = ["", f"### Candidate {_md(row.candidate_id)}"]
|
|
2117
|
+
lines.extend(
|
|
2118
|
+
_md_table(
|
|
2119
|
+
["Field", "Value"],
|
|
2120
|
+
[
|
|
2121
|
+
["Member", _md(row.member_id)],
|
|
2122
|
+
["Provider", _md(row.provider_id)],
|
|
2123
|
+
["Attempt", attempt],
|
|
2124
|
+
["State", _md(row.state)],
|
|
2125
|
+
["Selected", "yes (human)" if row.selected else "no"],
|
|
2126
|
+
["Readable", "yes" if row.readable else "no"],
|
|
2127
|
+
["Patch sha256", _md(row.patch_sha256)],
|
|
2128
|
+
["Base commit", _md(row.base_commit)],
|
|
2129
|
+
*([["Starts from", _md(start)]] if start is not None else []),
|
|
2130
|
+
*([["Conversation", _md(conversation)]] if conversation is not None else []),
|
|
2131
|
+
*(
|
|
2132
|
+
[["Checkpoint candidates", _md(", ".join(entry.candidates)) or "none"]]
|
|
2133
|
+
if entry is not None
|
|
2134
|
+
else []
|
|
2135
|
+
),
|
|
2136
|
+
],
|
|
2137
|
+
)
|
|
2138
|
+
)
|
|
2139
|
+
if not row.readable:
|
|
2140
|
+
lines.append("Unreadable, reason:")
|
|
2141
|
+
lines.extend(_md_fence([row.unreadable_reason or ""]))
|
|
2142
|
+
lines.append(f"Gate: {_md(_GATE_TEXT.get(row.gate.verdict, row.gate.verdict))}")
|
|
2143
|
+
if row.gate.reason:
|
|
2144
|
+
lines.extend(["", "Gate reason:", *_md_fence([row.gate.reason])])
|
|
2145
|
+
if row.gate.checks:
|
|
2146
|
+
lines.extend(
|
|
2147
|
+
_md_table(
|
|
2148
|
+
["Check", "Result", "Exit code", "Duration"],
|
|
2149
|
+
[
|
|
2150
|
+
[
|
|
2151
|
+
_md(index),
|
|
2152
|
+
"pass" if check.passed else "fail",
|
|
2153
|
+
"not recorded" if check.exit_code is None else _md(check.exit_code),
|
|
2154
|
+
"not recorded"
|
|
2155
|
+
if check.duration_seconds is None
|
|
2156
|
+
else _md(f"{check.duration_seconds:.1f}s"),
|
|
2157
|
+
]
|
|
2158
|
+
for index, check in enumerate(row.gate.checks, 1)
|
|
2159
|
+
],
|
|
2160
|
+
)
|
|
2161
|
+
)
|
|
2162
|
+
lines.append("Check commands:")
|
|
2163
|
+
lines.extend(
|
|
2164
|
+
_md_fence([f"{i}: {check.command}" for i, check in enumerate(row.gate.checks, 1)])
|
|
2165
|
+
)
|
|
2166
|
+
if row.eligible:
|
|
2167
|
+
lines.extend(["", "Eligible: yes"])
|
|
2168
|
+
elif row.not_eligible_reason:
|
|
2169
|
+
lines.extend(["", "Eligible: no, reason:", *_md_fence([row.not_eligible_reason])])
|
|
2170
|
+
else:
|
|
2171
|
+
lines.extend(["", "Eligible: no"])
|
|
2172
|
+
if row.apply_blockers:
|
|
2173
|
+
lines.extend(["", "Apply blockers:", *_md_fence(list(row.apply_blockers))])
|
|
2174
|
+
else:
|
|
2175
|
+
lines.extend(["", "Apply blockers: none known (select/apply still revalidate)"])
|
|
2176
|
+
if row.warnings:
|
|
2177
|
+
lines.extend(["", "Warnings:", *_md_fence(list(row.warnings))])
|
|
2178
|
+
return lines
|
|
2179
|
+
|
|
2180
|
+
|
|
2181
|
+
def _md_overlap(overlap: FileOverlap) -> list[str]:
|
|
2182
|
+
body: list[str] = []
|
|
2183
|
+
if len(overlap.compared) >= 2:
|
|
2184
|
+
body.append(f"common to all ({len(overlap.common)}):")
|
|
2185
|
+
body.extend(_md_capped(overlap.common))
|
|
2186
|
+
for candidate_id, names in overlap.only.items():
|
|
2187
|
+
body.append(f"only {candidate_id} ({len(names)}):")
|
|
2188
|
+
body.extend(_md_capped(names))
|
|
2189
|
+
if overlap.partial:
|
|
2190
|
+
partial = tuple(overlap.partial)
|
|
2191
|
+
body.append(f"changed by some, not all ({len(partial)}):")
|
|
2192
|
+
for name in partial[:TABLE_FILE_CAP]:
|
|
2193
|
+
body.append(f" {name}: {', '.join(overlap.partial[name])}")
|
|
2194
|
+
if len(partial) > TABLE_FILE_CAP:
|
|
2195
|
+
body.append(f" ... {len(partial) - TABLE_FILE_CAP} more")
|
|
2196
|
+
if overlap.no_changes:
|
|
2197
|
+
body.append("no files changed: " + ", ".join(overlap.no_changes))
|
|
2198
|
+
if overlap.unknown:
|
|
2199
|
+
body.append("file set unknown: " + ", ".join(overlap.unknown))
|
|
2200
|
+
if not body:
|
|
2201
|
+
return []
|
|
2202
|
+
return ["", "Files:", *_md_fence(body)]
|
|
2203
|
+
|
|
2204
|
+
|
|
2205
|
+
def _md_round(group: RoundGroup, round_count: int) -> list[str]:
|
|
2206
|
+
lines = [
|
|
2207
|
+
"",
|
|
2208
|
+
f"## Round {_md(group.number)} of {_md(round_count)} ({_md(group.state)})",
|
|
2209
|
+
"",
|
|
2210
|
+
f"Round id: {_md(group.round_id)}",
|
|
2211
|
+
"",
|
|
2212
|
+
f"Starting tree: {_md(starting_tree_label(group))}",
|
|
2213
|
+
"",
|
|
2214
|
+
]
|
|
2215
|
+
lines.extend([f"Conversations: {_md(round_conversation_label(group))}", ""])
|
|
2216
|
+
settings = group.gate_settings
|
|
2217
|
+
if settings.status == "recorded" and settings.kind == "commands":
|
|
2218
|
+
lines.append(f"Gate settings: {_md(gate_settings_label(settings))}:")
|
|
2219
|
+
lines.extend(_md_fence(list(settings.commands)))
|
|
2220
|
+
else:
|
|
2221
|
+
lines.extend([f"Gate settings: {_md(gate_settings_label(settings))}", ""])
|
|
2222
|
+
profile = group.gate_profile
|
|
2223
|
+
if profile.kind == "commands":
|
|
2224
|
+
lines.append(
|
|
2225
|
+
"Gate reports: commands (timeout not recorded):"
|
|
2226
|
+
if _timeout_note(group)
|
|
2227
|
+
else "Gate reports: commands:"
|
|
2228
|
+
)
|
|
2229
|
+
lines.extend(_md_fence(list(profile.commands)))
|
|
2230
|
+
else:
|
|
2231
|
+
lines.append(
|
|
2232
|
+
"Gate reports: "
|
|
2233
|
+
+ {
|
|
2234
|
+
"no_report": "no gate report on any completed candidate",
|
|
2235
|
+
"unknown": "unknown (no completed candidate with a usable record)",
|
|
2236
|
+
"mixed": "mixed (records disagree, see warnings)",
|
|
2237
|
+
}.get(profile.kind, _md(profile.kind))
|
|
2238
|
+
)
|
|
2239
|
+
if not group.rows and not group.missing:
|
|
2240
|
+
lines.extend(["", "No candidates."])
|
|
2241
|
+
if group.rows:
|
|
2242
|
+
lines.extend(
|
|
2243
|
+
_md_table(
|
|
2244
|
+
[
|
|
2245
|
+
"Candidate", "Provider", "State", "Gate", "Eligible",
|
|
2246
|
+
"Blockers", "Duration", "Usage", "Files", "Patch",
|
|
2247
|
+
],
|
|
2248
|
+
[
|
|
2249
|
+
[
|
|
2250
|
+
_md(row.candidate_id)
|
|
2251
|
+
+ (" (selected, human)" if row.selected else ""),
|
|
2252
|
+
_md(row.provider_id),
|
|
2253
|
+
_md(row.state),
|
|
2254
|
+
_md(_GATE_TEXT.get(row.gate.verdict, row.gate.verdict)),
|
|
2255
|
+
"yes" if row.eligible else "no",
|
|
2256
|
+
_md(len(row.apply_blockers)),
|
|
2257
|
+
"not recorded"
|
|
2258
|
+
if row.duration_seconds is None
|
|
2259
|
+
else _md(f"{row.duration_seconds:.1f}s"),
|
|
2260
|
+
_md(_usage_text(row.usage)),
|
|
2261
|
+
"unknown"
|
|
2262
|
+
if row.files_changed is None
|
|
2263
|
+
else _md(len(row.files_changed)),
|
|
2264
|
+
_md_patch(row),
|
|
2265
|
+
]
|
|
2266
|
+
for row in group.rows
|
|
2267
|
+
],
|
|
2268
|
+
)
|
|
2269
|
+
)
|
|
2270
|
+
if group.missing:
|
|
2271
|
+
lines.extend(["", "Members without a retained candidate:"])
|
|
2272
|
+
lines.extend(
|
|
2273
|
+
_md_table(
|
|
2274
|
+
["Member", "Provider", "Status"],
|
|
2275
|
+
[
|
|
2276
|
+
[
|
|
2277
|
+
_md(m.member_id),
|
|
2278
|
+
_md(m.provider_id),
|
|
2279
|
+
"in progress" if m.status == "in_progress" else "no candidate retained",
|
|
2280
|
+
]
|
|
2281
|
+
for m in group.missing
|
|
2282
|
+
],
|
|
2283
|
+
)
|
|
2284
|
+
)
|
|
2285
|
+
evidence = [
|
|
2286
|
+
f"{m.member_id}: " + "; ".join(m.evidence)
|
|
2287
|
+
for m in group.missing
|
|
2288
|
+
if m.status != "in_progress"
|
|
2289
|
+
]
|
|
2290
|
+
if evidence:
|
|
2291
|
+
lines.append("Progress evidence:")
|
|
2292
|
+
lines.extend(_md_fence(evidence))
|
|
2293
|
+
if group.rows:
|
|
2294
|
+
lines.extend(_md_overlap(group.overlap))
|
|
2295
|
+
if group.warnings:
|
|
2296
|
+
lines.extend(["", "Round warnings:", *_md_fence(list(group.warnings))])
|
|
2297
|
+
for row in group.rows:
|
|
2298
|
+
lines.extend(
|
|
2299
|
+
_md_candidate(
|
|
2300
|
+
row,
|
|
2301
|
+
row_start_label(group, row),
|
|
2302
|
+
conversation_label(group, row),
|
|
2303
|
+
member_conversation(group, row),
|
|
2304
|
+
)
|
|
2305
|
+
)
|
|
2306
|
+
return lines
|
|
2307
|
+
|
|
2308
|
+
|
|
2309
|
+
def render_review_markdown(
|
|
2310
|
+
cmp: RoomComparison, *, generated_at: str, appendix: list[str] | None = None
|
|
2311
|
+
) -> str:
|
|
2312
|
+
"""A portable, body-free review summary of one comparison.
|
|
2313
|
+
|
|
2314
|
+
It carries the same qualifications as :func:`render_room_comparison` (the
|
|
2315
|
+
starting tree, ``timeout not recorded``, every gate check), so a handoff
|
|
2316
|
+
never implies equal starting trees or gate settings. Escaping is by
|
|
2317
|
+
position: inline cells and header values hold only :func:`_md` output,
|
|
2318
|
+
and every free-form stored string (rationale, file names, gate commands,
|
|
2319
|
+
warnings, reasons) sits in a :func:`_md_fence` block.
|
|
2320
|
+
|
|
2321
|
+
``appendix`` lines go before the footer; this module never builds them
|
|
2322
|
+
(the explicit raw-content section comes from ``room_raw``)."""
|
|
2323
|
+
source = _SOURCE_TEXT.get(cmp.source_status)
|
|
2324
|
+
lines = [
|
|
2325
|
+
f"# Room review: {_md(cmp.room_id)}",
|
|
2326
|
+
*_md_table(
|
|
2327
|
+
["Field", "Value"],
|
|
2328
|
+
[
|
|
2329
|
+
["Room", _md(cmp.room_id)],
|
|
2330
|
+
["State", _md(cmp.room_state)],
|
|
2331
|
+
["Revision", _md(cmp.revision)],
|
|
2332
|
+
["Source", _md(source) if source else "check unavailable"],
|
|
2333
|
+
["Generated", _md(generated_at)],
|
|
2334
|
+
],
|
|
2335
|
+
),
|
|
2336
|
+
]
|
|
2337
|
+
if source is None:
|
|
2338
|
+
lines.extend(["Source check unavailable, reason:", *_md_fence([cmp.source_reason or ""])])
|
|
2339
|
+
lines.extend(_md_selection(cmp.selection))
|
|
2340
|
+
if cmp.damaged_rounds:
|
|
2341
|
+
lines.extend(["", "## Damaged rounds"])
|
|
2342
|
+
for entry in cmp.damaged_rounds:
|
|
2343
|
+
lines.extend(["", f"Round {_md(entry['round_id'])}:", *_md_fence([entry["reason"]])])
|
|
2344
|
+
if not cmp.groups:
|
|
2345
|
+
lines.extend(
|
|
2346
|
+
[
|
|
2347
|
+
"",
|
|
2348
|
+
"No readable rounds in this room."
|
|
2349
|
+
if cmp.damaged_rounds
|
|
2350
|
+
else "No rounds have run in this room yet.",
|
|
2351
|
+
]
|
|
2352
|
+
)
|
|
2353
|
+
for group in cmp.groups:
|
|
2354
|
+
lines.extend(_md_round(group, cmp.round_count))
|
|
2355
|
+
if cmp.cross_round or cmp.cross_round_settings:
|
|
2356
|
+
lines.extend(["", "## Across rounds"])
|
|
2357
|
+
# Commands and reasons are free text, so the whole summary is fenced.
|
|
2358
|
+
lines.extend(_md_fence(cross_round_lines(cmp, str)))
|
|
2359
|
+
if appendix:
|
|
2360
|
+
lines.extend(appendix)
|
|
2361
|
+
lines.extend(["", "---", "", REVIEW_FOOTER])
|
|
2362
|
+
return "\n".join(_squeeze(lines)) + "\n"
|
|
2363
|
+
|
|
2364
|
+
|
|
2365
|
+
def _squeeze(lines: list[str]) -> list[str]:
|
|
2366
|
+
"""Collapse runs of blank lines between blocks (block helpers pad both
|
|
2367
|
+
sides), never inside a fence: a blank line there is stored content."""
|
|
2368
|
+
out: list[str] = []
|
|
2369
|
+
fence: str | None = None
|
|
2370
|
+
for line in lines:
|
|
2371
|
+
if fence is None and len(line) >= 3 and set(line) == {"`"}:
|
|
2372
|
+
fence = line
|
|
2373
|
+
elif fence is not None and line == fence:
|
|
2374
|
+
fence = None
|
|
2375
|
+
elif fence is None and line == "" and (not out or out[-1] == ""):
|
|
2376
|
+
continue
|
|
2377
|
+
out.append(line)
|
|
2378
|
+
while out and out[-1] == "":
|
|
2379
|
+
out.pop()
|
|
2380
|
+
return out
|