mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-18 14:07:33 +02:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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++; };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user