throughline 0.16.3 → 0.16.5

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
@@ -10,6 +10,48 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.16.5] — 2026-10-08
14
+
15
+ ### 修正
16
+
17
+ - Codexのhookが、道具を1回使うたびに、その会話のL3(`details`)を全部消して入れ直していたのを直す([ADR 0042](docs/adr/0042-codex-capture-writes-only-changed-tail.md))。DBにある行とrolloutの行を先頭から本文まで比べ、最初に食い違った行から後ろだけを書く。取り込んだ後の中身と並びは、全部入れ直した時と同じ。
18
+ - Windowsの端末で、L3が3,552行・約1.47億字(rolloutは288MB)の会話のhookが、1回でWALへ152.9MBを書いていた。その端末の`throughline.db-wal`は12.8GBまで伸びていた(DB本体は230MB)。本物のDBの写しで測ると、変化の無いhookは0.3MB、89行が増えたhookは12.5MBになる。
19
+ - `getDb()`が`PRAGMA journal_size_limit`を64MBに設定する。WALが使い直された後の最初のcommitで、ファイルを64MBまで切り詰める。今までは、一度伸びたWALは中身が空になっても縮まなかった。
20
+
21
+ ### 変更
22
+
23
+ - rolloutに時刻の無いL3の行は、最初に取り込んだ時刻のまま残る。今までは、取り込むたびにその時の時刻へ変わっていた。
24
+ - `codex-capture --json`の結果へ、`keptDetails`・`writtenDetails`・`removedDetails`を足す。
25
+
26
+ ### 変わらない事
27
+
28
+ - schema(v12)。`bodies`(L2)と`skeletons`(L1)の扱い。
29
+ - Claude Code・Grok・Cursorの取り込み。
30
+
31
+ ### 確認していない範囲
32
+
33
+ - 複数のCodexの会話が同時に動く本物の端末で、WALが伸びなくなるか。公開の後に端末で読む。
34
+ - hookは今までどおり毎回rolloutの全体を読む。上の会話では約1.1秒かかる。Codexの画面での待ちとして見えているかは調べていない。
35
+
36
+ ## [0.16.4] — 2026-10-07
37
+
38
+ ### 変更
39
+
40
+ - Codex StopのL1要約backend(Codex CLI)の失敗を、1回ごとにruntime errorとして数えるのをやめる([ADR 0041](docs/adr/0041-l1-summarizer-backend-unrecovered.md))。この失敗は、通信の断、利用上限、認証切れ、利用者の取り消しでも起きる。会話の取り込みは済んでいて、要約は次のStopがやり直し、recallはL2の本文を返すので、修理の対象として自動で登録しない。理由と外部CLIのstderrの末尾は、今までどおり端末の`hook-failures.log`に残る。hookの終了codeとstderrは変えない。
41
+ - 最初の失敗から24時間を過ぎても成功を確認できない時だけ、`L1_SUMMARIZER_BACKEND_UNRECOVERED`(`warn`)で数える。要約が成功したら、その記録を`recovered`で解決にする。
42
+ - Codexの自動継続のworkerは、要約のbackendの失敗を`handoff_summarizer_backend_failed`で止める(今までは汎用の`handoff_worker_failed`)。失敗の画面に、通信の断・利用上限・認証切れで起きる事、記録と入力が失われていない事、再開の仕方を書く。
43
+
44
+ ### 変わらない事
45
+
46
+ - BugHubへ送る本文の形。項目は足していない。
47
+ - hook処理の失敗(`HOOK_*_FAILED`)の数え方と重大度。これらの経路に通信は無い。
48
+ - BugHubへの送信の失敗は、今までどおり送信の状態にだけ残し、runtime errorには数えない。
49
+
50
+ ### 確認していない範囲
51
+
52
+ - 要約が失敗し続ける間のCodexの画面への影響。
53
+ - 本物のCodex CLIが、通信の断・利用上限・認証切れのそれぞれで返す終了codeとstderr。
54
+
13
55
  ## [0.16.3] — 2026-10-07
14
56
 
15
57
  ### 修正
@@ -1940,7 +1982,9 @@ two attempts, instrument first instead of patching again.
1940
1982
 
1941
1983
  ---
1942
1984
 
1943
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.3...HEAD
1985
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.5...HEAD
1986
+ [0.16.5]: https://github.com/kitepon/Throughline/compare/v0.16.4...v0.16.5
1987
+ [0.16.4]: https://github.com/kitepon/Throughline/compare/v0.16.3...v0.16.4
1944
1988
  [0.16.3]: https://github.com/kitepon/Throughline/compare/v0.16.2...v0.16.3
1945
1989
  [0.16.2]: https://github.com/kitepon/Throughline/compare/v0.16.1...v0.16.2
1946
1990
  [0.16.1]: https://github.com/kitepon/Throughline/compare/v0.16.0...v0.16.1
