From 9aace2e6fe3f28264a5c810a3e340e154b858662 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 30 Apr 2026 06:08:58 -0400 Subject: [PATCH] fix: keep loop status scans fail-soft --- scripts/loop-status.js | 39 ++++++++++++++---- tests/scripts/loop-status.test.js | 67 ++++++++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 9 deletions(-) diff --git a/scripts/loop-status.js b/scripts/loop-status.js index 6a3c4805..454ef6b0 100644 --- a/scripts/loop-status.js +++ b/scripts/loop-status.js @@ -135,21 +135,32 @@ function getNow(options = {}) { return now; } -function walkJsonlFiles(dir, files = []) { +function walkJsonlFiles(dir, result = { errors: [], files: [] }) { if (!fs.existsSync(dir)) { - return files; + return result; + } + + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (error) { + result.errors.push({ + code: error.code || null, + message: error.message, + transcriptPath: dir, + }); + return result; } - const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { - walkJsonlFiles(fullPath, files); + walkJsonlFiles(fullPath, result); } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { - files.push(fullPath); + result.files.push(fullPath); } } - return files; + return result; } function findTranscriptPaths(options = {}) { @@ -164,10 +175,11 @@ function findTranscriptPaths(options = {}) { const homeDir = getHomeDir(normalizedOptions); const transcriptRoot = path.join(homeDir, '.claude', 'projects'); - const errors = []; + const walkResult = walkJsonlFiles(transcriptRoot); + const errors = [...walkResult.errors]; const transcriptEntries = []; - for (const transcriptPath of walkJsonlFiles(transcriptRoot)) { + for (const transcriptPath of walkResult.files) { try { transcriptEntries.push({ transcriptPath, @@ -345,6 +357,10 @@ function buildRecommendation(signals) { return 'Open the transcript or interrupt the parked session; the scheduled wake is overdue.'; } + if (signals.some(signal => signal.type === 'transcript_parse_errors')) { + return 'Inspect the transcript; some JSONL lines could not be parsed.'; + } + return 'No stale ScheduleWakeup or Bash waits detected.'; } @@ -454,6 +470,13 @@ function analyzeTranscript(transcriptPath, options = {}) { } } + if (parseErrors > 0) { + signals.push({ + count: parseErrors, + type: 'transcript_parse_errors', + }); + } + return { eventCount: entries.length, lastEventAt: toIso(lastEventAt), diff --git a/tests/scripts/loop-status.test.js b/tests/scripts/loop-status.test.js index 98787cf7..4da04a4c 100644 --- a/tests/scripts/loop-status.test.js +++ b/tests/scripts/loop-status.test.js @@ -9,7 +9,7 @@ const path = require('path'); const { execFileSync } = require('child_process'); const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'loop-status.js'); -const { analyzeTranscript } = require('../../scripts/loop-status'); +const { analyzeTranscript, buildStatus } = require('../../scripts/loop-status'); const NOW = '2026-04-30T10:00:00.000Z'; function run(args = [], options = {}) { @@ -313,6 +313,71 @@ function runTests() { assert.strictEqual(payload.errors[0].transcriptPath, missingTranscript); })) passed++; else failed++; + if (test('continues when one transcript directory cannot be read', () => { + const homeDir = createTempHome(); + const blockedDir = path.join(homeDir, '.claude', 'projects', '-blocked-project'); + const originalReaddirSync = fs.readdirSync; + + try { + writeTranscript(homeDir, '-Users-affoon-project-readable', 'session-readable.jsonl', [ + toolResult('2026-04-30T09:41:00.000Z', 'session-readable', 'toolu_done', 'done'), + ]); + fs.mkdirSync(blockedDir, { recursive: true }); + fs.readdirSync = (dir, options) => { + if (path.resolve(dir) === path.resolve(blockedDir)) { + const error = new Error('permission denied'); + error.code = 'EACCES'; + throw error; + } + return originalReaddirSync(dir, options); + }; + + const payload = buildStatus({ home: homeDir, now: NOW }); + + assert.strictEqual(payload.sessions.length, 1); + assert.strictEqual(payload.sessions[0].sessionId, 'session-readable'); + assert.strictEqual(payload.errors.length, 1); + assert.strictEqual(payload.errors[0].code, 'EACCES'); + assert.strictEqual(payload.errors[0].transcriptPath, blockedDir); + } finally { + fs.readdirSync = originalReaddirSync; + fs.rmSync(homeDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('reports malformed JSONL lines as an attention signal', () => { + const homeDir = createTempHome(); + + try { + const transcriptDir = path.join(homeDir, '.claude', 'projects', '-Users-affoon-project-malformed'); + fs.mkdirSync(transcriptDir, { recursive: true }); + fs.writeFileSync( + path.join(transcriptDir, 'session-malformed.jsonl'), + [ + JSON.stringify({ + timestamp: '2026-04-30T09:55:00.000Z', + sessionId: 'session-malformed', + message: { role: 'assistant', content: [{ type: 'text', text: 'partial log' }] }, + }), + '{"timestamp":', + ].join('\n') + '\n', + 'utf8' + ); + + const result = run(['--home', homeDir, '--now', NOW, '--json']); + + assert.strictEqual(result.code, 0, result.stderr); + const payload = parsePayload(result.stdout); + assert.strictEqual(payload.sessions[0].state, 'attention'); + assert.ok(payload.sessions[0].signals.some(signal => ( + signal.type === 'transcript_parse_errors' + && signal.count === 1 + ))); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + if (test('rejects non-integer limit values', () => { const result = run(['--limit', '1.5']);