Merge PR #3490: reserve lifecycle time for Stop persistence

Isolate deadline fixtures from inherited dry-run/disabled hook flags. Remove an unapproved emoji from the new routing regression to satisfy Unicode validation.
This commit is contained in:
haelyra
2026-10-10 17:43:46 -04:00
7 changed files with 240 additions and 14 deletions
+4 -2
View File
@@ -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
}
+18 -2
View File
@@ -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));
+8 -2
View File
@@ -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');
}
}
+22 -3
View File
@@ -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'
});
@@ -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++; };
+120 -4
View File
@@ -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);
}
+67
View File
@@ -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}`);