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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taskmux
3
- Version: 0.2.5
3
+ Version: 0.2.6
4
4
  Summary: Modern tmux-based task manager for LLM development tools
5
5
  Project-URL: Homepage, https://github.com/nc9/taskmux
6
6
  Project-URL: Repository, https://github.com/nc9/taskmux
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "taskmux"
3
- version = "0.2.5"
3
+ version = "0.2.6"
4
4
  description = "Modern tmux-based task manager for LLM development tools"
5
5
  readme = "README.md"
6
6
  license-file = "LICENSE"
@@ -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.cli.tmux.auto_restart_unhealthy_tasks()
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 != "bash"
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 (C-c) a single task. Window stays alive."""
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
- # Stop each task with hooks
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
- runHook(task_cfg.hooks.after_stop, task_name)
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 (works regardless of auto_start)"""
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
- time.sleep(0.5)
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