quirework 0.1.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.
- quirework/__init__.py +3 -0
- quirework/__main__.py +3 -0
- quirework/cli.py +431 -0
- quirework/core/__init__.py +1 -0
- quirework/core/artifact_paths.py +86 -0
- quirework/core/checks.py +235 -0
- quirework/core/frontmatter.py +83 -0
- quirework/core/globs.py +45 -0
- quirework/core/pinned.py +169 -0
- quirework/core/usage.py +604 -0
- quirework/delivery/__init__.py +1 -0
- quirework/delivery/artifacts.py +210 -0
- quirework/delivery/text.py +166 -0
- quirework/manuscript/__init__.py +1 -0
- quirework/manuscript/backfill.py +1972 -0
- quirework/manuscript/editions.py +111 -0
- quirework/manuscript/footprint.py +95 -0
- quirework/manuscript/formats.py +208 -0
- quirework/manuscript/lifecycle.py +191 -0
- quirework/manuscript/notes.py +2301 -0
- quirework/manuscript/pages.py +284 -0
- quirework/manuscript/quarto.py +416 -0
- quirework/manuscript/quotes.py +512 -0
- quirework/manuscript/rename.py +309 -0
- quirework/manuscript/sources.py +286 -0
- quirework/manuscript/trail.py +1875 -0
- quirework/manuscript/units.py +416 -0
- quirework/project.py +645 -0
- quirework/provenance/__init__.py +1 -0
- quirework/provenance/agyfilter.py +436 -0
- quirework/provenance/backup/__init__.py +469 -0
- quirework/provenance/backup/base.py +290 -0
- quirework/provenance/backup/command.py +107 -0
- quirework/provenance/backup/directory.py +142 -0
- quirework/provenance/backup/git.py +150 -0
- quirework/provenance/backup/rclone.py +153 -0
- quirework/provenance/capture/__init__.py +1 -0
- quirework/provenance/capture/commands.py +28 -0
- quirework/provenance/capture/extract_artifacts.py +110 -0
- quirework/provenance/capture/extract_files.py +118 -0
- quirework/provenance/capture/fetch_conversation.py +127 -0
- quirework/provenance/capture/fetch_gemini.py +149 -0
- quirework/provenance/capture/render_transcript.py +126 -0
- quirework/provenance/capture/transform.py +22 -0
- quirework/provenance/catalog.py +733 -0
- quirework/provenance/ccfilter.py +1126 -0
- quirework/provenance/codexfilter.py +883 -0
- quirework/provenance/coldread.py +1731 -0
- quirework/provenance/curate.py +67 -0
- quirework/provenance/humaninput.py +811 -0
- quirework/provenance/ingest.py +31 -0
- quirework/provenance/ingestion/__init__.py +1 -0
- quirework/provenance/ingestion/agy.py +192 -0
- quirework/provenance/ingestion/claude.py +493 -0
- quirework/provenance/ingestion/codex.py +296 -0
- quirework/provenance/lineage.py +148 -0
- quirework/provenance/metrics.py +1247 -0
- quirework/provenance/rulings.py +478 -0
- quirework/provenance/scope.py +73 -0
- quirework/provenance/search.py +455 -0
- quirework/provenance/semantic.py +475 -0
- quirework/provenance/show.py +1213 -0
- quirework/provenance/store.py +1271 -0
- quirework/provenance/trees.py +133 -0
- quirework/provenance/worktrees.py +199 -0
- quirework/providers/__init__.py +1 -0
- quirework/providers/blind.py +2192 -0
- quirework/providers/canary.py +685 -0
- quirework/providers/catalog.py +44 -0
- quirework/providers/claude_login.py +92 -0
- quirework/providers/exits.py +196 -0
- quirework/providers/harness.py +830 -0
- quirework/providers/images.py +92 -0
- quirework/providers/models.py +1284 -0
- quirework/providers/quota.py +588 -0
- quirework/providers/reads.py +474 -0
- quirework/providers/replies.py +175 -0
- quirework/publish/__init__.py +1 -0
- quirework/publish/backends.py +112 -0
- quirework/publish/bookstage.py +1086 -0
- quirework/publish/build.py +60 -0
- quirework/publish/editions.py +48 -0
- quirework/publish/epubcheck.py +287 -0
- quirework/publish/experiment.py +1097 -0
- quirework/publish/figfonts.py +234 -0
- quirework/publish/fignumbers.py +293 -0
- quirework/publish/figures.py +729 -0
- quirework/publish/folios.py +62 -0
- quirework/publish/fonts.py +307 -0
- quirework/publish/graybuild.py +73 -0
- quirework/publish/hugo.py +117 -0
- quirework/publish/kindle.js +411 -0
- quirework/publish/kindle.py +1393 -0
- quirework/publish/panelkit.py +918 -0
- quirework/publish/plans.py +651 -0
- quirework/publish/proof.py +792 -0
- quirework/publish/redpen.py +1654 -0
- quirework/publish/render.py +942 -0
- quirework/publish/targets.py +216 -0
- quirework/publish/variant.py +918 -0
- quirework/runs/__init__.py +1 -0
- quirework/runs/__main__.py +15 -0
- quirework/runs/commands.py +278 -0
- quirework/runs/coverage.py +86 -0
- quirework/runs/engine.py +2143 -0
- quirework/runs/feed.py +853 -0
- quirework/runs/observe.py +379 -0
- quirework/runs/storage.py +155 -0
- quirework/settings.py +492 -0
- quirework/sops/__init__.py +1 -0
- quirework/sops/catalog.py +386 -0
- quirework/sops/commands.py +567 -0
- quirework/sops/composition.py +382 -0
- quirework/sops/instruments.py +184 -0
- quirework/sops/personas.py +296 -0
- quirework/sops/review.py +2568 -0
- quirework/sops/room.py +2791 -0
- quirework/sops/roundtable.py +2578 -0
- quirework/sops/sections.py +169 -0
- quirework/sops/workshop.py +1629 -0
- quirework/templates/backup.toml +39 -0
- quirework/templates/book.toml +88 -0
- quirework/templates/run.toml +32 -0
- quirework/templates/site.toml +90 -0
- quirework/templates/tells.toml +64 -0
- quirework/templates/terminology.toml +20 -0
- quirework/templates/tmpfiles.conf +42 -0
- quirework/testing.py +345 -0
- quirework/text/__init__.py +1 -0
- quirework/text/jargon.py +545 -0
- quirework/text/mdwrap.py +1001 -0
- quirework/text/tells.py +547 -0
- quirework-0.1.0.dist-info/METADATA +444 -0
- quirework-0.1.0.dist-info/RECORD +138 -0
- quirework-0.1.0.dist-info/WHEEL +5 -0
- quirework-0.1.0.dist-info/entry_points.txt +2 -0
- quirework-0.1.0.dist-info/licenses/LICENSE +21 -0
- quirework-0.1.0.dist-info/top_level.txt +1 -0
quirework/sops/room.py
ADDED
|
@@ -0,0 +1,2791 @@
|
|
|
1
|
+
"""A writers room: several seats, one board, one round at a time.
|
|
2
|
+
|
|
3
|
+
Seats never see each other's workspaces. A seat writes to its own outbox and
|
|
4
|
+
the controller promotes that file onto the board between rounds, which is the
|
|
5
|
+
only moment shared state changes. Promotion is mechanical: it copies, hashes
|
|
6
|
+
and attributes, and it never reads a post to decide whether it deserves to be
|
|
7
|
+
there. The board a round was shown is therefore one object with one digest,
|
|
8
|
+
so "what everyone saw in round three" has an answer.
|
|
9
|
+
|
|
10
|
+
Rounds exist because a CLI harness answers once and exits. A round is a resume
|
|
11
|
+
of the session a seat already holds, not a fresh run of the same assignment:
|
|
12
|
+
the seat keeps its reading and its reasoning, and is handed only what changed.
|
|
13
|
+
|
|
14
|
+
The controller appends to ``room.log`` as it goes. `watch` follows a running
|
|
15
|
+
room by reading that file and the per-seat records beside it, without taking a
|
|
16
|
+
lock or touching a session.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import shutil
|
|
26
|
+
import signal
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import textwrap
|
|
30
|
+
import time
|
|
31
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from threading import Event, Lock
|
|
34
|
+
|
|
35
|
+
from quirework.project import current_project, submit
|
|
36
|
+
from quirework.core import usage as usage_records
|
|
37
|
+
from quirework.providers import blind
|
|
38
|
+
from quirework.providers import quota
|
|
39
|
+
from quirework.providers import exits
|
|
40
|
+
from quirework.providers import models as drivers
|
|
41
|
+
from quirework.runs import feed
|
|
42
|
+
from quirework.runs import observe
|
|
43
|
+
from quirework.runs.storage import (
|
|
44
|
+
digest,
|
|
45
|
+
hashes,
|
|
46
|
+
identifier,
|
|
47
|
+
lock,
|
|
48
|
+
process_identity,
|
|
49
|
+
read,
|
|
50
|
+
shown,
|
|
51
|
+
slot,
|
|
52
|
+
write,
|
|
53
|
+
)
|
|
54
|
+
from quirework.sops import personas
|
|
55
|
+
|
|
56
|
+
SCHEMA = 1
|
|
57
|
+
_LOG_LOCK = Lock()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def event(root, **fields):
|
|
61
|
+
"""Append one complete line to the room's watch trail.
|
|
62
|
+
|
|
63
|
+
One write of a whole line under O_APPEND is what stops a reader from ever
|
|
64
|
+
seeing half a record, which matters because the point of this file is that
|
|
65
|
+
somebody is tailing it while the room runs.
|
|
66
|
+
"""
|
|
67
|
+
line = json.dumps({"t": round(time.time(), 3), **fields}, ensure_ascii=False)
|
|
68
|
+
with _LOG_LOCK:
|
|
69
|
+
fd = os.open(root / "room.log", os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
70
|
+
try:
|
|
71
|
+
os.write(fd, (line + "\n").encode())
|
|
72
|
+
finally:
|
|
73
|
+
os.close(fd)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load(root, *, verify=True):
|
|
77
|
+
root = Path(root).expanduser().resolve()
|
|
78
|
+
plan = read(root / "plan.json")
|
|
79
|
+
if plan.get("schema") != SCHEMA:
|
|
80
|
+
raise ValueError("unsupported room schema")
|
|
81
|
+
if (
|
|
82
|
+
verify
|
|
83
|
+
and digest(root / "plan.json") != read(root / "plan-digest.json")["sha256"]
|
|
84
|
+
):
|
|
85
|
+
raise ValueError("frozen room plan changed")
|
|
86
|
+
return root, plan
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
ROOMS = "rooms"
|
|
90
|
+
|
|
91
|
+
# A seat's runner home is a live harness home: the adapter binds it over
|
|
92
|
+
# `~/.claude` or `~/.codex` for the duration of a turn, with the harness's
|
|
93
|
+
# credentials mounted into it. `prepare` accepts a path as readily as a name,
|
|
94
|
+
# and a room prepared into a checkout puts the seat's sessions, and in a room
|
|
95
|
+
# older than the cache its whole home, one `git add` away from being
|
|
96
|
+
# published. The pattern names no directory, because what sits there now is
|
|
97
|
+
# a link into the cache, or a tombstone once the home is pruned. The room
|
|
98
|
+
# writes this itself because the operator who most needs it is the one who
|
|
99
|
+
# has not yet looked inside a room directory.
|
|
100
|
+
IGNORE = """\
|
|
101
|
+
# Where a seat reaches its private runner home, a live harness home. Never
|
|
102
|
+
# commit it, and never copy one anywhere.
|
|
103
|
+
seats/*.blind-state
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def spelled_as_path(value):
|
|
108
|
+
"""Whether the operator spelled a path rather than naming a room.
|
|
109
|
+
|
|
110
|
+
The rule lives here rather than inside `room_root` so a caller can say
|
|
111
|
+
which of the two resolutions it used without restating the rule and
|
|
112
|
+
drifting from it.
|
|
113
|
+
"""
|
|
114
|
+
text = str(value)
|
|
115
|
+
return "/" in text or text.startswith("~")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def room_root(value, *, must_exist=True):
|
|
119
|
+
"""Resolve a room by name under the project, or by an explicit path.
|
|
120
|
+
|
|
121
|
+
A bare name is a room this project owns and is looked up in the project's
|
|
122
|
+
rooms directory. Anything containing a separator is a path the operator
|
|
123
|
+
spelled out. The two never overlap, so `status foo` and `status ./foo`
|
|
124
|
+
name different things and the error says which one was tried.
|
|
125
|
+
|
|
126
|
+
Rooms live outside the checkout, in a directory nobody navigates to by
|
|
127
|
+
hand, so resolving a bare name is the difference between naming a room and
|
|
128
|
+
remembering where the state root hashed it to.
|
|
129
|
+
"""
|
|
130
|
+
text = str(value)
|
|
131
|
+
if spelled_as_path(text):
|
|
132
|
+
root = Path(text).expanduser().resolve()
|
|
133
|
+
else:
|
|
134
|
+
root = (current_project().state_root / ROOMS / identifier(text)).resolve()
|
|
135
|
+
if must_exist and not (root / "plan.json").is_file():
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"no room at {shown(root)}; name a room under "
|
|
138
|
+
f"{shown(current_project().state_root / ROOMS)} or give a path to one"
|
|
139
|
+
)
|
|
140
|
+
return root
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def state_path(root, seat):
|
|
144
|
+
return root / "records" / seat / "state.json"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def label(seat):
|
|
148
|
+
return seat["persona_frozen"]["profile"]["label"]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def phases(plan):
|
|
152
|
+
"""The room's schedule, as a phase per round.
|
|
153
|
+
|
|
154
|
+
A room without phases is one long phase in which everyone speaks every
|
|
155
|
+
round, which is what every room did before phases existed.
|
|
156
|
+
"""
|
|
157
|
+
declared = plan.get("phases")
|
|
158
|
+
if not declared:
|
|
159
|
+
return [{"name": "discussion", "rounds": plan["rounds"]}]
|
|
160
|
+
return declared
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def phase_for(plan, n):
|
|
164
|
+
"""Which phase round ``n`` belongs to, and nothing about who acts in it."""
|
|
165
|
+
first = 1
|
|
166
|
+
for phase in phases(plan):
|
|
167
|
+
if first <= n < first + phase["rounds"]:
|
|
168
|
+
return phase
|
|
169
|
+
first += phase["rounds"]
|
|
170
|
+
return phases(plan)[-1]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def acting(plan, n):
|
|
174
|
+
"""The seats that speak in round ``n``.
|
|
175
|
+
|
|
176
|
+
A phase may seat only some of the room. The rest are not absent and have
|
|
177
|
+
not failed: they are listening, which is a normal thing to do while one
|
|
178
|
+
person reads their draft aloud.
|
|
179
|
+
"""
|
|
180
|
+
named = phase_for(plan, n).get("seats")
|
|
181
|
+
every = [s["id"] for s in plan["seats"]]
|
|
182
|
+
if not named:
|
|
183
|
+
return every
|
|
184
|
+
unknown = set(named) - set(every)
|
|
185
|
+
if unknown:
|
|
186
|
+
raise ValueError(
|
|
187
|
+
f"phase names seats this room does not have: {sorted(unknown)}"
|
|
188
|
+
)
|
|
189
|
+
return [s for s in every if s in named]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def start_text(root, plan, seat):
|
|
193
|
+
"""What a writer is told once: who they are, what to read, where to post.
|
|
194
|
+
|
|
195
|
+
Deliberately says nothing about rounds, promotion, outboxes as a mechanism
|
|
196
|
+
or the controller that runs them. A writer in a room knows the room, not
|
|
197
|
+
the building's plumbing, and a model told it is executing a procedure
|
|
198
|
+
starts writing like one. `{{persona}}` is a slot personas.compose fills in
|
|
199
|
+
place, which keeps a machine header off the top of a person's character.
|
|
200
|
+
"""
|
|
201
|
+
# Relative to the seat's own workspace, which the jail makes its working
|
|
202
|
+
# directory. An absolute path here is the operator's home layout, restated
|
|
203
|
+
# in a writer's context on every turn, naming a machine they cannot see.
|
|
204
|
+
reads = "".join(f"- input/{rel}\n" for rel in seat["reads"])
|
|
205
|
+
text = (
|
|
206
|
+
"{{persona}}\n\n"
|
|
207
|
+
"You are in a room of writers working on the same material.\n\n"
|
|
208
|
+
f"Read:\n\n{reads}\n"
|
|
209
|
+
"Post to outbox/post.md, replacing your last post each\n"
|
|
210
|
+
f"time. That file is what the room sees. About {plan['post_words']} words.\n"
|
|
211
|
+
)
|
|
212
|
+
roster = holdings(plan)
|
|
213
|
+
if roster:
|
|
214
|
+
text += "\nIn the room, and what each person brought:\n\n" + roster
|
|
215
|
+
return personas.compose(text, seat["persona_frozen"])
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def holdings(plan):
|
|
219
|
+
"""Who is in the room and what each of them was given beyond the common set.
|
|
220
|
+
|
|
221
|
+
Seats are handed different material on purpose, and that asymmetry is what
|
|
222
|
+
makes their disagreement substantive. It also makes a colleague's citation
|
|
223
|
+
impossible to check: a seat without the file cannot tell a private source
|
|
224
|
+
from an invention, and a room told to cite its sources will eventually
|
|
225
|
+
accuse someone of fabricating one. Naming the holdings is the same courtesy
|
|
226
|
+
as seeing a colleague reach for the book they brought.
|
|
227
|
+
"""
|
|
228
|
+
common = (
|
|
229
|
+
set.intersection(*(set(s.get("reads", [])) for s in plan["seats"]))
|
|
230
|
+
if plan["seats"]
|
|
231
|
+
else set()
|
|
232
|
+
)
|
|
233
|
+
# A path is only the default name. Whatever is in it reaches every seat,
|
|
234
|
+
# so a filename carrying a model id, an author or a verdict puts that in
|
|
235
|
+
# the room: one measured run named six reader reports after the models
|
|
236
|
+
# that wrote them and had the seats addressing each other as "Sol" and
|
|
237
|
+
# "Astra" within two rounds, each attached to the wrong colleague. A
|
|
238
|
+
# `label` on the file says what it is without saying what it is called.
|
|
239
|
+
named = {
|
|
240
|
+
entry["path"]: entry["label"]
|
|
241
|
+
for seat in plan["seats"]
|
|
242
|
+
for entry in seat.get("files", [])
|
|
243
|
+
if entry.get("label")
|
|
244
|
+
}
|
|
245
|
+
lines = []
|
|
246
|
+
for seat in plan["seats"]:
|
|
247
|
+
extra = sorted(set(seat.get("reads", [])) - common)
|
|
248
|
+
shown_as = [named.get(rel, rel) for rel in extra]
|
|
249
|
+
brought = ", ".join(shown_as) if extra else "nothing the rest of you lack"
|
|
250
|
+
lines.append(f"- {label(seat)}: {brought}\n")
|
|
251
|
+
return "".join(lines)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def chair_note(root, n):
|
|
255
|
+
"""What the operator told the room before round ``n``, if anything.
|
|
256
|
+
|
|
257
|
+
A room is a closed conversation on purpose, and the operator is the only
|
|
258
|
+
person who can see both the room and the world it is writing for. The
|
|
259
|
+
packet is hashed, so a fact discovered after preparation cannot be
|
|
260
|
+
corrected in it, and the board carries posts and nothing else. In the
|
|
261
|
+
first measured room four seats put direct questions to the chair in their
|
|
262
|
+
opening round -- a miscounted phrase, a reference with two possible
|
|
263
|
+
referents, a ruling only the editor held -- and nothing could reach them.
|
|
264
|
+
|
|
265
|
+
The note travels with the turn so every acting seat is told the same thing
|
|
266
|
+
at the same instant, and it is copied onto that round's digest so the
|
|
267
|
+
record shows what the room was working from. It is not a post: the chair
|
|
268
|
+
does not take a seat, is not answered by name, and never arrives in the
|
|
269
|
+
middle of a round.
|
|
270
|
+
"""
|
|
271
|
+
path = root / "chair" / f"r{n:03d}.md"
|
|
272
|
+
if not path.exists():
|
|
273
|
+
return None
|
|
274
|
+
return path.read_text().strip() or None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def turn_prompt(root, plan, seat, n, *, cold=False):
|
|
278
|
+
"""The one line a writer gets when the room moves.
|
|
279
|
+
|
|
280
|
+
Everything standing -- the perspective, the reading, the length, where a
|
|
281
|
+
post goes -- is already in the session from the first turn, so repeating
|
|
282
|
+
it every round would buy nothing and cost tokens on every seat.
|
|
283
|
+
"""
|
|
284
|
+
phase = phase_for(plan, n)
|
|
285
|
+
# A phase states the work's length, or `"words": null` (or false) to
|
|
286
|
+
# state none: a figure is an instruction, and a plan that wants the work
|
|
287
|
+
# to find its own length must be able to say nothing. A phase that does
|
|
288
|
+
# not mention words keeps the post's length, as it always has.
|
|
289
|
+
words = phase.get("words", plan["post_words"]) or None
|
|
290
|
+
extra = phase.get("instruction", "").strip()
|
|
291
|
+
# Relative to the seat's workspace, as in the standing assignment: this
|
|
292
|
+
# line is sent on every round, so an absolute path is the operator's home
|
|
293
|
+
# directory spelled out again in a writer's context every time the room
|
|
294
|
+
# moves, for a file they are already standing next to.
|
|
295
|
+
opening = n == 1 or n in plan["silent_rounds"]
|
|
296
|
+
if opening:
|
|
297
|
+
text = (
|
|
298
|
+
"Read START.md and post. Nobody has posted yet, so say "
|
|
299
|
+
"what you think before you hear anyone else."
|
|
300
|
+
)
|
|
301
|
+
elif cold:
|
|
302
|
+
# This seat has no session, so it has none of the standing assignment
|
|
303
|
+
# and none of the conversation. Saying only that the board moved would
|
|
304
|
+
# be addressed to a reader who has never read the brief.
|
|
305
|
+
text = (
|
|
306
|
+
"You are joining a room already in progress. Read START.md "
|
|
307
|
+
"first, then the boards in board/, beginning with "
|
|
308
|
+
f"board/round-{n - 1:03d}/digest.md. Then post as the others "
|
|
309
|
+
"do. Answer your colleagues by name. You were expected earlier "
|
|
310
|
+
"and could not be reached, so the room has not heard from you "
|
|
311
|
+
"yet and nothing it has said is a reply to you."
|
|
312
|
+
)
|
|
313
|
+
elif others_spoke(root, plan, seat["id"], n - 1):
|
|
314
|
+
text = (
|
|
315
|
+
f"The board has moved: board/round-{n - 1:03d}/digest.md. Read it "
|
|
316
|
+
"and post again, replacing what is there. Answer your colleagues "
|
|
317
|
+
"by name."
|
|
318
|
+
)
|
|
319
|
+
else:
|
|
320
|
+
# A phase seating one writer hands that writer a board with no other
|
|
321
|
+
# voice on it, and announcing that the board has moved is then false.
|
|
322
|
+
# A drafter reads that silence as an answer: a measured room had its
|
|
323
|
+
# drafter say "absent an answer I am treating that beat as landed"
|
|
324
|
+
# about a note three colleagues were writing at that moment, one round
|
|
325
|
+
# behind where she had been told to look.
|
|
326
|
+
heard = last_heard(root, plan, seat["id"], n - 1)
|
|
327
|
+
text = (
|
|
328
|
+
"Nobody else has posted since"
|
|
329
|
+
+ (
|
|
330
|
+
f" round {heard}: board/round-{heard:03d}/digest.md."
|
|
331
|
+
if heard
|
|
332
|
+
else " the room opened."
|
|
333
|
+
)
|
|
334
|
+
+ " Post again, replacing what is there. Anything you asked them "
|
|
335
|
+
"is still outstanding, not declined."
|
|
336
|
+
)
|
|
337
|
+
missed = [r for r in range(1, n) if seat["id"] not in acting(plan, r)]
|
|
338
|
+
if missed and not opening:
|
|
339
|
+
# A seat that sat out a phase comes back to a board it has not read.
|
|
340
|
+
# Saying only that the board moved would have it answer the last round
|
|
341
|
+
# as though it were the whole conversation. A silent round is shown no
|
|
342
|
+
# board at all, deliberately, so it is not told where the others are.
|
|
343
|
+
text += (
|
|
344
|
+
f" You did not post in round{'s' if len(missed) > 1 else ''} "
|
|
345
|
+
f"{', '.join(str(r) for r in missed)}; those boards are in "
|
|
346
|
+
"board/ and are worth reading first."
|
|
347
|
+
)
|
|
348
|
+
made = deliverable(plan, n)
|
|
349
|
+
if made:
|
|
350
|
+
# Two files because they are two things, and the word count belongs to
|
|
351
|
+
# the work rather than to the note about it. Afterwards the work is a
|
|
352
|
+
# file, not a passage somebody has to find inside a conversation.
|
|
353
|
+
length = f", about {words} words" if words else ""
|
|
354
|
+
text += (
|
|
355
|
+
f" Write the work itself to outbox/{made}, as finished text and"
|
|
356
|
+
f" nothing else{length}. Your post is your note to the"
|
|
357
|
+
f" room about it, about {plan['post_words']} words."
|
|
358
|
+
)
|
|
359
|
+
elif words and words != plan["post_words"]:
|
|
360
|
+
text += f" About {words} words this time."
|
|
361
|
+
text = f"{text}{(' ' + extra) if extra else ''}"
|
|
362
|
+
note = chair_note(root, n)
|
|
363
|
+
if note:
|
|
364
|
+
text += f"\n\nFrom the chair, to everyone acting this round:\n\n{note}"
|
|
365
|
+
return f"{text}\n"
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def prepare(plan_path, destination, *, resolve_models=True):
|
|
369
|
+
"""Stage one workspace per seat and freeze the plan that governs them."""
|
|
370
|
+
plan_path = Path(plan_path).expanduser().resolve()
|
|
371
|
+
destination = Path(destination).expanduser().resolve()
|
|
372
|
+
supplied = read(plan_path)
|
|
373
|
+
if destination.exists():
|
|
374
|
+
raise ValueError("room exists; continue or inspect it, do not replace it")
|
|
375
|
+
source_root = Path(supplied.get("source_root", current_project().root)).resolve()
|
|
376
|
+
rounds = supplied.get("rounds")
|
|
377
|
+
if not isinstance(rounds, int) or rounds < 1:
|
|
378
|
+
raise ValueError("rounds must be a positive integer")
|
|
379
|
+
silent = supplied.get("silent_rounds", [])
|
|
380
|
+
if not isinstance(silent, list) or any(
|
|
381
|
+
r not in range(1, rounds + 1) for r in silent
|
|
382
|
+
):
|
|
383
|
+
raise ValueError("silent_rounds must name declared rounds")
|
|
384
|
+
if not isinstance(supplied.get("post_words"), int) or supplied["post_words"] < 1:
|
|
385
|
+
raise ValueError("post_words must be a positive integer")
|
|
386
|
+
if not supplied.get("seats"):
|
|
387
|
+
raise ValueError("declare at least one seat")
|
|
388
|
+
declared = supplied.get("phases") or []
|
|
389
|
+
if declared:
|
|
390
|
+
if sum(p["rounds"] for p in declared) != rounds:
|
|
391
|
+
raise ValueError("phase rounds must add up to the room's rounds")
|
|
392
|
+
named = {s["id"] for s in supplied["seats"]}
|
|
393
|
+
for phase in declared:
|
|
394
|
+
if not isinstance(phase.get("rounds"), int) or phase["rounds"] < 1:
|
|
395
|
+
raise ValueError("each phase needs a positive round count")
|
|
396
|
+
if set(phase.get("seats") or named) - named:
|
|
397
|
+
raise ValueError(f"phase {phase.get('name')!r} names unknown seats")
|
|
398
|
+
if not phase.get("seats", named):
|
|
399
|
+
raise ValueError(f"phase {phase.get('name')!r} seats nobody")
|
|
400
|
+
if "deliverable" in phase:
|
|
401
|
+
safe_name(phase["deliverable"])
|
|
402
|
+
length = phase.get("words")
|
|
403
|
+
if not (
|
|
404
|
+
length is None
|
|
405
|
+
or length is False
|
|
406
|
+
or (
|
|
407
|
+
isinstance(length, int)
|
|
408
|
+
and not isinstance(length, bool)
|
|
409
|
+
and length > 0
|
|
410
|
+
)
|
|
411
|
+
):
|
|
412
|
+
raise ValueError(
|
|
413
|
+
f"phase {phase.get('name')!r}: words is a positive integer,"
|
|
414
|
+
" or null to state no length"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
fields = ("harness", "model", "effort")
|
|
418
|
+
config = {k: supplied[k] for k in fields}
|
|
419
|
+
if resolve_models:
|
|
420
|
+
config = drivers.resolve(config["harness"], config["model"], config["effort"])
|
|
421
|
+
|
|
422
|
+
profiles = plan_path.parent / supplied.get("profiles_dir", "profiles")
|
|
423
|
+
seats = []
|
|
424
|
+
for item in supplied["seats"]:
|
|
425
|
+
source = profiles / f"{item['persona']}.json"
|
|
426
|
+
if not source.is_file():
|
|
427
|
+
# A persona id is not guessable, and an assistant writing a plan
|
|
428
|
+
# will invent one. Name the ones that exist so the fix is a copy.
|
|
429
|
+
offered = sorted(p.stem for p in profiles.glob("*.json"))
|
|
430
|
+
raise ValueError(
|
|
431
|
+
f"seat {item['id']!r}: no persona {item['persona']!r} in "
|
|
432
|
+
f"{shown(profiles)}; it has {', '.join(offered) or 'none'}"
|
|
433
|
+
)
|
|
434
|
+
profile = personas.validate(personas.parse(source.read_text()))
|
|
435
|
+
# A seat may name its own model. Everything downstream already takes a
|
|
436
|
+
# config rather than reading the room's, so a mixed room needs no
|
|
437
|
+
# special case beyond resolving each seat separately.
|
|
438
|
+
chosen = {
|
|
439
|
+
**{k: supplied[k] for k in fields},
|
|
440
|
+
**{k: item[k] for k in fields if k in item},
|
|
441
|
+
}
|
|
442
|
+
if resolve_models:
|
|
443
|
+
chosen = drivers.resolve(
|
|
444
|
+
chosen["harness"], chosen["model"], chosen["effort"]
|
|
445
|
+
)
|
|
446
|
+
# The digest tells the room which seats can search the web, so the
|
|
447
|
+
# claim has to be true: the policy travels in the seat's config to the
|
|
448
|
+
# launcher, and a harness that cannot enforce it is refused here
|
|
449
|
+
# rather than marked on the board as if it could.
|
|
450
|
+
tools = item.get("tools", "write")
|
|
451
|
+
if tools not in TOOLS:
|
|
452
|
+
raise ValueError(f"seat {item['id']!r}: tools must be one of {TOOLS}")
|
|
453
|
+
if tools == "research" and chosen["harness"] == "agy":
|
|
454
|
+
raise ValueError(
|
|
455
|
+
f"seat {item['id']!r}: agy cannot confine a seat to a tool set, "
|
|
456
|
+
"so a research seat there cannot be told from a writing one"
|
|
457
|
+
)
|
|
458
|
+
chosen = {**chosen, "tools": tools}
|
|
459
|
+
seats.append(
|
|
460
|
+
dict(
|
|
461
|
+
item,
|
|
462
|
+
id=identifier(item["id"]),
|
|
463
|
+
config=chosen,
|
|
464
|
+
persona_frozen=personas.resolve({"inline": profile}),
|
|
465
|
+
)
|
|
466
|
+
)
|
|
467
|
+
if len({s["id"] for s in seats}) != len(seats):
|
|
468
|
+
raise ValueError("duplicate seat id")
|
|
469
|
+
if resolve_models:
|
|
470
|
+
from quirework.providers import canary as canaries
|
|
471
|
+
|
|
472
|
+
canaries.require_models(
|
|
473
|
+
(f"seat {s['id']}", s["config"]["harness"]) for s in seats
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
def staged(entry):
|
|
477
|
+
if "local_source" in entry:
|
|
478
|
+
return (plan_path.parent / entry["local_source"]).read_bytes()
|
|
479
|
+
return (source_root / entry["source"]).read_bytes()
|
|
480
|
+
|
|
481
|
+
shared = {supplied["brief"]["path"]: staged(supplied["brief"])}
|
|
482
|
+
for entry in supplied.get("shared_files", []):
|
|
483
|
+
shared[entry["path"]] = staged(entry)
|
|
484
|
+
|
|
485
|
+
destination.mkdir(parents=True, mode=0o700)
|
|
486
|
+
(destination / ".gitignore").write_text(IGNORE)
|
|
487
|
+
(destination / "board" / "posts").mkdir(parents=True)
|
|
488
|
+
(destination / "board" / "rounds").mkdir(parents=True)
|
|
489
|
+
(destination / "chair").mkdir()
|
|
490
|
+
frozen = dict(
|
|
491
|
+
supplied,
|
|
492
|
+
schema=SCHEMA,
|
|
493
|
+
config=config,
|
|
494
|
+
seats=seats,
|
|
495
|
+
source_root=str(source_root),
|
|
496
|
+
silent_rounds=silent,
|
|
497
|
+
prepared_at=time.time(),
|
|
498
|
+
)
|
|
499
|
+
for seat in seats:
|
|
500
|
+
work = destination / "seats" / seat["id"]
|
|
501
|
+
for sub in ("input", "outbox", "board"):
|
|
502
|
+
(work / sub).mkdir(parents=True)
|
|
503
|
+
files = dict(shared)
|
|
504
|
+
for entry in seat.get("files", []):
|
|
505
|
+
files[entry["path"]] = staged(entry)
|
|
506
|
+
for rel, content in files.items():
|
|
507
|
+
target = work / "input" / rel
|
|
508
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
509
|
+
target.write_bytes(content)
|
|
510
|
+
seat["reads"] = sorted(files)
|
|
511
|
+
seat["input_hashes"] = hashes(work / "input")
|
|
512
|
+
(destination / "board" / "posts" / seat["id"]).mkdir(parents=True)
|
|
513
|
+
(destination / "records" / seat["id"]).mkdir(parents=True)
|
|
514
|
+
write(
|
|
515
|
+
state_path(destination, seat["id"]),
|
|
516
|
+
{
|
|
517
|
+
"status": "prepared",
|
|
518
|
+
"session_id": None,
|
|
519
|
+
"rounds": {},
|
|
520
|
+
"persona": personas.identity(seat["persona_frozen"]),
|
|
521
|
+
},
|
|
522
|
+
)
|
|
523
|
+
# Second pass: the roster in START.md names what every seat holds, which
|
|
524
|
+
# is only knowable once each of them has been staged.
|
|
525
|
+
for seat in seats:
|
|
526
|
+
start = destination / "seats" / seat["id"] / "START.md"
|
|
527
|
+
start.write_text(start_text(destination, frozen, seat))
|
|
528
|
+
seat["start_sha256"] = digest(start)
|
|
529
|
+
write(destination / "plan.json", frozen)
|
|
530
|
+
write(
|
|
531
|
+
destination / "plan-digest.json",
|
|
532
|
+
{"sha256": digest(destination / "plan.json")},
|
|
533
|
+
)
|
|
534
|
+
event(destination, event="prepared", seats=[s["id"] for s in seats], rounds=rounds)
|
|
535
|
+
# The roster is the one part of the packet an operator writes without
|
|
536
|
+
# reading: it is assembled from paths chosen for the filesystem and then
|
|
537
|
+
# shown to every seat. Printing it at the last moment anything can change
|
|
538
|
+
# is what turns a filename back into a decision.
|
|
539
|
+
frozen["roster"] = holdings(frozen)
|
|
540
|
+
return frozen
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
# A room that has stopped producing positions keeps producing paragraphs, so
|
|
544
|
+
# it cannot be trusted to notice it is finished. Both signals below read the
|
|
545
|
+
# board and nothing else: no model call is spent asking a room whether it is
|
|
546
|
+
# done. They are heuristics fitted to two ten-round rooms, not laws, which is
|
|
547
|
+
# why a plan can move either of them.
|
|
548
|
+
STOP_NOVELTY = 55.0
|
|
549
|
+
STOP_CLOSING = 0.75
|
|
550
|
+
CLOSING = re.compile(
|
|
551
|
+
r"\b(unanimous|our work here|the room has|settled|adjourn|send the contract"
|
|
552
|
+
r"|sign the|close the room|no more objections|verdict)",
|
|
553
|
+
re.IGNORECASE,
|
|
554
|
+
)
|
|
555
|
+
WORD = re.compile(r"[a-z][a-z'-]+")
|
|
556
|
+
COMMON = frozenset(
|
|
557
|
+
[
|
|
558
|
+
"the",
|
|
559
|
+
"a",
|
|
560
|
+
"an",
|
|
561
|
+
"and",
|
|
562
|
+
"or",
|
|
563
|
+
"but",
|
|
564
|
+
"of",
|
|
565
|
+
"to",
|
|
566
|
+
"in",
|
|
567
|
+
"is",
|
|
568
|
+
"it",
|
|
569
|
+
"that",
|
|
570
|
+
"this",
|
|
571
|
+
"for",
|
|
572
|
+
"with",
|
|
573
|
+
"as",
|
|
574
|
+
"on",
|
|
575
|
+
"not",
|
|
576
|
+
"you",
|
|
577
|
+
"we",
|
|
578
|
+
"they",
|
|
579
|
+
"be",
|
|
580
|
+
"are",
|
|
581
|
+
"was",
|
|
582
|
+
"were",
|
|
583
|
+
"has",
|
|
584
|
+
"have",
|
|
585
|
+
"had",
|
|
586
|
+
"will",
|
|
587
|
+
"would",
|
|
588
|
+
"can",
|
|
589
|
+
"could",
|
|
590
|
+
"their",
|
|
591
|
+
"its",
|
|
592
|
+
"his",
|
|
593
|
+
"her",
|
|
594
|
+
"them",
|
|
595
|
+
"me",
|
|
596
|
+
"my",
|
|
597
|
+
"our",
|
|
598
|
+
"your",
|
|
599
|
+
"if",
|
|
600
|
+
"then",
|
|
601
|
+
"than",
|
|
602
|
+
"so",
|
|
603
|
+
"what",
|
|
604
|
+
"which",
|
|
605
|
+
"who",
|
|
606
|
+
"when",
|
|
607
|
+
"how",
|
|
608
|
+
"all",
|
|
609
|
+
"any",
|
|
610
|
+
"each",
|
|
611
|
+
"other",
|
|
612
|
+
"more",
|
|
613
|
+
"most",
|
|
614
|
+
"some",
|
|
615
|
+
"such",
|
|
616
|
+
"no",
|
|
617
|
+
"nor",
|
|
618
|
+
"only",
|
|
619
|
+
"own",
|
|
620
|
+
"same",
|
|
621
|
+
"too",
|
|
622
|
+
"very",
|
|
623
|
+
"just",
|
|
624
|
+
"do",
|
|
625
|
+
"does",
|
|
626
|
+
"did",
|
|
627
|
+
"at",
|
|
628
|
+
"by",
|
|
629
|
+
"from",
|
|
630
|
+
"up",
|
|
631
|
+
"down",
|
|
632
|
+
"out",
|
|
633
|
+
"off",
|
|
634
|
+
"over",
|
|
635
|
+
"under",
|
|
636
|
+
"again",
|
|
637
|
+
"once",
|
|
638
|
+
"here",
|
|
639
|
+
"there",
|
|
640
|
+
"why",
|
|
641
|
+
"while",
|
|
642
|
+
"about",
|
|
643
|
+
"against",
|
|
644
|
+
"between",
|
|
645
|
+
"into",
|
|
646
|
+
"through",
|
|
647
|
+
"during",
|
|
648
|
+
"before",
|
|
649
|
+
"after",
|
|
650
|
+
"above",
|
|
651
|
+
"below",
|
|
652
|
+
]
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _content_words(text):
|
|
657
|
+
return {w for w in WORD.findall(text.lower()) if w not in COMMON and len(w) > 3}
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def spoke(root, plan, n):
|
|
661
|
+
"""The seats that both acted in round ``n`` and left a post to read.
|
|
662
|
+
|
|
663
|
+
A seat that was not seated by the phase never had a turn, and a seat whose
|
|
664
|
+
turn produced nothing is recorded absent. Neither has said anything the
|
|
665
|
+
stop rule may read as the room running out of things to say.
|
|
666
|
+
"""
|
|
667
|
+
return [
|
|
668
|
+
name
|
|
669
|
+
for name in acting(plan, n)
|
|
670
|
+
if (root / "board" / "posts" / name / f"r{n:03d}.md").exists()
|
|
671
|
+
]
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def others_spoke(root, plan, seat, n):
|
|
675
|
+
"""Whether anyone but ``seat`` left a post on round ``n``'s board."""
|
|
676
|
+
return bool([name for name in spoke(root, plan, n) if name != seat])
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def last_heard(root, plan, seat, n):
|
|
680
|
+
"""The most recent round up to ``n`` where ``seat`` heard somebody else.
|
|
681
|
+
|
|
682
|
+
A drafter coming out of a solo phase needs the board where the room last
|
|
683
|
+
spoke, not the one where it last changed. Those are the same round for
|
|
684
|
+
everyone except the seat that was drafting alone.
|
|
685
|
+
"""
|
|
686
|
+
return next((r for r in range(n, 0, -1) if others_spoke(root, plan, seat, r)), None)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def convergence(root, plan, n):
|
|
690
|
+
"""How close the room is to having nothing left to say, after round ``n``.
|
|
691
|
+
|
|
692
|
+
Novelty is the share of a post's content words that seat has never used
|
|
693
|
+
before; it falls as writers begin restating themselves. Closing is the
|
|
694
|
+
share of seats reaching for consensus language. Either signal must hold
|
|
695
|
+
for two consecutive rounds, because a single round of agreement is noise:
|
|
696
|
+
one measured room hit three-quarters closing at round four and was back to
|
|
697
|
+
one-quarter at round five, still arguing.
|
|
698
|
+
"""
|
|
699
|
+
seats = [s["id"] for s in plan["seats"]]
|
|
700
|
+
seen = {s: set() for s in seats}
|
|
701
|
+
series = []
|
|
702
|
+
for number in range(1, n + 1):
|
|
703
|
+
novel, closers = [], 0
|
|
704
|
+
# Only the seats that spoke. A resting seat scored zero novelty and was
|
|
705
|
+
# averaged in with the rest, so a phase that seats one writer read as a
|
|
706
|
+
# room three-quarters out of things to say: the drafting round of the
|
|
707
|
+
# first real room reported convergence at 15 percent while the drafter
|
|
708
|
+
# was putting the first new page of the book on the board. Silence
|
|
709
|
+
# under a phase is not a seat repeating itself. It is not a turn.
|
|
710
|
+
for name in spoke(root, plan, number):
|
|
711
|
+
text = (root / "board" / "posts" / name / f"r{number:03d}.md").read_text()
|
|
712
|
+
words = _content_words(text)
|
|
713
|
+
novel.append(100 * len(words - seen[name]) / max(1, len(words)))
|
|
714
|
+
seen[name] |= words
|
|
715
|
+
closers += bool(CLOSING.search(text))
|
|
716
|
+
series.append(
|
|
717
|
+
{
|
|
718
|
+
"round": number,
|
|
719
|
+
"novelty": round(sum(novel) / len(novel), 1) if novel else None,
|
|
720
|
+
"closing": round(closers / len(novel), 2) if novel else None,
|
|
721
|
+
}
|
|
722
|
+
)
|
|
723
|
+
quiet_at = plan.get("stop_novelty", STOP_NOVELTY)
|
|
724
|
+
closed_at = plan.get("stop_closing", STOP_CLOSING)
|
|
725
|
+
last = series[-2:]
|
|
726
|
+
# A round nobody spoke in measures nothing, so it cannot be one of the two
|
|
727
|
+
# consecutive rounds that stop a room. Dropping it instead would let two
|
|
728
|
+
# rounds either side of it read as consecutive when they are not.
|
|
729
|
+
usable = last if all(x["novelty"] is not None for x in last) else []
|
|
730
|
+
quiet = len(usable) == 2 and all(x["novelty"] <= quiet_at for x in usable)
|
|
731
|
+
closed = len(usable) == 2 and all(x["closing"] >= closed_at for x in usable)
|
|
732
|
+
return {
|
|
733
|
+
"round": n,
|
|
734
|
+
"novelty": series[-1]["novelty"] if series else None,
|
|
735
|
+
"closing": series[-1]["closing"] if series else None,
|
|
736
|
+
"converged": quiet or closed,
|
|
737
|
+
"reason": "novelty" if quiet else "closing" if closed else None,
|
|
738
|
+
"series": series,
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
# What a seat may do beyond writing its post: `write` is the room's floor,
|
|
743
|
+
# `research` adds the harness's web tools for a seat that must check sources.
|
|
744
|
+
TOOLS = ("write", "research")
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def seat_config(plan, seat):
|
|
748
|
+
"""This seat's model. Seats may differ; the room supplies the default."""
|
|
749
|
+
return seat.get("config") or plan["config"]
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def budget(root, plan):
|
|
753
|
+
"""How many rounds this room may run: what it declared, plus extensions.
|
|
754
|
+
|
|
755
|
+
The frozen plan holds the assignment -- personas, packets, the standing
|
|
756
|
+
brief -- and must stay byte-identical for its digest to mean anything. How
|
|
757
|
+
long the room runs is not part of the assignment; it is a decision the
|
|
758
|
+
operator keeps making. Keeping it in its own file lets the budget move
|
|
759
|
+
without ever touching what the writers were promised.
|
|
760
|
+
"""
|
|
761
|
+
path = root / "budget.json"
|
|
762
|
+
return read(path)["rounds"] if path.exists() else plan["rounds"]
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def extend(root, rounds):
|
|
766
|
+
"""Raise the round budget. One way, because lowering it orphans rounds."""
|
|
767
|
+
root, plan = load(root)
|
|
768
|
+
current = budget(root, plan)
|
|
769
|
+
if not isinstance(rounds, int) or rounds <= current:
|
|
770
|
+
raise ValueError(f"this room already allows {current} rounds")
|
|
771
|
+
write(root / "budget.json", {"rounds": rounds, "declared": plan["rounds"]})
|
|
772
|
+
event(root, event="budget-extended", rounds=rounds, was=current)
|
|
773
|
+
return rounds
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def board_order(seats, n):
|
|
777
|
+
"""Seat order for round ``n``'s digest, rotated so no seat always leads.
|
|
778
|
+
|
|
779
|
+
Whoever appears first anchors the read. A fixed order hands the same seat
|
|
780
|
+
that advantage in every round; rotating by the round number spreads it
|
|
781
|
+
evenly and keeps the digest deterministic.
|
|
782
|
+
"""
|
|
783
|
+
names = [s["id"] for s in seats]
|
|
784
|
+
offset = (n - 1) % len(names)
|
|
785
|
+
return names[offset:] + names[:offset]
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def deliverable(plan, n):
|
|
789
|
+
"""The file round ``n``'s phase asks its seats to write, if it asks for one.
|
|
790
|
+
|
|
791
|
+
A drafting phase produces two different things that were previously one:
|
|
792
|
+
a note to the room, and the passage itself. Welded together in a post, the
|
|
793
|
+
passage has to be cut back out by hand afterwards, against whatever
|
|
794
|
+
separator the room improvised. Named here, it is a file.
|
|
795
|
+
"""
|
|
796
|
+
return phase_for(plan, n).get("deliverable")
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def declared_files(plan):
|
|
800
|
+
"""Every file this plan asks for anywhere, not only in the current round.
|
|
801
|
+
|
|
802
|
+
A seat that corrects a section after its phase has passed has nowhere to
|
|
803
|
+
put the correction: only one file is asked for per round, and a file left
|
|
804
|
+
in an outbox is invisible until something asks for it again. In the
|
|
805
|
+
measured run the drafter fixed the preface four rounds after the preface
|
|
806
|
+
phase, then spent a round arguing that the book would be bound stale,
|
|
807
|
+
because nothing had acknowledged the file. Taking any file the plan names
|
|
808
|
+
costs nothing and keeps the latest version of every section collected,
|
|
809
|
+
hashed and attributed the moment it exists.
|
|
810
|
+
"""
|
|
811
|
+
return sorted({p["deliverable"] for p in phases(plan) if p.get("deliverable")})
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def safe_name(value):
|
|
815
|
+
"""A deliverable name a seat may write and the room may copy.
|
|
816
|
+
|
|
817
|
+
The seat chooses the contents and the plan chooses the name, but the name
|
|
818
|
+
is a path the controller resolves inside a seat's outbox, so it stays a
|
|
819
|
+
plain filename. Anything else is a way out of the directory.
|
|
820
|
+
"""
|
|
821
|
+
text = str(value)
|
|
822
|
+
if not text or "/" in text or text.startswith(".") or text != Path(text).name:
|
|
823
|
+
raise ValueError(f"deliverable must be a plain file name: {value!r}")
|
|
824
|
+
return text
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def attributed(text, plan):
|
|
828
|
+
"""The board as the operator reads it: each post's seat named by model.
|
|
829
|
+
|
|
830
|
+
Deliberately applied when printing and never when composing. The stored
|
|
831
|
+
digest is what the seats are shown, and a model name on it would tell
|
|
832
|
+
every writer which model wrote each post, which is a different room: a
|
|
833
|
+
seat would start weighing a colleague by its make rather than by what it
|
|
834
|
+
said. The operator has the opposite need, because the seats are the
|
|
835
|
+
instrument and reading the room means knowing which one spoke.
|
|
836
|
+
|
|
837
|
+
It also answers a real failure. A seat invented model nicknames for
|
|
838
|
+
colleagues it could not see and attached one of them to the wrong
|
|
839
|
+
argument, which is invisible until someone holds the board beside the
|
|
840
|
+
plan.
|
|
841
|
+
"""
|
|
842
|
+
named = {
|
|
843
|
+
s["id"]: f"{s['config']['harness']}/{s['config'].get('model')}"
|
|
844
|
+
for s in plan["seats"]
|
|
845
|
+
}
|
|
846
|
+
for seat, made in named.items():
|
|
847
|
+
text = text.replace(f"({seat})", f"({seat}, {made})")
|
|
848
|
+
return text
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def compose_digest(root, plan, n):
|
|
852
|
+
"""Concatenate round ``n``'s posts, attributed and whole. Never summarize.
|
|
853
|
+
|
|
854
|
+
A digest that compressed four posts into a paragraph would make the
|
|
855
|
+
controller the room's narrator, and every later round would be a response
|
|
856
|
+
to its reading rather than to what the seats actually wrote.
|
|
857
|
+
"""
|
|
858
|
+
rule = "-" * 68
|
|
859
|
+
labels = {s["id"]: label(s) for s in plan["seats"]}
|
|
860
|
+
research = {s["id"] for s in plan["seats"] if s.get("tools") == "research"}
|
|
861
|
+
# Attribution is the whole frame. A name above a post is what makes it a
|
|
862
|
+
# colleague's opinion rather than loose text next to the brief, and it
|
|
863
|
+
# needs no sentence explaining itself. Describing the order, the promotion
|
|
864
|
+
# or the rotation would only tell a writer about machinery it does not
|
|
865
|
+
# occupy.
|
|
866
|
+
made = deliverable(plan, n)
|
|
867
|
+
text = f"# The board -- round {n}\n\n"
|
|
868
|
+
note = chair_note(root, n)
|
|
869
|
+
if note:
|
|
870
|
+
text += f"{rule}\n## From the chair\n{rule}\n\n{note}\n\n"
|
|
871
|
+
included = {}
|
|
872
|
+
for name in board_order(plan["seats"], n):
|
|
873
|
+
post = root / "board" / "posts" / name / f"r{n:03d}.md"
|
|
874
|
+
if not post.exists():
|
|
875
|
+
continue
|
|
876
|
+
mark = " [can search the web]" if name in research else ""
|
|
877
|
+
text += f"{rule}\n## {labels[name]} ({name}){mark}\n{rule}\n\n"
|
|
878
|
+
text += post.read_text().strip() + "\n\n"
|
|
879
|
+
included[name] = digest(post)
|
|
880
|
+
# A phase's deliverable is the work itself, and the room still has to
|
|
881
|
+
# read it. It goes on the board under its author's name like anything
|
|
882
|
+
# else, so a reviewer sees the note and the thing the note is about,
|
|
883
|
+
# while the file stays separately readable as a file.
|
|
884
|
+
work = root / "board" / "made" / name / f"r{n:03d}" / made if made else None
|
|
885
|
+
if work and work.exists():
|
|
886
|
+
text += f"### {made}\n\n{work.read_text().strip()}\n\n"
|
|
887
|
+
absent = [
|
|
888
|
+
s["id"]
|
|
889
|
+
for s in plan["seats"]
|
|
890
|
+
if s["id"] in acting(plan, n) and s["id"] not in included
|
|
891
|
+
]
|
|
892
|
+
if absent:
|
|
893
|
+
names = ", ".join(labels[a] for a in absent)
|
|
894
|
+
text += f"{rule}\n\nNo post this round from: {names}.\n"
|
|
895
|
+
return text, included, absent
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def trail(root):
|
|
899
|
+
"""The room's log rows, oldest first. A partly written last line is
|
|
900
|
+
skipped rather than treated as corruption, because the writer may be
|
|
901
|
+
mid-append."""
|
|
902
|
+
rows = []
|
|
903
|
+
log = Path(root) / "room.log"
|
|
904
|
+
if log.exists():
|
|
905
|
+
for line in log.read_text().splitlines():
|
|
906
|
+
try:
|
|
907
|
+
rows.append(json.loads(line))
|
|
908
|
+
except json.JSONDecodeError:
|
|
909
|
+
continue
|
|
910
|
+
return rows
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def held_round(root, n, rows=None):
|
|
914
|
+
"""The latest ``round-held`` row for round ``n``, while it is unpublished."""
|
|
915
|
+
if (Path(root) / "board" / "rounds" / f"r{n:03d}").exists():
|
|
916
|
+
return None
|
|
917
|
+
rows = trail(root) if rows is None else rows
|
|
918
|
+
return next(
|
|
919
|
+
(
|
|
920
|
+
r
|
|
921
|
+
for r in reversed(rows)
|
|
922
|
+
if r.get("event") == "round-held" and r.get("round") == n
|
|
923
|
+
),
|
|
924
|
+
None,
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def held_back(root, n, rows=None):
|
|
929
|
+
"""Seats round ``n`` held back that have not yet taken their turn.
|
|
930
|
+
|
|
931
|
+
A seat is held back when another stopped on a provider limit first, and
|
|
932
|
+
it stays held back until a later run of the round launches it. Any step
|
|
933
|
+
of its own in the round after the hold counts as taking the turn, a
|
|
934
|
+
failed launch included, since that failure is then the seat's own fact.
|
|
935
|
+
"""
|
|
936
|
+
rows = trail(root) if rows is None else rows
|
|
937
|
+
held = held_round(root, n, rows)
|
|
938
|
+
if not held:
|
|
939
|
+
return []
|
|
940
|
+
since = rows[rows.index(held) + 1 :]
|
|
941
|
+
took = {
|
|
942
|
+
r.get("seat")
|
|
943
|
+
for r in since
|
|
944
|
+
if r.get("round") == n and r.get("event") in ("launching", "stopped")
|
|
945
|
+
}
|
|
946
|
+
return [name for name in held.get("held_back") or [] if name not in took]
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def promote(root, n):
|
|
950
|
+
"""Move every outbox onto the board, then publish the round's digest.
|
|
951
|
+
|
|
952
|
+
This is the only writer of shared state and it runs with every seat
|
|
953
|
+
stopped. It copies, hashes and attributes; it never reads a post to decide
|
|
954
|
+
whether it belongs. A missing or empty outbox means the seat was absent
|
|
955
|
+
for the round, which is a fact about the round and not a failure.
|
|
956
|
+
"""
|
|
957
|
+
root, plan = load(root)
|
|
958
|
+
# A seat held back when another stopped on a provider limit never took
|
|
959
|
+
# its turn, so its outbox is empty for a reason that is not its own.
|
|
960
|
+
# Filing it absent would put a false fact on a board that is never
|
|
961
|
+
# rewritten; it takes its turn when the round is run again.
|
|
962
|
+
waiting = held_back(root, n)
|
|
963
|
+
if waiting:
|
|
964
|
+
raise RuntimeError(
|
|
965
|
+
f"round {n} held back {', '.join(waiting)} when a seat stopped on "
|
|
966
|
+
f"a provider limit; run `round {n}` to take their turns and close "
|
|
967
|
+
"the round"
|
|
968
|
+
)
|
|
969
|
+
speaking = acting(plan, n)
|
|
970
|
+
for seat in plan["seats"]:
|
|
971
|
+
if seat["id"] not in speaking:
|
|
972
|
+
continue
|
|
973
|
+
outbox = root / "seats" / seat["id"] / "outbox" / "post.md"
|
|
974
|
+
if not outbox.exists() or not outbox.read_bytes().strip():
|
|
975
|
+
event(root, round=n, seat=seat["id"], event="absent")
|
|
976
|
+
continue
|
|
977
|
+
post = root / "board" / "posts" / seat["id"] / f"r{n:03d}.md"
|
|
978
|
+
if post.exists():
|
|
979
|
+
raise RuntimeError(f"round {n} already promoted for {seat['id']}")
|
|
980
|
+
shutil.copyfile(outbox, post)
|
|
981
|
+
outbox.unlink()
|
|
982
|
+
event(
|
|
983
|
+
root,
|
|
984
|
+
round=n,
|
|
985
|
+
seat=seat["id"],
|
|
986
|
+
event="promoted",
|
|
987
|
+
bytes=post.stat().st_size,
|
|
988
|
+
sha256=digest(post),
|
|
989
|
+
)
|
|
990
|
+
made = deliverable(plan, n)
|
|
991
|
+
for name in declared_files(plan):
|
|
992
|
+
written = root / "seats" / seat["id"] / "outbox" / name
|
|
993
|
+
if not written.exists() or not written.read_bytes().strip():
|
|
994
|
+
if name == made:
|
|
995
|
+
# The seat posted and did not produce the file the phase
|
|
996
|
+
# asked for. That is a fact about the round, like an
|
|
997
|
+
# absence, and not a reason to fail a round whose post is
|
|
998
|
+
# already on the board.
|
|
999
|
+
event(root, round=n, seat=seat["id"], event="unmade", file=name)
|
|
1000
|
+
continue
|
|
1001
|
+
kept = root / "board" / "made" / seat["id"] / f"r{n:03d}" / name
|
|
1002
|
+
kept.parent.mkdir(parents=True, exist_ok=True)
|
|
1003
|
+
shutil.copyfile(written, kept)
|
|
1004
|
+
written.unlink()
|
|
1005
|
+
event(
|
|
1006
|
+
root,
|
|
1007
|
+
round=n,
|
|
1008
|
+
seat=seat["id"],
|
|
1009
|
+
event="made" if name == made else "revised",
|
|
1010
|
+
file=name,
|
|
1011
|
+
bytes=kept.stat().st_size,
|
|
1012
|
+
sha256=digest(kept),
|
|
1013
|
+
)
|
|
1014
|
+
text, included, absent = compose_digest(root, plan, n)
|
|
1015
|
+
out = root / "board" / "rounds" / f"r{n:03d}"
|
|
1016
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
1017
|
+
(out / "digest.md").write_text(text)
|
|
1018
|
+
write(
|
|
1019
|
+
out / "digest.json",
|
|
1020
|
+
{
|
|
1021
|
+
"round": n,
|
|
1022
|
+
"order": board_order(plan["seats"], n),
|
|
1023
|
+
"posts": included,
|
|
1024
|
+
"absent": absent,
|
|
1025
|
+
"digest_sha256": digest(out / "digest.md"),
|
|
1026
|
+
},
|
|
1027
|
+
)
|
|
1028
|
+
signal = convergence(root, plan, n)
|
|
1029
|
+
write(out / "convergence.json", signal)
|
|
1030
|
+
event(root, round=n, event="round-closed", posted=len(included), absent=len(absent))
|
|
1031
|
+
event(
|
|
1032
|
+
root,
|
|
1033
|
+
round=n,
|
|
1034
|
+
event="convergence",
|
|
1035
|
+
novelty=signal["novelty"],
|
|
1036
|
+
closing=signal["closing"],
|
|
1037
|
+
converged=signal["converged"],
|
|
1038
|
+
reason=signal["reason"],
|
|
1039
|
+
)
|
|
1040
|
+
release_if_finished(root, plan)
|
|
1041
|
+
return {
|
|
1042
|
+
"posted": sorted(included),
|
|
1043
|
+
"absent": absent,
|
|
1044
|
+
"convergence": {
|
|
1045
|
+
k: signal[k] for k in ("novelty", "closing", "converged", "reason")
|
|
1046
|
+
},
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def release_if_finished(root, plan):
|
|
1051
|
+
"""Let `prune` take the seats' homes once the room is where `run` stops.
|
|
1052
|
+
|
|
1053
|
+
That is the stop rule `status` reports: no round held, and either every
|
|
1054
|
+
round of the budget published or the room converged. Extending a room,
|
|
1055
|
+
or running it past its convergence, is the operator's call, and so is
|
|
1056
|
+
pruning before that; each seat launched again holds its home again.
|
|
1057
|
+
"""
|
|
1058
|
+
value = status(root)
|
|
1059
|
+
if value["held"]:
|
|
1060
|
+
return
|
|
1061
|
+
if len(value["published_rounds"]) < value["rounds"] and not value["converged"]:
|
|
1062
|
+
return
|
|
1063
|
+
for seat in plan["seats"]:
|
|
1064
|
+
blind.release(root / "seats" / seat["id"])
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def open_round(root, n):
|
|
1068
|
+
"""Put the previous round's digest inside every workspace, read-only."""
|
|
1069
|
+
root, plan = load(root)
|
|
1070
|
+
if n == 1 or n in plan["silent_rounds"]:
|
|
1071
|
+
return None
|
|
1072
|
+
source = root / "board" / "rounds" / f"r{n - 1:03d}" / "digest.md"
|
|
1073
|
+
if not source.exists():
|
|
1074
|
+
raise RuntimeError(f"round {n - 1} has not been promoted; nothing to show")
|
|
1075
|
+
for seat in plan["seats"]:
|
|
1076
|
+
target = root / "seats" / seat["id"] / "board" / f"round-{n - 1:03d}"
|
|
1077
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
1078
|
+
shutil.copyfile(source, target / "digest.md")
|
|
1079
|
+
event(root, round=n, event="board-published", digest_sha256=digest(source))
|
|
1080
|
+
return str(source)
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def _terminate(proc):
|
|
1084
|
+
"""Stop a seat and everything it started; it is its own session leader."""
|
|
1085
|
+
for number in (signal.SIGTERM, signal.SIGKILL):
|
|
1086
|
+
try:
|
|
1087
|
+
os.killpg(os.getpgid(proc.pid), number)
|
|
1088
|
+
except (ProcessLookupError, PermissionError):
|
|
1089
|
+
return
|
|
1090
|
+
try:
|
|
1091
|
+
proc.wait(timeout=20)
|
|
1092
|
+
return
|
|
1093
|
+
except subprocess.TimeoutExpired:
|
|
1094
|
+
continue
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def compaction(stream):
|
|
1098
|
+
"""Compaction markers in one turn's event stream, if the harness said so.
|
|
1099
|
+
|
|
1100
|
+
A room's whole economy rests on a round being a resume: the seat keeps its
|
|
1101
|
+
reading and its reasoning and is handed only what changed. Compaction is
|
|
1102
|
+
that continuity being silently rewritten mid-conversation, which is why
|
|
1103
|
+
`docs/workflows.md` makes it a red flag for every procedure that invokes a
|
|
1104
|
+
harness. The room had no way to raise it, so a compacted room and a clean
|
|
1105
|
+
one produced identical records.
|
|
1106
|
+
|
|
1107
|
+
Detected, never enforced: the answer is already bought and stays
|
|
1108
|
+
available, and a supervisor decides what a compacted round is worth.
|
|
1109
|
+
"""
|
|
1110
|
+
if not stream.exists():
|
|
1111
|
+
return []
|
|
1112
|
+
try:
|
|
1113
|
+
return observe.compaction_markers(observe.native_rows(stream))
|
|
1114
|
+
except (ValueError, OSError):
|
|
1115
|
+
# A malformed or truncated stream is the failure the receipt already
|
|
1116
|
+
# reports. It must not also turn into a missing compaction check that
|
|
1117
|
+
# reads as a clean one.
|
|
1118
|
+
return [{"timestamp": None, "tags": ["unreadable-stream"]}]
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def run_seat(root, plan, seat, n, *, timeout=None):
|
|
1122
|
+
"""Run one seat's turn for round ``n``: launch it, or resume its session."""
|
|
1123
|
+
name = seat["id"]
|
|
1124
|
+
work, config = root / "seats" / name, seat_config(plan, seat)
|
|
1125
|
+
record = root / "records" / name / f"r{n:03d}"
|
|
1126
|
+
state = read(state_path(root, name))
|
|
1127
|
+
if str(n) in state["rounds"]:
|
|
1128
|
+
return state["rounds"][str(n)]["status"]
|
|
1129
|
+
# The packet and the standing assignment are frozen and checked every
|
|
1130
|
+
# round. The board is deliberately not: it is the one thing that is
|
|
1131
|
+
# supposed to change, which is why it lives outside `input/`.
|
|
1132
|
+
if hashes(work / "input") != seat["input_hashes"]:
|
|
1133
|
+
raise RuntimeError(f"seat input changed: {name}")
|
|
1134
|
+
if digest(work / "START.md") != seat["start_sha256"]:
|
|
1135
|
+
raise RuntimeError(f"standing assignment changed: {name}")
|
|
1136
|
+
resume = state.get("session_id")
|
|
1137
|
+
# A seat with no session is one whose earlier turn produced nothing at
|
|
1138
|
+
# all: a provider refusing before the model ran leaves a failed round and
|
|
1139
|
+
# no conversation to continue. Refusing to run it again costs the seat for
|
|
1140
|
+
# the rest of the room, and the alternative the procedure offered was to
|
|
1141
|
+
# throw away every turn the other seats had already bought. So it opens a
|
|
1142
|
+
# session now and joins in progress, reading the boards it missed. What it
|
|
1143
|
+
# cannot recover is its opening position, which the room heard from
|
|
1144
|
+
# everyone else before it arrived; `status` reports the round it joined so
|
|
1145
|
+
# no later reading mistakes a late arrival for an independent opening.
|
|
1146
|
+
cold = n > 1 and not resume
|
|
1147
|
+
if resume:
|
|
1148
|
+
blind.require_session(work, config["harness"], resume)
|
|
1149
|
+
record.mkdir(parents=True)
|
|
1150
|
+
return launch_turn(
|
|
1151
|
+
root,
|
|
1152
|
+
plan,
|
|
1153
|
+
seat,
|
|
1154
|
+
n,
|
|
1155
|
+
turn_prompt(root, plan, seat, n, cold=cold),
|
|
1156
|
+
resume,
|
|
1157
|
+
timeout=timeout,
|
|
1158
|
+
)
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def launch_turn(
|
|
1162
|
+
root,
|
|
1163
|
+
plan,
|
|
1164
|
+
seat,
|
|
1165
|
+
n,
|
|
1166
|
+
prompt,
|
|
1167
|
+
resume,
|
|
1168
|
+
*,
|
|
1169
|
+
timeout=None,
|
|
1170
|
+
workflow="room",
|
|
1171
|
+
readonly=("board",),
|
|
1172
|
+
):
|
|
1173
|
+
"""Run one turn and bank what it produced, however it ended.
|
|
1174
|
+
|
|
1175
|
+
Split from ``run_seat`` so a continuation after a quota stop is the same
|
|
1176
|
+
launch, the same jail, the same receipt and the same records as the turn
|
|
1177
|
+
it replaces. A recovery that took a different path would be evidence
|
|
1178
|
+
about the recovery rather than about the room.
|
|
1179
|
+
|
|
1180
|
+
Public because the roundtable takes its turns through it too: one jail,
|
|
1181
|
+
one receipt and one record layout for every procedure that seats models
|
|
1182
|
+
in rounds. ``readonly`` names the workspace folders bound read-only over
|
|
1183
|
+
the writable workspace, and ``workflow`` is what the usage record calls
|
|
1184
|
+
the procedure that launched the seat.
|
|
1185
|
+
"""
|
|
1186
|
+
name = seat["id"]
|
|
1187
|
+
work, config = root / "seats" / name, seat_config(plan, seat)
|
|
1188
|
+
record = root / "records" / name / f"r{n:03d}"
|
|
1189
|
+
record.mkdir(parents=True, exist_ok=True)
|
|
1190
|
+
# A family such as `opus` means whatever is newest when the CLI runs, so
|
|
1191
|
+
# a resumed session names the version its first answer was served on. A
|
|
1192
|
+
# release between rounds would otherwise switch the seat's model while
|
|
1193
|
+
# the room reads it as the same voice. A seat whose plan already names
|
|
1194
|
+
# that exact version needs no pin, and claiming one would add nothing.
|
|
1195
|
+
pinned = read(state_path(root, name)).get("model_served") if resume else None
|
|
1196
|
+
if pinned and pinned != config["model"]:
|
|
1197
|
+
config = {**config, "model_pinned": pinned}
|
|
1198
|
+
proven = drivers.canary(config, f"{workflow} seat {name}")
|
|
1199
|
+
# Open before the jail is built, since its argv names the proxy's socket,
|
|
1200
|
+
# and closed only after the seat's process has exited.
|
|
1201
|
+
with blind.session_bus([config["harness"]]) as bus:
|
|
1202
|
+
args, payload = drivers.command(config, work, prompt, resume=resume)
|
|
1203
|
+
jail, env, private = drivers.launch_context(config, work, bus, proven)
|
|
1204
|
+
blind.hold(
|
|
1205
|
+
work,
|
|
1206
|
+
f"{workflow} {root.name} resumes seat {name}'s session until it finishes",
|
|
1207
|
+
)
|
|
1208
|
+
for folder in readonly:
|
|
1209
|
+
jail += ["--ro-bind", str(work / folder), str(work / folder)]
|
|
1210
|
+
(record / "prompt.md").write_text(prompt)
|
|
1211
|
+
usage_records.seat(workflow, config)
|
|
1212
|
+
write(
|
|
1213
|
+
record / "launch.json",
|
|
1214
|
+
{
|
|
1215
|
+
"argv": args,
|
|
1216
|
+
"jail": jail,
|
|
1217
|
+
"resumed": resume,
|
|
1218
|
+
# Where the session stood before this turn was sent, so a turn
|
|
1219
|
+
# the provider refuses can be cut back out of it and sent
|
|
1220
|
+
# again into the same session (`recovery`).
|
|
1221
|
+
"resume_point": quota.session_marks(
|
|
1222
|
+
blind.session_paths(private, config["harness"], resume)
|
|
1223
|
+
)
|
|
1224
|
+
if resume
|
|
1225
|
+
else None,
|
|
1226
|
+
"harness_version": blind.runner_version(config["harness"]),
|
|
1227
|
+
"private_runtime": str(private),
|
|
1228
|
+
"window_assurance": drivers.window_assurance(config["harness"]),
|
|
1229
|
+
"session_bus": bus.receipt() if bus else None,
|
|
1230
|
+
"canary": proven,
|
|
1231
|
+
},
|
|
1232
|
+
)
|
|
1233
|
+
with slot(config["harness"]):
|
|
1234
|
+
event(root, round=n, seat=name, event="launching", resumed=resume)
|
|
1235
|
+
started = time.time()
|
|
1236
|
+
with (
|
|
1237
|
+
(record / "events.jsonl").open("xb") as out,
|
|
1238
|
+
(record / "stderr.log").open("xb") as err,
|
|
1239
|
+
):
|
|
1240
|
+
proc = blind.popen(
|
|
1241
|
+
jail + args,
|
|
1242
|
+
bus,
|
|
1243
|
+
stdin=subprocess.PIPE,
|
|
1244
|
+
stdout=out,
|
|
1245
|
+
stderr=err,
|
|
1246
|
+
cwd=current_project().root,
|
|
1247
|
+
env=env,
|
|
1248
|
+
start_new_session=True,
|
|
1249
|
+
)
|
|
1250
|
+
event(
|
|
1251
|
+
root,
|
|
1252
|
+
round=n,
|
|
1253
|
+
seat=name,
|
|
1254
|
+
event="running",
|
|
1255
|
+
pid=proc.pid,
|
|
1256
|
+
process_identity=process_identity(proc.pid),
|
|
1257
|
+
)
|
|
1258
|
+
why = exits.wait(
|
|
1259
|
+
proc,
|
|
1260
|
+
payload,
|
|
1261
|
+
harness=config["harness"],
|
|
1262
|
+
events=record / "events.jsonl",
|
|
1263
|
+
timeout=timeout,
|
|
1264
|
+
)
|
|
1265
|
+
if why:
|
|
1266
|
+
_terminate(proc)
|
|
1267
|
+
proc.communicate()
|
|
1268
|
+
timed_out = why == exits.TIMEOUT
|
|
1269
|
+
if why == exits.ANSWERED:
|
|
1270
|
+
exits.record(
|
|
1271
|
+
record,
|
|
1272
|
+
harness=config["harness"],
|
|
1273
|
+
grace=exits.EXIT_GRACE,
|
|
1274
|
+
exit_code=proc.returncode,
|
|
1275
|
+
)
|
|
1276
|
+
code, seconds = proc.returncode, round(time.time() - started, 2)
|
|
1277
|
+
event(
|
|
1278
|
+
root,
|
|
1279
|
+
round=n,
|
|
1280
|
+
seat=name,
|
|
1281
|
+
event="exited",
|
|
1282
|
+
code=code,
|
|
1283
|
+
seconds=seconds,
|
|
1284
|
+
timed_out=timed_out,
|
|
1285
|
+
stopped_after_answer=why == exits.ANSWERED,
|
|
1286
|
+
)
|
|
1287
|
+
try:
|
|
1288
|
+
got = drivers.receipt(config, record, private, resumed=resume)
|
|
1289
|
+
status, detail = "answered", None
|
|
1290
|
+
except (RuntimeError, ValueError, OSError) as exc:
|
|
1291
|
+
got, status, detail = {}, "failed", str(exc)
|
|
1292
|
+
compacted = compaction(record / "events.jsonl")
|
|
1293
|
+
# A provider limit and a crash both leave a failed turn, and only one of
|
|
1294
|
+
# them is worth waiting out. Classifying here is what lets `continue`
|
|
1295
|
+
# refuse everything else: a timeout re-run looks identical to a quota
|
|
1296
|
+
# re-run at the command line and duplicates work that actually happened.
|
|
1297
|
+
stop = quota.classify(config["harness"], record) if status == "failed" else None
|
|
1298
|
+
if stop:
|
|
1299
|
+
status = "quota"
|
|
1300
|
+
write(
|
|
1301
|
+
record / "receipt.json",
|
|
1302
|
+
{
|
|
1303
|
+
**got,
|
|
1304
|
+
"exit_code": code,
|
|
1305
|
+
"seconds": seconds,
|
|
1306
|
+
"timed_out": timed_out,
|
|
1307
|
+
"stopped_after_answer": exits.recorded(record),
|
|
1308
|
+
"error": detail,
|
|
1309
|
+
"compaction_markers": compacted,
|
|
1310
|
+
"quota_stop": stop["phase"] if stop else None,
|
|
1311
|
+
"quota_scope": stop.get("scope") if stop else None,
|
|
1312
|
+
"quota_resets_at": stop.get("resets_at") if stop else None,
|
|
1313
|
+
"red_flags": got.get("red_flags", [])
|
|
1314
|
+
+ ([blind.Bus.flag(bus.receipt())] if bus else []),
|
|
1315
|
+
},
|
|
1316
|
+
)
|
|
1317
|
+
if compacted:
|
|
1318
|
+
event(root, round=n, seat=name, event="compacted", markers=len(compacted))
|
|
1319
|
+
state = read(state_path(root, name))
|
|
1320
|
+
if got.get("session_id"):
|
|
1321
|
+
state["session_id"] = got["session_id"]
|
|
1322
|
+
if status == "answered" and got.get("model") and not state.get("model_served"):
|
|
1323
|
+
state["model_served"] = got["model"]
|
|
1324
|
+
state["rounds"][str(n)] = {
|
|
1325
|
+
"status": status,
|
|
1326
|
+
"seconds": seconds,
|
|
1327
|
+
"error": detail,
|
|
1328
|
+
**(
|
|
1329
|
+
{"quota_stop": stop["phase"], "quota_scope": stop.get("scope")}
|
|
1330
|
+
if stop
|
|
1331
|
+
else {}
|
|
1332
|
+
),
|
|
1333
|
+
}
|
|
1334
|
+
# A refused stop leaves no session; an interrupted one leaves the session
|
|
1335
|
+
# holding the reasoning, and losing its id here is what turns a
|
|
1336
|
+
# continuation into a cold restart wearing a continuation's receipt.
|
|
1337
|
+
if stop and stop.get("session_id") and not state.get("session_id"):
|
|
1338
|
+
state["session_id"] = stop["session_id"]
|
|
1339
|
+
state["status"] = status
|
|
1340
|
+
write(state_path(root, name), state)
|
|
1341
|
+
event(root, round=n, seat=name, event="collected", status=status, error=detail)
|
|
1342
|
+
return status
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
def continue_seat(root, n, name, *, timeout=None):
|
|
1346
|
+
"""Take one seat's turn again after a provider limit stopped it.
|
|
1347
|
+
|
|
1348
|
+
Two stops, opposite recoveries, and `quota` owns which is which. *Refused*
|
|
1349
|
+
means the provider declined before the model ran, so nothing of the turn
|
|
1350
|
+
exists to continue: the identical turn is delivered again, out of the
|
|
1351
|
+
prompt the stopped attempt was given, into the session it was continuing
|
|
1352
|
+
and with the refused attempt's rows cut from it (`recovery`). *Interrupted*
|
|
1353
|
+
means the model ran and the wall arrived partway, so the session holds the
|
|
1354
|
+
reading and the reasoning and the turn is continued inside it, with no
|
|
1355
|
+
re-delivery of anything the seat has already been shown.
|
|
1356
|
+
|
|
1357
|
+
It refuses everything that is not a classified quota stop. A crash, a
|
|
1358
|
+
timeout and an ambiguous transport fault look identical to this at the
|
|
1359
|
+
command line, and re-running one of those can duplicate work that really
|
|
1360
|
+
happened. The stopped attempt is archived whole beside the new one, so the
|
|
1361
|
+
round reads as two submissions rather than one that changed its mind, and
|
|
1362
|
+
the receipt says so.
|
|
1363
|
+
"""
|
|
1364
|
+
root, plan = load(root)
|
|
1365
|
+
# `seat_named` resolves an id or a position and returns the id, so the
|
|
1366
|
+
# seat itself still has to be looked up.
|
|
1367
|
+
name = seat_named(plan, name)
|
|
1368
|
+
seat = next(s for s in plan["seats"] if s["id"] == name)
|
|
1369
|
+
record = root / "records" / name / f"r{n:03d}"
|
|
1370
|
+
state = read(state_path(root, name))
|
|
1371
|
+
attempt = state.get("rounds", {}).get(str(n))
|
|
1372
|
+
if not attempt:
|
|
1373
|
+
raise ValueError(f"{name} has no round {n} to continue")
|
|
1374
|
+
if attempt["status"] == "answered":
|
|
1375
|
+
raise ValueError(f"{name} already answered round {n}")
|
|
1376
|
+
if (root / "board" / "posts" / name / f"r{n:03d}.md").exists():
|
|
1377
|
+
raise RuntimeError(f"round {n} is already published for {name}")
|
|
1378
|
+
config = seat_config(plan, seat)
|
|
1379
|
+
stop = quota.classify(config["harness"], record)
|
|
1380
|
+
if not stop:
|
|
1381
|
+
raise RuntimeError(
|
|
1382
|
+
f"{name} does not carry a provider quota stop in round {n}; "
|
|
1383
|
+
"diagnose the failure rather than taking the turn again"
|
|
1384
|
+
)
|
|
1385
|
+
resume, prompt, rewound = recovery(
|
|
1386
|
+
record, root / "seats" / name, config["harness"], stop
|
|
1387
|
+
)
|
|
1388
|
+
number = len(list((record / "attempts").glob("[0-9][0-9]"))) + 1
|
|
1389
|
+
folder = record / "attempts" / f"{number:02d}"
|
|
1390
|
+
folder.mkdir(parents=True)
|
|
1391
|
+
for item in (
|
|
1392
|
+
"events.jsonl",
|
|
1393
|
+
"stderr.log",
|
|
1394
|
+
"prompt.md",
|
|
1395
|
+
"launch.json",
|
|
1396
|
+
"receipt.json",
|
|
1397
|
+
):
|
|
1398
|
+
source = record / item
|
|
1399
|
+
if source.exists():
|
|
1400
|
+
shutil.move(str(source), str(folder / item))
|
|
1401
|
+
del state["rounds"][str(n)]
|
|
1402
|
+
state.setdefault("attempts", []).append(
|
|
1403
|
+
{
|
|
1404
|
+
"round": n,
|
|
1405
|
+
"attempt": number,
|
|
1406
|
+
"quota_stop": stop["phase"],
|
|
1407
|
+
"quota_scope": stop.get("scope"),
|
|
1408
|
+
"resumed": resume,
|
|
1409
|
+
"rewound": rewound,
|
|
1410
|
+
}
|
|
1411
|
+
)
|
|
1412
|
+
write(state_path(root, name), state)
|
|
1413
|
+
event(
|
|
1414
|
+
root,
|
|
1415
|
+
round=n,
|
|
1416
|
+
seat=name,
|
|
1417
|
+
event="continuing",
|
|
1418
|
+
quota_stop=stop["phase"],
|
|
1419
|
+
resumed=resume,
|
|
1420
|
+
rewound=rewound,
|
|
1421
|
+
attempt=number,
|
|
1422
|
+
)
|
|
1423
|
+
return launch_turn(root, plan, seat, n, prompt, resume, timeout=timeout)
|
|
1424
|
+
|
|
1425
|
+
|
|
1426
|
+
def recovery(record, work, harness, stop):
|
|
1427
|
+
"""How a turn a provider limit stopped is taken again.
|
|
1428
|
+
|
|
1429
|
+
Returns the session to launch into (None for a fresh one), the prompt,
|
|
1430
|
+
and whether the session was rewound first. An *interrupted* turn is
|
|
1431
|
+
continued inside the session that holds its reading, with a short notice.
|
|
1432
|
+
Any other stop is the identical turn delivered again, out of the prompt
|
|
1433
|
+
the stopped attempt was given. A turn that opened the seat's session goes
|
|
1434
|
+
in a fresh one, since nothing of it exists. A turn that was itself a
|
|
1435
|
+
resume, which is every round after a seat's first, goes back into the
|
|
1436
|
+
session it was continuing, because that session holds the seat's earlier
|
|
1437
|
+
rounds. Sending it in a fresh one lost them, and its prompt, written for
|
|
1438
|
+
a seat that remembers them, reached one with no assignment at all.
|
|
1439
|
+
|
|
1440
|
+
The rows the refused attempt appended are cut away first, back to the
|
|
1441
|
+
mark its launch took, as the run engine does. A launch from before marks
|
|
1442
|
+
were taken left none, and the session then still shows the refused
|
|
1443
|
+
prompt once before the same prompt again. The session is checked before
|
|
1444
|
+
anything changes, and the rewind comes last, so a refusal of either kind
|
|
1445
|
+
leaves the turn as it was.
|
|
1446
|
+
"""
|
|
1447
|
+
session = quota.resumable_session(stop)
|
|
1448
|
+
if session:
|
|
1449
|
+
blind.require_session(work, harness, session)
|
|
1450
|
+
return session, quota.continuation(), False
|
|
1451
|
+
launch = read(record / "launch.json") if (record / "launch.json").exists() else {}
|
|
1452
|
+
prompt = (record / "prompt.md").read_text()
|
|
1453
|
+
session = launch.get("resumed")
|
|
1454
|
+
if not session:
|
|
1455
|
+
return None, prompt, False
|
|
1456
|
+
blind.require_session(work, harness, session)
|
|
1457
|
+
marks = launch.get("resume_point")
|
|
1458
|
+
if marks is None:
|
|
1459
|
+
return session, prompt, False
|
|
1460
|
+
quota.rewind_sessions(marks)
|
|
1461
|
+
return session, prompt, True
|
|
1462
|
+
|
|
1463
|
+
|
|
1464
|
+
def run_round(root, n, *, jobs=1, timeout=None, past=()):
|
|
1465
|
+
"""Open the round, run every seat, then close it by promoting the outboxes.
|
|
1466
|
+
|
|
1467
|
+
Seats run one at a time unless ``jobs`` says otherwise, and the account
|
|
1468
|
+
lease caps them again per harness. Sequential is the default because
|
|
1469
|
+
seats sharing one account drain a rate window several times faster
|
|
1470
|
+
together and the wall then lands mid-turn on all of them at once; it also
|
|
1471
|
+
preserves whatever spend order the plan recorded. The round closes once
|
|
1472
|
+
every seat has stopped, so the board changes at exactly one instant and no
|
|
1473
|
+
seat reads a partial one.
|
|
1474
|
+
|
|
1475
|
+
A seat that stops on a provider limit pauses the round: no seat after it
|
|
1476
|
+
is launched, because what to do next is the operator's decision (wait the
|
|
1477
|
+
window out, or go on without that seat), and launching the rest first
|
|
1478
|
+
spends their allowance before it is made. The round is held with the
|
|
1479
|
+
stopped seats and the held-back seats named. Running the same round
|
|
1480
|
+
again resumes it: a seat that already answered is not launched again, a
|
|
1481
|
+
seat still stopped holds it again, and the held-back seats take their
|
|
1482
|
+
turns in plan order. ``past`` names stopped seats the operator has chosen
|
|
1483
|
+
not to wait for; they no longer hold the others back, and the round stays
|
|
1484
|
+
held on them until ``promote`` files them absent.
|
|
1485
|
+
"""
|
|
1486
|
+
root, plan = load(root)
|
|
1487
|
+
with lock(root / "room.lock"):
|
|
1488
|
+
allowed = budget(root, plan)
|
|
1489
|
+
if n < 1 or n > allowed:
|
|
1490
|
+
raise ValueError(f"round must be 1..{allowed}; extend the room first")
|
|
1491
|
+
names = {s["id"] for s in plan["seats"]}
|
|
1492
|
+
past = {seat_named(plan, name) for name in past}
|
|
1493
|
+
# Every seat resumes its session each round. One whose private home
|
|
1494
|
+
# has gone would fail after the round opened and the board went out
|
|
1495
|
+
# to the others, so the round is refused before either happens.
|
|
1496
|
+
for seat in plan["seats"]:
|
|
1497
|
+
state = read(state_path(root, seat["id"]))
|
|
1498
|
+
if seat["id"] in acting(plan, n) and str(n) not in state["rounds"]:
|
|
1499
|
+
if state.get("session_id"):
|
|
1500
|
+
blind.require_session(
|
|
1501
|
+
root / "seats" / seat["id"],
|
|
1502
|
+
seat_config(plan, seat)["harness"],
|
|
1503
|
+
state["session_id"],
|
|
1504
|
+
)
|
|
1505
|
+
resumed = held_round(root, n)
|
|
1506
|
+
if resumed:
|
|
1507
|
+
# The board this round shows was published when it first opened,
|
|
1508
|
+
# and every seat that already spoke read that one; publishing it
|
|
1509
|
+
# again would only repeat the trail.
|
|
1510
|
+
event(root, round=n, event="round-resumed", jobs=jobs)
|
|
1511
|
+
else:
|
|
1512
|
+
event(root, round=n, event="round-opened", jobs=jobs)
|
|
1513
|
+
open_round(root, n)
|
|
1514
|
+
results = {}
|
|
1515
|
+
|
|
1516
|
+
speaking = acting(plan, n)
|
|
1517
|
+
for seat in plan["seats"]:
|
|
1518
|
+
if seat["id"] not in speaking:
|
|
1519
|
+
event(root, round=n, seat=seat["id"], event="resting")
|
|
1520
|
+
|
|
1521
|
+
halted = Event()
|
|
1522
|
+
|
|
1523
|
+
def task(seat):
|
|
1524
|
+
if halted.is_set():
|
|
1525
|
+
event(root, round=n, seat=seat["id"], event="held-back")
|
|
1526
|
+
return seat["id"], "held back"
|
|
1527
|
+
try:
|
|
1528
|
+
outcome = run_seat(root, plan, seat, n, timeout=timeout)
|
|
1529
|
+
except (RuntimeError, ValueError, OSError, subprocess.SubprocessError) as e:
|
|
1530
|
+
event(root, round=n, seat=seat["id"], event="stopped", error=str(e))
|
|
1531
|
+
return seat["id"], f"stopped: {e}"
|
|
1532
|
+
if outcome == "quota" and seat["id"] not in past:
|
|
1533
|
+
halted.set()
|
|
1534
|
+
return seat["id"], outcome
|
|
1535
|
+
|
|
1536
|
+
# `submit` carries the caller's project context into each thread.
|
|
1537
|
+
# A bare pool.map would let a worker re-resolve the project from disk,
|
|
1538
|
+
# which is exactly the cross-thread leak the project layer prevents.
|
|
1539
|
+
with ThreadPoolExecutor(max_workers=max(1, jobs)) as pool:
|
|
1540
|
+
speakers = [s for s in plan["seats"] if s["id"] in speaking]
|
|
1541
|
+
for future in [submit(pool, task, s) for s in speakers]:
|
|
1542
|
+
name, outcome = future.result()
|
|
1543
|
+
results[name] = outcome
|
|
1544
|
+
# A quota stop is not an absence, and the digest is the durable record
|
|
1545
|
+
# of what the room was shown. Publishing one that files a seat as
|
|
1546
|
+
# silent would put a false fact beyond reach, because a published
|
|
1547
|
+
# board is never rewritten. So the round stays open, the operator
|
|
1548
|
+
# waits the provider out, continues that seat, and runs the round
|
|
1549
|
+
# again to take the held-back seats' turns and close it.
|
|
1550
|
+
stopped = sorted(k for k, v in results.items() if v == "quota")
|
|
1551
|
+
back = [s["id"] for s in speakers if results.get(s["id"]) == "held back"]
|
|
1552
|
+
if stopped or back:
|
|
1553
|
+
event(
|
|
1554
|
+
root,
|
|
1555
|
+
round=n,
|
|
1556
|
+
event="round-held",
|
|
1557
|
+
waiting_on=stopped,
|
|
1558
|
+
held_back=back,
|
|
1559
|
+
past=sorted(past & names & set(stopped)),
|
|
1560
|
+
)
|
|
1561
|
+
results["_board"] = {"held": stopped, "held_back": back}
|
|
1562
|
+
return results
|
|
1563
|
+
results["_board"] = promote(root, n)
|
|
1564
|
+
return results
|
|
1565
|
+
|
|
1566
|
+
|
|
1567
|
+
def run_until(root, *, limit=None, jobs=1, timeout=None):
|
|
1568
|
+
"""Run rounds until the room converges or the budget runs out.
|
|
1569
|
+
|
|
1570
|
+
The room is asked to stop, never silenced: a single `round` still runs on
|
|
1571
|
+
request, and this only decides when to stop asking for another. Leaving
|
|
1572
|
+
the decision to the room itself is the one thing that does not work, since
|
|
1573
|
+
a room with nothing left to say goes on writing paragraphs.
|
|
1574
|
+
|
|
1575
|
+
A room that declared phases is not asked. Its schedule already says how
|
|
1576
|
+
long it runs and in what order, and the stop rule reads a board, not a
|
|
1577
|
+
plan: the signal cannot tell "these writers have exhausted the question"
|
|
1578
|
+
from "this phase is two rounds of notes on a draft, and the revision that
|
|
1579
|
+
answers them comes next." Stopping such a room mid-schedule ships the
|
|
1580
|
+
draft nobody rewrote. The signal is still measured and still recorded on
|
|
1581
|
+
every round, because an operator watching a phased room still wants to
|
|
1582
|
+
know when a discussion has gone quiet.
|
|
1583
|
+
"""
|
|
1584
|
+
root = room_root(root)
|
|
1585
|
+
played = []
|
|
1586
|
+
while True:
|
|
1587
|
+
_, plan = load(root)
|
|
1588
|
+
ceiling = (
|
|
1589
|
+
budget(root, plan) if limit is None else min(limit, budget(root, plan))
|
|
1590
|
+
)
|
|
1591
|
+
number = len(list((root / "board" / "rounds").glob("r[0-9][0-9][0-9]"))) + 1
|
|
1592
|
+
if number > ceiling:
|
|
1593
|
+
return played, "budget"
|
|
1594
|
+
outcome = run_round(root, number, jobs=jobs, timeout=timeout)
|
|
1595
|
+
played.append((number, outcome))
|
|
1596
|
+
# A held round has no board and no signal: a seat hit a provider
|
|
1597
|
+
# wall and the round stays open until the operator continues it.
|
|
1598
|
+
# Asking for another round here would run it on top of an unclosed
|
|
1599
|
+
# one, so the loop hands back instead of reading a digest that was
|
|
1600
|
+
# deliberately never written.
|
|
1601
|
+
held = outcome["_board"].get("held")
|
|
1602
|
+
if held:
|
|
1603
|
+
return played, f"held on {', '.join(held)}"
|
|
1604
|
+
signal = outcome["_board"]["convergence"]
|
|
1605
|
+
if signal["converged"] and not plan.get("phases"):
|
|
1606
|
+
return played, f"converged on {signal['reason']}"
|
|
1607
|
+
|
|
1608
|
+
|
|
1609
|
+
def status(root):
|
|
1610
|
+
"""A read-only view of a room, safe to call while it runs.
|
|
1611
|
+
|
|
1612
|
+
It takes no lock, opens no session and writes nothing, so watching cannot
|
|
1613
|
+
perturb what it watches. A partly written final log line is ignored rather
|
|
1614
|
+
than treated as corruption, because the writer may be mid-append.
|
|
1615
|
+
"""
|
|
1616
|
+
root, plan = load(root, verify=False)
|
|
1617
|
+
rows = trail(root)
|
|
1618
|
+
allowed = budget(root, plan)
|
|
1619
|
+
seats = {}
|
|
1620
|
+
for seat in plan["seats"]:
|
|
1621
|
+
state = read(state_path(root, seat["id"]))
|
|
1622
|
+
posts = sorted(
|
|
1623
|
+
p.name for p in (root / "board" / "posts" / seat["id"]).glob("r*.md")
|
|
1624
|
+
)
|
|
1625
|
+
outbox = root / "seats" / seat["id"] / "outbox" / "post.md"
|
|
1626
|
+
receipts = {
|
|
1627
|
+
int(path.parent.name[1:]): read(path)
|
|
1628
|
+
for path in (root / "records" / seat["id"]).glob(
|
|
1629
|
+
"r[0-9][0-9][0-9]/receipt.json"
|
|
1630
|
+
)
|
|
1631
|
+
}
|
|
1632
|
+
seats[seat["id"]] = {
|
|
1633
|
+
"label": label(seat),
|
|
1634
|
+
"status": state.get("status"),
|
|
1635
|
+
"session_id": state.get("session_id"),
|
|
1636
|
+
"rounds": state.get("rounds", {}),
|
|
1637
|
+
# The rounds this seat speaks in. It listens through the others, so
|
|
1638
|
+
# they are not turns it owes, and counting them would report a
|
|
1639
|
+
# phase seat as permanently behind. Read over the budget rather
|
|
1640
|
+
# than the declared count, so an extended room counts its own.
|
|
1641
|
+
"acting_rounds": [
|
|
1642
|
+
n for n in range(1, allowed + 1) if seat["id"] in acting(plan, n)
|
|
1643
|
+
],
|
|
1644
|
+
"posts": posts,
|
|
1645
|
+
"drafting": outbox.stat().st_size if outbox.exists() else 0,
|
|
1646
|
+
"served": f"{seat['config']['harness']}/{drivers.model_text(seat['config'])}",
|
|
1647
|
+
"red_flags": {
|
|
1648
|
+
n: got["red_flags"]
|
|
1649
|
+
for n, got in sorted(receipts.items())
|
|
1650
|
+
if got.get("red_flags")
|
|
1651
|
+
},
|
|
1652
|
+
"quota_resets_at": {
|
|
1653
|
+
n: got["quota_resets_at"]
|
|
1654
|
+
for n, got in receipts.items()
|
|
1655
|
+
if got.get("quota_resets_at")
|
|
1656
|
+
},
|
|
1657
|
+
# A seat that opened its session after round one heard the room
|
|
1658
|
+
# before it spoke, so its first post is a reply and not an opening
|
|
1659
|
+
# position. Round one is the only independent evidence a room
|
|
1660
|
+
# contains, and a late arrival is the one way a seat can appear in
|
|
1661
|
+
# it and not have supplied any.
|
|
1662
|
+
"joined_late": next(
|
|
1663
|
+
(
|
|
1664
|
+
int(r)
|
|
1665
|
+
for r, v in sorted(
|
|
1666
|
+
state.get("rounds", {}).items(), key=lambda i: int(i[0])
|
|
1667
|
+
)
|
|
1668
|
+
if v.get("status") == "answered"
|
|
1669
|
+
),
|
|
1670
|
+
None,
|
|
1671
|
+
),
|
|
1672
|
+
}
|
|
1673
|
+
published = sorted(p.name for p in (root / "board" / "rounds").glob("r*"))
|
|
1674
|
+
# The latest signal carries the whole measured series, so the approach to
|
|
1675
|
+
# convergence can be shown without recomputing anything from the board.
|
|
1676
|
+
signal = (
|
|
1677
|
+
read(root / "board" / "rounds" / published[-1] / "convergence.json")
|
|
1678
|
+
if published
|
|
1679
|
+
and (root / "board" / "rounds" / published[-1] / "convergence.json").exists()
|
|
1680
|
+
else None
|
|
1681
|
+
)
|
|
1682
|
+
# A phased room is not asked whether it converged, so the measured
|
|
1683
|
+
# signal must not end it early here either; `run_until` has the same rule.
|
|
1684
|
+
converged = bool(signal and signal["converged"] and not plan.get("phases"))
|
|
1685
|
+
held = [r for r in rows if r.get("event") == "round-held"]
|
|
1686
|
+
still_held = (
|
|
1687
|
+
dict(held[-1])
|
|
1688
|
+
if held and f"r{held[-1].get('round', 0):03d}" not in published
|
|
1689
|
+
else None
|
|
1690
|
+
)
|
|
1691
|
+
if still_held:
|
|
1692
|
+
# Recorded as held back when the round paused; still owed a turn
|
|
1693
|
+
# only until a later run of the round launches it.
|
|
1694
|
+
still_held["held_back"] = held_back(root, still_held.get("round"), rows)
|
|
1695
|
+
# An operator writes `chair/r<NNN>.md` before opening round NNN and has
|
|
1696
|
+
# nothing else to confirm it will be picked up, or to notice one left over
|
|
1697
|
+
# from a round that has already run.
|
|
1698
|
+
spoken = sorted(p.stem for p in (root / "chair").glob("r[0-9][0-9][0-9].md"))
|
|
1699
|
+
chair = {
|
|
1700
|
+
"delivered": [r for r in spoken if f"{r}" in {p for p in published}],
|
|
1701
|
+
"queued": [r for r in spoken if r not in {p for p in published}],
|
|
1702
|
+
}
|
|
1703
|
+
value = {
|
|
1704
|
+
"room": str(root),
|
|
1705
|
+
"title": plan.get("title"),
|
|
1706
|
+
"rounds": allowed,
|
|
1707
|
+
"declared_rounds": plan["rounds"],
|
|
1708
|
+
"published_rounds": published,
|
|
1709
|
+
"convergence": signal,
|
|
1710
|
+
"converged": converged,
|
|
1711
|
+
"held": still_held,
|
|
1712
|
+
"chair": chair,
|
|
1713
|
+
"seats": seats,
|
|
1714
|
+
"red_flags": sum(
|
|
1715
|
+
len(flags) for one in seats.values() for flags in one["red_flags"].values()
|
|
1716
|
+
),
|
|
1717
|
+
# Read from the trail rather than from every receipt: the trail is
|
|
1718
|
+
# already open, and a red flag an operator has to go looking for is
|
|
1719
|
+
# one they will find after deciding the room went fine.
|
|
1720
|
+
"compacted": [
|
|
1721
|
+
{"seat": r.get("seat"), "round": r.get("round")}
|
|
1722
|
+
for r in rows
|
|
1723
|
+
if r.get("event") == "compacted"
|
|
1724
|
+
],
|
|
1725
|
+
"last_events": rows[-8:],
|
|
1726
|
+
}
|
|
1727
|
+
value["next"] = next_step(value)
|
|
1728
|
+
return value
|
|
1729
|
+
|
|
1730
|
+
|
|
1731
|
+
def reset_text(when):
|
|
1732
|
+
"""A provider's reset as an operator reads it.
|
|
1733
|
+
|
|
1734
|
+
Harnesses report it as epoch seconds or as their own text; a number is
|
|
1735
|
+
shown in local time, because the operator sets a timer by it, and
|
|
1736
|
+
anything else is shown as the provider gave it.
|
|
1737
|
+
"""
|
|
1738
|
+
if isinstance(when, (int, float)) and not isinstance(when, bool):
|
|
1739
|
+
return time.strftime("%Y-%m-%d %H:%M %Z", time.localtime(when))
|
|
1740
|
+
return str(when)
|
|
1741
|
+
|
|
1742
|
+
|
|
1743
|
+
def next_step(value):
|
|
1744
|
+
"""The one command to type next.
|
|
1745
|
+
|
|
1746
|
+
Every other room command ends by naming the one that follows it; status
|
|
1747
|
+
read the room and stopped, so a prepared room and a finished one ended
|
|
1748
|
+
the same way and neither said what to do. The branches mirror
|
|
1749
|
+
`run_until`'s stop rule and `continue_seat`'s refusals, so a reader is
|
|
1750
|
+
never sent to a command the room would refuse.
|
|
1751
|
+
"""
|
|
1752
|
+
room, command = shown(value["room"]), "quirework sop room"
|
|
1753
|
+
held = value["held"]
|
|
1754
|
+
if held:
|
|
1755
|
+
number, waiting = held.get("round"), held.get("waiting_on") or []
|
|
1756
|
+
back = held.get("held_back") or []
|
|
1757
|
+
waited = [w for w in waiting if w not in (held.get("past") or [])]
|
|
1758
|
+
# Seats the operator chose not to wait for are filed absent by
|
|
1759
|
+
# promote; held-back seats are owed their turns first, and running
|
|
1760
|
+
# the round again is what takes them.
|
|
1761
|
+
close = (
|
|
1762
|
+
f"{command} round {room} {number} to take the held-back seats' "
|
|
1763
|
+
"turns and close it"
|
|
1764
|
+
if back
|
|
1765
|
+
else f"{command} promote {room} {number}"
|
|
1766
|
+
)
|
|
1767
|
+
if not waited:
|
|
1768
|
+
return close
|
|
1769
|
+
first = waited[0]
|
|
1770
|
+
more = f" (and {len(waited) - 1} more)" if len(waited) > 1 else ""
|
|
1771
|
+
return (
|
|
1772
|
+
f"{command} continue {room} {number} {first}{more} once the window "
|
|
1773
|
+
f"resets, then {close}"
|
|
1774
|
+
)
|
|
1775
|
+
published = value["published_rounds"]
|
|
1776
|
+
if len(published) < value["rounds"] and not value["converged"]:
|
|
1777
|
+
return f"{command} rounds {room}"
|
|
1778
|
+
last = int(published[-1][1:]) if published else 1
|
|
1779
|
+
if value["converged"] and len(published) < value["rounds"]:
|
|
1780
|
+
# `rounds` would stop again at once, so say both halves: the room
|
|
1781
|
+
# thinks it is done, and running past that is the operator's call.
|
|
1782
|
+
return (
|
|
1783
|
+
f"{command} board {room} {last}; the room converged, and "
|
|
1784
|
+
f"{command} rounds {room} runs past it"
|
|
1785
|
+
)
|
|
1786
|
+
return f"{command} board {room} {last}"
|
|
1787
|
+
|
|
1788
|
+
|
|
1789
|
+
def _spans(numbers):
|
|
1790
|
+
"""[1, 2, 3, 5] -> "1-3, 5", because a Python list repr is not a notation."""
|
|
1791
|
+
spans = []
|
|
1792
|
+
for n in sorted(numbers):
|
|
1793
|
+
if spans and n == spans[-1][1] + 1:
|
|
1794
|
+
spans[-1][1] = n
|
|
1795
|
+
else:
|
|
1796
|
+
spans.append([n, n])
|
|
1797
|
+
return ", ".join(str(a) if a == b else f"{a}-{b}" for a, b in spans)
|
|
1798
|
+
|
|
1799
|
+
|
|
1800
|
+
def _seat_summary(seat):
|
|
1801
|
+
"""A seat's record across the room, not just how its last turn ended.
|
|
1802
|
+
|
|
1803
|
+
The seat's state keeps one `status`, overwritten every turn, so a seat
|
|
1804
|
+
that answered twenty rounds and failed the twenty-first used to read
|
|
1805
|
+
`failed`, which looks like a verdict on the seat.
|
|
1806
|
+
"""
|
|
1807
|
+
rounds = seat["rounds"]
|
|
1808
|
+
expected = len(seat["acting_rounds"]) or len(rounds)
|
|
1809
|
+
plural = "" if expected == 1 else "s"
|
|
1810
|
+
if not rounds:
|
|
1811
|
+
return f"not started, {expected} round{plural} to speak in"
|
|
1812
|
+
done = sum(1 for v in rounds.values() if v.get("status") == "answered")
|
|
1813
|
+
trouble = [
|
|
1814
|
+
f"r{int(n):03d} {v.get('status')}"
|
|
1815
|
+
for n, v in sorted(rounds.items(), key=lambda i: int(i[0]))
|
|
1816
|
+
if v.get("status") != "answered"
|
|
1817
|
+
]
|
|
1818
|
+
return f"answered {done} of {expected} round{plural}" + "".join(
|
|
1819
|
+
f", {t}" for t in trouble
|
|
1820
|
+
)
|
|
1821
|
+
|
|
1822
|
+
|
|
1823
|
+
def usage(root):
|
|
1824
|
+
"""What each seat spent, per seat and per harness, from its own receipts.
|
|
1825
|
+
|
|
1826
|
+
A mixed room is the case that needs this. Every adapter records what its
|
|
1827
|
+
harness reports and nothing reconciles the three, so an operator asking
|
|
1828
|
+
what a room cost reads whichever seats they happened to parse. Claude is
|
|
1829
|
+
the only harness quoting a price, and a room that omits the other seats
|
|
1830
|
+
rather than saying their cost is unknown reports a cheaper room than it
|
|
1831
|
+
ran.
|
|
1832
|
+
"""
|
|
1833
|
+
root, plan = load(root, verify=False)
|
|
1834
|
+
rows = []
|
|
1835
|
+
for seat in plan["seats"]:
|
|
1836
|
+
config = seat_config(plan, seat)
|
|
1837
|
+
totals = {k: 0 for k in ("input", "output", "reasoning", "cache_read")}
|
|
1838
|
+
cost, priced, rounds, inside = 0.0, 0, 0, False
|
|
1839
|
+
for receipt in sorted((root / "records" / seat["id"]).glob("r*/receipt.json")):
|
|
1840
|
+
counted = drivers.usage_totals(
|
|
1841
|
+
config["harness"], read(receipt).get("usage")
|
|
1842
|
+
)
|
|
1843
|
+
if not counted:
|
|
1844
|
+
continue
|
|
1845
|
+
rounds += 1
|
|
1846
|
+
for name in totals:
|
|
1847
|
+
totals[name] += counted[name]
|
|
1848
|
+
inside = inside or counted["cache_inside_input"]
|
|
1849
|
+
if counted["cost_usd"] is not None:
|
|
1850
|
+
cost, priced = cost + counted["cost_usd"], priced + 1
|
|
1851
|
+
rows.append(
|
|
1852
|
+
{
|
|
1853
|
+
"seat": seat["id"],
|
|
1854
|
+
"label": label(seat),
|
|
1855
|
+
"harness": config["harness"],
|
|
1856
|
+
"model": config["model"],
|
|
1857
|
+
"rounds": rounds,
|
|
1858
|
+
**totals,
|
|
1859
|
+
"cache_inside_input": inside,
|
|
1860
|
+
"cost_usd": cost if priced else None,
|
|
1861
|
+
}
|
|
1862
|
+
)
|
|
1863
|
+
return {"room": str(root), "title": plan.get("title"), "seats": rows}
|
|
1864
|
+
|
|
1865
|
+
|
|
1866
|
+
def _render_usage(value):
|
|
1867
|
+
lines = [value["title"], f" {shown(value['room'])}", ""]
|
|
1868
|
+
head = f" {'seat':<16}{'harness':<9}{'rounds':>7}{'output':>10}{'cache read':>12}"
|
|
1869
|
+
lines += [head + f"{'cost':>10}"]
|
|
1870
|
+
unknown = []
|
|
1871
|
+
for row in value["seats"]:
|
|
1872
|
+
price = f"${row['cost_usd']:.2f}" if row["cost_usd"] is not None else "unknown"
|
|
1873
|
+
if row["cost_usd"] is None:
|
|
1874
|
+
unknown.append(row["harness"])
|
|
1875
|
+
lines.append(
|
|
1876
|
+
f" {row['label']:<16}{row['harness']:<9}{row['rounds']:>7}"
|
|
1877
|
+
f"{row['output']:>10,}{row['cache_read']:>12,}{price:>10}"
|
|
1878
|
+
)
|
|
1879
|
+
known = sum(r["cost_usd"] or 0 for r in value["seats"])
|
|
1880
|
+
lines += ["", f" reported cost: ${known:.2f}"]
|
|
1881
|
+
if unknown:
|
|
1882
|
+
# Naming them is the point: a total missing two of four seats looks
|
|
1883
|
+
# exactly like a total of a cheaper room.
|
|
1884
|
+
lines.append(
|
|
1885
|
+
f" no price reported by {', '.join(sorted(set(unknown)))}; "
|
|
1886
|
+
"the room cost more than this"
|
|
1887
|
+
)
|
|
1888
|
+
if any(r["cache_inside_input"] for r in value["seats"]):
|
|
1889
|
+
lines.append(
|
|
1890
|
+
" codex counts cached tokens inside input; claude counts them beside it"
|
|
1891
|
+
)
|
|
1892
|
+
return "\n".join(lines)
|
|
1893
|
+
|
|
1894
|
+
|
|
1895
|
+
NOVELTY_SHOWN = 6
|
|
1896
|
+
|
|
1897
|
+
|
|
1898
|
+
def _render(value):
|
|
1899
|
+
published, allowed = value["published_rounds"], value["rounds"]
|
|
1900
|
+
rounds = f" rounds: {len(published)} of {allowed} published"
|
|
1901
|
+
if published:
|
|
1902
|
+
rounds += f" ({_spans(int(r[1:]) for r in published)})"
|
|
1903
|
+
if allowed != value["declared_rounds"]:
|
|
1904
|
+
rounds += f"; budget raised from the {value['declared_rounds']} declared"
|
|
1905
|
+
lines = [value["title"], f" {shown(value['room'])}", rounds]
|
|
1906
|
+
series = (value.get("convergence") or {}).get("series") or []
|
|
1907
|
+
if len(series) > 1:
|
|
1908
|
+
# Novelty is the room's own stop rule. Printing it only once it has
|
|
1909
|
+
# fired hides the approach, which is the part worth watching. A lone
|
|
1910
|
+
# round is left out: with nothing to differ from it is always 100%.
|
|
1911
|
+
shown_series = series[-NOVELTY_SHOWN:]
|
|
1912
|
+
measured = ", ".join(
|
|
1913
|
+
f"r{s['round']:03d} "
|
|
1914
|
+
+ ("-" if s["novelty"] is None else f"{s['novelty']:.0f}%")
|
|
1915
|
+
for s in shown_series
|
|
1916
|
+
)
|
|
1917
|
+
signal = value["convergence"]
|
|
1918
|
+
fired = (
|
|
1919
|
+
f" (converged at round {signal['round']} on {signal['reason']})"
|
|
1920
|
+
if value["converged"]
|
|
1921
|
+
else ""
|
|
1922
|
+
)
|
|
1923
|
+
more = "..., " if len(series) > len(shown_series) else ""
|
|
1924
|
+
lines.append(f" novelty: {more}{measured}{fired}")
|
|
1925
|
+
if value.get("red_flags"):
|
|
1926
|
+
lines.append(f" red flags: {value['red_flags']} (marked ! below)")
|
|
1927
|
+
if value.get("held"):
|
|
1928
|
+
number = value["held"].get("round")
|
|
1929
|
+
waiting = value["held"].get("waiting_on") or []
|
|
1930
|
+
resets = sorted(
|
|
1931
|
+
{
|
|
1932
|
+
when
|
|
1933
|
+
for name in waiting
|
|
1934
|
+
for n, when in value["seats"]
|
|
1935
|
+
.get(name, {})
|
|
1936
|
+
.get("quota_resets_at", {})
|
|
1937
|
+
.items()
|
|
1938
|
+
if n == number
|
|
1939
|
+
}
|
|
1940
|
+
)
|
|
1941
|
+
lines.append(
|
|
1942
|
+
f" HELD: round {number} waiting on {', '.join(waiting)} "
|
|
1943
|
+
f"(provider quota{f'; resets {reset_text(resets[0])}' if resets else ''})"
|
|
1944
|
+
)
|
|
1945
|
+
back = value["held"].get("held_back") or []
|
|
1946
|
+
if back:
|
|
1947
|
+
lines.append(f" held back: {', '.join(back)}, not yet launched this round")
|
|
1948
|
+
lines.append("")
|
|
1949
|
+
width = max(len(v["label"]) for v in value["seats"].values())
|
|
1950
|
+
served = max(len(v["served"]) for v in value["seats"].values())
|
|
1951
|
+
for number, seat in enumerate(value["seats"].values(), 1):
|
|
1952
|
+
drafting = f", drafting {seat['drafting']}B" if seat["drafting"] else ""
|
|
1953
|
+
lines.append(
|
|
1954
|
+
f" {number}. {seat['label']:<{width}} {seat['served']:<{served}} "
|
|
1955
|
+
f"{_seat_summary(seat)}; posts: {len(seat['posts'])}{drafting}"
|
|
1956
|
+
)
|
|
1957
|
+
if (joined := seat.get("joined_late")) and joined > 1:
|
|
1958
|
+
lines.append(f" joined in round {joined}; it has no opening position")
|
|
1959
|
+
for n, flags in seat.get("red_flags", {}).items():
|
|
1960
|
+
for flag in flags:
|
|
1961
|
+
lines.append(f" ! r{n:03d}: {flag}")
|
|
1962
|
+
chair = value.get("chair") or {}
|
|
1963
|
+
if chair.get("queued"):
|
|
1964
|
+
lines += ["", f" chair note queued for {', '.join(chair['queued'])}"]
|
|
1965
|
+
if chair.get("delivered"):
|
|
1966
|
+
lines.append(f" chair spoke in {', '.join(chair['delivered'])}")
|
|
1967
|
+
if value.get("compacted"):
|
|
1968
|
+
where = ", ".join(
|
|
1969
|
+
f"{r['seat']} in round {r['round']}" for r in value["compacted"]
|
|
1970
|
+
)
|
|
1971
|
+
lines += [
|
|
1972
|
+
"",
|
|
1973
|
+
f" COMPACTED: {where}",
|
|
1974
|
+
(
|
|
1975
|
+
" a compacted round lost context it was resumed to keep;"
|
|
1976
|
+
" review before trusting it"
|
|
1977
|
+
),
|
|
1978
|
+
]
|
|
1979
|
+
if value["last_events"]:
|
|
1980
|
+
lines += ["", " recent:"]
|
|
1981
|
+
for row in value["last_events"]:
|
|
1982
|
+
when = time.strftime("%H:%M:%S", time.localtime(row["t"]))
|
|
1983
|
+
parts = [
|
|
1984
|
+
f"{k}={v}"
|
|
1985
|
+
for k, v in row.items()
|
|
1986
|
+
if k not in ("t", "event", "seat") and v not in (None, "")
|
|
1987
|
+
]
|
|
1988
|
+
who = row.get("seat", "-")
|
|
1989
|
+
lines.append(
|
|
1990
|
+
f" {when} {who:<18} {row.get('event', '?'):<16} {' '.join(parts)}"
|
|
1991
|
+
)
|
|
1992
|
+
lines += ["", f"next: {value['next']}"]
|
|
1993
|
+
return "\n".join(lines)
|
|
1994
|
+
|
|
1995
|
+
|
|
1996
|
+
class Feed:
|
|
1997
|
+
"""A room told as it runs, for the person following it.
|
|
1998
|
+
|
|
1999
|
+
Read-only, like `status`: no lock, no session, no writes, so following
|
|
2000
|
+
cannot perturb what it follows. The trail gives the room's own steps,
|
|
2001
|
+
each seat's stream what it does inside its turn, and a post is printed
|
|
2002
|
+
whole once its turn is collected: from the outbox while the round is
|
|
2003
|
+
open and from the board once promoted, so a round read back later shows
|
|
2004
|
+
the same words it showed live.
|
|
2005
|
+
|
|
2006
|
+
``history`` is how much to tell before following: ``round`` from the
|
|
2007
|
+
latest round's opening, ``all`` from the room's preparation, and
|
|
2008
|
+
``none`` nothing, which is what a running command's own log wants.
|
|
2009
|
+
"""
|
|
2010
|
+
|
|
2011
|
+
def __init__(self, root, *, history="round", show=feed.FULL, width=None):
|
|
2012
|
+
self.root, self.plan = self.opened(root)
|
|
2013
|
+
self.history, self.show = history, show
|
|
2014
|
+
self.seats = {s["id"]: s for s in self.plan["seats"]}
|
|
2015
|
+
self.labels = self.names()
|
|
2016
|
+
self.page = feed.Page(width, who=max(map(len, self.labels.values()), default=0))
|
|
2017
|
+
self.trails = self.sources(at_end=history == "none")
|
|
2018
|
+
self.turns, self.exits = {}, {}
|
|
2019
|
+
self.first, self.stamp = True, None
|
|
2020
|
+
|
|
2021
|
+
def opened(self, root):
|
|
2022
|
+
return load(root, verify=False)
|
|
2023
|
+
|
|
2024
|
+
def names(self):
|
|
2025
|
+
return feed.short_names({name: label(s) for name, s in self.seats.items()})
|
|
2026
|
+
|
|
2027
|
+
def described(self, name):
|
|
2028
|
+
"""A seat as the header lists it: its column name, then its label."""
|
|
2029
|
+
full = label(self.seats[name])
|
|
2030
|
+
return full if full == self.labels[name] else f"{self.labels[name]} {full}"
|
|
2031
|
+
|
|
2032
|
+
def sources(self, *, at_end):
|
|
2033
|
+
return [("event", feed.Tail(self.root / "room.log", at_end=at_end))]
|
|
2034
|
+
|
|
2035
|
+
def tails(self):
|
|
2036
|
+
return self.trails
|
|
2037
|
+
|
|
2038
|
+
def served(self, name):
|
|
2039
|
+
config = seat_config(self.plan, self.seats[name])
|
|
2040
|
+
return f"{config['harness']}/{drivers.model_text(config)}"
|
|
2041
|
+
|
|
2042
|
+
def header(self):
|
|
2043
|
+
lines = [
|
|
2044
|
+
f"following {self.plan.get('title') or self.root.name}",
|
|
2045
|
+
f" {shown(self.root)} (interrupt to stop)",
|
|
2046
|
+
]
|
|
2047
|
+
for number, name in enumerate(self.seats, 1):
|
|
2048
|
+
lines.append(f" {number}. {self.described(name)} {self.served(name)}")
|
|
2049
|
+
return lines
|
|
2050
|
+
|
|
2051
|
+
def poll(self):
|
|
2052
|
+
catching_up, self.stamp = self.first, None if self.first else time.time()
|
|
2053
|
+
rows = sorted(
|
|
2054
|
+
(
|
|
2055
|
+
(0 if row is feed.UNREADABLE else row.get("t") or 0, kind, row)
|
|
2056
|
+
for kind, tail in self.tails()
|
|
2057
|
+
for row in tail.rows()
|
|
2058
|
+
),
|
|
2059
|
+
key=lambda item: item[0],
|
|
2060
|
+
)
|
|
2061
|
+
if catching_up and self.history == "round":
|
|
2062
|
+
opened = [
|
|
2063
|
+
t
|
|
2064
|
+
for t, kind, row in rows
|
|
2065
|
+
if kind == "event"
|
|
2066
|
+
and row is not feed.UNREADABLE
|
|
2067
|
+
and row.get("event") == "round-opened"
|
|
2068
|
+
]
|
|
2069
|
+
if opened:
|
|
2070
|
+
rows = [item for item in rows if item[0] >= opened[-1]]
|
|
2071
|
+
lines = []
|
|
2072
|
+
for follower in self.turns.values():
|
|
2073
|
+
lines += follower.poll(self.stamp)
|
|
2074
|
+
for _, kind, row in rows:
|
|
2075
|
+
lines += self.handle(kind, row)
|
|
2076
|
+
if catching_up:
|
|
2077
|
+
# A turn still running when following began shows what it has
|
|
2078
|
+
# done so far, then carries on live.
|
|
2079
|
+
for follower in self.turns.values():
|
|
2080
|
+
lines += follower.poll(None)
|
|
2081
|
+
self.first = False
|
|
2082
|
+
return lines
|
|
2083
|
+
|
|
2084
|
+
def handle(self, kind, row):
|
|
2085
|
+
if row is feed.UNREADABLE:
|
|
2086
|
+
return self.page.event(
|
|
2087
|
+
self.stamp, f"an unreadable line in the {kind} record"
|
|
2088
|
+
)
|
|
2089
|
+
name = (row.get("event") or "") if kind == "event" else kind
|
|
2090
|
+
handler = getattr(self, "on_" + name.replace("-", "_"), None)
|
|
2091
|
+
if handler is not None:
|
|
2092
|
+
return handler(row) or []
|
|
2093
|
+
if kind != "event":
|
|
2094
|
+
return []
|
|
2095
|
+
# A step this version does not know is still a step: shown as the
|
|
2096
|
+
# trail has it rather than dropped.
|
|
2097
|
+
rest = {k: v for k, v in row.items() if k not in ("t", "event")}
|
|
2098
|
+
return self.page.event(
|
|
2099
|
+
row.get("t"),
|
|
2100
|
+
f"{name or 'an unnamed step'} {json.dumps(rest, ensure_ascii=False)}",
|
|
2101
|
+
)
|
|
2102
|
+
|
|
2103
|
+
def who(self, row):
|
|
2104
|
+
return self.labels.get(row.get("seat"), row.get("seat") or "a seat")
|
|
2105
|
+
|
|
2106
|
+
def record(self, seat, n):
|
|
2107
|
+
return self.root / "records" / seat / f"r{n:03d}"
|
|
2108
|
+
|
|
2109
|
+
def follower(self, seat, n):
|
|
2110
|
+
config = seat_config(self.plan, self.seats[seat])
|
|
2111
|
+
work = self.root / "seats" / seat
|
|
2112
|
+
return feed.Turn(
|
|
2113
|
+
self.page,
|
|
2114
|
+
self.labels[seat],
|
|
2115
|
+
config["harness"],
|
|
2116
|
+
self.record(seat, n) / "events.jsonl",
|
|
2117
|
+
places=[(work, "", work)],
|
|
2118
|
+
private=blind.state_root(work) / config["harness"],
|
|
2119
|
+
show=self.show,
|
|
2120
|
+
)
|
|
2121
|
+
|
|
2122
|
+
def post(self, seat, n):
|
|
2123
|
+
"""The post this turn left: in the outbox, or on the board once promoted."""
|
|
2124
|
+
for path in (
|
|
2125
|
+
self.root / "seats" / seat / "outbox" / "post.md",
|
|
2126
|
+
self.root / "board" / "posts" / seat / f"r{n:03d}.md",
|
|
2127
|
+
):
|
|
2128
|
+
if path.is_file() and path.read_text().strip():
|
|
2129
|
+
return path.read_text()
|
|
2130
|
+
return None
|
|
2131
|
+
|
|
2132
|
+
def on_prepared(self, row):
|
|
2133
|
+
return self.page.event(
|
|
2134
|
+
row["t"],
|
|
2135
|
+
f"prepared: {len(row.get('seats') or [])} seats, {row.get('rounds')} rounds",
|
|
2136
|
+
)
|
|
2137
|
+
|
|
2138
|
+
def on_round_opened(self, row):
|
|
2139
|
+
n = row["round"]
|
|
2140
|
+
phase = phase_for(self.plan, n).get("name") if self.plan.get("phases") else None
|
|
2141
|
+
speaking = ", ".join(self.labels[s] for s in acting(self.plan, n))
|
|
2142
|
+
jobs = row.get("jobs") or 1
|
|
2143
|
+
pace = "one at a time" if jobs == 1 else f"{jobs} at a time"
|
|
2144
|
+
lines = self.page.event(
|
|
2145
|
+
row["t"],
|
|
2146
|
+
f"round {n} opened"
|
|
2147
|
+
+ (f" ({phase})" if phase else "")
|
|
2148
|
+
+ f" \u00b7 speaking: {speaking} \u00b7 {pace}",
|
|
2149
|
+
)
|
|
2150
|
+
note = chair_note(self.root, n)
|
|
2151
|
+
if note and self.show.words:
|
|
2152
|
+
lines += self.page.block(f"from the chair, before round {n}", note)
|
|
2153
|
+
return lines
|
|
2154
|
+
|
|
2155
|
+
def on_board_published(self, row):
|
|
2156
|
+
n = row["round"]
|
|
2157
|
+
return self.page.event(
|
|
2158
|
+
row["t"], f"every seat now has board/round-{n - 1:03d}/digest.md to read"
|
|
2159
|
+
)
|
|
2160
|
+
|
|
2161
|
+
def on_resting(self, row):
|
|
2162
|
+
return self.page.event(row["t"], f"{self.who(row)} listens this round")
|
|
2163
|
+
|
|
2164
|
+
def on_launching(self, row):
|
|
2165
|
+
seat, n = row["seat"], row["round"]
|
|
2166
|
+
self.turns[(seat, n)] = self.follower(seat, n)
|
|
2167
|
+
session = "resuming its session" if row.get("resumed") else "in a new session"
|
|
2168
|
+
return self.page.event(
|
|
2169
|
+
row["t"],
|
|
2170
|
+
f"{self.who(row)} [{self.served(seat)}] starts round {n}, {session}",
|
|
2171
|
+
)
|
|
2172
|
+
|
|
2173
|
+
def on_running(self, row):
|
|
2174
|
+
return []
|
|
2175
|
+
|
|
2176
|
+
def on_exited(self, row):
|
|
2177
|
+
follower = self.turns.pop((row.get("seat"), row.get("round")), None)
|
|
2178
|
+
self.exits[(row.get("seat"), row.get("round"))] = row
|
|
2179
|
+
return follower.finish(self.stamp) if follower else []
|
|
2180
|
+
|
|
2181
|
+
def on_compacted(self, row):
|
|
2182
|
+
return self.page.event(
|
|
2183
|
+
row["t"],
|
|
2184
|
+
f"{self.who(row)}'s session was compacted during the turn: a red flag, "
|
|
2185
|
+
"and `status` lists it",
|
|
2186
|
+
)
|
|
2187
|
+
|
|
2188
|
+
def on_collected(self, row):
|
|
2189
|
+
seat, n = row["seat"], row["round"]
|
|
2190
|
+
exited = self.exits.pop((seat, n), {})
|
|
2191
|
+
took = feed.duration(exited.get("seconds")) if "seconds" in exited else None
|
|
2192
|
+
timed = " (timed out)" if exited.get("timed_out") else ""
|
|
2193
|
+
status = row.get("status")
|
|
2194
|
+
if status == "answered":
|
|
2195
|
+
text = f"{self.who(row)} answered" + (f" in {took}" if took else "")
|
|
2196
|
+
elif status == "quota":
|
|
2197
|
+
text = f"{self.who(row)} stopped on a provider limit" + (
|
|
2198
|
+
f" after {took}" if took else ""
|
|
2199
|
+
)
|
|
2200
|
+
else:
|
|
2201
|
+
text = f"{self.who(row)}'s turn failed" + (f" after {took}" if took else "")
|
|
2202
|
+
text += timed + (f": {row['error']}" if row.get("error") else "")
|
|
2203
|
+
lines = self.page.event(row["t"], text)
|
|
2204
|
+
if status == "answered" and self.show.words:
|
|
2205
|
+
lines += self.posted(seat, n)
|
|
2206
|
+
return lines
|
|
2207
|
+
|
|
2208
|
+
def posted(self, seat, n):
|
|
2209
|
+
words = self.post(seat, n)
|
|
2210
|
+
if not words:
|
|
2211
|
+
return self.page.event(None, "no post")
|
|
2212
|
+
return self.page.block(f"{self.labels[seat]}, round {n}", words)
|
|
2213
|
+
|
|
2214
|
+
def on_stopped(self, row):
|
|
2215
|
+
return self.page.event(row["t"], f"{self.who(row)} stopped: {row.get('error')}")
|
|
2216
|
+
|
|
2217
|
+
def on_continuing(self, row):
|
|
2218
|
+
how = "in the same session" if row.get("resumed") else "in a fresh session"
|
|
2219
|
+
attempt = f" (attempt {row['attempt']})" if row.get("attempt") else ""
|
|
2220
|
+
return self.page.event(
|
|
2221
|
+
row["t"],
|
|
2222
|
+
f"{self.who(row)} takes round {row['round']} again after a provider "
|
|
2223
|
+
f"limit, {how}{attempt}",
|
|
2224
|
+
)
|
|
2225
|
+
|
|
2226
|
+
def on_absent(self, row):
|
|
2227
|
+
return self.page.event(row["t"], f"{self.who(row)} left no post this round")
|
|
2228
|
+
|
|
2229
|
+
def on_promoted(self, row):
|
|
2230
|
+
return []
|
|
2231
|
+
|
|
2232
|
+
def on_made(self, row):
|
|
2233
|
+
return self.page.event(
|
|
2234
|
+
row["t"],
|
|
2235
|
+
f"{self.who(row)}'s {row.get('file')} is on the board "
|
|
2236
|
+
f"({feed.size(row.get('bytes') or 0)})",
|
|
2237
|
+
)
|
|
2238
|
+
|
|
2239
|
+
def on_revised(self, row):
|
|
2240
|
+
return self.page.event(
|
|
2241
|
+
row["t"], f"{self.who(row)} revised {row.get('file')}, now on the board"
|
|
2242
|
+
)
|
|
2243
|
+
|
|
2244
|
+
def on_unmade(self, row):
|
|
2245
|
+
return self.page.event(
|
|
2246
|
+
row["t"],
|
|
2247
|
+
f"{self.who(row)} did not write {row.get('file')}, which this phase asks for",
|
|
2248
|
+
)
|
|
2249
|
+
|
|
2250
|
+
def on_round_closed(self, row):
|
|
2251
|
+
posted = row.get("posted") or 0
|
|
2252
|
+
absent = f", {row['absent']} absent" if row.get("absent") else ""
|
|
2253
|
+
return self.page.event(
|
|
2254
|
+
row["t"],
|
|
2255
|
+
f"round {row['round']} closed: {posted} post{'' if posted == 1 else 's'}"
|
|
2256
|
+
f"{absent}",
|
|
2257
|
+
)
|
|
2258
|
+
|
|
2259
|
+
def on_convergence(self, row):
|
|
2260
|
+
text = f"novelty {row.get('novelty')}%, closing {row.get('closing')}"
|
|
2261
|
+
if row.get("converged"):
|
|
2262
|
+
text += f"; converged on {row.get('reason')}"
|
|
2263
|
+
if self.plan.get("phases"):
|
|
2264
|
+
text += ", which a phased room notes and does not stop for"
|
|
2265
|
+
return self.page.event(row["t"], text)
|
|
2266
|
+
|
|
2267
|
+
def on_round_held(self, row):
|
|
2268
|
+
waiting = ", ".join(self.labels.get(s, s) for s in row.get("waiting_on") or [])
|
|
2269
|
+
back = ", ".join(self.labels.get(s, s) for s in row.get("held_back") or [])
|
|
2270
|
+
held = f"; held back, not launched: {back}" if back else ""
|
|
2271
|
+
return self.page.event(
|
|
2272
|
+
row["t"],
|
|
2273
|
+
f"round {row['round']} held on a provider limit, waiting on {waiting}"
|
|
2274
|
+
f"{held}; `status` names the command that continues it",
|
|
2275
|
+
)
|
|
2276
|
+
|
|
2277
|
+
def on_round_resumed(self, row):
|
|
2278
|
+
return self.page.event(
|
|
2279
|
+
row["t"], f"round {row['round']} resumed after a provider limit"
|
|
2280
|
+
)
|
|
2281
|
+
|
|
2282
|
+
def on_held_back(self, row):
|
|
2283
|
+
return self.page.event(
|
|
2284
|
+
row["t"],
|
|
2285
|
+
f"{self.who(row)} held back: a seat before it stopped on a provider limit",
|
|
2286
|
+
)
|
|
2287
|
+
|
|
2288
|
+
def on_budget_extended(self, row):
|
|
2289
|
+
return self.page.event(
|
|
2290
|
+
row["t"],
|
|
2291
|
+
f"the room may now run {row.get('rounds')} rounds (was {row.get('was')})",
|
|
2292
|
+
)
|
|
2293
|
+
|
|
2294
|
+
|
|
2295
|
+
def narrating(root):
|
|
2296
|
+
"""A turn-taking command's own progress, on stderr, while it runs.
|
|
2297
|
+
|
|
2298
|
+
Brief, for the log an assistant reads: the round's steps and the files
|
|
2299
|
+
each seat writes, and none of the posts, which `watch` shows.
|
|
2300
|
+
"""
|
|
2301
|
+
return feed.narrating(Feed(root, history="none", show=feed.BRIEF))
|
|
2302
|
+
|
|
2303
|
+
|
|
2304
|
+
def watch(root, *, history="round", thinking=False, poll=1.0, width=None):
|
|
2305
|
+
"""Follow a room in plain lines until interrupted. Read-only, like `status`."""
|
|
2306
|
+
following = Feed(
|
|
2307
|
+
room_root(root), history=history, show=feed.Show(thinking=thinking), width=width
|
|
2308
|
+
)
|
|
2309
|
+
print("\n".join(following.header()), file=sys.stderr)
|
|
2310
|
+
feed.watch(following, poll=poll)
|
|
2311
|
+
|
|
2312
|
+
|
|
2313
|
+
def seat_named(plan, value):
|
|
2314
|
+
"""A seat by its id, or by its position in the room.
|
|
2315
|
+
|
|
2316
|
+
Position exists because following a room means four terminals and four
|
|
2317
|
+
commands, and `follow room 2` is easier to type and to keep straight than
|
|
2318
|
+
a persona's full slug. An id that happens to be a number wins over the
|
|
2319
|
+
position, because a seat's own name is never a guess.
|
|
2320
|
+
"""
|
|
2321
|
+
names = [s["id"] for s in plan["seats"]]
|
|
2322
|
+
text = str(value)
|
|
2323
|
+
if text in names:
|
|
2324
|
+
return text
|
|
2325
|
+
if text.isdigit() and 1 <= int(text) <= len(names):
|
|
2326
|
+
return names[int(text) - 1]
|
|
2327
|
+
listed = ", ".join(f"{i}. {n}" for i, n in enumerate(names, 1))
|
|
2328
|
+
raise ValueError(f"no seat {value!r} here; this room seats {listed}")
|
|
2329
|
+
|
|
2330
|
+
|
|
2331
|
+
def transcripts(root, plan, seat):
|
|
2332
|
+
"""Every native transcript this seat has, oldest first.
|
|
2333
|
+
|
|
2334
|
+
Not one file: codex opens a fresh rollout each time a session is resumed,
|
|
2335
|
+
so a seat ten rounds in has ten of them and the conversation is their
|
|
2336
|
+
concatenation. Claude keeps one per session but also writes subagent
|
|
2337
|
+
files beside it, which are not this seat talking.
|
|
2338
|
+
"""
|
|
2339
|
+
record = next(s for s in plan["seats"] if s["id"] == seat)
|
|
2340
|
+
harness = seat_config(plan, record)["harness"]
|
|
2341
|
+
pattern = observe.NATIVE_TRANSCRIPTS.get(harness)
|
|
2342
|
+
if pattern is None:
|
|
2343
|
+
raise ValueError(f"no known transcript location for {harness}")
|
|
2344
|
+
private = blind.state_root(root / "seats" / seat) / harness
|
|
2345
|
+
found = [p for p in private.glob(pattern) if "subagents" not in p.parts]
|
|
2346
|
+
return sorted(found, key=lambda p: (p.stat().st_mtime, p.name))
|
|
2347
|
+
|
|
2348
|
+
|
|
2349
|
+
def _agy_lines(row, *, thinking, width):
|
|
2350
|
+
"""One agy transcript row as the lines a reader wants, or none at all.
|
|
2351
|
+
|
|
2352
|
+
Tool *results* are dropped. A single file read returns two kilobytes of
|
|
2353
|
+
quoted source, and four of those bury the one thing the reader came for,
|
|
2354
|
+
which is what the seat said and what it decided to do next.
|
|
2355
|
+
"""
|
|
2356
|
+
kind, status = row.get("type"), row.get("status")
|
|
2357
|
+
content = (row.get("content") or "").strip()
|
|
2358
|
+
if kind == "USER_INPUT":
|
|
2359
|
+
# The harness appends its own blocks -- a clock reading, a note that
|
|
2360
|
+
# the model was selected -- to every prompt. They are its bookkeeping,
|
|
2361
|
+
# not the turn the room sent, and printing them buries the turn.
|
|
2362
|
+
text = re.sub(
|
|
2363
|
+
r"<(ADDITIONAL_METADATA|USER_SETTINGS_CHANGE)>.*?</\1>",
|
|
2364
|
+
"",
|
|
2365
|
+
content,
|
|
2366
|
+
flags=re.DOTALL,
|
|
2367
|
+
)
|
|
2368
|
+
text = re.sub(r"</?USER_REQUEST>", "", text).strip()
|
|
2369
|
+
wrapped = textwrap.wrap(" ".join(text.split()), width - 4)
|
|
2370
|
+
shown = wrapped[:8] + (["..."] if len(wrapped) > 8 else [])
|
|
2371
|
+
return ["", "=" * width, *(f" {line}" for line in shown), "=" * width, ""]
|
|
2372
|
+
if status == "ERROR":
|
|
2373
|
+
# The harness prefixes every result with its own timestamps. Printing
|
|
2374
|
+
# those instead of the message is how an error line ends up saying
|
|
2375
|
+
# nothing at all about what went wrong.
|
|
2376
|
+
detail = " ".join(
|
|
2377
|
+
line
|
|
2378
|
+
for line in content.splitlines()
|
|
2379
|
+
if not line.startswith(("Created At:", "Completed At:"))
|
|
2380
|
+
)
|
|
2381
|
+
# Errors wrap rather than truncate: a cut-off error names the tool
|
|
2382
|
+
# that failed and never the reason, which is the half nobody needs.
|
|
2383
|
+
lines = textwrap.wrap(" ".join(detail.split()), width - 5)
|
|
2384
|
+
return [f" ! {line}" for line in lines]
|
|
2385
|
+
if kind != "PLANNER_RESPONSE":
|
|
2386
|
+
return []
|
|
2387
|
+
out = []
|
|
2388
|
+
if thinking and row.get("thinking"):
|
|
2389
|
+
for line in textwrap.wrap(" ".join(row["thinking"].split()), width - 5):
|
|
2390
|
+
out.append(f" · {line}")
|
|
2391
|
+
for call in row.get("tool_calls") or []:
|
|
2392
|
+
args = call.get("args") or {}
|
|
2393
|
+
# agy's newer file tools carry no summary of their own.
|
|
2394
|
+
what = (
|
|
2395
|
+
args.get("toolSummary")
|
|
2396
|
+
or args.get("toolAction")
|
|
2397
|
+
or feed.agy_names(call.get("name") or "a tool", args)
|
|
2398
|
+
)
|
|
2399
|
+
out.append(f" \u2192 {what}")
|
|
2400
|
+
if content:
|
|
2401
|
+
out.append("")
|
|
2402
|
+
for paragraph in content.splitlines():
|
|
2403
|
+
out.extend(textwrap.wrap(paragraph, width) or [""])
|
|
2404
|
+
out.append("")
|
|
2405
|
+
return out
|
|
2406
|
+
|
|
2407
|
+
|
|
2408
|
+
def _banner(text, width):
|
|
2409
|
+
"""A turn the room sent, framed so round boundaries are visible."""
|
|
2410
|
+
wrapped = textwrap.wrap(" ".join(text.split()), width - 4)
|
|
2411
|
+
shown = wrapped[:8] + (["..."] if len(wrapped) > 8 else [])
|
|
2412
|
+
return ["", "=" * width, *(f" {line}" for line in shown), "=" * width, ""]
|
|
2413
|
+
|
|
2414
|
+
|
|
2415
|
+
def _said(text, width):
|
|
2416
|
+
out = [""]
|
|
2417
|
+
for paragraph in text.strip().splitlines():
|
|
2418
|
+
out.extend(textwrap.wrap(paragraph, width) or [""])
|
|
2419
|
+
return out + [""]
|
|
2420
|
+
|
|
2421
|
+
|
|
2422
|
+
def _thought(text, width):
|
|
2423
|
+
return [f" · {line}" for line in textwrap.wrap(" ".join(text.split()), width - 5)]
|
|
2424
|
+
|
|
2425
|
+
|
|
2426
|
+
def _claude_lines(row, *, thinking, width):
|
|
2427
|
+
"""One Claude Code transcript row. Tool results are dropped, as for agy."""
|
|
2428
|
+
kind = row.get("type")
|
|
2429
|
+
if kind not in ("user", "assistant"):
|
|
2430
|
+
return []
|
|
2431
|
+
content = (row.get("message") or {}).get("content")
|
|
2432
|
+
if isinstance(content, str):
|
|
2433
|
+
return _banner(content, width) if kind == "user" else _said(content, width)
|
|
2434
|
+
out = []
|
|
2435
|
+
for block in content or []:
|
|
2436
|
+
shape = block.get("type")
|
|
2437
|
+
if shape == "text":
|
|
2438
|
+
out.extend(
|
|
2439
|
+
_banner(block["text"], width)
|
|
2440
|
+
if kind == "user"
|
|
2441
|
+
else _said(block["text"], width)
|
|
2442
|
+
)
|
|
2443
|
+
elif shape == "thinking" and thinking and block.get("thinking", "").strip():
|
|
2444
|
+
out.extend(_thought(block["thinking"], width))
|
|
2445
|
+
elif shape == "tool_use":
|
|
2446
|
+
target = (block.get("input") or {}).get("file_path", "")
|
|
2447
|
+
name = block.get("name", "tool")
|
|
2448
|
+
out.append(
|
|
2449
|
+
f" \u2192 {name} {Path(target).name if target else ''}".rstrip()
|
|
2450
|
+
)
|
|
2451
|
+
return out
|
|
2452
|
+
|
|
2453
|
+
|
|
2454
|
+
def _codex_cmd(text):
|
|
2455
|
+
"""The shell command inside a codex exec call, for a one-line summary."""
|
|
2456
|
+
found = re.search(r'cmd:\s*"((?:[^"\\]|\\.)*)"', text or "")
|
|
2457
|
+
return found.group(1) if found else (text or "")[:60]
|
|
2458
|
+
|
|
2459
|
+
|
|
2460
|
+
def _codex_lines(row, *, thinking, width):
|
|
2461
|
+
"""One codex rollout row, minus the harness's own developer preamble."""
|
|
2462
|
+
if row.get("type") != "response_item":
|
|
2463
|
+
return []
|
|
2464
|
+
payload = row.get("payload") or {}
|
|
2465
|
+
shape = payload.get("type")
|
|
2466
|
+
if shape == "message":
|
|
2467
|
+
role = payload.get("role")
|
|
2468
|
+
# The CLI injects its own preamble -- skills, plugins, recommendations
|
|
2469
|
+
# -- partly as `developer` and partly as `user`, so the role alone does
|
|
2470
|
+
# not separate it from the turn the room sent. Every such block opens
|
|
2471
|
+
# with a tag and a room prompt never does.
|
|
2472
|
+
if role == "developer":
|
|
2473
|
+
return []
|
|
2474
|
+
text = " ".join(
|
|
2475
|
+
part.get("text", "") for part in payload.get("content") or []
|
|
2476
|
+
).strip()
|
|
2477
|
+
if not text or text.startswith("<"):
|
|
2478
|
+
return []
|
|
2479
|
+
return _banner(text, width) if role == "user" else _said(text, width)
|
|
2480
|
+
if shape == "reasoning" and thinking:
|
|
2481
|
+
summary = " ".join(
|
|
2482
|
+
part.get("text", "") for part in payload.get("summary") or []
|
|
2483
|
+
).strip()
|
|
2484
|
+
return _thought(summary, width) if summary else []
|
|
2485
|
+
if shape == "custom_tool_call":
|
|
2486
|
+
return [f" \u2192 {_codex_cmd(payload.get('input'))[: width - 5]}"]
|
|
2487
|
+
return []
|
|
2488
|
+
|
|
2489
|
+
|
|
2490
|
+
_RENDERERS = {"agy": _agy_lines, "claude": _claude_lines, "codex": _codex_lines}
|
|
2491
|
+
|
|
2492
|
+
# Whether a transcript row is finished and safe to print. Only one harness
|
|
2493
|
+
# revises a row in place: agy writes a step, then marks it DONE. Claude and
|
|
2494
|
+
# codex append rows already complete, so waiting for a status they never carry
|
|
2495
|
+
# would print nothing at all -- which is exactly what it did.
|
|
2496
|
+
_SETTLED = {
|
|
2497
|
+
"agy": lambda row: row.get("status") in ("DONE", "ERROR"),
|
|
2498
|
+
"claude": lambda row: True,
|
|
2499
|
+
"codex": lambda row: True,
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
|
|
2503
|
+
def follow(root, seat, *, thinking=True, poll=1.0, width=88):
|
|
2504
|
+
"""Print one seat's side of the room as it happens.
|
|
2505
|
+
|
|
2506
|
+
Read-only, like `status` and `watch`: no lock, no session, no writes. Rows
|
|
2507
|
+
are consumed by position and only once they carry a terminal status, so a
|
|
2508
|
+
step still being written is waited for rather than printed half-formed,
|
|
2509
|
+
and a harness that rewrites its transcript instead of appending to it
|
|
2510
|
+
cannot produce duplicates.
|
|
2511
|
+
"""
|
|
2512
|
+
root = room_root(root)
|
|
2513
|
+
_, plan = load(root, verify=False)
|
|
2514
|
+
seat = seat_named(plan, seat)
|
|
2515
|
+
record = next(s for s in plan["seats"] if s["id"] == seat)
|
|
2516
|
+
harness = seat_config(plan, record)["harness"]
|
|
2517
|
+
render = _RENDERERS.get(harness)
|
|
2518
|
+
if render is None:
|
|
2519
|
+
raise ValueError(f"following a {harness} seat is not implemented yet")
|
|
2520
|
+
settled = _SETTLED[harness]
|
|
2521
|
+
# Which seat and which model, because a live view is where an operator is
|
|
2522
|
+
# most likely to be asking who is speaking.
|
|
2523
|
+
config = seat_config(plan, record)
|
|
2524
|
+
who = f"{label(record)} [{config['harness']}/{config.get('model')}]"
|
|
2525
|
+
seen, waiting, opened = 0, False, False
|
|
2526
|
+
while True:
|
|
2527
|
+
paths = transcripts(root, plan, seat)
|
|
2528
|
+
if not paths:
|
|
2529
|
+
if not waiting:
|
|
2530
|
+
print(f"{who}: waiting for a session to open", file=sys.stderr)
|
|
2531
|
+
waiting = True
|
|
2532
|
+
time.sleep(poll)
|
|
2533
|
+
continue
|
|
2534
|
+
if not opened:
|
|
2535
|
+
print(f"--- {who} ({seat}, {harness}) ---", file=sys.stderr)
|
|
2536
|
+
opened = True
|
|
2537
|
+
rows = []
|
|
2538
|
+
for path in paths:
|
|
2539
|
+
for line in path.read_text(errors="replace").splitlines():
|
|
2540
|
+
if not line.strip():
|
|
2541
|
+
continue
|
|
2542
|
+
try:
|
|
2543
|
+
rows.append(json.loads(line))
|
|
2544
|
+
except json.JSONDecodeError:
|
|
2545
|
+
break # the writer is mid-row; pick it up next poll
|
|
2546
|
+
while seen < len(rows) and settled(rows[seen]):
|
|
2547
|
+
for out in render(rows[seen], thinking=thinking, width=width):
|
|
2548
|
+
print(out, flush=True)
|
|
2549
|
+
seen += 1
|
|
2550
|
+
time.sleep(poll)
|
|
2551
|
+
|
|
2552
|
+
|
|
2553
|
+
STATUS_HELP = """\
|
|
2554
|
+
Read a room without disturbing it: no lock, no session, no writes, so it
|
|
2555
|
+
is safe while a round runs. A worked example, line by line:
|
|
2556
|
+
|
|
2557
|
+
Lighthouse proposal the room's title
|
|
2558
|
+
~/rooms/lighthouse where it lives
|
|
2559
|
+
rounds: 3 of 6 published (1-3) published rounds against the budget
|
|
2560
|
+
novelty: r001 100%, r002 71%, r003 58% the stop rule, measured per round
|
|
2561
|
+
red flags: 1 (marked ! below) count of the ! lines further down
|
|
2562
|
+
HELD: round 4 waiting on bo-larke (provider quota; resets 14:20Z)
|
|
2563
|
+
|
|
2564
|
+
1. Ana Pell claude/opus answered 3 of 3 rounds; posts: 3
|
|
2565
|
+
2. Bo Larke codex/gpt-5.6-terra (from terra) answered 3 of 4 rounds, r004 quota; posts: 3
|
|
2566
|
+
! r002: Session bus proxied into the jail for ...
|
|
2567
|
+
|
|
2568
|
+
next: quirework sop room continue ~/rooms/lighthouse 4 bo-larke once ...
|
|
2569
|
+
|
|
2570
|
+
Novelty is the share of words a seat has not used before; two quiet rounds
|
|
2571
|
+
stop an unphased room, and a lone round is not shown because it is always
|
|
2572
|
+
100%. A seat counts only the rounds its phase lets it speak in, so a seat
|
|
2573
|
+
resting through a drafting phase is not behind. A red flag is a fact about
|
|
2574
|
+
how a turn ran (a door the jail had to open, a compaction), not a judgment
|
|
2575
|
+
of what the seat wrote. The last line names the command to run next.
|
|
2576
|
+
"""
|
|
2577
|
+
|
|
2578
|
+
|
|
2579
|
+
def main(argv=None):
|
|
2580
|
+
parser = argparse.ArgumentParser(prog="quirework sop room", description=__doc__)
|
|
2581
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
2582
|
+
|
|
2583
|
+
made = sub.add_parser("prepare", help="stage a room from a plan")
|
|
2584
|
+
made.add_argument("plan")
|
|
2585
|
+
made.add_argument("destination")
|
|
2586
|
+
|
|
2587
|
+
turn = sub.add_parser(
|
|
2588
|
+
"round", help="open, run and close one round, or resume a held one"
|
|
2589
|
+
)
|
|
2590
|
+
turn.add_argument("root")
|
|
2591
|
+
turn.add_argument("number", type=int)
|
|
2592
|
+
turn.add_argument("--jobs", type=int, default=1)
|
|
2593
|
+
turn.add_argument("--timeout", type=float, default=None)
|
|
2594
|
+
turn.add_argument(
|
|
2595
|
+
"--past",
|
|
2596
|
+
action="append",
|
|
2597
|
+
default=[],
|
|
2598
|
+
metavar="SEAT",
|
|
2599
|
+
help="a seat stopped on a provider limit that the round should not "
|
|
2600
|
+
"wait for; promote then files it absent (repeatable)",
|
|
2601
|
+
)
|
|
2602
|
+
|
|
2603
|
+
for name, help_text in (
|
|
2604
|
+
("open", "republish a round's board"),
|
|
2605
|
+
("promote", "close a round from its outboxes"),
|
|
2606
|
+
):
|
|
2607
|
+
recover = sub.add_parser(name, help=help_text)
|
|
2608
|
+
recover.add_argument("root")
|
|
2609
|
+
recover.add_argument("number", type=int)
|
|
2610
|
+
|
|
2611
|
+
again = sub.add_parser(
|
|
2612
|
+
"continue", help="take a seat's turn again after a provider quota stop"
|
|
2613
|
+
)
|
|
2614
|
+
again.add_argument("root")
|
|
2615
|
+
again.add_argument("number", type=int)
|
|
2616
|
+
again.add_argument("seat")
|
|
2617
|
+
again.add_argument("--timeout", type=float, default=None)
|
|
2618
|
+
|
|
2619
|
+
loop = sub.add_parser("rounds", help="run rounds until the room converges")
|
|
2620
|
+
loop.add_argument("root")
|
|
2621
|
+
loop.add_argument("--to", type=int, default=None, help="stop at this round")
|
|
2622
|
+
loop.add_argument("--jobs", type=int, default=1)
|
|
2623
|
+
loop.add_argument("--timeout", type=float, default=None)
|
|
2624
|
+
|
|
2625
|
+
more = sub.add_parser("extend", help="raise the room's round budget")
|
|
2626
|
+
more.add_argument("root")
|
|
2627
|
+
more.add_argument("rounds", type=int)
|
|
2628
|
+
|
|
2629
|
+
view = sub.add_parser(
|
|
2630
|
+
"status",
|
|
2631
|
+
help="read a room without disturbing it",
|
|
2632
|
+
description=STATUS_HELP,
|
|
2633
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
2634
|
+
)
|
|
2635
|
+
view.add_argument("root")
|
|
2636
|
+
view.add_argument("--json", action="store_true")
|
|
2637
|
+
|
|
2638
|
+
board = sub.add_parser("board", help="print a published round's digest")
|
|
2639
|
+
board.add_argument("root")
|
|
2640
|
+
board.add_argument("number", type=int)
|
|
2641
|
+
|
|
2642
|
+
spend = sub.add_parser("usage", help="what each seat spent, from its receipts")
|
|
2643
|
+
spend.add_argument("root")
|
|
2644
|
+
spend.add_argument("--json", action="store_true")
|
|
2645
|
+
|
|
2646
|
+
trail = sub.add_parser(
|
|
2647
|
+
"watch", help="follow the room as it runs: turns, files and posts"
|
|
2648
|
+
)
|
|
2649
|
+
trail.add_argument("root")
|
|
2650
|
+
trail.add_argument(
|
|
2651
|
+
"--all",
|
|
2652
|
+
action="store_true",
|
|
2653
|
+
help="tell the whole room, not only its latest round",
|
|
2654
|
+
)
|
|
2655
|
+
trail.add_argument(
|
|
2656
|
+
"--thinking", action="store_true", help="show the seats' reasoning"
|
|
2657
|
+
)
|
|
2658
|
+
trail.add_argument("--poll", type=float, default=1.0)
|
|
2659
|
+
trail.add_argument("--width", type=int, default=None)
|
|
2660
|
+
|
|
2661
|
+
voice = sub.add_parser("follow", help="follow one seat's side of the room")
|
|
2662
|
+
voice.add_argument("root")
|
|
2663
|
+
voice.add_argument("seat")
|
|
2664
|
+
voice.add_argument("--poll", type=float, default=1.0)
|
|
2665
|
+
voice.add_argument("--width", type=int, default=88)
|
|
2666
|
+
voice.add_argument(
|
|
2667
|
+
"--no-thinking", action="store_true", help="show only actions and posts"
|
|
2668
|
+
)
|
|
2669
|
+
|
|
2670
|
+
args = parser.parse_args(argv)
|
|
2671
|
+
# Every command resolves its room the same way, at the operation boundary
|
|
2672
|
+
# and before any work starts, so a mistyped name fails identically on all
|
|
2673
|
+
# of them instead of failing late or, in watch's case, not at all.
|
|
2674
|
+
if args.command == "prepare":
|
|
2675
|
+
destination = room_root(args.destination, must_exist=False)
|
|
2676
|
+
value = prepare(args.plan, destination)
|
|
2677
|
+
print(f"prepared {len(value['seats'])} seats in {shown(destination)}")
|
|
2678
|
+
if not spelled_as_path(args.destination):
|
|
2679
|
+
# The destination is printed either way, but a state-root path is
|
|
2680
|
+
# long, unfamiliar and easy to read past. An operator who meant a
|
|
2681
|
+
# directory here finds out now rather than at the next command.
|
|
2682
|
+
print(
|
|
2683
|
+
f"{args.destination!r} names a room in this project's store; "
|
|
2684
|
+
f"write ./{args.destination} for a room in the current "
|
|
2685
|
+
"directory"
|
|
2686
|
+
)
|
|
2687
|
+
return 0
|
|
2688
|
+
root = room_root(args.root)
|
|
2689
|
+
if args.command == "round":
|
|
2690
|
+
with narrating(root):
|
|
2691
|
+
played = run_round(
|
|
2692
|
+
root,
|
|
2693
|
+
args.number,
|
|
2694
|
+
jobs=args.jobs,
|
|
2695
|
+
timeout=args.timeout,
|
|
2696
|
+
past=args.past,
|
|
2697
|
+
)
|
|
2698
|
+
for name, outcome in played.items():
|
|
2699
|
+
detail = json.dumps(outcome) if isinstance(outcome, dict) else outcome
|
|
2700
|
+
print(f"{name}: {detail}")
|
|
2701
|
+
if played["_board"].get("held"):
|
|
2702
|
+
print(f"next: {next_step(status(root))}")
|
|
2703
|
+
elif args.command == "continue":
|
|
2704
|
+
# Not `status`: that name is a function in this module, and binding it
|
|
2705
|
+
# here makes it local to `main` for the whole body, so every other
|
|
2706
|
+
# command's call to it raises UnboundLocalError.
|
|
2707
|
+
with narrating(root):
|
|
2708
|
+
outcome = continue_seat(root, args.number, args.seat, timeout=args.timeout)
|
|
2709
|
+
print(f"{args.seat}: {outcome}")
|
|
2710
|
+
if outcome == "answered":
|
|
2711
|
+
if held_back(root, args.number):
|
|
2712
|
+
print(
|
|
2713
|
+
"now take the held-back seats' turns and close the round: "
|
|
2714
|
+
f"quirework sop room round {shown(root)} {args.number}"
|
|
2715
|
+
)
|
|
2716
|
+
else:
|
|
2717
|
+
print(
|
|
2718
|
+
"now close the round: quirework sop room promote "
|
|
2719
|
+
f"{shown(root)} {args.number}"
|
|
2720
|
+
)
|
|
2721
|
+
elif args.command == "open":
|
|
2722
|
+
print(open_round(root, args.number) or "silent round; no board")
|
|
2723
|
+
elif args.command == "rounds":
|
|
2724
|
+
with narrating(root):
|
|
2725
|
+
played, why = run_until(
|
|
2726
|
+
root, limit=args.to, jobs=args.jobs, timeout=args.timeout
|
|
2727
|
+
)
|
|
2728
|
+
for number, outcome in played:
|
|
2729
|
+
held = outcome["_board"].get("held")
|
|
2730
|
+
if held:
|
|
2731
|
+
back = outcome["_board"].get("held_back") or []
|
|
2732
|
+
close = (
|
|
2733
|
+
f"round {number} again to take {', '.join(back)}'s turns"
|
|
2734
|
+
if back
|
|
2735
|
+
else f"promote {number}"
|
|
2736
|
+
)
|
|
2737
|
+
print(
|
|
2738
|
+
f"round {number}: held, waiting on {', '.join(held)}; "
|
|
2739
|
+
f"continue each with: quirework sop room continue "
|
|
2740
|
+
f"{shown(root)} {number} SEAT, then {close}"
|
|
2741
|
+
)
|
|
2742
|
+
continue
|
|
2743
|
+
signal = outcome["_board"]["convergence"]
|
|
2744
|
+
print(
|
|
2745
|
+
f"round {number}: {len(outcome['_board']['posted'])} posted, "
|
|
2746
|
+
f"novelty {signal['novelty']}%, closing {signal['closing']}"
|
|
2747
|
+
)
|
|
2748
|
+
print(f"stopped: {why}")
|
|
2749
|
+
elif args.command == "extend":
|
|
2750
|
+
print(f"this room may now run {extend(root, args.rounds)} rounds")
|
|
2751
|
+
elif args.command == "promote":
|
|
2752
|
+
print(json.dumps(promote(root, args.number), indent=2))
|
|
2753
|
+
elif args.command == "status":
|
|
2754
|
+
value = status(root)
|
|
2755
|
+
print(json.dumps(value, indent=2) if args.json else _render(value))
|
|
2756
|
+
elif args.command == "usage":
|
|
2757
|
+
value = usage(root)
|
|
2758
|
+
print(json.dumps(value, indent=2) if args.json else _render_usage(value))
|
|
2759
|
+
elif args.command == "board":
|
|
2760
|
+
digest_file = root / "board" / "rounds" / f"r{args.number:03d}" / "digest.md"
|
|
2761
|
+
if not digest_file.is_file():
|
|
2762
|
+
raise ValueError(f"round {args.number} has not been published in {root}")
|
|
2763
|
+
_, plan = load(root, verify=False)
|
|
2764
|
+
print(attributed(digest_file.read_text(), plan))
|
|
2765
|
+
elif args.command == "watch":
|
|
2766
|
+
try:
|
|
2767
|
+
watch(
|
|
2768
|
+
root,
|
|
2769
|
+
history="all" if args.all else "round",
|
|
2770
|
+
thinking=args.thinking,
|
|
2771
|
+
poll=args.poll,
|
|
2772
|
+
width=args.width,
|
|
2773
|
+
)
|
|
2774
|
+
except KeyboardInterrupt:
|
|
2775
|
+
return 0
|
|
2776
|
+
elif args.command == "follow":
|
|
2777
|
+
try:
|
|
2778
|
+
follow(
|
|
2779
|
+
root,
|
|
2780
|
+
args.seat,
|
|
2781
|
+
thinking=not args.no_thinking,
|
|
2782
|
+
poll=args.poll,
|
|
2783
|
+
width=args.width,
|
|
2784
|
+
)
|
|
2785
|
+
except KeyboardInterrupt:
|
|
2786
|
+
return 0
|
|
2787
|
+
return 0
|
|
2788
|
+
|
|
2789
|
+
|
|
2790
|
+
if __name__ == "__main__":
|
|
2791
|
+
sys.exit(main())
|