diff --git a/bin/exec-constrained.py b/bin/exec-constrained.py index cda6267..7cccb41 100755 --- a/bin/exec-constrained.py +++ b/bin/exec-constrained.py @@ -729,6 +729,27 @@ def _tmux_kill_build(a): return [sys.executable, os.path.join(BIN_DIR, 'muse-tmux.py'), 'kill', a['session']] +def _tmux_prune_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'ttl'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + ttl = raw.get('ttl', 7200) + try: + ttl = int(ttl) + if ttl < 60: + ttl = 60 + except Exception: + ttl = 7200 + return {'ttl': ttl} + + +def _tmux_prune_build(a): + return [sys.executable, os.path.join(BIN_DIR, 'muse-tmux.py'), 'prune', '--ttl', str(a['ttl'])] + + def _vars_list_validate(raw): if raw not in ({}, None): raise OpError('vars.list takes no required args') @@ -1174,6 +1195,11 @@ OPS = { 'timeout': 15, 'side_effecting': True, 'desc': 'Kill a session on shared Muse tmux socket (/tmp/tmux-muse.sock)', }, + 'tmux.prune': { + 'validate': _tmux_prune_validate, 'build': _tmux_prune_build, + 'timeout': 15, 'side_effecting': True, + 'desc': 'Reap stale unattached sessions inactive for >TTL (default 2h)', + }, 'exec.ping': { 'validate': _health_validate, 'build': lambda a: ['/bin/echo', 'PONG'], diff --git a/bin/muse-tmux.py b/bin/muse-tmux.py index 675aab6..78e85d5 100755 --- a/bin/muse-tmux.py +++ b/bin/muse-tmux.py @@ -105,6 +105,40 @@ def cmd_kill(args): print(f"Error: {err.strip() or out.strip()}", file=sys.stderr) return rc +def cmd_prune(args): + ttl_seconds = getattr(args, "ttl", 7200) or 7200 + rc, out, err = _run_tmux("list-sessions", "-F", "#{session_name} #{session_activity} #{session_attached}") + if rc != 0: + if "no server running" in (err or "").lower() or rc == 1: + print(f"No active tmux server on {SOCKET_PATH}.") + return 0 + print(f"Error listing sessions: {err.strip() or out.strip()}", file=sys.stderr) + return rc + + now = int(time.time()) + reaped = [] + kept = [] + + for line in out.strip().splitlines(): + parts = line.split() + if len(parts) >= 3: + s_name, s_activity, s_attached = parts[0], int(parts[1]), int(parts[2]) + idle_time = now - s_activity + # Only prune if unattached and idle > TTL + if s_attached == 0 and idle_time > ttl_seconds: + _run_tmux("kill-session", "-t", s_name) + reaped.append((s_name, idle_time)) + else: + kept.append((s_name, idle_time, s_attached)) + + if reaped: + print(f"✔ Reaped {len(reaped)} stale unattached session(s) (idle > {ttl_seconds//3600}h):") + for s_name, idle in reaped: + print(f" • {s_name} (idle: {idle//60}m)") + else: + print(f"No stale unattached sessions to reap (all active within {ttl_seconds//3600}h).") + return 0 + def cmd_attach(args): session = args.session cmd = [TMUX_BIN, "-S", SOCKET_PATH, "attach", "-t", session] @@ -144,6 +178,11 @@ def main(): p_kill.add_argument("session", help="Session name") p_kill.set_defaults(func=cmd_kill) + # prune + p_prune = subparsers.add_parser("prune", help="Reap stale unattached sessions inactive for >TTL (default: 7200s / 2h)") + p_prune.add_argument("--ttl", type=int, default=7200, help="Inactivity threshold in seconds (default: 7200)") + p_prune.set_defaults(func=cmd_prune) + # attach p_att = subparsers.add_parser("attach", help="Attach to a session interactively") p_att.add_argument("session", help="Session name") diff --git a/bin/netvm-reaper.sh b/bin/netvm-reaper.sh index e6db038..7a63d7c 100755 --- a/bin/netvm-reaper.sh +++ b/bin/netvm-reaper.sh @@ -22,3 +22,6 @@ done find /tmp -name "*-smoke.log" -mtime +7 -delete 2>/dev/null find /tmp -name "otp_*.py" -mtime +1 -delete 2>/dev/null find /tmp -name "check_*.py" -mtime +1 -delete 2>/dev/null + +# Prune unattached stale agent tmux sessions older than 2 hours (7200s) +python3 "$(cd "$(dirname "$0")" && pwd)/muse-tmux.py" prune --ttl 7200 2>/dev/null || true diff --git a/docs/AGENT-TOOLING.md b/docs/AGENT-TOOLING.md index e47353b..9a8a886 100644 --- a/docs/AGENT-TOOLING.md +++ b/docs/AGENT-TOOLING.md @@ -89,6 +89,9 @@ muse tmux capture --lines 30 # Kill a session muse tmux kill + +# Prune unattached sessions inactive for >2 hours (or custom --ttl in seconds) +muse tmux prune [--ttl 7200] ``` ### Via Autonomous Agent Directives: @@ -98,6 +101,7 @@ Agents can emit structured tool calls in sidechats: [TOOL tmux.send {"session": "build-worker", "keys": "ls -la"}] [TOOL tmux.capture {"session": "build-worker", "lines": 20}] [TOOL tmux.list {}] +[TOOL tmux.prune {"ttl": 7200}] [TOOL tmux.kill {"session": "build-worker"}] ```