throughline 0.16.2 → 0.16.4

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,52 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.16.4] — 2026-10-07
14
+
15
+ ### 変更
16
+
17
+ - 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は変えない。
18
+ - 最初の失敗から24時間を過ぎても成功を確認できない時だけ、`L1_SUMMARIZER_BACKEND_UNRECOVERED`(`warn`)で数える。要約が成功したら、その記録を`recovered`で解決にする。
19
+ - Codexの自動継続のworkerは、要約のbackendの失敗を`handoff_summarizer_backend_failed`で止める(今までは汎用の`handoff_worker_failed`)。失敗の画面に、通信の断・利用上限・認証切れで起きる事、記録と入力が失われていない事、再開の仕方を書く。
20
+
21
+ ### 変わらない事
22
+
23
+ - BugHubへ送る本文の形。項目は足していない。
24
+ - hook処理の失敗(`HOOK_*_FAILED`)の数え方と重大度。これらの経路に通信は無い。
25
+ - BugHubへの送信の失敗は、今までどおり送信の状態にだけ残し、runtime errorには数えない。
26
+
27
+ ### 確認していない範囲
28
+
29
+ - 要約が失敗し続ける間のCodexの画面への影響。
30
+ - 本物のCodex CLIが、通信の断・利用上限・認証切れのそれぞれで返す終了codeとstderr。
31
+
32
+ ## [0.16.3] — 2026-10-07
33
+
34
+ ### 修正
35
+
36
+ - Codexの自動継続で、引き継ぎの最中(継続の指示を送る前)に旧タスクへ入力が来ると、同じ旧タスクから2つ目の引き継ぎが立っていたのを直す([ADR 0040](docs/adr/0040-auto-handoff-no-second-successor-while-pending.md))。1つ目は`handoff_source_advanced`で失敗して失敗の画面を出し、記憶だけが入った後継が残っていた。workerが動いている引き継ぎがある間は、新しい引き継ぎを作らず、ターンを止めて進んでいる引き継ぎのIDを示す。入力を受けただけで止まったturnは、元turnの境界を進めた物として数えない。
37
+ - 後継を作った後、配送の前に止まった引き継ぎは、次の入力で同じ引き継ぎをやり直す(同じID、出来ている後継を使う)。今までは別の引き継ぎを作り、後継が2つになっていた。後継を利用者がもう使っている時、後継が消えている時、旧タスクが進んでいる時、後継を作る前に止まった時は、今までどおり新しい引き継ぎを作る。
38
+ - 2つの入力がほぼ同時に来た時は、先に記録した方だけが引き継ぎを作る。
39
+ - 引き継ぎの最中に旧タスクへ来た入力は、後継へ渡す記憶に入れない。
40
+ - Claude Codeの自動継続で、引き継ぎの記録を24時間で掃除していたため、その後に同じ会話からもう1つ後継が立っていたのを直す。後継の会話が残っている間は、記録を消さない。
41
+
42
+ ### 変わらない事
43
+
44
+ - 後継が残っていない(消した、アーカイブした)旧タスクからは、今までどおり新しい後継を立てる。
45
+ - 止める場所は`PreCompact(auto)`のまま。旧タスクが圧縮に掛からない間は、旧タスクはそのまま動く。
46
+ - DBの形は変えない(v12)。
47
+
48
+ ### 確認した範囲
49
+
50
+ - macOSで、公開するtarballを切り離したHOMEへ入れ、2026-10-07の実物のrolloutと`codex_handoffs`の写しへ当てた。止まった入力が続くrolloutで境界が進まない事、最中の入力で行もworkerも増えない事、引き継ぎ済みの旧タスクと引き継いでいないタスクが0.16.2と同じ動きである事。
51
+ - 同じ旧タスクへ6つの入力を同じ時刻に入れて、引き継ぎが1つだけ出来る事(LinuxとmacOS、本物のprocess)。
52
+
53
+ ### 確認していない範囲
54
+
55
+ - 本物のCodex Desktopでの通しと、画面で最中の入力を止めた理由がどう見えるか。
56
+ - 同じ引き継ぎのやり直しを、本物の後継のタスクで。
57
+ - Claude Codeの記録を残す事を、本物のClaude Codeの会話で。
58
+
13
59
  ## [0.16.2] — 2026-10-07
14
60
 
15
61
  ### 修正
@@ -1913,7 +1959,9 @@ two attempts, instrument first instead of patching again.
1913
1959
 
1914
1960
  ---
1915
1961
 
1916
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.2...HEAD
1962
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.4...HEAD
1963
+ [0.16.4]: https://github.com/kitepon/Throughline/compare/v0.16.3...v0.16.4
1964
+ [0.16.3]: https://github.com/kitepon/Throughline/compare/v0.16.2...v0.16.3
1917
1965
  [0.16.2]: https://github.com/kitepon/Throughline/compare/v0.16.1...v0.16.2
1918
1966
  [0.16.1]: https://github.com/kitepon/Throughline/compare/v0.16.0...v0.16.1
1919
1967
  [0.16.0]: https://github.com/kitepon/Throughline/compare/v0.15.5...v0.16.0
@@ -162,6 +162,9 @@ DBには引き継ぎID、元thread/turn、project/Codex環境、snapshot参照
162
162
  元thread/turnの要求は一意にし、フックの重複で後継を増やさない。
163
163
  継続の指示が届いた旧タスクへ新しいturnが来た時は、後継が残っている間は要求を作らず、
164
164
  ターンを止めて今の後継を示して開く([ADR 0039](adr/0039-codex-auto-handoff-one-successor-per-source.md))。
165
+ 継続の指示を送る前の引き継ぎがある旧タスクへ新しいturnが来た時も、要求を作らない。workerが動いていれば
166
+ 進んでいる引き継ぎを返して止め、後継を作ったまま止まっていれば同じ引き継ぎをやり直す
167
+ ([ADR 0040](adr/0040-auto-handoff-no-second-successor-while-pending.md))。
165
168
  schema変更は `src/db.mjs` と製品所有migrationへ置き、
166
169
  schemaの版とmigrationは同じ製品releaseで更新する。
167
170
 
