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.
Files changed (56) hide show
  1. syncmill/__init__.py +11 -0
  2. syncmill/agents.py +2064 -0
  3. syncmill/artifact_io.py +201 -0
  4. syncmill/artifacts.py +1946 -0
  5. syncmill/board.py +2060 -0
  6. syncmill/breaker.py +327 -0
  7. syncmill/cli.py +4986 -0
  8. syncmill/command_errors.py +32 -0
  9. syncmill/config.py +1118 -0
  10. syncmill/dashboard.py +935 -0
  11. syncmill/gates.py +179 -0
  12. syncmill/gateway.py +234 -0
  13. syncmill/git_env.py +99 -0
  14. syncmill/isolated_session.py +431 -0
  15. syncmill/matcher.py +127 -0
  16. syncmill/memory.py +1067 -0
  17. syncmill/memory_backend.py +1907 -0
  18. syncmill/memory_migration.py +962 -0
  19. syncmill/model_routing.py +482 -0
  20. syncmill/models.py +244 -0
  21. syncmill/monitor.py +775 -0
  22. syncmill/phoenix_exporter.py +339 -0
  23. syncmill/planning.py +512 -0
  24. syncmill/platform_support.py +14 -0
  25. syncmill/preflight.py +1129 -0
  26. syncmill/project.py +1067 -0
  27. syncmill/report.py +502 -0
  28. syncmill/review_candidates.py +107 -0
  29. syncmill/room_compare.py +2380 -0
  30. syncmill/room_raw.py +484 -0
  31. syncmill/room_viewer.py +604 -0
  32. syncmill/rooms.py +4044 -0
  33. syncmill/session.py +444 -0
  34. syncmill/strategies/__init__.py +82 -0
  35. syncmill/strategies/base.py +910 -0
  36. syncmill/strategies/compete.py +233 -0
  37. syncmill/strategies/council.py +429 -0
  38. syncmill/strategies/decompose.py +570 -0
  39. syncmill/strategies/pipeline.py +221 -0
  40. syncmill/strategies/route.py +130 -0
  41. syncmill/supervisor.py +1423 -0
  42. syncmill/teamfile.py +571 -0
  43. syncmill/telemetry_hook.py +231 -0
  44. syncmill/terminal.py +245 -0
  45. syncmill/tracer.py +548 -0
  46. syncmill/tui.py +3537 -0
  47. syncmill/tui_collaboration.py +499 -0
  48. syncmill/tui_jobs.py +1407 -0
  49. syncmill/tui_render.py +156 -0
  50. syncmill/workflow.py +3746 -0
  51. syncmill/worktree.py +1045 -0
  52. syncmill-0.2.0.dist-info/METADATA +853 -0
  53. syncmill-0.2.0.dist-info/RECORD +56 -0
  54. syncmill-0.2.0.dist-info/WHEEL +4 -0
  55. syncmill-0.2.0.dist-info/entry_points.txt +2 -0
  56. 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
+ )