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

@@ -2,12 +2,50 @@
'use strict';
/**
* Session end marker hook - outputs stdin to stdout unchanged.
* Exports run() for in-process execution (avoids spawnSync issues on Windows).
* Session end marker hook - performs lightweight observer cleanup and
* outputs stdin to stdout unchanged. Exports run() for in-process execution.
*/
const {
resolveProjectContext,
removeSessionLease,
listSessionLeases,
stopObserverForContext,
resolveSessionId
} = require('../lib/observer-sessions');
function log(message) {
process.stderr.write(`[SessionEnd] ${message}\n`);
}
function run(rawInput) {
return rawInput || '';
const output = rawInput || '';
const sessionId = resolveSessionId();
if (!sessionId) {
log('No CLAUDE_SESSION_ID available; skipping observer cleanup');
return output;
}
try {
const observerContext = resolveProjectContext();
removeSessionLease(observerContext, sessionId);
const remainingLeases = listSessionLeases(observerContext);
if (remainingLeases.length === 0) {
if (stopObserverForContext(observerContext)) {
log(`Stopped observer for project ${observerContext.projectId} after final session lease ended`);
} else {
log(`No running observer to stop for project ${observerContext.projectId}`);
}
} else {
log(`Retained observer for project ${observerContext.projectId}; ${remainingLeases.length} session lease(s) remain`);
}
} catch (err) {
log(`Observer cleanup skipped: ${err.message}`);
}
return output;
}
// Legacy CLI execution (when run directly)
@@ -22,7 +60,7 @@ if (require.main === module) {
}
});
process.stdin.on('end', () => {
process.stdout.write(raw);
process.stdout.write(run(raw));
});
}

View File