@@ -0,0 +1,99 @@
1
+ # ADR 0040: 引き継ぎの最中と、止まった引き継ぎから、2つ目の後継を立てない
2
+
3
+ 日付: 2026-10-07
4
+
5
+ ## Context
6
+
7
+ ADR 0039(0.16.2)は、継続の指示が届いた後継が残っている旧タスクから、2つ目の後継を立てない様にした。
8
+ オーナーは、1つのタスクや会話で自動継続が2回以上発動しない事を求めた(2026-10-07)。0.16.2 のコードを動かして、
9
+ 同じ旧タスクから2つ目の後継が立つ道が残っている事を確かめた。
10
+
11
+ Codex:
12
+
13
+ 1. **引き継ぎの最中の入力。** worker が継続の指示を送る前(macOS の実績で、要求から継続の確認まで 9〜74 秒)に
14
+ 旧タスクへ入力が来ると、`PreCompact(auto)` がもう一度発火する。ADR 0039 の判定は「指示が届いた引き継ぎ」だけを
15
+ 見るので、別の引き継ぎを作った。1つ目の worker は、元の turn が最新でなくなった事を `handoff_source_advanced` と
16
+ 判定して失敗し、失敗の画面を出す。1つ目が作った後継(記憶だけが入り、指示は無い)が残り、2つ目の引き継ぎが
17
+ 別の後継を立てる。
18
+ 2. **後継を作った後、配送の前に止まった引き継ぎ。** 次の入力で、やり直しではなく別の引き継ぎを作った。
19
+ 前の後継が残り、後継が2つになる。
20
+
21
+ Claude Code:
22
+
23
+ 3. **記録の期限。** 記録は24時間で掃除していた(ADR 0033 の10)。掃除の後、引き継ぎ済みの会話で自動圧縮が起きると、
24
+ 同じ会話からもう1つ後継が立つ。
25
+
26
+ hook が止めた入力の turn は、Codex Desktop 0.160.1 の rollout に次の並びで残る(2026-10-07 の実物)。
27
+ `thread_settings_applied`、`task_started`、user の `message`、`item_completed`、約1秒後の `turn_aborted`。
28
+ モデルの発言も道具の呼び出しも無い。
29
+
30
+ オーナーは3つの案から「止める場所は今のまま、穴だけ塞ぐ」を選んだ(2026-10-07)。入力を受けた時点で断る案
31
+ (`UserPromptSubmit` の `decision: "block"`。Codex 0.160.1 と Claude Code 2.1.292 の実物で、モデルが呼ばれない事を
32
+ 確かめた)と、DB の一意を元タスクだけにする案は採らない。
33
+
34
+ ## Decision
35
+
36
+ 1. Codex の `PreCompact(auto)` は、その旧タスクに、継続の指示をまだ送っていない引き継ぎがあり、その worker が
37
+ 動いている時、新しい引き継ぎを作らない。`continue: false` でターンを止め、止めた理由に進んでいる引き継ぎの ID を書く。
38
+ worker が動いているかは、記録の worker の process(pid と誕生時刻)が process 一覧にあるかで見る。
39
+ worker を起動してから worker が名乗るまでの間(15 秒)は、記録の更新時刻で見る。
40
+ process 一覧を読めない時は、記録の更新から 10 分の間だけ、動いている物として扱う(後継を2つにしない側へ倒す)。
41
+ 2. 入力を受けただけで止まった turn は、元 turn の境界を進めない。worker は、元 turn より後の turn がどれも
42
+ 「止まっていて、モデルの発言・道具の呼び出し・完了・圧縮が無い」時、今までどおり引き継ぎを続ける。
43
+ 後の turn に動きがあれば、今までどおり `handoff_source_advanced` で止まる。まだ止まり切っていない turn は、
44
+ 止まるまで待つ(30 秒。過ぎれば `handoff_source_stop_unconfirmed`)。
45
+ 3. 止めた後で旧タスクへ来た入力は、凍結する記憶へ入れない。後継へは渡さない入力(ADR 0039 の Consequences)。
46
+ 4. 後継を作った後、配送の前に止まった引き継ぎは、次の入力で同じ引き継ぎをやり直す。条件は、後継のタスクが
47
+ 残っていて、後継にまだ turn が無く、止まった後の旧タスクに動きが無い事。`failed` は止まった工程から再開し、
48
+ worker が居なくなった途中の記録はその状態から続ける。
49
+ 条件に合わない時(後継を利用者がもう使っている、後継が消えている、結果不明、旧タスクが進んでいる)と、
50
+ 後継を作る前に止まった引き継ぎからは、今までどおり新しい引き継ぎを作る。後継を作る前の失敗は、残る物が無い。
51
+ 5. 2つの入力がほぼ同時に来た時は、先に記録した方だけが引き継ぎを作る。「継続の指示をまだ送っていない引き継ぎが
52
+ 増えていないか」の確認と記録を、1つの transaction で行う。後から来た方は、1の扱いになる。
53
+ ADR 0039 の5(worker が指示を送る直前の確認)は残す。
54
+ 6. 1と4で止めた事は、`~/.throughline/codex-auto-handoff/redirects.jsonl` に `kind`(`in_flight`・`retry`)を付けて残す。
55
+ ADR 0039 の行(後継を示した回)には `kind` を付けない。
56
+ 7. Claude Code の記録は、後継の会話が残っている間は、24時間を過ぎても掃除しない。後継が残っているかは、
57
+ 受領が残った引き継ぎ(`sent`)について、後継の transcript があるかで見る。transcript は、旧い会話の transcript の
58
+ 場所から Claude の projects をたどり、その下のフォルダを順に見る(フォルダ名の付け方は OS で違う)。
59
+ 後継が消えた記録、受領を確かめられなかった記録、失敗した記録は、今までどおり期限で掃除する。
60
+ 8. DB の形は変えない(v12)。ADR 0039 の3(後継が残っていない旧タスクからは、新しい引き継ぎを作る)は変えない。
61
+
62
+ ADR 0039 の2のうち「配送の前に失敗した引き継ぎからは、今までどおり新しい引き継ぎを作る」は、4で置き換える。
63
+ ADR 0033 の10のうち「24時間を過ぎたものは次の引き継ぎの時に掃除する」は、7で置き換える。
64
+
65
+ ## Consequences
66
+
67
+ - 引き継ぎの最中に旧タスクへ打った入力は、止まる。後継へは渡らない。後継が開いてから、後継で打ち直す。
68
+ - 引き継ぎの最中に来た入力では、失敗の画面は出ない。記憶だけが入った後継も残らない。
69
+ - 後継を消した(アーカイブした)旧タスクからは、今までどおり新しい後継が立つ。1つの旧タスクから後継が2つ出来るのは、
70
+ この時と、4の条件に合わなかった時だけになる。
71
+ - 旧タスクが圧縮に掛からない間(手動の `/compact` の後など)は、旧タスクはそのまま動く。止めるのは `PreCompact(auto)` の時だけ。
72
+ - Claude Code の引き継ぎ済みの会話は、後継が残っている間、道具と圧縮を止め続ける。道具を呼ばない会話は続けられるが、
73
+ 圧縮されないので、続けると文脈の上限に当たる(0.16.2 までも、記録が残っている間は同じ)。
74
+ - worker が動いているかを見るために、`PreCompact(auto)` の hook が process 一覧を読む事がある。読むのは、その旧タスクに
75
+ 途中の引き継ぎがあり、worker が名乗っている時だけ。
76
+
77
+ ## 確認した範囲
78
+
79
+ - 0.16.2 のコードで、1・2・3の道を再現した(切り離した HOME と空の DB)。
80
+ - hook が止めた turn の並びは、macOS の Codex Desktop(同梱の Codex 0.160.1)で 2026-10-07 に起きた2回の記録を読んだ。
81
+ - macOS(公開する tarball、切り離した HOME、本物の `codex_handoffs` の写し)。
82
+ - 2026-10-07 の実物の rollout(元 turn の後に、hook が止めた入力が2つ)で、境界の判定は「止まったまま」になった。
83
+ 0.16.2 は同じ rollout を `handoff_source_advanced` と判定する。
84
+ - 1つ目の引き継ぎを指示を送る前の状態へ戻した写しへ、本物の入口(`codex-hook pre-compact`)で新しい turn を渡した。
85
+ `continue: false`、新しい行 0、worker の起動 0、Desktop を開く呼び出し 0。worker が名乗る前(更新時刻)と、
86
+ worker が動いている時(macOS の process 一覧)の両方で同じ。worker が居なくなり後継も残っていない時は、新しい引き継ぎを作った。
87
+ - 引き継ぎ済みの旧タスクへの入力(ADR 0039)と、引き継いでいないタスクは、0.16.2 と同じ動き。
88
+ - 同じ旧タスクへ6つの入力を同じ時刻に入れた(本物の process、Linux は5回、macOS は3回)。どの回も、引き継ぎの行は1つ、
89
+ worker の起動は1回。0.16.2 は6つずつ出来る。
90
+ - hook の本体と worker の本体を通した(Linux、Codex Desktop に触る所だけ差し替え)。最中に来て止まった入力では、
91
+ 1つ目の引き継ぎが `continued` まで進み、失敗の画面は出ず、凍結した記憶に後から来た入力は入らなかった。
92
+ 旧タスクでモデルが答えた時は、後継を作る前に `handoff_source_advanced` で止まった。
93
+
94
+ ## 確認していない範囲
95
+
96
+ - 本物の Codex Desktop での通し(引き継ぎの最中に、画面から旧タスクへ打つ)。画面で、止めた理由がどう見えるか。
97
+ - 4(同じ引き継ぎのやり直し)を、本物の後継のタスクで。後継にまだ turn が無い状態は、試験の差し替えでだけ見た。
98
+ - Windows と Linux の Codex Desktop。Codex の自動継続は macOS でだけ使われている。
99
+ - 7(Claude Code の記録を残す事)を、本物の Claude Code の会話で。試験で、transcript のフォルダが同じ時と別の時を見た。
@@ -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 で行った。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.16.2",
3
+ "version": "0.16.4",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -136,6 +136,23 @@ export function publicClaudeAutoHandoff(record) {
136
136
  requested_at, updated_at };
