swarph-cli 0.7.1__tar.gz → 0.7.2__tar.gz
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.
- {swarph_cli-0.7.1/src/swarph_cli.egg-info → swarph_cli-0.7.2}/PKG-INFO +1 -1
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/pyproject.toml +1 -1
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/__init__.py +1 -1
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/commands/watchdog.py +184 -13
- {swarph_cli-0.7.1 → swarph_cli-0.7.2/src/swarph_cli.egg-info}/PKG-INFO +1 -1
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_watchdog.py +133 -1
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/LICENSE +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/README.md +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/setup.cfg +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/caller.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/cell.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/commands/__init__.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/commands/chat.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/commands/daemon.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/commands/hook_output.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/commands/import_session.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/commands/install_hook.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/commands/onboard.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/commands/ratify.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/commands/spawn.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/main.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/parsers/__init__.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/parsers/claude.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli.egg-info/SOURCES.txt +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli.egg-info/entry_points.txt +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli.egg-info/requires.txt +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli.egg-info/top_level.txt +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_cell_loader.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_chat_command.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_claude_parser.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_daemon_command.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_hook_output.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_import_command.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_install_hook.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_main.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_onboard_command.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_ratify_command.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_smoke_chat.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_smoke_one_shot.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_smoke_phase_5_5.py +0 -0
- {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_spawn_command.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: swarph-cli
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.7.0 ships Phase 7 substrate-doc R7 §11.1.7 operator-tooling layer in 5 increments: PR-A `--new-instance` flag (sibling-spawn case) + PR-B auto-suffix on collision (sibling-slot persistence) + PR-C SessionStart hook (closes bare-claude operator-paste gap) + watchdog (stranded-session recovery) + PR-D swarph-shared cell.yaml relocation (cell-yaml schema graduates to swarph-shared 0.3.0 kernel-tier; substrate-doc R7 §11.1.5 (O5) RESOLVED).
|
|
5
5
|
Author: Pierre Samson, Claude Opus
|
|
6
6
|
License: MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "swarph-cli"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.2"
|
|
8
8
|
description = "The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.7.0 ships Phase 7 substrate-doc R7 §11.1.7 operator-tooling layer in 5 increments: PR-A `--new-instance` flag (sibling-spawn case) + PR-B auto-suffix on collision (sibling-slot persistence) + PR-C SessionStart hook (closes bare-claude operator-paste gap) + watchdog (stranded-session recovery) + PR-D swarph-shared cell.yaml relocation (cell-yaml schema graduates to swarph-shared 0.3.0 kernel-tier; substrate-doc R7 §11.1.5 (O5) RESOLVED)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -79,6 +79,11 @@ _DEFAULT_THRESHOLD_SEC = 1800 # 30 minutes
|
|
|
79
79
|
_DEFAULT_A1_RETRIES = 3
|
|
80
80
|
_DEFAULT_A1_BACKOFF_SEC = 60
|
|
81
81
|
_DEFAULT_GATEWAY_URL = "http://localhost:8788"
|
|
82
|
+
# F3 — tmux pane_activity gate threshold. If pane has activity within this
|
|
83
|
+
# many seconds, suppress A1 (session is working, not stalled). 600s (10min)
|
|
84
|
+
# is comfortably above legitimate-pause noise + comfortably below the
|
|
85
|
+
# 30min cursor-staleness threshold, so the two gates compose cleanly.
|
|
86
|
+
_DEFAULT_PANE_ACTIVITY_THRESHOLD_SEC = 600
|
|
82
87
|
|
|
83
88
|
_USAGE = """\
|
|
84
89
|
Usage:
|
|
@@ -137,10 +142,29 @@ def _stat_mtime(path: Path) -> Optional[int]:
|
|
|
137
142
|
return None
|
|
138
143
|
|
|
139
144
|
|
|
140
|
-
def _resolve_cursor_path(
|
|
141
|
-
|
|
145
|
+
def _resolve_cursor_path(
|
|
146
|
+
role: str,
|
|
147
|
+
explicit: Optional[str],
|
|
148
|
+
cell_yaml_value: Optional[str] = None,
|
|
149
|
+
) -> Path:
|
|
150
|
+
"""Resolve cursor file path with documented fallback chain.
|
|
151
|
+
|
|
152
|
+
Precedence (F4 — mother #1057/#1060 + beta #1061/#1065):
|
|
153
|
+
1. Explicit ``--cursor`` CLI arg (highest)
|
|
154
|
+
2. ``cell.yaml`` extra.cursor_path when --cell present
|
|
155
|
+
3. ``$TMPDIR/<role>-cursor.json``
|
|
156
|
+
4. ``/tmp/lab-claude-cursor.json`` (legacy lab-orchestrator default)
|
|
157
|
+
|
|
158
|
+
F4 closes the host-prefix-variant + sibling-instance-variant gap
|
|
159
|
+
class — cell.yaml carries the canonical cursor path per-cell, watchdog
|
|
160
|
+
auto-resolves when --cell is provided. Eliminates the silent-default-
|
|
161
|
+
to-lab-prefix failure mode that gave droplet 23hr of cursor-unreadable
|
|
162
|
+
errors before catch.
|
|
163
|
+
"""
|
|
142
164
|
if explicit:
|
|
143
165
|
return Path(explicit).expanduser()
|
|
166
|
+
if cell_yaml_value:
|
|
167
|
+
return Path(cell_yaml_value).expanduser()
|
|
144
168
|
tmpdir = os.environ.get("TMPDIR", "/tmp")
|
|
145
169
|
primary = Path(tmpdir) / f"{role}-cursor.json"
|
|
146
170
|
if primary.exists():
|
|
@@ -149,6 +173,74 @@ def _resolve_cursor_path(role: str, explicit: Optional[str]) -> Path:
|
|
|
149
173
|
return Path("/tmp/lab-claude-cursor.json")
|
|
150
174
|
|
|
151
175
|
|
|
176
|
+
def _resolve_tmux_session(
|
|
177
|
+
role: str,
|
|
178
|
+
explicit: Optional[str],
|
|
179
|
+
cell_yaml_value: Optional[str] = None,
|
|
180
|
+
) -> str:
|
|
181
|
+
"""Resolve tmux session name with documented fallback chain.
|
|
182
|
+
|
|
183
|
+
Precedence (F4 sibling to cursor_path):
|
|
184
|
+
1. Explicit ``--tmux-session`` CLI arg
|
|
185
|
+
2. ``cell.yaml`` extra.tmux_session when --cell present
|
|
186
|
+
3. Role itself (convention default)
|
|
187
|
+
|
|
188
|
+
Mother's sibling-instance variant (#1061): when slot-N siblings spawn,
|
|
189
|
+
each slot needs its own tmux session name; the cell.yaml that pins the
|
|
190
|
+
slot SHOULD also pin the tmux_session to keep the watchdog's reads
|
|
191
|
+
consistent with the spawn's writes.
|
|
192
|
+
"""
|
|
193
|
+
if explicit:
|
|
194
|
+
return explicit
|
|
195
|
+
if cell_yaml_value:
|
|
196
|
+
return cell_yaml_value
|
|
197
|
+
return role
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _read_cell_yaml_pins(role: str) -> tuple[Optional[str], Optional[str]]:
|
|
201
|
+
"""Best-effort read of cell.yaml extra.cursor_path + extra.tmux_session.
|
|
202
|
+
|
|
203
|
+
Tries the cwd-local ``./cell.yaml`` first (matches hook_output discovery),
|
|
204
|
+
falls back to ``<cells_dir>/<role>.yaml``. Returns (None, None) on any
|
|
205
|
+
failure — F4 is additive non-breaking, malformed cell.yaml falls through
|
|
206
|
+
to the legacy convention defaults.
|
|
207
|
+
|
|
208
|
+
NOTE: ``cursor_path`` / ``tmux_session`` live in ``Cell.extra`` (forward-
|
|
209
|
+
compat catch-all per swarph-shared v0.3) in v0.7.2. swarph-shared 0.4
|
|
210
|
+
will graduate them to first-class typed fields on ``Cell``; this reader
|
|
211
|
+
will continue to work because graduate-to-typed-field preserves the
|
|
212
|
+
extra-dict reading path (per swarph-shared's documented forward-compat
|
|
213
|
+
discipline).
|
|
214
|
+
"""
|
|
215
|
+
from swarph_cli.cell import (
|
|
216
|
+
cells_dir,
|
|
217
|
+
discover_cell_in_cwd,
|
|
218
|
+
load_cell,
|
|
219
|
+
CellError,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
cell_path = discover_cell_in_cwd()
|
|
223
|
+
if cell_path is None:
|
|
224
|
+
candidate = cells_dir() / f"{role}.yaml"
|
|
225
|
+
if candidate.is_file():
|
|
226
|
+
cell_path = candidate
|
|
227
|
+
if cell_path is None:
|
|
228
|
+
return None, None
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
cell = load_cell(cell_path)
|
|
232
|
+
except (CellError, OSError):
|
|
233
|
+
return None, None
|
|
234
|
+
|
|
235
|
+
extra = cell.extra or {}
|
|
236
|
+
cursor_path = extra.get("cursor_path")
|
|
237
|
+
tmux_session = extra.get("tmux_session")
|
|
238
|
+
return (
|
|
239
|
+
str(cursor_path) if cursor_path else None,
|
|
240
|
+
str(tmux_session) if tmux_session else None,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
152
244
|
def _resolve_log_path(explicit: Optional[str]) -> Path:
|
|
153
245
|
if explicit:
|
|
154
246
|
return Path(explicit).expanduser()
|
|
@@ -228,6 +320,39 @@ def _tmux_send_keys(name: str, text: str) -> bool:
|
|
|
228
320
|
return False
|
|
229
321
|
|
|
230
322
|
|
|
323
|
+
def _pane_activity_age_sec(name: str) -> Optional[int]:
|
|
324
|
+
"""Age in seconds since the tmux pane's last activity event.
|
|
325
|
+
|
|
326
|
+
Reads tmux's `#{pane_activity}` format variable, which returns a unix
|
|
327
|
+
epoch timestamp of the most recent activity in the active pane of the
|
|
328
|
+
target session. Returns None if tmux is missing, the session doesn't
|
|
329
|
+
exist, or tmux's output isn't parseable as an integer epoch.
|
|
330
|
+
|
|
331
|
+
Used by F3 (mother #1087 / drop-on-meta-edge proposal) as a third
|
|
332
|
+
AND-gate input to distinguish (a) session genuinely stalled from (b)
|
|
333
|
+
session actively working in a long bash block. cursor-mtime alone
|
|
334
|
+
measures "time since last turn-end" not "time since last activity";
|
|
335
|
+
pane_activity covers the mid-turn-active case.
|
|
336
|
+
|
|
337
|
+
Returns None on detection error so the caller can fall through to
|
|
338
|
+
the legacy AND-gate behavior — F3 is a strengthening of the gate,
|
|
339
|
+
not a replacement of it.
|
|
340
|
+
"""
|
|
341
|
+
try:
|
|
342
|
+
result = subprocess.run(
|
|
343
|
+
["tmux", "display", "-p", "-t", name, "#{pane_activity}"],
|
|
344
|
+
capture_output=True, text=True, timeout=5,
|
|
345
|
+
)
|
|
346
|
+
if result.returncode != 0:
|
|
347
|
+
return None
|
|
348
|
+
out = result.stdout.strip()
|
|
349
|
+
if not out:
|
|
350
|
+
return None
|
|
351
|
+
return max(0, _now() - int(out))
|
|
352
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError):
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
|
|
231
356
|
def _tmux_kill_session(name: str) -> bool:
|
|
232
357
|
try:
|
|
233
358
|
result = subprocess.run(
|
|
@@ -263,7 +388,7 @@ def _spawn_via_swarph(role: str, tmux_session: str) -> bool:
|
|
|
263
388
|
return False
|
|
264
389
|
|
|
265
390
|
|
|
266
|
-
def _a1_marker_path(log_path: Path, role: str) -> Path:
|
|
391
|
+
def _a1_marker_path(log_path: Path, role: str, tmux_session: Optional[str] = None) -> Path:
|
|
267
392
|
"""Marker file recording the cursor_mtime at which A1 was last fired.
|
|
268
393
|
|
|
269
394
|
Co-located with the watchdog log so it inherits the same XDG_STATE_HOME
|
|
@@ -272,14 +397,27 @@ def _a1_marker_path(log_path: Path, role: str) -> Path:
|
|
|
272
397
|
where cron fired A1 every 5min for 65min into an active session's tmux
|
|
273
398
|
input buffer (commander #1092 + droplet #1087).
|
|
274
399
|
|
|
275
|
-
Keyed on ``role``
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
400
|
+
Keyed on ``(role, tmux_session)`` post-F4 so sibling-instance patterns
|
|
401
|
+
(alpha+beta drop-on-meta-edge per project_drop_mitosis_to_meta_edge)
|
|
402
|
+
don't clobber each other's markers — mother's flag from #1103 closed in
|
|
403
|
+
v0.7.2. tmux_session is sanitized to alphanumeric + ``-_.`` for the
|
|
404
|
+
filename to avoid path-traversal or weird characters from cell.yaml-
|
|
405
|
+
pinned values.
|
|
406
|
+
|
|
407
|
+
NOTE (mother #1138 sanitization edge case): two siblings whose
|
|
408
|
+
``tmux_session`` values differ ONLY in disallowed characters (e.g.,
|
|
409
|
+
``cell:a`` vs ``cell:b`` — colons sanitized to ``_`` collapsing both
|
|
410
|
+
to ``cell_a`` / ``cell_b`` — fine in this example, but ``cell:a`` vs
|
|
411
|
+
``cell$a`` would both collapse to ``cell_a``) would collide post-
|
|
412
|
+
sanitization. cell.yaml-pinned ``tmux_session`` values SHOULD differ
|
|
413
|
+
in alphanumeric content, not just punctuation. Cosmetic in practice
|
|
414
|
+
(operators don't choose session names that close), but worth knowing.
|
|
281
415
|
"""
|
|
282
|
-
|
|
416
|
+
safe_tmux = "".join(
|
|
417
|
+
c if (c.isalnum() or c in "-_.") else "_"
|
|
418
|
+
for c in (tmux_session or role)
|
|
419
|
+
)
|
|
420
|
+
return log_path.parent / f"a1-fired-{role}-{safe_tmux}.marker"
|
|
283
421
|
|
|
284
422
|
|
|
285
423
|
def _a1_already_fired_at(marker: Path, cursor_mtime: int) -> bool:
|
|
@@ -331,10 +469,17 @@ def _log_event(log_path: Path, event: str, details: dict, verbose: bool = False)
|
|
|
331
469
|
|
|
332
470
|
def run_check(args: argparse.Namespace) -> int:
|
|
333
471
|
role = args.cell
|
|
334
|
-
|
|
472
|
+
# F4 — cell.yaml-pinned cursor_path + tmux_session (mother #1057/#1060
|
|
473
|
+
# + beta #1061/#1065). Reads cell.yaml `extra.cursor_path` /
|
|
474
|
+
# `extra.tmux_session` when --cell is provided; explicit CLI args still
|
|
475
|
+
# win. Best-effort: malformed cell.yaml falls through to legacy
|
|
476
|
+
# convention defaults (additive non-breaking).
|
|
477
|
+
cell_cursor, cell_tmux = _read_cell_yaml_pins(role)
|
|
478
|
+
cursor = _resolve_cursor_path(role, args.cursor, cell_cursor)
|
|
479
|
+
tmux_session = _resolve_tmux_session(role, args.tmux_session, cell_tmux)
|
|
335
480
|
log_path = _resolve_log_path(args.log)
|
|
336
481
|
threshold = args.threshold
|
|
337
|
-
|
|
482
|
+
pane_activity_threshold = args.pane_activity_threshold
|
|
338
483
|
peer = args.peer or role
|
|
339
484
|
gateway = args.gateway
|
|
340
485
|
token = os.environ.get("MESH_GATEWAY_TOKEN")
|
|
@@ -346,6 +491,9 @@ def run_check(args: argparse.Namespace) -> int:
|
|
|
346
491
|
"threshold_sec": threshold,
|
|
347
492
|
"tmux_session": tmux_session,
|
|
348
493
|
"peer": peer,
|
|
494
|
+
"pane_activity_threshold_sec": pane_activity_threshold,
|
|
495
|
+
"cell_yaml_pinned_cursor": cell_cursor is not None,
|
|
496
|
+
"cell_yaml_pinned_tmux": cell_tmux is not None,
|
|
349
497
|
}
|
|
350
498
|
|
|
351
499
|
# PRIMARY signal: cursor file mtime
|
|
@@ -378,7 +526,7 @@ def run_check(args: argparse.Namespace) -> int:
|
|
|
378
526
|
# cursor_stale + process_alive + unread None → noop (F2 fail-closed: can't verify work, don't poke)
|
|
379
527
|
# cursor_stale + a1_marker matches cursor_mtime → noop (F1 same-window suppression)
|
|
380
528
|
|
|
381
|
-
marker = _a1_marker_path(log_path, role)
|
|
529
|
+
marker = _a1_marker_path(log_path, role, tmux_session)
|
|
382
530
|
diag["a1_marker"] = str(marker)
|
|
383
531
|
|
|
384
532
|
if not process_alive:
|
|
@@ -434,6 +582,21 @@ def run_check(args: argparse.Namespace) -> int:
|
|
|
434
582
|
_log_event(log_path, "noop", diag, verbose)
|
|
435
583
|
return 0
|
|
436
584
|
|
|
585
|
+
# F3 — tmux pane_activity AND-gate (mother #1087). cursor-mtime measures
|
|
586
|
+
# "time since last turn-end" not "time since last activity"; mid-long-
|
|
587
|
+
# turn cursor is stale even though session is maximally alive. tmux's
|
|
588
|
+
# `#{pane_activity}` covers the mid-turn-active case. If the pane has
|
|
589
|
+
# had activity within `pane_activity_threshold_sec`, suppress A1 — the
|
|
590
|
+
# session is working, not stalled. Falls through to firing A1 when
|
|
591
|
+
# pane_activity is None (tmux missing / older tmux without the format)
|
|
592
|
+
# so F3 is a strengthening of the gate, not a hard dependency.
|
|
593
|
+
pane_age = _pane_activity_age_sec(tmux_session)
|
|
594
|
+
diag["pane_activity_age_sec"] = pane_age
|
|
595
|
+
if pane_age is not None and pane_age < pane_activity_threshold:
|
|
596
|
+
diag["decision"] = "noop_pane_activity_recent"
|
|
597
|
+
_log_event(log_path, "noop", diag, verbose)
|
|
598
|
+
return 0
|
|
599
|
+
|
|
437
600
|
diag["decision"] = "a1_send_keys"
|
|
438
601
|
wake_text = (
|
|
439
602
|
f"watchdog wake — cursor stale {cursor_age}s, "
|
|
@@ -463,6 +626,14 @@ def run_watchdog(argv: Optional[list[str]] = None) -> int:
|
|
|
463
626
|
p.add_argument("--cell", default=os.environ.get("SWARPH_CELL", "lab"))
|
|
464
627
|
p.add_argument("--cursor", default=None)
|
|
465
628
|
p.add_argument("--threshold", type=int, default=_DEFAULT_THRESHOLD_SEC)
|
|
629
|
+
p.add_argument(
|
|
630
|
+
"--pane-activity-threshold",
|
|
631
|
+
type=int,
|
|
632
|
+
default=_DEFAULT_PANE_ACTIVITY_THRESHOLD_SEC,
|
|
633
|
+
help="F3 gate: suppress A1 if tmux pane had activity within this "
|
|
634
|
+
"many seconds (covers mid-long-turn working sessions where "
|
|
635
|
+
"cursor-mtime is stale but session is alive).",
|
|
636
|
+
)
|
|
466
637
|
p.add_argument("--gateway", default=_DEFAULT_GATEWAY_URL)
|
|
467
638
|
p.add_argument("--tmux-session", default=None)
|
|
468
639
|
p.add_argument("--peer", default=None)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: swarph-cli
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.7.0 ships Phase 7 substrate-doc R7 §11.1.7 operator-tooling layer in 5 increments: PR-A `--new-instance` flag (sibling-spawn case) + PR-B auto-suffix on collision (sibling-slot persistence) + PR-C SessionStart hook (closes bare-claude operator-paste gap) + watchdog (stranded-session recovery) + PR-D swarph-shared cell.yaml relocation (cell-yaml schema graduates to swarph-shared 0.3.0 kernel-tier; substrate-doc R7 §11.1.5 (O5) RESOLVED).
|
|
5
5
|
Author: Pierre Samson, Claude Opus
|
|
6
6
|
License: MIT
|
|
@@ -341,6 +341,135 @@ def test_a1_rearms_after_cursor_advance(
|
|
|
341
341
|
assert send_mock.call_count == 2
|
|
342
342
|
|
|
343
343
|
|
|
344
|
+
# ---------------------------------------------------------------------------
|
|
345
|
+
# F3 — tmux pane_activity AND-gate (mother #1087)
|
|
346
|
+
# ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_pane_activity_recent_suppresses_a1(
|
|
350
|
+
isolated_state, stale_cursor, monkeypatch
|
|
351
|
+
):
|
|
352
|
+
"""F3 — cursor stale + alive + unread > 0 but pane_activity recent →
|
|
353
|
+
suppress A1. Session is working in a long bash block; cursor only
|
|
354
|
+
updates at turn-end. Same incident class as commander #1092 65-min
|
|
355
|
+
spam, but caught upstream of F1 marker by checking pane_activity
|
|
356
|
+
BEFORE firing."""
|
|
357
|
+
with patch("swarph_cli.commands.watchdog._process_alive", return_value=True), \
|
|
358
|
+
patch("swarph_cli.commands.watchdog._gateway_unread_count", return_value=3), \
|
|
359
|
+
patch("swarph_cli.commands.watchdog._tmux_session_exists", return_value=True), \
|
|
360
|
+
patch("swarph_cli.commands.watchdog._pane_activity_age_sec", return_value=30), \
|
|
361
|
+
patch("swarph_cli.commands.watchdog._tmux_send_keys") as send_mock:
|
|
362
|
+
rc = run_watchdog(argv=[
|
|
363
|
+
"--check", "--cell", "lab",
|
|
364
|
+
"--cursor", str(stale_cursor),
|
|
365
|
+
"--threshold", "60",
|
|
366
|
+
"--pane-activity-threshold", "600",
|
|
367
|
+
])
|
|
368
|
+
assert rc == 0
|
|
369
|
+
send_mock.assert_not_called()
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def test_pane_activity_old_falls_through_to_a1(
|
|
373
|
+
isolated_state, stale_cursor, monkeypatch
|
|
374
|
+
):
|
|
375
|
+
"""F3 — pane_activity OLDER than threshold means session has actually
|
|
376
|
+
been quiet; A1 still fires. Stop signal compatibility check."""
|
|
377
|
+
with patch("swarph_cli.commands.watchdog._process_alive", return_value=True), \
|
|
378
|
+
patch("swarph_cli.commands.watchdog._gateway_unread_count", return_value=3), \
|
|
379
|
+
patch("swarph_cli.commands.watchdog._tmux_session_exists", return_value=True), \
|
|
380
|
+
patch("swarph_cli.commands.watchdog._pane_activity_age_sec", return_value=1200), \
|
|
381
|
+
patch("swarph_cli.commands.watchdog._tmux_send_keys", return_value=True) as send_mock:
|
|
382
|
+
rc = run_watchdog(argv=[
|
|
383
|
+
"--check", "--cell", "lab",
|
|
384
|
+
"--cursor", str(stale_cursor),
|
|
385
|
+
"--threshold", "60",
|
|
386
|
+
"--pane-activity-threshold", "600",
|
|
387
|
+
])
|
|
388
|
+
assert rc == 1
|
|
389
|
+
send_mock.assert_called_once()
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def test_pane_activity_unavailable_falls_through_to_a1(
|
|
393
|
+
isolated_state, stale_cursor, monkeypatch
|
|
394
|
+
):
|
|
395
|
+
"""F3 — detection error (tmux missing / older tmux without
|
|
396
|
+
#{pane_activity}) returns None; A1 still fires. F3 is a strengthening
|
|
397
|
+
of the gate, not a hard dependency."""
|
|
398
|
+
with patch("swarph_cli.commands.watchdog._process_alive", return_value=True), \
|
|
399
|
+
patch("swarph_cli.commands.watchdog._gateway_unread_count", return_value=3), \
|
|
400
|
+
patch("swarph_cli.commands.watchdog._tmux_session_exists", return_value=True), \
|
|
401
|
+
patch("swarph_cli.commands.watchdog._pane_activity_age_sec", return_value=None), \
|
|
402
|
+
patch("swarph_cli.commands.watchdog._tmux_send_keys", return_value=True) as send_mock:
|
|
403
|
+
rc = run_watchdog(argv=[
|
|
404
|
+
"--check", "--cell", "lab",
|
|
405
|
+
"--cursor", str(stale_cursor),
|
|
406
|
+
"--threshold", "60",
|
|
407
|
+
])
|
|
408
|
+
assert rc == 1
|
|
409
|
+
send_mock.assert_called_once()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ---------------------------------------------------------------------------
|
|
413
|
+
# F4 — cell.yaml-pinned cursor_path + tmux_session (mother #1057/#1060 + beta #1061/#1065)
|
|
414
|
+
# ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def test_resolve_cursor_path_cell_yaml_pin_beats_default(isolated_state):
|
|
418
|
+
"""F4 — cell.yaml extra.cursor_path takes precedence over the
|
|
419
|
+
/tmp/lab-claude-cursor.json fallback when no explicit --cursor."""
|
|
420
|
+
from swarph_cli.commands.watchdog import _resolve_cursor_path
|
|
421
|
+
pinned = isolated_state / "custom-cursor.json"
|
|
422
|
+
assert _resolve_cursor_path("lab", None, str(pinned)) == pinned
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def test_resolve_cursor_path_explicit_beats_cell_yaml_pin(isolated_state):
|
|
426
|
+
"""F4 — explicit --cursor still wins over cell.yaml pin."""
|
|
427
|
+
from swarph_cli.commands.watchdog import _resolve_cursor_path
|
|
428
|
+
explicit = isolated_state / "explicit-cursor.json"
|
|
429
|
+
pinned = isolated_state / "pinned-cursor.json"
|
|
430
|
+
assert _resolve_cursor_path("lab", str(explicit), str(pinned)) == explicit
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def test_resolve_tmux_session_cell_yaml_pin_beats_role(isolated_state):
|
|
434
|
+
"""F4 — cell.yaml extra.tmux_session takes precedence over role
|
|
435
|
+
default when no explicit --tmux-session."""
|
|
436
|
+
from swarph_cli.commands.watchdog import _resolve_tmux_session
|
|
437
|
+
assert _resolve_tmux_session("drop-mother", None, "drop-mother-tmux") == "drop-mother-tmux"
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def test_resolve_tmux_session_explicit_beats_cell_yaml_pin(isolated_state):
|
|
441
|
+
"""F4 — explicit --tmux-session still wins over cell.yaml pin."""
|
|
442
|
+
from swarph_cli.commands.watchdog import _resolve_tmux_session
|
|
443
|
+
assert _resolve_tmux_session("lab", "explicit-name", "pinned-name") == "explicit-name"
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def test_resolve_tmux_session_falls_back_to_role(isolated_state):
|
|
447
|
+
"""F4 — no explicit + no cell.yaml pin → role itself."""
|
|
448
|
+
from swarph_cli.commands.watchdog import _resolve_tmux_session
|
|
449
|
+
assert _resolve_tmux_session("lab", None, None) == "lab"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def test_a1_marker_path_keyed_on_role_and_tmux_session(isolated_state):
|
|
453
|
+
"""F4 — marker filename includes both role + tmux_session to prevent
|
|
454
|
+
sibling-instance marker collisions (mother #1103 follow-up)."""
|
|
455
|
+
from swarph_cli.commands.watchdog import _a1_marker_path
|
|
456
|
+
log_path = isolated_state / "wd.log"
|
|
457
|
+
m1 = _a1_marker_path(log_path, "drop-on-meta-edge", "drop-on-meta-edge")
|
|
458
|
+
m2 = _a1_marker_path(log_path, "drop-on-meta-edge", "drop-on-meta-edge-2")
|
|
459
|
+
assert m1 != m2
|
|
460
|
+
assert m1.name == "a1-fired-drop-on-meta-edge-drop-on-meta-edge.marker"
|
|
461
|
+
assert m2.name == "a1-fired-drop-on-meta-edge-drop-on-meta-edge-2.marker"
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def test_a1_marker_path_sanitizes_tmux_session(isolated_state):
|
|
465
|
+
"""F4 — tmux_session sanitized to alphanumeric+underscore so
|
|
466
|
+
cell.yaml-pinned values with weird chars don't break the filename."""
|
|
467
|
+
from swarph_cli.commands.watchdog import _a1_marker_path
|
|
468
|
+
log_path = isolated_state / "wd.log"
|
|
469
|
+
m = _a1_marker_path(log_path, "lab", "weird/name with spaces!")
|
|
470
|
+
assert ":" not in m.name and "/" not in m.name and " " not in m.name
|
|
471
|
+
|
|
472
|
+
|
|
344
473
|
def test_a2_escalation_clears_a1_marker(
|
|
345
474
|
isolated_state, stale_cursor, monkeypatch
|
|
346
475
|
):
|
|
@@ -361,7 +490,10 @@ def test_a2_escalation_clears_a1_marker(
|
|
|
361
490
|
"--threshold", "60",
|
|
362
491
|
"--log", str(log_path),
|
|
363
492
|
])
|
|
364
|
-
|
|
493
|
+
# F4 v0.7.2 marker keyed on (role, tmux_session) — tmux_session defaults
|
|
494
|
+
# to role when no --tmux-session arg + no cell.yaml pin, so filename is
|
|
495
|
+
# a1-fired-{role}-{role}.marker.
|
|
496
|
+
marker = log_path.parent / "a1-fired-lab-lab.marker"
|
|
365
497
|
assert marker.exists()
|
|
366
498
|
|
|
367
499
|
# Now force A2 path (process dead) and confirm marker is gone
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|