swarph-cli 0.7.4__tar.gz → 0.7.6__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.4/src/swarph_cli.egg-info → swarph_cli-0.7.6}/PKG-INFO +1 -1
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/pyproject.toml +1 -1
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/src/swarph_cli/__init__.py +1 -1
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/src/swarph_cli/commands/spawn.py +36 -1
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/src/swarph_cli/commands/watchdog.py +131 -4
- {swarph_cli-0.7.4 → swarph_cli-0.7.6/src/swarph_cli.egg-info}/PKG-INFO +1 -1
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/tests/test_spawn_command.py +89 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/LICENSE +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/README.md +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/setup.cfg +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/src/swarph_cli/caller.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/src/swarph_cli/cell.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/src/swarph_cli/commands/__init__.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/src/swarph_cli/commands/chat.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/src/swarph_cli/commands/daemon.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/src/swarph_cli/commands/hook_output.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/src/swarph_cli/commands/import_session.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/src/swarph_cli/commands/install_hook.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/src/swarph_cli/commands/onboard.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/src/swarph_cli/commands/ratify.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/src/swarph_cli/main.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/src/swarph_cli/parsers/__init__.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/src/swarph_cli/parsers/claude.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/src/swarph_cli/systemd/swarph-watchdog.default +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/src/swarph_cli/systemd/swarph-watchdog.service +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/src/swarph_cli/systemd/swarph-watchdog.timer +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/src/swarph_cli.egg-info/SOURCES.txt +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/src/swarph_cli.egg-info/entry_points.txt +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/src/swarph_cli.egg-info/requires.txt +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/src/swarph_cli.egg-info/top_level.txt +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/tests/test_cell_loader.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/tests/test_chat_command.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/tests/test_claude_parser.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/tests/test_daemon_command.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/tests/test_hook_output.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/tests/test_import_command.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/tests/test_install_hook.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/tests/test_main.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/tests/test_onboard_command.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/tests/test_ratify_command.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/tests/test_smoke_chat.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/tests/test_smoke_one_shot.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/tests/test_smoke_phase_5_5.py +0 -0
- {swarph_cli-0.7.4 → swarph_cli-0.7.6}/tests/test_watchdog.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.6
|
|
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.6"
|
|
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" }
|
|
@@ -27,6 +27,7 @@ import argparse
|
|
|
27
27
|
import os
|
|
28
28
|
import shutil
|
|
29
29
|
import sys
|
|
30
|
+
from pathlib import Path
|
|
30
31
|
from typing import Optional
|
|
31
32
|
|
|
32
33
|
from swarph_cli import __version__
|
|
@@ -200,6 +201,33 @@ def _resolve_cell(args: argparse.Namespace) -> tuple[Cell, Optional[str]]:
|
|
|
200
201
|
return load_cell(path), requested_role
|
|
201
202
|
|
|
202
203
|
|
|
204
|
+
def _session_state_exists(session_id: str) -> bool:
|
|
205
|
+
"""True if Claude Code already has on-disk session state for this UUID.
|
|
206
|
+
|
|
207
|
+
Closes v0.7.4 spawn-bug surfaced 2026-05-14 post-reboot (DM #1255):
|
|
208
|
+
`claude --session-id <UUID>` rejects with "Session ID <UUID> is already
|
|
209
|
+
in use" when session-state files exist on disk, even after reboot
|
|
210
|
+
(files persist; the in-use check is filesystem-based not runtime-lock-
|
|
211
|
+
based). Switching to `claude --resume <UUID>` is the correct semantic
|
|
212
|
+
when the UUID's state already exists.
|
|
213
|
+
|
|
214
|
+
Probes the three filesystem locations Claude Code stores per-session
|
|
215
|
+
state in: ~/.claude/file-history/<UUID>, ~/.claude/session-env/<UUID>,
|
|
216
|
+
and ~/.claude/projects/<project-hash>/<UUID>.jsonl (the latter
|
|
217
|
+
discovered via glob since project-hash varies).
|
|
218
|
+
"""
|
|
219
|
+
claude_dir = Path.home() / ".claude"
|
|
220
|
+
if (claude_dir / "file-history" / session_id).exists():
|
|
221
|
+
return True
|
|
222
|
+
if (claude_dir / "session-env" / session_id).exists():
|
|
223
|
+
return True
|
|
224
|
+
projects_dir = claude_dir / "projects"
|
|
225
|
+
if projects_dir.exists():
|
|
226
|
+
for _ in projects_dir.glob(f"*/{session_id}.jsonl"):
|
|
227
|
+
return True
|
|
228
|
+
return False
|
|
229
|
+
|
|
230
|
+
|
|
203
231
|
def _build_claude_argv(
|
|
204
232
|
cell: Cell,
|
|
205
233
|
session_id: str,
|
|
@@ -208,7 +236,14 @@ def _build_claude_argv(
|
|
|
208
236
|
effective_role: Optional[str] = None,
|
|
209
237
|
) -> list[str]:
|
|
210
238
|
name_value = effective_role if effective_role is not None else cell.role
|
|
211
|
-
|
|
239
|
+
# v0.7.5: auto-detect existing session state and switch from --session-id
|
|
240
|
+
# (create-new-with-pinned-UUID semantic) to --resume (attach-to-existing
|
|
241
|
+
# semantic). Both pass the same UUID; the verb determines whether claude
|
|
242
|
+
# treats it as fresh-create vs resume-existing.
|
|
243
|
+
if _session_state_exists(session_id):
|
|
244
|
+
argv: list[str] = ["claude", "--name", name_value, "--resume", session_id]
|
|
245
|
+
else:
|
|
246
|
+
argv = ["claude", "--name", name_value, "--session-id", session_id]
|
|
212
247
|
|
|
213
248
|
if not no_starter:
|
|
214
249
|
starter = read_starter_prompt(cell)
|
|
@@ -71,7 +71,9 @@ import subprocess
|
|
|
71
71
|
import sys
|
|
72
72
|
import time
|
|
73
73
|
import urllib.error
|
|
74
|
+
import urllib.parse
|
|
74
75
|
import urllib.request
|
|
76
|
+
from datetime import datetime, timedelta, timezone
|
|
75
77
|
from pathlib import Path
|
|
76
78
|
from typing import Optional
|
|
77
79
|
|
|
@@ -85,12 +87,23 @@ _DEFAULT_GATEWAY_URL = "http://localhost:8788"
|
|
|
85
87
|
# is comfortably above legitimate-pause noise + comfortably below the
|
|
86
88
|
# 30min cursor-staleness threshold, so the two gates compose cleanly.
|
|
87
89
|
_DEFAULT_PANE_ACTIVITY_THRESHOLD_SEC = 600
|
|
90
|
+
# Phase 4 (v0.7.6) — peer-health-event poll defaults. The recovery
|
|
91
|
+
# event we care about is `usage_limit_reset` (throttle cleared; session
|
|
92
|
+
# may be sitting idle unaware of queued DMs). 600s window catches a
|
|
93
|
+
# reset that fired up to 10min before this cron tick. 120s recovery
|
|
94
|
+
# threshold gives the session a brief grace period to notice the reset
|
|
95
|
+
# itself before we send-keys at it.
|
|
96
|
+
_DEFAULT_PEER_HEALTH_WINDOW_SEC = 600
|
|
97
|
+
_DEFAULT_PEER_HEALTH_RECOVERY_THRESHOLD_SEC = 120
|
|
98
|
+
_RECOVERY_EVENT_TYPES = ("usage_limit_reset",)
|
|
88
99
|
|
|
89
100
|
_USAGE = """\
|
|
90
101
|
Usage:
|
|
91
102
|
swarph watchdog --check [--cell ROLE] [--cursor PATH] [--threshold SEC]
|
|
92
103
|
[--gateway URL] [--tmux-session NAME]
|
|
93
104
|
[--peer NAME] [--no-respawn]
|
|
105
|
+
[--peer-health-poll] [--peer-health-window-sec SEC]
|
|
106
|
+
[--peer-health-recovery-threshold SEC]
|
|
94
107
|
[--log PATH] [--verbose]
|
|
95
108
|
swarph watchdog --install-service [--cell ROLE] [--dry-run]
|
|
96
109
|
|
|
@@ -127,6 +140,18 @@ Flags:
|
|
|
127
140
|
--tmux-session NAME tmux session name; default = cell role
|
|
128
141
|
--peer NAME mesh peer name for unread-DM query; default = cell name
|
|
129
142
|
--no-respawn A1 only; don't escalate to A2 (dry-run mode)
|
|
143
|
+
--peer-health-poll Phase 4: also query /peer-health-events.
|
|
144
|
+
On recent usage_limit_reset event, treat
|
|
145
|
+
sessions as wake-candidates even before
|
|
146
|
+
the 30min cursor-staleness threshold.
|
|
147
|
+
Requires MESH_GATEWAY_TOKEN in env.
|
|
148
|
+
--peer-health-window-sec SEC how far back to look for recovery
|
|
149
|
+
events; default 600 (10 min)
|
|
150
|
+
--peer-health-recovery-threshold SEC min cursor staleness before a recovery
|
|
151
|
+
event promotes the session to wake-
|
|
152
|
+
candidate; default 120 (2 min). Avoids
|
|
153
|
+
poking a session that JUST got reset
|
|
154
|
+
and is already self-recovering.
|
|
130
155
|
--log PATH append diagnostic log; default $XDG_STATE_HOME/swarph/watchdog.log
|
|
131
156
|
--verbose also write diagnostics to stderr
|
|
132
157
|
|
|
@@ -282,6 +307,57 @@ def _gateway_unread_count(gateway: str, peer: str, token: Optional[str]) -> Opti
|
|
|
282
307
|
return None
|
|
283
308
|
|
|
284
309
|
|
|
310
|
+
def _gateway_recent_recovery_event(
|
|
311
|
+
gateway: str,
|
|
312
|
+
peer: str,
|
|
313
|
+
window_sec: int,
|
|
314
|
+
token: Optional[str],
|
|
315
|
+
) -> Optional[dict]:
|
|
316
|
+
"""Phase 4 (v0.7.6) — query /peer-health-events for a recent recovery event.
|
|
317
|
+
|
|
318
|
+
Returns the most recent event whose ``event_type`` is in
|
|
319
|
+
``_RECOVERY_EVENT_TYPES`` (currently just ``usage_limit_reset``) for
|
|
320
|
+
this peer within the last ``window_sec`` seconds. Returns None if no
|
|
321
|
+
such event exists OR if the query fails (treat absence + error as
|
|
322
|
+
"no override"; the regular cursor-staleness path still applies).
|
|
323
|
+
|
|
324
|
+
Why this matters: the lab + drop both hit ``usage_limit_reset`` from
|
|
325
|
+
Claude's quota system — the throttle clears, but the session has no
|
|
326
|
+
autonomous mechanism to notice. DMs queued during the throttle sit
|
|
327
|
+
unread until commander manually chimes the session, OR until the
|
|
328
|
+
30min cursor-staleness threshold trips A1. Phase 4 closes that gap
|
|
329
|
+
by lowering the threshold to ``--peer-health-recovery-threshold``
|
|
330
|
+
(default 2min) once the gateway sees the reset event.
|
|
331
|
+
|
|
332
|
+
Detection ≠ recovery distinction: the gateway already CAPTURES these
|
|
333
|
+
events (claude_session_event_logger.py + POST /peer-health-events).
|
|
334
|
+
What was missing was the wake-up mechanism — this function plus the
|
|
335
|
+
fall-through in run_check is the watchdog half of the loop.
|
|
336
|
+
"""
|
|
337
|
+
since_dt = datetime.now(timezone.utc) - timedelta(seconds=window_sec)
|
|
338
|
+
since_iso = since_dt.isoformat()
|
|
339
|
+
query = urllib.parse.urlencode(
|
|
340
|
+
{"peer": peer, "since": since_iso, "limit": 50},
|
|
341
|
+
)
|
|
342
|
+
url = f"{gateway.rstrip('/')}/peer-health-events?{query}"
|
|
343
|
+
req = urllib.request.Request(url)
|
|
344
|
+
if token:
|
|
345
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
346
|
+
try:
|
|
347
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
348
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
349
|
+
except (urllib.error.URLError, urllib.error.HTTPError, OSError, json.JSONDecodeError):
|
|
350
|
+
return None
|
|
351
|
+
events = data.get("events") if isinstance(data, dict) else None
|
|
352
|
+
if not isinstance(events, list):
|
|
353
|
+
return None
|
|
354
|
+
# Server sorts by time DESC, so the first match is the most recent.
|
|
355
|
+
for ev in events:
|
|
356
|
+
if isinstance(ev, dict) and ev.get("event_type") in _RECOVERY_EVENT_TYPES:
|
|
357
|
+
return ev
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
|
|
285
361
|
def _process_alive(tmux_session: str) -> bool:
|
|
286
362
|
"""Detect if a claude process is running inside the tmux session.
|
|
287
363
|
|
|
@@ -515,10 +591,41 @@ def run_check(args: argparse.Namespace) -> int:
|
|
|
515
591
|
diag["cursor_age_sec"] = cursor_age
|
|
516
592
|
|
|
517
593
|
if cursor_age <= threshold:
|
|
518
|
-
#
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
594
|
+
# Phase 4 (v0.7.6) — peer-health-event override. If the gateway
|
|
595
|
+
# observed a recent recovery event (usage_limit_reset) for this
|
|
596
|
+
# peer AND the cursor is at least somewhat stale, fall through
|
|
597
|
+
# to the A1 path so an idle-after-reset session gets nudged.
|
|
598
|
+
# When --peer-health-poll is OFF, behavior is identical to v0.7.5.
|
|
599
|
+
if args.peer_health_poll:
|
|
600
|
+
recovery_event = _gateway_recent_recovery_event(
|
|
601
|
+
gateway, peer, args.peer_health_window_sec, token,
|
|
602
|
+
)
|
|
603
|
+
diag["peer_health_poll"] = True
|
|
604
|
+
diag["recovery_event_seen"] = bool(recovery_event)
|
|
605
|
+
if recovery_event:
|
|
606
|
+
diag["recovery_event_type"] = recovery_event.get("event_type")
|
|
607
|
+
diag["recovery_event_time"] = recovery_event.get("time")
|
|
608
|
+
if recovery_event and cursor_age > args.peer_health_recovery_threshold:
|
|
609
|
+
# Promote to wake-candidate. Don't return — fall through
|
|
610
|
+
# below to the existing process_alive / unread / F1-F3
|
|
611
|
+
# gates, which still get a vote. This is a threshold
|
|
612
|
+
# override, not a gate bypass.
|
|
613
|
+
diag["phase4_override"] = "fall_through_to_a1"
|
|
614
|
+
else:
|
|
615
|
+
# Either no recovery event, OR cursor is fresh enough
|
|
616
|
+
# that the session is likely self-recovering. No action.
|
|
617
|
+
diag["decision"] = (
|
|
618
|
+
"healthy_cursor_fresh_recovery_too_recent"
|
|
619
|
+
if recovery_event
|
|
620
|
+
else "healthy_cursor_fresh"
|
|
621
|
+
)
|
|
622
|
+
_log_event(log_path, "noop", diag, verbose)
|
|
623
|
+
return 0
|
|
624
|
+
else:
|
|
625
|
+
# Cursor recent — Claude has been active. No action.
|
|
626
|
+
diag["decision"] = "healthy_cursor_fresh"
|
|
627
|
+
_log_event(log_path, "noop", diag, verbose)
|
|
628
|
+
return 0
|
|
522
629
|
|
|
523
630
|
# FALLBACK signal: pgrep claude (per mother #1021 AND-gate)
|
|
524
631
|
process_alive = _process_alive(tmux_session)
|
|
@@ -799,6 +906,26 @@ def run_watchdog(argv: Optional[list[str]] = None) -> int:
|
|
|
799
906
|
p.add_argument("--tmux-session", default=None)
|
|
800
907
|
p.add_argument("--peer", default=None)
|
|
801
908
|
p.add_argument("--no-respawn", action="store_true")
|
|
909
|
+
p.add_argument(
|
|
910
|
+
"--peer-health-poll", action="store_true",
|
|
911
|
+
help="Phase 4 (v0.7.6): also query mesh-gateway /peer-health-events. "
|
|
912
|
+
"On recent usage_limit_reset event, treat sessions as wake-"
|
|
913
|
+
"candidates even before the 30min cursor-staleness threshold. "
|
|
914
|
+
"Requires MESH_GATEWAY_TOKEN in env. Default OFF (opt-in).",
|
|
915
|
+
)
|
|
916
|
+
p.add_argument(
|
|
917
|
+
"--peer-health-window-sec",
|
|
918
|
+
type=int,
|
|
919
|
+
default=_DEFAULT_PEER_HEALTH_WINDOW_SEC,
|
|
920
|
+
help="Phase 4: window for recovery-event lookup; default 600 (10 min).",
|
|
921
|
+
)
|
|
922
|
+
p.add_argument(
|
|
923
|
+
"--peer-health-recovery-threshold",
|
|
924
|
+
type=int,
|
|
925
|
+
default=_DEFAULT_PEER_HEALTH_RECOVERY_THRESHOLD_SEC,
|
|
926
|
+
help="Phase 4: min cursor staleness for recovery event to promote "
|
|
927
|
+
"session to wake-candidate; default 120 (2 min).",
|
|
928
|
+
)
|
|
802
929
|
p.add_argument("--log", default=None)
|
|
803
930
|
p.add_argument("--verbose", action="store_true")
|
|
804
931
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: swarph-cli
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.6
|
|
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
|
|
@@ -339,3 +339,92 @@ def test_run_spawn_dry_run_redacts_starter_prompt_in_command(
|
|
|
339
339
|
assert rc == 0
|
|
340
340
|
assert "redacted" not in captured.out # the literal word from the prompt
|
|
341
341
|
assert "starter prompt>" in captured.out # the redaction marker
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# ---------------------------------------------------------------------------
|
|
345
|
+
# v0.7.5 — _session_state_exists + --resume on existing session
|
|
346
|
+
# ---------------------------------------------------------------------------
|
|
347
|
+
#
|
|
348
|
+
# Closes the bug surfaced 2026-05-14 post-reboot: claude --session-id <UUID>
|
|
349
|
+
# rejects with "Session ID <UUID> is already in use" when on-disk session
|
|
350
|
+
# state exists, even after host reboot (files persist; check is filesystem-
|
|
351
|
+
# based not runtime-lock-based). Fix: detect existing state + switch from
|
|
352
|
+
# --session-id (create-new semantic) to --resume (attach-existing semantic).
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def test_session_state_exists_false_for_fresh_uuid(tmp_path, monkeypatch):
|
|
356
|
+
"""No filesystem state for the UUID = fresh; _build_claude_argv uses
|
|
357
|
+
--session-id (create-new semantic)."""
|
|
358
|
+
from swarph_cli.commands.spawn import _session_state_exists
|
|
359
|
+
|
|
360
|
+
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
|
361
|
+
fresh_uuid = "00000000-0000-0000-0000-000000000000"
|
|
362
|
+
assert _session_state_exists(fresh_uuid) is False
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def test_session_state_exists_true_when_file_history_present(tmp_path, monkeypatch):
|
|
366
|
+
"""File-history dir alone is enough to flip detection (any one of the
|
|
367
|
+
three location signals triggers)."""
|
|
368
|
+
from swarph_cli.commands.spawn import _session_state_exists
|
|
369
|
+
|
|
370
|
+
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
|
371
|
+
uuid = "a30e406c-8bae-4ea2-8cb2-fb0dff35a6f0"
|
|
372
|
+
(tmp_path / ".claude" / "file-history" / uuid).mkdir(parents=True)
|
|
373
|
+
assert _session_state_exists(uuid) is True
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def test_session_state_exists_true_when_session_env_present(tmp_path, monkeypatch):
|
|
377
|
+
from swarph_cli.commands.spawn import _session_state_exists
|
|
378
|
+
|
|
379
|
+
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
|
380
|
+
uuid = "a30e406c-8bae-4ea2-8cb2-fb0dff35a6f0"
|
|
381
|
+
(tmp_path / ".claude" / "session-env").mkdir(parents=True)
|
|
382
|
+
(tmp_path / ".claude" / "session-env" / uuid).write_text("")
|
|
383
|
+
assert _session_state_exists(uuid) is True
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def test_session_state_exists_true_when_project_jsonl_present(tmp_path, monkeypatch):
|
|
387
|
+
"""Projects path varies by project-hash; glob discovers any match."""
|
|
388
|
+
from swarph_cli.commands.spawn import _session_state_exists
|
|
389
|
+
|
|
390
|
+
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
|
391
|
+
uuid = "a30e406c-8bae-4ea2-8cb2-fb0dff35a6f0"
|
|
392
|
+
proj = tmp_path / ".claude" / "projects" / "-some-project-hash"
|
|
393
|
+
proj.mkdir(parents=True)
|
|
394
|
+
(proj / f"{uuid}.jsonl").write_text("{}\n")
|
|
395
|
+
assert _session_state_exists(uuid) is True
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def test_build_claude_argv_uses_session_id_when_fresh(fake_cell_yaml, tmp_path, monkeypatch):
|
|
399
|
+
"""No prior session state → --session-id (create-new) verb."""
|
|
400
|
+
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
|
401
|
+
cell = load_cell(fake_cell_yaml)
|
|
402
|
+
argv = _build_claude_argv(
|
|
403
|
+
cell=cell,
|
|
404
|
+
session_id="00000000-0000-0000-0000-000000000000",
|
|
405
|
+
no_starter=True,
|
|
406
|
+
passthrough=[],
|
|
407
|
+
)
|
|
408
|
+
assert "--session-id" in argv
|
|
409
|
+
assert "--resume" not in argv
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def test_build_claude_argv_uses_resume_when_state_exists(fake_cell_yaml, tmp_path, monkeypatch):
|
|
413
|
+
"""Prior session state exists → --resume (attach-existing) verb.
|
|
414
|
+
|
|
415
|
+
Closes the v0.7.4 spawn-after-reboot rejection class.
|
|
416
|
+
"""
|
|
417
|
+
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
|
418
|
+
uuid = "a30e406c-8bae-4ea2-8cb2-fb0dff35a6f0"
|
|
419
|
+
(tmp_path / ".claude" / "file-history" / uuid).mkdir(parents=True)
|
|
420
|
+
cell = load_cell(fake_cell_yaml)
|
|
421
|
+
argv = _build_claude_argv(
|
|
422
|
+
cell=cell,
|
|
423
|
+
session_id=uuid,
|
|
424
|
+
no_starter=True,
|
|
425
|
+
passthrough=[],
|
|
426
|
+
)
|
|
427
|
+
assert "--resume" in argv
|
|
428
|
+
assert "--session-id" not in argv
|
|
429
|
+
# UUID still passed (just as --resume's value not --session-id's)
|
|
430
|
+
assert uuid in argv
|
|
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
|
|
File without changes
|
|
File without changes
|