137
137
  }
138
138
 
139
+ /**
140
+ * 後継の会話が残っているか。受領が残った引き継ぎ(後継の session id が分かっている物)だけを見る。
141
+ * 後継の transcript は、Claude の projects の下の、後継を起動した project のフォルダに出来る。フォルダ名の付け方は
142
+ * OS で違うので、旧い会話の transcript の場所から projects をたどり、その下のフォルダを順に見る。
143
+ */
144
+ function successorRemains(record) {
145
+ const successorId = record?.successor?.session_id;
146
+ if (record?.state !== 'sent' || !validSessionId(successorId) || typeof record.transcript_path !== 'string') return false;
147
+ const file = `${successorId}.jsonl`;
148
+ const own = dirname(record.transcript_path);
149
+ if (existsSync(join(own, file))) return true;
150
+ let folders;
151
+ try { folders = readdirSync(dirname(own), { withFileTypes: true }); }
152
+ catch { return false; }
153
+ return folders.some(entry => entry.isDirectory() && existsSync(join(dirname(own), entry.name, file)));
154
+ }
155
+
139
156
  function removeStale(dir, now) {
140
157
  for (const folder of [dir, targetsDir(dir)]) {
141
158
  let names;
@@ -153,7 +170,14 @@ function removeStale(dir, now) {
153
170
  if (error.code === 'ENOENT') continue; // 別の hook が同時に消した
154
171
  throw error;
155
172
  }
156
- if (now - modifiedAt > STALE_RECORD_MS) rmSync(path, { force: true });
173
+ if (now - modifiedAt <= STALE_RECORD_MS) continue;
174
+ // 後継が残っている会話の記録は消さない。消すと、旧い会話からもう1つ後継が立つ。
175
+ if (folder === dir && name.endsWith('.json')) {
176
+ let record = null;
177
+ try { record = readJson(path); } catch { /* 読めない記録は、期限どおり消す */ }
178
+ if (successorRemains(record)) continue;
179
+ }
180
+ rmSync(path, { force: true });
157
181
  }
158
182
  }
159
183
  }
@@ -174,6 +174,34 @@ test('PreCompact: session id が無い・pathを含む時は失敗し、期限
174
174
  assert.deepEqual(readdirSync(join(dir, 'targets')), []);
175
175
  }));
176
176
 
