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
@@ -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)