throughline 0.15.4 → 0.16.0
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 +48 -1
- package/README.ja.md +5 -5
- package/README.md +5 -5
- package/docs/adr/0033-claude-auto-handoff-new-session.md +4 -3
- package/docs/adr/0036-first-read-retry-on-io-error.md +50 -0
- package/docs/adr/0037-auto-handoff-successor-title.md +47 -0
- package/package.json +1 -1
- package/rag/01-hooks/raw/claude-compact-hooks-extract.md +2 -1
- package/rag/01-hooks/raw/claude-desktop-session-open-extract.md +15 -0
- package/rag/INDEX.md +1 -0
- package/src/auditor-context.mjs +3 -1
- package/src/auto-handoff-title.mjs +57 -0
- package/src/auto-handoff-title.test.mjs +50 -0
- package/src/caveat-context.mjs +2 -0
- package/src/claude-auto-handoff.mjs +21 -2
- package/src/claude-auto-handoff.test.mjs +40 -3
- package/src/cli/factory-diagnostics.mjs +2 -1
- package/src/cli/recall.mjs +2 -0
- package/src/codex-auto-handoff.mjs +27 -0
- package/src/codex-auto-handoff.test.mjs +29 -1
- package/src/db-contention.test.mjs +62 -0
- package/src/db.mjs +32 -0
- package/src/hook-failure-log.mjs +3 -0
- package/src/hook-failure-log.test.mjs +13 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,51 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.16.0] — 2026-10-07
|
|
14
|
+
|
|
15
|
+
### 変更
|
|
16
|
+
|
|
17
|
+
- 自動継続が立てる後継に、`<project 名>|<作業の概要>(自動引き継ぎ)`の名前を付ける([ADR 0037](docs/adr/0037-auto-handoff-successor-title.md))。一覧を見た時に、どのprojectの何の作業の続きかを読めるようにする。
|
|
18
|
+
- Claude Code: 今までの名前は`tl-<フォルダ名>-<引き継ぎIDの先頭8桁>`で、Claude Desktopの一覧にもそのまま出ていた。作業の概要は前任の会話の題(Claude Desktopの題、人が付けた題、Claude Codeが付けた題の順)から取り、題が無ければ止めた時点の依頼の最初の行を使う。
|
|
19
|
+
- Codex: 今までは後継タスクに名前を付けておらず、継続の指示(`Throughline自動継続 <ID> / <ID>`)がそのまま題として見えていた。前任のタスクの題を概要にして、後継を作った直後に`thread/name/set`で付ける。前任がこの版より前に立てた名前の無い後継なら、引き継ぎの記録をさかのぼって元のタスクの題を使う。
|
|
20
|
+
- 概要は40字で切る。引き継ぎを重ねても、project名と印は積み重ならない。AIは呼ばない。
|
|
21
|
+
- Codexで名前を付けられなかった時も、引き継ぎは止めない(理由はworkerのログに残る)。
|
|
22
|
+
|
|
23
|
+
### 確認した範囲
|
|
24
|
+
|
|
25
|
+
- Linux(Claude Code 2.1.292、Haiku)の切り離した置き場で、本物の会話に自動圧縮と道具のhookを渡した。後継は`p1|read file fruit colors(自動引き継ぎ)`の名前で立ち、継続の指示を受け取った。その後継からもう一度引き継ぐと、3つ目も同じ名前になった。
|
|
26
|
+
- Linux(Codex 0.160.1)のapp-serverで、新しいスレッドに最初のターンの前に付けた名前が、ターンの後も残る。
|
|
27
|
+
- 公開するpackageを展開して、本物のworkerから後継を立て、`claude agents`に出る名前が渡した名前と一致することを3環境で見た(題は日本語・全角の記号・`&`・二重引用符を含む): Linux(Claude Code 2.1.292)、macOS(2.1.289)、Windows 11(2.1.292、PowerShellのshim経由、Node 24.20.0)。
|
|
28
|
+
- macOSのCodex Desktopに同梱のCodex(0.160.1)が`thread/name/set`を持つ。
|
|
29
|
+
|
|
30
|
+
### 確認していない範囲
|
|
31
|
+
|
|
32
|
+
- Codex Desktopの実機での自動継続の通し(後継タスクが一覧に新しい名前で出るところ)。確かめたのは、同じ版のCodexのapp-serverで名前が付いて残ることまで。
|
|
33
|
+
- Claude Desktopの一覧に新しい名前が出るところ。今までの名前(`tl-…`)がDesktopの題としてそのまま出ていたことは、macOSの記録で見ている。
|
|
34
|
+
|
|
35
|
+
## [0.15.5] — 2026-10-07
|
|
36
|
+
|
|
37
|
+
### 修正
|
|
38
|
+
|
|
39
|
+
- Windowsで、同じhookが続けて走る会話(Cursorが約10秒おきにターンを回す会話など)の間に、hookが`disk I/O error`で落ちることがある不具合を直す([ADR 0036](docs/adr/0036-first-read-retry-on-io-error.md))。落ちるのはDBを開いた直後の最初の読み取りで、落ちたhookはその回の記憶の注入や保存をしなかった。hookはDBを閉じずに終わる。終わったprocessの片付けと次のprocessの最初の読み取りが重なると、SQLiteがWALの索引を切り詰められずに失敗を返していた。最初の読み取りを、`disk I/O error`の間だけ25msごとに読み直す(hookは最長5秒)。
|
|
40
|
+
- 読み取り専用でDBを開く所(`handoff-context`、`latest-session`、`auto-handoff status`、`recall`、`factory-diagnostics`、`caveat-context`、Spotter向けの`auditor-context`とObserver向けの読み出し)も同じ形で落ちていたので、同じ読み直しを通す。`migrate`も通す。
|
|
41
|
+
|
|
42
|
+
### 追加
|
|
43
|
+
|
|
44
|
+
- `hook-failures.log`に、SQLiteの拡張code(`errcode`)を残す。`disk I/O error`は文面が同じでも、codeで原因が分かれる。
|
|
45
|
+
|
|
46
|
+
### 確認した範囲
|
|
47
|
+
|
|
48
|
+
- Windows 11(Node 24.20.0)の切り離した置き場。DBを開いて閉じずに終わる短命のprocessを6列で続ける再現で、直す前は5,295本のうち128本が`disk I/O error`(code 1546)で落ち、読み直しを入れると5,180本とも開けた(読み直したのは139本、最大2回、最長74ms)。読み取り専用は、直す前に4,059本のうち217本が落ちた。
|
|
49
|
+
- 同じ端末で、足した試験(240本)がこの版では2回とも通り、読み直しを外すと同じ試験が`disk I/O error`で落ちる。
|
|
50
|
+
- Linux(Node 26)では、直す前から同じ再現で1,398本とも開ける。
|
|
51
|
+
|
|
52
|
+
### 確認していない範囲
|
|
53
|
+
|
|
54
|
+
- Windowsのどの操作が失敗を返したか。SQLiteのcode(切り詰めの失敗)までで、その下は確かめていない。
|
|
55
|
+
- 2026-10-04に同じ端末で起きたSessionStartとUserPromptSubmitの失敗が、同じ形だったか(当時の版は理由を残していない)。
|
|
56
|
+
- Codexの状態DBなど、他の製品のDBを読む所(`codex-restore-source-audit`)は変えていない。
|
|
57
|
+
|
|
13
58
|
## [0.15.4] — 2026-10-06
|
|
14
59
|
|
|
15
60
|
### 修正
|
|
@@ -1831,7 +1876,9 @@ two attempts, instrument first instead of patching again.
|
|
|
1831
1876
|
|
|
1832
1877
|
---
|
|
1833
1878
|
|
|
1834
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.
|
|
1879
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.0...HEAD
|
|
1880
|
+
[0.16.0]: https://github.com/kitepon/Throughline/compare/v0.15.5...v0.16.0
|
|
1881
|
+
[0.15.5]: https://github.com/kitepon/Throughline/compare/v0.15.4...v0.15.5
|
|
1835
1882
|
[0.15.4]: https://github.com/kitepon/Throughline/compare/v0.15.3...v0.15.4
|
|
1836
1883
|
[0.15.3]: https://github.com/kitepon/Throughline/compare/v0.15.2...v0.15.3
|
|
1837
1884
|
[0.15.2]: https://github.com/kitepon/Throughline/compare/v0.15.1...v0.15.2
|
package/README.ja.md
CHANGED
|
@@ -29,7 +29,7 @@ throughline auto-handoff disable
|
|
|
29
29
|
|
|
30
30
|
既定では無効です。`enable`は必要な`PreCompact(auto)`フックを登録し、そのフックの承認と有効状態を公式APIで確認します。`--project`を省略すると全projectを対象にします。macOSのCodex Desktopで実機確認済みです。VS Code・CLI・他OSの自動継続は未検証です。
|
|
31
31
|
|
|
32
|
-
自動圧縮の開始前に旧ターンを停止し、同じproject
|
|
32
|
+
自動圧縮の開始前に旧ターンを停止し、同じprojectの新しいタスクへ記憶を注入して表示します。新しいタスクには、前任のタスクの題から`<project名>|<作業の概要>(自動引き継ぎ)`の名前を付けます。元のモデル・推論強度・権限・モードなどが表示後も一致することを確認してから、継続指示を一度だけ送ります。後継でその入力と作業の進捗を観測して完了とします。手動`/compact`は発火対象に含めません。
|
|
33
33
|
|
|
34
34
|
記憶は引き継ぎ全体の直近20ターンをL2全文、それより古いターンをL1で渡します。元のユーザー依頼と中断地点を保持し、tool結果とThinkingは注入しません。本文内の`auto-handoff detail`コマンドで、凍結したL2/L3を引き継ぎID・origin・turnから取得できます。A→B→Cの連続引き継ぎでも祖先の詳細を取得できます。
|
|
35
35
|
|
|
@@ -58,10 +58,10 @@ throughline auto-handoff disable --host claude
|
|
|
58
58
|
|
|
59
59
|
1. 自動圧縮の直前に、圧縮を止めます。`/tl`と同じ印を残します。
|
|
60
60
|
2. 旧い会話の次の道具を、実行させずに止めます。旧い会話は止めるだけで、空にしません。
|
|
61
|
-
3. 同じproject
|
|
62
|
-
4.
|
|
61
|
+
3. 同じprojectに、継続の指示を最初の指示として付けた新しいClaudeの会話を裏で立てます(`claude --bg`)。会話の名前は、前任の会話の題から`<project名>|<作業の概要>(自動引き継ぎ)`にします。モデル・推論強度・権限は旧い会話から引き継ぎます。
|
|
62
|
+
4. 最初の指示を受け取った時に、止めた時点の依頼、そのターンでここまでにしたこと(発言と道具の呼び出し)、実行されなかった道具、直近の会話の原文を注入します(上限9,500字)。止めたターンの道具の入出力は、後継が`throughline detail`で取り出せます。
|
|
63
63
|
|
|
64
|
-
続きは裏の会話で動きます。`claude agents`の一覧と`claude attach <id>`で見られます。
|
|
64
|
+
続きは裏の会話で動きます。`claude agents`の一覧と`claude attach <id>`で見られます。Desktopへ自動で表示はしません。Desktopで開く時は、作業完了を確認して`claude stop <id>`で裏の実行を停止し、`claude --desktop --resume <session-id>`を実行します。`session-id`は`claude agents --json --all`の`sessionId`です。手動`/compact`、subagentの中の圧縮、`claude -p`などscriptから起動した会話は対象に含めません。
|
|
65
65
|
|
|
66
66
|
```bash
|
|
67
67
|
throughline auto-handoff status --host claude --operation <handoff-id> --json
|
|
@@ -69,7 +69,7 @@ throughline auto-handoff status --host claude --operation <handoff-id> --json
|
|
|
69
69
|
|
|
70
70
|
後継の立ち上げや配送に失敗した時は、固定の理由を記録して止まります。結果が不明な指示は再送しません。圧縮を止めた後にモデルが道具を呼ばずにターンを終えた時は、後継を立てません。印は残るので、1時間以内に開いた新しい会話が記憶を引き継ぎます。
|
|
71
71
|
|
|
72
|
-
有効にした端末では、Claudeの道具の呼び出しのたびに`PreToolUse`フックが1回走ります。Linux・macOS・WindowsのClaude
|
|
72
|
+
有効にした端末では、Claudeの道具の呼び出しのたびに`PreToolUse`フックが1回走ります。Linux・macOS・Windowsの端末から始めた会話と、macOSのClaude Desktopの画面から始めた会話を実機確認済みです。Desktopでは、移動済みの会話の最初の後継への記憶注入と、最初からフォルダを選んだ会話の連続継承・作業完了を確認しました。[実測と検証範囲](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-05-claude-auto-handoff.md)を参照してください。
|
|
73
73
|
|
|
74
74
|
## 所有境界
|
|
75
75
|
|
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ throughline auto-handoff disable
|
|
|
28
28
|
|
|
29
29
|
既定では無効です。`enable`は必要な`PreCompact(auto)`フックを登録し、そのフックの承認と有効状態を公式APIで確認します。`--project`を省略すると全projectを対象にします。macOSのCodex Desktopで実機確認済みです。VS Code・CLI・他OSの自動継続は未検証です。
|
|
30
30
|
|
|
31
|
-
自動圧縮の開始前に旧ターンを停止し、同じproject
|
|
31
|
+
自動圧縮の開始前に旧ターンを停止し、同じprojectの新しいタスクへ記憶を注入して表示します。新しいタスクには、前任のタスクの題から`<project名>|<作業の概要>(自動引き継ぎ)`の名前を付けます。元のモデル・推論強度・権限・モードなどが表示後も一致することを確認してから、継続指示を一度だけ送ります。後継でその入力と作業の進捗を観測して完了とします。手動`/compact`は発火対象に含めません。
|
|
32
32
|
|
|
33
33
|
記憶は引き継ぎ全体の直近20ターンをL2全文、それより古いターンをL1で渡します。元のユーザー依頼と中断地点を保持し、tool結果とThinkingは注入しません。本文内の`auto-handoff detail`コマンドで、凍結したL2/L3を引き継ぎID・origin・turnから取得できます。A→B→Cの連続引き継ぎでも祖先の詳細を取得できます。
|
|
34
34
|
|
|
@@ -57,10 +57,10 @@ throughline auto-handoff disable --host claude
|
|
|
57
57
|
|
|
58
58
|
1. 自動圧縮の直前に、圧縮を止めます。`/tl`と同じ印を残します。
|
|
59
59
|
2. 旧い会話の次の道具を、実行させずに止めます。旧い会話は止めるだけで、空にしません。
|
|
60
|
-
3. 同じproject
|
|
61
|
-
4.
|
|
60
|
+
3. 同じprojectに、継続の指示を最初の指示として付けた新しいClaudeの会話を裏で立てます(`claude --bg`)。会話の名前は、前任の会話の題から`<project名>|<作業の概要>(自動引き継ぎ)`にします。モデル・推論強度・権限は旧い会話から引き継ぎます。
|
|
61
|
+
4. 最初の指示を受け取った時に、止めた時点の依頼、そのターンでここまでにしたこと(発言と道具の呼び出し)、実行されなかった道具、直近の会話の原文を注入します(上限9,500字)。止めたターンの道具の入出力は、後継が`throughline detail`で取り出せます。
|
|
62
62
|
|
|
63
|
-
続きは裏の会話で動きます。`claude agents`の一覧と`claude attach <id>`で見られます。
|
|
63
|
+
続きは裏の会話で動きます。`claude agents`の一覧と`claude attach <id>`で見られます。Desktopへ自動で表示はしません。Desktopで開く時は、作業完了を確認して`claude stop <id>`で裏の実行を停止し、`claude --desktop --resume <session-id>`を実行します。`session-id`は`claude agents --json --all`の`sessionId`です。手動`/compact`、subagentの中の圧縮、`claude -p`などscriptから起動した会話は対象に含めません。
|
|
64
64
|
|
|
65
65
|
```bash
|
|
66
66
|
throughline auto-handoff status --host claude --operation <handoff-id> --json
|
|
@@ -68,7 +68,7 @@ throughline auto-handoff status --host claude --operation <handoff-id> --json
|
|
|
68
68
|
|
|
69
69
|
後継の立ち上げや配送に失敗した時は、固定の理由を記録して止まります。結果が不明な指示は再送しません。圧縮を止めた後にモデルが道具を呼ばずにターンを終えた時は、後継を立てません。印は残るので、1時間以内に開いた新しい会話が記憶を引き継ぎます。
|
|
70
70
|
|
|
71
|
-
有効にした端末では、Claudeの道具の呼び出しのたびに`PreToolUse`フックが1回走ります。Linux・macOS・WindowsのClaude
|
|
71
|
+
有効にした端末では、Claudeの道具の呼び出しのたびに`PreToolUse`フックが1回走ります。Linux・macOS・Windowsの端末から始めた会話と、macOSのClaude Desktopの画面から始めた会話を実機確認済みです。Desktopでは、移動済みの会話の最初の後継への記憶注入と、最初からフォルダを選んだ会話の連続継承・作業完了を確認しました。[実測と検証範囲](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-05-claude-auto-handoff.md)を参照してください。
|
|
72
72
|
|
|
73
73
|
## Ownership boundary
|
|
74
74
|
|
|
@@ -92,8 +92,8 @@ Claude Desktop の新しい会話は、画面を開いただけでは process
|
|
|
92
92
|
## Consequences
|
|
93
93
|
|
|
94
94
|
- 有効な project では、Claude Code の自動圧縮は走らない。旧い会話は、止めた道具の hook の文を出して止まる。
|
|
95
|
-
- 続きは裏の会話(agent view)で動く。`claude agents` の一覧と `claude attach <id>` で見られる。
|
|
96
|
-
|
|
95
|
+
- 続きは裏の会話(agent view)で動く。`claude agents` の一覧と `claude attach <id>` で見られる。Desktopへは自動表示しない。
|
|
96
|
+
作業完了を確認して裏の実行を停止した後、`claude --desktop --resume <session-id>`で同じ会話をDesktopに開ける。
|
|
97
97
|
- `PreToolUse` hook は、有効な端末の全ての Claude の会話で、道具の呼び出しのたびに1回走る。
|
|
98
98
|
- 止めたターンは Stop hook を通らない。worker が取り込む(0.14.1)。発言が1つも無いまま止めたターンは、
|
|
99
99
|
user の行と道具の入出力だけが入る。完了の受領(Stop が出すもの)は、止めたターンには出さない。
|
|
@@ -120,4 +120,5 @@ Claude Desktop の新しい会話は、画面を開いただけでは process
|
|
|
120
120
|
旧い会話を止める・後継を立てる、の3段は通った。継続の指示は、バイパス中の後継が承認待ちで止めた(0.15.3 で渡し方を変えた理由)。
|
|
121
121
|
- 0.15.3(継続の指示を最初の指示として渡す)。macOS の端末の会話(2.1.289、Haiku 4.5)で、バイパスは4回、`acceptEdits` も4回、
|
|
122
122
|
連続で引き継いで完了。Windows 11 の端末の会話(`acceptEdits`)でも4回連続で引き継いで完了した。
|
|
123
|
-
- 0.15.
|
|
123
|
+
- 0.15.4。macOSのClaude Desktopの画面から、移動済みの会話(Opus 5.5、high、バイパス)と、最初からフォルダを選んだ会話(同モデル・強度、auto)を試した。前者は最初の後継へ記憶が入り、4個の再読を完了した。後者は3回引き継ぎ、14個を順に読み、結果を作った。圧縮・読み直し・読み飛ばし・承認待ちは無かった。試行の経緯と範囲は[実測記録](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-05-claude-auto-handoff.md#claude-desktopの画面2026-10-06)を参照する。
|
|
124
|
+
- 0.15.3以降のLinuxの実物での通し、Windowsのバイパス、Fable、subagentが動いている最中の引き継ぎは確かめていない。
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# ADR 0036: DB を開いた最初の読み取りは、`disk I/O error` の間だけ読み直す
|
|
2
|
+
|
|
3
|
+
日付: 2026-10-07
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
Windows(0.15.4、2026-10-07)で、UserPromptSubmit の hook が2回 `disk I/O error` で落ちた。落ちた位置は
|
|
8
|
+
`getDb()` の中の最初の読み取り(`db.prepare('PRAGMA journal_mode')`)だった。Cursor の会話が約10秒おきに
|
|
9
|
+
ターンを回している最中で、Cursor は同じ hook を2本ほぼ同時に起動する。
|
|
10
|
+
|
|
11
|
+
同じ端末の切り離した置き場で再現した。DB を開いて読み、**閉じずに `process.exit` で終わる**短命の process を
|
|
12
|
+
6列で続けると、5,295 本のうち 128 本(約2%)が同じ位置・同じ文面で落ちた。node:sqlite の拡張 code は
|
|
13
|
+
1546(`SQLITE_IOERR_TRUNCATE`)。読み取り専用で開いた process は、4,059 本のうち 217 本が落ちた。
|
|
14
|
+
Linux(Node 26)では 1,398 本で 0 本。process の中で開いて閉じるのを繰り返す形(閉じてから次を開く)では、
|
|
15
|
+
Windows でも 8,906 回で 0 回だった。
|
|
16
|
+
|
|
17
|
+
SQLite は WAL の索引(`-shm`)を開く時、他に接続が無いと見ると索引を 0 バイトへ切り詰める。Windows は、
|
|
18
|
+
他の process が map したままのファイルを切り詰めさせない。閉じずに終わった process の片付けと、次の process の
|
|
19
|
+
最初の読み取りが重なると、この切り詰めが断られる、と読める(Windows のどの API がどの code で断ったかは、
|
|
20
|
+
node:sqlite からは取れないので確かめていない)。Throughline の hook は DB を閉じずに `process.exit` で終わる。
|
|
21
|
+
|
|
22
|
+
失敗した読み取りを同じ接続で読み直すと、同じ再現で 5,180 本とも開けた。読み直したのは 139 本、
|
|
23
|
+
最大2回、最長 74ms だった。接続を閉じて開き直す形でも同じ結果だった(5,227 本、最長 87ms)。
|
|
24
|
+
|
|
25
|
+
2026-10-04 に同じ端末(0.12.4)で起きた SessionStart と UserPromptSubmit の失敗(ADR 0031 の Consequences で
|
|
26
|
+
「原因は分かっていない」とした分)は、UserPromptSubmit が今回と同じ記録(fingerprint)に数えられている。
|
|
27
|
+
0.12.4 は理由を端末に残さなかったので、同じ形だったとは確かめられない。
|
|
28
|
+
|
|
29
|
+
## Decision
|
|
30
|
+
|
|
31
|
+
1. `src/db.mjs` に `settleFirstRead(db, { timeoutMs })` を置く。`PRAGMA user_version` を読み、
|
|
32
|
+
SQLite の `disk I/O error`(拡張 code の下位 8 bit が 10)で断られたら 25ms 待って読み直す。
|
|
33
|
+
既定の期限は `busy_timeout` と同じ 5 秒。期限まで解けなければ、同じ失敗をそのまま返す。
|
|
34
|
+
2. 他の失敗は読み直さない。lock の待ちは今までどおり `busy_timeout` が受け持つ。
|
|
35
|
+
3. Throughline の DB を開く所は、開いた直後にここを通す: `getDb()`、`openReadOnlyDb()`、`migrateDefaultDb()`、
|
|
36
|
+
`auditor-context`(2か所)、`caveat-context`、`factory-diagnostics`、`recall`。hook の途中で呼ばれる
|
|
37
|
+
`caveat-context` と Observer 向けの読み出しは、それぞれの `busy_timeout` と同じ 1 秒を期限にする。
|
|
38
|
+
4. hook は今までどおり DB を閉じずに終わる。終わる時に閉じると、最後の接続が checkpoint と WAL の削除を
|
|
39
|
+
行い、hook の終了が遅くなる。host に止められた process は閉じられないので、閉じる側だけでは無くせない。
|
|
40
|
+
5. `hook-failures.log` に、node:sqlite が付ける拡張 code(`errcode`)を残す。`disk I/O error` は文面が同じでも
|
|
41
|
+
code で原因が分かれる。
|
|
42
|
+
|
|
43
|
+
## Consequences
|
|
44
|
+
|
|
45
|
+
- Windows で同じ hook が続けて走る会話でも、DB を開く所で落ちなくなる。重なった時は 25〜100ms ほど待つ。
|
|
46
|
+
- 本当に読めない DB(壊れたディスクなど)では、失敗が返るまで期限の分だけ待つ(hook は最長 5 秒)。
|
|
47
|
+
今までは即座に失敗していた。
|
|
48
|
+
- Codex の状態 DB など、他の製品の DB を読む所(`codex-restore-source-audit`)と、lock 用の小さな DB
|
|
49
|
+
(WAL を使わない)は変えていない。
|
|
50
|
+
- macOS と Linux では、この失敗は元から起きていない。読み取りが1回増えるだけで、動きは変わらない。
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# ADR 0037: 自動継続の後継に「project 名|作業の概要(自動引き継ぎ)」の名前を付ける
|
|
2
|
+
|
|
3
|
+
日付: 2026-10-07
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
自動継続が立てる後継は、一覧で何の project の何の続きかを読めなかった(2026-10-07、オーナーの指摘)。
|
|
8
|
+
|
|
9
|
+
- Claude Code: 後継を `claude --bg --name tl-<フォルダ名>-<引き継ぎ ID の先頭8桁>` で立てていた。Claude Desktop の一覧にも
|
|
10
|
+
この名前がそのまま題として出る(macOS で実測: `tl-tl-claude-probe-3eacc0af`)。作業の中身は名前に入らない。
|
|
11
|
+
- Codex: 後継タスクに名前を付けていなかった。Codex は名前の無いタスクを最初の user 発言で表示するので、継続の指示
|
|
12
|
+
(`Throughline自動継続 <引き継ぎ ID> / <配送 ID>`)がそのまま題として見える。macOS の実機で、自動継続が立てた
|
|
13
|
+
後継4つに名前が無いことを `session_index.jsonl` で確かめた。project 名も作業の中身も読めない。
|
|
14
|
+
|
|
15
|
+
前任の題は host が既に持っている。
|
|
16
|
+
|
|
17
|
+
- Claude Code は会話記録に題を書く。端末の会話は `ai-title`(Claude Code が付ける。変わるたびに行が増える)、
|
|
18
|
+
Claude Desktop の会話と人が名前を変えた会話は `custom-title`。`--name` で立てた会話も `custom-title` に入り、
|
|
19
|
+
`ai-title` は付かない(Linux 2.1.292、macOS の記録で確認)。
|
|
20
|
+
- Codex は `thread/read` が `name`(Desktop が付けた題、または人が付けた題)と `preview`(最初の user 発言)を返す。
|
|
21
|
+
自動継続は、前任の状態を読む時にこの値を既に取っている(`runtime.title`)。名前は `thread/name/set` で付けられ、
|
|
22
|
+
最初のターンの前に付けた名前はターンの後も残る(Linux 0.160.1 で確認)。書かれるのは `session_index.jsonl` だけで、
|
|
23
|
+
rollout には何も足されない。
|
|
24
|
+
|
|
25
|
+
## Decision
|
|
26
|
+
|
|
27
|
+
1. 後継の名前を `<project 名>|<作業の概要>(自動引き継ぎ)` にする。project 名は project の場所の最後のフォルダ名。
|
|
28
|
+
概要が取れない時は `<project 名>(自動引き継ぎ)`。形は `src/auto-handoff-title.mjs` に1か所だけ置く。
|
|
29
|
+
2. 作業の概要は前任の題から取る。AI は呼ばない(引き継ぎを遅らせず、後継を立てる前に失敗する所を増やさない)。
|
|
30
|
+
- Claude Code: 最後の `custom-title`、無ければ最後の `ai-title`、どちらも無ければ止めた時点の依頼の最初の行。
|
|
31
|
+
- Codex: 前任の `name`、無ければ `preview`。
|
|
32
|
+
3. 概要は最初の行だけを1行にして、40字で切る。制御文字と二重引用符は落とす(名前は起動の引数として渡す。
|
|
33
|
+
Windows は PowerShell の shim を通る)。
|
|
34
|
+
4. 前任がこの形の名前を持つ後継なら、中の概要だけを取り出して使う。引き継ぎを重ねても project 名と印は
|
|
35
|
+
積み重ならない。継続の指示は概要にしない。Codex で前任が名前の無い後継(この版より前に立てたもの)の時は、
|
|
36
|
+
引き継ぎの記録をさかのぼって元のタスクの題を使う。
|
|
37
|
+
5. Claude Code へは `--name=<名前>` の形で渡す。フォルダ名が `-` で始まっても option として読まれない。
|
|
38
|
+
6. Codex は、後継を作った直後、記憶を注入する前に `thread/name/set` を呼ぶ。名前は表示だけに使うので、
|
|
39
|
+
断られても引き継ぎは止めず、worker のログに理由の code を残す。
|
|
40
|
+
|
|
41
|
+
## Consequences
|
|
42
|
+
|
|
43
|
+
- 名前に引き継ぎ ID が入らなくなる。後継の ID は今までどおり記録(Claude は `successor.short_id`、Codex は
|
|
44
|
+
`target_thread_id`)と `auto-handoff status` にある。Throughline は名前で後継を探していない。
|
|
45
|
+
- 同じ会話から引き継ぎを重ねると、後継は同じ名前になる。一覧では新しい順で見分ける。
|
|
46
|
+
- 概要は前任の題を写すだけなので、題が作業の中身からずれていれば、名前もずれる。
|
|
47
|
+
- 記録の schema は変えない。
|
package/package.json
CHANGED
|
@@ -53,7 +53,8 @@
|
|
|
53
53
|
`backgrounded · <id> · <name> (idle — send a prompt to start)` と出て、指示を待つ会話が立つ(実機。SessionStart は走る)。
|
|
54
54
|
`--model`・`--effort`・`--permission-mode`・`--name`・`--settings` を受ける。`--session-id` は無視される
|
|
55
55
|
(短い ID は session id の先頭8桁)。別の background session の中から起動すると、出力の ID に色の制御文字が付く。
|
|
56
|
-
- 動いている background session は Desktop へ移せない(`claude --desktop --resume
|
|
56
|
+
- 動いている background session は Desktop へ移せない(`claude --desktop --resume`)。裏の実行を停止した後に開ける。
|
|
57
|
+
現行の公式仕様は[Desktopの会話を開く一次ソース抜粋](claude-desktop-session-open-extract.md)を参照する。
|
|
57
58
|
|
|
58
59
|
## 既存の会話へ外から送る口(inbox socket)
|
|
59
60
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Claude Code: 既存の会話をDesktopで開く
|
|
2
|
+
|
|
3
|
+
- 出典: [Desktop application — Coming from the CLI?](https://code.claude.com/docs/en/desktop#coming-from-the-cli)
|
|
4
|
+
- 取得日: 2026-10-06
|
|
5
|
+
- 確度: 公式仕様
|
|
6
|
+
|
|
7
|
+
既存のCLI会話を開く公開コマンド:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
claude --desktop --resume <session-id>
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
> Claude Code doesn’t move a session that is open in another terminal or still running in the background.
|
|
14
|
+
|
|
15
|
+
画面での確認結果は[Desktop受入記録](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-05-claude-auto-handoff.md#完了した後継をdesktopで開く)に置く。
|
package/rag/INDEX.md
CHANGED
|
@@ -29,6 +29,7 @@ rag/
|
|
|
29
29
|
|
|
30
30
|
## Claude自動継続の根拠
|
|
31
31
|
|
|
32
|
+
- [既存の会話をDesktopで開く一次ソース抜粋](01-hooks/raw/claude-desktop-session-open-extract.md) — `claude --desktop --resume <session-id>`は裏の実行を停止した会話を開く。実行中のbackground sessionは移せない。Desktop受入で最後の後継の完了報告を画面から確認した。
|
|
32
33
|
- [圧縮・会話の起動・受け口の一次ソース抜粋](01-hooks/raw/claude-compact-hooks-extract.md) — `PreCompact`は圧縮を止められる(exit code 2)が`continue`は捨てられる。`PreToolUse`の`deny`+`continue:false`で道具を実行させずにターンを止められる。`claude --bg`は指示なしで起動すると指示待ちの会話が立つ。会話ごとの受け口(inbox socket)へ外から1通送ると、入力待ちの会話でターンが始まる。[実測](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-05-claude-auto-handoff.md)で、対話の会話から後継へ3回連続で引き継ぎ、圧縮は1回も走らなかった。Finding 6・第三仮説(PreCompactでの再投影)に対する現行の答えは[ADR 0033](../docs/adr/0033-claude-auto-handoff-new-session.md)。
|
|
33
34
|
|
|
34
35
|
## Question this RAG was built to answer
|
package/src/auditor-context.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { CLAUDE_HOST, CODEX_HOST, isCodexSessionId } from './hosts/identity.mjs'
|
|
|
7
7
|
import { parseCodexRolloutFile } from './codex-rollout-memory.mjs';
|
|
8
8
|
import { getLogicalTurnGroups } from './transcript-reader.mjs';
|
|
9
9
|
import { hashAuditorBody, normalizeAuditorBody } from './body-digest.mjs';
|
|
10
|
-
import { CURRENT_VERSION } from './db.mjs';
|
|
10
|
+
import { CURRENT_VERSION, settleFirstRead } from './db.mjs';
|
|
11
11
|
|
|
12
12
|
export { hashAuditorBody, normalizeAuditorBody } from './body-digest.mjs';
|
|
13
13
|
|
|
@@ -86,6 +86,7 @@ export function readAuditorContext({
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
try {
|
|
89
|
+
settleFirstRead(db);
|
|
89
90
|
const version = Number(db.prepare('PRAGMA user_version').get()?.user_version ?? 0);
|
|
90
91
|
if (version < 9 || version > AUDITOR_CONTEXT_DB_SCHEMA_VERSION) {
|
|
91
92
|
return emptyResult('schema_mismatch', 'unsupported_db_schema', {
|
|
@@ -201,6 +202,7 @@ export function readCompletedPairProjection({
|
|
|
201
202
|
throw new AuditorContextError('E_AUDITOR_CONTEXT_DB_OPEN', 'auditor context DB could not be opened', { cause });
|
|
202
203
|
}
|
|
203
204
|
try {
|
|
205
|
+
settleFirstRead(db, { timeoutMs: OBSERVER_PROJECTION_BUSY_TIMEOUT_MS });
|
|
204
206
|
const version = Number(db.prepare('PRAGMA user_version').get()?.user_version ?? 0);
|
|
205
207
|
if (version < 9 || version > AUDITOR_CONTEXT_DB_SCHEMA_VERSION) throw new AuditorContextError('E_AUDITOR_CONTEXT_SCHEMA', 'auditor context DB schema is unsupported');
|
|
206
208
|
const session = db.prepare('SELECT session_id, project_path FROM sessions WHERE session_id = ?').get(sessionId);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 自動継続が立てる後継(Claude の会話、Codex のタスク)の名前。
|
|
3
|
+
*
|
|
4
|
+
* 形は「project 名|作業の概要(自動引き継ぎ)」。一覧を見た時に、どの project の、何の作業の続きかを読める
|
|
5
|
+
* ようにする。作業の概要は前任の題から取り、AI は呼ばない。前任の題が取れない時は project 名と印だけにする。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const AUTO_HANDOFF_TITLE_MARK = '自動引き継ぎ';
|
|
9
|
+
const SEPARATOR = '|';
|
|
10
|
+
const SUFFIX = `(${AUTO_HANDOFF_TITLE_MARK})`;
|
|
11
|
+
// 一覧の幅で末尾の印が切れにくい長さに、概要を抑える。
|
|
12
|
+
const SUMMARY_MAX_CHARS = 40;
|
|
13
|
+
// 継続の指示(Claude・Codex とも、この語と引き継ぎ ID で始まる)。名前の付いていない後継は、これが題として見える。
|
|
14
|
+
const CONTINUATION_INPUT_PATTERN = /^Throughline自動継続 [0-9a-f]{8}-/;
|
|
15
|
+
|
|
16
|
+
/** project の場所から、名前に使うフォルダ名を取る。Windows の場所を他の OS で読んでも同じ値にする。 */
|
|
17
|
+
export function autoHandoffProjectName(projectPath) {
|
|
18
|
+
if (typeof projectPath !== 'string') return '';
|
|
19
|
+
return oneLine(projectPath.split(/[\\/]+/).filter(Boolean).at(-1) ?? '');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function oneLine(text) {
|
|
23
|
+
// 起動の引数として渡すので、制御文字と改行を落とす。二重引用符は Windows の引数の受け渡しで崩れるので落とす。
|
|
24
|
+
return text.replace(/[\u0000-\u001f\u007f"]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function clip(text, maxChars) {
|
|
28
|
+
const chars = Array.from(text);
|
|
29
|
+
return chars.length <= maxChars ? text : `${chars.slice(0, maxChars - 1).join('').trimEnd()}…`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 前任の題や依頼から、作業の概要を取る。前任がこの形の名前を持つ後継なら、中の概要だけを取り出す
|
|
34
|
+
* (引き継ぎを重ねても、project 名と印が積み重ならない)。概要にならない値は null。
|
|
35
|
+
*/
|
|
36
|
+
export function autoHandoffSummaryOf(title) {
|
|
37
|
+
if (typeof title !== 'string') return null;
|
|
38
|
+
const firstLine = title.split(/\r?\n/).map(oneLine).find(Boolean) ?? '';
|
|
39
|
+
if (!firstLine || CONTINUATION_INPUT_PATTERN.test(firstLine)) return null;
|
|
40
|
+
if (firstLine.endsWith(SUFFIX)) {
|
|
41
|
+
const body = firstLine.slice(0, -SUFFIX.length);
|
|
42
|
+
const at = body.indexOf(SEPARATOR);
|
|
43
|
+
const inner = at < 0 ? '' : body.slice(at + SEPARATOR.length).trim();
|
|
44
|
+
return inner ? clip(inner, SUMMARY_MAX_CHARS) : null;
|
|
45
|
+
}
|
|
46
|
+
return clip(firstLine, SUMMARY_MAX_CHARS);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 後継の名前を作る。
|
|
51
|
+
* @param {{projectPath: string, titles?: Array<string|null|undefined>}} input titles は概要の候補(先頭から使えるものを採る)
|
|
52
|
+
*/
|
|
53
|
+
export function composeAutoHandoffTitle({ projectPath, titles = [] }) {
|
|
54
|
+
const project = autoHandoffProjectName(projectPath);
|
|
55
|
+
const summary = titles.map(autoHandoffSummaryOf).find(Boolean) ?? null;
|
|
56
|
+
return [project, summary].filter(Boolean).join(SEPARATOR) + SUFFIX;
|
|
57
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { autoHandoffProjectName, autoHandoffSummaryOf, composeAutoHandoffTitle } from './auto-handoff-title.mjs';
|
|
4
|
+
|
|
5
|
+
test('名前は「project 名|作業の概要(自動引き継ぎ)」の形にする', () => {
|
|
6
|
+
assert.equal(composeAutoHandoffTitle({ projectPath: '/Users/kite/Developer/BellTeam', titles: ['ASCからの連絡を確認'] }),
|
|
7
|
+
'BellTeam|ASCからの連絡を確認(自動引き継ぎ)');
|
|
8
|
+
assert.equal(composeAutoHandoffTitle({ projectPath: '/work/app', titles: [null, '', ' ', '2つ目の候補'] }),
|
|
9
|
+
'app|2つ目の候補(自動引き継ぎ)', '使える最初の候補を採る');
|
|
10
|
+
assert.equal(composeAutoHandoffTitle({ projectPath: '/work/app' }), 'app(自動引き継ぎ)', '概要が取れない時は project 名と印だけ');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('project 名は場所の最後のフォルダ名。Windows の場所を他の OS で読んでも同じ', () => {
|
|
14
|
+
assert.equal(autoHandoffProjectName('C:\\Users\\kite_\\Developer\\Throughline'), 'Throughline');
|
|
15
|
+
assert.equal(autoHandoffProjectName('C:\\Users\\kite_\\tl-claude-probe\\'), 'tl-claude-probe');
|
|
16
|
+
assert.equal(autoHandoffProjectName('/srv/bellteam/bots/bot-220e0f1b/'), 'bot-220e0f1b');
|
|
17
|
+
assert.equal(autoHandoffProjectName(null), '');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('前任がこの形の名前を持つ後継なら、中の概要だけを取り出す', () => {
|
|
21
|
+
const first = composeAutoHandoffTitle({ projectPath: '/work/app', titles: ['hookの失敗を直す'] });
|
|
22
|
+
assert.equal(autoHandoffSummaryOf(first), 'hookの失敗を直す');
|
|
23
|
+
assert.equal(composeAutoHandoffTitle({ projectPath: '/work/app', titles: [first] }), first, '引き継ぎを重ねても同じ名前');
|
|
24
|
+
assert.equal(composeAutoHandoffTitle({ projectPath: '/work/moved', titles: [first] }), 'moved|hookの失敗を直す(自動引き継ぎ)',
|
|
25
|
+
'project 名は今の場所から付け直す');
|
|
26
|
+
assert.equal(autoHandoffSummaryOf('app(自動引き継ぎ)'), null, '概要の無い名前からは、概要を作らない');
|
|
27
|
+
assert.equal(composeAutoHandoffTitle({ projectPath: '/work/app', titles: ['app(自動引き継ぎ)', '元の依頼'] }),
|
|
28
|
+
'app|元の依頼(自動引き継ぎ)');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('継続の指示は概要にしない(名前の無い後継は、これが題として見える)', () => {
|
|
32
|
+
const claude = 'Throughline自動継続 89903aed-a0a8-486e-9932-a10ce526d2d5\n注入された記憶と元のユーザー依頼に従い、未完了の作業をそのまま継続してください。';
|
|
33
|
+
const codex = 'Throughline自動継続 89903aed-a0a8-486e-9932-a10ce526d2d5 / b90f43ab-7056-4216-9737-a7430a6768ca\n注入された記憶と…';
|
|
34
|
+
assert.equal(autoHandoffSummaryOf(claude), null);
|
|
35
|
+
assert.equal(autoHandoffSummaryOf(codex), null);
|
|
36
|
+
assert.equal(composeAutoHandoffTitle({ projectPath: '/work/app', titles: [codex, 'ASCからの連絡を確認'] }),
|
|
37
|
+
'app|ASCからの連絡を確認(自動引き継ぎ)');
|
|
38
|
+
assert.equal(autoHandoffSummaryOf('Throughline自動継続の試験'), 'Throughline自動継続の試験', '人が付けた題は残す');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('概要は最初の行を1行にして、長さを抑える', () => {
|
|
42
|
+
assert.equal(autoHandoffSummaryOf('\n 1行目の 依頼\t文 \n2行目'), '1行目の 依頼 文');
|
|
43
|
+
assert.equal(autoHandoffSummaryOf('say "hello" & exit'), 'say hello & exit', '二重引用符は落とす');
|
|
44
|
+
const long = autoHandoffSummaryOf('あ'.repeat(100));
|
|
45
|
+
assert.equal(Array.from(long).length, 40);
|
|
46
|
+
assert.ok(long.endsWith('…'));
|
|
47
|
+
assert.equal(autoHandoffSummaryOf(long), long, '切った概要は、もう一度通しても変わらない');
|
|
48
|
+
assert.equal(Array.from(autoHandoffSummaryOf(`${'😀'.repeat(50)}`)).length, 40, '絵文字を途中で割らない');
|
|
49
|
+
assert.equal(autoHandoffSummaryOf(undefined), null);
|
|
50
|
+
});
|
package/src/caveat-context.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
import { settleFirstRead } from './db.mjs';
|
|
3
4
|
|
|
4
5
|
import {
|
|
5
6
|
AUDITOR_CONTEXT_DB_SCHEMA_VERSION,
|
|
@@ -31,6 +32,7 @@ export function readCaveatContext({
|
|
|
31
32
|
try {
|
|
32
33
|
db = new DatabaseSync(dbPath, { readOnly: true });
|
|
33
34
|
db.exec('PRAGMA busy_timeout = 1000');
|
|
35
|
+
settleFirstRead(db, { timeoutMs: 1000 });
|
|
34
36
|
const version = Number(db.prepare('PRAGMA user_version').get()?.user_version ?? 0);
|
|
35
37
|
if (version < 9 || version > AUDITOR_CONTEXT_DB_SCHEMA_VERSION) return empty('schema_mismatch');
|
|
36
38
|
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
21
21
|
import { homedir } from 'node:os';
|
|
22
|
-
import {
|
|
22
|
+
import { dirname, join, posix, resolve, win32 } from 'node:path';
|
|
23
23
|
import { randomUUID } from 'node:crypto';
|
|
24
24
|
import { fileURLToPath } from 'node:url';
|
|
25
25
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
@@ -38,6 +38,7 @@ import { resolveMergeTarget } from './session-merger.mjs';
|
|
|
38
38
|
import { writeBaton } from './baton.mjs';
|
|
39
39
|
import { sameProjectPath } from './project-path.mjs';
|
|
40
40
|
import { spawnPortable, spawnPortableSync } from './os/portable-spawn-sync.mjs';
|
|
41
|
+
import { composeAutoHandoffTitle } from './auto-handoff-title.mjs';
|
|
41
42
|
|
|
42
43
|
export const CLAUDE_AUTO_HANDOFF_SCHEMA = 'throughline.claude-auto-handoff.v2';
|
|
43
44
|
const CLI_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '../bin/throughline.mjs');
|
|
@@ -262,6 +263,17 @@ function readLatestModel(transcriptPath) {
|
|
|
262
263
|
return null;
|
|
263
264
|
}
|
|
264
265
|
|
|
266
|
+
/**
|
|
267
|
+
* 止める会話の題。人が付けた題を先に読む(Claude Desktop の題と、`--name` で付いた後継の名前もこの行に入る)。
|
|
268
|
+
* 無ければ Claude Code が付けた題を読む。
|
|
269
|
+
*/
|
|
270
|
+
function readSessionTitle(transcriptPath) {
|
|
271
|
+
const entries = readRawEntries(transcriptPath);
|
|
272
|
+
const latest = (type, key) => entries.findLast(entry => entry?.type === type &&
|
|
273
|
+
typeof entry[key] === 'string' && entry[key].trim())?.[key] ?? null;
|
|
274
|
+
return latest('custom-title', 'customTitle') ?? latest('ai-title', 'aiTitle');
|
|
275
|
+
}
|
|
276
|
+
|
|
265
277
|
const CONTINUATION_MARKER = /Throughline自動継続 ([0-9a-f-]{36})/;
|
|
266
278
|
|
|
267
279
|
// 後継へ渡す「このターンでここまでにしたこと」。1件の長さと件数を抑える。全文は取り込んだ details にある。
|
|
@@ -525,8 +537,15 @@ function readAcceptance(sessionId, handoffId, dir) {
|
|
|
525
537
|
return acceptance?.handoff_id === handoffId && typeof acceptance.accepted_at === 'number' ? acceptance : null;
|
|
526
538
|
}
|
|
527
539
|
|
|
540
|
+
/** 後継の名前。一覧で、どの project の何の作業の続きかを読めるようにする。概要は前任の題、無ければ元の依頼から取る。 */
|
|
541
|
+
function successorName(record) {
|
|
542
|
+
return composeAutoHandoffTitle({ projectPath: record.project_path,
|
|
543
|
+
titles: [record.transcript_path ? readSessionTitle(record.transcript_path) : null, record.in_flight?.user?.content] });
|
|
544
|
+
}
|
|
545
|
+
|
|
528
546
|
function successorArgs(record) {
|
|
529
|
-
|
|
547
|
+
// 名前は `--name=` の形で渡す。project のフォルダ名が `-` で始まっても、option として読まれない。
|
|
548
|
+
const args = ['--bg', `--name=${successorName(record)}`];
|
|
530
549
|
const { model, effort, permission_mode: permissionMode } = record.settings ?? {};
|
|
531
550
|
if (model) args.push('--model', model);
|
|
532
551
|
if (effort) args.push('--effort', effort);
|
|
@@ -278,9 +278,10 @@ test('worker: 継続の指示を最初の指示として付けて後継を同じ
|
|
|
278
278
|
assert.equal(calls.spawn.length, 1);
|
|
279
279
|
const { command, args, options } = calls.spawn[0];
|
|
280
280
|
assert.equal(command, 'claude');
|
|
281
|
-
assert.deepEqual(args, ['--bg', '--name',
|
|
281
|
+
assert.deepEqual(args, ['--bg', '--name=app(自動引き継ぎ)', '--effort', 'high',
|
|
282
282
|
'--permission-mode', 'acceptEdits', '--settings', '{"worktree":{"bgIsolation":"none"}}', claudeContinuationInput(record)],
|
|
283
|
-
'指示は起動時に渡す(外から送ると、権限のバイパス中の後継が承認まで止める)。モデルは transcript
|
|
283
|
+
'指示は起動時に渡す(外から送ると、権限のバイパス中の後継が承認まで止める)。モデルは transcript が無いので付かない。' +
|
|
284
|
+
'名前は、前任の題も依頼も読めないので project 名と印だけ');
|
|
284
285
|
assert.match(args.at(-1), new RegExp(`^Throughline自動継続 ${record.handoff_id}\\n注入された記憶と元のユーザー依頼に従い、未完了の作業をそのまま継続してください。`));
|
|
285
286
|
assert.equal(options.cwd, '/work/app');
|
|
286
287
|
assert.deepEqual(options.env, { PATH: '/bin', CLAUDE_CODE_USE_BEDROCK: '1' }, '会話ごとの環境変数は後継へ渡さない');
|
|
@@ -334,7 +335,8 @@ test('worker: 止めた時点の依頼とモデルを transcript から写し、
|
|
|
334
335
|
assert.equal(first.record.state, 'sent');
|
|
335
336
|
assert.equal(first.record.successor.short_id, 'a41a97ae');
|
|
336
337
|
assert.equal(first.record.settings.model, 'claude-opus-5');
|
|
337
|
-
assert.deepEqual(first.calls[0].slice(
|
|
338
|
+
assert.deepEqual(first.calls[0].slice(1, 4), ['--name=app|14個のファイルを順に読んで(自動引き継ぎ)', '--model', 'claude-opus-5'],
|
|
339
|
+
'前任に題が無い時は、元の依頼を名前の概要にする');
|
|
338
340
|
assert.deepEqual(first.record.in_flight, {
|
|
339
341
|
user: { content: '14個のファイルを順に読んで', timestamp: Date.parse('2026-10-04T03:00:10Z') },
|
|
340
342
|
last_fragment: { content: 'part05 を読みました。次に part06 を読みます。', timestamp: Date.parse('2026-10-04T03:00:14Z') },
|
|
@@ -351,6 +353,8 @@ test('worker: 止めた時点の依頼とモデルを transcript から写し、
|
|
|
351
353
|
// 後継 B が止められた時、最後の user 発言は A からの継続の指示。現在地には元の依頼を運ぶ
|
|
352
354
|
const transcriptB = join(dir, 'b.jsonl');
|
|
353
355
|
writeFileSync(transcriptB, jsonl([
|
|
356
|
+
// `--name` で立てた会話には、Claude Code が名前を題として書く
|
|
357
|
+
{ type: 'custom-title', customTitle: first.calls[0][1].slice('--name='.length), sessionId: SUCCESSOR },
|
|
354
358
|
user(claudeContinuationInput(first.record), '2026-10-04T03:01:00Z'),
|
|
355
359
|
assistant('part09 を読みました。次に part10 を読みます。', '2026-10-04T03:01:20Z', 'claude-opus-5'),
|
|
356
360
|
{ type: 'assistant', message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'tool_use', id: 'toolu_10', name: 'Read', input: {} }] } },
|
|
@@ -365,6 +369,39 @@ test('worker: 止めた時点の依頼とモデルを transcript から写し、
|
|
|
365
369
|
stopped_tools_total: 1,
|
|
366
370
|
turn: null,
|
|
367
371
|
});
|
|
372
|
+
assert.equal(second.calls[0][1], first.calls[0][1], '引き継ぎを重ねても、project 名と印は積み重ならない');
|
|
373
|
+
}));
|
|
374
|
+
|
|
375
|
+
test('worker: 後継の名前は project 名・前任の題・自動引き継ぎの印で作る。人が付けた題を Claude Code の題より先に使う', () => withDir(async dir => {
|
|
376
|
+
const nameFor = async (sessionId, titleLines) => {
|
|
377
|
+
const transcriptPath = join(dir, `${sessionId}.jsonl`);
|
|
378
|
+
writeFileSync(transcriptPath, jsonl([
|
|
379
|
+
user('14個のファイルを順に読んで', '2026-10-04T03:00:10Z'),
|
|
380
|
+
...titleLines,
|
|
381
|
+
assistant('part04 を読みました。', '2026-10-04T03:00:12Z'),
|
|
382
|
+
]));
|
|
383
|
+
const db = makeBatonDb();
|
|
384
|
+
requestClaudeAutoHandoff({ payload: { session_id: sessionId, trigger: 'auto', cwd: '/work/app', transcript_path: transcriptPath },
|
|
385
|
+
env: {}, config: ENABLED, openDb: () => db, dir, platform: 'linux' });
|
|
386
|
+
await stopClaudeTurnForHandoff({ payload: { session_id: sessionId, cwd: '/work/app', transcript_path: transcriptPath },
|
|
387
|
+
dir, launchWorker: async () => {} });
|
|
388
|
+
let name = null;
|
|
389
|
+
await runClaudeAutoHandoffWorker(sessionId, { dir, env: {}, pollMs: 10, acceptTimeoutMs: 300, transcriptTimeoutMs: 50,
|
|
390
|
+
spawn: (command, args) => { name = args[1]; successorStarts(dir, sessionId)(args); return launchedOk(); } });
|
|
391
|
+
return name;
|
|
392
|
+
};
|
|
393
|
+
// 端末の会話: Claude Code が付けた題(新しい方)
|
|
394
|
+
assert.equal(await nameFor('aaaaaaaa-0000-4000-8000-000000000001', [
|
|
395
|
+
{ type: 'ai-title', aiTitle: 'ファイルの読み込み' }, { type: 'ai-title', aiTitle: '14ファイル順序読み込みと結果記録' },
|
|
396
|
+
]), '--name=app|14ファイル順序読み込みと結果記録(自動引き継ぎ)');
|
|
397
|
+
// Claude Desktop の会話と、人が名前を変えた会話: 題は custom-title に入る
|
|
398
|
+
assert.equal(await nameFor('aaaaaaaa-0000-4000-8000-000000000002', [
|
|
399
|
+
{ type: 'custom-title', customTitle: 'MCP共有ラッパー設計' }, { type: 'ai-title', aiTitle: '別の題' },
|
|
400
|
+
]), '--name=app|MCP共有ラッパー設計(自動引き継ぎ)');
|
|
401
|
+
// 改行・二重引用符・長い題は、1行の引数として渡せる形にする
|
|
402
|
+
assert.equal(await nameFor('aaaaaaaa-0000-4000-8000-000000000003', [
|
|
403
|
+
{ type: 'custom-title', customTitle: `"結果" を\n${'長'.repeat(60)}` },
|
|
404
|
+
]), '--name=app|結果 を(自動引き継ぎ)');
|
|
368
405
|
}));
|
|
369
406
|
|
|
370
407
|
function makeMemoryDb() {
|
|
@@ -6,7 +6,7 @@ import { defaultAuditorContextDbPath } from '../auditor-context.mjs';
|
|
|
6
6
|
import { buildFactoryDiagnostics } from '../factory-diagnostics.mjs';
|
|
7
7
|
import { findCodexThreadCandidate, defaultCodexHome } from '../codex-thread-index.mjs';
|
|
8
8
|
import { resolveCodexThreadIdentity } from '../codex-thread-identity.mjs';
|
|
9
|
-
import { CURRENT_VERSION } from '../db.mjs';
|
|
9
|
+
import { CURRENT_VERSION, settleFirstRead } from '../db.mjs';
|
|
10
10
|
import { isEquivalentCodexHookCommand } from './install.mjs';
|
|
11
11
|
import { _internal as doctorInternal } from './doctor.mjs';
|
|
12
12
|
|
|
@@ -51,6 +51,7 @@ export function inspectFactoryDatabase({
|
|
|
51
51
|
let db;
|
|
52
52
|
try {
|
|
53
53
|
db = new DatabaseSync(dbPath, { readOnly: true });
|
|
54
|
+
settleFirstRead(db);
|
|
54
55
|
const schemaVersion = Number(db.prepare('PRAGMA user_version').get()?.user_version ?? 0);
|
|
55
56
|
if (schemaVersion !== CURRENT_VERSION || !hasFactoryDatabaseShape(db)) {
|
|
56
57
|
return databaseResult('not_ready', schemaVersion, false);
|
package/src/cli/recall.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { existsSync } from 'node:fs';
|
|
|
23
23
|
import { homedir } from 'node:os';
|
|
24
24
|
import { join } from 'node:path';
|
|
25
25
|
import { DatabaseSync } from 'node:sqlite';
|
|
26
|
+
import { settleFirstRead } from '../db.mjs';
|
|
26
27
|
import { formatTime } from '../handoff-record.mjs';
|
|
27
28
|
import { groupL3ByTurn, buildPartsSummary } from '../l3-summary.mjs';
|
|
28
29
|
|
|
@@ -267,6 +268,7 @@ export function run(argv) {
|
|
|
267
268
|
}
|
|
268
269
|
|
|
269
270
|
try {
|
|
271
|
+
settleFirstRead(db);
|
|
270
272
|
const result = runRecall(db, opts);
|
|
271
273
|
process.stdout.write(`${result.text}\n`);
|
|
272
274
|
return 0;
|
|
@@ -18,6 +18,7 @@ import { ensureAutoHandoffSummaries, renderAutoHandoffMemory } from './codex-aut
|
|
|
18
18
|
import { CODEX_NATIVE_ID_PATTERN, CodexHandoffError, readCodexHandoffState,
|
|
19
19
|
settingsMatch, threadStartSettings } from './hosts/codex-handoff-state.mjs';
|
|
20
20
|
import { resolvePreparedSettings } from './hosts/codex-handoff-state.mjs';
|
|
21
|
+
import { composeAutoHandoffTitle } from './auto-handoff-title.mjs';
|
|
21
22
|
|
|
22
23
|
const CLI_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '../bin/throughline.mjs');
|
|
23
24
|
const stateRoot = () => join(homedir(), '.throughline', 'codex-auto-handoff');
|
|
@@ -120,6 +121,30 @@ async function sourceNativeState(operation, runtime) {
|
|
|
120
121
|
}, runtime);
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
/**
|
|
125
|
+
* 後継タスクの名前。一覧で、どの project の何の作業の続きかを読めるようにする。概要は前任の題から取る。
|
|
126
|
+
* 前任が名前の無い後継(題が継続の指示になっている)なら、引き継ぎをさかのぼって元のタスクの題を使う。
|
|
127
|
+
*/
|
|
128
|
+
export function autoHandoffTargetName(db, operation) {
|
|
129
|
+
const titles = [];
|
|
130
|
+
for (let current = operation, depth = 0; current && depth < 20; depth++) {
|
|
131
|
+
titles.push(current.runtime?.title);
|
|
132
|
+
current = current.previous_handoff_id ? getAutoHandoff(db, current.previous_handoff_id) : null;
|
|
133
|
+
}
|
|
134
|
+
return composeAutoHandoffTitle({ projectPath: operation.project_path, titles });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 後継タスクに名前を付ける。名前は表示だけに使うので、付けられなくても引き継ぎは止めない(理由は worker のログに残す)。 */
|
|
138
|
+
export async function nameAutoHandoffTarget(request, targetId, name) {
|
|
139
|
+
try {
|
|
140
|
+
await request('thread/name/set', { threadId: targetId, name });
|
|
141
|
+
return true;
|
|
142
|
+
} catch (cause) {
|
|
143
|
+
process.stderr.write(`[auto-handoff] thread/name/set failed: ${cause?.delivery_code ?? cause?.code ?? 'unknown'}\n`);
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
123
148
|
async function createTarget(db, operation, runtime) {
|
|
124
149
|
const targetId = await withCodexReceiver(autoHandoffDeliveryProfile, { thread_id: operation.source_thread_id, codex_home: operation.codex_home },
|
|
125
150
|
async request => {
|
|
@@ -133,6 +158,8 @@ async function createTarget(db, operation, runtime) {
|
|
|
133
158
|
if (!CODEX_NATIVE_ID_PATTERN.test(targetId ?? '') || !sameProjectPath(start.cwd, operation.project_path)) throw error('handoff_target_response_invalid');
|
|
134
159
|
updateAutoHandoff(db, operation.handoff_id, { target_thread_id: targetId, mutation_stage: 'inject' });
|
|
135
160
|
}
|
|
161
|
+
// 最初のターンの前に付ける。付けないと、継続の指示がそのままタスクの題として見える。
|
|
162
|
+
await nameAutoHandoffTarget(request, targetId, autoHandoffTargetName(db, operation));
|
|
136
163
|
if (!operation.runtime.targetMemoryInjected) {
|
|
137
164
|
const text = renderAutoHandoffMemory(db, operation);
|
|
138
165
|
await request('thread/inject_items', { threadId: targetId, items: [{ type: 'message', role: 'developer',
|
|
@@ -10,7 +10,8 @@ import { requestAutoHandoff, updateAutoHandoff, getAutoHandoff, freezeCodexMemor
|
|
|
10
10
|
import { collectAutoHandoffMemory, ensureAutoHandoffSummaries, renderAutoHandoffMemory,
|
|
11
11
|
renderFrozenDetail } from './codex-auto-handoff-memory.mjs';
|
|
12
12
|
import { readCodexHandoffState, settingsMatch, threadStartSettings } from './hosts/codex-handoff-state.mjs';
|
|
13
|
-
import { runAutoHandoffWorker, requestCodexAutoHandoff, sourceBoundary
|
|
13
|
+
import { runAutoHandoffWorker, requestCodexAutoHandoff, sourceBoundary,
|
|
14
|
+
autoHandoffTargetName, nameAutoHandoffTarget } from './codex-auto-handoff.mjs';
|
|
14
15
|
import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
|
|
15
16
|
|
|
16
17
|
async function withDb(fn) {
|
|
@@ -291,3 +292,30 @@ test('forkした子の先頭metadataを保持し、継承された親metadataで
|
|
|
291
292
|
assert.equal(state.stoppedAt, state.turnStartAt);
|
|
292
293
|
assert.throws(() => readCodexHandoffState(file, { threadId: 'parent' }), /handoff_thread_mismatch/);
|
|
293
294
|
}));
|
|
295
|
+
|
|
296
|
+
test('後継タスクの名前は project 名・前任の題・自動引き継ぎの印で作り、名前の無い後継からは元のタスクの題をさかのぼる', async () => withDb(async db => {
|
|
297
|
+
const named = (operation, title) => updateAutoHandoff(db, operation.handoff_id, { runtime_json: { projectId: null, title } }).operation;
|
|
298
|
+
const a = named(request(db, 'A', { projectPath: '/Users/kite/Developer/BellTeam' }).operation, 'ASCからの連絡を確認');
|
|
299
|
+
assert.equal(autoHandoffTargetName(db, a), 'BellTeam|ASCからの連絡を確認(自動引き継ぎ)');
|
|
300
|
+
|
|
301
|
+
// A の後継 B は、名前を付けない版が作った。題は継続の指示として見える
|
|
302
|
+
updateAutoHandoff(db, a.handoff_id, { target_thread_id: 'B' });
|
|
303
|
+
const b = named(request(db, 'B', { projectPath: '/Users/kite/Developer/BellTeam' }).operation, continuationInput(a));
|
|
304
|
+
assert.equal(b.previous_handoff_id, a.handoff_id);
|
|
305
|
+
assert.equal(autoHandoffTargetName(db, b), 'BellTeam|ASCからの連絡を確認(自動引き継ぎ)');
|
|
306
|
+
|
|
307
|
+
// B の後継 C は、この形の名前を持つ。重ねても同じ名前になる
|
|
308
|
+
updateAutoHandoff(db, b.handoff_id, { target_thread_id: 'C' });
|
|
309
|
+
const c = named(request(db, 'C', { projectPath: '/Users/kite/Developer/BellTeam' }).operation, autoHandoffTargetName(db, b));
|
|
310
|
+
assert.equal(autoHandoffTargetName(db, c), 'BellTeam|ASCからの連絡を確認(自動引き継ぎ)');
|
|
311
|
+
|
|
312
|
+
// 題がどこにも無い時は project 名と印だけ
|
|
313
|
+
const lone = named(request(db, 'D', { projectPath: 'C:\\Users\\kite_\\Throughline' }).operation, '');
|
|
314
|
+
assert.equal(autoHandoffTargetName(db, lone), 'Throughline(自動引き継ぎ)');
|
|
315
|
+
|
|
316
|
+
const sent = [];
|
|
317
|
+
assert.equal(await nameAutoHandoffTarget(async (method, params) => { sent.push([method, params]); return {}; }, 'T', 'BellTeam(自動引き継ぎ)'), true);
|
|
318
|
+
assert.deepEqual(sent, [['thread/name/set', { threadId: 'T', name: 'BellTeam(自動引き継ぎ)' }]]);
|
|
319
|
+
const rejected = async () => { throw Object.assign(new Error('unknown method'), { delivery_code: 'CODEX_RECEIVER_REJECTED' }); };
|
|
320
|
+
assert.equal(await nameAutoHandoffTarget(rejected, 'T', 'x'), false, '名前を付けられなくても、引き継ぎは止めない');
|
|
321
|
+
}));
|
|
@@ -5,6 +5,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
|
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
import { settleFirstRead } from './db.mjs';
|
|
8
9
|
|
|
9
10
|
const DB_MODULE_URL = pathToFileURL(fileURLToPath(new URL('./db.mjs', import.meta.url))).href;
|
|
10
11
|
|
|
@@ -120,3 +121,64 @@ test('getDbは、新しいDBを複数のprocessが同時に開いても、全員
|
|
|
120
121
|
}
|
|
121
122
|
}
|
|
122
123
|
});
|
|
124
|
+
|
|
125
|
+
test('settleFirstReadは、disk I/O errorの間だけ最初の読み取りを読み直す', () => {
|
|
126
|
+
const ioError = Object.assign(new Error('disk I/O error'), { errcode: 1546 });
|
|
127
|
+
let calls = 0;
|
|
128
|
+
settleFirstRead({
|
|
129
|
+
prepare() {
|
|
130
|
+
calls += 1;
|
|
131
|
+
if (calls <= 2) throw ioError;
|
|
132
|
+
return { get: () => ({ user_version: 12 }) };
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
assert.equal(calls, 3);
|
|
136
|
+
|
|
137
|
+
// 他の失敗は読み直さず、そのまま返す。lock の待ちは busy_timeout が受け持つ。
|
|
138
|
+
const locked = Object.assign(new Error('database is locked'), { errcode: 5 });
|
|
139
|
+
let lockedCalls = 0;
|
|
140
|
+
assert.throws(() => settleFirstRead({ prepare() { lockedCalls += 1; throw locked; } }), /database is locked/u);
|
|
141
|
+
assert.equal(lockedCalls, 1);
|
|
142
|
+
|
|
143
|
+
// 期限まで解けなければ、同じ失敗を返す。
|
|
144
|
+
let persistentCalls = 0;
|
|
145
|
+
assert.throws(
|
|
146
|
+
() => settleFirstRead({ prepare() { persistentCalls += 1; throw ioError; } }, { timeoutMs: 60 }),
|
|
147
|
+
/disk I\/O error/u,
|
|
148
|
+
);
|
|
149
|
+
assert.ok(persistentCalls >= 2, `calls ${persistentCalls}`);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('getDbとopenReadOnlyDbは、DBを閉じずに終わる短命のprocessが続いても開ける', async () => {
|
|
153
|
+
// hookはDBを閉じずに終わる。Windowsでは、終わったprocessの片付けと次のprocessの最初の読み取りが重なると、
|
|
154
|
+
// SQLiteが `disk I/O error` を返した(修理前は実機で、240本のうち5本以上が落ちる割合)。他のOSでは元から起きない。
|
|
155
|
+
const home = mkdtempSync(join(tmpdir(), 'throughline-db-unclosed-'));
|
|
156
|
+
const env = { ...process.env, HOME: home, USERPROFILE: home };
|
|
157
|
+
const lanes = 6;
|
|
158
|
+
const perLane = 40;
|
|
159
|
+
const failures = [];
|
|
160
|
+
try {
|
|
161
|
+
const initialize = spawn(process.execPath, ['--input-type=module', '-e', `
|
|
162
|
+
import { getDb } from ${JSON.stringify(DB_MODULE_URL)};
|
|
163
|
+
getDb().close();
|
|
164
|
+
`], { env, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
165
|
+
assert.equal((await waitForExit(initialize)).code, 0);
|
|
166
|
+
|
|
167
|
+
await Promise.all(Array.from({ length: lanes }, async (_, lane) => {
|
|
168
|
+
for (let index = 0; index < perLane; index += 1) {
|
|
169
|
+
const open = (lane + index) % 2 === 0 ? 'getDb()' : 'openReadOnlyDb()';
|
|
170
|
+
const child = spawn(process.execPath, ['--input-type=module', '-e', `
|
|
171
|
+
import { getDb, openReadOnlyDb } from ${JSON.stringify(DB_MODULE_URL)};
|
|
172
|
+
const db = ${open};
|
|
173
|
+
db.prepare('SELECT count(*) AS n FROM sessions').get();
|
|
174
|
+
process.exit(0);
|
|
175
|
+
`], { env, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
176
|
+
const result = await waitForExit(child);
|
|
177
|
+
if (result.code !== 0) failures.push({ open, ...result });
|
|
178
|
+
}
|
|
179
|
+
}));
|
|
180
|
+
assert.deepEqual(failures, []);
|
|
181
|
+
} finally {
|
|
182
|
+
rmSync(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
183
|
+
}
|
|
184
|
+
});
|
package/src/db.mjs
CHANGED
|
@@ -18,6 +18,7 @@ export function openReadOnlyDb(path = DB_PATH) {
|
|
|
18
18
|
const db = new DatabaseSync(path, { readOnly: true });
|
|
19
19
|
try {
|
|
20
20
|
db.exec(`PRAGMA busy_timeout = ${DB_BUSY_TIMEOUT_MS}`);
|
|
21
|
+
settleFirstRead(db);
|
|
21
22
|
return db;
|
|
22
23
|
} catch (error) {
|
|
23
24
|
db.close();
|
|
@@ -32,10 +33,39 @@ function isSqliteBusy(error) {
|
|
|
32
33
|
return /database is locked/u.test(String(error?.message ?? ''));
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
function isSqliteIoError(error) {
|
|
37
|
+
if (typeof error?.errcode === 'number') return (error.errcode & 0xff) === 10;
|
|
38
|
+
return /disk I\/O error/u.test(String(error?.message ?? ''));
|
|
39
|
+
}
|
|
40
|
+
|
|
35
41
|
function sleepSync(ms) {
|
|
36
42
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
/**
|
|
46
|
+
* 開いた接続の最初の読み取りを済ませる。`disk I/O error` の間は 25ms ごとに読み直す (ADR 0036)。
|
|
47
|
+
*
|
|
48
|
+
* Windows では、DB を閉じずに終わった process の片付けと、次の process の最初の読み取りが重なると、
|
|
49
|
+
* SQLite が WAL の索引 (-shm) を切り詰められず `disk I/O error` (SQLITE_IOERR_TRUNCATE) を返す。
|
|
50
|
+
* hook は DB を閉じずに終わるので、同じ hook が続けて走る会話で起きる。重なりは 100ms ほどで解ける。
|
|
51
|
+
* 最初の読み取りが通った接続は索引を持ち続けるので、後の読み書きではこの形にならない。
|
|
52
|
+
* 読み取り専用の接続でも起きるので、Throughline の DB を開く所は全部ここを通す。
|
|
53
|
+
* @param {DatabaseSync} db
|
|
54
|
+
* @param {{ timeoutMs?: number }} [options]
|
|
55
|
+
*/
|
|
56
|
+
export function settleFirstRead(db, { timeoutMs = DB_BUSY_TIMEOUT_MS } = {}) {
|
|
57
|
+
const deadline = Date.now() + timeoutMs;
|
|
58
|
+
for (;;) {
|
|
59
|
+
try {
|
|
60
|
+
db.prepare('PRAGMA user_version').get();
|
|
61
|
+
return;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (!isSqliteIoError(error) || Date.now() >= deadline) throw error;
|
|
64
|
+
sleepSync(WAL_SWITCH_RETRY_MS);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
39
69
|
/**
|
|
40
70
|
* journal mode を WAL にする。既に WAL なら何も書かない。
|
|
41
71
|
*
|
|
@@ -395,6 +425,7 @@ export function migrateDefaultDb() {
|
|
|
395
425
|
try {
|
|
396
426
|
db = new DatabaseSync(DB_PATH);
|
|
397
427
|
db.exec(`PRAGMA busy_timeout = ${DB_BUSY_TIMEOUT_MS}`);
|
|
428
|
+
settleFirstRead(db);
|
|
398
429
|
db.exec('PRAGMA foreign_keys = ON');
|
|
399
430
|
|
|
400
431
|
const beforeSchemaVersion = Number(db.prepare('PRAGMA user_version').get().user_version ?? 0);
|
|
@@ -452,6 +483,7 @@ export function getDb() {
|
|
|
452
483
|
const db = new DatabaseSync(DB_PATH);
|
|
453
484
|
try {
|
|
454
485
|
db.exec(`PRAGMA busy_timeout = ${DB_BUSY_TIMEOUT_MS}`);
|
|
486
|
+
settleFirstRead(db);
|
|
455
487
|
ensureWalJournalMode(db);
|
|
456
488
|
db.exec('PRAGMA foreign_keys = ON');
|
|
457
489
|
initSchema(db);
|
package/src/hook-failure-log.mjs
CHANGED
|
@@ -52,6 +52,9 @@ export function logHookFailure(code, error, { home, now = Date.now(), stderr = p
|
|
|
52
52
|
// どの処理で落ちたかが分からない(2026-10-04、Windows の SessionStart と UserPromptSubmit の失敗)。
|
|
53
53
|
const frames = stackFrames(error);
|
|
54
54
|
if (frames.length > 0) entry.stack = frames;
|
|
55
|
+
// SQLite の失敗は、文面が同じでも番号で原因が分かれる(`disk I/O error` は 1546 なら索引の切り詰め、
|
|
56
|
+
// 522 なら読み取り)。node:sqlite が付ける拡張 code を残す。
|
|
57
|
+
if (typeof error?.errcode === 'number') entry.errcode = error.errcode;
|
|
55
58
|
// どの会話の失敗かを残す(errorが `hookContext` を持つ時)。回数と文面だけでは、失敗した会話を
|
|
56
59
|
// 端末の他の記録から探すことになる。
|
|
57
60
|
const context = error?.hookContext;
|
|
@@ -95,6 +95,19 @@ test('logHookFailureは、失敗した位置をstackの先頭から残す', () =
|
|
|
95
95
|
}
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
test('logHookFailureは、SQLiteの拡張codeを残す', () => {
|
|
99
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
|
|
100
|
+
try {
|
|
101
|
+
logHookFailure('HOOK_PROMPT_SUBMIT_FAILED', Object.assign(new Error('disk I/O error'), { errcode: 1546 }), { home });
|
|
102
|
+
logHookFailure('HOOK_PROMPT_SUBMIT_FAILED', new Error('no errcode'), { home });
|
|
103
|
+
const [first, second] = readFileSync(hookFailureLogPath({ home }), 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
104
|
+
assert.equal(first.errcode, 1546);
|
|
105
|
+
assert.equal('errcode' in second, false);
|
|
106
|
+
} finally {
|
|
107
|
+
rmSync(home, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
98
111
|
test('logHookFailureは、長い文面を上限で切る', () => {
|
|
99
112
|
const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
|
|
100
113
|
try {
|