177
+ test('PreCompact: 後継の会話が残っている引き継ぎの記録は、期限を過ぎても消さない', () => withDir(dir => {
178
+ const now = Date.parse('2026-10-04T12:00:00Z');
179
+ const old = new Date(now - 25 * 3_600_000);
180
+ const projects = join(dir, 'claude', 'projects');
181
+ mkdirSync(join(projects, '-work-app'), { recursive: true }); mkdirSync(join(projects, '-moved-app'), { recursive: true });
182
+ const record = (id, fields) => {
183
+ writeFileSync(join(dir, `${id}.json`), JSON.stringify({ schema: 'throughline.claude-auto-handoff.v2', handoff_id: `h-${id}`,
184
+ source_session_id: id, project_path: '/work/app', transcript_path: join(projects, '-work-app', `${id}.jsonl`),
185
+ state: 'sent', successor: { short_id: 'bbbbbbbb', session_id: `bbbbbbbb-${id.slice(9)}` }, ...fields }));
186
+ utimesSync(join(dir, `${id}.json`), old, old);
187
+ return fields?.successor?.session_id ?? `bbbbbbbb-${id.slice(9)}`;
188
+ };
189
+ const kept = 'aaaaaaaa-0000-4000-8000-000000000011', moved = 'aaaaaaaa-0000-4000-8000-000000000012';
190
+ const gone = 'aaaaaaaa-0000-4000-8000-000000000013', unconfirmed = 'aaaaaaaa-0000-4000-8000-000000000014';
191
+ const failed = 'aaaaaaaa-0000-4000-8000-000000000015';
192
+ // 後継の transcript が、旧い会話と同じフォルダにある。別の project のフォルダにある(会話を移していた時)。
193
+ writeFileSync(join(projects, '-work-app', `${record(kept)}.jsonl`), '');
194
+ writeFileSync(join(projects, '-moved-app', `${record(moved)}.jsonl`), '');
195
+ // 後継が消されている。受領を確かめられなかった(後継の session id が無い)。立ち上げに失敗した。
196
+ record(gone);
197
+ record(unconfirmed, { state: 'unknown', successor: { short_id: 'bbbbbbbb', session_id: null } });
198
+ writeFileSync(join(projects, '-work-app', `${record(failed, { state: 'failed' })}.jsonl`), '');
199
+ request(dir, { now });
200
+ assert.deepEqual(readdirSync(dir).filter(name => name.endsWith('.json')).sort(), [`${SESSION}.json`, `${kept}.json`, `${moved}.json`].sort());
201
+ // 残した記録の会話は、圧縮と道具を止め続ける。同じ会話から2つ目の後継は立たない。
202
+ assert.equal(request(dir, { now, payload: { session_id: kept } }).result.status, 'already_requested');
203
+ }));
204
+
177
205
  test('PreToolUse: 記録のある会話の道具を実行させずに止め、止めた時点の依頼と設定を写して worker を1回だけ起動する', () => withDir(async dir => {
178
206
  const transcriptPath = join(dir, 'transcript.jsonl');
179
207
  writeFileSync(transcriptPath, jsonl([
@@ -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) {
@@ -37,6 +37,16 @@ export function listDeliveredAutoHandoffsForSource(db, threadId) {
37
37
  ORDER BY created_at DESC`).all(threadId).map(decode);
38
38
  }
39
39
 
40
+ /**
41
+ * 継続の指示をまだ送っていない引き継ぎ(新しい順)。進んでいる最中の物と、配送の前に止まった物。
42
+ * 同じ旧タスクへ次の入力が来た時に、別の引き継ぎを作る前にここを見る。
43
+ */
44
+ export function listUndeliveredAutoHandoffsForSource(db, threadId) {
45
+ return db.prepare(`SELECT * FROM codex_handoffs WHERE source_thread_id = ? AND queued_submission_id IS NULL
46
+ AND state NOT IN ('submitted', 'continued') AND (mutation_stage IS NULL OR mutation_stage <> 'submit')
47
+ ORDER BY created_at DESC, rowid DESC`).all(threadId).map(decode);
48
+ }
49
+
40
50
  export function listAutoHandoffs(db, { projectPath = null, limit = 20 } = {}) {
41
51
  const rows = projectPath == null
42
52
  ? db.prepare('SELECT * FROM codex_handoffs ORDER BY created_at DESC LIMIT ?').all(limit)
@@ -106,13 +116,35 @@ export function releaseAutoHandoff(db, id, identity) {
106
116
  .run(id, JSON.stringify(identity));
107
117
  }
108
118
 
119
+ /**
120
+ * 元turnを止めた後で旧タスクへ来た入力のturn番号。hookが止めたturnで、入力だけが残っている。
121
+ * 末尾から、止めた時刻より後に始まり、userの発言だけで、道具の記録が無いturnを、後続turnの数まで数える。
122
+ */
123
+ function turnsAfterSourceStop(bodies, details, source) {
124
+ const after = new Set();
125
+ let remaining = source.laterTurns?.length ?? 0;
126
+ if (!remaining || !Number.isFinite(source.stoppedAt)) return after;
127
+ for (const number of [...new Set(bodies.map(row => row.turn_number))].sort((a, b) => b - a)) {
128
+ const rows = bodies.filter(row => row.turn_number === number);
129
+ if (rows.some(row => row.role !== 'user' || !(row.created_at > source.stoppedAt)) ||
130
+ details.some(row => row.turn_number === number)) break;
131
+ after.add(number);
132
+ if (--remaining === 0) break;
133
+ }
134
+ return after;
135
+ }
136
+
109
137
  export function freezeCodexMemory(db, operation, settings, source) {
110
138
  const session = db.prepare('SELECT session_id, project_path FROM sessions WHERE session_id = ?')
111
139
  .get(operation.source_session_id);
112
140
  if (!session || session.project_path !== operation.project_path) throw new Error('handoff_memory_source_mismatch');
113
141
  const load = table => db.prepare(`SELECT * FROM ${table} WHERE session_id = ? ORDER BY created_at, id`)
114
142
  .all(operation.source_session_id);
115
- const bodies = load('bodies'), skeletons = load('skeletons'), details = load('details');
143
+ const all = { bodies: load('bodies'), skeletons: load('skeletons'), details: load('details') };
144
+ // 引き継ぎの最中に旧タスクへ来た入力は、後継へ渡さない。記憶は元turnまでで凍結する。
145
+ const after = turnsAfterSourceStop(all.bodies, all.details, source);
146
+ const keep = row => !after.has(row.turn_number);
147
+ const bodies = all.bodies.filter(keep), skeletons = all.skeletons.filter(keep), details = all.details.filter(keep);
116
148
  return {
117
149
  version: 1, session: { ...session }, settings,
118
150
  sourceTurnId: operation.source_turn_id,
@@ -14,7 +14,8 @@ import { captureCodexRolloutToDb } from './codex-capture.mjs';
14
14
  import { readAutoHandoffConfig, autoHandoffEnabledFor } from './codex-auto-handoff-config.mjs';
15
15
  import { getAutoHandoff, requestAutoHandoff, updateAutoHandoff, failAutoHandoff,
16
16
  claimAutoHandoff, releaseAutoHandoff, freezeCodexMemory, continuationInput,
17
- findAutoHandoffForTurn, listDeliveredAutoHandoffsForSource } from './codex-auto-handoff-store.mjs';
17
+ findAutoHandoffForTurn, listDeliveredAutoHandoffsForSource, listUndeliveredAutoHandoffsForSource,
18
+ HANDOFF_TERMINAL_STATES } from './codex-auto-handoff-store.mjs';
18
19
  import { ensureAutoHandoffSummaries, renderAutoHandoffMemory } from './codex-auto-handoff-memory.mjs';
19
20
  import { CODEX_NATIVE_ID_PATTERN, CodexHandoffError, readCodexHandoffState,
20
21
  settingsMatch, threadStartSettings } from './hosts/codex-handoff-state.mjs';
@@ -38,7 +39,12 @@ const sourceState = (operation, options = {}) => readCodexHandoffState(operation
38
39
  { threadId: operation.source_thread_id, turnId: operation.source_turn_id, ...options });
39
40
 
40
41
  export function sourceBoundary(operation, state) {
41
- if (state.latestTurnId !== operation.source_turn_id) throw error('handoff_source_advanced');
42
+ if (state.latestTurnId !== operation.source_turn_id) {
43
+ // 引き継ぎの最中に旧タスクへ来た入力は、hookが止める。入力を受けただけで止まったturnは、元turnの境界を進めない。
44
+ const later = state.laterTurns ?? [];
45
+ if (!later.length || later.some(turn => turn.activity)) throw error('handoff_source_advanced');
46
+ if (later.some(turn => !turn.closed)) return false;
47
+ }
42
48
  if (state.pendingCallCount) throw error('handoff_source_operation_pending');
43
49
  if (state.pendingNativeSessionCount) throw error('handoff_native_session_pending');
44
50
  return Number.isFinite(state.stoppedAt) && state.stoppedAt >= operation.created_at;
@@ -55,11 +61,11 @@ export async function waitForHandoff(check, { timeoutMs = 120_000, intervalMs =
55
61
  throw error(timeoutCode);
56
62
  }
57
63
 
58
- export async function launchAutoHandoffWorker(id) {
64
+ export async function launchAutoHandoffWorker(id, { resume = false } = {}) {
59
65
  mkdirSync(stateRoot(), { recursive: true, mode: 0o700 });
60
66
  const log = openSync(join(stateRoot(), `${id}.log`), 'a', 0o600);
61
67
  try {
62
- const child = spawnPortable(process.execPath, [CLI_PATH, 'auto-handoff', 'worker', '--operation', id, '--json'],
68
+ const child = spawnPortable(process.execPath, [CLI_PATH, 'auto-handoff', resume ? 'resume' : 'worker', '--operation', id, '--json'],
63
69
  { detached: true, stdio: ['ignore', log, log], windowsHide: true });
64
70
  await new Promise((resolveSpawn, reject) => { child.once('spawn', resolveSpawn); child.once('error', reject); });
65
71
  child.unref();
@@ -103,9 +109,55 @@ function recordAutoHandoffRedirect(entry) {
103
109
  appendFileSync(join(stateRoot(), 'redirects.jsonl'), JSON.stringify(entry) + '\n', { mode: 0o600 });
104
110
  }
105
111
 
112
+ // workerを起動してから、workerが自分のprocessを記録へ名乗るまでの間。この間の引き継ぎは、進んでいる物として扱う。
113
+ const WORKER_START_GRACE_MS = 15_000;
114
+ // workerが1回の引き継ぎに掛ける時間の上限(各段の待ち時間の合計より長い)。process一覧を読めない時だけ使う。
115
+ const WORKER_RUN_LIMIT_MS = 10 * 60_000;
116
+
117
+ /** その引き継ぎのworkerが動いているか。名乗る前は、記録の更新時刻で見る。 */
118
+ function workerActive(operation, processes, now) {
119
+ const identity = operation.worker_identity;
120
+ if (!identity) return now - operation.updated_at < WORKER_START_GRACE_MS;
121
+ let table;
122
+ // process一覧を読めない時は、後継を2つにしない側へ倒す。記録が新しい間は、動いている物として扱う。
123
+ try { table = processes(); } catch { return now - operation.updated_at < WORKER_RUN_LIMIT_MS; }
124
+ return table.some(p => p.pid === identity.pid && p.started_identity === identity.started_identity);
125
+ }
126
+
127
+ /**
128
+ * 配送の前に止まった引き継ぎを、同じ引き継ぎとしてやり直せるか。後継のタスクが出来ていて、まだ誰も使っておらず、
129
+ * 止まった後の旧タスクに動きが無い時だけ。後継が出来る前に止まった物は残る物が無いので、新しい引き継ぎで足りる。
130
+ */
131
+ function resumableAfterStop(operation, rolloutPath, readTarget) {
132
+ if (operation.mutation_stage || !operation.target_thread_id) return false;
133
+ try {
134
+ const target = readTarget(operation);
135
+ if (!target || target.latestTurnId) return false;
136
+ const later = readCodexHandoffState(rolloutPath,
137
+ { threadId: operation.source_thread_id, turnId: operation.source_turn_id }).laterTurns;
138
+ return later.length > 0 && !later.some(turn => turn.activity);
139
+ } catch { return false; }
140
+ }
141
+
142
+ /**
143
+ * 同じ旧タスクの、継続の指示をまだ送っていない引き継ぎの扱い。workerが動いていれば「最中」、後継が出来たまま
144
+ * 止まっていれば「やり直し」。どちらでもなければ null(新しい引き継ぎを作る)。
145
+ */
146
+ function settleUndeliveredAutoHandoff(db, threadId, { rolloutPath, processes, readTarget, now }) {
147
+ const undelivered = listUndeliveredAutoHandoffsForSource(db, threadId);
148
+ let table = null;
149
+ const running = () => (table ??= processes());
150
+ const inFlight = undelivered.find(operation => !HANDOFF_TERMINAL_STATES.has(operation.state) && workerActive(operation, running, now));
151
+ if (inFlight) return { kind: 'in_flight', operation: inFlight, seen: undelivered };
152
+ const stopped = undelivered.find(operation => operation.state !== 'unknown' && resumableAfterStop(operation, rolloutPath, readTarget));
153
+ if (stopped) return { kind: 'retry', operation: stopped, seen: undelivered };
154
+ return { kind: null, seen: undelivered };
155
+ }
156
+
106
157
  export async function requestCodexAutoHandoff({ payload, db = null,
107
158
  config = readAutoHandoffConfig(), launchWorker = launchAutoHandoffWorker,
108
- findThread = findCodexThreadCandidate, openThread = openSuccessor, recordRedirect = recordAutoHandoffRedirect } = {}) {
159
+ findThread = findCodexThreadCandidate, openThread = openSuccessor, recordRedirect = recordAutoHandoffRedirect,
160
+ processes = readRuntimeProcesses, readTarget = targetState, now = Date.now() } = {}) {
109
161
  if (payload.trigger !== 'auto' || !autoHandoffEnabledFor(config, payload.cwd)) return { status: 'skipped' };
110
162
  const threadId = payload.session_id, turnId = payload.turn_id;
111
163
  if (!CODEX_NATIVE_ID_PATTERN.test(threadId ?? '') || !CODEX_NATIVE_ID_PATTERN.test(turnId ?? '') ||
@@ -121,8 +173,8 @@ export async function requestCodexAutoHandoff({ payload, db = null,
121
173
  const codexHome = rolloutPath.slice(0, sessionPart.index);
122
174
  const actualDb = db ?? getDb();
123
175
  // 引き継ぎ済みの旧タスクへ、新しい入力が来た。作業は後継が持っているので、後継を増やさずに止めて後継を示す。
124
- const successor = findAutoHandoffForTurn(actualDb, threadId, turnId) ? null
125
- : findLiveAutoHandoffSuccessor(actualDb, threadId, { findThread });
176
+ const sameTurn = findAutoHandoffForTurn(actualDb, threadId, turnId);
177
+ const successor = sameTurn ? null : findLiveAutoHandoffSuccessor(actualDb, threadId, { findThread });
126
178
  if (successor) {
127
179
  const opened = openThread(successor);
128
180
  recordRedirect({ at: new Date().toISOString(), source_thread_id: threadId, source_turn_id: turnId,
@@ -133,8 +185,42 @@ export async function requestCodexAutoHandoff({ payload, db = null,
133
185
  `続きは後継のタスク「${autoHandoffTargetName(actualDb, successor)}」で行ってください: ` +
134
186
  `codex://threads/${successor.target_thread_id}` };
135
187
  }
136
- const { operation, inserted } = requestAutoHandoff(actualDb, { threadId, turnId, projectPath: state.meta.cwd,
137
- rolloutPath, codexHome, openHost: config.openHost });
188
+ // 引き継ぎの最中(継続の指示を送る前)と、後継が出来たまま止まった旧タスクへ、新しい入力が来た。
189
+ // 別の引き継ぎを作ると後継が2つになるので、進んでいる引き継ぎを返すか、同じ引き継ぎをやり直す。
190
+ let settled = sameTurn ? { kind: null, seen: [] }
191
+ : settleUndeliveredAutoHandoff(actualDb, threadId, { rolloutPath, processes, readTarget, now });
192
+ let created = null;
193
+ if (!settled.kind) {
194
+ // 2つの入力がほぼ同時に来た時は、先に記録した方だけが引き継ぎを作る。確認と記録を1つのtransactionで行う。
195
+ actualDb.exec('BEGIN IMMEDIATE');
196
+ try {
197
+ const seen = new Set(settled.seen.map(operation => operation.handoff_id));
198
+ const raced = sameTurn ? null : listUndeliveredAutoHandoffsForSource(actualDb, threadId)
199
+ .find(operation => !seen.has(operation.handoff_id) && !HANDOFF_TERMINAL_STATES.has(operation.state));
200
+ if (raced) settled = { kind: 'in_flight', operation: raced };
201
+ else created = requestAutoHandoff(actualDb, { threadId, turnId, projectPath: state.meta.cwd,
202
+ rolloutPath, codexHome, openHost: config.openHost });
203
+ actualDb.exec('COMMIT');
204
+ } catch (cause) {
205
+ actualDb.exec('ROLLBACK');
206
+ throw cause;
207
+ }
208
+ }
209
+ if (settled.kind) {
210
+ const pending = settled.operation;
211
+ if (settled.kind === 'retry') {
212
+ try { await launchWorker(pending.handoff_id, { resume: pending.state === 'failed' }); }
213
+ catch { throw error('handoff_worker_start_failed'); }
214
+ }
215
+ recordRedirect({ at: new Date().toISOString(), kind: settled.kind, source_thread_id: threadId, source_turn_id: turnId,
216
+ handoff_id: pending.handoff_id, target_thread_id: pending.target_thread_id ?? null, opened: false });
217
+ return { status: 'ok', operationId: pending.handoff_id, inserted: false, continue: false,
218
+ ...(settled.kind === 'retry' ? { resumed: true } : { inFlight: true }),
219
+ stopReason: settled.kind === 'retry'
220
+ ? `Throughlineが、途中で止まった引き継ぎをやり直します(引き継ぎID: ${pending.handoff_id})。後継のタスクが開いたら、続きはそちらで行ってください。`
221
+ : `Throughlineが、このタスクを新しいタスクへ引き継いでいる最中です(引き継ぎID: ${pending.handoff_id})。後継のタスクが開いたら、続きはそちらで行ってください。` };
222
+ }
223
+ const { operation, inserted } = created;
138
224
  if (inserted) {
139
225
  try { await launchWorker(operation.handoff_id); }
140
226
  catch {
@@ -256,6 +342,7 @@ export function showAutoHandoffFailure(operation) {
256
342
  <h1>自動継続を完了できませんでした</h1><p>引き継ぎは下記の工程で止まっています。タスクと記録を確認してください。</p>
257
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>
258
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>' : ''}
259
346
  <p>状態: ${escape(operation.state)} / 停止した工程: ${escape(operation.resume_state)}</p>
260
347
  <p>確認: <code>throughline auto-handoff status --operation ${escape(operation.handoff_id)}</code></p>
261
348
  <p>原因を解消した後の再開: <code>throughline auto-handoff resume --operation ${escape(operation.handoff_id)}</code></p>
@@ -310,8 +397,11 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
310
397
  const native = await deps.nativeState(operation, runtime);
311
398
  update({ state: 'source_stopped', settings_json: state.settings, runtime_json: { ...native, databasePath: DB_PATH } });
312
399
  }
400
+ // 最中に来た入力のturnが止まり切るまでの間は、境界がまだ見えない。短く待ってから判定する。
401
+ const confirmSourceBoundary = () => deps.wait(() => sourceBoundary(operation, deps.readSource(operation)) || null,
402
+ { timeoutMs: 30_000, timeoutCode: 'handoff_source_stop_unconfirmed' });
313
403
  if (operation.state === 'source_stopped') {
314
- if (!sourceBoundary(operation, deps.readSource(operation))) throw error('handoff_source_stop_unconfirmed');
404
+ await confirmSourceBoundary();
315
405
  if (!operation.snapshot) {
316
406
  const captured = deps.capture(db, { threadId: operation.source_thread_id, codexHome: operation.codex_home,
317
407
  projectPath: operation.project_path });
@@ -319,7 +409,14 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
319
409
  const state = deps.readSource(operation);
320
410
  update({ snapshot_json: freezeCodexMemory(db, operation, operation.settings, state) });
321
411
  }
322
- 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
+ }
323
420
  update({ state: 'memory_ready' });
324
421
  }
325
422
  if (operation.state === 'memory_ready') {
@@ -344,7 +441,7 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
344
441
  update({ state: 'target_ready' });
345
442
  }
346
443
  if (operation.state === 'target_ready') {
347
- if (!sourceBoundary(operation, deps.readSource(operation))) throw error('handoff_source_stop_unconfirmed');
444
+ await confirmSourceBoundary();
348
445
  await deps.nativeState(operation, runtime);
349
446
  const target = deps.readTarget(operation);
350
447
  if (target?.latestTurnId || !target?.settings || !settingsMatch(operation.runtime.preparedSettings, target.settings)) throw error('handoff_target_advanced');
@@ -429,3 +429,205 @@ test('後継タスクの名前は project 名・前任の題・自動引き継
429
429
  const rejected = async () => { throw Object.assign(new Error('unknown method'), { delivery_code: 'CODEX_RECEIVER_REJECTED' }); };
430
430
  assert.equal(await nameAutoHandoffTarget(rejected, 'T', 'x'), false, '名前を付けられなくても、引き継ぎは止めない');
431
431
  }));
432
+
433
+ // 元turn(止めた)の後に、旧タスクへ来た入力のturnが続くrollout。laterは後続turnの行。
434
+ function sourceRollout(home, thread, turn, later = [], stoppedAt = '2026-10-07T00:49:01.000Z') {
435
+ const dir = join(home, 'codex', 'sessions'); mkdirSync(dir, { recursive: true });
436
+ const file = join(dir, `rollout-${thread}.jsonl`);
437
+ const rows = [
438
+ { timestamp: '2026-10-07T00:47:00.000Z', type: 'session_meta', payload: { id: thread, originator: 'Codex Desktop', source: 'vscode', cwd: home } },
439
+ { timestamp: '2026-10-07T00:47:08.000Z', type: 'event_msg', payload: { type: 'task_started', turn_id: turn } },
440
+ { timestamp: '2026-10-07T00:47:09.000Z', type: 'response_item', payload: { type: 'message', role: 'user', content: [{ text: '進めて' }] } },
441
+ { timestamp: '2026-10-07T00:47:15.000Z', type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ text: '進めるね' }] } },
442
+ { timestamp: stoppedAt, type: 'event_msg', payload: { type: 'turn_aborted', turn_id: turn, reason: 'interrupted' } },
443
+ ...later,
444
+ ];
445
+ writeFileSync(file, rows.map(row => JSON.stringify(row)).join('\n') + '\n');
446
+ return file;
447
+ }
448
+
449
+ // hookが止めたturnの、Codex Desktop 0.160.1 の実物の並び(入力を受けて、1秒ほどで止まる)。
450
+ function stoppedInput(turn, at, { closed = true } = {}) {
451
+ return [
452
+ { timestamp: `${at}.071Z`, type: 'event_msg', payload: { type: 'thread_settings_applied', thread_settings: {
453
+ model: 'model', cwd: '/project', model_provider_id: 'openai', approval_policy: 'never', permission_profile: { type: 'disabled' } } } },
454
+ { timestamp: `${at}.078Z`, type: 'event_msg', payload: { type: 'task_started', turn_id: turn } },
455
+ { timestamp: `${at}.206Z`, type: 'response_item', payload: { type: 'message', role: 'user', content: [{ text: '動いてる?' }] } },
456
+ { timestamp: `${at}.207Z`, type: 'event_msg', payload: { type: 'item_completed', turn_id: turn, item: { type: 'UserMessage' } } },
457
+ ...(closed ? [{ timestamp: `${at}.213Z`, type: 'event_msg', payload: { type: 'turn_aborted', turn_id: turn, reason: 'interrupted' } }] : []),
458
+ ];
459
+ }
460
+
461
+ test('入力を受けただけで止まった後続turnは元turnの境界を進めず、動きのある後続turnは進んだと判定する', async () => withDb((db, home) => {
462
+ const thread = '11111111-1111-1111-1111-111111111111', turn = '22222222-2222-2222-2222-222222222222';
463
+ const operation = { source_thread_id: thread, source_turn_id: turn, created_at: Date.parse('2026-10-07T00:49:00.000Z') };
464
+ const read = later => readCodexHandoffState(sourceRollout(home, thread, turn, later), { threadId: thread, turnId: turn });
465
+ assert.deepEqual(read([]).laterTurns, []);
466
+ assert.equal(sourceBoundary(operation, read([])), true);
467
+ // 止まった入力が2つ続いても、元turnの境界のまま。
468
+ const two = read([...stoppedInput('turn-b', '2026-10-07T00:56:48'), ...stoppedInput('turn-c', '2026-10-07T00:56:58')]);
469
+ assert.deepEqual(two.laterTurns, [{ turnId: 'turn-b', closed: true, activity: false }, { turnId: 'turn-c', closed: true, activity: false }]);
470
+ assert.equal(two.latestTurnId, 'turn-c'); assert.equal(two.stoppedAt, Date.parse('2026-10-07T00:49:01.000Z'));
471
+ assert.equal(sourceBoundary(operation, two), true);
472
+ // まだ止まり切っていない入力は、止まるまで待つ(境界は未確認)。
473
+ assert.equal(sourceBoundary(operation, read(stoppedInput('turn-b', '2026-10-07T00:56:48', { closed: false }))), false);
474
+ // モデルの発言・道具の呼び出し・完了・圧縮があれば、旧タスクは進んでいる。
475
+ const activity = [
476
+ { timestamp: '2026-10-07T00:56:50.000Z', type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ text: '動いてるよ' }] } },
477
+ { timestamp: '2026-10-07T00:56:50.000Z', type: 'response_item', payload: { type: 'function_call', call_id: 'c1', name: 'exec_command', arguments: '{}' } },
478
+ { timestamp: '2026-10-07T00:56:50.000Z', type: 'response_item', payload: { type: 'reasoning' } },
479
+ { timestamp: '2026-10-07T00:56:50.000Z', type: 'event_msg', payload: { type: 'agent_message', message: '動いてるよ' } },
480
+ { timestamp: '2026-10-07T00:56:50.000Z', type: 'event_msg', payload: { type: 'task_complete', turn_id: 'turn-b' } },
481
+ { timestamp: '2026-10-07T00:56:50.000Z', type: 'compacted', payload: {} },
482
+ ];
483
+ for (const row of activity) {
484
+ const state = read([...stoppedInput('turn-b', '2026-10-07T00:56:48', { closed: false }), row]);
485
+ assert.equal(state.laterTurns[0].activity, true, JSON.stringify(row.payload));
486
+ assert.throws(() => sourceBoundary(operation, state), /handoff_source_advanced/);
487
+ }
488
+ // 元turnの開始が見えないrolloutで最新turnが違う時は、今までどおり進んだと判定する。
489
+ assert.throws(() => sourceBoundary(operation, { latestTurnId: 'other', stoppedAt: Date.now(), pendingCallCount: 0 }), /handoff_source_advanced/);
490
+ }));
491
+
492
+ test('引き継ぎの最中に旧タスクへ入力が来ても、別の引き継ぎを作らず、進んでいる引き継ぎを返して止める', async () => withDb(async (db, home) => {
493
+ const thread = '11111111-1111-1111-1111-111111111111', turnA = '22222222-2222-2222-2222-222222222222';
494
+ const turnB = '33333333-3333-3333-3333-333333333333', turnC = '44444444-4444-4444-4444-444444444444';
495
+ const config = { enabled: true, projects: [home], openHost: 'desktop' };
496
+ const seen = { launched: [], recorded: [], opened: 0 };
497
+ const worker = { pid: 4242, started_identity: 'worker-born' };
498
+ const base = { db, config, launchWorker: async (id, options) => { seen.launched.push([id, options?.resume ?? false]); },
499
+ findThread: () => null, openThread: () => { seen.opened++; return true; }, recordRedirect: entry => { seen.recorded.push(entry); },
500
+ processes: () => [worker], readTarget: () => null };
501
+ const first = await requestCodexAutoHandoff({ ...base, payload: desktopSource(home, thread, turnA) });
502
+ assert.equal(first.inserted, true); assert.equal(seen.launched.length, 1);
503
+ const count = () => db.prepare('SELECT COUNT(*) AS n FROM codex_handoffs').get().n;
504
+
505
+ // workerが名乗る前(起動した直後)に来た入力。
506
+ const early = await requestCodexAutoHandoff({ ...base, payload: desktopSource(home, thread, turnB) });
507
+ assert.equal(early.inFlight, true); assert.equal(early.inserted, false); assert.equal(early.continue, false);
508
+ assert.equal(early.operationId, first.operationId); assert.match(early.stopReason, /引き継いでいる最中/);
509
+ assert.equal(count(), 1); assert.equal(seen.launched.length, 1);
510
+
511
+ // workerが後継を作った後(継続の指示を送る前)に来た入力。時間が経っていても、workerが動いていれば最中。
512
+ updateAutoHandoff(db, first.operationId, { state: 'memory_ready', target_thread_id: 'target-1', worker_identity_json: worker });
513
+ const later = await requestCodexAutoHandoff({ ...base, now: Date.now() + 3_600_000, payload: desktopSource(home, thread, turnC) });
514
+ assert.equal(later.inFlight, true); assert.equal(later.operationId, first.operationId);
515
+ assert.equal(count(), 1); assert.equal(seen.launched.length, 1); assert.equal(seen.opened, 0);
516
+ assert.deepEqual(seen.recorded.map(entry => [entry.kind, entry.source_turn_id, entry.handoff_id, entry.target_thread_id, entry.opened]),
517
+ [['in_flight', turnB, first.operationId, null, false], ['in_flight', turnC, first.operationId, 'target-1', false]]);
518
+
519
+ // 同じturnのhookがもう一度来た時は、今までどおり同じ引き継ぎを返す(最中の扱いにしない)。
520
+ const again = await requestCodexAutoHandoff({ ...base, payload: desktopSource(home, thread, turnA) });
521
+ assert.equal(again.operationId, first.operationId); assert.equal(again.inFlight, undefined); assert.equal(again.inserted, false);
522
+
523
+ // process一覧を読めない時は、記録が新しい間だけ最中として扱う(後継を2つにしない側へ倒す)。
524
+ const unreadable = () => { throw new Error('process一覧を読めません'); };
525
+ assert.equal((await requestCodexAutoHandoff({ ...base, processes: unreadable, payload: desktopSource(home, thread, turnC) })).inFlight, true);
526
+ assert.equal(count(), 1);
527
+
528
+ // workerが居なくなり、後継も残っていない引き継ぎは、最中に数えない。今までどおり新しい引き継ぎを作る。
529
+ const dead = await requestCodexAutoHandoff({ ...base, processes: () => [], payload: desktopSource(home, thread, turnC) });
530
+ assert.equal(dead.inserted, true); assert.notEqual(dead.operationId, first.operationId); assert.equal(count(), 2);
531
+ }));
532
+
533
+ test('後継が出来たまま止まった引き継ぎは、次の入力で同じ引き継ぎをやり直し、別の後継を作らない', async () => withDb(async (db, home) => {
534
+ const thread = '11111111-1111-1111-1111-111111111111', turnA = '22222222-2222-2222-2222-222222222222';
535
+ const turnB = '33333333-3333-3333-3333-333333333333';
536
+ const config = { enabled: true, projects: [home], openHost: 'desktop' };
537
+ const seen = { launched: [], recorded: [] };
538
+ const base = { db, config, launchWorker: async (id, options) => { seen.launched.push([id, options?.resume ?? false]); },
539
+ findThread: () => null, openThread: () => { throw new Error('開かない'); }, recordRedirect: entry => { seen.recorded.push(entry); },
540
+ processes: () => [] };
541
+ const count = () => db.prepare('SELECT COUNT(*) AS n FROM codex_handoffs').get().n;
542
+ const fail = (turn, fields) => {
543
+ const rollout = sourceRollout(home, thread, turn);
544
+ const operation = requestAutoHandoff(db, { threadId: thread, turnId: turn, projectPath: home, rolloutPath: rollout,
545
+ codexHome: join(home, 'codex'), openHost: 'desktop', now: 1 }).operation;
546
+ return updateAutoHandoff(db, operation.handoff_id, { target_thread_id: 'target-1', state: 'failed',
547
+ resume_state: 'memory_ready', error_code: 'handoff_target_not_loaded', ...fields }).operation;
548
+ };
549
+ const input = (turn, later) => {
550
+ const file = sourceRollout(home, thread, turnA, later);
551
+ return { trigger: 'auto', cwd: home, session_id: thread, turn_id: turn, transcript_path: file };
552
+ };
553
+ const failed = fail(turnA, {});
554
+ const open = stoppedInput(turnB, '2026-10-07T00:56:48', { closed: false });
555
+
556
+ // 後継が残っていて、まだ誰も使っておらず、旧タスクに動きが無い。同じ引き継ぎを再開する。
557
+ const resumed = await requestCodexAutoHandoff({ ...base, readTarget: () => ({ latestTurnId: null }), payload: input(turnB, open) });
558
+ assert.equal(resumed.resumed, true); assert.equal(resumed.inserted, false); assert.equal(resumed.continue, false);
559
+ assert.equal(resumed.operationId, failed.handoff_id); assert.match(resumed.stopReason, /やり直します/);
560
+ assert.deepEqual(seen.launched, [[failed.handoff_id, true]]); assert.equal(count(), 1);
561
+ assert.deepEqual(seen.recorded.map(entry => [entry.kind, entry.handoff_id, entry.target_thread_id]), [['retry', failed.handoff_id, 'target-1']]);
562
+
563
+ // 後継を利用者がもう使っている・後継が消えている・結果不明・旧タスクが進んでいる時は、今までどおり新しい引き継ぎを作る。
564
+ const fresh = async (fields, readTarget, later = open) => {
565
+ db.prepare('DELETE FROM codex_handoffs').run(); seen.launched.length = 0;
566
+ const row = fail(turnA, fields);
567
+ const result = await requestCodexAutoHandoff({ ...base, readTarget, payload: input(turnB, later) });
568
+ assert.equal(result.inserted, true); assert.notEqual(result.operationId, row.handoff_id);
569
+ assert.deepEqual(seen.launched, [[result.operationId, false]]); assert.equal(count(), 2);
570
+ };
571
+ await fresh({}, () => ({ latestTurnId: 'used-by-user' }));
572
+ await fresh({}, () => null);
573
+ await fresh({ state: 'unknown', mutation_stage: 'inject' }, () => ({ latestTurnId: null }));
574
+ await fresh({ target_thread_id: null }, () => ({ latestTurnId: null }));
575
+ await fresh({}, () => ({ latestTurnId: null }), [
576
+ ...stoppedInput('turn-x', '2026-10-07T00:55:00', { closed: false }),
577
+ { timestamp: '2026-10-07T00:55:02.000Z', type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ text: '続けたよ' }] } },
578
+ { timestamp: '2026-10-07T00:55:03.000Z', type: 'event_msg', payload: { type: 'task_complete', turn_id: 'turn-x' } },
579
+ ...open,
580
+ ]);
581
+ }));
582
+
583
+ test('workerは、最中に来て止まった入力では失敗せず、旧タスクが進んだ時だけ止まる', async () => withDb(async db => {
584
+ const run = async (source, laterTurns) => {
585
+ const operation = request(db, source, { now: 1 }).operation;
586
+ const counters = { created: 0, submitted: 0, notified: 0 };
587
+ const deps = workerDependencies(db, {}, async () => { counters.submitted++; return { queued_submission_id: 'receipt' }; }, counters);
588
+ const read = deps.readSource;
589
+ deps.readSource = item => ({ ...read(item), latestTurnId: 'input-during-handoff', laterTurns });
590
+ return { result: await runAutoHandoffWorker(operation.handoff_id, { db, dependencies: deps }), counters };
591
+ };
592
+ const stopped = await run('stopped-input', [{ turnId: 'input-during-handoff', closed: true, activity: false }]);
593
+ assert.equal(stopped.result.state, 'continued'); assert.equal(stopped.counters.submitted, 1); assert.equal(stopped.counters.notified, 0);
594
+ const advanced = await run('advanced', [{ turnId: 'input-during-handoff', closed: true, activity: true }]);
595
+ assert.equal(advanced.result.state, 'failed'); assert.equal(advanced.result.error_code, 'handoff_source_advanced');
596
+ assert.equal(advanced.counters.created, 0); assert.equal(advanced.counters.submitted, 0);
597
+ }));
598
+
599
+ test('引き継ぎの最中に旧タスクへ来た入力は、凍結する記憶へ入れない', async () => withDb(db => {
600
+ const operation = request(db, 'frozen-source', { now: 1 }).operation;
601
+ const session = operation.source_session_id;
602
+ db.prepare('INSERT INTO sessions (session_id,project_path,created_at,updated_at) VALUES (?, ?, 1, 1)').run(session, '/project');
603
+ const body = db.prepare('INSERT INTO bodies (session_id,origin_session_id,turn_number,role,text,created_at) VALUES (?, ?, ?, ?, ?, ?)');
604
+ body.run(session, session, 1, 'user', '進めて', 100); body.run(session, session, 1, 'assistant', '途中まで進めた', 100);
605
+ body.run(session, session, 2, 'user', '動いてる?', 300); body.run(session, session, 3, 'user', 'ん?', 310);
606
+ db.prepare('INSERT INTO details (session_id,origin_session_id,turn_number,tool_name,kind,output_text,created_at) VALUES (?, ?, 1, ?, ?, ?, ?)')
607
+ .run(session, session, 'exec_command', 'tool_output', '結果', 999);
608
+ const later = [{ turnId: 'b', closed: true, activity: false }, { turnId: 'c', closed: true, activity: false }];
609
+ const frozen = freezeCodexMemory(db, operation, {}, { stoppedAt: 200, laterTurns: later });
610
+ assert.deepEqual(frozen.bodies.map(row => row.text), ['進めて', '途中まで進めた']);
611
+ assert.equal(frozen.sourceTurnNumber, 1); assert.equal(frozen.details.length, 1);
612
+ // 後続turnが無い時と、止める前からあった発言は、今までどおり全部入れる。
613
+ assert.equal(freezeCodexMemory(db, operation, {}, { stoppedAt: 200 }).bodies.length, 4);
614
+ assert.equal(freezeCodexMemory(db, operation, {}, { stoppedAt: 305, laterTurns: later }).bodies.length, 3);
615
+ assert.equal(freezeCodexMemory(db, operation, {}, { stoppedAt: 200, laterTurns: [later[0]] }).bodies.length, 3);
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
+ }));
@@ -102,8 +102,13 @@ export function readCodexHandoffState(path, { threadId = null, turnId = null, de
102
102
  const nativeSessions = new Set();
103
103
  let inheritedThroughlineMemory = false;
104
104
  let completedTools = 0;
105
+ // turnId を指定した時だけ、その turn より後に始まった turn を並べる。closed は止まった(turn_aborted)か完了したか、
106
+ // activity はモデルの発言・道具の呼び出し・圧縮・完了のどれかがあったか。入力を受けただけで止まった turn は activity が false。
107
+ const laterTurns = [];
108
+ let sourceSeen = false, later = null;
105
109
  for (const row of rows) {
106
110
  const payload = row.payload;
111
+ if (later && (row.type === 'compacted' || row.type === 'token_usage_record')) later.activity = true;
107
112
  // forkの先頭は子自身のmetadata。後続にコピーされた親のmetadataは識別へ使わない。
108
113
  if (row.type === 'session_meta') { meta ??= payload; continue; }
109
114
  if (row.type === 'turn_context' && (!turnId || payload.turn_id === turnId)) context = payload;
@@ -114,6 +119,8 @@ export function readCodexHandoffState(path, { threadId = null, turnId = null, de
114
119
  if (row.type === 'event_msg' && payload?.type === 'task_started') {
115
120
  latestTurnId = payload.turn_id;
116
121
  turnOpen = true;
122
+ if (turnId && latestTurnId === turnId) sourceSeen = true;
123
+ else if (turnId && sourceSeen) { later = { turnId: latestTurnId, closed: false, activity: false }; laterTurns.push(later); }
117
124
  hasTurnInput = pendingUser != null;
118
125
  if (!turnId || latestTurnId === turnId) {
119
126
  turnStartAt = Date.parse(row.timestamp); pendingCalls.clear(); completedTools = 0;
@@ -126,6 +133,12 @@ export function readCodexHandoffState(path, { threadId = null, turnId = null, de
126
133
  stoppedAt = Date.parse(row.timestamp); stoppedReason = payload.reason;
127
134
  }
128
135
  if (row.type === 'event_msg' && ['turn_aborted', 'task_complete'].includes(payload?.type) && payload.turn_id === latestTurnId) turnOpen = false;
136
+ if (later && row.type === 'event_msg' && payload?.turn_id === later.turnId) {
137
+ if (payload.type === 'turn_aborted') later.closed = true;
138
+ if (payload.type === 'task_complete') { later.closed = true; later.activity = true; }
139
+ }
140
+ if (later && row.type === 'event_msg' && payload?.type === 'agent_message') later.activity = true;
141
+ if (later && row.type === 'response_item' && !(payload?.type === 'message' && payload.role !== 'assistant')) later.activity = true;
129
142
  if (row.type === 'event_msg' && payload?.type === 'task_complete' && payload.turn_id === (turnId ?? latestTurnId)) {
130
143
  completedAt = Date.parse(row.timestamp);
131
144
  }
@@ -197,6 +210,6 @@ export function readCodexHandoffState(path, { threadId = null, turnId = null, de
197
210
  }
198
211
  return { meta, settings, rawSettings, preparedSettings, context, latestTurnId, turnStartAt, stoppedAt, stoppedReason,
199
212
  completedAt, correlatedTurnId, hasTurnInput, progress, completedTools, pendingCallCount: pendingCalls.size,
200
- pendingNativeSessionCount: nativeSessions.size, inheritedThroughlineMemory,
213
+ pendingNativeSessionCount: nativeSessions.size, inheritedThroughlineMemory, laterTurns,
201
214
  compactedRows: rows.filter(r => r.type === 'compacted').length };
202
215
  }
@@ -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',