mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
chore: bump version to 0.1485
This commit is contained in:
@@ -26,7 +26,7 @@ When user says "COM":
|
||||
1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`)
|
||||
2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web`
|
||||
|
||||
**Version**: 0.1484 (must match `package.json` for npm publish)
|
||||
**Version**: 0.1485 (must match `package.json` for npm publish)
|
||||
|
||||
## Project Overview
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claudeman",
|
||||
"version": "0.1484",
|
||||
"version": "0.1485",
|
||||
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
+20
-2
@@ -115,6 +115,13 @@ kill_session() {
|
||||
local mux_name=$(get_session_field $idx "muxName")
|
||||
local pid=$(get_session_field $idx "pid")
|
||||
|
||||
# SAFETY: Never kill own tmux session
|
||||
local current_session=$(tmux display-message -p '#{session_name}' 2>/dev/null || echo "")
|
||||
if [[ -n "$current_session" && "$mux_name" == "$current_session" ]]; then
|
||||
echo -e "${RED}BLOCKED: Cannot kill own tmux session: $mux_name${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
pkill -TERM -P $pid 2>/dev/null
|
||||
kill -TERM -$pid 2>/dev/null
|
||||
tmux kill-session -t "$mux_name" 2>/dev/null
|
||||
@@ -489,8 +496,19 @@ main() {
|
||||
;;
|
||||
kill-all)
|
||||
force_refresh
|
||||
for ((i=CACHED_COUNT-1; i>=0; i--)); do kill_session $i; done
|
||||
echo "All sessions killed"
|
||||
# SAFETY: Never kill own tmux session
|
||||
local current_session=$(tmux display-message -p '#{session_name}' 2>/dev/null || echo "")
|
||||
local killed=0
|
||||
for ((i=CACHED_COUNT-1; i>=0; i--)); do
|
||||
local mux_name=$(get_session_field $i "muxName")
|
||||
if [[ -n "$current_session" && "$mux_name" == "$current_session" ]]; then
|
||||
echo -e "${RED}SKIPPED: Own tmux session: $mux_name${NC}"
|
||||
continue
|
||||
fi
|
||||
kill_session $i
|
||||
((killed++))
|
||||
done
|
||||
echo "$killed sessions killed"
|
||||
;;
|
||||
info)
|
||||
[[ -z "${2:-}" ]] && { echo "Usage: $0 info <N>"; exit 1; }
|
||||
|
||||
@@ -57,14 +57,10 @@ const DEFAULT_STATS_INTERVAL_MS = 2000;
|
||||
/** Maximum retry attempts for carriage return (3) */
|
||||
const CR_MAX_ATTEMPTS = 3;
|
||||
|
||||
/**
|
||||
* Wraps a command with `nice` for priority adjustment.
|
||||
*/
|
||||
export function wrapWithNice(cmd: string, config: NiceConfig): string {
|
||||
if (!config.enabled) return cmd;
|
||||
const niceValue = Math.max(-20, Math.min(19, config.niceValue));
|
||||
return `nice -n ${niceValue} ${cmd}`;
|
||||
}
|
||||
// Import from shared utility (extracted to avoid cross-dependency)
|
||||
import { wrapWithNice } from './utils/nice-wrapper.js';
|
||||
// Re-export for backward compatibility
|
||||
export { wrapWithNice } from './utils/nice-wrapper.js';
|
||||
|
||||
|
||||
/** Cached directory containing the claude binary */
|
||||
|
||||
+25
-1
@@ -27,7 +27,7 @@ import { existsSync, readFileSync, mkdirSync, writeFile } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { ProcessStats, PersistedRespawnConfig, getErrorMessage, NiceConfig, DEFAULT_NICE_CONFIG } from './types.js';
|
||||
import { wrapWithNice } from './screen-manager.js';
|
||||
import { wrapWithNice } from './utils/nice-wrapper.js';
|
||||
import type { TerminalMultiplexer, MuxSession, MuxSessionWithStats } from './mux-interface.js';
|
||||
|
||||
// ============================================================================
|
||||
@@ -156,6 +156,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
private sessions: Map<string, MuxSession> = new Map();
|
||||
private statsInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
private trueColorConfigured = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.loadSessions();
|
||||
@@ -296,6 +298,21 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// Non-critical — session still works with status bar
|
||||
}
|
||||
|
||||
// Enable 24-bit true color passthrough — without this, tmux downgrades
|
||||
// RGB colors (like Claude's red logo) to the nearest 256-color palette entry.
|
||||
// Server-wide option, only set once per TmuxManager lifetime to avoid duplicates.
|
||||
if (!this.trueColorConfigured) {
|
||||
try {
|
||||
execSync(
|
||||
`tmux set-option -sa terminal-overrides ",*:Tc"`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
this.trueColorConfigured = true;
|
||||
} catch {
|
||||
// Non-critical — colors will still work, just limited to 256
|
||||
}
|
||||
}
|
||||
|
||||
// Get the PID of the pane process
|
||||
const pid = this.getPanePid(muxName);
|
||||
if (!pid) {
|
||||
@@ -419,6 +436,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return false;
|
||||
}
|
||||
|
||||
// SAFETY: Never kill the tmux session we're running inside of
|
||||
const currentMuxName = process.env.CLAUDEMAN_SCREEN_NAME;
|
||||
if (currentMuxName && session.muxName === currentMuxName) {
|
||||
console.error(`[TmuxManager] BLOCKED: Refusing to kill own tmux session: ${session.muxName}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get current PID (may have changed)
|
||||
const currentPid = this.getPanePid(session.muxName) || session.pid;
|
||||
|
||||
|
||||
@@ -34,3 +34,4 @@ export {
|
||||
todoContentHash,
|
||||
} from './string-similarity.js';
|
||||
export { assertNever } from './type-safety.js';
|
||||
export { wrapWithNice } from './nice-wrapper.js';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @fileoverview Shared utility for wrapping commands with `nice` priority.
|
||||
*
|
||||
* Extracted from screen-manager.ts so both tmux-manager and screen-manager
|
||||
* can use it without cross-dependency on the deprecated screen module.
|
||||
*
|
||||
* @module utils/nice-wrapper
|
||||
*/
|
||||
|
||||
import type { NiceConfig } from '../types.js';
|
||||
|
||||
/**
|
||||
* Wraps a command with `nice` for priority adjustment.
|
||||
*/
|
||||
export function wrapWithNice(cmd: string, config: NiceConfig): string {
|
||||
if (!config.enabled) return cmd;
|
||||
const niceValue = Math.max(-20, Math.min(19, config.niceValue));
|
||||
return `nice -n ${niceValue} ${cmd}`;
|
||||
}
|
||||
+282
-4
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for TmuxManager
|
||||
* @fileoverview Unit + integration tests for TmuxManager
|
||||
*
|
||||
* Tests validation functions, command construction, and parsing logic
|
||||
* using mocked exec calls. Does NOT create or kill real tmux sessions.
|
||||
* Unit tests (mocked): validation, command construction, parsing logic.
|
||||
* Integration tests (real tmux): session creation, input, kill, reconciliation.
|
||||
*
|
||||
* Port: N/A (no server needed)
|
||||
*/
|
||||
@@ -10,6 +10,11 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { registerTestTmuxSession, unregisterTestTmuxSession } from './setup.js';
|
||||
|
||||
// ============================================================================
|
||||
// Unit Tests (mocked)
|
||||
// ============================================================================
|
||||
|
||||
// Mock child_process
|
||||
vi.mock('node:child_process', async () => {
|
||||
@@ -37,7 +42,7 @@ vi.mock('node:fs', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
describe('TmuxManager', () => {
|
||||
describe('TmuxManager (unit)', () => {
|
||||
let manager: TmuxManager;
|
||||
const mockedExecSync = vi.mocked(execSync);
|
||||
|
||||
@@ -299,6 +304,97 @@ describe('TmuxManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('killSession self-kill protection', () => {
|
||||
it('should block kill when session matches CLAUDEMAN_SCREEN_NAME', async () => {
|
||||
const originalEnv = process.env.CLAUDEMAN_SCREEN_NAME;
|
||||
process.env.CLAUDEMAN_SCREEN_NAME = 'claudeman-5e1f1111';
|
||||
|
||||
try {
|
||||
manager.registerSession({
|
||||
sessionId: 'self-kill-test',
|
||||
muxName: 'claudeman-5e1f1111',
|
||||
pid: 999,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude',
|
||||
attached: false,
|
||||
});
|
||||
|
||||
const result = await manager.killSession('self-kill-test');
|
||||
expect(result).toBe(false);
|
||||
|
||||
// Session should still exist (not removed)
|
||||
expect(manager.getSession('self-kill-test')).toBeDefined();
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.CLAUDEMAN_SCREEN_NAME;
|
||||
} else {
|
||||
process.env.CLAUDEMAN_SCREEN_NAME = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow kill when session does NOT match CLAUDEMAN_SCREEN_NAME', async () => {
|
||||
const originalEnv = process.env.CLAUDEMAN_SCREEN_NAME;
|
||||
process.env.CLAUDEMAN_SCREEN_NAME = 'claudeman-0ther1111';
|
||||
|
||||
try {
|
||||
manager.registerSession({
|
||||
sessionId: 'other-kill-test',
|
||||
muxName: 'claudeman-d1ff1111',
|
||||
pid: 888,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude',
|
||||
attached: false,
|
||||
});
|
||||
|
||||
// Mock the kill flow
|
||||
mockedExecSync.mockImplementation(() => '');
|
||||
|
||||
const result = await manager.killSession('other-kill-test');
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Session should be removed
|
||||
expect(manager.getSession('other-kill-test')).toBeUndefined();
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.CLAUDEMAN_SCREEN_NAME;
|
||||
} else {
|
||||
process.env.CLAUDEMAN_SCREEN_NAME = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow kill when CLAUDEMAN_SCREEN_NAME is not set', async () => {
|
||||
const originalEnv = process.env.CLAUDEMAN_SCREEN_NAME;
|
||||
delete process.env.CLAUDEMAN_SCREEN_NAME;
|
||||
|
||||
try {
|
||||
manager.registerSession({
|
||||
sessionId: 'no-env-test',
|
||||
muxName: 'claudeman-aaa11111',
|
||||
pid: 777,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude',
|
||||
attached: false,
|
||||
});
|
||||
|
||||
mockedExecSync.mockImplementation(() => '');
|
||||
|
||||
const result = await manager.killSession('no-env-test');
|
||||
expect(result).toBe(true);
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.CLAUDEMAN_SCREEN_NAME;
|
||||
} else {
|
||||
process.env.CLAUDEMAN_SCREEN_NAME = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('metadata operations', () => {
|
||||
beforeEach(() => {
|
||||
manager.registerSession({
|
||||
@@ -385,3 +481,185 @@ describe('TmuxManager', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Integration Tests (real tmux sessions)
|
||||
// ============================================================================
|
||||
|
||||
describe('TmuxManager (integration)', () => {
|
||||
// Skip entire block if tmux is not available
|
||||
const tmuxAvailable = (() => {
|
||||
try {
|
||||
const { execSync: realExecSync } = require('node:child_process');
|
||||
realExecSync('which tmux', { encoding: 'utf-8', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!tmuxAvailable) {
|
||||
it.skip('tmux not available — skipping integration tests', () => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Real execSync for integration tests (bypasses mock)
|
||||
const { execSync: realExecSync } = require('node:child_process') as typeof import('node:child_process');
|
||||
|
||||
// Helper: create a test tmux session directly via tmux CLI
|
||||
function createRawTmuxSession(name: string): void {
|
||||
realExecSync(`tmux new-session -ds "${name}" -x 80 -y 24 bash`, { timeout: 5000 });
|
||||
registerTestTmuxSession(name);
|
||||
}
|
||||
|
||||
// Helper: check if tmux session exists
|
||||
function tmuxSessionExists(name: string): boolean {
|
||||
try {
|
||||
realExecSync(`tmux has-session -t "${name}" 2>/dev/null`, { timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: kill a test tmux session directly
|
||||
function killRawTmuxSession(name: string): void {
|
||||
try {
|
||||
realExecSync(`tmux kill-session -t "${name}" 2>/dev/null`, { timeout: 5000 });
|
||||
} catch {
|
||||
// May already be dead
|
||||
}
|
||||
unregisterTestTmuxSession(name);
|
||||
}
|
||||
|
||||
// Track sessions created during integration tests for cleanup
|
||||
const createdSessions: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up any sessions created during the test
|
||||
for (const name of createdSessions) {
|
||||
killRawTmuxSession(name);
|
||||
}
|
||||
createdSessions.length = 0;
|
||||
});
|
||||
|
||||
it('should create a real tmux session', () => {
|
||||
const sessionName = 'claudeman-test-create';
|
||||
createRawTmuxSession(sessionName);
|
||||
createdSessions.push(sessionName);
|
||||
|
||||
expect(tmuxSessionExists(sessionName)).toBe(true);
|
||||
});
|
||||
|
||||
it('should send input to a real tmux session and verify output', async () => {
|
||||
const sessionName = 'claudeman-test-input';
|
||||
createRawTmuxSession(sessionName);
|
||||
createdSessions.push(sessionName);
|
||||
|
||||
// Send text to the session
|
||||
realExecSync(`tmux send-keys -t "${sessionName}" -l 'echo TMUX_INPUT_TEST_OK'`, { timeout: 5000 });
|
||||
realExecSync(`tmux send-keys -t "${sessionName}" Enter`, { timeout: 5000 });
|
||||
|
||||
// Wait for command to execute
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Capture pane contents
|
||||
const output = realExecSync(`tmux capture-pane -t "${sessionName}" -p`, { encoding: 'utf-8', timeout: 5000 });
|
||||
expect(output).toContain('TMUX_INPUT_TEST_OK');
|
||||
});
|
||||
|
||||
it('should kill a real tmux session', () => {
|
||||
const sessionName = 'claudeman-test-kill';
|
||||
createRawTmuxSession(sessionName);
|
||||
// Don't push to createdSessions since we'll kill it manually
|
||||
|
||||
expect(tmuxSessionExists(sessionName)).toBe(true);
|
||||
|
||||
realExecSync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, { timeout: 5000 });
|
||||
unregisterTestTmuxSession(sessionName);
|
||||
|
||||
expect(tmuxSessionExists(sessionName)).toBe(false);
|
||||
});
|
||||
|
||||
it('should discover unknown claudeman sessions via reconcile', async () => {
|
||||
// Create a tmux session directly (not via TmuxManager) — simulates a "ghost"
|
||||
const sessionName = 'claudeman-te51abcd';
|
||||
createRawTmuxSession(sessionName);
|
||||
createdSessions.push(sessionName);
|
||||
|
||||
// existsSync is already mocked to return false (module-level mock),
|
||||
// so TmuxManager won't load any persisted sessions from disk
|
||||
const freshManager = new TmuxManager();
|
||||
|
||||
// Verify it doesn't know about the session yet
|
||||
expect(freshManager.getSessions()).toHaveLength(0);
|
||||
|
||||
// Note: Full reconcile with real tmux requires unmocked execSync,
|
||||
// which is covered by the tmux-restart-recovery.test.ts integration tests.
|
||||
freshManager.destroy();
|
||||
});
|
||||
|
||||
it('should verify self-kill protection with real env var', async () => {
|
||||
const sessionName = 'claudeman-te515e1f';
|
||||
createRawTmuxSession(sessionName);
|
||||
createdSessions.push(sessionName);
|
||||
|
||||
const originalEnv = process.env.CLAUDEMAN_SCREEN_NAME;
|
||||
process.env.CLAUDEMAN_SCREEN_NAME = sessionName;
|
||||
|
||||
try {
|
||||
// existsSync is already mocked to return false (module-level mock)
|
||||
const testManager = new TmuxManager();
|
||||
testManager.registerSession({
|
||||
sessionId: 'self-test',
|
||||
muxName: sessionName,
|
||||
pid: 99999,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude',
|
||||
attached: false,
|
||||
});
|
||||
|
||||
// killSession should refuse
|
||||
const result = await testManager.killSession('self-test');
|
||||
expect(result).toBe(false);
|
||||
|
||||
// Session should still be alive in tmux
|
||||
expect(tmuxSessionExists(sessionName)).toBe(true);
|
||||
|
||||
testManager.destroy();
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.CLAUDEMAN_SCREEN_NAME;
|
||||
} else {
|
||||
process.env.CLAUDEMAN_SCREEN_NAME = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should persist and load session metadata', () => {
|
||||
// This test verifies the persistence format is correct by checking
|
||||
// that registerSession + getSessions round-trips properly
|
||||
// existsSync is already mocked to return false (module-level mock)
|
||||
const manager1 = new TmuxManager();
|
||||
manager1.registerSession({
|
||||
sessionId: 'persist-test',
|
||||
muxName: 'claudeman-be51aaa1',
|
||||
pid: 12345,
|
||||
createdAt: 1700000000000,
|
||||
workingDir: '/home/test',
|
||||
mode: 'claude',
|
||||
attached: false,
|
||||
name: 'Test Session',
|
||||
respawnConfig: { enabled: true, idleTimeoutMs: 5000, updatePrompt: 'test', interStepDelayMs: 1000, sendClear: true, sendInit: true },
|
||||
});
|
||||
|
||||
const sessions = manager1.getSessions();
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].sessionId).toBe('persist-test');
|
||||
expect(sessions[0].name).toBe('Test Session');
|
||||
expect(sessions[0].respawnConfig?.enabled).toBe(true);
|
||||
|
||||
manager1.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* @fileoverview Integration test for tmux session recovery after server restart.
|
||||
*
|
||||
* Tests the full restart cycle:
|
||||
* 1. Create a WebServer with real tmux backend
|
||||
* 2. Create a session (tmux)
|
||||
* 3. Stop the server (without killing tmux sessions)
|
||||
* 4. Start a new server instance
|
||||
* 5. Verify session is recovered via reconcileSessions()
|
||||
*
|
||||
* Port: 3152
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import { registerTestTmuxSession, unregisterTestTmuxSession } from './setup.js';
|
||||
|
||||
// Skip all tests if tmux is not available
|
||||
const tmuxAvailable = (() => {
|
||||
try {
|
||||
execSync('which tmux', { encoding: 'utf-8', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
const MUX_SESSIONS_FILE = join(homedir(), '.claudeman', 'mux-sessions.json');
|
||||
|
||||
describe.skipIf(!tmuxAvailable)('TmuxManager restart recovery', () => {
|
||||
// Track tmux sessions for cleanup
|
||||
const createdTmuxSessions: string[] = [];
|
||||
let originalMuxSessions: string | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
// Back up the current mux-sessions.json if it exists
|
||||
if (existsSync(MUX_SESSIONS_FILE)) {
|
||||
originalMuxSessions = readFileSync(MUX_SESSIONS_FILE, 'utf-8');
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Kill all tmux sessions created during tests
|
||||
for (const name of createdTmuxSessions) {
|
||||
try {
|
||||
execSync(`tmux kill-session -t "${name}" 2>/dev/null`, { timeout: 5000 });
|
||||
} catch {
|
||||
// May already be dead
|
||||
}
|
||||
unregisterTestTmuxSession(name);
|
||||
}
|
||||
createdTmuxSessions.length = 0;
|
||||
|
||||
// Restore original mux-sessions.json
|
||||
if (originalMuxSessions !== null) {
|
||||
writeFileSync(MUX_SESSIONS_FILE, originalMuxSessions);
|
||||
}
|
||||
|
||||
// Brief wait for cleanup
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
});
|
||||
|
||||
it('should recover a tmux session after manager restart', async () => {
|
||||
// Step 1: Create a tmux session directly (simulating a session created by old server)
|
||||
const sessionName = 'claudeman-de51ecaf';
|
||||
execSync(`tmux new-session -ds "${sessionName}" -x 80 -y 24 bash`, { timeout: 5000 });
|
||||
registerTestTmuxSession(sessionName);
|
||||
createdTmuxSessions.push(sessionName);
|
||||
|
||||
// Step 2: Write session metadata to mux-sessions.json (as if old server persisted it)
|
||||
const dir = join(homedir(), '.claudeman');
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const sessionData = [{
|
||||
sessionId: 'test-recovery-1',
|
||||
muxName: sessionName,
|
||||
pid: 1, // Stale PID — will be updated during reconcile
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude',
|
||||
attached: false,
|
||||
name: 'Recovery Test',
|
||||
respawnConfig: { enabled: true, idleTimeoutMs: 10000, updatePrompt: 'continue', interStepDelayMs: 2000, sendClear: false, sendInit: true },
|
||||
}];
|
||||
writeFileSync(MUX_SESSIONS_FILE, JSON.stringify(sessionData, null, 2));
|
||||
|
||||
// Step 3: Create a new TmuxManager (simulates server restart — loads from file)
|
||||
const manager = new TmuxManager();
|
||||
|
||||
// Verify session was loaded from disk
|
||||
const loaded = manager.getSession('test-recovery-1');
|
||||
expect(loaded).toBeDefined();
|
||||
expect(loaded!.muxName).toBe(sessionName);
|
||||
expect(loaded!.name).toBe('Recovery Test');
|
||||
expect(loaded!.respawnConfig?.enabled).toBe(true);
|
||||
|
||||
// Step 4: Reconcile — should detect the tmux session is alive
|
||||
const result = await manager.reconcileSessions();
|
||||
expect(result.alive).toContain('test-recovery-1');
|
||||
expect(result.dead).toHaveLength(0);
|
||||
|
||||
// PID should be updated to actual tmux pane PID
|
||||
const reconciled = manager.getSession('test-recovery-1');
|
||||
expect(reconciled).toBeDefined();
|
||||
expect(reconciled!.pid).toBeGreaterThan(1); // Updated from stale PID
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
it('should detect dead sessions during reconcile', async () => {
|
||||
// Write metadata for a session that doesn't actually exist in tmux
|
||||
const dir = join(homedir(), '.claudeman');
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const sessionData = [{
|
||||
sessionId: 'test-dead-1',
|
||||
muxName: 'claudeman-deadbeef',
|
||||
pid: 99999,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude',
|
||||
attached: false,
|
||||
name: 'Dead Session',
|
||||
}];
|
||||
writeFileSync(MUX_SESSIONS_FILE, JSON.stringify(sessionData, null, 2));
|
||||
|
||||
const manager = new TmuxManager();
|
||||
|
||||
// Verify session was loaded
|
||||
expect(manager.getSession('test-dead-1')).toBeDefined();
|
||||
|
||||
// Reconcile — should detect the session is dead
|
||||
const result = await manager.reconcileSessions();
|
||||
expect(result.dead).toContain('test-dead-1');
|
||||
expect(result.alive).not.toContain('test-dead-1');
|
||||
|
||||
// Session should be removed after reconcile
|
||||
expect(manager.getSession('test-dead-1')).toBeUndefined();
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
it('should discover ghost sessions not in metadata', async () => {
|
||||
// Create a tmux session directly (not via TmuxManager, no metadata)
|
||||
const sessionName = 'claudeman-ab12ef34';
|
||||
execSync(`tmux new-session -ds "${sessionName}" -x 80 -y 24 bash`, { timeout: 5000 });
|
||||
registerTestTmuxSession(sessionName);
|
||||
createdTmuxSessions.push(sessionName);
|
||||
|
||||
// Start with empty metadata
|
||||
const dir = join(homedir(), '.claudeman');
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
writeFileSync(MUX_SESSIONS_FILE, '[]');
|
||||
|
||||
const manager = new TmuxManager();
|
||||
|
||||
// No sessions loaded
|
||||
expect(manager.getSessions()).toHaveLength(0);
|
||||
|
||||
// Reconcile should discover the ghost session
|
||||
const result = await manager.reconcileSessions();
|
||||
expect(result.discovered.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Find the discovered session
|
||||
const discoveredId = result.discovered.find(id => id === 'restored-ab12ef34');
|
||||
expect(discoveredId).toBeDefined();
|
||||
|
||||
// Verify the discovered session has correct metadata
|
||||
const session = manager.getSession(discoveredId!);
|
||||
expect(session).toBeDefined();
|
||||
expect(session!.muxName).toBe(sessionName);
|
||||
expect(session!.mode).toBe('claude');
|
||||
expect(session!.pid).toBeGreaterThan(0);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
it('should handle mixed alive, dead, and ghost sessions', async () => {
|
||||
// Create a real tmux session (will be "alive")
|
||||
const aliveSessionName = 'claudeman-a11eeaaa';
|
||||
execSync(`tmux new-session -ds "${aliveSessionName}" -x 80 -y 24 bash`, { timeout: 5000 });
|
||||
registerTestTmuxSession(aliveSessionName);
|
||||
createdTmuxSessions.push(aliveSessionName);
|
||||
|
||||
// Create a ghost session (real tmux, no metadata)
|
||||
const ghostSessionName = 'claudeman-ab05fabf';
|
||||
execSync(`tmux new-session -ds "${ghostSessionName}" -x 80 -y 24 bash`, { timeout: 5000 });
|
||||
registerTestTmuxSession(ghostSessionName);
|
||||
createdTmuxSessions.push(ghostSessionName);
|
||||
|
||||
// Write metadata with alive + dead sessions
|
||||
const dir = join(homedir(), '.claudeman');
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const sessionData = [
|
||||
{
|
||||
sessionId: 'alive-session',
|
||||
muxName: aliveSessionName,
|
||||
pid: 1,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude',
|
||||
attached: false,
|
||||
name: 'Alive Session',
|
||||
},
|
||||
{
|
||||
sessionId: 'dead-session',
|
||||
muxName: 'claudeman-dead0000',
|
||||
pid: 99999,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude',
|
||||
attached: false,
|
||||
name: 'Dead Session',
|
||||
},
|
||||
];
|
||||
writeFileSync(MUX_SESSIONS_FILE, JSON.stringify(sessionData, null, 2));
|
||||
|
||||
const manager = new TmuxManager();
|
||||
const result = await manager.reconcileSessions();
|
||||
|
||||
expect(result.alive).toContain('alive-session');
|
||||
expect(result.dead).toContain('dead-session');
|
||||
expect(result.discovered).toContain('restored-ab05fabf');
|
||||
|
||||
// Verify final session state
|
||||
const sessions = manager.getSessions();
|
||||
const sessionIds = sessions.map(s => s.sessionId);
|
||||
expect(sessionIds).toContain('alive-session');
|
||||
expect(sessionIds).not.toContain('dead-session');
|
||||
expect(sessionIds).toContain('restored-ab05fabf');
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user