claude-spotter 0.13.1 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.2
4
+
5
+ **Daemon の死因を必ずログに残す診断インフラ + Haiku 子プロセス stdio の防御的 error listener**。v0.13.1 までは daemon が `uncaughtException` / `unhandledRejection` で死ぬと痕跡ゼロで消えていた ([daemon-80b5c0af.log](../../.spotter/logs/daemon-80b5c0af-700f-47af-a3ac-796144823a7d.log) line 15 → line 16 で shutdown ログなしに再起動)。次に同じことが起きた時に真因を必ず捕まえられるよう、診断 handler を導入。
6
+
7
+ ### 監査の経過と結論
8
+
9
+ [haiku-caller.mjs](src/daemon/haiku-caller.mjs) の `child.stdin.end(prompt)` → timeout で `child.kill()` という流れで、未 flush の stdin が EPIPE を emit → unhandled stream error → daemon 即死、という仮説を立てた。Windows + Node v24.14.0 で repro script を書いて検証したところ、**`child.stdin/stdout/stderr` の error listener 不在でも uncaughtException は発火せず process は生存** (stdin 8KB end → 500ms 後に kill → 4 秒生存して clean exit、EXIT=0)。
10
+
11
+ つまり 80b5c0af の死因は stdin EPIPE ではなく、別経路。ログには `handler error on turn_end: E_HAIKU_TIMEOUT` (transport の catch + onError まで完了) が残っているので、その後の何かで死んでいる。**真因を確定できる証拠がログにないことが本質的な問題**と判断、診断 handler を先に入れる方針に切替えた。
12
+
13
+ ### 変更点
14
+
15
+ - **編集 [src/cli/daemon-cmd.mjs](src/cli/daemon-cmd.mjs)**: daemon 起動冒頭で `process.on('uncaughtException')` / `process.on('unhandledRejection')` を登録。**同期 `writeFileSync` で log file に append** してから `process.exit(1)`。async write はバッファ flush 前に exit して line を失うが、sync write なら必ず残る。次回死亡時に stack trace + 種別 (`uncaughtException` か `unhandledRejection` か) が確実に記録される
16
+ - **編集 [src/daemon/haiku-caller.mjs](src/daemon/haiku-caller.mjs)**: `child.stdin/stdout/stderr` に no-op error listener を追加。今の Node v24 では落ちないと実証済みだが、Node 公式 docs はこの edge case を明示保証していない (将来の Node 変更や別 OS で挙動が変わる可能性) ため、defensive coding として投入
17
+
18
+ ### 残課題
19
+
20
+ - daemon 突然死の真因特定: **次回再現を待つ**。診断 handler が入ったので、次に死亡した時はログに必ず痕跡が残る。それを見て対処する
21
+ - v0.13.1 で 30s → 45s 緩和した Haiku timeout の効果観測は継続: 現セッション [daemon-69bd2b93.log](../../.spotter/logs/daemon-69bd2b93-ffbe-43bc-94e7-1d0ba2bd9e74.log) line 5 で `mode=first, duration_ms=32703` を観測 (30s 設定なら timeout していた値が 45s で生存)。サンプル 1 件で結論はまだ早い
22
+
3
23
  ## 0.13.1
4
24
 
5
25
  **Haiku timeout 30s → 45s 緩和 + hook 側 IPC timeout を整合**。v0.13.0 以前の実セッション ([daemon-80b5c0af.log](../../.spotter/logs/daemon-80b5c0af-700f-47af-a3ac-796144823a7d.log) line 15) で `E_HAIKU_TIMEOUT: haiku did not respond within 30000ms` を観測。同ログ line 20 でも `mode=first, duration_ms=20948` と 30s の 70% 域まで達しており、timeout が実測レイテンシに対して狭すぎた。合わせて [src/hooks/stop.mjs](src/hooks/stop.mjs) の IPC timeout が元々 15s で Haiku 側 30s と整合していなかった既存バグ (turn_end で Haiku が 16s 超かかると hook 側が先に諦めていた) も同時解消。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "0.13.1",
3
+ "version": "0.13.2",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,6 +4,7 @@ import { startDaemon, DaemonAlreadyRunningError } from '../daemon/daemon.mjs';
4
4
  import { homedir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { open } from 'node:fs/promises';
7
+ import { writeFileSync } from 'node:fs';
7
8
 
8
9
  function parseArgs(argv) {
9
10
  const out = { sessionId: null, projectRoot: null };
@@ -30,10 +31,32 @@ export async function runDaemonStart({ argv }) {
30
31
  process.exit(2);
31
32
  }
32
33
 
33
- const logFile = await open(
34
- join(homedir(), '.spotter', 'logs', `daemon-${sessionId}.log`),
35
- 'a'
36
- );
34
+ const logFilePath = join(homedir(), '.spotter', 'logs', `daemon-${sessionId}.log`);
35
+
36
+ // v0.13.2: last-resort fatal handlers. Without these, an uncaughtException or
37
+ // unhandledRejection silently kills the daemon and the regular logFile.write()
38
+ // (async) loses the trailing line on sudden death — leaving zero forensic
39
+ // trace. We do a sync append so the cause is always captured before exit.
40
+ // See open-issues.md "daemon プロセスが shutdown ログなしに死ぬ".
41
+ const fatalLog = (kind, err) => {
42
+ const detail = err && err.stack ? err.stack : String(err);
43
+ const line = `[${new Date().toISOString()}] FATAL ${kind}: ${detail}\n`;
44
+ try {
45
+ writeFileSync(logFilePath, line, { flag: 'a' });
46
+ } catch {
47
+ process.stderr.write(line);
48
+ }
49
+ };
50
+ process.on('uncaughtException', (err) => {
51
+ fatalLog('uncaughtException', err);
52
+ process.exit(1);
53
+ });
54
+ process.on('unhandledRejection', (reason) => {
55
+ fatalLog('unhandledRejection', reason);
56
+ process.exit(1);
57
+ });
58
+
59
+ const logFile = await open(logFilePath, 'a');
37
60
  const log = (msg) => {
38
61
  const line = `[${new Date().toISOString()}] ${msg}\n`;
39
62
  logFile.write(line).catch(() => {});
@@ -242,6 +242,16 @@ export function createHaikuCaller({ preamble, timeoutMs, claudeBin = 'claude', m
242
242
  let stderr = '';
243
243
  let settled = false;
244
244
 
245
+ // v0.13.2: absorb EPIPE/ECONNRESET on stdio streams. We end(prompt) and
246
+ // then kill() on timeout — if the write hadn't drained yet, the unflushed
247
+ // stream can emit 'error' after kill. Today's Node doesn't crash on
248
+ // unhandled stdin errors, but the docs don't guarantee that, and this
249
+ // listener removes a potential silent-death path.
250
+ const noop = () => {};
251
+ child.stdin.on('error', noop);
252
+ child.stdout.on('error', noop);
253
+ child.stderr.on('error', noop);
254
+
245
255
  const timer = setTimeout(() => {
246
256
  if (settled) return;
247
257
  settled = true;