diff --git a/scripts/hooks/lifecycle-hook-bootstrap.js b/scripts/hooks/lifecycle-hook-bootstrap.js index d0fab3c0a..629e031c9 100644 --- a/scripts/hooks/lifecycle-hook-bootstrap.js +++ b/scripts/hooks/lifecycle-hook-bootstrap.js @@ -80,6 +80,7 @@ async function main() { writeStderr(`[Hook] lifecycle stdin exceeded ${maxStdin} bytes; forwarded a bounded prefix`); } + const timeout = resolveTimeout(timeoutValue); const result = spawnSync( process.execPath, [runner, hookId, relScriptPath, profilesCsv || 'minimal,standard,strict'], @@ -92,10 +93,11 @@ async function main() { ECC_PLUGIN_ROOT: resolvedRoot, ECC_HOOK_INPUT_MAX_BYTES: String(maxStdin), ECC_HOOK_INPUT_TRUNCATED_UPSTREAM: truncated ? '1' : '0', - ECC_HOOK_CONTEXT_JSON: JSON.stringify(hookContext) + ECC_HOOK_CONTEXT_JSON: JSON.stringify(hookContext), + ECC_HOOK_DEADLINE_MS: String(Date.now() + timeout) }, cwd: process.cwd(), - timeout: resolveTimeout(timeoutValue), + timeout, maxBuffer: 16 * 1024 * 1024, windowsHide: true } diff --git a/scripts/hooks/run-with-flags.js b/scripts/hooks/run-with-flags.js index 19f0d1b9a..00c666308 100755 --- a/scripts/hooks/run-with-flags.js +++ b/scripts/hooks/run-with-flags.js @@ -299,6 +299,21 @@ async function main() { } // Legacy path: spawn a child Node process for hooks without run() export + // Direct runner invocations also need a deadline. Preserve an earlier + // bootstrap deadline instead of granting another full 30 seconds. + const inheritedDeadline = Number(process.env.ECC_HOOK_DEADLINE_MS); + const deadline = Math.min( + Date.now() + 30000, + Number.isSafeInteger(inheritedDeadline) && inheritedDeadline > 0 + ? inheritedDeadline + : Infinity + ); + const remaining = deadline - Date.now(); + if (remaining <= 0) { + writeStderr(`[Hook] lifecycle time budget exhausted for ${hookId}; skipping legacy hook`); + exitWithStdout('', 0); + return; + } const result = spawnSync(process.execPath, [scriptPath], { input: raw, encoding: 'utf8', @@ -308,10 +323,11 @@ async function main() { ECC_PLUGIN_ROOT: pluginRoot, ECC_HOOK_ID: hookId, ECC_HOOK_INPUT_TRUNCATED: truncated ? '1' : '0', - ECC_HOOK_INPUT_MAX_BYTES: String(MAX_STDIN) + ECC_HOOK_INPUT_MAX_BYTES: String(MAX_STDIN), + ECC_HOOK_DEADLINE_MS: String(deadline) }, cwd: process.cwd(), - timeout: 30000 + timeout: remaining }); const legacyStdout = sanitizeEcho(resolveLegacySpawnStdout(result)); diff --git a/scripts/hooks/session-end.js b/scripts/hooks/session-end.js index 5c31a8e0b..c7431eea7 100644 --- a/scripts/hooks/session-end.js +++ b/scripts/hooks/session-end.js @@ -257,10 +257,16 @@ async function main() { const isPeriodicTurn = summary.totalMessages > 0 && summary.totalMessages % safeInterval === 0; if (isContextLow || isPeriodicTurn) { log(`[SessionEnd] LLM summary triggered (context: ${contextPct ?? 'unknown'}%, messages: ${summary.totalMessages})`); - llmSummary = generateSessionSummary(transcriptPath); + let skipped = false; + llmSummary = generateSessionSummary(transcriptPath, { + onSkip: reason => { + skipped = true; + log(`[SessionEnd] LLM summary skipped (${reason}); falling back to mechanical extraction`); + } + }); if (llmSummary) { log('[SessionEnd] LLM summary generated successfully'); - } else { + } else if (!skipped) { log('[SessionEnd] LLM summary failed; falling back to mechanical extraction'); } } diff --git a/scripts/lib/llm-summary.js b/scripts/lib/llm-summary.js index b53fabd89..2473b3797 100644 --- a/scripts/lib/llm-summary.js +++ b/scripts/lib/llm-summary.js @@ -18,6 +18,8 @@ const fs = require('fs'); const MAX_TRANSCRIPT_CHARS = 7000; const MAX_TURNS = 25; const LLM_TIMEOUT_MS = 90000; +// Leave the Stop hook time to persist its mechanical fallback after a timeout. +const HOOK_FINISH_RESERVE_MS = 1000; function getLLMModel() { return process.env.ECC_LLM_SUMMARY_MODEL || 'haiku'; @@ -109,12 +111,29 @@ function getContextRemainingPct(transcriptPath) { * Generate a session summary using `claude -p`. * Returns the summary string, or null on failure or when recursion guard is active. */ -function generateSessionSummary(transcriptPath) { - if (process.env.ECC_SKIP_LLM_SUMMARY) return null; +function generateSessionSummary(transcriptPath, { onSkip = () => {} } = {}) { + if (process.env.ECC_SKIP_LLM_SUMMARY) { + onSkip('ECC_SKIP_LLM_SUMMARY'); + return null; + } const conversation = extractConversationText(transcriptPath); if (!conversation) return null; + let timeout = LLM_TIMEOUT_MS; + if (process.env.ECC_HOOK_DEADLINE_MS !== undefined) { + const deadline = Number(process.env.ECC_HOOK_DEADLINE_MS); + if (!Number.isSafeInteger(deadline) || deadline <= 0) { + onSkip('invalid lifecycle deadline'); + return null; + } + timeout = Math.min(timeout, deadline - Date.now() - HOOK_FINISH_RESERVE_MS); + if (timeout <= 0) { + onSkip('insufficient lifecycle time budget'); + return null; + } + } + const prompt = [ 'Below is a conversation log from a Claude Code coding session.', 'Create a summary to help the next session quickly understand the context.', @@ -159,7 +178,7 @@ function generateSessionSummary(transcriptPath) { ECC_SKIP_LLM_SUMMARY: '1', ECC_LLM_SUMMARY_SUBPROCESS: '1' }, - timeout: LLM_TIMEOUT_MS, + timeout, shell: process.platform === 'win32' }); diff --git a/tests/hooks/claude-config-dir-routing.test.js b/tests/hooks/claude-config-dir-routing.test.js index d82f41204..7f41435e7 100644 --- a/tests/hooks/claude-config-dir-routing.test.js +++ b/tests/hooks/claude-config-dir-routing.test.js @@ -98,7 +98,7 @@ function writeSessions(stateDir, sessions) { } function runTests() { - console.log('\n🧪 Testing CLAUDE_CONFIG_DIR routing in hooks\n'); + console.log('\nTesting CLAUDE_CONFIG_DIR routing in hooks\n'); let passed = 0; let failed = 0; const record = ok => { if (ok) passed++; else failed++; }; diff --git a/tests/hooks/session-end.test.js b/tests/hooks/session-end.test.js index 05e74eeac..93692e38a 100644 --- a/tests/hooks/session-end.test.js +++ b/tests/hooks/session-end.test.js @@ -37,11 +37,19 @@ function countOccurrences(haystack, needle) { return n; } +function isolatedHomeEnv(home) { + return { + ...process.env, HOME: home, USERPROFILE: home, + ECC_AGENT_DATA_HOME: path.join(home, '.claude'), + CLAUDE_CONFIG_DIR: path.join(home, '.claude') + }; +} + function runHook(home, transcript, env = {}) { - return spawnSync('node', [script], { + return spawnSync(process.execPath, [script], { encoding: 'utf8', input: transcript ? JSON.stringify({ transcript_path: transcript }) : '', - env: { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_SESSION_ID: '', ...env }, + env: { ...isolatedHomeEnv(home), CLAUDE_SESSION_ID: '', ...env }, timeout: 10000, }); } @@ -96,7 +104,7 @@ function runTests() { const res = spawnSync('node', [script], { encoding: 'utf8', input: JSON.stringify({ transcript_path: transcript }), - env: { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_SESSION_ID: '' }, + env: { ...isolatedHomeEnv(home), CLAUDE_SESSION_ID: '' }, timeout: 10000, }); assert.strictEqual(res.status || 0, 0, `hook exited ${res.status}: ${res.stderr}`); @@ -225,7 +233,7 @@ function runTests() { const res = spawnSync('node', [script], { encoding: 'utf8', input: '{not-json', - env: { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_SESSION_ID: 'fallback-session-12345678', CLAUDE_TRANSCRIPT_PATH: '' }, + env: { ...isolatedHomeEnv(home), CLAUDE_SESSION_ID: 'fallback-session-12345678', CLAUDE_TRANSCRIPT_PATH: '' }, timeout: 10000, }); assert.strictEqual(res.status || 0, 0, `hook exited ${res.status}: ${res.stderr}`); @@ -237,6 +245,114 @@ function runTests() { } }) ? passed++ : failed++); + (test('persists mechanical resume state when the LLM budget is exhausted', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-budget-')); + try { + const uuid = '12345678-1234-4234-8234-123456789abc'; + const transcript = path.join(home, `${uuid}.jsonl`); + fs.writeFileSync(transcript, JSON.stringify({ type: 'user', content: 'Keep this task resumable' }) + '\n'); + const bin = path.join(home, 'empty-bin'); + fs.mkdirSync(bin); + const res = runHook(home, transcript, { + ECC_LLM_SUMMARY_INTERVAL: '1', ECC_HOOK_DEADLINE_MS: '1', + ECC_SKIP_LLM_SUMMARY: '', ECC_LLM_SUMMARY_SUBPROCESS: '', PATH: bin + }); + assert.strictEqual(res.status, 0, res.stderr); + assert.match(res.stderr, /LLM summary skipped.*insufficient lifecycle time budget/); + assert.doesNotMatch(res.stderr, /LLM summary failed/); + assert.match(fs.readFileSync(sessionFileFor(home, uuid), 'utf8'), /Keep this task resumable/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }) ? passed++ : failed++); + + (test('logs an explicit LLM disable as skipped and persists mechanical resume state', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-skip-')); + try { + const uuid = '12345678-1234-4234-8234-123456789abc'; + const transcript = path.join(home, `${uuid}.jsonl`); + const bin = path.join(home, 'empty-bin'); + fs.mkdirSync(bin); + fs.writeFileSync(transcript, JSON.stringify({ type: 'user', content: 'Save context with LLM disabled' }) + '\n'); + const res = runHook(home, transcript, { + ECC_LLM_SUMMARY_INTERVAL: '1', ECC_SKIP_LLM_SUMMARY: '1', + ECC_LLM_SUMMARY_SUBPROCESS: '', PATH: bin + }); + assert.strictEqual(res.status, 0, res.stderr); + assert.match(res.stderr, /LLM summary skipped.*ECC_SKIP_LLM_SUMMARY/); + assert.doesNotMatch(res.stderr, /LLM summary failed/); + assert.match(fs.readFileSync(sessionFileFor(home, uuid), 'utf8'), /Save context with LLM disabled/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }) ? passed++ : failed++); + + for (const inherited of ['', String(Date.now() + 300000), '1']) { + (test(`direct legacy runner bounds the deadline ${inherited || '(unset)'}`, () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-runner-budget-')); + try { + const relative = 'legacy.js'; + fs.writeFileSync(path.join(home, relative), "process.stdout.write(JSON.stringify({deadline:Number(process.env.ECC_HOOK_DEADLINE_MS),now:Date.now()}));\n"); + const runner = path.resolve(__dirname, '../../scripts/hooks/run-with-flags.js'); + const res = spawnSync(process.execPath, [runner, 'session:stop:session-end', relative, 'standard'], { + input: '{}', encoding: 'utf8', timeout: 5000, + env: { + ...isolatedHomeEnv(home), CLAUDE_PLUGIN_ROOT: home, + ECC_HOOK_PROFILE: 'standard', ECC_DISABLED_HOOKS: '', ECC_DRY_RUN: '0', ECC_HOOKS_ENABLED: 'true', + ECC_HOOK_DEADLINE_MS: inherited + } + }); + assert.strictEqual(res.status, 0, res.stderr); + if (inherited === '1') { + assert.strictEqual(res.stdout, ''); + assert.match(res.stderr, /time budget exhausted/); + } else { + const result = JSON.parse(res.stdout); + assert.ok(result.deadline > result.now); + assert.ok(result.deadline - result.now <= 30000, 'Inherited far-future deadlines must not extend the runner budget'); + } + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }) ? passed++ : failed++); + } + + // A local stand-in exercises the actual bootstrap -> runner -> Stop hook -> + // child timeout chain without invoking Claude or using authentication. + if (process.platform !== 'win32') { + (test('times out a slow summarizer before lifecycle expiry and saves fallback', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-budget-')); + try { + const uuid = '12345678-1234-4234-8234-123456789abc'; + const transcript = path.join(home, `${uuid}.jsonl`); + const bin = path.join(home, 'bin'); + fs.mkdirSync(bin); + fs.writeFileSync(transcript, JSON.stringify({ type: 'user', content: 'Persist after a slow summary' }) + '\n'); + fs.writeFileSync(path.join(bin, 'claude'), `#!${process.execPath}\nrequire('fs').writeFileSync(${JSON.stringify(path.join(home, 'started'))}, 'started');\nsetTimeout(() => console.log('late summary'), 5000);\n`, { mode: 0o755 }); + const root = path.resolve(__dirname, '../..'); + const bootstrap = path.join(root, 'scripts/hooks/lifecycle-hook-bootstrap.js'); + const res = spawnSync(process.execPath, [bootstrap, 'session:stop:session-end', 'scripts/hooks/session-end.js', 'minimal,standard,strict', '2500'], { + encoding: 'utf8', input: JSON.stringify({ transcript_path: transcript }), + env: { + ...isolatedHomeEnv(home), PATH: bin, + CLAUDE_PLUGIN_ROOT: root, ECC_HOOK_PROFILE: 'standard', + ECC_LLM_SUMMARY_INTERVAL: '1', ECC_SKIP_LLM_SUMMARY: '', + ECC_LLM_SUMMARY_SUBPROCESS: '', ECC_HOOK_DEADLINE_MS: '1', + ECC_DISABLED_HOOKS: '', ECC_DRY_RUN: '0', ECC_HOOKS_ENABLED: 'true' + }, + timeout: 7000 + }); + assert.strictEqual(res.status, 0, res.stderr); + assert.ok(fs.existsSync(path.join(home, 'started')), 'Fresh bootstrap must replace an inherited expired deadline'); + assert.match(res.stderr, /LLM summary failed; falling back/); + assert.doesNotMatch(res.stderr, /lifecycle runner failed/); + assert.match(fs.readFileSync(sessionFileFor(home, uuid), 'utf8'), /Persist after a slow summary/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }) ? passed++ : failed++); + } + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } diff --git a/tests/lib/llm-summary.test.js b/tests/lib/llm-summary.test.js index 1705fe499..f836d20c4 100644 --- a/tests/lib/llm-summary.test.js +++ b/tests/lib/llm-summary.test.js @@ -185,6 +185,21 @@ test('returns null when ECC_SKIP_LLM_SUMMARY is set', () => { else delete process.env.ECC_SKIP_LLM_SUMMARY; }); +test('reports the explicit recursion guard as a deliberate skip', () => { + const orig = process.env.ECC_SKIP_LLM_SUMMARY; + process.env.ECC_SKIP_LLM_SUMMARY = '1'; + try { + const skipped = []; + assert.strictEqual(generateSessionSummary('/nonexistent.jsonl', { + onSkip: reason => skipped.push(reason) + }), null); + assert.deepStrictEqual(skipped, ['ECC_SKIP_LLM_SUMMARY']); + } finally { + if (orig !== undefined) process.env.ECC_SKIP_LLM_SUMMARY = orig; + else delete process.env.ECC_SKIP_LLM_SUMMARY; + } +}); + test('returns null for missing transcript (no conversation to summarize)', () => { const orig = process.env.ECC_SKIP_LLM_SUMMARY; delete process.env.ECC_SKIP_LLM_SUMMARY; @@ -201,6 +216,58 @@ test('marks the spawned summarizer so its Stop hook cannot create resume state', }); // --- Results --- +// Execute the real helper with a controlled clock and child-process boundary. +// These cases never invoke Claude or use the developer's authentication. +function summaryProbe(deadline, now = 100000, spawnResult = { status: 0, stdout: 'summary' }) { + const vm = require('vm'); + const source = fs.readFileSync(path.join(__dirname, '../../scripts/lib/llm-summary.js'), 'utf8'); + const calls = []; + const env = deadline === undefined ? {} : { ECC_HOOK_DEADLINE_MS: String(deadline) }; + const sandbox = { + module: { exports: {} }, + process: { env, platform: process.platform }, + Date: { now: () => now }, + require: name => name === 'child_process' + ? { spawnSync: (...args) => { calls.push(args); return spawnResult; } } + : require(name) + }; + vm.runInNewContext(source, sandbox, { filename: path.join(__dirname, '../../scripts/lib/llm-summary.js') }); + const skipped = []; + const result = sandbox.module.exports.generateSessionSummary( + writeTranscript([userEntry('Preserve mechanical resume state')]), + { onSkip: reason => skipped.push(reason) } + ); + return { calls, skipped, result }; +} + +test('limits summarizer to remaining hook budget minus persistence reserve', () => { + const probe = summaryProbe(105000); + assert.strictEqual(probe.result, 'summary'); + assert.strictEqual(probe.calls[0][2].timeout, 4000); + assert.strictEqual(probe.skipped.length, 0); +}); + +test('retains the standalone timeout and caps longer inherited budgets', () => { + assert.strictEqual(summaryProbe(undefined).calls[0][2].timeout, 90000); + assert.strictEqual(summaryProbe(300000).calls[0][2].timeout, 90000); +}); + +for (const deadline of [99999, 100000, 100999, 101000, '', -1, 'invalid', 'Infinity', '1.5', Number.MAX_SAFE_INTEGER + 1]) { + test(`skips summarizer with exhausted or invalid deadline ${deadline}`, () => { + const probe = summaryProbe(deadline); + assert.strictEqual(probe.result, null); + assert.strictEqual(probe.calls.length, 0, 'No child should start without a safe budget'); + assert.strictEqual(probe.skipped.length, 1, 'Skip must be distinguishable from Claude failure'); + }); +} + +test('keeps process failures distinct from deliberate budget skips', () => { + const probe = summaryProbe(105000, 100000, { status: 1, stdout: '' }); + assert.strictEqual(probe.result, null); + assert.strictEqual(probe.calls.length, 1); + assert.strictEqual(probe.skipped.length, 0); +}); + console.log('\n=== Test Results ==='); console.log(`Passed: ${passed}`); console.log(`Failed: ${failed}`);