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
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
"""Living with a provider's account limit: what order to spend in, and how
|
|
2
|
+
to carry on after the wall.
|
|
3
|
+
|
|
4
|
+
A quota stop is not a failure of the work. The provider stopped selling
|
|
5
|
+
tokens for a while; the session, its reasoning and whatever the agent had
|
|
6
|
+
already written are all still there. So the recovery is a **resume of the
|
|
7
|
+
same session**, not a fresh run of the same task: the agent is handed one
|
|
8
|
+
short notice that it was interrupted and told to carry on.
|
|
9
|
+
|
|
10
|
+
Two shapes, and they need different recoveries:
|
|
11
|
+
|
|
12
|
+
``refused``
|
|
13
|
+
The provider declined before the model ran, so nothing was generated.
|
|
14
|
+
The harness has nonetheless already appended the prompt -- and often
|
|
15
|
+
its own refusal notice -- to the session, which is why the shared
|
|
16
|
+
runner rewinds the session file and resubmits the identical turn
|
|
17
|
+
(``rewind_sessions``). Cheap and exact.
|
|
18
|
+
|
|
19
|
+
``interrupted``
|
|
20
|
+
The model ran, used tools, wrote files, and the wall arrived partway
|
|
21
|
+
through. Nothing can be replayed: the turn's reasoning is banked in
|
|
22
|
+
the session and the only way to keep it is to continue that session.
|
|
23
|
+
Restarting the task throws away the expensive half. One measured
|
|
24
|
+
workshop seat spent 58 percent of its cost on output and reasoning
|
|
25
|
+
and 4.5 percent on cached context reads, so a restart pays the whole
|
|
26
|
+
bill again to re-derive what the session still holds.
|
|
27
|
+
|
|
28
|
+
This module owns the detection, the session-id recovery and the resume
|
|
29
|
+
argv for all three harnesses, so no driver has to recognize a quota stop
|
|
30
|
+
on its own. It also recognizes the one stop that is neither a limit nor a
|
|
31
|
+
fault: a Claude seat whose borrowed login was revoked or expired mid-turn
|
|
32
|
+
(``auth_stop``). That stop is kept apart from ``classify`` on purpose,
|
|
33
|
+
because its recovery is not a wait: the host renews its login on its own,
|
|
34
|
+
and the turn goes on as soon as a newer token exists (``claude_login``). Deliberately stdlib-only and free of project imports: the
|
|
35
|
+
run engine's frozen executor, the workshop and the review driver all
|
|
36
|
+
reach it, and two of those must not pull the engine in behind them.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import hashlib
|
|
42
|
+
import json
|
|
43
|
+
import re
|
|
44
|
+
from datetime import datetime, timedelta
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
HARNESSES = ("codex", "claude", "agy")
|
|
48
|
+
|
|
49
|
+
# What a seat costs, declared rather than inferred. A table of model ids would
|
|
50
|
+
# be stale within a month -- providers rename models constantly -- and a run
|
|
51
|
+
# that departs from its roster has seats no table could know about. So a plan
|
|
52
|
+
# says which of ITS OWN seats is the cheap one and which is the premium one,
|
|
53
|
+
# and everything else is standard.
|
|
54
|
+
TIERS = ("cheap", "premium", "standard")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def spend_order(items, tier=lambda item: item.get("tier", "standard")):
|
|
58
|
+
"""Seats in the order a run should spend money on them.
|
|
59
|
+
|
|
60
|
+
One cheap seat, then the premium seat, then the rest in declared order.
|
|
61
|
+
The two ends of a roster fail differently and both failures are cheaper
|
|
62
|
+
early, which is the whole argument:
|
|
63
|
+
|
|
64
|
+
A **broken assignment** -- a missing file, an ambiguous instruction, an
|
|
65
|
+
output path nobody can write -- is found by whichever seat runs first, so
|
|
66
|
+
that seat should be the one it costs least to throw away. Only ONE cheap
|
|
67
|
+
seat leads, because a second adds wall time and learns nothing the first
|
|
68
|
+
did not.
|
|
69
|
+
|
|
70
|
+
A **quota wall** arrives at the end of a budget, and the seat standing
|
|
71
|
+
there when it lands is whichever ran last. Putting the premium seat second
|
|
72
|
+
spends it into a nearly empty allowance and leaves the remainder available
|
|
73
|
+
to continue it; the seats that then meet the wall are the cheap and
|
|
74
|
+
replaceable ones, which is the loss a run can afford. Prefer a shakedown
|
|
75
|
+
seat billed against a DIFFERENT account from the premium one, or the cheap
|
|
76
|
+
run eats the budget it is supposed to be protecting.
|
|
77
|
+
|
|
78
|
+
Nothing here runs anything, and nothing here is parallel. Seats sharing an
|
|
79
|
+
account exhaust a five-hour window several times faster in parallel, and
|
|
80
|
+
the wall then lands mid-turn on every one of them at once instead of on
|
|
81
|
+
one; three measured workshop runs lost seats exactly that way.
|
|
82
|
+
"""
|
|
83
|
+
ranked, led = [], False
|
|
84
|
+
for item in items:
|
|
85
|
+
if tier(item) == "cheap" and not led:
|
|
86
|
+
ranked.append(item)
|
|
87
|
+
led = True
|
|
88
|
+
ranked += [i for i in items if tier(i) == "premium" and i not in ranked]
|
|
89
|
+
ranked += [i for i in items if i not in ranked]
|
|
90
|
+
return ranked
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# What a provider says when it is a limit rather than a fault. Claude's
|
|
94
|
+
# wording has been seen as both "You've hit your session limit" and
|
|
95
|
+
# "Claude AI usage limit reached|<epoch>"; codex reports a code, except
|
|
96
|
+
# that 0.161 states a plan's usage limit in prose with none.
|
|
97
|
+
# The structured signals below are preferred wherever a harness emits one,
|
|
98
|
+
# and this is the fallback for a harness that only returns prose.
|
|
99
|
+
LIMIT_TEXT = re.compile(
|
|
100
|
+
r"usage limit|session limit|rate.?limit|quota|too many requests|"
|
|
101
|
+
r"resets? at|429\b",
|
|
102
|
+
re.I,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
CODEX_LIMIT_CODES = ("rate_limit_exceeded", "insufficient_quota")
|
|
106
|
+
|
|
107
|
+
# Codex names a plan's reset as a time on the clock and nothing more: "...
|
|
108
|
+
# or try again at 1:18 PM." No date and no zone, because the CLI formats it
|
|
109
|
+
# for whoever is at the terminal. It is read as local time, at the next such
|
|
110
|
+
# time at or after the stop. A stop filed with no reset tells the operator
|
|
111
|
+
# nothing about when to come back, and the sentence was right there.
|
|
112
|
+
CODEX_RESET = re.compile(
|
|
113
|
+
r"try again (?:at|after)\s+(\d{1,2}):(\d{2})(?:\s*([ap])\.?\s*m\b\.?)?",
|
|
114
|
+
re.I,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# A reset named a few minutes before the stop was recorded is the same
|
|
118
|
+
# reset, reached late (the file is stamped when the harness finishes
|
|
119
|
+
# writing), and not one a whole day away.
|
|
120
|
+
RESET_GRACE = timedelta(minutes=10)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def codex_reset(message, stopped_at):
|
|
124
|
+
"""Epoch seconds of the reset a Codex limit message names, or None.
|
|
125
|
+
|
|
126
|
+
``stopped_at`` is when the stop was recorded, in epoch seconds; the
|
|
127
|
+
clock time in the message is taken as the next one at or after it, in
|
|
128
|
+
local time. A message naming no time, or an impossible one, gives None
|
|
129
|
+
rather than a guess.
|
|
130
|
+
"""
|
|
131
|
+
found = CODEX_RESET.search(str(message or ""))
|
|
132
|
+
if not found or stopped_at is None:
|
|
133
|
+
return None
|
|
134
|
+
hour, minute = int(found[1]), int(found[2])
|
|
135
|
+
half = (found[3] or "").lower()
|
|
136
|
+
if minute > 59:
|
|
137
|
+
return None
|
|
138
|
+
if half:
|
|
139
|
+
if not 1 <= hour <= 12:
|
|
140
|
+
return None
|
|
141
|
+
hour = hour % 12 + (12 if half == "p" else 0)
|
|
142
|
+
elif hour > 23:
|
|
143
|
+
return None
|
|
144
|
+
stop = datetime.fromtimestamp(stopped_at)
|
|
145
|
+
reset = stop.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
146
|
+
if reset < stop - RESET_GRACE:
|
|
147
|
+
reset += timedelta(days=1)
|
|
148
|
+
return int(reset.timestamp())
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# What Claude Code says when the token it was handed stops working. The 401
|
|
152
|
+
# status, the assistant record's ``authentication_failed`` and the retry
|
|
153
|
+
# records' ``error_status`` are the structured signals; this wording is the
|
|
154
|
+
# fallback for a release that drops them. Observed verbatim: "Failed to
|
|
155
|
+
# authenticate: OAuth token revoked. Please log in again or contact your
|
|
156
|
+
# administrator."
|
|
157
|
+
AUTH_TEXT = re.compile(
|
|
158
|
+
r"failed to authenticate|oauth (?:access )?token (?:has been )?(?:revoked|expired)",
|
|
159
|
+
re.I,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# A provider declining because the model itself is busy, which is not a usage
|
|
163
|
+
# limit: there is no allowance to wait out and no reset time to wait for. The
|
|
164
|
+
# recovery is the same as a refusal's, because nothing ran either way, and the
|
|
165
|
+
# scope is reported separately so nobody is told to sit out a window that does
|
|
166
|
+
# not exist. A writers room lost a seat for a whole run to one of these, with
|
|
167
|
+
# the controller calling it an ordinary failure and refusing to take the turn
|
|
168
|
+
# again; the model answered normally two minutes later.
|
|
169
|
+
CAPACITY_TEXT = re.compile(
|
|
170
|
+
r"at capacity|overloaded|try a different model|temporarily unavailable",
|
|
171
|
+
re.I,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# The whole prompt a resumed agent gets. It says what happened, that the
|
|
176
|
+
# assignment is unchanged, and that the work already done is still its own
|
|
177
|
+
# -- and it deliberately says nothing about the task itself, because the
|
|
178
|
+
# session already holds the task and restating it invites a fresh start.
|
|
179
|
+
CONTINUATION = (
|
|
180
|
+
"There was a session quota limit event and your previous turn was cut "
|
|
181
|
+
"off partway through. Nothing about the assignment has changed and "
|
|
182
|
+
"nothing you already did has been lost.\n\n"
|
|
183
|
+
"Continue from where you were. Re-read what you have already written, "
|
|
184
|
+
"do not redo work you have already completed, do not start over, and "
|
|
185
|
+
"finish the task you were given."
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# The same prompt for a turn continued after any other stop: a dropped
|
|
190
|
+
# connection, an API error, a failure no classifier names. It does not guess
|
|
191
|
+
# at the cause, because the agent needs only to know that it was cut off and
|
|
192
|
+
# that its work survived.
|
|
193
|
+
ERROR_CONTINUATION = (
|
|
194
|
+
"We hit an error and your previous turn was cut off partway through. "
|
|
195
|
+
"Nothing about the assignment has changed and nothing you already did "
|
|
196
|
+
"has been lost.\n\n"
|
|
197
|
+
"Please resume your task. Re-read what you have already written, do not "
|
|
198
|
+
"redo work you have already completed, do not start over, and finish the "
|
|
199
|
+
"task you were given."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# The same prompt for a turn continued after the seat's borrowed login was
|
|
204
|
+
# renewed under it. Honest about the cause and no more: the agent needs to
|
|
205
|
+
# know only that the interruption was the connection's, not its own doing.
|
|
206
|
+
AUTH_CONTINUATION = (
|
|
207
|
+
"The connection's login was renewed and your previous turn was cut off "
|
|
208
|
+
"partway through. Nothing about the assignment has changed and nothing "
|
|
209
|
+
"you already did has been lost.\n\n"
|
|
210
|
+
"Continue from where you were. Re-read what you have already written, "
|
|
211
|
+
"do not redo work you have already completed, do not start over, and "
|
|
212
|
+
"finish the task you were given."
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
_CONTINUATIONS = dict(
|
|
216
|
+
quota=CONTINUATION, error=ERROR_CONTINUATION, auth=AUTH_CONTINUATION
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def continuation(note=None, *, cause="quota"):
|
|
221
|
+
"""The resume prompt, plus one caller line naming where its work is.
|
|
222
|
+
|
|
223
|
+
`cause` is "quota" for a provider limit, "auth" for a seat whose borrowed
|
|
224
|
+
login was renewed mid-turn, and "error" for any other stop.
|
|
225
|
+
"""
|
|
226
|
+
if cause not in _CONTINUATIONS:
|
|
227
|
+
raise ValueError(f"unknown continuation cause: {cause}")
|
|
228
|
+
text = _CONTINUATIONS[cause]
|
|
229
|
+
return text if not note else f"{text}\n\n{note.strip()}\n"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def rows(path):
|
|
233
|
+
"""Parse the complete JSON lines; an unterminated last line is dropped.
|
|
234
|
+
|
|
235
|
+
A seat killed mid-flush leaves its stream ending in half a row. The
|
|
236
|
+
receipt already fails on that, loudly, and the classifier runs right
|
|
237
|
+
after it on the same stream. Raising here a second time was worse than
|
|
238
|
+
useless: it escaped the room's launch before the receipt was written,
|
|
239
|
+
so a killed turn banked no ``receipt.json`` and no round state, and the
|
|
240
|
+
round reported a JSON parse error where the seat's exit code belonged.
|
|
241
|
+
Only a line that lacks its newline is dropped; a malformed complete
|
|
242
|
+
line is still the visible failure it always was.
|
|
243
|
+
"""
|
|
244
|
+
path = Path(path)
|
|
245
|
+
if not path.exists():
|
|
246
|
+
return []
|
|
247
|
+
out = []
|
|
248
|
+
with path.open("rb") as stream:
|
|
249
|
+
for line in stream:
|
|
250
|
+
if not line.strip():
|
|
251
|
+
continue
|
|
252
|
+
try:
|
|
253
|
+
out.append(json.loads(line))
|
|
254
|
+
except json.JSONDecodeError:
|
|
255
|
+
if not line.endswith(b"\n"):
|
|
256
|
+
break
|
|
257
|
+
raise
|
|
258
|
+
return out
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def session_id(harness, stream):
|
|
262
|
+
"""The harness session this record belongs to, even when the turn failed.
|
|
263
|
+
|
|
264
|
+
The drivers read the session id out of a *successful* receipt, so a
|
|
265
|
+
quota-stopped turn banks ``session_id: null`` and the one thing a
|
|
266
|
+
resume needs survives only inside the event stream. Recovering it here
|
|
267
|
+
is what makes a stopped turn resumable at all.
|
|
268
|
+
"""
|
|
269
|
+
if harness == "codex":
|
|
270
|
+
ids = [r["thread_id"] for r in stream if r.get("type") == "thread.started"]
|
|
271
|
+
elif harness == "claude":
|
|
272
|
+
ids = [r["session_id"] for r in stream if r.get("session_id")]
|
|
273
|
+
elif harness == "agy":
|
|
274
|
+
ids = [r["conversation_id"] for r in stream if r.get("conversation_id")]
|
|
275
|
+
else:
|
|
276
|
+
raise ValueError(f"unknown harness: {harness}")
|
|
277
|
+
unique = list(dict.fromkeys(ids))
|
|
278
|
+
# More than one session in one turn's stream means the record is not
|
|
279
|
+
# what it claims to be; resuming "the" session would be a guess.
|
|
280
|
+
return unique[0] if len(unique) == 1 else None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _claude(stream):
|
|
284
|
+
results = [r for r in stream if r.get("type") == "result"]
|
|
285
|
+
if len(results) != 1 or not results[0].get("is_error"):
|
|
286
|
+
return None
|
|
287
|
+
result = results[0]
|
|
288
|
+
said = str(result.get("result") or "")
|
|
289
|
+
if CAPACITY_TEXT.search(said) and not LIMIT_TEXT.search(said):
|
|
290
|
+
# Busy, not out of allowance. Nothing ran, so the turn is delivered
|
|
291
|
+
# again rather than continued, and no reset time is reported because
|
|
292
|
+
# there is none to report.
|
|
293
|
+
return dict(
|
|
294
|
+
phase="refused",
|
|
295
|
+
scope="capacity",
|
|
296
|
+
resets_at=None,
|
|
297
|
+
detail=said[:200],
|
|
298
|
+
output_tokens=0,
|
|
299
|
+
)
|
|
300
|
+
limit = [
|
|
301
|
+
r
|
|
302
|
+
for r in stream
|
|
303
|
+
if r.get("type") == "rate_limit_event"
|
|
304
|
+
and (r.get("rate_limit_info") or {}).get("status") == "rejected"
|
|
305
|
+
]
|
|
306
|
+
if not limit and not LIMIT_TEXT.search(said):
|
|
307
|
+
return None
|
|
308
|
+
info = (limit[-1].get("rate_limit_info") or {}) if limit else {}
|
|
309
|
+
# The receipt that says whether the model ran: a provider refusal
|
|
310
|
+
# reports no output tokens anywhere and an empty `modelUsage`, because
|
|
311
|
+
# nothing was generated. A real turn cannot satisfy that.
|
|
312
|
+
produced = sum(
|
|
313
|
+
v.get("outputTokens", 0) or 0 for v in (result.get("modelUsage") or {}).values()
|
|
314
|
+
)
|
|
315
|
+
return dict(
|
|
316
|
+
phase="refused" if not produced else "interrupted",
|
|
317
|
+
scope=info.get("rateLimitType") or "quota",
|
|
318
|
+
resets_at=info.get("resetsAt"),
|
|
319
|
+
detail=said[:200],
|
|
320
|
+
output_tokens=produced,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def claude_auth(stream):
|
|
325
|
+
"""The login-stop detail in a Claude event stream already read, or None.
|
|
326
|
+
|
|
327
|
+
The stream-level half of ``auth_stop``, for a driver that has the rows in
|
|
328
|
+
hand and no record directory to point at.
|
|
329
|
+
"""
|
|
330
|
+
results = [r for r in stream if r.get("type") == "result"]
|
|
331
|
+
if len(results) != 1 or not results[0].get("is_error"):
|
|
332
|
+
return None
|
|
333
|
+
result = results[0]
|
|
334
|
+
said = str(result.get("result") or "")
|
|
335
|
+
structured = (
|
|
336
|
+
result.get("api_error_status") == 401
|
|
337
|
+
or any(
|
|
338
|
+
r.get("type") == "assistant" and r.get("error") == "authentication_failed"
|
|
339
|
+
for r in stream
|
|
340
|
+
)
|
|
341
|
+
or any(
|
|
342
|
+
r.get("type") == "system"
|
|
343
|
+
and r.get("subtype") == "api_retry"
|
|
344
|
+
and (
|
|
345
|
+
str(r.get("error_status")) == "401"
|
|
346
|
+
or r.get("error") == "authentication_failed"
|
|
347
|
+
)
|
|
348
|
+
for r in stream
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
if not structured and not AUTH_TEXT.search(said):
|
|
352
|
+
return None
|
|
353
|
+
produced = sum(
|
|
354
|
+
v.get("outputTokens", 0) or 0 for v in (result.get("modelUsage") or {}).values()
|
|
355
|
+
)
|
|
356
|
+
return dict(
|
|
357
|
+
phase="refused" if not produced else "interrupted",
|
|
358
|
+
scope="auth",
|
|
359
|
+
resets_at=None,
|
|
360
|
+
detail=said[:200],
|
|
361
|
+
output_tokens=produced,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def auth_stop(harness, record):
|
|
366
|
+
"""A Claude login revoked or expired mid-turn in this record, or None.
|
|
367
|
+
|
|
368
|
+
Only a Claude seat borrows a token it cannot renew, so only Claude is
|
|
369
|
+
read; the other harnesses keep their own credentials. ``phase`` follows
|
|
370
|
+
``classify``'s meaning: ``refused`` when nothing ran, so the identical
|
|
371
|
+
turn is delivered again, and ``interrupted`` when the model ran, so the
|
|
372
|
+
session is continued. Whether to do either now is the caller's question,
|
|
373
|
+
and ``claude_login`` answers it: only once the host holds a newer token.
|
|
374
|
+
"""
|
|
375
|
+
if harness not in HARNESSES:
|
|
376
|
+
raise ValueError(f"unknown harness: {harness}")
|
|
377
|
+
if harness != "claude":
|
|
378
|
+
return None
|
|
379
|
+
record = Path(record)
|
|
380
|
+
stream = rows(record if record.is_file() else record / "events.jsonl")
|
|
381
|
+
if not stream:
|
|
382
|
+
return None
|
|
383
|
+
detail = claude_auth(stream)
|
|
384
|
+
if detail is None:
|
|
385
|
+
return None
|
|
386
|
+
detail["harness"] = harness
|
|
387
|
+
detail["session_id"] = session_id(harness, stream)
|
|
388
|
+
return detail
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _codex_limit(error):
|
|
392
|
+
"""Whether a failed Codex turn says it stopped for an account limit.
|
|
393
|
+
|
|
394
|
+
The code is the structured signal and wins wherever it is present, so a
|
|
395
|
+
coded failure that is not a limit code stays a fault. Codex 0.161 reports
|
|
396
|
+
a ChatGPT plan's usage limit with no code at all, only the sentence, and a
|
|
397
|
+
seat stopped that way was filed as an ambiguous receipt and could not be
|
|
398
|
+
resumed. A codeless failure therefore falls back to the limit wording the
|
|
399
|
+
other harnesses are already read by.
|
|
400
|
+
"""
|
|
401
|
+
code = error.get("code")
|
|
402
|
+
if code:
|
|
403
|
+
return code in CODEX_LIMIT_CODES
|
|
404
|
+
return bool(LIMIT_TEXT.search(str(error.get("message", ""))))
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _codex(stream, stopped_at=None):
|
|
408
|
+
stopped = [r for r in stream if r.get("type") == "turn.failed"]
|
|
409
|
+
failed = [r for r in stopped if _codex_limit(r.get("error") or {})]
|
|
410
|
+
if not failed:
|
|
411
|
+
busy = [
|
|
412
|
+
r
|
|
413
|
+
for r in stopped
|
|
414
|
+
if CAPACITY_TEXT.search(str((r.get("error") or {}).get("message", "")))
|
|
415
|
+
]
|
|
416
|
+
if not busy:
|
|
417
|
+
return None
|
|
418
|
+
error = busy[-1].get("error") or {}
|
|
419
|
+
return dict(
|
|
420
|
+
phase="refused",
|
|
421
|
+
scope="capacity",
|
|
422
|
+
resets_at=None,
|
|
423
|
+
detail=str(error.get("message", ""))[:200],
|
|
424
|
+
output_tokens=None,
|
|
425
|
+
)
|
|
426
|
+
error = failed[-1].get("error") or {}
|
|
427
|
+
produced = any(r.get("type") == "item.completed" for r in stream)
|
|
428
|
+
return dict(
|
|
429
|
+
phase="interrupted" if produced else "refused",
|
|
430
|
+
scope=error.get("code") or "quota",
|
|
431
|
+
resets_at=error.get("resets_at")
|
|
432
|
+
or codex_reset(error.get("message"), stopped_at),
|
|
433
|
+
detail=str(error.get("message", ""))[:200],
|
|
434
|
+
output_tokens=None,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _agy(stream):
|
|
439
|
+
results = [r["result"] for r in stream if r.get("event") == "result"]
|
|
440
|
+
if len(results) != 1 or results[0].get("status") != "ERROR":
|
|
441
|
+
return None
|
|
442
|
+
result = results[0]
|
|
443
|
+
said = str(result.get("error") or result.get("response") or "")
|
|
444
|
+
if not LIMIT_TEXT.search(said):
|
|
445
|
+
return None
|
|
446
|
+
produced = (
|
|
447
|
+
bool(result.get("response"))
|
|
448
|
+
or bool((result.get("usage") or {}).get("output_tokens"))
|
|
449
|
+
or any(r.get("step_update", {}).get("step_type") == "tool" for r in stream)
|
|
450
|
+
)
|
|
451
|
+
return dict(
|
|
452
|
+
phase="interrupted" if produced else "refused",
|
|
453
|
+
scope="quota",
|
|
454
|
+
resets_at=None,
|
|
455
|
+
detail=said[:200],
|
|
456
|
+
output_tokens=(result.get("usage") or {}).get("output_tokens"),
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def classify(harness, record):
|
|
461
|
+
"""A quota stop in this record's event stream, or None.
|
|
462
|
+
|
|
463
|
+
``record`` is a directory holding ``events.jsonl``, or the file itself.
|
|
464
|
+
Returns ``phase`` (``refused``/``interrupted``), the provider's scope and
|
|
465
|
+
reset time where it gives one, and the session id to resume.
|
|
466
|
+
|
|
467
|
+
Returning None is the safe answer and the common one: an ambiguous
|
|
468
|
+
transport fault must never be treated as a quota stop, because the two
|
|
469
|
+
recoveries differ and resubmitting an ambiguous turn can duplicate work
|
|
470
|
+
that actually happened.
|
|
471
|
+
"""
|
|
472
|
+
if harness not in HARNESSES:
|
|
473
|
+
raise ValueError(f"unknown harness: {harness}")
|
|
474
|
+
record = Path(record)
|
|
475
|
+
path = record if record.is_file() else record / "events.jsonl"
|
|
476
|
+
stream = rows(path)
|
|
477
|
+
if not stream:
|
|
478
|
+
return None
|
|
479
|
+
if harness == "codex":
|
|
480
|
+
# Codex's reset is a clock time with no date, so it is read against
|
|
481
|
+
# when the stream was last written: the stop's own moment, give or
|
|
482
|
+
# take the harness finishing its file.
|
|
483
|
+
detail = _codex(stream, stopped_at=path.stat().st_mtime)
|
|
484
|
+
else:
|
|
485
|
+
detail = {"claude": _claude, "agy": _agy}[harness](stream)
|
|
486
|
+
if detail is None:
|
|
487
|
+
return None
|
|
488
|
+
detail["harness"] = harness
|
|
489
|
+
detail["session_id"] = session_id(harness, stream)
|
|
490
|
+
return detail
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def resumable(harness, record):
|
|
494
|
+
"""The session id to resume after an interrupted turn, or None.
|
|
495
|
+
|
|
496
|
+
A ``refused`` stop is not resumable through this path on purpose: nothing
|
|
497
|
+
ran, so there is no reasoning to continue from and the turn has to be
|
|
498
|
+
delivered again rather than continued.
|
|
499
|
+
|
|
500
|
+
*How* it is delivered again is the caller's, not this module's. Where the
|
|
501
|
+
refused turn was itself a resume -- the shared runner's later turns, and
|
|
502
|
+
every round after a room or roundtable seat's first -- its session holds
|
|
503
|
+
the turns before it. That caller rewinds to the mark it took before
|
|
504
|
+
sending (``session_marks``) and resubmits the identical turn into the
|
|
505
|
+
same session; a fresh one would lose every earlier turn. A driver whose
|
|
506
|
+
unit is one assignment in its own session -- the prose workshop, the
|
|
507
|
+
review driver -- relaunches the turn in a fresh session instead, and
|
|
508
|
+
loses nothing by it, since there is no earlier turn to keep.
|
|
509
|
+
"""
|
|
510
|
+
return resumable_session(classify(harness, record))
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def resumable_session(detail):
|
|
514
|
+
"""The same rule as ``resumable``, over an already-classified stop.
|
|
515
|
+
|
|
516
|
+
Split out so a caller that has paid for ``classify`` already -- the
|
|
517
|
+
workshop's ``inspect`` walks every failed record -- applies the predicate
|
|
518
|
+
without reading the event stream a second time. One owner for the rule,
|
|
519
|
+
two ways in: deriving it from ``session_id`` alone is what made ``inspect``
|
|
520
|
+
report a refused writer as resumable.
|
|
521
|
+
"""
|
|
522
|
+
if not detail or detail["phase"] != "interrupted":
|
|
523
|
+
return None
|
|
524
|
+
return detail["session_id"]
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def session_marks(paths):
|
|
528
|
+
"""Record each native session file's length and the digest of that prefix.
|
|
529
|
+
|
|
530
|
+
The pair is what makes a later rewind safe: length alone would truncate a
|
|
531
|
+
file the client had rewritten rather than appended to, and a digest alone
|
|
532
|
+
could not say where to cut.
|
|
533
|
+
"""
|
|
534
|
+
marks = []
|
|
535
|
+
for path in paths:
|
|
536
|
+
data = path.read_bytes()
|
|
537
|
+
marks.append(
|
|
538
|
+
dict(
|
|
539
|
+
path=str(path),
|
|
540
|
+
size=len(data),
|
|
541
|
+
sha256=hashlib.sha256(data).hexdigest(),
|
|
542
|
+
)
|
|
543
|
+
)
|
|
544
|
+
return marks
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def rewind_sessions(marks):
|
|
548
|
+
"""Return each recorded session file to the state in `marks`.
|
|
549
|
+
|
|
550
|
+
Refuses unless the file still begins with exactly the bytes that were
|
|
551
|
+
there before, so this can only ever remove the refused turn's own
|
|
552
|
+
appended rows. A session that shrank, or whose prefix changed, is not
|
|
553
|
+
rewound: that is no longer a clean resume and the operator decides.
|
|
554
|
+
"""
|
|
555
|
+
for mark in marks or []:
|
|
556
|
+
path = Path(mark["path"])
|
|
557
|
+
if not path.exists():
|
|
558
|
+
if mark["size"]:
|
|
559
|
+
raise RuntimeError(f"session file vanished: {path}")
|
|
560
|
+
continue
|
|
561
|
+
data = path.read_bytes()
|
|
562
|
+
if len(data) < mark["size"]:
|
|
563
|
+
raise RuntimeError(f"session file shrank; not a clean rewind: {path}")
|
|
564
|
+
prefix = data[: mark["size"]]
|
|
565
|
+
if hashlib.sha256(prefix).hexdigest() != mark["sha256"]:
|
|
566
|
+
raise RuntimeError(f"session prefix changed; not a clean rewind: {path}")
|
|
567
|
+
if len(data) == mark["size"]:
|
|
568
|
+
continue
|
|
569
|
+
with path.open("r+b") as handle:
|
|
570
|
+
handle.truncate(mark["size"])
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def resume_argv(harness, session):
|
|
574
|
+
"""The tokens that turn a launch command into a resume of ``session``.
|
|
575
|
+
|
|
576
|
+
Codex takes a subcommand, so its tokens follow ``exec``; Claude and agy
|
|
577
|
+
take a flag anywhere in their argv. Every caller must then check the
|
|
578
|
+
returned session id against ``session``: a harness that silently starts a
|
|
579
|
+
new conversation instead of resuming looks identical until that check,
|
|
580
|
+
and the evidence would be a second session claiming to be the first.
|
|
581
|
+
"""
|
|
582
|
+
if not session:
|
|
583
|
+
raise ValueError("a resume needs a session id")
|
|
584
|
+
return {
|
|
585
|
+
"codex": ["resume", session],
|
|
586
|
+
"claude": ["--resume", session],
|
|
587
|
+
"agy": ["--conversation", session],
|
|
588
|
+
}[harness]
|