syncmill 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- syncmill/__init__.py +11 -0
- syncmill/agents.py +2064 -0
- syncmill/artifact_io.py +201 -0
- syncmill/artifacts.py +1946 -0
- syncmill/board.py +2060 -0
- syncmill/breaker.py +327 -0
- syncmill/cli.py +4986 -0
- syncmill/command_errors.py +32 -0
- syncmill/config.py +1118 -0
- syncmill/dashboard.py +935 -0
- syncmill/gates.py +179 -0
- syncmill/gateway.py +234 -0
- syncmill/git_env.py +99 -0
- syncmill/isolated_session.py +431 -0
- syncmill/matcher.py +127 -0
- syncmill/memory.py +1067 -0
- syncmill/memory_backend.py +1907 -0
- syncmill/memory_migration.py +962 -0
- syncmill/model_routing.py +482 -0
- syncmill/models.py +244 -0
- syncmill/monitor.py +775 -0
- syncmill/phoenix_exporter.py +339 -0
- syncmill/planning.py +512 -0
- syncmill/platform_support.py +14 -0
- syncmill/preflight.py +1129 -0
- syncmill/project.py +1067 -0
- syncmill/report.py +502 -0
- syncmill/review_candidates.py +107 -0
- syncmill/room_compare.py +2380 -0
- syncmill/room_raw.py +484 -0
- syncmill/room_viewer.py +604 -0
- syncmill/rooms.py +4044 -0
- syncmill/session.py +444 -0
- syncmill/strategies/__init__.py +82 -0
- syncmill/strategies/base.py +910 -0
- syncmill/strategies/compete.py +233 -0
- syncmill/strategies/council.py +429 -0
- syncmill/strategies/decompose.py +570 -0
- syncmill/strategies/pipeline.py +221 -0
- syncmill/strategies/route.py +130 -0
- syncmill/supervisor.py +1423 -0
- syncmill/teamfile.py +571 -0
- syncmill/telemetry_hook.py +231 -0
- syncmill/terminal.py +245 -0
- syncmill/tracer.py +548 -0
- syncmill/tui.py +3537 -0
- syncmill/tui_collaboration.py +499 -0
- syncmill/tui_jobs.py +1407 -0
- syncmill/tui_render.py +156 -0
- syncmill/workflow.py +3746 -0
- syncmill/worktree.py +1045 -0
- syncmill-0.2.0.dist-info/METADATA +853 -0
- syncmill-0.2.0.dist-info/RECORD +56 -0
- syncmill-0.2.0.dist-info/WHEEL +4 -0
- syncmill-0.2.0.dist-info/entry_points.txt +2 -0
- syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
syncmill/breaker.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Circuit breaker (#8) — reactive back-off from repeatedly-failing agents.
|
|
2
|
+
|
|
3
|
+
`RouteStrategy` already falls back on a *single* failure, but keeps no memory of
|
|
4
|
+
*repeated* failures across runs. The breaker adds that memory: after an agent
|
|
5
|
+
fails ``failure_threshold`` times in a row its circuit OPENS and it's skipped for
|
|
6
|
+
``cooldown_seconds``; once the cooldown elapses the circuit goes HALF-OPEN (one
|
|
7
|
+
trial allowed); any success CLOSES it (resets the count).
|
|
8
|
+
|
|
9
|
+
REACTIVE, never a liveness probe — honoring the "no ``health_check`` on agents"
|
|
10
|
+
invariant. The breaker never calls an agent to test it. Skipping an OPEN agent is
|
|
11
|
+
"remember it kept failing when we actually ran it," not "ping it to see if it's
|
|
12
|
+
alive." ``allows()`` decides purely from *recorded past outcomes*; failures are
|
|
13
|
+
observed exactly as before (run, then fall back on failure).
|
|
14
|
+
|
|
15
|
+
Like quality gates, the breaker is plumbed through the **strategy layer only** —
|
|
16
|
+
agents stay pure ``execute(prompt, workdir, timeout)`` functions and never see it.
|
|
17
|
+
The Supervisor builds it from config and injects it; each strategy filters
|
|
18
|
+
OPEN agents out of its run and records each agent's raw execution outcome.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import math
|
|
26
|
+
import os
|
|
27
|
+
import tempfile
|
|
28
|
+
import time
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Callable
|
|
32
|
+
|
|
33
|
+
from syncmill.config import BreakerConfig
|
|
34
|
+
from syncmill.models import TaskStatus
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class _AgentState:
|
|
41
|
+
"""Per-agent breaker state. ``failures`` is the consecutive-failure count;
|
|
42
|
+
``opened_at`` is the wall-clock of the most recent failure once the threshold
|
|
43
|
+
was crossed (``None`` while the circuit is closed)."""
|
|
44
|
+
|
|
45
|
+
failures: int = 0
|
|
46
|
+
opened_at: float | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CircuitBreaker:
|
|
50
|
+
"""Per-agent reactive back-off with persistent, across-run state.
|
|
51
|
+
|
|
52
|
+
The three states are DERIVED from ``(failures, opened_at)``, never stored:
|
|
53
|
+
|
|
54
|
+
* CLOSED — ``failures < threshold`` → allowed
|
|
55
|
+
* OPEN — ``failures >= threshold`` and within cooldown → skipped
|
|
56
|
+
* HALF_OPEN — ``failures >= threshold`` and cooldown elapsed → allowed (one trial)
|
|
57
|
+
|
|
58
|
+
Transitions happen only via ``record`` (``allows`` is side-effect-free)::
|
|
59
|
+
|
|
60
|
+
CLOSED --record(FAILED) × threshold--> OPEN
|
|
61
|
+
OPEN --cooldown elapses (no action)--> HALF_OPEN
|
|
62
|
+
HALF_OPEN --record(COMPLETED)--> CLOSED (failures=0, opened_at=None)
|
|
63
|
+
HALF_OPEN --record(FAILED)----> OPEN (opened_at=now → cooldown restarts)
|
|
64
|
+
|
|
65
|
+
**Persistence is what makes "N failures in a row" meaningful.** A one-shot
|
|
66
|
+
``syncmill run`` is a fresh process that runs an agent at most once, so the
|
|
67
|
+
count only accumulates *across* runs. State is a JSON map
|
|
68
|
+
``{agent_id: {failures, opened_at}}`` at ``state_path``, loaded on
|
|
69
|
+
construction and rewritten atomically (unique temp file + ``os.replace``)
|
|
70
|
+
after each ``record`` — a crash can leak a temp file but never a
|
|
71
|
+
half-written ``breaker.json``. A long-lived process (``board run --loop``
|
|
72
|
+
holds one Supervisor — and one breaker — for the whole drain) would
|
|
73
|
+
otherwise diverge from other processes' writes indefinitely, so ``allows``
|
|
74
|
+
first does a best-effort mtime-guarded reload: when the file changed on
|
|
75
|
+
disk since this instance last read or wrote it, the in-memory state is
|
|
76
|
+
replaced from disk (still last-writer-wins; a stat/read failure keeps the
|
|
77
|
+
current state).
|
|
78
|
+
|
|
79
|
+
**Best-effort / advisory**, like ``SyncmillMemory``: every file error is
|
|
80
|
+
caught, sets ``degraded``/``last_error``, and never raises — a broken state
|
|
81
|
+
file must never abort a task. While degraded, ``allows`` returns ``True`` for
|
|
82
|
+
everyone (permissive: don't block agents because the breaker's own bookkeeping
|
|
83
|
+
broke). ``degraded`` reflects the LATEST persistence outcome, not a one-way
|
|
84
|
+
latch: a later successful load or save clears it, so a transient failure at
|
|
85
|
+
construction can't leave a long-lived process permissive forever.
|
|
86
|
+
|
|
87
|
+
Uses WALL-CLOCK ``time.time`` (not ``monotonic``) so a cooldown survives
|
|
88
|
+
process restarts; ``now`` is injectable for tests. Concurrency across
|
|
89
|
+
simultaneous syncmill processes is last-writer-wins — the atomic write
|
|
90
|
+
prevents a corrupt/torn file, not a lost increment, and an occasional lost
|
|
91
|
+
increment for an advisory back-off self-corrects on the next run.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
failure_threshold: int,
|
|
97
|
+
cooldown_seconds: int,
|
|
98
|
+
state_path: str,
|
|
99
|
+
*,
|
|
100
|
+
now: Callable[[], float] = time.time,
|
|
101
|
+
):
|
|
102
|
+
self._threshold = max(1, failure_threshold)
|
|
103
|
+
self._cooldown = max(0, cooldown_seconds)
|
|
104
|
+
# expanduser here: the config field is a raw string (so a `~` set via
|
|
105
|
+
# SYNCMILL_BREAKER__STATE_PATH wouldn't be expanded at config-build time).
|
|
106
|
+
self._path = Path(state_path).expanduser()
|
|
107
|
+
self._now = now
|
|
108
|
+
self.degraded = False
|
|
109
|
+
self.last_error: str | None = None
|
|
110
|
+
# Marker BEFORE read (the _reload_if_stale ordering): if another
|
|
111
|
+
# process replaces the file between the two, we pair a NEWER read with
|
|
112
|
+
# an older marker and merely reload once more — stat-after-read would
|
|
113
|
+
# pair the OLD state with the new file's marker and mask that writer's
|
|
114
|
+
# state from every later `allows` until the next write.
|
|
115
|
+
self._mtime_ns: int | None = self._stat_mtime_ns()
|
|
116
|
+
self._state: dict[str, _AgentState] = self._load()
|
|
117
|
+
|
|
118
|
+
# ── decision (read-only) ──────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
def allows(self, agent_id: str) -> bool:
|
|
121
|
+
"""True if the agent may run now (CLOSED or HALF-OPEN); False if OPEN.
|
|
122
|
+
|
|
123
|
+
Never records an outcome — safe to call repeatedly. In the one-shot CLI
|
|
124
|
+
an agent runs at most once per invocation, so a single ``allows`` +
|
|
125
|
+
later ``record`` yields the classic "one trial per cooldown" without
|
|
126
|
+
``allows`` recording anything. It does refresh: a best-effort reload
|
|
127
|
+
picks up other processes' writes when the state file changed on disk
|
|
128
|
+
(the ``board run --loop`` long-lived case). While ``degraded`` it
|
|
129
|
+
returns ``True`` (don't block agents because the breaker's own state
|
|
130
|
+
file is unreadable).
|
|
131
|
+
"""
|
|
132
|
+
self._reload_if_stale()
|
|
133
|
+
if self.degraded:
|
|
134
|
+
return True
|
|
135
|
+
st = self._state.get(agent_id)
|
|
136
|
+
if st is None or st.failures < self._threshold:
|
|
137
|
+
return True # CLOSED
|
|
138
|
+
if st.opened_at is None:
|
|
139
|
+
# Defensive: threshold reached but no timestamp (e.g. hand-edited
|
|
140
|
+
# state) — don't trap the agent OPEN forever with no way to recover.
|
|
141
|
+
return True
|
|
142
|
+
# Clamp a backward wall-clock jump to 0 so it can't make `elapsed`
|
|
143
|
+
# negative and wedge the agent OPEN.
|
|
144
|
+
elapsed = max(0.0, self._now() - st.opened_at)
|
|
145
|
+
return elapsed >= self._cooldown # HALF_OPEN (allow) vs OPEN (skip)
|
|
146
|
+
|
|
147
|
+
def is_half_open(self, agent_id: str) -> bool:
|
|
148
|
+
"""True when this agent is allowed exactly ONE trial, not free running.
|
|
149
|
+
|
|
150
|
+
``allows`` deliberately cannot express this — it answers a single yes/no
|
|
151
|
+
and records nothing, which is right for a caller that runs an agent at
|
|
152
|
+
most once. A caller that may dispatch the SAME agent several times at
|
|
153
|
+
once (a workflow batch) has to know the difference, or it spends one
|
|
154
|
+
cooldown's single trial on every node in the batch simultaneously.
|
|
155
|
+
"""
|
|
156
|
+
self._reload_if_stale()
|
|
157
|
+
if self.degraded:
|
|
158
|
+
return False
|
|
159
|
+
st = self._state.get(agent_id)
|
|
160
|
+
return (
|
|
161
|
+
st is not None
|
|
162
|
+
and st.failures >= self._threshold
|
|
163
|
+
and st.opened_at is not None
|
|
164
|
+
and max(0.0, self._now() - st.opened_at) >= self._cooldown
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# ── outcome recording (mutates + persists) ────────────────────────────
|
|
168
|
+
|
|
169
|
+
def record(self, agent_id: str, status: TaskStatus) -> None:
|
|
170
|
+
"""Record an agent's RAW execution outcome and persist.
|
|
171
|
+
|
|
172
|
+
``COMPLETED`` closes the circuit (resets the count); anything else counts
|
|
173
|
+
as a failure (increments, and once the threshold is crossed (re)stamps
|
|
174
|
+
``opened_at`` so each failure restarts the cooldown). Pass the agent's
|
|
175
|
+
*execution* status — taken right after the agent runs, BEFORE any quality
|
|
176
|
+
gate — so a gate rejection (a verdict on a *working* agent's output) does
|
|
177
|
+
not trip the breaker.
|
|
178
|
+
"""
|
|
179
|
+
if status == TaskStatus.COMPLETED:
|
|
180
|
+
self._state[agent_id] = _AgentState(failures=0, opened_at=None)
|
|
181
|
+
else:
|
|
182
|
+
st = self._state.setdefault(agent_id, _AgentState())
|
|
183
|
+
st.failures += 1
|
|
184
|
+
if st.failures >= self._threshold:
|
|
185
|
+
st.opened_at = self._now() # (re)start cooldown from the latest failure
|
|
186
|
+
self._save()
|
|
187
|
+
|
|
188
|
+
# ── persistence (best-effort) ─────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
def _stat_mtime_ns(self) -> int | None:
|
|
191
|
+
try:
|
|
192
|
+
return self._path.stat().st_mtime_ns
|
|
193
|
+
except OSError:
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
def _reload_if_stale(self) -> None:
|
|
197
|
+
"""Replace in-memory state from disk when another process wrote it.
|
|
198
|
+
|
|
199
|
+
Best-effort: a stat failure (file missing, transient I/O) keeps the
|
|
200
|
+
current state and never degrades — only an actual re-read of a broken
|
|
201
|
+
file does (inside ``_load``, same as construction). mtime_ns guards the
|
|
202
|
+
common no-change case down to one ``stat`` per ``allows``.
|
|
203
|
+
"""
|
|
204
|
+
mtime = self._stat_mtime_ns()
|
|
205
|
+
if mtime is None or mtime == self._mtime_ns:
|
|
206
|
+
return
|
|
207
|
+
self._state = self._load()
|
|
208
|
+
self._mtime_ns = mtime
|
|
209
|
+
|
|
210
|
+
def _load(self) -> dict[str, _AgentState]:
|
|
211
|
+
try:
|
|
212
|
+
if not self._path.exists():
|
|
213
|
+
self._recover()
|
|
214
|
+
return {}
|
|
215
|
+
raw = json.loads(self._path.read_text())
|
|
216
|
+
except (OSError, ValueError) as exc:
|
|
217
|
+
self._degrade(f"failed to load breaker state: {exc}")
|
|
218
|
+
return {}
|
|
219
|
+
if not isinstance(raw, dict):
|
|
220
|
+
# Valid JSON but the wrong top-level shape (e.g. `[]` or `42`) is a
|
|
221
|
+
# broken state file, not an empty one — surface it (degrade) rather
|
|
222
|
+
# than silently treating it as healthy/empty.
|
|
223
|
+
self._degrade(f"breaker state is not a JSON object: {type(raw).__name__}")
|
|
224
|
+
return {}
|
|
225
|
+
state: dict[str, _AgentState] = {}
|
|
226
|
+
# Lenient per-entry parse: a single malformed agent entry is skipped, not
|
|
227
|
+
# fatal — one bad record shouldn't wipe or block all the others.
|
|
228
|
+
for agent_id, data in raw.items():
|
|
229
|
+
try:
|
|
230
|
+
# Clamp a negative count (corrupt/hand-edited) up to 0: a negative
|
|
231
|
+
# failures stays < threshold no matter how many real failures
|
|
232
|
+
# follow, silently suppressing the back-off.
|
|
233
|
+
failures = max(0, int(data["failures"]))
|
|
234
|
+
opened = data.get("opened_at")
|
|
235
|
+
opened_at = float(opened) if opened is not None else None
|
|
236
|
+
except (TypeError, ValueError, KeyError, AttributeError):
|
|
237
|
+
continue
|
|
238
|
+
if opened_at is not None and (
|
|
239
|
+
not math.isfinite(opened_at) or opened_at > self._now()
|
|
240
|
+
):
|
|
241
|
+
# A bogus persisted timestamp slips past the `opened_at is None`
|
|
242
|
+
# guard in allows() and wedges the agent OPEN forever, because
|
|
243
|
+
# `max(0, now - opened_at)` never reaches the cooldown:
|
|
244
|
+
# * NaN/Infinity — json.loads accepts these tokens, and the
|
|
245
|
+
# arithmetic with them clamps to 0.
|
|
246
|
+
# * a finite value in the FUTURE (a huge corrupt number, or one
|
|
247
|
+
# written under clock skew) — now - opened_at is negative, also
|
|
248
|
+
# clamped to 0.
|
|
249
|
+
# Normalize either to None so the defensive "no timestamp" path
|
|
250
|
+
# (allow, don't trap) applies instead. record() only ever writes a
|
|
251
|
+
# sane time.time(), so this can't reject a value we wrote.
|
|
252
|
+
opened_at = None
|
|
253
|
+
state[str(agent_id)] = _AgentState(failures=failures, opened_at=opened_at)
|
|
254
|
+
self._recover()
|
|
255
|
+
return state
|
|
256
|
+
|
|
257
|
+
def _save(self) -> None:
|
|
258
|
+
payload = {
|
|
259
|
+
aid: {"failures": st.failures, "opened_at": st.opened_at}
|
|
260
|
+
for aid, st in self._state.items()
|
|
261
|
+
}
|
|
262
|
+
try:
|
|
263
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
264
|
+
# Atomic: write a unique temp file in the SAME directory (so
|
|
265
|
+
# os.replace stays on one filesystem and is atomic), then replace.
|
|
266
|
+
fd, tmp = tempfile.mkstemp(dir=str(self._path.parent), suffix=".tmp")
|
|
267
|
+
try:
|
|
268
|
+
# fdopen takes ownership of fd; if it raises, nothing else will
|
|
269
|
+
# ever close the descriptor (the handler below only unlinks).
|
|
270
|
+
try:
|
|
271
|
+
handle = os.fdopen(fd, "w")
|
|
272
|
+
except BaseException:
|
|
273
|
+
os.close(fd)
|
|
274
|
+
raise
|
|
275
|
+
with handle as f:
|
|
276
|
+
json.dump(payload, f)
|
|
277
|
+
# The marker of the state WE wrote: os.replace preserves the
|
|
278
|
+
# temp inode's mtime, so stat the temp file BEFORE the replace.
|
|
279
|
+
# Stat'ing state_path afterwards would race a concurrent
|
|
280
|
+
# writer — its replace between ours and the stat pairs OUR
|
|
281
|
+
# in-memory state with ITS file marker, and every later
|
|
282
|
+
# `allows` skips the reload that would have picked it up.
|
|
283
|
+
written_mtime_ns = os.stat(tmp).st_mtime_ns
|
|
284
|
+
os.replace(tmp, self._path)
|
|
285
|
+
except BaseException:
|
|
286
|
+
# Don't leak the temp file if the write/replace failed.
|
|
287
|
+
try:
|
|
288
|
+
os.unlink(tmp)
|
|
289
|
+
except OSError:
|
|
290
|
+
pass
|
|
291
|
+
raise
|
|
292
|
+
except (OSError, ValueError) as exc:
|
|
293
|
+
self._degrade(f"failed to save breaker state: {exc}")
|
|
294
|
+
else:
|
|
295
|
+
# This instance is now the latest writer: remember our own mtime so
|
|
296
|
+
# _reload_if_stale doesn't re-read a file we just wrote.
|
|
297
|
+
self._mtime_ns = written_mtime_ns
|
|
298
|
+
self._recover()
|
|
299
|
+
|
|
300
|
+
def _degrade(self, msg: str) -> None:
|
|
301
|
+
self.degraded = True
|
|
302
|
+
self.last_error = msg
|
|
303
|
+
logger.warning("circuit breaker degraded: %s", msg)
|
|
304
|
+
|
|
305
|
+
def _recover(self) -> None:
|
|
306
|
+
"""Clear ``degraded`` after a successful load or save — the flag
|
|
307
|
+
reflects the LATEST persistence outcome, not a one-way latch (a
|
|
308
|
+
transient failure at construction must not leave a long-lived process
|
|
309
|
+
permissive forever)."""
|
|
310
|
+
if self.degraded:
|
|
311
|
+
logger.info("circuit breaker recovered: %s", self.last_error)
|
|
312
|
+
self.degraded = False
|
|
313
|
+
self.last_error = None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def build_breaker(config: BreakerConfig) -> CircuitBreaker | None:
|
|
317
|
+
"""The breaker described by ``config``, or ``None`` when disabled
|
|
318
|
+
(``enabled=False``, the default) — strategies skip breaker logic and behavior
|
|
319
|
+
is unchanged. Mirrors ``build_gate``: a single place that decides "configured
|
|
320
|
+
or not," so the disabled path never touches the filesystem."""
|
|
321
|
+
if not config.enabled:
|
|
322
|
+
return None
|
|
323
|
+
return CircuitBreaker(
|
|
324
|
+
failure_threshold=config.failure_threshold,
|
|
325
|
+
cooldown_seconds=config.cooldown_seconds,
|
|
326
|
+
state_path=config.state_path,
|
|
327
|
+
)
|