Skip to content

Commit 6a18f80

Browse files
committed
fix: prevent worker from stopping when another session is still active
1 parent 4110326 commit 6a18f80

3 files changed

Lines changed: 100 additions & 33 deletions

File tree

4.77 KB
Binary file not shown.

pilot/hooks/session_end.py

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,65 @@
11
#!/usr/bin/env python3
22
"""SessionEnd hook - stops worker only when no other sessions are active.
33
4-
Sends a 'Session Ended' notification. Spec-specific notifications
5-
(verification_complete, plan_approval) are sent by the spec skills
6-
via `pilot notify` to avoid duplication.
4+
Checks session directories directly (no subprocess) for reliability during
5+
shutdown. Only stops the worker when the current session is the last active one.
76
"""
87

98
from __future__ import annotations
109

11-
import json
1210
import os
1311
import subprocess
1412
import sys
1513
from pathlib import Path
1614

1715
sys.path.insert(0, str(Path(__file__).parent))
1816

19-
PILOT_BIN = Path.home() / ".pilot" / "bin" / "pilot"
17+
SESSIONS_DIR = Path.home() / ".pilot" / "sessions"
18+
SKIP_NAMES = {"default", "pipes"}
2019

2120

22-
def _get_active_session_count() -> int:
23-
"""Get active session count from the pilot binary."""
21+
def _has_other_active_sessions() -> bool:
22+
"""Check if any other Pilot wrapper sessions are still active.
23+
24+
Iterates session directories directly (no subprocess) to avoid failures
25+
during shutdown. Skips the current session via PILOT_SESSION_ID.
26+
Returns True on any error (safe default: don't stop the worker).
27+
"""
2428
try:
25-
result = subprocess.run(
26-
[str(PILOT_BIN), "sessions", "--json"],
27-
capture_output=True,
28-
text=True,
29-
check=False,
30-
timeout=10,
31-
)
32-
if result.returncode == 0:
33-
data = json.loads(result.stdout)
34-
return data.get("count", 0)
35-
except (json.JSONDecodeError, OSError, subprocess.TimeoutExpired):
36-
pass
37-
return 0
29+
if not SESSIONS_DIR.exists():
30+
return False
31+
32+
my_session = os.environ.get("PILOT_SESSION_ID", "")
33+
34+
for entry in SESSIONS_DIR.iterdir():
35+
if not entry.is_dir() or entry.name in SKIP_NAMES:
36+
continue
37+
if entry.name == my_session:
38+
continue
39+
try:
40+
pid = int(entry.name)
41+
except ValueError:
42+
continue
43+
try:
44+
os.kill(pid, 0)
45+
except OSError:
46+
continue
47+
48+
# Another wrapper PID is alive → another session exists
49+
return True
50+
51+
return False
52+
except OSError:
53+
# Can't read directory → assume other sessions exist (safe default)
54+
return True
3855

3956

4057
def main() -> int:
4158
plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
4259
if not plugin_root:
4360
return 0
4461

45-
count = _get_active_session_count()
46-
if count > 1:
62+
if _has_other_active_sessions():
4763
return 0
4864

4965
stop_script = Path(plugin_root) / "scripts" / "worker-service.cjs"
Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
"""Tests for session_end hook — worker stop behavior."""
1+
"""Tests for session_end hook — worker stop behavior with direct filesystem checks."""
22

33
from __future__ import annotations
44

55
import os
6+
from pathlib import Path
67
from unittest.mock import MagicMock, patch
78

89
import session_end
@@ -17,11 +18,16 @@ def test_returns_early_without_plugin_root():
1718
assert result == 0
1819

1920

20-
def test_skips_stop_when_other_sessions_active():
21+
def test_skips_stop_when_other_sessions_active(tmp_path: Path):
2122
"""Should skip worker stop when other Pilot sessions are running."""
23+
base = tmp_path / "sessions"
24+
(base / "1001").mkdir(parents=True)
25+
(base / "2002").mkdir(parents=True)
26+
2227
with (
23-
patch.dict(os.environ, {"CLAUDE_PLUGIN_ROOT": "/fake/plugin"}),
24-
patch("session_end._get_active_session_count", return_value=2),
28+
patch.dict(os.environ, {"CLAUDE_PLUGIN_ROOT": "/fake/plugin", "PILOT_SESSION_ID": "1001"}),
29+
patch.object(session_end, "SESSIONS_DIR", base),
30+
patch("session_end.os.kill", return_value=None),
2531
patch("session_end.subprocess.run") as mock_run,
2632
):
2733
result = session_end.main()
@@ -30,11 +36,14 @@ def test_skips_stop_when_other_sessions_active():
3036
mock_run.assert_not_called()
3137

