marcus-toolkit 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.
- marcus_toolkit/__init__.py +27 -0
- marcus_toolkit/agent.py +451 -0
- marcus_toolkit/candidates.py +489 -0
- marcus_toolkit/client.py +319 -0
- marcus_toolkit/funnel.py +154 -0
- marcus_toolkit/gate.py +150 -0
- marcus_toolkit/integrity.py +251 -0
- marcus_toolkit/livecheck.py +223 -0
- marcus_toolkit/measure.py +197 -0
- marcus_toolkit/pricing.py +96 -0
- marcus_toolkit/record.py +84 -0
- marcus_toolkit/sampler.py +169 -0
- marcus_toolkit/seed.py +189 -0
- marcus_toolkit/server.py +348 -0
- marcus_toolkit/tender.py +369 -0
- marcus_toolkit/tending.py +946 -0
- marcus_toolkit/usage.py +222 -0
- marcus_toolkit/views.py +150 -0
- marcus_toolkit/watchdog.py +264 -0
- marcus_toolkit-0.1.0.dist-info/METADATA +98 -0
- marcus_toolkit-0.1.0.dist-info/RECORD +25 -0
- marcus_toolkit-0.1.0.dist-info/WHEEL +5 -0
- marcus_toolkit-0.1.0.dist-info/entry_points.txt +5 -0
- marcus_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- marcus_toolkit-0.1.0.dist-info/top_level.txt +1 -0
marcus_toolkit/usage.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Read what a run's agents spent, from the rows they wrote, while the run is going.
|
|
3
|
+
|
|
4
|
+
Two layouts are read, because two kinds of agent exist today:
|
|
5
|
+
|
|
6
|
+
* ``<state>/<agent>/<agent>/pi-sessions/**/*.jsonl``: the pi coding agent's
|
|
7
|
+
own transcripts, as the Minecraft harness writes them; every usage mapping
|
|
8
|
+
nested anywhere in an entry counts.
|
|
9
|
+
* ``<state>/<agent>/usage.jsonl``: one row per model call, ``{"at": ..,
|
|
10
|
+
"model": .., "usage": {input, output, cacheRead, cacheWrite}}``, which any
|
|
11
|
+
runtime can write (ADR-0013 K8: a non-Claude-Code runtime has no transcript
|
|
12
|
+
to parse).
|
|
13
|
+
|
|
14
|
+
A file may be mid-write when it is read during a live run: a trailing partial
|
|
15
|
+
line is skipped, so this is safe to call repeatedly.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from collections.abc import Iterable, Iterator
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from . import pricing
|
|
26
|
+
|
|
27
|
+
_ANTHROPIC_NAMES = {
|
|
28
|
+
"input_tokens": "input",
|
|
29
|
+
"output_tokens": "output",
|
|
30
|
+
"cache_read_input_tokens": "cacheRead",
|
|
31
|
+
"cache_creation_input_tokens": "cacheWrite",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def normalize_usage(raw: Any) -> dict[str, int] | None:
|
|
36
|
+
"""
|
|
37
|
+
Read a usage mapping in either pi's or Anthropic's field names.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
raw : Any
|
|
42
|
+
A mapping with ``input``/``output`` or ``input_tokens``/``output_tokens``.
|
|
43
|
+
|
|
44
|
+
Returns
|
|
45
|
+
-------
|
|
46
|
+
Optional[Dict[str, int]]
|
|
47
|
+
``{input, output, cacheRead, cacheWrite}`` with every key present, or
|
|
48
|
+
``None`` if ``raw`` is not a usage mapping.
|
|
49
|
+
"""
|
|
50
|
+
if not isinstance(raw, dict):
|
|
51
|
+
return None
|
|
52
|
+
mapped = {pricing.USAGE_KEYS[i]: 0 for i in range(4)}
|
|
53
|
+
seen = False
|
|
54
|
+
for key, value in raw.items():
|
|
55
|
+
name = _ANTHROPIC_NAMES.get(key, key)
|
|
56
|
+
if name in mapped and isinstance(value, int):
|
|
57
|
+
mapped[name] = value
|
|
58
|
+
if name in ("input", "output"):
|
|
59
|
+
seen = True
|
|
60
|
+
return mapped if seen else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _usages(entry: Any) -> Iterable[dict[str, Any]]:
|
|
64
|
+
"""Yield every usage mapping nested anywhere in a transcript entry."""
|
|
65
|
+
if isinstance(entry, dict):
|
|
66
|
+
if isinstance(entry.get("input"), int) and isinstance(entry.get("output"), int):
|
|
67
|
+
yield entry
|
|
68
|
+
for value in entry.values():
|
|
69
|
+
yield from _usages(value)
|
|
70
|
+
elif isinstance(entry, list):
|
|
71
|
+
for value in entry:
|
|
72
|
+
yield from _usages(value)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _lines(file: Path) -> Iterator[Any]:
|
|
76
|
+
for line in file.read_text(errors="replace").splitlines():
|
|
77
|
+
try:
|
|
78
|
+
yield json.loads(line)
|
|
79
|
+
except json.JSONDecodeError:
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def rows(state_dir: Path) -> Iterator[tuple[str, dict[str, int], str | None]]:
|
|
84
|
+
"""
|
|
85
|
+
Yield ``(agent, usage, model)`` for every usage row under ``state_dir``.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
state_dir : Path
|
|
90
|
+
The run's state directory, one subdirectory per agent.
|
|
91
|
+
|
|
92
|
+
Yields
|
|
93
|
+
------
|
|
94
|
+
Tuple[str, Dict[str, int], Optional[str]]
|
|
95
|
+
The agent's name, its normalised usage, and the model the row names
|
|
96
|
+
(``None`` for pi transcripts, which do not say).
|
|
97
|
+
"""
|
|
98
|
+
for session_dir in sorted(state_dir.glob("*/*/pi-sessions")):
|
|
99
|
+
agent = session_dir.parent.name
|
|
100
|
+
for file in sorted(session_dir.rglob("*.jsonl")):
|
|
101
|
+
for entry in _lines(file):
|
|
102
|
+
for usage in _usages(entry):
|
|
103
|
+
normalized = normalize_usage(usage)
|
|
104
|
+
if normalized is not None:
|
|
105
|
+
yield agent, normalized, None
|
|
106
|
+
for file in sorted(state_dir.glob("*/usage.jsonl")):
|
|
107
|
+
agent = file.parent.name
|
|
108
|
+
for entry in _lines(file):
|
|
109
|
+
if not isinstance(entry, dict):
|
|
110
|
+
continue
|
|
111
|
+
normalized = normalize_usage(entry.get("usage"))
|
|
112
|
+
if normalized is not None:
|
|
113
|
+
model = entry.get("model")
|
|
114
|
+
yield agent, normalized, str(model) if model else None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def costs(state_dir: Path, model_hint: str = "sonnet") -> dict[str, dict[str, Any]]:
|
|
118
|
+
"""
|
|
119
|
+
Price each agent's usage.
|
|
120
|
+
|
|
121
|
+
Parameters
|
|
122
|
+
----------
|
|
123
|
+
state_dir : Path
|
|
124
|
+
The run's state directory.
|
|
125
|
+
model_hint : str
|
|
126
|
+
The price list for rows that do not name their model.
|
|
127
|
+
|
|
128
|
+
Returns
|
|
129
|
+
-------
|
|
130
|
+
Dict[str, Dict[str, Any]]
|
|
131
|
+
Per agent: ``model_calls``, ``billed_calls`` (rows with any tokens;
|
|
132
|
+
pi records a zero row for an aborted continuation), token totals,
|
|
133
|
+
``dollars`` and the models seen.
|
|
134
|
+
"""
|
|
135
|
+
per_agent: dict[str, dict[str, Any]] = {}
|
|
136
|
+
for agent, usage, model in rows(state_dir):
|
|
137
|
+
row = per_agent.setdefault(
|
|
138
|
+
agent,
|
|
139
|
+
{
|
|
140
|
+
"model_calls": 0,
|
|
141
|
+
"billed_calls": 0,
|
|
142
|
+
"input": 0,
|
|
143
|
+
"output": 0,
|
|
144
|
+
"cache_read": 0,
|
|
145
|
+
"cache_write": 0,
|
|
146
|
+
"dollars": 0.0,
|
|
147
|
+
"models": [],
|
|
148
|
+
},
|
|
149
|
+
)
|
|
150
|
+
row["model_calls"] += 1
|
|
151
|
+
tokens = sum(usage.values())
|
|
152
|
+
if tokens > 0:
|
|
153
|
+
row["billed_calls"] += 1
|
|
154
|
+
row["input"] += usage["input"]
|
|
155
|
+
row["output"] += usage["output"]
|
|
156
|
+
row["cache_read"] += usage["cacheRead"]
|
|
157
|
+
row["cache_write"] += usage["cacheWrite"]
|
|
158
|
+
name = model or model_hint
|
|
159
|
+
row["dollars"] += pricing.price(usage, name)
|
|
160
|
+
key = pricing.resolve(name)
|
|
161
|
+
if key not in row["models"]:
|
|
162
|
+
row["models"].append(key)
|
|
163
|
+
for row in per_agent.values():
|
|
164
|
+
row["dollars"] = round(row["dollars"], 4)
|
|
165
|
+
return per_agent
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def total_dollars(state_dir: Path, model_hint: str = "sonnet") -> float:
|
|
169
|
+
"""
|
|
170
|
+
Sum every agent's spend: the one number a stop-line check needs.
|
|
171
|
+
|
|
172
|
+
Parameters
|
|
173
|
+
----------
|
|
174
|
+
state_dir : Path
|
|
175
|
+
The run's state directory.
|
|
176
|
+
model_hint : str
|
|
177
|
+
The price list for rows that do not name their model.
|
|
178
|
+
|
|
179
|
+
Returns
|
|
180
|
+
-------
|
|
181
|
+
float
|
|
182
|
+
Dollars so far, rounded to four places.
|
|
183
|
+
"""
|
|
184
|
+
return float(
|
|
185
|
+
round(
|
|
186
|
+
sum(
|
|
187
|
+
float(agent["dollars"])
|
|
188
|
+
for agent in costs(state_dir, model_hint).values()
|
|
189
|
+
),
|
|
190
|
+
4,
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def write_usage_row(
|
|
196
|
+
path: Path,
|
|
197
|
+
usage: dict[str, Any],
|
|
198
|
+
model: str,
|
|
199
|
+
at: str,
|
|
200
|
+
extra: dict[str, Any] | None = None,
|
|
201
|
+
) -> None:
|
|
202
|
+
"""
|
|
203
|
+
Append one usage row in the simple layout, for runtimes that write their own.
|
|
204
|
+
|
|
205
|
+
Parameters
|
|
206
|
+
----------
|
|
207
|
+
path : Path
|
|
208
|
+
``<state>/<agent>/usage.jsonl``; parents are created.
|
|
209
|
+
usage : Dict[str, Any]
|
|
210
|
+
Token counts in either naming.
|
|
211
|
+
model : str
|
|
212
|
+
The model billed.
|
|
213
|
+
at : str
|
|
214
|
+
An ISO timestamp.
|
|
215
|
+
extra : Optional[Dict[str, Any]]
|
|
216
|
+
Any other fields to keep beside the row (a turn number, a task id).
|
|
217
|
+
"""
|
|
218
|
+
normalized = normalize_usage(usage) or {k: 0 for k in pricing.USAGE_KEYS}
|
|
219
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
220
|
+
row = {"at": at, "model": model, "usage": normalized, **(extra or {})}
|
|
221
|
+
with path.open("a") as handle:
|
|
222
|
+
handle.write(json.dumps(row) + "\n")
|
marcus_toolkit/views.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Who was shown a candidate, and who declined it.
|
|
3
|
+
|
|
4
|
+
Evaporation counts views rather than seconds (Larry, 2026-11-06, "views,
|
|
5
|
+
k=3"), and a view alone is not a decline: a decline requires an opportunity.
|
|
6
|
+
An agent counts against a candidate only when, after the reply that showed
|
|
7
|
+
it, that agent had at least one further turn in which support was possible
|
|
8
|
+
and did not give it. The four cases:
|
|
9
|
+
|
|
10
|
+
* shown, then supported: support, never a decline;
|
|
11
|
+
* shown, then a turn without naming the subject: one decline;
|
|
12
|
+
* shown, then no further turn (died, or the run ended): not a decline;
|
|
13
|
+
* shown twice, then one turn: still one decline; agents are counted, not views.
|
|
14
|
+
|
|
15
|
+
The data: the agent's runtime writes a ``candidate_view`` record into its
|
|
16
|
+
``board-calls.jsonl`` for every reply that carried candidates, beside the one
|
|
17
|
+
record per board call. Views are never reported to the board, where they
|
|
18
|
+
could be mistaken for support.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from collections.abc import Iterable
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from .candidates import ABSTAIN_MARK, OBJECT_MARK
|
|
27
|
+
|
|
28
|
+
#: Calls only a deciding agent makes; an absorbed poll is not a turn.
|
|
29
|
+
OPPORTUNITY_TOOLS: set[str] = {
|
|
30
|
+
"report_task_progress",
|
|
31
|
+
"give_opinion",
|
|
32
|
+
"log_artifact",
|
|
33
|
+
"get_task_context",
|
|
34
|
+
"query_project_history",
|
|
35
|
+
"log_decision",
|
|
36
|
+
}
|
|
37
|
+
#: Calls through which support can be given.
|
|
38
|
+
SUPPORT_TOOLS: set[str] = {"report_task_progress", "give_opinion"}
|
|
39
|
+
NOT_SUPPORT_MARKS = (OBJECT_MARK, ABSTAIN_MARK)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _is_support(what: Any) -> bool:
|
|
43
|
+
said = str(what or "").strip().upper()
|
|
44
|
+
return not any(said.startswith(mark) for mark in NOT_SUPPORT_MARKS)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def tally(
|
|
48
|
+
calls: Iterable[dict[str, Any]], subjects: Iterable[str] | None = None
|
|
49
|
+
) -> dict[str, dict[str, Any]]:
|
|
50
|
+
"""
|
|
51
|
+
Compute, per candidate subject, who was shown it and who declined it.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
calls : Iterable[Dict[str, Any]]
|
|
56
|
+
Board-call records in time order (``marcus_toolkit.measure.read_calls``).
|
|
57
|
+
subjects : Optional[Iterable[str]]
|
|
58
|
+
Only these subjects; ``None`` tallies every subject seen.
|
|
59
|
+
|
|
60
|
+
Returns
|
|
61
|
+
-------
|
|
62
|
+
Dict[str, Dict[str, Any]]
|
|
63
|
+
``{subject: {"shown_to", "declined_by", "supported_by", "awaiting_turn"}}``,
|
|
64
|
+
each a sorted list of agents.
|
|
65
|
+
"""
|
|
66
|
+
wanted = set(subjects) if subjects is not None else None
|
|
67
|
+
first_view: dict[str, dict[str, str]] = {}
|
|
68
|
+
supported: dict[str, set[str]] = {}
|
|
69
|
+
turns: dict[str, list[str]] = {}
|
|
70
|
+
|
|
71
|
+
for call in calls:
|
|
72
|
+
agent = str(call.get("agent") or "")
|
|
73
|
+
tool = str(call.get("tool") or "")
|
|
74
|
+
at = str(call.get("at") or "")
|
|
75
|
+
args = call.get("args") or {}
|
|
76
|
+
if not agent or not at:
|
|
77
|
+
continue
|
|
78
|
+
if tool in OPPORTUNITY_TOOLS:
|
|
79
|
+
turns.setdefault(agent, []).append(at)
|
|
80
|
+
if tool == "candidate_view":
|
|
81
|
+
for subject in args.get("subjects") or []:
|
|
82
|
+
subject = str(subject)
|
|
83
|
+
if wanted is not None and subject not in wanted:
|
|
84
|
+
continue
|
|
85
|
+
first_view.setdefault(subject, {}).setdefault(agent, at)
|
|
86
|
+
elif tool in SUPPORT_TOOLS:
|
|
87
|
+
subject = str(args.get("subject") or "")
|
|
88
|
+
if (
|
|
89
|
+
subject
|
|
90
|
+
and (wanted is None or subject in wanted)
|
|
91
|
+
and _is_support(args.get("what"))
|
|
92
|
+
):
|
|
93
|
+
supported.setdefault(subject, set()).add(agent)
|
|
94
|
+
|
|
95
|
+
out: dict[str, dict[str, Any]] = {}
|
|
96
|
+
for subject in sorted(set(first_view) | set(wanted or ())):
|
|
97
|
+
seen = first_view.get(subject, {})
|
|
98
|
+
supporters = supported.get(subject, set())
|
|
99
|
+
declined: set[str] = set()
|
|
100
|
+
awaiting: set[str] = set()
|
|
101
|
+
for agent, view_at in seen.items():
|
|
102
|
+
if agent in supporters:
|
|
103
|
+
continue
|
|
104
|
+
if any(t > view_at for t in turns.get(agent, [])):
|
|
105
|
+
declined.add(agent)
|
|
106
|
+
else:
|
|
107
|
+
awaiting.add(agent)
|
|
108
|
+
out[subject] = {
|
|
109
|
+
"shown_to": sorted(seen),
|
|
110
|
+
"declined_by": sorted(declined),
|
|
111
|
+
"supported_by": sorted(supporters & set(seen)),
|
|
112
|
+
"awaiting_turn": sorted(awaiting),
|
|
113
|
+
}
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def candidate_views(
|
|
118
|
+
calls: Iterable[dict[str, Any]], before: dict[str, str] | None = None
|
|
119
|
+
) -> dict[str, list[str]]:
|
|
120
|
+
"""
|
|
121
|
+
Count, per candidate subject, the agents that were shown it while it was open.
|
|
122
|
+
|
|
123
|
+
Parameters
|
|
124
|
+
----------
|
|
125
|
+
calls : Iterable[Dict[str, Any]]
|
|
126
|
+
Board-call records.
|
|
127
|
+
before : Optional[Dict[str, str]]
|
|
128
|
+
Subject to its close time; a view at or after the close is a view of
|
|
129
|
+
a stale marker and does not count. A subject absent here is open.
|
|
130
|
+
|
|
131
|
+
Returns
|
|
132
|
+
-------
|
|
133
|
+
Dict[str, List[str]]
|
|
134
|
+
Subject to the sorted agents that saw it in time; subjects nobody saw
|
|
135
|
+
in time are absent.
|
|
136
|
+
"""
|
|
137
|
+
seen: dict[str, set[str]] = {}
|
|
138
|
+
for call in calls:
|
|
139
|
+
if call.get("tool") != "candidate_view":
|
|
140
|
+
continue
|
|
141
|
+
agent = str(call.get("agent") or "")
|
|
142
|
+
at = str(call.get("at") or "")
|
|
143
|
+
for subject in (call.get("args") or {}).get("subjects") or []:
|
|
144
|
+
subject = str(subject)
|
|
145
|
+
closed = (before or {}).get(subject)
|
|
146
|
+
if closed and at >= closed:
|
|
147
|
+
continue
|
|
148
|
+
if agent:
|
|
149
|
+
seen.setdefault(subject, set()).add(agent)
|
|
150
|
+
return {s: sorted(a) for s, a in seen.items() if a}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The cost watchdog: stop a run the moment its spend reaches the approved line.
|
|
3
|
+
|
|
4
|
+
Lifted from the Minecraft plumbing's ``cost_watchdog.py`` (issue #815 there),
|
|
5
|
+
with the defect the numerical verifier's run found fixed: the stop line is
|
|
6
|
+
read fresh on every poll when it comes from a file, so a raised cap takes
|
|
7
|
+
effect without a restart (``haiku-run-v2/REPORT.md``, section 8, item 4).
|
|
8
|
+
|
|
9
|
+
The comparison is ``>=``: a stop line is the ceiling the human approved, and
|
|
10
|
+
a run that has reached it has reached it. The watchdog returns on the poll
|
|
11
|
+
that breaches and never polls again; the run it watched is expected to be
|
|
12
|
+
gone.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import subprocess # nosec B404 - stopping our own run's processes, fixed argv, no shell
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from collections.abc import Callable, Sequence
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from .usage import total_dollars
|
|
26
|
+
|
|
27
|
+
StopLine = float | Callable[[], float]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class PollResult:
|
|
32
|
+
"""What one poll found and did."""
|
|
33
|
+
|
|
34
|
+
dollars: float
|
|
35
|
+
stop_line: float
|
|
36
|
+
killed: bool
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class StopLineFile:
|
|
40
|
+
"""
|
|
41
|
+
A stop line read from a file on every poll.
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
path : Path
|
|
46
|
+
A file holding one number. A missing or unreadable file keeps the
|
|
47
|
+
last value read, and the first read must succeed.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, path: Path) -> None:
|
|
51
|
+
self.path = path
|
|
52
|
+
self._last = float(path.read_text().strip())
|
|
53
|
+
|
|
54
|
+
def __call__(self) -> float:
|
|
55
|
+
"""Return the stop line, re-read from the file."""
|
|
56
|
+
try:
|
|
57
|
+
self._last = float(self.path.read_text().strip())
|
|
58
|
+
except (OSError, ValueError):
|
|
59
|
+
pass
|
|
60
|
+
return self._last
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _line(stop_line: StopLine) -> float:
|
|
64
|
+
return float(stop_line()) if callable(stop_line) else float(stop_line)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def watch_once(
|
|
68
|
+
state_dir: Path,
|
|
69
|
+
stop_line: StopLine,
|
|
70
|
+
kill: Callable[[], None],
|
|
71
|
+
model_hint: str = "sonnet",
|
|
72
|
+
) -> PollResult:
|
|
73
|
+
"""
|
|
74
|
+
Read accumulated cost once and kill the run if it has reached the stop line.
|
|
75
|
+
|
|
76
|
+
Parameters
|
|
77
|
+
----------
|
|
78
|
+
state_dir : Path
|
|
79
|
+
The run's state directory.
|
|
80
|
+
stop_line : float or callable
|
|
81
|
+
The approved cap, or a callable that returns it fresh.
|
|
82
|
+
kill : Callable[[], None]
|
|
83
|
+
Called exactly once, synchronously, on breach.
|
|
84
|
+
model_hint : str
|
|
85
|
+
The price list for rows that do not name their model.
|
|
86
|
+
|
|
87
|
+
Returns
|
|
88
|
+
-------
|
|
89
|
+
PollResult
|
|
90
|
+
"""
|
|
91
|
+
line = _line(stop_line)
|
|
92
|
+
dollars = total_dollars(state_dir, model_hint)
|
|
93
|
+
breached = dollars >= line
|
|
94
|
+
if breached:
|
|
95
|
+
kill()
|
|
96
|
+
return PollResult(dollars=dollars, stop_line=line, killed=breached)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def watch(
|
|
100
|
+
state_dir: Path,
|
|
101
|
+
stop_line: StopLine,
|
|
102
|
+
kill: Callable[[], None],
|
|
103
|
+
poll_seconds: float = 30.0,
|
|
104
|
+
max_polls: int | None = None,
|
|
105
|
+
model_hint: str = "sonnet",
|
|
106
|
+
on_poll: Callable[[PollResult], None] | None = None,
|
|
107
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
108
|
+
) -> PollResult | None:
|
|
109
|
+
"""
|
|
110
|
+
Poll until the run is killed or ``max_polls`` is exhausted.
|
|
111
|
+
|
|
112
|
+
Parameters
|
|
113
|
+
----------
|
|
114
|
+
state_dir : Path
|
|
115
|
+
The run's state directory.
|
|
116
|
+
stop_line : float or callable
|
|
117
|
+
The approved cap, or a callable that returns it fresh each poll.
|
|
118
|
+
kill : Callable[[], None]
|
|
119
|
+
The stop action.
|
|
120
|
+
poll_seconds : float
|
|
121
|
+
Sleep between polls.
|
|
122
|
+
max_polls : Optional[int]
|
|
123
|
+
Stop after this many polls without a breach; ``None`` is unbounded.
|
|
124
|
+
model_hint : str
|
|
125
|
+
The price list for rows that do not name their model.
|
|
126
|
+
on_poll : Optional[Callable[[PollResult], None]]
|
|
127
|
+
Called after every poll, for logging.
|
|
128
|
+
sleep : Callable[[float], None]
|
|
129
|
+
The sleep function; tests pass a stub.
|
|
130
|
+
|
|
131
|
+
Returns
|
|
132
|
+
-------
|
|
133
|
+
Optional[PollResult]
|
|
134
|
+
The last poll, or ``None`` if none ran.
|
|
135
|
+
"""
|
|
136
|
+
polls = 0
|
|
137
|
+
last: PollResult | None = None
|
|
138
|
+
while max_polls is None or polls < max_polls:
|
|
139
|
+
last = watch_once(state_dir, stop_line, kill, model_hint)
|
|
140
|
+
if on_poll:
|
|
141
|
+
on_poll(last)
|
|
142
|
+
polls += 1
|
|
143
|
+
if last.killed:
|
|
144
|
+
return last
|
|
145
|
+
if max_polls is not None and polls >= max_polls:
|
|
146
|
+
return last
|
|
147
|
+
sleep(poll_seconds)
|
|
148
|
+
return last
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def kill_action(
|
|
152
|
+
pid_files: Sequence[Path] = (), docker_name_pattern: str | None = None
|
|
153
|
+
) -> Callable[[], None]:
|
|
154
|
+
"""
|
|
155
|
+
Build the stop action: stop matching containers, then kill each pid in the files.
|
|
156
|
+
|
|
157
|
+
Each step is wrapped on its own, so one failing half does not block the
|
|
158
|
+
other: a half-stopped run still beats an unstopped one. Containers go
|
|
159
|
+
first, because they are what spends.
|
|
160
|
+
|
|
161
|
+
Parameters
|
|
162
|
+
----------
|
|
163
|
+
pid_files : Sequence[Path]
|
|
164
|
+
Files holding one pid each (the server under test, the agents).
|
|
165
|
+
docker_name_pattern : Optional[str]
|
|
166
|
+
A ``docker ps --filter name=`` substring for the run's containers.
|
|
167
|
+
|
|
168
|
+
Returns
|
|
169
|
+
-------
|
|
170
|
+
Callable[[], None]
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def _kill() -> None:
|
|
174
|
+
if docker_name_pattern:
|
|
175
|
+
try:
|
|
176
|
+
names = subprocess.run( # nosec B603 B607
|
|
177
|
+
[
|
|
178
|
+
"docker",
|
|
179
|
+
"ps",
|
|
180
|
+
"--filter",
|
|
181
|
+
f"name={docker_name_pattern}",
|
|
182
|
+
"--format",
|
|
183
|
+
"{{.Names}}",
|
|
184
|
+
],
|
|
185
|
+
capture_output=True,
|
|
186
|
+
text=True,
|
|
187
|
+
timeout=10,
|
|
188
|
+
).stdout.split()
|
|
189
|
+
for name in names:
|
|
190
|
+
subprocess.run(
|
|
191
|
+
["docker", "stop", "-t", "5", name],
|
|
192
|
+
capture_output=True,
|
|
193
|
+
timeout=15,
|
|
194
|
+
) # nosec B603 B607
|
|
195
|
+
except Exception as error: # noqa: BLE001
|
|
196
|
+
print(f"[watchdog] docker stop failed: {error}", file=sys.stderr)
|
|
197
|
+
for pid_file in pid_files:
|
|
198
|
+
try:
|
|
199
|
+
pid = int(Path(pid_file).read_text().strip())
|
|
200
|
+
subprocess.run(
|
|
201
|
+
["kill", str(pid)], capture_output=True, timeout=5
|
|
202
|
+
) # nosec B603 B607
|
|
203
|
+
except Exception as error: # noqa: BLE001
|
|
204
|
+
print(
|
|
205
|
+
f"[watchdog] kill from {pid_file} failed: {error}", file=sys.stderr
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
return _kill
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def main(argv: list[str] | None = None) -> int:
|
|
212
|
+
"""CLI entry point: the background watchdog a run script starts."""
|
|
213
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
214
|
+
parser.add_argument("--state", required=True, help="the run's state directory")
|
|
215
|
+
group = parser.add_mutually_exclusive_group(required=True)
|
|
216
|
+
group.add_argument("--stop-line", type=float, help="approved hard dollar cap")
|
|
217
|
+
group.add_argument(
|
|
218
|
+
"--stop-line-file", help="a file holding the cap, re-read every poll"
|
|
219
|
+
)
|
|
220
|
+
parser.add_argument("--poll-seconds", type=float, default=30.0)
|
|
221
|
+
parser.add_argument(
|
|
222
|
+
"--model", default="sonnet", help="price list for rows that do not name a model"
|
|
223
|
+
)
|
|
224
|
+
parser.add_argument(
|
|
225
|
+
"--pid-file",
|
|
226
|
+
action="append",
|
|
227
|
+
default=[],
|
|
228
|
+
help="a file holding a pid to kill on breach (repeatable)",
|
|
229
|
+
)
|
|
230
|
+
parser.add_argument("--docker-name-pattern", default=None)
|
|
231
|
+
parser.add_argument("--max-minutes", type=float, default=None)
|
|
232
|
+
args = parser.parse_args(argv)
|
|
233
|
+
|
|
234
|
+
stop_line: StopLine = (
|
|
235
|
+
StopLineFile(Path(args.stop_line_file))
|
|
236
|
+
if args.stop_line_file
|
|
237
|
+
else float(args.stop_line)
|
|
238
|
+
)
|
|
239
|
+
max_polls = (
|
|
240
|
+
int(args.max_minutes * 60 / args.poll_seconds) if args.max_minutes else None
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def log(result: PollResult) -> None:
|
|
244
|
+
tag = "BREACH -- stopping the run" if result.killed else "ok"
|
|
245
|
+
print(
|
|
246
|
+
f"[watchdog] ${result.dollars:.2f} of ${result.stop_line:.2f}"
|
|
247
|
+
f" stop line -- {tag}",
|
|
248
|
+
flush=True,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
watch(
|
|
252
|
+
Path(args.state),
|
|
253
|
+
stop_line,
|
|
254
|
+
kill_action([Path(p) for p in args.pid_file], args.docker_name_pattern),
|
|
255
|
+
poll_seconds=args.poll_seconds,
|
|
256
|
+
max_polls=max_polls,
|
|
257
|
+
model_hint=args.model,
|
|
258
|
+
on_poll=log,
|
|
259
|
+
)
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
sys.exit(main())
|