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
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""Project-scoped interactive sessions backed by persistent isolated worktrees."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import threading
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Iterator
|
|
12
|
+
from uuid import UUID, uuid4
|
|
13
|
+
|
|
14
|
+
from syncmill.platform_support import require_supported_platform
|
|
15
|
+
from syncmill.command_errors import command_error
|
|
16
|
+
from syncmill.artifacts import ArtifactStore
|
|
17
|
+
from syncmill.config import SyncmillConfig
|
|
18
|
+
from syncmill.git_env import (
|
|
19
|
+
GIT_CONFIG_INJECTION_ENV,
|
|
20
|
+
GIT_PATHSPEC_SEMANTICS_ENV,
|
|
21
|
+
GIT_REPOSITORY_ROUTING_ENV,
|
|
22
|
+
)
|
|
23
|
+
from syncmill.models import Task, TaskResult, TaskStatus
|
|
24
|
+
from syncmill.project import project_id
|
|
25
|
+
from syncmill.rooms import _atomic_write, _now
|
|
26
|
+
from syncmill.session import (
|
|
27
|
+
_attach_and_report,
|
|
28
|
+
_create_session,
|
|
29
|
+
_exact,
|
|
30
|
+
_tmux,
|
|
31
|
+
interactive_agents,
|
|
32
|
+
session_exists,
|
|
33
|
+
)
|
|
34
|
+
from syncmill.worktree import (
|
|
35
|
+
PatchCaptureError,
|
|
36
|
+
capture_changes,
|
|
37
|
+
Worktree,
|
|
38
|
+
WorktreePool,
|
|
39
|
+
capture_repository_snapshot,
|
|
40
|
+
cleanup_worktree,
|
|
41
|
+
repository_fingerprint,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
try: # Isolated interactive sessions are POSIX/tmux today; keep import graceful.
|
|
45
|
+
import fcntl
|
|
46
|
+
except ImportError: # pragma: no cover - non-POSIX session commands already fail
|
|
47
|
+
fcntl = None # type: ignore[assignment]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_LOCKS_GUARD = threading.Lock()
|
|
51
|
+
_PROCESS_LOCKS: dict[Path, threading.Lock] = {}
|
|
52
|
+
# Refcounts for _PROCESS_LOCKS so entries are evicted once the last holder
|
|
53
|
+
# releases — otherwise a long-lived TUI accumulates one Lock per session id
|
|
54
|
+
# ever touched. An entry is only popped at refcount zero, never while a
|
|
55
|
+
# waiter still holds a reference to the Lock.
|
|
56
|
+
_PROCESS_LOCK_REFS: dict[Path, int] = {}
|
|
57
|
+
# Membership set (the snapshot writer filters by name), so the numbered
|
|
58
|
+
# GIT_CONFIG_KEY_<n>/VALUE_<n> pairs can't be listed here — dropping
|
|
59
|
+
# GIT_CONFIG_COUNT is what makes them inert, since git reads the count to learn
|
|
60
|
+
# how many pairs to apply and ignores them entirely without it.
|
|
61
|
+
_ISOLATED_LAUNCH_ROUTING_ENV = (
|
|
62
|
+
GIT_REPOSITORY_ROUTING_ENV
|
|
63
|
+
| GIT_CONFIG_INJECTION_ENV
|
|
64
|
+
| GIT_PATHSPEC_SEMANTICS_ENV
|
|
65
|
+
| {
|
|
66
|
+
"PWD",
|
|
67
|
+
"OLDPWD",
|
|
68
|
+
"CDPATH",
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class IsolatedSessionError(RuntimeError):
|
|
74
|
+
"""A project-scoped isolated session operation failed."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _strict_checkpoint_patch(workdir: Path, base_commit: str) -> tuple[list[str], str]:
|
|
78
|
+
"""Capture a complete binary patch without touching the session's real index."""
|
|
79
|
+
try:
|
|
80
|
+
changes = capture_changes(workdir, base_commit)
|
|
81
|
+
except PatchCaptureError as exc:
|
|
82
|
+
raise IsolatedSessionError(str(exc)) from exc
|
|
83
|
+
return list(changes.files), changes.patch
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class IsolatedSessionStore:
|
|
87
|
+
def __init__(self, config: SyncmillConfig, repo_root: Path):
|
|
88
|
+
require_supported_platform()
|
|
89
|
+
self.config = config
|
|
90
|
+
self.repo_root = repo_root.resolve()
|
|
91
|
+
self.project_id = project_id(self.repo_root, config.project.id)
|
|
92
|
+
self.root = (
|
|
93
|
+
Path(config.runs.root).expanduser() / self.project_id / "sessions"
|
|
94
|
+
).resolve()
|
|
95
|
+
self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
96
|
+
os.chmod(self.root, 0o700)
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def _canonical_session_id(session_id: str) -> str:
|
|
100
|
+
try:
|
|
101
|
+
canonical = str(UUID(session_id))
|
|
102
|
+
except (TypeError, ValueError, AttributeError) as exc:
|
|
103
|
+
raise IsolatedSessionError(f"invalid session id: {session_id}") from exc
|
|
104
|
+
if canonical != session_id:
|
|
105
|
+
raise IsolatedSessionError(f"invalid session id: {session_id}")
|
|
106
|
+
return canonical
|
|
107
|
+
|
|
108
|
+
def _path(self, session_id: str) -> Path:
|
|
109
|
+
canonical = self._canonical_session_id(session_id)
|
|
110
|
+
return self.root / f"{canonical}.json"
|
|
111
|
+
|
|
112
|
+
@contextmanager
|
|
113
|
+
def _locked(self, session_id: str) -> Iterator[None]:
|
|
114
|
+
canonical = self._canonical_session_id(session_id)
|
|
115
|
+
lock_path = self.root / f"{canonical}.lock"
|
|
116
|
+
with _LOCKS_GUARD:
|
|
117
|
+
process_lock = _PROCESS_LOCKS.setdefault(lock_path, threading.Lock())
|
|
118
|
+
_PROCESS_LOCK_REFS[lock_path] = _PROCESS_LOCK_REFS.get(lock_path, 0) + 1
|
|
119
|
+
try:
|
|
120
|
+
with process_lock:
|
|
121
|
+
fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR, 0o600)
|
|
122
|
+
try:
|
|
123
|
+
if fcntl is not None:
|
|
124
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
125
|
+
yield
|
|
126
|
+
finally:
|
|
127
|
+
if fcntl is not None:
|
|
128
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
129
|
+
os.close(fd)
|
|
130
|
+
finally:
|
|
131
|
+
# The inner `with` released process_lock before this runs, so a
|
|
132
|
+
# zero refcount means no holder and no waiter: safe to evict.
|
|
133
|
+
with _LOCKS_GUARD:
|
|
134
|
+
remaining = _PROCESS_LOCK_REFS[lock_path] - 1
|
|
135
|
+
if remaining:
|
|
136
|
+
_PROCESS_LOCK_REFS[lock_path] = remaining
|
|
137
|
+
else:
|
|
138
|
+
del _PROCESS_LOCK_REFS[lock_path]
|
|
139
|
+
del _PROCESS_LOCKS[lock_path]
|
|
140
|
+
|
|
141
|
+
def _write(self, value: dict, *, expected_revision: int | None = None) -> None:
|
|
142
|
+
if expected_revision is not None:
|
|
143
|
+
try:
|
|
144
|
+
current = json.loads(
|
|
145
|
+
self._path(value["session_id"]).read_text(encoding="utf-8")
|
|
146
|
+
)
|
|
147
|
+
except (OSError, ValueError, TypeError) as exc:
|
|
148
|
+
raise IsolatedSessionError("session changed or disappeared") from exc
|
|
149
|
+
if int(current.get("revision", 0)) != expected_revision:
|
|
150
|
+
raise IsolatedSessionError("session changed concurrently; retry")
|
|
151
|
+
value["updated_at"] = _now()
|
|
152
|
+
_atomic_write(
|
|
153
|
+
self._path(value["session_id"]),
|
|
154
|
+
(json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode(),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def load(self, session_id: str) -> dict:
|
|
158
|
+
try:
|
|
159
|
+
value = json.loads(self._path(session_id).read_text(encoding="utf-8"))
|
|
160
|
+
except (OSError, ValueError, TypeError) as exc:
|
|
161
|
+
raise IsolatedSessionError(f"unknown or corrupt session: {session_id}") from exc
|
|
162
|
+
if Path(value.get("repo_root", "")).resolve() != self.repo_root:
|
|
163
|
+
raise IsolatedSessionError("session belongs to a different repository")
|
|
164
|
+
self._validate_paths(value)
|
|
165
|
+
return value
|
|
166
|
+
|
|
167
|
+
def _validate_paths(self, value: dict) -> None:
|
|
168
|
+
"""Bind cleanup and attachment to paths derived from this session id."""
|
|
169
|
+
try:
|
|
170
|
+
sid = str(UUID(value["session_id"]))
|
|
171
|
+
if sid != value["session_id"]:
|
|
172
|
+
raise ValueError("non-canonical session id")
|
|
173
|
+
expected_run = (
|
|
174
|
+
Path(self.config.workspace.root).expanduser().resolve()
|
|
175
|
+
/ f"session-{sid}"
|
|
176
|
+
)
|
|
177
|
+
run_root = Path(value["run_root"]).resolve()
|
|
178
|
+
control = Path(value["control_repo"]).resolve()
|
|
179
|
+
worktree = Path(value["worktree_path"]).resolve()
|
|
180
|
+
worktree.relative_to(run_root / "worktrees")
|
|
181
|
+
except (KeyError, TypeError, ValueError, OSError) as exc:
|
|
182
|
+
raise IsolatedSessionError("session metadata contains unsafe paths") from exc
|
|
183
|
+
expected_tmux = (
|
|
184
|
+
f"syncmill-p-{self.project_id[:8]}-{value.get('agent_id')}-{sid[:8]}"
|
|
185
|
+
)
|
|
186
|
+
if (
|
|
187
|
+
run_root != expected_run
|
|
188
|
+
or control != run_root / "control"
|
|
189
|
+
or value.get("tmux_name") != expected_tmux
|
|
190
|
+
):
|
|
191
|
+
raise IsolatedSessionError("session metadata contains unsafe paths")
|
|
192
|
+
|
|
193
|
+
def list(self) -> list[dict]:
|
|
194
|
+
values: list[dict] = []
|
|
195
|
+
current_fingerprint: str | None = None
|
|
196
|
+
fingerprint_loaded = False
|
|
197
|
+
fingerprint_failed = False
|
|
198
|
+
for path in self.root.glob("*.json"):
|
|
199
|
+
try:
|
|
200
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
201
|
+
if Path(value.get("repo_root", "")).resolve() != self.repo_root:
|
|
202
|
+
continue
|
|
203
|
+
self._validate_paths(value)
|
|
204
|
+
if path.resolve() != self._path(value["session_id"]).resolve():
|
|
205
|
+
continue
|
|
206
|
+
except (OSError, ValueError, TypeError, KeyError, IsolatedSessionError):
|
|
207
|
+
continue
|
|
208
|
+
name = value.get("tmux_name", "")
|
|
209
|
+
try:
|
|
210
|
+
value["live"] = bool(name and session_exists(name))
|
|
211
|
+
except FileNotFoundError:
|
|
212
|
+
# Persisted session metadata remains useful after tmux is
|
|
213
|
+
# uninstalled or disappears from PATH. Listing is read-only,
|
|
214
|
+
# so report that process as stopped instead of making the CLI
|
|
215
|
+
# and TUI unavailable. Other launch failures still surface.
|
|
216
|
+
value["live"] = False
|
|
217
|
+
value["source_stale"] = False
|
|
218
|
+
if value.get("state") == "open":
|
|
219
|
+
if not fingerprint_loaded:
|
|
220
|
+
try:
|
|
221
|
+
current_fingerprint = repository_fingerprint(self.repo_root)
|
|
222
|
+
except Exception:
|
|
223
|
+
fingerprint_failed = True
|
|
224
|
+
fingerprint_loaded = True
|
|
225
|
+
value["source_stale"] = fingerprint_failed or (
|
|
226
|
+
current_fingerprint != value.get("source_fingerprint")
|
|
227
|
+
)
|
|
228
|
+
values.append(value)
|
|
229
|
+
return sorted(values, key=lambda value: value.get("created_at", ""), reverse=True)
|
|
230
|
+
|
|
231
|
+
def create(self, agent_id: str) -> dict:
|
|
232
|
+
if agent_id not in interactive_agents():
|
|
233
|
+
raise IsolatedSessionError(f"unknown interactive agent: {agent_id}")
|
|
234
|
+
session_id = str(uuid4())
|
|
235
|
+
snapshot = capture_repository_snapshot(
|
|
236
|
+
self.repo_root,
|
|
237
|
+
f"session-{session_id}",
|
|
238
|
+
Path(self.config.workspace.root),
|
|
239
|
+
)
|
|
240
|
+
pool = WorktreePool(
|
|
241
|
+
self.repo_root, f"session-{session_id}", snapshot=snapshot
|
|
242
|
+
)
|
|
243
|
+
tmux_name: str | None = None
|
|
244
|
+
launched = False
|
|
245
|
+
try:
|
|
246
|
+
worktree = pool.acquire(agent_id)
|
|
247
|
+
tmux_name = (
|
|
248
|
+
f"syncmill-p-{self.project_id[:8]}-{agent_id}-{session_id[:8]}"
|
|
249
|
+
)
|
|
250
|
+
value = {
|
|
251
|
+
"schema_version": 1,
|
|
252
|
+
"session_id": session_id,
|
|
253
|
+
"project_id": self.project_id,
|
|
254
|
+
"repo_root": str(self.repo_root),
|
|
255
|
+
"agent_id": agent_id,
|
|
256
|
+
"state": "creating",
|
|
257
|
+
"tmux_name": tmux_name,
|
|
258
|
+
"run_root": str(snapshot.run_root),
|
|
259
|
+
"control_repo": str(snapshot.control_repo),
|
|
260
|
+
"worktree_path": str(worktree.path),
|
|
261
|
+
"worktree_branch": worktree.branch,
|
|
262
|
+
"base_commit": worktree.base_commit,
|
|
263
|
+
"source_fingerprint": snapshot.source_fingerprint,
|
|
264
|
+
"source_head": snapshot.source_head,
|
|
265
|
+
"checkpoints": [],
|
|
266
|
+
"revision": 0,
|
|
267
|
+
"created_at": _now(),
|
|
268
|
+
}
|
|
269
|
+
self._validate_paths(value)
|
|
270
|
+
with self._locked(session_id):
|
|
271
|
+
# A crash before launch remains discoverable and cleanable, but
|
|
272
|
+
# close/checkpoint cannot cross this lifecycle lock while tmux
|
|
273
|
+
# is starting. Only the post-launch CAS publishes ``open``.
|
|
274
|
+
self._write(value)
|
|
275
|
+
_create_session(
|
|
276
|
+
tmux_name,
|
|
277
|
+
agent_id,
|
|
278
|
+
str(worktree.path),
|
|
279
|
+
_ISOLATED_LAUNCH_ROUTING_ENV,
|
|
280
|
+
)
|
|
281
|
+
launched = True
|
|
282
|
+
value["state"] = "open"
|
|
283
|
+
value["revision"] = 1
|
|
284
|
+
self._write(value, expected_revision=0)
|
|
285
|
+
return value
|
|
286
|
+
except BaseException:
|
|
287
|
+
if launched and tmux_name is not None:
|
|
288
|
+
try:
|
|
289
|
+
_tmux("kill-session", "-t", _exact(tmux_name))
|
|
290
|
+
except BaseException:
|
|
291
|
+
pass
|
|
292
|
+
try:
|
|
293
|
+
self._path(session_id).unlink()
|
|
294
|
+
except FileNotFoundError:
|
|
295
|
+
pass
|
|
296
|
+
shutil.rmtree(snapshot.run_root, ignore_errors=True)
|
|
297
|
+
raise
|
|
298
|
+
|
|
299
|
+
def attach(self, session_id: str) -> int:
|
|
300
|
+
value = self.load(session_id)
|
|
301
|
+
if value["state"] != "open":
|
|
302
|
+
raise IsolatedSessionError(f"session is {value['state']}, not open")
|
|
303
|
+
name = value["tmux_name"]
|
|
304
|
+
if not session_exists(name):
|
|
305
|
+
raise IsolatedSessionError("session process is no longer running")
|
|
306
|
+
# ``display-message -t =session`` is parsed as a target-pane and can
|
|
307
|
+
# return an empty path even though the exact session exists. ``list-panes``
|
|
308
|
+
# accepts an exact target-session and gives an unambiguous cwd record.
|
|
309
|
+
cwd = _tmux(
|
|
310
|
+
"list-panes", "-t", _exact(name), "-F", "#{pane_current_path}"
|
|
311
|
+
)
|
|
312
|
+
if cwd.returncode != 0:
|
|
313
|
+
raise command_error(
|
|
314
|
+
IsolatedSessionError, "could not inspect session cwd", cwd.stderr
|
|
315
|
+
)
|
|
316
|
+
panes = [line for line in cwd.stdout.splitlines() if line]
|
|
317
|
+
if len(panes) != 1 or Path(panes[0]).resolve() != Path(
|
|
318
|
+
value["worktree_path"]
|
|
319
|
+
).resolve():
|
|
320
|
+
raise IsolatedSessionError("session cwd does not match its isolated workspace")
|
|
321
|
+
return _attach_and_report(name, value["agent_id"])
|
|
322
|
+
|
|
323
|
+
def checkpoint(self, session_id: str) -> str:
|
|
324
|
+
with self._locked(session_id):
|
|
325
|
+
value = self.load(session_id)
|
|
326
|
+
if value["state"] != "open":
|
|
327
|
+
raise IsolatedSessionError(f"session is {value['state']}, not open")
|
|
328
|
+
workdir = Path(value["worktree_path"])
|
|
329
|
+
if not workdir.is_dir():
|
|
330
|
+
raise IsolatedSessionError("isolated worktree is missing")
|
|
331
|
+
base = value["base_commit"]
|
|
332
|
+
files, patch = _strict_checkpoint_patch(workdir, base)
|
|
333
|
+
task = Task(prompt=f"Interactive session checkpoint {session_id}")
|
|
334
|
+
result = TaskResult(
|
|
335
|
+
task_id=task.id,
|
|
336
|
+
agent_id=value["agent_id"],
|
|
337
|
+
status=TaskStatus.COMPLETED,
|
|
338
|
+
output=f"Checkpoint from isolated session {session_id}",
|
|
339
|
+
exit_code=0,
|
|
340
|
+
duration_seconds=0.0,
|
|
341
|
+
files_changed=files,
|
|
342
|
+
patch=patch,
|
|
343
|
+
)
|
|
344
|
+
run_id = str(uuid4())
|
|
345
|
+
artifacts = ArtifactStore(Path(self.config.runs.root), self.project_id)
|
|
346
|
+
plan = {"origin": "isolated-session", "session_id": session_id}
|
|
347
|
+
artifacts.begin(
|
|
348
|
+
run_id=run_id,
|
|
349
|
+
task=task,
|
|
350
|
+
repo_root=self.repo_root,
|
|
351
|
+
snapshot_commit=base,
|
|
352
|
+
source_fingerprint=value["source_fingerprint"],
|
|
353
|
+
strategy="interactive",
|
|
354
|
+
agents=[value["agent_id"]],
|
|
355
|
+
plan=plan,
|
|
356
|
+
)
|
|
357
|
+
artifacts.save(
|
|
358
|
+
run_id=run_id,
|
|
359
|
+
task=task,
|
|
360
|
+
result=result,
|
|
361
|
+
repo_root=self.repo_root,
|
|
362
|
+
snapshot_commit=base,
|
|
363
|
+
source_fingerprint=value["source_fingerprint"],
|
|
364
|
+
strategy="interactive",
|
|
365
|
+
plan=plan,
|
|
366
|
+
)
|
|
367
|
+
revision = int(value.get("revision", 0))
|
|
368
|
+
value["checkpoints"].append(run_id)
|
|
369
|
+
value["revision"] = revision + 1
|
|
370
|
+
self._write(value, expected_revision=revision)
|
|
371
|
+
return run_id
|
|
372
|
+
|
|
373
|
+
def close_session(self, session_id: str) -> dict:
|
|
374
|
+
with self._locked(session_id):
|
|
375
|
+
value = self.load(session_id)
|
|
376
|
+
if value["state"] == "closed":
|
|
377
|
+
return value
|
|
378
|
+
revision = int(value.get("revision", 0))
|
|
379
|
+
entry_state = value["state"]
|
|
380
|
+
if entry_state == "open":
|
|
381
|
+
value["state"] = "closing"
|
|
382
|
+
value["revision"] = revision + 1
|
|
383
|
+
self._write(value, expected_revision=revision)
|
|
384
|
+
revision += 1
|
|
385
|
+
name = value["tmux_name"]
|
|
386
|
+
if session_exists(name):
|
|
387
|
+
killed = _tmux("kill-session", "-t", _exact(name))
|
|
388
|
+
if killed.returncode != 0:
|
|
389
|
+
# Restore the state we found, not "open": a session in
|
|
390
|
+
# recovery_required (or creating, after a crash mid-launch)
|
|
391
|
+
# would otherwise be published as healthy, letting attach and
|
|
392
|
+
# list treat it as usable and destroying the only signal that
|
|
393
|
+
# its worktree was never cleaned up.
|
|
394
|
+
value["state"] = entry_state
|
|
395
|
+
value["revision"] = revision + 1
|
|
396
|
+
self._write(value, expected_revision=revision)
|
|
397
|
+
raise command_error(
|
|
398
|
+
IsolatedSessionError, "could not stop tmux session", killed.stderr
|
|
399
|
+
)
|
|
400
|
+
worktree = Worktree(
|
|
401
|
+
path=Path(value["worktree_path"]),
|
|
402
|
+
branch=value.get("worktree_branch"),
|
|
403
|
+
agent_id=value["agent_id"],
|
|
404
|
+
is_git=True,
|
|
405
|
+
base_commit=value.get("base_commit"),
|
|
406
|
+
)
|
|
407
|
+
cleaned = cleanup_worktree(Path(value["control_repo"]), worktree)
|
|
408
|
+
if not cleaned and Path(value["worktree_path"]).exists():
|
|
409
|
+
value["state"] = "recovery_required"
|
|
410
|
+
value["revision"] = revision + 1
|
|
411
|
+
self._write(value, expected_revision=revision)
|
|
412
|
+
raise IsolatedSessionError("could not remove isolated worktree")
|
|
413
|
+
run_root = Path(value["run_root"])
|
|
414
|
+
shutil.rmtree(run_root, ignore_errors=True)
|
|
415
|
+
try:
|
|
416
|
+
run_root.lstat()
|
|
417
|
+
except FileNotFoundError:
|
|
418
|
+
run_root_remains = False
|
|
419
|
+
except OSError:
|
|
420
|
+
run_root_remains = True
|
|
421
|
+
else:
|
|
422
|
+
run_root_remains = True
|
|
423
|
+
if run_root_remains:
|
|
424
|
+
value["state"] = "recovery_required"
|
|
425
|
+
value["revision"] = revision + 1
|
|
426
|
+
self._write(value, expected_revision=revision)
|
|
427
|
+
raise IsolatedSessionError("could not remove isolated session workspace")
|
|
428
|
+
value["state"] = "closed"
|
|
429
|
+
value["revision"] = revision + 1
|
|
430
|
+
self._write(value, expected_revision=revision)
|
|
431
|
+
return value
|
syncmill/matcher.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Capability matching (#9) — declarative, config-driven agent re-ordering.
|
|
2
|
+
|
|
3
|
+
Routing's baseline is a fixed priority order (``agent_priority``). Capability
|
|
4
|
+
matching layers *over* that baseline: given a task's capability hint (its
|
|
5
|
+
``Task.tags``) and each agent's **declared** capabilities (from config), it
|
|
6
|
+
re-orders the candidate agents so a matching agent is preferred ahead of raw
|
|
7
|
+
priority — falling back to priority order verbatim when nothing matches.
|
|
8
|
+
|
|
9
|
+
DECLARATIVE, never a probe — honoring the "no ``health_check`` on agents"
|
|
10
|
+
invariant. The matcher never runs, inspects, or pings a binary; a score is a
|
|
11
|
+
pure ``set``-intersection of static config (declared capabilities) with the
|
|
12
|
+
task's hint. It is strictly more passive than the circuit breaker (which at
|
|
13
|
+
least reads *recorded* outcomes) — the matcher reads only declared config.
|
|
14
|
+
|
|
15
|
+
RE-ORDERS, never DROPS. ``order`` returns a permutation of its input — every
|
|
16
|
+
agent is still present — so nothing is ever stranded: RouteStrategy's fallback
|
|
17
|
+
chain, PipelineStrategy's stages, CompeteStrategy's competitors,
|
|
18
|
+
CouncilStrategy's members, and DecomposeStrategy's workers all stay intact, just
|
|
19
|
+
re-prioritized. (Filtering would
|
|
20
|
+
risk emptying the pool and break the "fall back to priority when nothing
|
|
21
|
+
matches" criterion.)
|
|
22
|
+
|
|
23
|
+
Like quality gates and the circuit breaker, capability matching is plumbed
|
|
24
|
+
**outside** the agent layer — agents stay pure ``execute(prompt, workdir,
|
|
25
|
+
timeout)`` functions and never see a matcher, a Task, or a capability. Unlike
|
|
26
|
+
the gate/breaker (whose verdicts mean different things per strategy, so they are
|
|
27
|
+
threaded *into* ``Strategy.execute``), capability matching has **no
|
|
28
|
+
topology-specific semantics**: it produces exactly one artifact — a re-ordering
|
|
29
|
+
of the flat candidate list every strategy already consumes as a priority
|
|
30
|
+
sequence — so the Supervisor applies it **once**, before dispatching to the
|
|
31
|
+
strategy, and all five strategies become capability-aware for free.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import logging
|
|
37
|
+
from typing import Protocol, runtime_checkable
|
|
38
|
+
|
|
39
|
+
from syncmill.agents import AgentRunner
|
|
40
|
+
from syncmill.config import CapabilityConfig
|
|
41
|
+
from syncmill.models import Task
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@runtime_checkable
|
|
47
|
+
class CapabilityMatcher(Protocol):
|
|
48
|
+
"""Re-orders candidate agents by declared-capability fit for a task.
|
|
49
|
+
|
|
50
|
+
Pure: ``(agents, task)`` in, re-ordered agents out — like agents it knows
|
|
51
|
+
nothing about the Supervisor, memory, or strategies, and NEVER touches an
|
|
52
|
+
agent's ``execute``.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def order(self, agents: list[AgentRunner], task: Task) -> list[AgentRunner]: ...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class DeclarativeMatcher:
|
|
59
|
+
"""Stable-re-orders agents by how many of ``task.tags`` each agent's declared
|
|
60
|
+
capabilities cover.
|
|
61
|
+
|
|
62
|
+
Re-orders, never drops, so non-matching agents remain as fallback. The score
|
|
63
|
+
is the size of the intersection between an agent's declared capabilities and
|
|
64
|
+
the task's (normalized) hint tokens. A descending **stable** sort means equal
|
|
65
|
+
scores keep their input (priority) relative order — priority is the
|
|
66
|
+
deterministic secondary key, implicitly, via sort stability. Deterministic:
|
|
67
|
+
the key is a pure function of static config + ``task.tags`` (no wall-clock, no
|
|
68
|
+
``duration_seconds``, no set-iteration nondeterminism — intersection *size* is
|
|
69
|
+
order-free).
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, capabilities: dict[str, frozenset[str]]) -> None:
|
|
73
|
+
# agent_id -> its declared capabilities (already normalized: stripped,
|
|
74
|
+
# lower-cased, empties dropped). build_matcher does the normalization so
|
|
75
|
+
# the hot path is a plain set-intersection.
|
|
76
|
+
self._caps = capabilities
|
|
77
|
+
|
|
78
|
+
def _score(self, agent_id: str, wanted: set[str]) -> int:
|
|
79
|
+
return len(self._caps.get(agent_id, frozenset()) & wanted)
|
|
80
|
+
|
|
81
|
+
def order(self, agents: list[AgentRunner], task: Task) -> list[AgentRunner]:
|
|
82
|
+
# Normalize the hint the same way capabilities were normalized, so
|
|
83
|
+
# matching is case-/whitespace-insensitive without mutating Task.tags
|
|
84
|
+
# (the stored hint keeps the user's raw casing). A set dedups, so a
|
|
85
|
+
# repeated tag can't inflate a score.
|
|
86
|
+
wanted = {t.strip().lower() for t in task.tags if t.strip()}
|
|
87
|
+
if not wanted:
|
|
88
|
+
return list(agents) # no hint → raw priority order (criterion 2)
|
|
89
|
+
# Stable descending-score sort: matchers (score >= 1) move ahead of all
|
|
90
|
+
# non-matchers (score 0), and equal-score agents keep priority order.
|
|
91
|
+
return sorted(agents, key=lambda a: -self._score(a.agent_id, wanted))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def build_matcher(
|
|
95
|
+
config: CapabilityConfig, known_agent_ids: list[str] | None = None
|
|
96
|
+
) -> CapabilityMatcher | None:
|
|
97
|
+
"""The matcher described by ``config``, or ``None`` when no agent declares any
|
|
98
|
+
capability (the disabled default) — the Supervisor then leaves agent order at
|
|
99
|
+
raw priority. Mirrors ``build_gate``/``build_breaker``: a single place that
|
|
100
|
+
decides "configured or not", so the disabled path constructs nothing.
|
|
101
|
+
|
|
102
|
+
``known_agent_ids`` (when given, the configured ``agent_priority``) is used
|
|
103
|
+
only to surface a config typo: a capability declared for an id not in the
|
|
104
|
+
lineup is **advisory-warned** (``logger.warning``, never raised) — it stays an
|
|
105
|
+
inert no-op, but it's surfaced rather than silently scoring nothing, matching
|
|
106
|
+
syncmill's "name the typo" posture. The free-form capability *tokens* are NOT
|
|
107
|
+
validated — an unmatched token is a valid no-op (the graceful fallback).
|
|
108
|
+
"""
|
|
109
|
+
normalized = {
|
|
110
|
+
aid: frozenset(c.strip().lower() for c in caps if c.strip())
|
|
111
|
+
for aid, caps in config.agents.items()
|
|
112
|
+
}
|
|
113
|
+
# Drop agents whose declared list normalizes away entirely (e.g. [""], [" "])
|
|
114
|
+
# so they don't count as "configured" and can't keep the feature on by accident.
|
|
115
|
+
normalized = {aid: caps for aid, caps in normalized.items() if caps}
|
|
116
|
+
if not normalized:
|
|
117
|
+
return None
|
|
118
|
+
if known_agent_ids is not None:
|
|
119
|
+
unknown = sorted(aid for aid in normalized if aid not in known_agent_ids)
|
|
120
|
+
if unknown:
|
|
121
|
+
logger.warning(
|
|
122
|
+
"capability descriptors declared for unknown agent id(s): %s "
|
|
123
|
+
"(not in agent_priority: %s) — these will never match",
|
|
124
|
+
", ".join(unknown),
|
|
125
|
+
", ".join(known_agent_ids),
|
|
126
|
+
)
|
|
127
|
+
return DeclarativeMatcher(normalized)
|