@@ -0,0 +1,77 @@
1
+ # ADR 0041: L1 要約 backend の失敗は、復帰を確認できない時だけ数える
2
+
3
+ 日付: 2026-10-07
4
+
5
+ ## Context
6
+
7
+ オーナーは、通信失敗の報告・重大度・担当の決まりを定めた(2026-10-07、BugHub の `NETWORK_REPORTING.md`)。
8
+ 通信環境そのものの不調と、その状態への製品の対処不良を分ける。通常のオフライン、正常な取り消し、
9
+ 適切に処理された一時的な失敗を、自動で製品の欠陥として登録しない。必要な診断は保持する。重大度は
10
+ エラーコードや回数だけで固定せず、機能停止・データの喪失・重複・復帰の可否を根拠に付ける。
11
+
12
+ Throughline が runtime error として数える code は7つで、通信の失敗が入るのは `L1_SUMMARIZER_BACKEND_FAILED`
13
+ (ADR 0028)だけだった。Codex Stop の要約は外部 CLI(`codex exec`)を呼ぶ。通信の断、利用上限、認証切れ、
14
+ model の失敗、利用者の取り消しによる停止で、CLI は非0で終わるか空を返す。0.16.3 までは、この失敗を
15
+ 1回ごとに `warn` で数え、BugHub へ未解決の記録として送っていた。
16
+
17
+ この失敗への Throughline の対処は、次のとおり(ADR 0015・0016・0028)。
18
+
19
+ - 会話の取り込みは、要約の前に commit している。記憶は失われない。
20
+ - 要約は、次の Stop が同じ turn からやり直す。重複は作らない。
21
+ - L1 が無い間、recall は L2 の本文を返す。
22
+ - hook は終了 code 1 と stderr で失敗を示す。理由と外部 CLI の stderr の末尾は、端末の `hook-failures.log` に残る。
23
+
24
+ 1回ごとの失敗は「適切に処理された一時的な失敗」に当たり、修理の対象として登録する物ではない。
25
+ 一方、backend が使えないまま続く端末では、古い turn の要約が作られず、Codex の自動継続は要約を作れずに
26
+ 止まる。この影響を隠さない。
27
+
28
+ 他の通信の扱いは、次のとおりで、変えない。
29
+
30
+ - BugHub への送信の失敗(`unreachable` など)は、送信の状態(`runtime-errors report-status`)にだけ残し、
31
+ runtime error には数えない。次の時機に、その時点の累計を送り直す。hook の結果は変えない。
32
+ - Claude Code の要約は、Codex CLI → Haiku → L2 の本文の、宣言済みの fallback。backend の失敗では throw せず、数えない。
33
+ - `self-update` の npm の失敗は、呼び出し元へ結果として返す。runtime error には数えない。
34
+ - hook 処理の失敗(`HOOK_*_FAILED`)の経路に、通信は無い。
35
+
36
+ ## Decision
37
+
38
+ 1. `L1_SUMMARIZER_BACKEND_FAILED` を、runtime error として数えるのをやめる。失敗の理由と stderr の末尾は、
39
+ 今までどおり `hook-failures.log` に、この code の名前で残す(端末内だけ)。hook の終了 code と stderr は変えない。
40
+ 2. 端末ごとに、成功を確認できていない間の最初と最後の失敗の時刻を持つ
41
+ (`~/.throughline/l1-backend-recovery.json`。会話の内容も失敗の文面も書かない)。
42
+ 3. 最初の失敗から24時間を過ぎた後の失敗は、`L1_SUMMARIZER_BACKEND_UNRECOVERED` として数える。
43
+ component は `codex_l1_summarizer`、template は
44
+ `Throughline L1 summarizer backend (Codex CLI) has not recovered for over 24 hours`、severity は `warn`。
45
+ 4. severity を `warn` にする根拠は影響。記憶は失われていない(取り込みは済み、recall は L2 を返す)。重複は無い。
46
+ backend が戻れば、次の Stop で自動で復帰する。機能が止まるのは、Codex の自動継続が古い turn の要約を
47
+ 作れない時で、その時は5の画面が出て、入力と記録は残り、再開できる。
48
+ 5. Codex の自動継続の worker は、要約の backend の失敗を、汎用の `handoff_worker_failed` ではなく
49
+ `handoff_summarizer_backend_failed` で止める。失敗の画面に、通信の断・利用上限・認証切れで起きる事、
50
+ 記録と入力が失われていない事、backend が使える様になった後に旧タスクへ入力すると引き継ぎを始め直す事を書く。
51
+ 6. backend が要約を返したら、2の記録を消す。3を数えていた時は、その記録を `recovered` で解決にする
52
+ (製品の明示の解決。BugHub へは次の送信で届く)。復帰の後の失敗は、また最初から数える。
53
+ 7. 前の版が `L1_SUMMARIZER_BACKEND_FAILED` を記録した store を読める様に、その定義は残す。
54
+ 8. 送信の本文の形(BugHub の契約)は変えない。項目を足さない。
55
+
56
+ ADR 0028 の1(1回ごとに数える)と5(severity の根拠のうち、数える条件)は、この ADR で置き換える。
57
+ ADR 0028 の2(対象の error)・3(終了 code と stderr)・4(`hook-failures.log`)は変えない。
58
+
59
+ ## Consequences
60
+
61
+ - 通信の一時的な断、利用上限、取り消しで要約が1回失敗しても、BugHub には何も出ない。端末の `hook-failures.log` で読める。
62
+ - backend が使えないままの端末は、最初の失敗から24時間を過ぎた後の失敗で、BugHub に `warn` の未解決として出る。
63
+ これは「復帰を確認できていない」という事実で、原因が通信環境か、認証か、製品かは決めていない。
64
+ 記録の担当は Throughline の保守だが、通信環境や認証の修理は、その環境を管理する担当へ渡す。
65
+ - 「24時間」は、最初の失敗からの経過で見る。その間ずっと失敗し続けていた証明ではない(間に Stop が無ければ、
66
+ 試していない)。記録は成功で自動で解決になる。
67
+ - 要約が成功したかを見るのは、要約を実際に行った Stop だけ。20 turn 以内のスレッドの Stop は、復帰の証拠にしない。
68
+ - runtime error store は、知らない code を持つ記録を schema invalid として拒否する。この版で
69
+ `L1_SUMMARIZER_BACKEND_UNRECOVERED` を記録した端末を 0.16.3 以前へ戻すと、store を読めなくなる。
70
+ - 0.16.3 までに `L1_SUMMARIZER_BACKEND_FAILED` を記録した端末の記録は、そのまま残る。書き換えない。
71
+ 保守の担当が、端末の正規の入口(`runtime-errors resolve`)で、根拠を付けて閉じる。
72
+
73
+ ## 確認していない範囲
74
+
75
+ - 要約が失敗し続ける間、Codex の Stop hook は毎回 exit 1 になる。Codex の画面への影響は調べていない(ADR 0028 と同じ)。
76
+ - 本物の Codex CLI が、通信の断・利用上限・認証切れのそれぞれで、どの終了 code と stderr を返すか。
77
+ 試験は、非0で終わる代わりの CLI で行った。
@@ -0,0 +1,62 @@
1
+ # ADR 0042: Codex の取り込みは、L3 の変わった所から後ろだけを書く。WAL の大きさに上限を置く
2
+
3
+ 日付: 2026-10-08
4
+
5
+ ## Context
6
+
7
+ Codex の hook(UserPromptSubmit・PostToolUse・Stop・PreCompact)は、毎回 `captureCodexRolloutToDb` を呼ぶ。
8
+ 0.16.4 までは、その会話の `bodies` と `details` を全部消し、rollout を読み直した結果を全部入れ直していた。
9
+ PostToolUse は道具を1回使うたびに走るので、会話が長いほど、1回の hook が会話全体と同じ量を WAL へ書く。
10
+
11
+ 2026-10-08 に Windows の端末で次の状態を見た。
12
+
13
+ - `throughline.db` は 230MB。`throughline.db-wal` は 12.8GB。
14
+ - Codex の会話1つが、L3 に 3,552 行・約 1.47 億字を持つ(rollout は 288MB)。
15
+ - その会話の hook を、本物の DB の写しへ当てて測ると、1回で WAL へ 152.9MB を書いた。rollout に変化が無い時も同じ量を書く。
16
+ - hook が続く間、44 秒で wal-index が約 600MB 分伸びた。
17
+
18
+ WAL は、checkpoint が全部を本体へ書き戻し、読んでいる接続が無い時にだけ、先頭から使い直される。1回の
19
+ hook が百数十 MB を書き、別の会話の hook が続けて来ると、使い直す隙が出来ず、WAL は伸び続ける。また、
20
+ SQLite の既定(`journal_size_limit = -1`)では、一度伸びた WAL のファイルは、中身が空になっても縮まない。
21
+
22
+ rollout は追記で伸びる。普通の hook の間で変わるのは、L3 の末尾の数行だけ。
23
+
24
+ ## Decision
25
+
26
+ 1. `captureCodexRolloutToDb` は、`details` を次の形で書く。
27
+ - DB にある行(`id` の順)と、rollout から作った行を、先頭から1行ずつ比べる。
28
+ - 比べるのは `origin_session_id`・`turn_number`・`tool_name`・`kind`・`source_id`・`token_count`・`created_at` と、
29
+ `input_text`・`output_text` の本文。本文は SQLite の中で比べる(`input_text IS ? AND output_text IS ?`)。
30
+ - 最初に食い違った行から後ろを消し、rollout の同じ位置から後ろを入れる。食い違いが無ければ、増えた行だけを入れる。
31
+ 2. 比べる前に、rollout から作った行を、DB に入る形へ揃える。同じ `source_id` の2件目は
32
+ `uq_details_source` と `INSERT OR IGNORE` が捨てるので、先に除く。
33
+ 3. rollout に時刻が無い行は、取り込んだ時刻が `created_at` に入る。その行は `created_at` を比べず、
34
+ 最初に取り込んだ時刻のまま残す。0.16.4 までは、取り込むたびにその時の時刻へ変わっていた。
35
+ 4. `bodies` は、今までどおり全部消して入れ直す。会話の本文は小さい(上の会話で 98 行・約 7.7 万字)。
36
+ `skeletons`(L1)の扱いも変えない。
37
+ 5. `getDb()` は、開いた接続へ `PRAGMA journal_size_limit = 67108864`(64MB)を設定する。WAL が使い直された
38
+ 後の最初の commit で、SQLite がファイルを 64MB まで切り詰める。切り詰めに失敗しても、SQLite はその
39
+ 失敗を無視して書き込みを続ける。
40
+ 6. `captureCodexRolloutToDb` の結果へ、`keptDetails`・`writtenDetails`・`removedDetails` を足す。
41
+
42
+ ## Consequences
43
+
44
+ - 取り込んだ後の `details` の中身と並び(`id` の順)は、全部入れ直した時と同じになる。3 の `created_at` だけが違う。
45
+ - 変わっていない行の `id` は、hook をまたいで変わらなくなる。0.16.4 までは、毎回新しい `id` になっていた。
46
+ - 上の会話の写しで測ると、変化の無い hook が WAL へ書く量は 152.9MB から 0.3MB になる。89 行が増えた hook は 12.5MB。
47
+ - hook は、今までどおり毎回 rollout の全体を読む。上の会話では、読むのに約 1.1 秒、比べるのに約 0.3 秒かかる。
48
+ 本文を比べるために、DB にあるその会話の L3 を毎回読む。書く量は減るが、読む量は減らない。
49
+ - rollout の途中の行が変わった時(rollback の後など)は、その行から後ろを入れ直す。先頭の行が変われば、
50
+ 今までと同じく全部を入れ直す。
51
+ - 同じ会話の hook が2つ同時に走った時は、後で commit した方の rollout の読み取りが残る。今までと同じ。
52
+ - すでに伸びている WAL は、この版の接続が、使い直された WAL へ最初に commit した時に縮む。それまでは元の大きさのまま。
53
+ - `journal_size_limit` は接続ごとの設定で、DB には残らない。読み取り専用の接続(`openReadOnlyDb`)には設定しない。
54
+ - schema は変えない(v12 のまま)。
55
+
56
+ ## 確認していない範囲
57
+
58
+ - WAL が使い直されない状態(checkpoint が終わる前に次の書き込みが来る状態)が、この版で実際に起きなくなるか。
59
+ 書く量が減った分、起きにくくなるが、本物の端末で複数の会話が同時に動いた時の WAL の大きさは、公開の後に読む。
60
+ - rollout を毎回全部読む時間が、Codex の画面で道具の後の待ちとして見えているか。
61
+ - Claude Code・Grok・Cursor の取り込みは、ターンごとに `INSERT OR IGNORE` で足す形で、この変更の対象ではない。
62
+ これらの host の長い会話での書く量は測っていない。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.16.3",
3
+ "version": "0.16.5",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -1,4 +1,4 @@
1
- import { recordRuntimeErrorBestEffort } from '../runtime-error-store.mjs';
1
+ import { recordRuntimeErrorBestEffort, resolveRecoveredRuntimeErrorBestEffort } from '../runtime-error-store.mjs';
2
2
  import { logHookFailure } from '../hook-failure-log.mjs';