@@ -20,6 +20,7 @@ const {
stripAnsi,
log
} = require('../lib/utils');
const { resolveProjectContext, writeSessionLease, resolveSessionId } = require('../lib/observer-sessions');
const { getPackageManager, getSelectionPrompt } = require('../lib/package-manager');
const { listAliases } = require('../lib/session-aliases');
const { detectProjectType } = require('../lib/project-detect');
@@ -163,6 +164,18 @@ async function main() {
ensureDir(sessionsDir);
ensureDir(learnedDir);
const observerSessionId = resolveSessionId();
if (observerSessionId) {
const observerContext = resolveProjectContext();
writeSessionLease(observerContext, observerSessionId, {
hook: 'SessionStart',
projectRoot: observerContext.projectRoot
});
log(`[SessionStart] Registered observer lease for ${observerSessionId}`);
} else {
log('[SessionStart] No CLAUDE_SESSION_ID available; skipping observer lease registration');
}
// Check for recent session files (last 7 days)
const recentSessions = dedupeRecentSessions(getSessionSearchDirs());

View File

@@ -0,0 +1,175 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { spawnSync } = require('child_process');
const { getClaudeDir, ensureDir, sanitizeSessionId } = require('./utils');
function getHomunculusDir() {
return path.join(getClaudeDir(), 'homunculus');
}
function getProjectsDir() {
return path.join(getHomunculusDir(), 'projects');
}
function getProjectRegistryPath() {
return path.join(getHomunculusDir(), 'projects.json');
}
function readProjectRegistry() {
try {
return JSON.parse(fs.readFileSync(getProjectRegistryPath(), 'utf8'));
} catch {
return {};
}
}
function runGit(args, cwd) {
const result = spawnSync('git', args, {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
});
if (result.status !== 0) return '';
return (result.stdout || '').trim();
}
function stripRemoteCredentials(remoteUrl) {
if (!remoteUrl) return '';
return String(remoteUrl).replace(/:\/\/[^@]+@/, '://');
}
function resolveProjectRoot(cwd = process.cwd()) {
const envRoot = process.env.CLAUDE_PROJECT_DIR;
if (envRoot && fs.existsSync(envRoot)) {
return path.resolve(envRoot);
}
const gitRoot = runGit(['rev-parse', '--show-toplevel'], cwd);
if (gitRoot) return path.resolve(gitRoot);
return '';
}
function computeProjectId(projectRoot) {
const remoteUrl = stripRemoteCredentials(runGit(['remote', 'get-url', 'origin'], projectRoot));
return crypto.createHash('sha256').update(remoteUrl || projectRoot).digest('hex').slice(0, 12);
}
function resolveProjectContext(cwd = process.cwd()) {
const projectRoot = resolveProjectRoot(cwd);
if (!projectRoot) {
const projectDir = getHomunculusDir();
ensureDir(projectDir);
return { projectId: 'global', projectRoot: '', projectDir, isGlobal: true };
}
const registry = readProjectRegistry();
const registryEntry = Object.values(registry).find(entry => entry && path.resolve(entry.root || '') === projectRoot);
const projectId = registryEntry?.id || computeProjectId(projectRoot);
const projectDir = path.join(getProjectsDir(), projectId);
ensureDir(projectDir);
return { projectId, projectRoot, projectDir, isGlobal: false };
}
function getObserverPidFile(context) {
return path.join(context.projectDir, '.observer.pid');
}
function getObserverSignalCounterFile(context) {
return path.join(context.projectDir, '.observer-signal-counter');
}
function getObserverActivityFile(context) {
return path.join(context.projectDir, '.observer-last-activity');
}
function getSessionLeaseDir(context) {
return path.join(context.projectDir, '.observer-sessions');
}
function resolveSessionId(rawSessionId = process.env.CLAUDE_SESSION_ID) {
return sanitizeSessionId(rawSessionId || '') || '';
}
function getSessionLeaseFile(context, rawSessionId = process.env.CLAUDE_SESSION_ID) {
const sessionId = resolveSessionId(rawSessionId);
if (!sessionId) return '';
return path.join(getSessionLeaseDir(context), `${sessionId}.json`);
}
function writeSessionLease(context, rawSessionId = process.env.CLAUDE_SESSION_ID, extra = {}) {
const leaseFile = getSessionLeaseFile(context, rawSessionId);
if (!leaseFile) return '';
ensureDir(getSessionLeaseDir(context));
const payload = {
sessionId: resolveSessionId(rawSessionId),
cwd: process.cwd(),
pid: process.pid,
updatedAt: new Date().toISOString(),
...extra
};
fs.writeFileSync(leaseFile, JSON.stringify(payload, null, 2) + '\n');
return leaseFile;
}
function removeSessionLease(context, rawSessionId = process.env.CLAUDE_SESSION_ID) {
const leaseFile = getSessionLeaseFile(context, rawSessionId);
if (!leaseFile) return false;
try {
fs.rmSync(leaseFile, { force: true });
return true;
} catch {
return false;
}
}
function listSessionLeases(context) {
const leaseDir = getSessionLeaseDir(context);
if (!fs.existsSync(leaseDir)) return [];
return fs.readdirSync(leaseDir)
.filter(name => name.endsWith('.json'))
.map(name => path.join(leaseDir, name));
}
function stopObserverForContext(context) {
const pidFile = getObserverPidFile(context);
if (!fs.existsSync(pidFile)) return false;
const pid = (fs.readFileSync(pidFile, 'utf8') || '').trim();
if (!/^[0-9]+$/.test(pid) || pid === '0' || pid === '1') {
fs.rmSync(pidFile, { force: true });
return false;
}
try {
process.kill(Number(pid), 0);
} catch {
fs.rmSync(pidFile, { force: true });
return false;
}
try {
process.kill(Number(pid), 'SIGTERM');
} catch {
return false;
}
fs.rmSync(pidFile, { force: true });
fs.rmSync(getObserverSignalCounterFile(context), { force: true });
return true;
}
module.exports = {
resolveProjectContext,
getObserverActivityFile,
getObserverPidFile,
getSessionLeaseDir,
writeSessionLease,
removeSessionLease,
listSessionLeases,
stopObserverForContext,
resolveSessionId
};

View File

@@ -14,6 +14,9 @@ ANALYZING=0
LAST_ANALYSIS_EPOCH=0
# Minimum seconds between analyses (prevents rapid re-triggering)
ANALYSIS_COOLDOWN="${ECC_OBSERVER_ANALYSIS_COOLDOWN:-60}"
IDLE_TIMEOUT_SECONDS="${ECC_OBSERVER_IDLE_TIMEOUT_SECONDS:-1800}"
SESSION_LEASE_DIR="${PROJECT_DIR}/.observer-sessions"
ACTIVITY_FILE="${PROJECT_DIR}/.observer-last-activity"
cleanup() {
[ -n "$SLEEP_PID" ] && kill "$SLEEP_PID" 2>/dev/null
@@ -24,6 +27,62 @@ cleanup() {
}
trap cleanup TERM INT
file_mtime_epoch() {
local file="$1"
if [ ! -f "$file" ]; then
printf '0\n'
return
fi
if stat -c %Y "$file" >/dev/null 2>&1; then
stat -c %Y "$file" 2>/dev/null || printf '0\n'
return
fi
if stat -f %m "$file" >/dev/null 2>&1; then
stat -f %m "$file" 2>/dev/null || printf '0\n'
return
fi
printf '0\n'
}
has_active_session_leases() {
if [ ! -d "$SESSION_LEASE_DIR" ]; then
return 1
fi
find "$SESSION_LEASE_DIR" -type f -name '*.json' -print -quit 2>/dev/null | grep -q .
}
latest_activity_epoch() {
local observations_epoch activity_epoch
observations_epoch="$(file_mtime_epoch "$OBSERVATIONS_FILE")"
activity_epoch="$(file_mtime_epoch "$ACTIVITY_FILE")"
if [ "$activity_epoch" -gt "$observations_epoch" ] 2>/dev/null; then
printf '%s\n' "$activity_epoch"
else
printf '%s\n' "$observations_epoch"
fi
}
exit_if_idle_without_sessions() {
if has_active_session_leases; then
return
fi
local last_activity now_epoch idle_for
last_activity="$(latest_activity_epoch)"
now_epoch="$(date +%s)"
idle_for=$(( now_epoch - last_activity ))
if [ "$last_activity" -eq 0 ] || [ "$idle_for" -ge "$IDLE_TIMEOUT_SECONDS" ]; then
echo "[$(date)] Observer idle without active session leases for ${idle_for}s; exiting" >> "$LOG_FILE"
cleanup
fi
}
analyze_observations() {
if [ ! -f "$OBSERVATIONS_FILE" ]; then
return
@@ -197,11 +256,13 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
"${CLV2_PYTHON_CMD:-python3}" "${SCRIPT_DIR}/../scripts/instinct-cli.py" prune --quiet >> "$LOG_FILE" 2>&1 || echo "[$(date)] Warning: instinct prune failed (non-fatal)" >> "$LOG_FILE"
while true; do
exit_if_idle_without_sessions
sleep "$OBSERVER_INTERVAL_SECONDS" &
SLEEP_PID=$!
wait "$SLEEP_PID" 2>/dev/null
SLEEP_PID=""
exit_if_idle_without_sessions
if [ "$USR1_FIRED" -eq 1 ]; then
USR1_FIRED=0
else

View File

@@ -386,6 +386,9 @@ fi
# which caused runaway parallel Claude analysis processes.
SIGNAL_EVERY_N="${ECC_OBSERVER_SIGNAL_EVERY_N:-20}"
SIGNAL_COUNTER_FILE="${PROJECT_DIR}/.observer-signal-counter"
ACTIVITY_FILE="${PROJECT_DIR}/.observer-last-activity"
touch "$ACTIVITY_FILE" 2>/dev/null || true
should_signal=0
if [ -f "$SIGNAL_COUNTER_FILE" ]; then

View File

@@ -76,6 +76,12 @@ test('observe.sh default throttle is 20 observations per signal', () => {
assert.ok(content.includes('ECC_OBSERVER_SIGNAL_EVERY_N:-20'), 'Default signal frequency should be every 20 observations');
});
test('observe.sh touches observer activity marker on each observation', () => {
const content = fs.readFileSync(observeShPath, 'utf8');
assert.ok(content.includes('ACTIVITY_FILE="${PROJECT_DIR}/.observer-last-activity"'), 'observe.sh should define a project-scoped activity marker');
assert.ok(content.includes('touch "$ACTIVITY_FILE"'), 'observe.sh should update activity marker during observation capture');
});
// ──────────────────────────────────────────────────────
// Test group 2: observer-loop.sh re-entrancy guard
// ──────────────────────────────────────────────────────
@@ -126,6 +132,19 @@ test('default cooldown is 60 seconds', () => {
assert.ok(content.includes('ECC_OBSERVER_ANALYSIS_COOLDOWN:-60'), 'Default cooldown should be 60 seconds');
});
test('observer-loop.sh defines idle timeout fallback', () => {
const content = fs.readFileSync(observerLoopPath, 'utf8');
assert.ok(content.includes('IDLE_TIMEOUT_SECONDS'), 'observer-loop.sh should define an idle timeout');
assert.ok(content.includes('ECC_OBSERVER_IDLE_TIMEOUT_SECONDS:-1800'), 'Default idle timeout should be 30 minutes');
});
test('observer-loop.sh checks session lease directory before self-termination', () => {
const content = fs.readFileSync(observerLoopPath, 'utf8');
assert.ok(content.includes('SESSION_LEASE_DIR="${PROJECT_DIR}/.observer-sessions"'), 'observer-loop.sh should track active observer session leases');
assert.ok(content.includes('has_active_session_leases'), 'observer-loop.sh should define active session lease checks');
assert.ok(content.includes('exit_if_idle_without_sessions'), 'observer-loop.sh should define idle self-termination helper');
});
// ──────────────────────────────────────────────────────
// Test group 4: Tail-based sampling (no full file load)
// ──────────────────────────────────────────────────────

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(