3238

33-
def test_stops_worker_when_no_other_sessions():
39+
def test_stops_worker_when_no_other_sessions(tmp_path: Path):
3440
"""Should stop worker when this is the only active session."""
41+
base = tmp_path / "sessions"
42+
(base / "1001").mkdir(parents=True)
43+
3544
with (
36-
patch.dict(os.environ, {"CLAUDE_PLUGIN_ROOT": "/fake/plugin"}),
37-
patch("session_end._get_active_session_count", return_value=1),
45+
patch.dict(os.environ, {"CLAUDE_PLUGIN_ROOT": "/fake/plugin", "PILOT_SESSION_ID": "1001"}),
46+
patch.object(session_end, "SESSIONS_DIR", base),
3847
patch("session_end.subprocess.run", return_value=MagicMock(returncode=0)) as mock_run,
3948
):
4049
result = session_end.main()
@@ -44,14 +53,56 @@ def test_stops_worker_when_no_other_sessions():
4453
assert "stop" in str(mock_run.call_args)
4554

4655

47-
def test_stops_worker_when_zero_sessions():
48-
"""Should stop worker and return 0 when no sessions active."""
56+
def test_stops_worker_when_zero_sessions(tmp_path: Path):
57+
"""Should stop worker and return 0 when no sessions exist at all."""
58+
base = tmp_path / "sessions"
59+
base.mkdir(parents=True)
60+
4961
with (
50-
patch.dict(os.environ, {"CLAUDE_PLUGIN_ROOT": "/fake/plugin"}),
51-
patch("session_end._get_active_session_count", return_value=0),
62+
patch.dict(os.environ, {"CLAUDE_PLUGIN_ROOT": "/fake/plugin", "PILOT_SESSION_ID": "1001"}),
63+
patch.object(session_end, "SESSIONS_DIR", base),
5264
patch("session_end.subprocess.run", return_value=MagicMock(returncode=0)) as mock_run,
5365
):
5466
result = session_end.main()
5567

5668
assert result == 0
5769
mock_run.assert_called_once()
70+
71+
72+
def test_safe_default_on_directory_error():
73+
"""Should NOT stop worker when directory can't be read (safe default)."""
74+
mock_dir = MagicMock()
75+
mock_dir.exists.side_effect = OSError("permission denied")
76+
77+
with (
78+
patch.dict(os.environ, {"CLAUDE_PLUGIN_ROOT": "/fake/plugin", "PILOT_SESSION_ID": "1001"}),
79+
patch.object(session_end, "SESSIONS_DIR", mock_dir),
80+
patch("session_end.subprocess.run") as mock_run,
81+
):
82+
result = session_end.main()
83+
84+
assert result == 0
85+
mock_run.assert_not_called()
86+
87+
88+
def test_skips_dead_pid_sessions(tmp_path: Path):
89+
"""Should not count dead PID directories as active sessions."""
90+
base = tmp_path / "sessions"
91+
(base / "1001").mkdir(parents=True) # current
92+
(base / "9999").mkdir(parents=True) # dead
93+
94+
def kill_side_effect(pid: int, _sig: int) -> None:
95+
if pid == 9999:
96+
raise OSError("No such process")
97+
98+
with (
99+
patch.dict(os.environ, {"CLAUDE_PLUGIN_ROOT": "/fake/plugin", "PILOT_SESSION_ID": "1001"}),
100+
patch.object(session_end, "SESSIONS_DIR", base),
101+
patch("session_end.os.kill", side_effect=kill_side_effect),
102+
patch("session_end.subprocess.run", return_value=MagicMock(returncode=0)) as mock_run,
103+
):
104+
result = session_end.main()
105+
106+
assert result == 0
107+
mock_run.assert_called_once()
108+
assert "stop" in str(mock_run.call_args)

0 commit comments

Comments
 (0)