3
3
 
4
4
  function parseArgs(argv) {
@@ -422,6 +422,13 @@ export async function run(argv = []) {
422
422
  payload,
423
423
  env: process.env,
424
424
  });
425
+ // backend が要約を返した。失敗の後なら、復帰を確認できた(ADR 0041)。失敗の記録が無い時は、ファイルの有無を見るだけ。
426
+ if (result.summarized?.summarized?.some((turn) => turn.source === 'codex-cli')) {
427
+ const { noteL1BackendSuccess } = await import('../l1-backend-recovery.mjs');
428
+ if (noteL1BackendSuccess().reported) {
429
+ resolveRecoveredRuntimeErrorBestEffort('L1_SUMMARIZER_BACKEND_UNRECOVERED', { env: process.env });
430
+ }
431
+ }
425
432
  }
426
433
  if (parsed.event === 'pre-compact' && result.continue === false) {
427
434
  process.stdout.write(JSON.stringify({ continue: false, stopReason: result.stopReason }) + '\n');
@@ -434,7 +441,16 @@ export async function run(argv = []) {
434
441
  process.exit(result.continue === false || result.status === 'ok' || result.status === 'skipped' ? 0 : 1);
435
442
  } catch (err) {
436
443
  const code = codexHookFailureCode(err);
437
- recordRuntimeErrorBestEffort(code, { env: process.env });
444
+ if (code === 'L1_SUMMARIZER_BACKEND_FAILED') {
445
+ // 取り込みは済み、要約は次の Stop がやり直す。1回ごとの失敗(通信の断・利用上限・取り消しを含む)は
446
+ // 端末の診断(hook-failures.log)にだけ残す。復帰を24時間確認できていない時だけ数える(ADR 0041)。
447
+ const { noteL1BackendFailure } = await import('../l1-backend-recovery.mjs');
448
+ if (noteL1BackendFailure().unrecovered) {
449
+ recordRuntimeErrorBestEffort('L1_SUMMARIZER_BACKEND_UNRECOVERED', { env: process.env });
450
+ }
451
+ } else {
452
+ recordRuntimeErrorBestEffort(code, { env: process.env });
453
+ }
438
454
  logHookFailure(code, err);
439
455
  const msg = err instanceof Error ? err.message : 'unknown';
440
456
  if (parsed.json) {
@@ -342,6 +342,7 @@ export function showAutoHandoffFailure(operation) {
342
342
  <h1>自動継続を完了できませんでした</h1><p>引き継ぎは下記の工程で止まっています。タスクと記録を確認してください。</p>
343
343
  <p><a href="codex://threads/${escape(operation.source_thread_id)}">旧タスクを開く</a>${operation.target_thread_id ? ` / <a href="codex://threads/${escape(operation.target_thread_id)}">後継タスクを開く</a>` : ''}</p>
344
344
  <p>理由: <code>${escape(operation.error_code)}</code></p><p>引き継ぎID: <code>${escape(operation.handoff_id)}</code></p>
345
+ ${operation.error_code === 'handoff_summarizer_backend_failed' ? '<p>記憶の要約に使うCodex CLIが失敗しました。通信の断、利用上限、認証切れなどで起きます。会話の記録と入力は失われていません。Codex CLIが使える様になった後、旧タスクへ入力すると、引き継ぎをもう一度始めます。</p>' : ''}
345
346
  <p>状態: ${escape(operation.state)} / 停止した工程: ${escape(operation.resume_state)}</p>
346
347
  <p>確認: <code>throughline auto-handoff status --operation ${escape(operation.handoff_id)}</code></p>
347
348
  <p>原因を解消した後の再開: <code>throughline auto-handoff resume --operation ${escape(operation.handoff_id)}</code></p>
@@ -408,7 +409,14 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
408
409
  const state = deps.readSource(operation);
409
410
  update({ snapshot_json: freezeCodexMemory(db, operation, operation.settings, state) });
410
411
  }
411
- deps.summaries(db, operation);
412
+ try { deps.summaries(db, operation); }
413
+ catch (cause) {
414
+ // 古いturnの要約を作るbackend(Codex CLI)が使えない。通信の断・利用上限・認証切れでも起きる。
415
+ // 汎用の理由にせず、固定の理由で止める。失敗の画面に、失われた物が無い事と再開の仕方を書く(ADR 0041)。
416
+ if ((cause?.source === 'codex-cli' && ['codex_cli_failed', 'empty_output'].includes(cause.reason)) ||
417
+ cause?.message === 'handoff_l1_unavailable') throw error('handoff_summarizer_backend_failed');
418
+ throw cause;
419
+ }
412
420
  update({ state: 'memory_ready' });
413
421
  }
414
422
  if (operation.state === 'memory_ready') {
@@ -614,3 +614,20 @@ test('引き継ぎの最中に旧タスクへ来た入力は、凍結する記
614
614
  assert.equal(freezeCodexMemory(db, operation, {}, { stoppedAt: 305, laterTurns: later }).bodies.length, 3);
615
615
  assert.equal(freezeCodexMemory(db, operation, {}, { stoppedAt: 200, laterTurns: [later[0]] }).bodies.length, 3);
616
616
  }));
617
+
618
+ test('要約のbackendが使えない時、workerは固定の理由で止まり、後継を作らない', async () => withDb(async db => {
619
+ const backend = reason => Object.assign(new Error('Codex CLI summarizer failed: exit 1'), { source: 'codex-cli', reason });
620
+ const cases = [[backend('codex_cli_failed'), 'handoff_summarizer_backend_failed'], [backend('empty_output'), 'handoff_summarizer_backend_failed'],
621
+ [new Error('handoff_l1_unavailable'), 'handoff_summarizer_backend_failed'],
622
+ // 製品側の誤り(要約の呼び方の間違い)と、他の失敗は、今までどおり汎用の理由。
623
+ [backend('recursion_guard'), 'handoff_worker_failed'], [new Error('別の失敗'), 'handoff_worker_failed']];
624
+ for (const [index, [cause, expected]] of cases.entries()) {
625
+ const operation = request(db, `summarizer-${index}`, { now: 1 }).operation;
626
+ const counters = { created: 0, submitted: 0, notified: 0 };
627
+ const deps = workerDependencies(db, {}, async () => { counters.submitted++; }, counters);
628
+ deps.summaries = () => { throw cause; };
629
+ const result = await runAutoHandoffWorker(operation.handoff_id, { db, dependencies: deps });
630
+ assert.equal(result.state, 'failed'); assert.equal(result.error_code, expected); assert.equal(result.resume_state, 'source_stopped');
631
+ assert.equal(counters.created, 0); assert.equal(counters.submitted, 0); assert.equal(counters.notified, 1);
632
+ }
633
+ }));
@@ -58,6 +58,7 @@ export function captureCodexRolloutToDb(
58
58
  now,
59
59
  });
