fix: clean up observer sessions on lifecycle end

This commit is contained in:
Affaan Mustafa
2026-04-02 18:02:29 -07:00
parent be0c56957b
commit 16e9b17ad7
7 changed files with 408 additions and 4 deletions

View File

@@ -303,6 +303,101 @@ async function runTests() {
assert.strictEqual(result.code, 0, 'Non-blocking hook should exit 0');
})) passed++; else failed++;
if (await asyncTest('session-start registers an observer lease for the active session', async () => {
const testDir = createTestDir();
const projectDir = path.join(testDir, 'project');
fs.mkdirSync(projectDir, { recursive: true });
try {
const sessionId = `session-${Date.now()}`;
const result = await runHookWithInput(
path.join(scriptsDir, 'session-start.js'),
{},
{
HOME: testDir,
CLAUDE_PROJECT_DIR: projectDir,
CLAUDE_SESSION_ID: sessionId
}
);
assert.strictEqual(result.code, 0, 'SessionStart should exit 0');
const homunculusDir = path.join(testDir, '.claude', 'homunculus');
const projectsDir = path.join(homunculusDir, 'projects');
const projectEntries = fs.existsSync(projectsDir) ? fs.readdirSync(projectsDir) : [];
assert.ok(projectEntries.length > 0, 'SessionStart should create a homunculus project directory');
const leaseDir = path.join(projectsDir, projectEntries[0], '.observer-sessions');
const leaseFiles = fs.existsSync(leaseDir) ? fs.readdirSync(leaseDir).filter(name => name.endsWith('.json')) : [];
assert.ok(leaseFiles.length === 1, `Expected one observer lease file, found ${leaseFiles.length}`);
} finally {
cleanupTestDir(testDir);
}
})) passed++; else failed++;
if (await asyncTest('session-end-marker removes the last lease and stops the observer process', async () => {
const testDir = createTestDir();
const projectDir = path.join(testDir, 'project');
fs.mkdirSync(projectDir, { recursive: true });
const sessionId = `session-${Date.now()}`;
const sleeper = spawn(process.execPath, ['-e', "process.on('SIGTERM', () => process.exit(0)); setInterval(() => {}, 1000)"], {
stdio: 'ignore'
});
try {
await runHookWithInput(
path.join(scriptsDir, 'session-start.js'),
{},
{
HOME: testDir,
CLAUDE_PROJECT_DIR: projectDir,
CLAUDE_SESSION_ID: sessionId
}
);
const homunculusDir = path.join(testDir, '.claude', 'homunculus');
const projectsDir = path.join(homunculusDir, 'projects');
const projectEntries = fs.existsSync(projectsDir) ? fs.readdirSync(projectsDir) : [];
assert.ok(projectEntries.length > 0, 'Expected SessionStart to create a homunculus project directory');
const projectStorageDir = path.join(projectsDir, projectEntries[0]);
const pidFile = path.join(projectStorageDir, '.observer.pid');
fs.writeFileSync(pidFile, `${sleeper.pid}\n`);
const markerInput = { hook_event_name: 'SessionEnd' };
const result = await runHookWithInput(
path.join(scriptsDir, 'session-end-marker.js'),
markerInput,
{
HOME: testDir,
CLAUDE_PROJECT_DIR: projectDir,
CLAUDE_SESSION_ID: sessionId
}
);
assert.strictEqual(result.code, 0, 'SessionEnd marker should exit 0');
assert.strictEqual(result.stdout, JSON.stringify(markerInput), 'SessionEnd marker should pass stdin through unchanged');
await new Promise(resolve => setTimeout(resolve, 150));
const exited = sleeper.exitCode !== null || sleeper.signalCode !== null;
let processAlive = !exited;
if (processAlive) {
try {
process.kill(sleeper.pid, 0);
} catch {
processAlive = false;
}
}
assert.strictEqual(processAlive, false, 'SessionEnd marker should stop the observer process when the last lease ends');
const leaseDir = path.join(projectStorageDir, '.observer-sessions');
const leaseFiles = fs.existsSync(leaseDir) ? fs.readdirSync(leaseDir).filter(name => name.endsWith('.json')) : [];
assert.strictEqual(leaseFiles.length, 0, 'SessionEnd marker should remove the finished session lease');
assert.strictEqual(fs.existsSync(pidFile), false, 'SessionEnd marker should remove the observer pid file after stopping it');
} finally {
sleeper.kill();
cleanupTestDir(testDir);
}
})) passed++; else failed++;
if (await asyncTest('dev server hook transforms yarn dev to tmux session', async () => {
// The auto-tmux dev hook transforms dev commands (yarn dev, npm run dev, etc.)
const hookCommand = getHookCommandByDescription(