From c1492a40039d3b7765010457232777eb4556bfa0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 10:20:07 +0000 Subject: [PATCH] fix: stop the daily KPI run hanging when no manual activities are given promptUser only settled from readline's answer callback, which never fires when stdin is at EOF. Dispatching daily-kpi with the optional activities input left blank sets MANUAL_ACTIVITIES='', taking the interactive path on a runner that has no keyboard, so the job stalled and exited 0 without saving a KPI record or raising an alert. Closes #32 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CAM6ikRvg1YcErFvkFLMML --- src/tools/manual-kpi.test.ts | 57 ++++++++++++++++++++++++++++++++++++ src/tools/manual-kpi.tool.ts | 6 ++-- 2 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 src/tools/manual-kpi.test.ts diff --git a/src/tools/manual-kpi.test.ts b/src/tools/manual-kpi.test.ts new file mode 100644 index 0000000..af0348c --- /dev/null +++ b/src/tools/manual-kpi.test.ts @@ -0,0 +1,57 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const toolUrl = new URL('./manual-kpi.tool.ts', import.meta.url).href +const root = fileURLToPath(new URL('../..', import.meta.url)) + +const SCRIPT = ` + const { manualKpiTool } = await import(${JSON.stringify(toolUrl)}) + process.stdout.write('RESULT:' + (await manualKpiTool.invoke({}))) +` + +// readline writes the prompt to stdout, which would corrupt the test runner's own +// channel — so the tool runs in a child, the way a workflow step runs it. +function collect(typed: string | null): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', SCRIPT], { + cwd: root, + env: { ...process.env, MANUAL_ACTIVITIES: '' }, + stdio: [typed === null ? 'ignore' : 'pipe', 'pipe', 'pipe'], + }) + + let out = '' + + child.stdout?.on('data', (chunk) => (out += chunk)) + child.stdin?.end(typed ?? '') + + const timer = setTimeout(() => { + child.kill('SIGKILL') + reject(new Error('collect_manual_kpi_input never settled')) + }, 60_000) + + child.on('error', reject) + child.on('exit', () => { + clearTimeout(timer) + resolve(out.slice(out.indexOf('RESULT:') + 'RESULT:'.length)) + }) + }) +} + +const activitiesFrom = (out: string) => JSON.parse(out).activities + +test('returns no activities when stdin is closed rather than waiting for input that can never arrive', async () => { + const out = await collect(null) + + assert.ok(out.startsWith('{'), 'the tool must settle on a closed stdin, not leave the job hanging') + assert.deepEqual(activitiesFrom(out), []) +}) + +test('still records an activity that arrived before stdin ended', async () => { + assert.deepEqual(activitiesFrom(await collect('Reviewed 3 PRs\n')), ['Reviewed 3 PRs']) +}) + +test('still treats a blank line as the end of input', async () => { + assert.deepEqual(activitiesFrom(await collect('Paired on the auth bug\n\n')), ['Paired on the auth bug']) +}) diff --git a/src/tools/manual-kpi.tool.ts b/src/tools/manual-kpi.tool.ts index ab0871b..a392945 100644 --- a/src/tools/manual-kpi.tool.ts +++ b/src/tools/manual-kpi.tool.ts @@ -7,10 +7,12 @@ function promptUser(question: string): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) return new Promise((resolve) => { - rl.question(question, (answer) => { - rl.close() + rl.on('close', () => resolve('')) + rl.question(question, (answer) => { resolve(answer.trim()) + + rl.close() }) }) }