syncmill 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- syncmill/__init__.py +11 -0
- syncmill/agents.py +2064 -0
- syncmill/artifact_io.py +201 -0
- syncmill/artifacts.py +1946 -0
- syncmill/board.py +2060 -0
- syncmill/breaker.py +327 -0
- syncmill/cli.py +4986 -0
- syncmill/command_errors.py +32 -0
- syncmill/config.py +1118 -0
- syncmill/dashboard.py +935 -0
- syncmill/gates.py +179 -0
- syncmill/gateway.py +234 -0
- syncmill/git_env.py +99 -0
- syncmill/isolated_session.py +431 -0
- syncmill/matcher.py +127 -0
- syncmill/memory.py +1067 -0
- syncmill/memory_backend.py +1907 -0
- syncmill/memory_migration.py +962 -0
- syncmill/model_routing.py +482 -0
- syncmill/models.py +244 -0
- syncmill/monitor.py +775 -0
- syncmill/phoenix_exporter.py +339 -0
- syncmill/planning.py +512 -0
- syncmill/platform_support.py +14 -0
- syncmill/preflight.py +1129 -0
- syncmill/project.py +1067 -0
- syncmill/report.py +502 -0
- syncmill/review_candidates.py +107 -0
- syncmill/room_compare.py +2380 -0
- syncmill/room_raw.py +484 -0
- syncmill/room_viewer.py +604 -0
- syncmill/rooms.py +4044 -0
- syncmill/session.py +444 -0
- syncmill/strategies/__init__.py +82 -0
- syncmill/strategies/base.py +910 -0
- syncmill/strategies/compete.py +233 -0
- syncmill/strategies/council.py +429 -0
- syncmill/strategies/decompose.py +570 -0
- syncmill/strategies/pipeline.py +221 -0
- syncmill/strategies/route.py +130 -0
- syncmill/supervisor.py +1423 -0
- syncmill/teamfile.py +571 -0
- syncmill/telemetry_hook.py +231 -0
- syncmill/terminal.py +245 -0
- syncmill/tracer.py +548 -0
- syncmill/tui.py +3537 -0
- syncmill/tui_collaboration.py +499 -0
- syncmill/tui_jobs.py +1407 -0
- syncmill/tui_render.py +156 -0
- syncmill/workflow.py +3746 -0
- syncmill/worktree.py +1045 -0
- syncmill-0.2.0.dist-info/METADATA +853 -0
- syncmill-0.2.0.dist-info/RECORD +56 -0
- syncmill-0.2.0.dist-info/WHEEL +4 -0
- syncmill-0.2.0.dist-info/entry_points.txt +2 -0
- syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
syncmill/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
|
+
]
|