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.
Files changed (42) hide show
  1. {swarph_cli-0.7.1/src/swarph_cli.egg-info → swarph_cli-0.7.2}/PKG-INFO +1 -1
  2. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/pyproject.toml +1 -1
  3. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/__init__.py +1 -1
  4. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/commands/watchdog.py +184 -13
  5. {swarph_cli-0.7.1 → swarph_cli-0.7.2/src/swarph_cli.egg-info}/PKG-INFO +1 -1
  6. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_watchdog.py +133 -1
  7. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/LICENSE +0 -0
  8. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/README.md +0 -0
  9. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/setup.cfg +0 -0
  10. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/caller.py +0 -0
  11. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/cell.py +0 -0
  12. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/commands/__init__.py +0 -0
  13. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/commands/chat.py +0 -0
  14. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/commands/daemon.py +0 -0
  15. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/commands/hook_output.py +0 -0
  16. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/commands/import_session.py +0 -0
  17. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/commands/install_hook.py +0 -0
  18. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/commands/onboard.py +0 -0
  19. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/commands/ratify.py +0 -0
  20. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/commands/spawn.py +0 -0
  21. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/main.py +0 -0
  22. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/parsers/__init__.py +0 -0
  23. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli/parsers/claude.py +0 -0
  24. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli.egg-info/SOURCES.txt +0 -0
  25. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
  26. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli.egg-info/entry_points.txt +0 -0
  27. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli.egg-info/requires.txt +0 -0
  28. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/src/swarph_cli.egg-info/top_level.txt +0 -0
  29. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_cell_loader.py +0 -0
  30. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_chat_command.py +0 -0
  31. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_claude_parser.py +0 -0
  32. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_daemon_command.py +0 -0
  33. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_hook_output.py +0 -0
  34. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_import_command.py +0 -0
  35. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_install_hook.py +0 -0
  36. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_main.py +0 -0
  37. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_onboard_command.py +0 -0
  38. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_ratify_command.py +0 -0
  39. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_smoke_chat.py +0 -0
  40. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_smoke_one_shot.py +0 -0
  41. {swarph_cli-0.7.1 → swarph_cli-0.7.2}/tests/test_smoke_phase_5_5.py +0 -0
  42. {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.1
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.1"
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" }
@@ -16,6 +16,6 @@ The architecture splits CLI from substrate so:
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- __version__ = "0.7.1"
19
+ __version__ = "0.7.2"
20
20
 
21
21
  __all__ = ["__version__"]
@@ -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(role: str, explicit: Optional[str]) -> Path:
141
- """Resolve cursor file path with documented fallback chain."""
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`` alone today. When the F4 follow-up (cell.yaml-pinned
276
- cursor_path + tmux_session per mother+beta #1064/#1065) lands and the
277
- sibling-instance pattern (alpha+beta drop-on-meta-edge per
278
- project_drop_mitosis_to_meta_edge) ships at scale, two siblings sharing
279
- the same base ``role`` would clobber each other's markers. Re-key on
280
- ``(role, tmux_session)`` once F4 lands — flagged by mother in #1103.
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
- return log_path.parent / f"a1-fired-{role}.marker"
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
- cursor = _resolve_cursor_path(role, args.cursor)
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
- tmux_session = args.tmux_session or role
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.1
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
- marker = log_path.parent / "a1-fired-lab.marker"
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