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/session.py ADDED
@@ -0,0 +1,444 @@
1
+ """Durable interactive agent sessions in tmux (#17, v0.4).
2
+
3
+ The complement to the live monitor (#16): where ``--monitor`` gives an *ephemeral*
4
+ observability view of a *programmatic* concurrent run, this launches a
5
+ *long-lived* **interactive** agent — the bare ``claude``/``codex``/``kimi`` REPL a
6
+ human types into — inside a **dedicated, reattachable** tmux session that survives
7
+ detach (``Ctrl-b d`` / terminal close / SSH drop) and can be reattached by name.
8
+ It makes syncmill's interactive-collaboration mode first-class: drive one agent
9
+ yourself (no programmatic billing) while ``syncmill run --exclude <agent>``
10
+ orchestrates the rest, the two sides meeting only in the ``shared`` memtomem
11
+ namespace.
12
+
13
+ **Not an agent, not a strategy.** The interactive session is a separate human-
14
+ driven process — it NEVER enters ``AgentRunner.execute``/``aexecute`` (that would
15
+ re-incur programmatic billing and break agent purity), so the per-agent launch
16
+ command lives here as a standalone :data:`_INTERACTIVE` map, deliberately
17
+ *separate* from the programmatic ``CLIAgent._build_command``. This module touches
18
+ neither agents nor memory — it is pure tmux + process launch.
19
+
20
+ **Reuses #16's tmux primitives** (``monitor.py`` stays UNCHANGED): the control/
21
+ attach helpers ``_tmux``/``_tmux_attach``, the snapshot skip-set
22
+ ``_SNAPSHOT_SKIP_ENV``, and the inner-handshake var name ``INNER_ENV`` (so the
23
+ session snapshot can deliberately EXCLUDE it). These are private (``_``-prefixed)
24
+ cross-module imports — the same established intra-package pattern as
25
+ ``cli.py``'s ``from syncmill.supervisor import _matched_order`` — so they are a
26
+ de-facto API any monitor.py refactor must preserve. NOTE for tests: because
27
+ ``_tmux``/``_tmux_attach`` are imported by NAME, they are rebound into THIS
28
+ module's globals — patch ``session._tmux`` (the lookup site), NOT ``monitor._tmux``
29
+ (patch where a name is LOOKED UP, not where it is defined).
30
+
31
+ **Why a session-specific shim** (not ``monitor._ENV_SHIM``): a detached tmux
32
+ session inherits the (possibly stale) tmux *server* environment, not the client's,
33
+ so — exactly like the monitor — the interactive agent must be given the outer
34
+ shell's env (PATH/auth/``SYNCMILL_*``) via a ``0600`` snapshot file + an exec shim,
35
+ never via argv (no secrets in ``ps``). :data:`_SESSION_ENV_SHIM` differs from the
36
+ monitor's in two ways: it **self-unlinks** the secret env file (the detached pane's
37
+ shim runs *asynchronously after* ``new-session`` returns, so the parent can't
38
+ safely delete it without a use-after-free — the shim consuming it is race-free and
39
+ shrinks the secret window to "until the pane is scheduled"), unlinking in a
40
+ ``finally`` *before* parsing so a corrupt snapshot still doesn't linger; and the
41
+ session snapshot omits ``INNER_ENV`` so the interactive process never carries
42
+ ``SYNCMILL_MONITOR_DIR`` (which, if it pointed at a monitor run dir, would
43
+ false-trip ``monitor.inner_dir``).
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import contextlib
49
+ import json
50
+ import os
51
+ import shutil
52
+ import sys
53
+ import tempfile
54
+ import time
55
+ from collections.abc import Collection
56
+ from pathlib import Path
57
+ from typing import NamedTuple
58
+
59
+ from syncmill.platform_support import require_supported_platform
60
+ from syncmill.command_errors import command_error
61
+ from syncmill.artifacts import printable_name
62
+ from syncmill.terminal import printable_command, secho
63
+ from syncmill.monitor import (
64
+ INNER_ENV,
65
+ RUN_ID_ENV,
66
+ _SNAPSHOT_SKIP_ENV,
67
+ _tmux,
68
+ _tmux_attach,
69
+ )
70
+
71
+ # ── Identity: the interactive launch command + deterministic session names ──
72
+
73
+ #: agent id → the bare INTERACTIVE (REPL) argv. Deliberately SEPARATE from each
74
+ #: agent's programmatic ``CLIAgent._build_command`` (which adds ``-p``/``exec``/
75
+ #: ``--json`` etc.) — the interactive session is not a syncmill agent. This
76
+ #: duplicates the ``kimi-code → kimi`` binary fact in ``KimiCodeAgent`` on purpose:
77
+ #: interactive mode does not route through the registry (it must run an agent even
78
+ #: when that agent is ``enabled=false`` for orchestration). Keyed by the REAL agent
79
+ #: id, so the hyphenated ``kimi-code`` is a verbatim key.
80
+ _INTERACTIVE: dict[str, list[str]] = {
81
+ "claude": ["claude"],
82
+ "codex": ["codex"],
83
+ "kimi-code": ["kimi"],
84
+ }
85
+
86
+ #: Session-name prefix. Names are ``syncmill-session-<agent>`` — DETERMINISTIC
87
+ #: (unlike the monitor's ``syncmill-<uuid8>``), which is the whole durability/
88
+ #: reattach key: ``start`` finds an existing session by recomputing the same name.
89
+ _SESSION_PREFIX = "syncmill-session"
90
+
91
+ #: Ownership marker written into each run dir. The stale-dir sweep requires it
92
+ #: before ``rmtree`` (the #16 R5 lesson — a prefix match alone would delete any
93
+ #: same-prefixed tmp dir). Distinct from the monitor's ``.syncmill-inner`` so a
94
+ #: session run dir is never mistaken for a monitor inner dir, and vice versa.
95
+ _SESSION_SENTINEL = ".syncmill-session"
96
+
97
+ #: A run dir older than this (and carrying the sentinel) is assumed orphaned and
98
+ #: swept on the next ``start``. Far longer than the moment the shim needs the env
99
+ #: file, so a live session's (env-consumed, sentinel-only) dir is never at risk.
100
+ _STALE_SESSION_DIR_SECONDS = 24 * 3600
101
+
102
+ #: The exec shim run as the pane's process: it loads the ``0600`` env snapshot the
103
+ #: outer wrote, REPLACES the pane's inherited (possibly stale tmux-server) env with
104
+ #: it — preserving only the tmux-owned pane vars (``TERM``/``TMUX``/``TMUX_PANE``,
105
+ #: kept in lockstep with ``_SNAPSHOT_SKIP_ENV``) — then ``execvp``s the bare agent
106
+ #: binary. The env file is **unlinked in a ``finally`` immediately after the read,
107
+ #: BEFORE parsing**, so even a corrupt snapshot leaves no secret behind; the
108
+ #: parent never deletes it (the shim runs async after ``new-session`` returns —
109
+ #: deleting from the parent would race the read). Secrets ride the file, never argv.
110
+ _SESSION_ENV_SHIM = (
111
+ "import os,sys,json\n"
112
+ "keep={k:os.environ[k] for k in ('TERM','TMUX','TMUX_PANE') if k in os.environ}\n"
113
+ "p=os.path.join(sys.argv[1],'env')\n"
114
+ "try:\n"
115
+ " data=open(p).read()\n"
116
+ "finally:\n"
117
+ " try: os.unlink(p)\n"
118
+ " except OSError: pass\n"
119
+ "snap=json.loads(data)\n"
120
+ "os.environ.clear(); os.environ.update(snap); os.environ.update(keep)\n"
121
+ "os.execvp(sys.argv[2], sys.argv[2:])\n"
122
+ )
123
+
124
+
125
+ class SessionLaunchError(RuntimeError):
126
+ """A durable session couldn't be created/attached. The CLI converts it to a
127
+ clean error and exits non-zero — there is NO fallback (``session`` IS the tmux
128
+ feature, unlike ``--monitor`` which falls back to a normal run; that contract
129
+ difference is why this is a dedicated exception, not ``MonitorLaunchError``)."""
130
+
131
+
132
+ class SessionInfo(NamedTuple):
133
+ """One live syncmill interactive session: its tmux name and whether a client
134
+ is currently attached."""
135
+
136
+ name: str
137
+ attached: bool
138
+
139
+
140
+ def _safe(agent_id: str) -> str:
141
+ """A filename/tmux-name-safe form of an agent id (the real ids are already
142
+ safe; this only guards a stray path separator)."""
143
+ return agent_id.replace("/", "_").replace(os.sep, "_")
144
+
145
+
146
+ def _exact(name: str) -> str:
147
+ """A tmux ``target-session`` spec forcing EXACT matching. tmux resolves a raw
148
+ target by exact-THEN-prefix-THEN-glob, so a raw lookup of
149
+ ``syncmill-session-claude`` would also match a foreign ``syncmill-session-claude-x``
150
+ when the exact one is absent — defeating the exact-known-name scoping. The
151
+ leading ``=`` makes the match exact-only. Used for EVERY ``-t`` target
152
+ (``has-session``/``kill-session``/``attach-session``/pane cosmetics); never on
153
+ ``-s`` (the literal name to create) or ``-c`` (a directory)."""
154
+ return f"={name}"
155
+
156
+
157
+ def session_name(agent_id: str) -> str:
158
+ """The deterministic tmux session name for an agent — the reattach key."""
159
+ return f"{_SESSION_PREFIX}-{_safe(agent_id)}"
160
+
161
+
162
+ def interactive_agents() -> list[str]:
163
+ """The agent ids that can run interactively (the keys of :data:`_INTERACTIVE`)."""
164
+ return list(_INTERACTIVE)
165
+
166
+
167
+ def default_interactive_agent(priority: list[str]) -> str | None:
168
+ """The first agent in ``priority`` that has an interactive mapping, else None.
169
+
170
+ Used as ``session start``'s default agent. Walks ``agent_priority`` (not the
171
+ enabled registry) so a disabled-for-orchestration agent still counts — and
172
+ skips any priority entry lacking an interactive mapping rather than failing.
173
+ """
174
+ for agent_id in priority:
175
+ if agent_id in _INTERACTIVE:
176
+ return agent_id
177
+ return None
178
+
179
+
180
+ def _known_session_names() -> dict[str, str]:
181
+ """Map every session name we could ever create → its agent id. Lifecycle
182
+ operations (``list``/``stop --all``) match against this EXACT set rather than
183
+ the ``syncmill-session-`` prefix, so they can never touch a monitor run even if
184
+ the user sets ``SYNCMILL_MONITOR__SESSION_PREFIX=syncmill-session``."""
185
+ return {session_name(agent_id): agent_id for agent_id in _INTERACTIVE}
186
+
187
+
188
+ # ── tmux state queries ─────────────────────────────────────────────────────
189
+
190
+
191
+ def session_exists(name: str) -> bool:
192
+ """Whether a tmux session ``name`` currently exists (rc 0 from has-session).
193
+ False when no server is running (has-session then errors)."""
194
+ return _tmux("has-session", "-t", _exact(name)).returncode == 0
195
+
196
+
197
+ def list_sessions() -> list[SessionInfo]:
198
+ """Active syncmill interactive sessions (name + attached), or ``[]`` when no
199
+ tmux server is running.
200
+
201
+ Filters tmux's session list to the EXACT names in :func:`_known_session_names`
202
+ (never a prefix match — see that function). ``#{session_attached}`` is a client
203
+ *count*, so attached = ``int > 0`` (not ``== "1"``); malformed lines are
204
+ skipped defensively.
205
+ """
206
+ res = _tmux("list-sessions", "-F", "#{session_name} #{session_attached}")
207
+ if res.returncode != 0:
208
+ return [] # no server / no sessions
209
+ known = _known_session_names()
210
+ sessions: list[SessionInfo] = []
211
+ for line in res.stdout.splitlines():
212
+ name, _, attached = line.strip().rpartition(" ")
213
+ if not name or name not in known:
214
+ continue
215
+ a = attached.strip()
216
+ if not a.isdigit():
217
+ continue # malformed attached count — skip defensively
218
+ sessions.append(SessionInfo(name, int(a) > 0))
219
+ return sessions
220
+
221
+
222
+ # ── Launch (create) ────────────────────────────────────────────────────────
223
+
224
+
225
+ def _write_session_env_snapshot(
226
+ run_dir: Path, excluded_env: Collection[str] = ()
227
+ ) -> None:
228
+ """Write the outer process's environment to ``<run_dir>/env`` (mode ``0600``)
229
+ for :data:`_SESSION_ENV_SHIM` to restore.
230
+
231
+ Excludes the tmux-owned ``_SNAPSHOT_SKIP_ENV`` (the shim re-keeps those from the
232
+ pane) AND the monitor IPC vars ``INNER_ENV``/``RUN_ID_ENV`` — even an
233
+ already-exported ``SYNCMILL_MONITOR_DIR`` must not reach the interactive process
234
+ (if it pointed at a monitor run dir holding ``.syncmill-inner`` it would
235
+ false-trip ``monitor.inner_dir``), and a stale ``SYNCMILL_RUN_ID`` must not ride
236
+ along with it into ``resolve_run_id``. A FILE, not argv, keeps secrets out of
237
+ ``ps`` and the tmux command line. ``excluded_env`` lets isolated callers strip
238
+ routing variables that would redirect tools outside their assigned worktree.
239
+ """
240
+ snapshot = {
241
+ k: v
242
+ for k, v in os.environ.items()
243
+ if k not in _SNAPSHOT_SKIP_ENV
244
+ and k not in (INNER_ENV, RUN_ID_ENV)
245
+ and k not in excluded_env
246
+ }
247
+ fd = os.open(str(run_dir / "env"), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
248
+ with os.fdopen(fd, "w") as fh:
249
+ json.dump(snapshot, fh)
250
+
251
+
252
+ def _create_session(
253
+ name: str,
254
+ agent_id: str,
255
+ workdir: str,
256
+ excluded_env: Collection[str] = (),
257
+ ) -> None:
258
+ """Create the detached tmux session running ``agent_id``'s interactive binary
259
+ through the env-restoring shim, in ``workdir``. Raises :class:`SessionLaunchError`
260
+ on any setup/launch failure (cleaning the run dir — safe, the shim never ran).
261
+
262
+ No ``remain-on-exit``: the session SHOULD vanish when the user quits the agent
263
+ (the opposite of the monitor, which keeps the result on screen).
264
+ """
265
+ try:
266
+ run_dir = Path(tempfile.mkdtemp(prefix="syncmill-session-"))
267
+ except OSError as exc:
268
+ # mkdtemp can fail (missing/unwritable TMPDIR) before run_dir exists — convert
269
+ # to SessionLaunchError so the CLI reports a clean error instead of an uncaught
270
+ # traceback (the documented contract is "Raises SessionLaunchError on any
271
+ # setup/launch failure"). Separate from the setup/launch try below, whose
272
+ # except rmtrees a run_dir that does not exist yet at this point.
273
+ raise SessionLaunchError(str(exc)) from exc
274
+ try:
275
+ (run_dir / _SESSION_SENTINEL).write_text("") # ownership marker for the sweep
276
+ if excluded_env:
277
+ _write_session_env_snapshot(run_dir, excluded_env)
278
+ else:
279
+ _write_session_env_snapshot(run_dir)
280
+ shim_cmd = [
281
+ sys.executable, "-c", _SESSION_ENV_SHIM, str(run_dir), *_INTERACTIVE[agent_id]
282
+ ]
283
+ created = _tmux("new-session", "-d", "-s", name, "-c", workdir, *shim_cmd)
284
+ except (OSError, ValueError) as exc:
285
+ shutil.rmtree(run_dir, ignore_errors=True)
286
+ raise SessionLaunchError(str(exc)) from exc
287
+ if created.returncode != 0:
288
+ shutil.rmtree(run_dir, ignore_errors=True)
289
+ raise command_error(SessionLaunchError, "tmux new-session failed", created.stderr)
290
+ # The success-path run dir is NOT removed here: the shim self-unlinks the env
291
+ # file (consuming the secret) and the sentinel-only remainder is reaped by the
292
+ # stale-dir sweep — deleting it from the parent would race the async shim read.
293
+ with contextlib.suppress(Exception): # pane titles are best-effort cosmetics
294
+ _tmux("set-option", "-t", _exact(name), "pane-border-status", "top")
295
+ _tmux("set-option", "-t", _exact(name), "pane-border-format", " #{pane_title} ")
296
+ _tmux("select-pane", "-t", _exact(name), "-T", f"syncmill · {agent_id} (interactive)")
297
+
298
+
299
+ # ── Lifecycle: start / attach / stop ───────────────────────────────────────
300
+
301
+
302
+ def _resolve_target(target: str) -> str | None:
303
+ """A bare agent id → its deterministic session name; a full KNOWN session name
304
+ → itself; anything else → ``None``.
305
+
306
+ Returning ``None`` for an unknown target is load-bearing: it keeps lifecycle
307
+ ops scoped to syncmill interactive sessions, so a single-target ``stop``/``attach``
308
+ can never touch a foreign tmux session — including a monitor run that merely
309
+ shares the ``syncmill-session-`` prefix (e.g. ``SYNCMILL_MONITOR__SESSION_PREFIX``
310
+ set to ``syncmill-session``). Same guarantee the ``--all`` path already has.
311
+ """
312
+ if target in _INTERACTIVE:
313
+ return session_name(target)
314
+ if target in _known_session_names():
315
+ return target
316
+ return None
317
+
318
+
319
+ def _attach_and_report(name: str, agent_id: str) -> int:
320
+ """Attach (FOREGROUND, blocks until detach/exit), then report the outcome and
321
+ return an exit code: still-running (detached) → 0; ended (gone) → 0; attach
322
+ itself failed while the session lives → surface + return the tmux rc.
323
+
324
+ An isolated session's name and agent id come from its stored record, so
325
+ both are shown through ``printable_name``, and the reattach command is
326
+ printed only when the agent id can be shown unchanged."""
327
+ rc = _tmux_attach(_exact(name))
328
+ shown, agent = printable_name(str(name)), printable_name(str(agent_id))
329
+ reattach = printable_command("syncmill", "session", "attach", args=[agent_id])
330
+ if session_exists(name):
331
+ if rc != 0:
332
+ secho(
333
+ f"[syncmill] could not attach to '{shown}' (tmux exit {rc}); it is "
334
+ f"still running — reattach: {reattach}",
335
+ fg="red",
336
+ )
337
+ return rc
338
+ secho(
339
+ f"[syncmill] detached — '{shown}' is still running. "
340
+ f"reattach: {reattach}",
341
+ fg="yellow",
342
+ )
343
+ return 0
344
+ secho(f"[syncmill] interactive '{agent}' session ended.", fg="green")
345
+ return 0
346
+
347
+
348
+ def _print_collab_reminder(agent_id: str) -> None:
349
+ """Print the one operational rule of interactive collaboration: the session
350
+ must target the ``shared`` namespace EXPLICITLY (its default is cwd-scoped and
351
+ invisible to syncmill)."""
352
+ secho(
353
+ f"[syncmill] collaborate via the 'shared' memory namespace — target it "
354
+ f"EXPLICITLY from this session:\n"
355
+ f" write: mem_add(content=..., namespace=\"shared\")\n"
356
+ f" read: mem_agent_search(query=..., include_shared=True)\n"
357
+ f" orchestrate the rest: syncmill run --exclude {agent_id} "
358
+ f"-s compete \"...\"",
359
+ fg="cyan",
360
+ )
361
+
362
+
363
+ def start_session(agent_id: str, workdir: str) -> int:
364
+ """Start a new durable interactive session for ``agent_id`` (or reattach to the
365
+ existing one), attach, and return an exit code.
366
+
367
+ Reattach is by deterministic name: an existing session is reused as-is
368
+ (skipping creation) — the acceptance criterion ("survives detach and can be
369
+ reattached"). A fresh session sweeps stale run dirs, creates the detached
370
+ session, and prints the ``shared``-namespace collaboration reminder before the
371
+ foreground attach blocks. Raises :class:`SessionLaunchError` on a create failure.
372
+ """
373
+ require_supported_platform()
374
+ name = session_name(agent_id)
375
+ if session_exists(name):
376
+ secho(
377
+ f"[syncmill] reattaching to interactive '{agent_id}' session '{name}'",
378
+ fg="yellow",
379
+ )
380
+ return _attach_and_report(name, agent_id)
381
+ _sweep_stale_session_dirs() # bound the /tmp env-snapshot footprint (best-effort)
382
+ _create_session(name, agent_id, workdir)
383
+ secho(
384
+ f"[syncmill] started interactive '{agent_id}' session '{name}' in "
385
+ f"{printable_name(str(workdir))}",
386
+ fg="green",
387
+ )
388
+ _print_collab_reminder(agent_id)
389
+ return _attach_and_report(name, agent_id)
390
+
391
+
392
+ def attach_session(target: str) -> int:
393
+ """Reattach to an existing interactive session (``target`` = agent id or full
394
+ KNOWN session name). Raises :class:`SessionLaunchError` if ``target`` is not a
395
+ known syncmill session or no such session exists (a foreign/monitor session is
396
+ never attached)."""
397
+ name = _resolve_target(target)
398
+ if name is None or not session_exists(name):
399
+ raise SessionLaunchError(f"no such session: {target}")
400
+ return _attach_and_report(name, _known_session_names()[name])
401
+
402
+
403
+ def stop_sessions(target: str | None, all_: bool) -> int:
404
+ """Kill the session for ``target`` (agent id or full KNOWN name), or every
405
+ known interactive session when ``all_``. Returns the number actually killed
406
+ (idempotent: a missing/unknown target is a clean 0). BOTH paths only ever touch
407
+ the EXACT known session names — an unknown target resolves to ``None`` and a
408
+ foreign/monitor session sharing the prefix is never killed."""
409
+ if all_:
410
+ names = [n for n in _known_session_names() if session_exists(n)]
411
+ else:
412
+ name = _resolve_target(target) if target else None
413
+ names = [name] if name and session_exists(name) else []
414
+ killed = 0
415
+ for name in names:
416
+ if _tmux("kill-session", "-t", _exact(name)).returncode == 0:
417
+ killed += 1
418
+ return killed
419
+
420
+
421
+ # ── Housekeeping ───────────────────────────────────────────────────────────
422
+
423
+
424
+ def _sweep_stale_session_dirs() -> None:
425
+ """Remove orphaned ``syncmill-session-*`` run dirs left by past launches.
426
+
427
+ Sentinel-guarded (proof of ownership — never ``rmtree`` a same-prefixed dir we
428
+ didn't create) and age-bounded. Fully best-effort: any error is ignored. After
429
+ the shim self-unlinks the env file a real run dir holds only the sentinel, so
430
+ sweeping even a live session's old dir is harmless.
431
+ """
432
+ tmp = Path(tempfile.gettempdir())
433
+ try:
434
+ entries = list(tmp.glob("syncmill-session-*"))
435
+ except OSError:
436
+ return
437
+ for entry in entries:
438
+ with contextlib.suppress(OSError):
439
+ if (
440
+ entry.is_dir()
441
+ and (entry / _SESSION_SENTINEL).is_file()
442
+ and time.time() - entry.stat().st_mtime > _STALE_SESSION_DIR_SECONDS
443
+ ):
444
+ shutil.rmtree(entry, ignore_errors=True)
@@ -0,0 +1,82 @@
1
+ """Orchestration strategies — the ``Strategy`` Protocol, its implementations,
2
+ and the name→strategy registry.
3
+
4
+ Extracted from ``supervisor.py`` (#12) once the four strategies made it
5
+ unwieldy. The registry (``_STRATEGIES`` + ``_make_strategy`` +
6
+ ``available_strategies``) lives here at the package root because it needs every
7
+ concrete strategy class; ``base`` holds the Protocol and the cross-strategy
8
+ helpers, and each strategy gets its own module. The Supervisor imports
9
+ ``_make_strategy``/``Strategy`` from here — selection-by-``default_strategy`` is
10
+ unchanged.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from syncmill.strategies.base import (
16
+ Strategy,
17
+ _aggregate_failure,
18
+ _git_diff_excerpt,
19
+ _select_winner,
20
+ )
21
+ from syncmill.strategies.compete import CompeteStrategy
22
+ from syncmill.strategies.council import CouncilStrategy
23
+ from syncmill.strategies.decompose import DecomposeStrategy
24
+ from syncmill.strategies.pipeline import PipelineStrategy
25
+ from syncmill.strategies.route import RouteStrategy
26
+
27
+ _STRATEGIES: dict[str, type[Strategy]] = {
28
+ "route": RouteStrategy,
29
+ "pipeline": PipelineStrategy,
30
+ "compete": CompeteStrategy,
31
+ "council": CouncilStrategy,
32
+ "decompose": DecomposeStrategy,
33
+ }
34
+
35
+ #: The SEQUENTIAL strategies — they run agents one at a time via the sync
36
+ #: ``execute`` path, so the async-only live-monitor seam (#16) can't stream them.
37
+ SEQUENTIAL_STRATEGIES: frozenset[str] = frozenset({"route", "pipeline"})
38
+
39
+ #: The strategies that fan agents out CONCURRENTLY (over ``aexecute``) — the only
40
+ #: ones the live tmux monitor (#16) can stream. DERIVED from ``_STRATEGIES`` (all
41
+ #: minus the sequential ones) so a newly-registered concurrent strategy is
42
+ #: monitorable automatically and the set can't silently drift out of sync with the
43
+ #: registry. ``--monitor`` no-ops (with a CLI notice) for a sequential strategy.
44
+ CONCURRENT_STRATEGIES: frozenset[str] = frozenset(_STRATEGIES) - SEQUENTIAL_STRATEGIES
45
+
46
+
47
+ def available_strategies() -> list[str]:
48
+ """Registered strategy names, sorted — used by the CLI to validate the
49
+ ``--strategy`` option."""
50
+ return sorted(_STRATEGIES)
51
+
52
+
53
+ def _make_strategy(name: str) -> Strategy:
54
+ """Instantiate a strategy by name, raising on an unknown name.
55
+
56
+ Mirrors ``AgentRegistry``'s hard failure on unknown agent ids: a typo'd
57
+ strategy should be a clear error, not a silent fall back to RouteStrategy.
58
+ """
59
+ if name not in _STRATEGIES:
60
+ valid = ", ".join(sorted(_STRATEGIES))
61
+ raise ValueError(f"Unknown strategy: {name!r}. Valid strategies: {valid}.")
62
+ return _STRATEGIES[name]()
63
+
64
+
65
+ __all__ = [
66
+ "Strategy",
67
+ "RouteStrategy",
68
+ "PipelineStrategy",
69
+ "CompeteStrategy",
70
+ "CouncilStrategy",
71
+ "DecomposeStrategy",
72
+ "available_strategies",
73
+ "CONCURRENT_STRATEGIES",
74
+ "SEQUENTIAL_STRATEGIES",
75
+ "_make_strategy",
76
+ "_STRATEGIES",
77
+ # Re-exported for direct import by tests / callers that reach into the
78
+ # selection + diff-collection helpers (their canonical home is `base`).
79
+ "_select_winner",
80
+ "_aggregate_failure",
81
+ "_git_diff_excerpt",
82
+ ]