taskmux 0.2.5__tar.gz → 0.2.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.
- {taskmux-0.2.5 → taskmux-0.2.6}/PKG-INFO +1 -1
- {taskmux-0.2.5 → taskmux-0.2.6}/pyproject.toml +1 -1
- {taskmux-0.2.5 → taskmux-0.2.6}/taskmux/config.py +8 -0
- {taskmux-0.2.5 → taskmux-0.2.6}/taskmux/daemon.py +59 -2
- {taskmux-0.2.5 → taskmux-0.2.6}/taskmux/models.py +4 -0
- {taskmux-0.2.5 → taskmux-0.2.6}/taskmux/tmux_manager.py +125 -8
- {taskmux-0.2.5 → taskmux-0.2.6}/.gitignore +0 -0
- {taskmux-0.2.5 → taskmux-0.2.6}/LICENSE +0 -0
- {taskmux-0.2.5 → taskmux-0.2.6}/README.md +0 -0
- {taskmux-0.2.5 → taskmux-0.2.6}/taskmux/__init__.py +0 -0
- {taskmux-0.2.5 → taskmux-0.2.6}/taskmux/agent.py +0 -0
- {taskmux-0.2.5 → taskmux-0.2.6}/taskmux/cli.py +0 -0
- {taskmux-0.2.5 → taskmux-0.2.6}/taskmux/hooks.py +0 -0
- {taskmux-0.2.5 → taskmux-0.2.6}/taskmux/init.py +0 -0
- {taskmux-0.2.5 → taskmux-0.2.6}/taskmux/main.py +0 -0
- {taskmux-0.2.5 → taskmux-0.2.6}/taskmux/templates/claude.md +0 -0
|
@@ -106,6 +106,8 @@ def writeConfig(path: Path | None, config: TaskmuxConfig) -> Path:
|
|
|
106
106
|
inner.add("auto_start", False)
|
|
107
107
|
if task_cfg.cwd is not None:
|
|
108
108
|
inner.add("cwd", task_cfg.cwd)
|
|
109
|
+
if task_cfg.port is not None:
|
|
110
|
+
inner.add("port", task_cfg.port)
|
|
109
111
|
if task_cfg.health_check is not None:
|
|
110
112
|
inner.add("health_check", task_cfg.health_check)
|
|
111
113
|
if task_cfg.health_interval != 10:
|
|
@@ -114,6 +116,12 @@ def writeConfig(path: Path | None, config: TaskmuxConfig) -> Path:
|
|
|
114
116
|
inner.add("health_timeout", task_cfg.health_timeout)
|
|
115
117
|
if task_cfg.health_retries != 3:
|
|
116
118
|
inner.add("health_retries", task_cfg.health_retries)
|
|
119
|
+
if task_cfg.stop_grace_period != 5:
|
|
120
|
+
inner.add("stop_grace_period", task_cfg.stop_grace_period)
|
|
121
|
+
if task_cfg.max_restarts != 5:
|
|
122
|
+
inner.add("max_restarts", task_cfg.max_restarts)
|
|
123
|
+
if task_cfg.restart_backoff != 2.0:
|
|
124
|
+
inner.add("restart_backoff", task_cfg.restart_backoff)
|
|
117
125
|
if task_cfg.depends_on:
|
|
118
126
|
inner.add("depends_on", task_cfg.depends_on)
|
|
119
127
|
# Task-level hooks
|
|
@@ -39,6 +39,26 @@ class ConfigWatcher(FileSystemEventHandler):
|
|
|
39
39
|
self.taskmux_cli.handle_config_reload()
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
class RestartTracker:
|
|
43
|
+
"""Tracks per-task restart counts and timestamps for backoff."""
|
|
44
|
+
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
self._data: dict[str, dict[str, float]] = {}
|
|
47
|
+
|
|
48
|
+
def get(self, task_name: str) -> dict[str, float]:
|
|
49
|
+
return self._data.get(task_name, {"count": 0, "last": 0.0})
|
|
50
|
+
|
|
51
|
+
def record(self, task_name: str) -> None:
|
|
52
|
+
info = self.get(task_name)
|
|
53
|
+
self._data[task_name] = {
|
|
54
|
+
"count": info["count"] + 1,
|
|
55
|
+
"last": time.time(),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def reset(self, task_name: str) -> None:
|
|
59
|
+
self._data.pop(task_name, None)
|
|
60
|
+
|
|
61
|
+
|
|
42
62
|
class TaskmuxDaemon:
|
|
43
63
|
"""Daemon mode for Taskmux with enhanced monitoring and API"""
|
|
44
64
|
|
|
@@ -51,6 +71,7 @@ class TaskmuxDaemon:
|
|
|
51
71
|
self.health_check_interval = 30
|
|
52
72
|
self.health_check_task: asyncio.Task | None = None
|
|
53
73
|
self.websocket_clients: set = set()
|
|
74
|
+
self.restart_tracker = RestartTracker()
|
|
54
75
|
self.logger = self._setup_logging()
|
|
55
76
|
|
|
56
77
|
signal.signal(signal.SIGINT, self._signal_handler)
|
|
@@ -124,11 +145,11 @@ class TaskmuxDaemon:
|
|
|
124
145
|
self.logger.info("Taskmux daemon stopped")
|
|
125
146
|
|
|
126
147
|
async def _health_check_loop(self) -> None:
|
|
127
|
-
"""Continuous health checking loop"""
|
|
148
|
+
"""Continuous health checking loop with restart backoff."""
|
|
128
149
|
while self.running:
|
|
129
150
|
try:
|
|
130
151
|
if self.cli and self.cli.tmux.session_exists():
|
|
131
|
-
self.
|
|
152
|
+
self._auto_restart_with_backoff()
|
|
132
153
|
|
|
133
154
|
if self.websocket_clients:
|
|
134
155
|
status = await self._get_full_status()
|
|
@@ -139,6 +160,42 @@ class TaskmuxDaemon:
|
|
|
139
160
|
self.logger.error(f"Health check error: {e}")
|
|
140
161
|
await asyncio.sleep(5)
|
|
141
162
|
|
|
163
|
+
def _auto_restart_with_backoff(self) -> None:
|
|
164
|
+
"""Auto-restart unhealthy tasks with exponential backoff."""
|
|
165
|
+
assert self.cli is not None
|
|
166
|
+
now = time.time()
|
|
167
|
+
|
|
168
|
+
for task_name, task_cfg in self.cli.config.tasks.items():
|
|
169
|
+
healthy = self.cli.tmux.check_task_health(task_name)
|
|
170
|
+
|
|
171
|
+
if healthy:
|
|
172
|
+
# Reset tracker if healthy for >60s
|
|
173
|
+
info = self.restart_tracker.get(task_name)
|
|
174
|
+
if info["count"] > 0 and now - info["last"] > 60:
|
|
175
|
+
self.restart_tracker.reset(task_name)
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
# Skip if not previously healthy (avoid restart loop on first check)
|
|
179
|
+
prev_health = self.cli.tmux.task_health.get(task_name, {}).get("healthy", True)
|
|
180
|
+
if not prev_health:
|
|
181
|
+
info = self.restart_tracker.get(task_name)
|
|
182
|
+
|
|
183
|
+
# Check max_restarts
|
|
184
|
+
if task_cfg.max_restarts and info["count"] >= task_cfg.max_restarts:
|
|
185
|
+
self.logger.warning(
|
|
186
|
+
f"Task '{task_name}' exceeded max restarts ({task_cfg.max_restarts})"
|
|
187
|
+
)
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
# Check backoff delay
|
|
191
|
+
delay = min(task_cfg.restart_backoff ** info["count"], 60)
|
|
192
|
+
if info["last"] and now - info["last"] < delay:
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
self.logger.info(f"Auto-restarting unhealthy task: {task_name}")
|
|
196
|
+
self.cli.tmux.restart_task(task_name)
|
|
197
|
+
self.restart_tracker.record(task_name)
|
|
198
|
+
|
|
142
199
|
async def _start_api_server(self) -> None:
|
|
143
200
|
"""Start WebSocket API server"""
|
|
144
201
|
|
|
@@ -37,10 +37,14 @@ class TaskConfig(_StrictConfig):
|
|
|
37
37
|
command: str
|
|
38
38
|
auto_start: bool = True
|
|
39
39
|
cwd: str | None = None
|
|
40
|
+
port: int | None = None
|
|
40
41
|
health_check: str | None = None
|
|
41
42
|
health_interval: int = 10
|
|
42
43
|
health_timeout: int = 5
|
|
43
44
|
health_retries: int = 3
|
|
45
|
+
stop_grace_period: int = 5
|
|
46
|
+
max_restarts: int = 5
|
|
47
|
+
restart_backoff: float = 2.0
|
|
44
48
|
depends_on: list[str] = []
|
|
45
49
|
hooks: HookConfig = HookConfig()
|
|
46
50
|
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import signal as sig
|
|
5
8
|
import subprocess
|
|
6
9
|
import time
|
|
7
10
|
from collections import deque
|
|
@@ -14,6 +17,8 @@ from rich.markup import escape
|
|
|
14
17
|
from .hooks import runHook
|
|
15
18
|
from .models import TaskmuxConfig
|
|
16
19
|
|
|
20
|
+
SHELL_NAMES = frozenset(("bash", "zsh", "sh", "fish"))
|
|
21
|
+
|
|
17
22
|
TASK_COLORS = ["cyan", "green", "yellow", "magenta", "blue", "red"]
|
|
18
23
|
|
|
19
24
|
|
|
@@ -74,11 +79,61 @@ class TmuxManager:
|
|
|
74
79
|
window = self._get_session().windows.get(window_name=task_name, default=None)
|
|
75
80
|
if window and window.active_pane:
|
|
76
81
|
cmd = getattr(window.active_pane, "pane_current_command", "")
|
|
77
|
-
return cmd != "" and cmd
|
|
82
|
+
return cmd != "" and cmd not in SHELL_NAMES
|
|
78
83
|
except Exception:
|
|
79
84
|
pass
|
|
80
85
|
return False
|
|
81
86
|
|
|
87
|
+
def _wait_for_exit(self, pane: libtmux.Pane, timeout: float) -> bool:
|
|
88
|
+
"""Poll pane_current_command until it returns to a shell or timeout."""
|
|
89
|
+
elapsed = 0.0
|
|
90
|
+
while elapsed < timeout:
|
|
91
|
+
time.sleep(0.5)
|
|
92
|
+
elapsed += 0.5
|
|
93
|
+
cmd = getattr(pane, "pane_current_command", "")
|
|
94
|
+
if cmd == "" or cmd in SHELL_NAMES:
|
|
95
|
+
return True
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
def _get_pane_child_pid(self, pane: libtmux.Pane) -> int | None:
|
|
99
|
+
"""Get the child process PID running inside the pane's shell."""
|
|
100
|
+
shell_pid = getattr(pane, "pane_pid", None)
|
|
101
|
+
if not shell_pid:
|
|
102
|
+
return None
|
|
103
|
+
try:
|
|
104
|
+
result = subprocess.run(
|
|
105
|
+
["pgrep", "-P", str(shell_pid)],
|
|
106
|
+
capture_output=True,
|
|
107
|
+
text=True,
|
|
108
|
+
)
|
|
109
|
+
pids = result.stdout.strip().split("\n")
|
|
110
|
+
return int(pids[0]) if pids and pids[0] else None
|
|
111
|
+
except (ValueError, OSError):
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
def _kill_process_tree(self, pid: int, signal_num: int = sig.SIGKILL) -> None:
|
|
115
|
+
"""Kill process and all children via process group."""
|
|
116
|
+
try:
|
|
117
|
+
pgid = os.getpgid(pid)
|
|
118
|
+
os.killpg(pgid, signal_num)
|
|
119
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
def _cleanup_port(self, port: int) -> None:
|
|
123
|
+
"""Kill any process listening on port."""
|
|
124
|
+
try:
|
|
125
|
+
result = subprocess.run(
|
|
126
|
+
["lsof", "-ti", f":{port}"],
|
|
127
|
+
capture_output=True,
|
|
128
|
+
text=True,
|
|
129
|
+
)
|
|
130
|
+
for pid_str in result.stdout.strip().split("\n"):
|
|
131
|
+
if pid_str.strip():
|
|
132
|
+
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
|
133
|
+
os.kill(int(pid_str.strip()), sig.SIGKILL)
|
|
134
|
+
except OSError:
|
|
135
|
+
pass
|
|
136
|
+
|
|
82
137
|
def is_task_healthy(self, task_name: str) -> bool:
|
|
83
138
|
"""Check task health. Uses health_check command if configured, falls back to pane-alive."""
|
|
84
139
|
task_cfg = self.config.tasks.get(task_name)
|
|
@@ -188,6 +243,10 @@ class TmuxManager:
|
|
|
188
243
|
sess = self._get_session()
|
|
189
244
|
task_cfg = self.config.tasks[task_name]
|
|
190
245
|
|
|
246
|
+
# Kill anything occupying the port before starting
|
|
247
|
+
if task_cfg.port:
|
|
248
|
+
self._cleanup_port(task_cfg.port)
|
|
249
|
+
|
|
191
250
|
# Check if already running
|
|
192
251
|
existing = sess.windows.get(window_name=task_name, default=None)
|
|
193
252
|
if existing:
|
|
@@ -226,7 +285,7 @@ class TmuxManager:
|
|
|
226
285
|
print(f"Started task '{task_name}'")
|
|
227
286
|
|
|
228
287
|
def stop_task(self, task_name: str) -> None:
|
|
229
|
-
"""Graceful stop
|
|
288
|
+
"""Graceful stop with signal escalation: C-c → SIGTERM → SIGKILL."""
|
|
230
289
|
if not self.session_exists():
|
|
231
290
|
print(f"Session '{self.config.name}' doesn't exist")
|
|
232
291
|
return
|
|
@@ -249,8 +308,22 @@ class TmuxManager:
|
|
|
249
308
|
|
|
250
309
|
pane = window.active_pane
|
|
251
310
|
if pane:
|
|
311
|
+
# Phase 1: SIGINT (Ctrl+C)
|
|
252
312
|
pane.send_keys("C-c")
|
|
253
313
|
|
|
314
|
+
if not self._wait_for_exit(pane, timeout=task_cfg.stop_grace_period):
|
|
315
|
+
# Phase 2: SIGTERM via process group
|
|
316
|
+
pid = self._get_pane_child_pid(pane)
|
|
317
|
+
if pid:
|
|
318
|
+
self._kill_process_tree(pid, sig.SIGTERM)
|
|
319
|
+
|
|
320
|
+
if not self._wait_for_exit(pane, timeout=3):
|
|
321
|
+
# Phase 3: SIGKILL entire process group
|
|
322
|
+
if pid:
|
|
323
|
+
self._kill_process_tree(pid, sig.SIGKILL)
|
|
324
|
+
# Final wait for cleanup
|
|
325
|
+
self._wait_for_exit(pane, timeout=1)
|
|
326
|
+
|
|
254
327
|
# Hooks: task after_stop, then global after_stop
|
|
255
328
|
runHook(task_cfg.hooks.after_stop, task_name)
|
|
256
329
|
runHook(self.config.hooks.after_stop, task_name)
|
|
@@ -322,7 +395,7 @@ class TmuxManager:
|
|
|
322
395
|
print(f"Started session '{self.config.name}' with {len(auto_tasks)} tasks")
|
|
323
396
|
|
|
324
397
|
def stop_all(self) -> None:
|
|
325
|
-
"""Stop all tasks then kill session."""
|
|
398
|
+
"""Stop all tasks with signal escalation then kill session."""
|
|
326
399
|
if not self.session_exists():
|
|
327
400
|
print("No session running")
|
|
328
401
|
return
|
|
@@ -330,8 +403,9 @@ class TmuxManager:
|
|
|
330
403
|
# Global before_stop
|
|
331
404
|
runHook(self.config.hooks.before_stop)
|
|
332
405
|
|
|
333
|
-
#
|
|
406
|
+
# Phase 1: send C-c to all tasks
|
|
334
407
|
sess = self._get_session()
|
|
408
|
+
pane_map: dict[str, tuple[libtmux.Pane, int | None]] = {}
|
|
335
409
|
for task_name, task_cfg in self.config.tasks.items():
|
|
336
410
|
window = sess.windows.get(window_name=task_name, default=None)
|
|
337
411
|
if window:
|
|
@@ -339,7 +413,30 @@ class TmuxManager:
|
|
|
339
413
|
pane = window.active_pane
|
|
340
414
|
if pane:
|
|
341
415
|
pane.send_keys("C-c")
|
|
342
|
-
|
|
416
|
+
pid = self._get_pane_child_pid(pane)
|
|
417
|
+
pane_map[task_name] = (pane, pid)
|
|
418
|
+
|
|
419
|
+
# Wait for graceful exit (use max grace period across tasks)
|
|
420
|
+
max_grace = max((cfg.stop_grace_period for cfg in self.config.tasks.values()), default=5)
|
|
421
|
+
time.sleep(max_grace)
|
|
422
|
+
|
|
423
|
+
# Phase 2: SIGTERM then SIGKILL any survivors
|
|
424
|
+
for _name, (pane, pid) in pane_map.items():
|
|
425
|
+
cmd = getattr(pane, "pane_current_command", "")
|
|
426
|
+
if cmd and cmd not in SHELL_NAMES and pid:
|
|
427
|
+
self._kill_process_tree(pid, sig.SIGTERM)
|
|
428
|
+
|
|
429
|
+
time.sleep(1)
|
|
430
|
+
|
|
431
|
+
for _name, (pane, pid) in pane_map.items():
|
|
432
|
+
cmd = getattr(pane, "pane_current_command", "")
|
|
433
|
+
if cmd and cmd not in SHELL_NAMES and pid:
|
|
434
|
+
self._kill_process_tree(pid, sig.SIGKILL)
|
|
435
|
+
|
|
436
|
+
# Run after_stop hooks
|
|
437
|
+
for task_name in pane_map:
|
|
438
|
+
task_cfg = self.config.tasks[task_name]
|
|
439
|
+
runHook(task_cfg.hooks.after_stop, task_name)
|
|
343
440
|
|
|
344
441
|
sess.kill()
|
|
345
442
|
|
|
@@ -358,7 +455,7 @@ class TmuxManager:
|
|
|
358
455
|
self.start_all()
|
|
359
456
|
|
|
360
457
|
def restart_task(self, task_name: str) -> None:
|
|
361
|
-
"""Restart a specific task
|
|
458
|
+
"""Restart a specific task with full stop escalation."""
|
|
362
459
|
if not self.session_exists():
|
|
363
460
|
print(f"Session '{self.config.name}' doesn't exist. Run 'taskmux start' first.")
|
|
364
461
|
return
|
|
@@ -373,13 +470,25 @@ class TmuxManager:
|
|
|
373
470
|
|
|
374
471
|
window = sess.windows.get(window_name=task_name, default=None)
|
|
375
472
|
if window:
|
|
473
|
+
# Full stop with signal escalation
|
|
376
474
|
runHook(task_cfg.hooks.before_stop, task_name)
|
|
377
475
|
pane = window.active_pane
|
|
378
476
|
if pane:
|
|
379
477
|
pane.send_keys("C-c")
|
|
380
|
-
|
|
478
|
+
if not self._wait_for_exit(pane, timeout=task_cfg.stop_grace_period):
|
|
479
|
+
pid = self._get_pane_child_pid(pane)
|
|
480
|
+
if pid:
|
|
481
|
+
self._kill_process_tree(pid, sig.SIGTERM)
|
|
482
|
+
if not self._wait_for_exit(pane, timeout=3):
|
|
483
|
+
if pid:
|
|
484
|
+
self._kill_process_tree(pid, sig.SIGKILL)
|
|
485
|
+
self._wait_for_exit(pane, timeout=1)
|
|
381
486
|
runHook(task_cfg.hooks.after_stop, task_name)
|
|
382
487
|
|
|
488
|
+
# Port cleanup before restart
|
|
489
|
+
if task_cfg.port:
|
|
490
|
+
self._cleanup_port(task_cfg.port)
|
|
491
|
+
|
|
383
492
|
runHook(task_cfg.hooks.before_start, task_name)
|
|
384
493
|
pane = window.active_pane
|
|
385
494
|
if pane:
|
|
@@ -388,6 +497,9 @@ class TmuxManager:
|
|
|
388
497
|
pane.send_keys(command, enter=True)
|
|
389
498
|
runHook(task_cfg.hooks.after_start, task_name)
|
|
390
499
|
else:
|
|
500
|
+
# Port cleanup before start
|
|
501
|
+
if task_cfg.port:
|
|
502
|
+
self._cleanup_port(task_cfg.port)
|
|
391
503
|
runHook(task_cfg.hooks.before_start, task_name)
|
|
392
504
|
self._send_command_to_window(sess, task_name, command, task_cfg.cwd)
|
|
393
505
|
runHook(task_cfg.hooks.after_start, task_name)
|
|
@@ -395,13 +507,18 @@ class TmuxManager:
|
|
|
395
507
|
print(f"Restarted task '{task_name}'")
|
|
396
508
|
|
|
397
509
|
def kill_task(self, task_name: str) -> None:
|
|
398
|
-
"""Kill a specific task"""
|
|
510
|
+
"""Kill a specific task (process group + window)."""
|
|
399
511
|
if not self.session_exists():
|
|
400
512
|
print(f"Session '{self.config.name}' doesn't exist")
|
|
401
513
|
return
|
|
402
514
|
|
|
403
515
|
window = self._get_session().windows.get(window_name=task_name, default=None)
|
|
404
516
|
if window:
|
|
517
|
+
pane = window.active_pane
|
|
518
|
+
if pane:
|
|
519
|
+
pid = self._get_pane_child_pid(pane)
|
|
520
|
+
if pid:
|
|
521
|
+
self._kill_process_tree(pid)
|
|
405
522
|
window.kill()
|
|
406
523
|
print(f"Killed task '{task_name}'")
|
|
407
524
|
else:
|
|
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
|