60
60
 
61
+ let detailSync = null;
61
62
  db.exec('BEGIN IMMEDIATE');
62
63
  try {
63
64
  db.prepare(
@@ -80,19 +81,12 @@ export function captureCodexRolloutToDb(
80
81
  }
81
82
  }
82
83
  db.prepare('DELETE FROM bodies WHERE session_id = ?').run(sessionId);
83
- db.prepare('DELETE FROM details WHERE session_id = ?').run(sessionId);
84
84
 
85
85
  const insertBody = db.prepare(
86
86
  `INSERT INTO bodies
87
87
  (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
88
88
  VALUES (?, ?, ?, ?, ?, ?, ?)`,
89
89
  );
90
- const insertDetail = db.prepare(
91
- `INSERT OR IGNORE INTO details
92
- (session_id, origin_session_id, turn_number, tool_name, input_text, output_text,
93
- token_count, created_at, kind, source_id)
94
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
95
- );
96
90
 
97
91
  for (const row of rows) {
98
92
  insertBody.run(
@@ -105,20 +99,7 @@ export function captureCodexRolloutToDb(
105
99
  row.createdAt,
106
100
  );
107
101
  }
108
- for (const row of detailRows) {
109
- insertDetail.run(
110
- row.sessionId,
111
- row.originSessionId,
112
- row.turnNumber,
113
- row.toolName,
114
- row.inputText,
115
- row.outputText,
116
- row.tokenCount,
117
- row.createdAt,
118
- row.kind,
119
- row.sourceId,
120
- );
121
- }
102
+ detailSync = syncDetailRows(db, sessionId, detailRows, now);
122
103
 
123
104
  db.exec('COMMIT');
124
105
  } catch (err) {
@@ -137,10 +118,100 @@ export function captureCodexRolloutToDb(
137
118
  capturedTurns: parsed.activeTurnCount,
138
119
  capturedRows: rows.length,
139
120
  capturedDetails: detailRows.length,
121
+ keptDetails: detailSync.kept,
122
+ writtenDetails: detailSync.inserted,
123
+ removedDetails: detailSync.removed,
140
124
  stats: parsed.stats,
141
125
  };
142
126
  }
143
127
 
128
+ /**
129
+ * L3 (details) を rollout の今の中身へ合わせる。変わっていない先頭の行はそのまま残し、
130
+ * 最初に食い違った行から後ろだけを消して入れ直す (ADR 0042)。
131
+ *
132
+ * hook は道具を1回使うたびに走る。会話の全行を消して入れ直すと、長い会話では1回の hook が
133
+ * 会話全体と同じ量を WAL へ書く。rollout は追記で伸びるので、普通は末尾の数行だけが変わる。
134
+ *
135
+ * 残った行の並び (id の順) は、全部入れ直した時と同じになる。食い違いは本文まで比べて決める。
136
+ */
137
+ function syncDetailRows(db, sessionId, detailRows, now) {
138
+ const wanted = dropIgnoredDuplicates(detailRows);
139
+ const existing = db
140
+ .prepare(
141
+ `SELECT id, origin_session_id, turn_number, tool_name, kind, source_id, token_count, created_at
142
+ FROM details WHERE session_id = ? ORDER BY id`,
143
+ )
144
+ .all(sessionId);
145
+ const sameText = db.prepare(
146
+ 'SELECT 1 AS same FROM details WHERE id = ? AND input_text IS ? AND output_text IS ?',
147
+ );
148
+
149
+ let kept = 0;
150
+ while (kept < existing.length && kept < wanted.length) {
151
+ const have = existing[kept];
152
+ const want = wanted[kept];
153
+ if (!sameDetailColumns(have, want, now)) break;
154
+ if (!sameText.get(have.id, want.inputText, want.outputText)) break;
155
+ kept++;
156
+ }
157
+
158
+ if (kept < existing.length) {
159
+ db.prepare('DELETE FROM details WHERE session_id = ? AND id >= ?').run(sessionId, existing[kept].id);
160
+ }
161
+
162
+ const insertDetail = db.prepare(
163
+ `INSERT OR IGNORE INTO details
164
+ (session_id, origin_session_id, turn_number, tool_name, input_text, output_text,
165
+ token_count, created_at, kind, source_id)
166
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
167
+ );
168
+ for (const row of wanted.slice(kept)) {
169
+ insertDetail.run(
170
+ row.sessionId,
171
+ row.originSessionId,
172
+ row.turnNumber,
173
+ row.toolName,
174
+ row.inputText,
175
+ row.outputText,
176
+ row.tokenCount,
177
+ row.createdAt,
178
+ row.kind,
179
+ row.sourceId,
180
+ );
181
+ }
182
+
183
+ return { kept, removed: existing.length - kept, inserted: wanted.length - kept };
184
+ }
185
+
186
+ // uq_details_source (session_id, origin_session_id, source_id) は、同じ source_id の2件目を
187
+ // INSERT OR IGNORE で捨てる。DB に入る並びと比べるため、入れる前に同じ形へ揃える。
188
+ function dropIgnoredDuplicates(detailRows) {
189
+ const seen = new Set();
190
+ const rows = [];
191
+ for (const row of detailRows) {
192
+ if (row.sourceId !== null) {
193
+ const key = `${row.originSessionId}\0${row.sourceId}`;
194
+ if (seen.has(key)) continue;
195
+ seen.add(key);
196
+ }
197
+ rows.push(row);
198
+ }
199
+ return rows;
200
+ }
201
+
202
+ function sameDetailColumns(have, want, now) {
203
+ return (
204
+ have.origin_session_id === want.originSessionId &&
205
+ have.turn_number === want.turnNumber &&
206
+ have.tool_name === want.toolName &&
207
+ have.kind === want.kind &&
208
+ have.source_id === want.sourceId &&
209
+ have.token_count === want.tokenCount &&
210
+ // rollout に時刻が無い行は取り込んだ時刻が入る。その行は最初に取り込んだ時刻のまま残す。
211
+ (have.created_at === want.createdAt || want.createdAt === now)
212
+ );
213
+ }
214
+
144
215
  function bodyTurnTexts(rows) {
145
216
  const turns = new Map();
146
217
  for (const row of rows) {
@@ -459,6 +459,158 @@ test('同じ会話の再captureはL1を保持し、ユーザー本文が変わ
459
459
  }
460
460
  });
