commit 01b032b66a03fcd3a69b94ff66a39315e7322493 equwal <13551856+equwal@users.noreply.github.com> 2026-09-21 18:15:37 -0700 Record a background start that fails, and go on The watcher and the dashboard upload start a separate node process. When that process could not start, nothing handled the spawn error, and the error stopped the caller. Now the error goes to watch.json or publish.json, and kick() waits 10 minutes before it tries again.
src/publish.js | 5 ++++- src/watch.js | 5 ++++- tests/publish.test.js | 15 +++++++++++++++ tests/watch.test.js | 16 ++++++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-)
diff --git a/src/publish.js b/src/publish.js index 17a0a68..e925d56 100644 --- a/src/publish.js +++ b/src/publish.js @@ -203,7 +203,10 @@ export async function backgroundPublish() { function startProcess() { const bin = fileURLToPath(new URL('../bin/ideamine.js', import.meta.url)); - spawn(process.execPath, [bin, 'publish', '--background'], { cwd: home(), detached: true, stdio: 'ignore', windowsHide: true }).unref(); + const child = spawn(process.execPath, [bin, 'publish', '--background'], { cwd: home(), detached: true, stdio: 'ignore', windowsHide: true }); + // A publish that cannot start must not stop the caller, for example the server of the dashboard. + child.on('error', (e) => writeState({ error: `cannot start a publish: ${e.message}`, errorAt: new Date().toISOString() })); + child.unref(); } /** diff --git a/src/watch.js b/src/watch.js index b3ca90a..136937a 100644 --- a/src/watch.js +++ b/src/watch.js @@ -63,7 +63,10 @@ function isRunning(now) { /** Run a pass in a separate process, which goes on after the caller (the hook) exits. */ function startPassProcess() { const bin = fileURLToPath(new URL('../bin/ideamine.js', import.meta.url)); - spawn(process.execPath, [bin, 'watch-pass'], { cwd: home(), detached: true, stdio: 'ignore', windowsHide: true }).unref(); + const child = spawn(process.execPath, [bin, 'watch-pass'], { cwd: home(), detached: true, stdio: 'ignore', windowsHide: true }); + // A pass that cannot start must not stop the caller, for example the server of the dashboard. + child.on('error', (e) => writeState({ error: `cannot start a pass: ${e.message}`, errorAt: new Date().toISOString() })); + child.unref(); } /** Start a pass if the watcher is on, there is work, no pass runs, and no pass failed a short time ago. */ diff --git a/tests/publish.test.js b/tests/publish.test.js index 61376e0..6465f68 100644 --- a/tests/publish.test.js +++ b/tests/publish.test.js @@ -105,6 +105,21 @@ test('a failed background publish is recorded, and kick() waits before the next assert.match(publish.status(), /failed/); }); +test('a background publish that cannot start is recorded, and the caller goes on', async () => { + store.addIdeas(['one idea']); + config.set('publish_url', `${server.url}/`); + const node = process.execPath; + process.execPath = path.join(store.home(), 'no-such-node'); + try { + assert.equal(publish.kick(), true); + await new Promise((resolve) => setTimeout(resolve, 300)); // the spawn error comes later + } finally { + process.execPath = node; + } + assert.match(publish.readState().error, /^cannot start a publish: .*ENOENT/); + assert.equal(publish.kick({ start: () => assert.fail('must wait after the error') }), false); +}); + test('publish --dir writes both files to a folder', async () => { store.addIdeas(['one idea']); const dir = path.join(store.home(), 'out'); diff --git a/tests/watch.test.js b/tests/watch.test.js index 0865c46..471a26f 100644 --- a/tests/watch.test.js +++ b/tests/watch.test.js @@ -36,6 +36,22 @@ test('the watcher starts a pass only when it is on, there is work, and no pass r assert.equal(started, 1); }); +test('a pass that cannot start is recorded, and the caller goes on', async () => { + // The dashboard server calls kick() and must live on. Once, a failed start stopped that server. + watch.turnOn(); + store.addIdeas(['one']); + const node = process.execPath; + process.execPath = path.join(store.home(), 'no-such-node'); + try { + assert.equal(watch.kick(), true); + await new Promise((resolve) => setTimeout(resolve, 300)); // the spawn error comes later + } finally { + process.execPath = node; + } + assert.match(watch.readState().error, /^cannot start a pass: .*ENOENT/); + assert.equal(watch.kick({ startPass: () => assert.fail('must wait after the error') }), false); +}); + test('a pass triages the inbox and the ideas from before pairing, with Haiku, and logs it', async () => { store.addIdeas(['old idea', 'new idea']); store.applyTriage([{ id: 1, verdict: 'do', impact: 3, size: 's', model: 'sonnet', title: 'Old', why: 'w', brief: 'b' }]);