461
461
 
462
+ // 本物の schema と同じく、同じ source_id の2件目を INSERT OR IGNORE が捨てる DB。
463
+ function makeDbWithSourceIndex() {
464
+ const db = makeDb();
465
+ db.exec(`CREATE UNIQUE INDEX uq_details_source
466
+ ON details(session_id, origin_session_id, source_id) WHERE source_id IS NOT NULL`);
467
+ return db;
468
+ }
469
+
470
+ function detailSnapshot(db) {
471
+ return db
472
+ .prepare(
473
+ `SELECT origin_session_id, turn_number, tool_name, input_text, output_text, token_count,
474
+ created_at, kind, source_id
475
+ FROM details ORDER BY id`,
476
+ )
477
+ .all();
478
+ }
479
+
480
+ function toolCall(callId, input, output) {
481
+ return [
482
+ responseItem({ type: 'function_call', call_id: callId, name: 'shell', arguments: input }),
483
+ responseItem({ type: 'function_call_output', call_id: callId, output }),
484
+ ];
485
+ }
486
+
487
+ test('同じ会話の再captureは、変わっていないL3の行を書き直さず、増えた行だけを足す', () => {
488
+ const db = makeDbWithSourceIndex();
489
+ const home = mkdtempSync(join(tmpdir(), 'tl-codex-incr-'));
490
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
491
+ const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
492
+ try {
493
+ const head = [
494
+ event('task_started'), event('user_message', { message: '作業を続ける' }),
495
+ ...toolCall('call-1', '{"cmd":"pwd"}', '/work'),
496
+ ...toolCall('call-2', '{"cmd":"ls"}', 'a\nb'),
497
+ ];
498
+ const rollout = writeRollout(home, { id: threadId, cwd: project, events: head });
499
+ const first = captureCodexRolloutToDb(db, { threadId, codexHome: home, projectPath: project, now: 1 });
500
+ assert.deepEqual([first.keptDetails, first.writtenDetails, first.removedDetails], [0, 4, 0]);
501
+ const idsBefore = db.prepare('SELECT id FROM details ORDER BY id').all().map((row) => row.id);
502
+
503
+ // 道具をもう1回使った後の hook。前の4行はそのまま残り、2行だけ増える。
504
+ writeRolloutRows(rollout, [sessionMeta(threadId, project), ...head, ...toolCall('call-3', '{"cmd":"date"}', 'now')]);
505
+ const second = captureCodexRolloutToDb(db, { threadId, codexHome: home, projectPath: project, now: 2 });
506
+ assert.deepEqual([second.keptDetails, second.writtenDetails, second.removedDetails], [4, 2, 0]);
507
+ const idsAfter = db.prepare('SELECT id FROM details ORDER BY id').all().map((row) => row.id);
508
+ assert.deepEqual(idsAfter.slice(0, 4), idsBefore);
509
+ assert.equal(idsAfter.length, 6);
510
+ assert.ok(idsAfter[4] > idsBefore[3]);
511
+
512
+ // 何も増えていない hook は、L3 を1行も書かない。
513
+ const third = captureCodexRolloutToDb(db, { threadId, codexHome: home, projectPath: project, now: 3 });
514
+ assert.deepEqual([third.keptDetails, third.writtenDetails, third.removedDetails], [6, 0, 0]);
515
+ assert.deepEqual(db.prepare('SELECT id FROM details ORDER BY id').all().map((row) => row.id), idsAfter);
516
+ } finally {
517
+ db.close(); rmSync(home, { recursive: true, force: true }); rmSync(project, { recursive: true, force: true });
518
+ }
519
+ });
520
+
521
+ test('L3の再captureは、どの変わり方でも、全部入れ直した時と同じ中身と並びになる', () => {
522
+ const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
523
+ const turn = (message, ...items) => [
524
+ event('task_started'), event('user_message', { message }), ...items.flat(), event('task_complete'),
525
+ ];
526
+ const orphanOutput = (output) => responseItem({ type: 'function_call_output', output });
527
+ const base = [
528
+ ...turn('1つ目', toolCall('call-1', '{"cmd":"pwd"}', '/work'), [orphanOutput('aaaa')]),
529
+ ...turn('2つ目', toolCall('call-2', '{"cmd":"ls"}', 'file-a'), toolCall('call-3', '{"cmd":"date"}', 'now')),
530
+ ];
531
+ const cases = {
532
+ // 末尾へ足されただけ。前の6行は残る。
533
+ '末尾へ行が増えた': { next: [...base, ...turn('3つ目', toolCall('call-4', '{"cmd":"id"}', 'kite'))], kept: 7 },
534
+ // 同じ source_id・同じ長さで、本文だけが違う。その行から後ろを入れ直す。
535
+ '途中の行の本文が同じ長さで変わった': {
536
+ next: [
537
+ ...turn('1つ目', toolCall('call-1', '{"cmd":"pwd"}', '/WORK'), [orphanOutput('aaaa')]),
538
+ ...turn('2つ目', toolCall('call-2', '{"cmd":"ls"}', 'file-a'), toolCall('call-3', '{"cmd":"date"}', 'now')),
539
+ ],
540
+ kept: 1,
541
+ },
542
+ // source_id の無い行は、本文でしか区別できない。
543
+ 'source_idの無い行の本文が変わった': {
544
+ next: [
545
+ ...turn('1つ目', toolCall('call-1', '{"cmd":"pwd"}', '/work'), [orphanOutput('bbbb')]),
546
+ ...turn('2つ目', toolCall('call-2', '{"cmd":"ls"}', 'file-a'), toolCall('call-3', '{"cmd":"date"}', 'now')),
547
+ ],
548
+ kept: 2,
549
+ },
550
+ // 出力が後から届いた(NULL だった所へ入る形ではなく、行が増える)。
551
+ '途中へ行が差し込まれた': {
552
+ next: [
553
+ ...turn('1つ目', toolCall('call-1', '{"cmd":"pwd"}', '/work'), toolCall('call-1b', '{"cmd":"x"}', 'y'), [orphanOutput('aaaa')]),
554
+ ...turn('2つ目', toolCall('call-2', '{"cmd":"ls"}', 'file-a'), toolCall('call-3', '{"cmd":"date"}', 'now')),
555
+ ],
556
+ kept: 2,
557
+ },
558
+ // rollback で最後のターンが消えた。
559
+ '最後のターンが巻き戻された': { next: [...base, event('thread_rolled_back', { num_turns: 1 })], kept: 3 },
560
+ // 同じ call_id がもう一度出た。2件目は捨てられるので、並びは変わらない。
561
+ '同じsource_idがもう一度出た': {
562
+ next: [
563
+ ...turn('1つ目', toolCall('call-1', '{"cmd":"pwd"}', '/work'), [orphanOutput('aaaa')]),
564
+ ...turn('2つ目', toolCall('call-2', '{"cmd":"ls"}', 'file-a'), toolCall('call-2', '{"cmd":"ls"}', 'file-a'),
565
+ toolCall('call-3', '{"cmd":"date"}', 'now')),
566
+ ],
567
+ kept: 7,
568
+ },
569
+ '全部無くなった': { next: [...turn('1つ目')], kept: 0 },
570
+ };
571
+
572
+ for (const [name, { next, kept }] of Object.entries(cases)) {
573
+ const home = mkdtempSync(join(tmpdir(), 'tl-codex-incr-'));
574
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
575
+ const db = makeDbWithSourceIndex();
576
+ const fresh = makeDbWithSourceIndex();
577
+ try {
578
+ const rollout = writeRollout(home, { id: threadId, cwd: project, events: base });
579
+ captureCodexRolloutToDb(db, { threadId, codexHome: home, projectPath: project, now: 1 });
580
+ assert.equal(detailSnapshot(db).length, 7, name);
581
+
582
+ writeRolloutRows(rollout, [sessionMeta(threadId, project), ...next]);
583
+ const again = captureCodexRolloutToDb(db, { threadId, codexHome: home, projectPath: project, now: 2 });
584
+ captureCodexRolloutToDb(fresh, { threadId, codexHome: home, projectPath: project, now: 2 });
585
+
586
+ assert.deepEqual(detailSnapshot(db), detailSnapshot(fresh), name);
587
+ assert.equal(again.keptDetails, kept, name);
588
+ } finally {
589
+ db.close(); fresh.close();
590
+ rmSync(home, { recursive: true, force: true }); rmSync(project, { recursive: true, force: true });
591
+ }
592
+ }
593
+ });
594
+
595
+ test('rolloutに時刻の無いL3の行は、再captureしても最初に取り込んだ時刻のまま残る', () => {
596
+ const db = makeDbWithSourceIndex();
597
+ const home = mkdtempSync(join(tmpdir(), 'tl-codex-incr-'));
598
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
599
+ const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
600
+ try {
601
+ const untimed = { type: 'response_item', payload: { type: 'function_call', call_id: 'call-1', name: 'shell', arguments: '{}' } };
602
+ writeRollout(home, { id: threadId, cwd: project, events: [
603
+ event('task_started'), event('user_message', { message: '作業を続ける' }), untimed,
604
+ ] });
605
+ captureCodexRolloutToDb(db, { threadId, codexHome: home, projectPath: project, now: 1000 });
606
+ const again = captureCodexRolloutToDb(db, { threadId, codexHome: home, projectPath: project, now: 2000 });
607
+ assert.equal(again.keptDetails, 1);
608
+ assert.equal(db.prepare('SELECT created_at FROM details').get().created_at, 1000);
609
+ } finally {
610
+ db.close(); rmSync(home, { recursive: true, force: true }); rmSync(project, { recursive: true, force: true });
611
+ }
612
+ });
613
+
462
614
  function writeRollout(home, { id, cwd, events }) {
463
615
  const dir = join(home, 'sessions', '2026', '05', '06');
464
616
  mkdirSync(dir, { recursive: true });
@@ -52,6 +52,50 @@ test('getDb configures a bounded SQLite busy timeout for concurrent hook process
52
52
  }
53
53
  });
54
54
 
55
+ test('getDbは、膨らんだWALを次の書き込みで上限まで切り詰める', () => {
56
+ const home = mkdtempSync(join(tmpdir(), 'throughline-db-wal-limit-'));
57
+ try {
58
+ const env = { ...process.env, HOME: home, USERPROFILE: home };
59
+ // 上限を持たない接続で、checkpoint を止めたまま WAL を膨らませ、そのまま閉じずに終わる。
60
+ const grow = spawnSync(process.execPath, ['--input-type=module', '-e', `
61
+ import { statSync } from 'node:fs';
62
+ import { DatabaseSync } from 'node:sqlite';
63
+ import { getDb, DB_PATH, WAL_SIZE_LIMIT_BYTES } from ${JSON.stringify(DB_MODULE_URL)};
64
+ getDb().close();
65
+ const db = new DatabaseSync(DB_PATH);
66
+ db.exec('PRAGMA wal_autocheckpoint = 0');
67
+ db.exec('CREATE TABLE filler (id INTEGER PRIMARY KEY, body TEXT)');
68
+ const insert = db.prepare('INSERT INTO filler (body) VALUES (?)');
69
+ const body = 'x'.repeat(1024 * 1024);
70
+ while (statSync(DB_PATH + '-wal').size <= WAL_SIZE_LIMIT_BYTES + 8 * 1024 * 1024) insert.run(body);
71
+ db.exec('DELETE FROM filler');
72
+ console.log(JSON.stringify({ wal: statSync(DB_PATH + '-wal').size, limit: WAL_SIZE_LIMIT_BYTES }));
73
+ process.exit(0);
74
+ `], { env, encoding: 'utf8' });
75
+ assert.equal(grow.status, 0, grow.stderr);
76
+ const grown = JSON.parse(grow.stdout);
77
+ assert.ok(grown.wal > grown.limit, `WAL が膨らんでいない: ${grow.stdout}`);
78
+
79
+ // 製品の入口で開いた接続が、checkpoint の後の最初の書き込みで切り詰める。
80
+ const shrink = spawnSync(process.execPath, ['--input-type=module', '-e', `
81
+ import { statSync } from 'node:fs';
82
+ import { getDb, DB_PATH } from ${JSON.stringify(DB_MODULE_URL)};
83
+ const db = getDb();
84
+ const limit = db.prepare('PRAGMA journal_size_limit').get().journal_size_limit;
85
+ db.exec('PRAGMA wal_checkpoint(PASSIVE)');
86
+ db.prepare("INSERT INTO sessions (session_id, project_path, status, created_at, updated_at) VALUES ('wal-limit', '/tmp', 'active', 1, 1)").run();
87
+ console.log(JSON.stringify({ limit, wal: statSync(DB_PATH + '-wal').size }));
88
+ process.exit(0);
89
+ `], { env, encoding: 'utf8' });
90
+ assert.equal(shrink.status, 0, shrink.stderr);
91
+ const shrunk = JSON.parse(shrink.stdout);
92
+ assert.equal(shrunk.limit, grown.limit);
93
+ assert.ok(shrunk.wal <= grown.limit, `WAL が上限を超えたまま: ${shrink.stdout}`);
94
+ } finally {
95
+ rmSync(home, { recursive: true, force: true });
96
+ }
97
+ });
98
+
55
99
  test('getDb reuses an existing WAL database while another process owns the writer lock', async () => {
56
100
  const home = mkdtempSync(join(tmpdir(), 'throughline-db-contention-'));
57
101
  const env = { ...process.env, HOME: home, USERPROFILE: home };
package/src/db.mjs CHANGED
@@ -10,6 +10,9 @@ import { join } from 'path';
10
10
  const DB_DIR = join(homedir(), '.throughline');
11
11
  export const DB_PATH = join(DB_DIR, 'throughline.db');
12
12
  export const DB_BUSY_TIMEOUT_MS = 5_000;
13
+ // WAL が checkpoint で巻き戻った後、次の書き込みでファイルをこの大きさまで切り詰める (ADR 0042)。
14
+ // 既定 (-1) は切り詰めないので、一度膨らんだ WAL は中身が空でも元の大きさのまま残る。
15
+ export const WAL_SIZE_LIMIT_BYTES = 64 * 1024 * 1024;
13
16
  export const CURRENT_VERSION = 12;
14
17
 
15
18
  let _db = null;
@@ -485,6 +488,7 @@ export function getDb() {
485
488
  db.exec(`PRAGMA busy_timeout = ${DB_BUSY_TIMEOUT_MS}`);
486
489
  settleFirstRead(db);
487
490
  ensureWalJournalMode(db);
491
+ db.exec(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES}`);
488
492
  db.exec('PRAGMA foreign_keys = ON');
489
493
  initSchema(db);
490
494
  _db = db;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * l1-backend-recovery.mjs — L1 要約の backend(Codex CLI)が失敗してから、復帰を確認できたかの記録 (ADR 0041)
3
+ *
4
+ * Codex Stop の要約は外部 CLI を呼ぶ。通信の断、利用上限、認証切れ、利用者の取り消しでも失敗する。
5
+ * 会話の取り込みは済んでいて、要約は次の Stop がやり直すので、1回ごとの失敗は修理の対象として数えない。
6
+ * 最初の失敗から期限(24時間)を過ぎても成功を確認できていない時だけ、runtime error として数える。
7
+ *
8
+ * 端末ごとに1つのファイル(~/.throughline/l1-backend-recovery.json)に、成功を確認できていない間の
9
+ * 最初と最後の失敗の時刻だけを持つ。会話の内容も失敗の文面も書かない(文面は hook-failures.log に残る)。
10
+ * 読み書きの失敗は stderr に出し、hook の結果を変えない。
11
+ */
12
+
13
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { dirname, join } from 'node:path';
16
+ import { randomUUID } from 'node:crypto';
17
+
18
+ export const L1_BACKEND_RECOVERY_SCHEMA = 'throughline.l1_backend_recovery.v1';
19
+ export const L1_BACKEND_UNRECOVERED_MS = 24 * 60 * 60 * 1000;
20
+
21
+ export function l1BackendRecoveryPath({ home = homedir() } = {}) {
22
+ return join(home, '.throughline', 'l1-backend-recovery.json');
23
+ }
24
+
25
+ function read(path) {
26
+ try {
27
+ const state = JSON.parse(readFileSync(path, 'utf8'));
28
+ if (state?.schema !== L1_BACKEND_RECOVERY_SCHEMA || !Number.isFinite(state.first_failure_at)) return null;
29
+ return state;
30
+ } catch (error) {
31
+ if (error.code !== 'ENOENT') process.stderr.write(`[l1-backend-recovery] ${error.message}\n`);
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * backend の失敗を1回記す。最初の失敗から期限を過ぎていれば unrecovered を返す。
38
+ * @returns {{ unrecovered: boolean, firstFailureAt: number|null }}
39
+ */
40
+ export function noteL1BackendFailure({ home, now = Date.now() } = {}) {
41
+ const path = l1BackendRecoveryPath({ home });
42
+ try {
43
+ const previous = read(path);
44
+ const firstFailureAt = previous?.first_failure_at ?? now;
45
+ const unrecovered = now - firstFailureAt >= L1_BACKEND_UNRECOVERED_MS;
46
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
47
+ const temporary = `${path}.${randomUUID()}.tmp`;
48
+ writeFileSync(temporary, `${JSON.stringify({ schema: L1_BACKEND_RECOVERY_SCHEMA, first_failure_at: firstFailureAt,
49
+ last_failure_at: now, reported: Boolean(previous?.reported) || unrecovered })}\n`, { mode: 0o600 });
50
+ renameSync(temporary, path);
51
+ return { unrecovered, firstFailureAt };
52
+ } catch (error) {
53
+ process.stderr.write(`[l1-backend-recovery] ${error.message}\n`);
54
+ return { unrecovered: false, firstFailureAt: null };
55
+ }
56
+ }
57
+
58
+ /**
59
+ * backend が要約を返した。失敗の記録があれば消す。期限を過ぎて runtime error に数えていた時は reported を返す。
60
+ * 記録が無い時(ふだん)は、ファイルの有無を1回見るだけで終わる。
61
+ * @returns {{ recovered: boolean, reported: boolean }}
62
+ */
63
+ export function noteL1BackendSuccess({ home } = {}) {
64
+ const path = l1BackendRecoveryPath({ home });
65
+ if (!existsSync(path)) return { recovered: false, reported: false };
66
+ try {
67
+ const previous = read(path);
68
+ rmSync(path, { force: true });
69
+ return { recovered: true, reported: Boolean(previous?.reported) };
70
+ } catch (error) {
71
+ process.stderr.write(`[l1-backend-recovery] ${error.message}\n`);
72
+ return { recovered: false, reported: false };
73
+ }
74
+ }
@@ -144,7 +144,7 @@ function makeBin(dir, name, body) {
144
144
  return command;
145
145
  }
146
146
 
147
- test('Codex StopでL1要約backendが失敗した時、取り込みは残り、別のcodeで1回数える', () => {
147
+ test('Codex StopのL1要約backendの失敗は1回ごとには数えず、24時間復帰を確認できない時だけ数え、成功で解決にする', () => {
148
148
  const { root, env } = createEnabledEnvironment('throughline-runtime-hook-summarizer-');
149
149
  const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
150
150
  const project = join(root, 'project');
@@ -168,20 +168,29 @@ test('Codex StopでL1要約backendが失敗した時、取り込みは残り、
168
168
  mkdirSync(binDir, { recursive: true });
169
169
  const codex = makeBin(binDir, 'codex', "process.stderr.write('ERROR: usage limit reached\\n');\nprocess.exit(42);\n");
170
170
 
171
- const result = spawnSync(process.execPath, [BIN, 'codex-hook', 'stop'], {
172
- env: { ...env, CODEX_HOME: codexHome, THROUGHLINE_CODEX_CLI_BIN: codex },
171
+ const stop = (bin) => spawnSync(process.execPath, [BIN, 'codex-hook', 'stop'], {
172
+ env: { ...env, CODEX_HOME: codexHome, THROUGHLINE_CODEX_CLI_BIN: bin },
173
173
  input: JSON.stringify({ session_id: threadId, transcript_path: rolloutPath, cwd: project }),
174
174
  encoding: 'utf8',
175
175
  });
176
+ const records = () => {
177
+ const storePath = defaultRuntimeErrorStorePath(env);
178
+ if (!existsSync(storePath)) return [];
179
+ return JSON.parse(readFileSync(storePath, 'utf8')).records
180
+ .map((record) => [record.error_code, record.component, record.severity, record.count, record.status, record.reason_code]);
181
+ };
182
+ const recoveryPath = join(root, '.throughline', 'l1-backend-recovery.json');
183
+ const result = stop(codex);
176
184
 
177
185
  // hook は明示して失敗する(終了 code と stderr は変えない)。
178
186
  assert.equal(result.status, 1, result.stderr);
179
187
  assert.match(result.stderr, /\[codex-hook\] Codex CLI summarizer failed: exit 42/);
180
188
 
181
- const store = JSON.parse(readFileSync(defaultRuntimeErrorStorePath(env), 'utf8'));
182
- assert.deepEqual(store.records.map((record) => [record.error_code, record.component, record.severity, record.count]), [
183
- ['L1_SUMMARIZER_BACKEND_FAILED', 'codex_l1_summarizer', 'warn', 1],
184
- ]);
189
+ // 1回の失敗は、修理の対象として数えない。通信の断・利用上限・取り消しでも起き、次の Stop がやり直す。
190
+ assert.deepEqual(records(), []);
191
+ const first = JSON.parse(readFileSync(recoveryPath, 'utf8'));
192
+ assert.equal(first.schema, 'throughline.l1_backend_recovery.v1'); assert.equal(first.reported, false);
193
+ assert.deepEqual(Object.keys(first).sort(), ['first_failure_at', 'last_failure_at', 'reported', 'schema']);
185
194
 
186
195
  // 取り込みは要約の前に commit 済み。
187
196
  const db = new DatabaseSync(join(root, '.throughline', 'throughline.db'));
@@ -198,6 +207,31 @@ test('Codex StopでL1要約backendが失敗した時、取り込みは残り、
198
207
  assert.equal(entry.code, 'L1_SUMMARIZER_BACKEND_FAILED');
199
208
  assert.equal(entry.reason, 'codex_cli_failed');
200
209
  assert.match(entry.stderr, /usage limit reached/);
210
+
211
+ // 続けて失敗しても、最初の失敗から24時間に届くまでは数えない。
212
+ assert.equal(stop(codex).status, 1);
213
+ assert.deepEqual(records(), []);
214
+ assert.equal(JSON.parse(readFileSync(recoveryPath, 'utf8')).first_failure_at, first.first_failure_at);
215
+
216
+ // 最初の失敗から24時間を過ぎても成功を確認できていない。warn で数える。
217
+ writeFileSync(recoveryPath, JSON.stringify({ ...first, first_failure_at: Date.now() - 25 * 3_600_000 }));
218
+ assert.equal(stop(codex).status, 1);
219
+ assert.deepEqual(records(), [['L1_SUMMARIZER_BACKEND_UNRECOVERED', 'codex_l1_summarizer', 'warn', 1, 'open', null]]);
220
+ assert.equal(JSON.parse(readFileSync(recoveryPath, 'utf8')).reported, true);
221
+ assert.equal(stop(codex).status, 1);
222
+ assert.deepEqual(records(), [['L1_SUMMARIZER_BACKEND_UNRECOVERED', 'codex_l1_summarizer', 'warn', 2, 'open', null]]);
223
+
224
+ // backend が要約を返した。失敗の記録を消し、数えていた記録を recovered で解決にする。
225
+ const working = makeBin(binDir, 'codex-ok', "process.stdout.write('要約\\n');\n");
226
+ const recovered = stop(working);
227
+ assert.equal(recovered.status, 0, recovered.stderr);
228
+ assert.equal(existsSync(recoveryPath), false);
229
+ assert.deepEqual(records(), [['L1_SUMMARIZER_BACKEND_UNRECOVERED', 'codex_l1_summarizer', 'warn', 2, 'resolved', 'recovered']]);
230
+
231
+ // 復帰した後の失敗は、また最初から数える(すぐには数えない)。
232
+ assert.equal(stop(codex).status, 1);
233
+ assert.deepEqual(records(), [['L1_SUMMARIZER_BACKEND_UNRECOVERED', 'codex_l1_summarizer', 'warn', 2, 'resolved', 'recovered']]);
234
+ assert.equal(JSON.parse(readFileSync(recoveryPath, 'utf8')).reported, false);
201
235
  });
202
236
 
203
237
  test('store failure preserves product failure and emits only fixed storage diagnostic', () => {
@@ -1,7 +1,10 @@
1
- import { observeRuntimeError } from './runtime-error-store.mjs';
1
+ import { observeRuntimeError, resolveRuntimeError, runtimeErrorFingerprint } from './runtime-error-store.mjs';
2
2
 
3
3
  try {
4
- const result = observeRuntimeError({ code: process.argv[2] });
4
+ // 第2引数が recovered の時は、その code の記録を「復帰を確認した」として解決にする(ADR 0041)。
5
+ const result = process.argv[3] === 'recovered'
6
+ ? resolveRuntimeError(runtimeErrorFingerprint(process.argv[2]), { reasonCode: 'recovered' })
7
+ : observeRuntimeError({ code: process.argv[2] });
5
8
  process.exitCode = result.status === 'disabled' ? 3 : 0;
6
9
  } catch {
7
10
  process.exitCode = 1;
@@ -64,14 +64,30 @@ const DEFINITIONS = Object.freeze({
64
64
  template: 'Throughline Codex hook processing failed',
65
65
  }),
66
66
  // Codex Stop の取り込みは済み、L1 要約の backend(Codex CLI)だけが失敗した(ADR 0028)。
67
+ // 0.16.4 からは数えない(ADR 0041)。1回ごとの失敗は、通信の断・利用上限・取り消しでも起き、次の Stop がやり直す。
68
+ // 前の版が記録した store を読める様に、定義は残す。
67
69
  L1_SUMMARIZER_BACKEND_FAILED: Object.freeze({
68
70
  component: 'codex_l1_summarizer',
69
71
  // BugHub が受ける severity は fatal・high・warn・info だけ。他の値は report 全体が 422 になる。
70
72
  severity: 'warn',
71
73
  template: 'Throughline L1 summarizer backend (Codex CLI) failed',
72
74
  }),
75
+ // L1 要約の backend が失敗し、最初の失敗から24時間を過ぎても成功を確認できていない(ADR 0041)。
76
+ // 記憶は失われていない(取り込みは済み、recall は L2 を返す)。要約が成功した時に、recovered で解決にする。
77
+ L1_SUMMARIZER_BACKEND_UNRECOVERED: Object.freeze({
78
+ component: 'codex_l1_summarizer',
79
+ severity: 'warn',
80
+ template: 'Throughline L1 summarizer backend (Codex CLI) has not recovered for over 24 hours',
81
+ }),
73
82
  });
74
83
 
84
+ /** 定型 code の fingerprint。記録は code ごとに1つ。 */
85
+ export function runtimeErrorFingerprint(code) {
86
+ const definition = DEFINITIONS[code];
87
+ if (!definition) throw new TypeError('未登録の runtime error code です');
88
+ return createHash('sha256').update(['throughline', definition.component, code, definition.template].join('\0')).digest('hex');
89
+ }
90
+
75
91
  export function defaultRuntimeErrorConfigPath(env = process.env) {
76
92
  if (isWindows(env)) {
77
93
  return join(windowsLocalAppData(env), 'throughline', 'runtime-errors.config.json');
@@ -143,9 +159,7 @@ export function observeRuntimeError(input, options = {}) {
143
159
  return withStoreLock(options, (privateDirectory) => {
144
160
  const now = normalizeTimestamp(input.now);
145
161
  const version = normalizeVersion(options.version ?? PACKAGE_VERSION);
146
- const fingerprint = createHash('sha256')
147
- .update(['throughline', definition.component, input.code, definition.template].join('\0'))
148
- .digest('hex');
162
+ const fingerprint = runtimeErrorFingerprint(input.code);
149
163
  const store = readStore(options, { privateDirectory });
150
164
  const existing = store.records.find((record) => record.fingerprint === fingerprint);
151
165
  const sequence = nextSequence(store);
@@ -307,10 +321,15 @@ export function getRuntimeErrorDiagnostics(options = {}) {
307
321
  }
308
322
  }
309
323
 
324
+ /** 復帰を確認できた記録を、recovered で解決にする。記録が無い時と、解決済みの時は何もしない。 */
325
+ export function resolveRecoveredRuntimeErrorBestEffort(code, options = {}) {
326
+ return recordRuntimeErrorBestEffort(code, { ...options, action: 'recovered' });
327
+ }
328
+
310
329
  export function recordRuntimeErrorBestEffort(code, options = {}) {
311
- const { stderr = process.stderr, ...storeOptions } = options;
330
+ const { stderr = process.stderr, action = null, ...storeOptions } = options;
312
331
  try {
313
- const child = childProcess.spawnSync(process.execPath, [fileURLToPath(new URL('./runtime-error-observer.mjs', import.meta.url)), code], {
332
+ const child = childProcess.spawnSync(process.execPath, [fileURLToPath(new URL('./runtime-error-observer.mjs', import.meta.url)), code, ...(action ? [action] : [])], {
314
333
  env: storeOptions.env ?? process.env,
315
334
  encoding: 'utf8',
316
335
  stdio: 'ignore',