throughline 0.15.3 → 0.15.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -1
- package/README.ja.md +4 -4
- package/README.md +4 -4
- package/docs/adr/0033-claude-auto-handoff-new-session.md +5 -3
- package/docs/adr/0036-first-read-retry-on-io-error.md +50 -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/caveat-context.mjs +2 -0
- package/src/claude-auto-handoff.mjs +10 -2
- package/src/claude-auto-handoff.test.mjs +60 -1
- package/src/cli/factory-diagnostics.mjs +2 -1
- package/src/cli/recall.mjs +2 -0
- 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,47 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.15.5] — 2026-10-07
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- 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秒)。
|
|
18
|
+
- 読み取り専用でDBを開く所(`handoff-context`、`latest-session`、`auto-handoff status`、`recall`、`factory-diagnostics`、`caveat-context`、Spotter向けの`auditor-context`とObserver向けの読み出し)も同じ形で落ちていたので、同じ読み直しを通す。`migrate`も通す。
|
|
19
|
+
|
|
20
|
+
### 追加
|
|
21
|
+
|
|
22
|
+
- `hook-failures.log`に、SQLiteの拡張code(`errcode`)を残す。`disk I/O error`は文面が同じでも、codeで原因が分かれる。
|
|
23
|
+
|
|
24
|
+
### 確認した範囲
|
|
25
|
+
|
|
26
|
+
- 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本が落ちた。
|
|
27
|
+
- 同じ端末で、足した試験(240本)がこの版では2回とも通り、読み直しを外すと同じ試験が`disk I/O error`で落ちる。
|
|
28
|
+
- Linux(Node 26)では、直す前から同じ再現で1,398本とも開ける。
|
|
29
|
+
|
|
30
|
+
### 確認していない範囲
|
|
31
|
+
|
|
32
|
+
- Windowsのどの操作が失敗を返したか。SQLiteのcode(切り詰めの失敗)までで、その下は確かめていない。
|
|
33
|
+
- 2026-10-04に同じ端末で起きたSessionStartとUserPromptSubmitの失敗が、同じ形だったか(当時の版は理由を残していない)。
|
|
34
|
+
- Codexの状態DBなど、他の製品のDBを読む所(`codex-restore-source-audit`)は変えていない。
|
|
35
|
+
|
|
36
|
+
## [0.15.4] — 2026-10-06
|
|
37
|
+
|
|
38
|
+
### 修正
|
|
39
|
+
|
|
40
|
+
- Claude Desktopで別のprojectへ移した会話を、移った後に1回もターンを終えないまま自動継続で引き継ぐと、後継へ記憶が入らない不具合を直す(0.15.2・0.15.3)。後継の最初の指示は、前任と後継のprojectが同じ時だけ前任を合流させる。前任の`sessions.project_path`を移った先へ付け替えるのはターン終了(Stop)だけだったので、その前に引き継ぐと移る前の場所のまま残り、合流が`project_mismatch`で見送られていた。後継は継続の指示だけを受け取り、記憶と止めた時点の依頼を持たずに動き出していた。
|
|
41
|
+
- 自動圧縮を止める時(`pre-compact`)にも、移っている会話の`sessions.project_path`を移った先へ付け替える。
|
|
42
|
+
|
|
43
|
+
### 確認した範囲
|
|
44
|
+
|
|
45
|
+
- macOS、Claude Desktop(同梱の2.1.286、権限はバイパス、0.15.3)。画面から送った指示で、圧縮を止める・旧い会話を止める・後継を立てる・継続の指示が届く、の4段が通り、承認待ちは出なかった。1つ目の後継には記憶が入らず(上の不具合)、後継は自分でフォルダを調べて作業を続け、次の引き継ぎ(後継から後継、記憶あり)を経て作業を完了した。
|
|
46
|
+
- macOS、同じ会話の本物のtranscriptの写しを使い、切り離したHOMEでhookを手で呼ぶ再現。0.15.3は`pre-compact`の後も`sessions.project_path`が移る前の場所で、後継の最初の指示は`project_mismatch`・注入なし。この版は移った先へ付け替わり、合流して注入する。
|
|
47
|
+
|
|
48
|
+
### 確認していない範囲
|
|
49
|
+
|
|
50
|
+
- Claude Desktopの画面で、この版の1つ目の後継に記憶が入ること。
|
|
51
|
+
- Linuxで実物のClaude Codeを動かした通し。Windowsのバイパスの会話、Opus・Fableの後継(0.15.3と同じ)。
|
|
52
|
+
|
|
53
|
+
|
|
13
54
|
## [0.15.3] — 2026-10-06
|
|
14
55
|
|
|
15
56
|
### 修正
|
|
@@ -1813,7 +1854,9 @@ two attempts, instrument first instead of patching again.
|
|
|
1813
1854
|
|
|
1814
1855
|
---
|
|
1815
1856
|
|
|
1816
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.15.
|
|
1857
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.15.5...HEAD
|
|
1858
|
+
[0.15.5]: https://github.com/kitepon/Throughline/compare/v0.15.4...v0.15.5
|
|
1859
|
+
[0.15.4]: https://github.com/kitepon/Throughline/compare/v0.15.3...v0.15.4
|
|
1817
1860
|
[0.15.3]: https://github.com/kitepon/Throughline/compare/v0.15.2...v0.15.3
|
|
1818
1861
|
[0.15.2]: https://github.com/kitepon/Throughline/compare/v0.15.1...v0.15.2
|
|
1819
1862
|
[0.15.1]: https://github.com/kitepon/Throughline/compare/v0.15.0...v0.15.1
|
package/README.ja.md
CHANGED
|
@@ -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`)。モデル・推論強度・権限は旧い会話から引き継ぎます。
|
|
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
|
@@ -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`)。モデル・推論強度・権限は旧い会話から引き継ぎます。
|
|
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
|
|
|
@@ -33,6 +33,7 @@ Claude Desktop の新しい会話は、画面を開いただけでは process
|
|
|
33
33
|
印の project は、会話を起動した場所(`CLAUDE_PROJECT_DIR`)を、その OS の書き方にそろえたもの(0.14.2)。
|
|
34
34
|
Claude Desktop で会話を別の project へ移している時は、transcript の `relocated` の行(`relocatedCwd`)が示す移った先を使う。
|
|
35
35
|
`CLAUDE_PROJECT_DIR` は移る前の場所のまま届くため(0.15.2)。
|
|
36
|
+
この時、前任の `sessions.project_path` も移った先へ付け替える(0.15.4)。後継は同じ project の前任だけを合流させる。
|
|
36
37
|
hook の cwd は Bash の `cd` に追従するので使わない。後継は起動した場所で立ち、その cwd で印を探す。
|
|
37
38
|
手動の `/compact`、無効な project、subagent の中の圧縮(payload に `agent_id`)、script から起動した会話
|
|
38
39
|
(`CLAUDE_CODE_ENTRYPOINT` が `sdk-` で始まる)では止めない。
|
|
@@ -91,8 +92,8 @@ Claude Desktop の新しい会話は、画面を開いただけでは process
|
|
|
91
92
|
## Consequences
|
|
92
93
|
|
|
93
94
|
- 有効な project では、Claude Code の自動圧縮は走らない。旧い会話は、止めた道具の hook の文を出して止まる。
|
|
94
|
-
- 続きは裏の会話(agent view)で動く。`claude agents` の一覧と `claude attach <id>` で見られる。
|
|
95
|
-
|
|
95
|
+
- 続きは裏の会話(agent view)で動く。`claude agents` の一覧と `claude attach <id>` で見られる。Desktopへは自動表示しない。
|
|
96
|
+
作業完了を確認して裏の実行を停止した後、`claude --desktop --resume <session-id>`で同じ会話をDesktopに開ける。
|
|
96
97
|
- `PreToolUse` hook は、有効な端末の全ての Claude の会話で、道具の呼び出しのたびに1回走る。
|
|
97
98
|
- 止めたターンは Stop hook を通らない。worker が取り込む(0.14.1)。発言が1つも無いまま止めたターンは、
|
|
98
99
|
user の行と道具の入出力だけが入る。完了の受領(Stop が出すもの)は、止めたターンには出さない。
|
|
@@ -119,4 +120,5 @@ Claude Desktop の新しい会話は、画面を開いただけでは process
|
|
|
119
120
|
旧い会話を止める・後継を立てる、の3段は通った。継続の指示は、バイパス中の後継が承認待ちで止めた(0.15.3 で渡し方を変えた理由)。
|
|
120
121
|
- 0.15.3(継続の指示を最初の指示として渡す)。macOS の端末の会話(2.1.289、Haiku 4.5)で、バイパスは4回、`acceptEdits` も4回、
|
|
121
122
|
連続で引き継いで完了。Windows 11 の端末の会話(`acceptEdits`)でも4回連続で引き継いで完了した。
|
|
122
|
-
- 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回増えるだけで、動きは変わらない。
|
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);
|
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
|
|
|
@@ -25,7 +25,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
25
25
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
26
26
|
import { readClaudeAutoHandoffConfig, claudeAutoHandoffEnabledFor } from './claude-auto-handoff-config.mjs';
|
|
27
27
|
import { CLAUDE_HOST, hostOfSessionId } from './hosts/identity.mjs';
|
|
28
|
-
import { claudeHostAdapter } from './hosts/claude.mjs';
|
|
28
|
+
import { claudeHostAdapter, readClaudeRelocatedCwd } from './hosts/claude.mjs';
|
|
29
29
|
import {
|
|
30
30
|
isJunkAssistantText,
|
|
31
31
|
normalizeTerminalText,
|
|
@@ -222,7 +222,15 @@ export function requestClaudeAutoHandoff({
|
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
// /tl と同じ印。後継の最初の指示が、この会話を前任として合流させる。
|
|
225
|
-
|
|
225
|
+
const db = openDb();
|
|
226
|
+
// 会話が別の project へ移っている時(Claude Desktop)は、sessions の project も移った先にそろえる。
|
|
227
|
+
// 合流は前任と後継の project が同じ時だけ行う。付け替えは Stop でも行うが、移った後に1回も Stop を
|
|
228
|
+
// 通らないまま引き継ぐ会話があり、その時は後継へ記憶が入らなかった(0.15.2・0.15.3、macOS で実測)。
|
|
229
|
+
if (readClaudeRelocatedCwd(payload.transcript_path)) {
|
|
230
|
+
db.prepare('UPDATE sessions SET project_path = ? WHERE session_id = ? AND project_path <> ?')
|
|
231
|
+
.run(projectPath, sessionId, projectPath);
|
|
232
|
+
}
|
|
233
|
+
writeBaton(db, { projectPath, sessionId, now });
|
|
226
234
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
227
235
|
removeStale(dir, now);
|
|
228
236
|
const handoffId = randomUUID();
|
|
@@ -70,13 +70,19 @@ test('PreCompact: 会話が別の project へ移っている時は、移った
|
|
|
70
70
|
writeFileSync(transcriptPath, jsonl([user('読んで', '2026-10-05T22:38:17.050Z'), assistant('読みました', '2026-10-05T22:39:18.926Z'),
|
|
71
71
|
{ type: 'relocated', sessionId: SESSION, relocatedCwd: moved }]));
|
|
72
72
|
const onlyMoved = { enabled: true, projects: [moved] };
|
|
73
|
+
// SessionStart は移る前の場所で sessions の行を作っている
|
|
74
|
+
const relocatedDb = makeBatonDb();
|
|
75
|
+
relocatedDb.exec('CREATE TABLE sessions (session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL)');
|
|
76
|
+
relocatedDb.prepare('INSERT INTO sessions VALUES (?, ?)').run(SESSION, scratch);
|
|
73
77
|
|
|
74
|
-
const { result, db } = request(dir, { config: onlyMoved, transcriptPath, env: { CLAUDE_PROJECT_DIR: scratch }, payload: { cwd: moved } });
|
|
78
|
+
const { result, db } = request(dir, { db: relocatedDb, config: onlyMoved, transcriptPath, env: { CLAUDE_PROJECT_DIR: scratch }, payload: { cwd: moved } });
|
|
75
79
|
assert.equal(result.status, 'requested');
|
|
76
80
|
assert.equal(result.block, true);
|
|
77
81
|
assert.equal(result.projectPath, moved);
|
|
78
82
|
assert.equal(readClaudeAutoHandoff(SESSION, dir).project_path, moved);
|
|
79
83
|
assert.equal(db.prepare('SELECT session_id FROM handoff_batons WHERE project_path = ?').get(moved).session_id, SESSION);
|
|
84
|
+
assert.equal(db.prepare('SELECT project_path FROM sessions WHERE session_id = ?').get(SESSION).project_path, moved,
|
|
85
|
+
'後継は同じ project の前任だけを合流させるので、sessions の project も移った先にそろえる');
|
|
80
86
|
}));
|
|
81
87
|
|
|
82
88
|
test('PreCompact: 移っていない会話は、これまでどおり起動した project で決める', () => withDir(dir => {
|
|
@@ -717,6 +723,59 @@ test('hook: 自動圧縮を止め、次の道具を止めて worker を起動し
|
|
|
717
723
|
assert.deepEqual([merged.triggered_path, merged.injection.auto_handoff_id], ['baton', record.handoff_id]);
|
|
718
724
|
}));
|
|
719
725
|
|
|
726
|
+
test('hook: 別の project へ移した会話(Claude Desktop)を、移った後に1回も Stop を通らないまま引き継いでも、後継へ記憶が入る', () => withDir(async home => {
|
|
727
|
+
// Desktop は「フォルダなし」で始めた会話を project へ移す。SessionStart は移る前の場所で呼ばれ、
|
|
728
|
+
// CLAUDE_PROJECT_DIR は移った後も移る前の場所のまま届く。
|
|
729
|
+
const scratch = join(home, 'scratch-2026-10-05-ac3fa8');
|
|
730
|
+
const project = join(home, 'project');
|
|
731
|
+
mkdirSync(scratch, { recursive: true });
|
|
732
|
+
mkdirSync(project, { recursive: true });
|
|
733
|
+
const transcriptPath = join(home, 'transcript.jsonl');
|
|
734
|
+
const base = Date.parse('2026-10-05T22:38:00Z');
|
|
735
|
+
const at = seconds => new Date(base + seconds * 1000).toISOString();
|
|
736
|
+
const moved = { ...fakeClaudePath(home), CLAUDE_PROJECT_DIR: scratch };
|
|
737
|
+
const hook = (command, payload, cwd = project) =>
|
|
738
|
+
cli(home, [command], JSON.stringify({ session_id: SESSION, cwd, transcript_path: transcriptPath, ...payload }), moved);
|
|
739
|
+
|
|
740
|
+
assert.equal(cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--project', project, '--json']).status, 0);
|
|
741
|
+
assert.equal(hook('session-start', { hook_event_name: 'SessionStart', source: 'startup' }, scratch).status, 0);
|
|
742
|
+
writeFileSync(transcriptPath, jsonl([
|
|
743
|
+
user('14個を順に読んで', at(0)),
|
|
744
|
+
assistant('読み終えました。', at(60)),
|
|
745
|
+
{ type: 'relocated', sessionId: SESSION, relocatedCwd: project },
|
|
746
|
+
user('part06 から part09 をもう一度読んで', at(3000)),
|
|
747
|
+
assistant('part06 から読みます。', at(3002)),
|
|
748
|
+
toolUse('toolu_6', 'Read', { file_path: join(project, 'part06.txt') }),
|
|
749
|
+
]));
|
|
750
|
+
|
|
751
|
+
const pre = hook('pre-compact', { hook_event_name: 'PreCompact', trigger: 'auto' });
|
|
752
|
+
assert.equal(pre.status, 2, pre.stderr);
|
|
753
|
+
const stopped = hook('pre-tool-use', { hook_event_name: 'PreToolUse', tool_name: 'Read', tool_use_id: 'toolu_6', permission_mode: 'bypassPermissions' });
|
|
754
|
+
assert.equal(JSON.parse(stopped.stdout).continue, false);
|
|
755
|
+
const status = () => JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout).handoffs;
|
|
756
|
+
let handoff;
|
|
757
|
+
for (let i = 0; i < 100 && (handoff = status()[0]).state !== 'failed'; i++) await new Promise(r => setTimeout(r, 100));
|
|
758
|
+
assert.equal(handoff.error_code, 'handoff_successor_launch_failed', '偽の claude で止まる。本物は起動しない');
|
|
759
|
+
|
|
760
|
+
// 後継の最初の指示(起動時に渡された継続の指示)
|
|
761
|
+
const recordFile = join(home, '.throughline', 'claude-auto-handoff', `${SESSION}.json`);
|
|
762
|
+
const record = JSON.parse(readFileSync(recordFile, 'utf8'));
|
|
763
|
+
writeFileSync(recordFile, JSON.stringify({ ...record, state: 'launching', error_code: null }));
|
|
764
|
+
const successorPayload = { session_id: SUCCESSOR, cwd: project, hook_event_name: 'SessionStart', source: 'startup' };
|
|
765
|
+
assert.equal(cli(home, ['session-start'], JSON.stringify(successorPayload), fakeClaudePath(home)).status, 0);
|
|
766
|
+
const first = cli(home, ['prompt-submit'], JSON.stringify({ ...successorPayload, hook_event_name: 'UserPromptSubmit',
|
|
767
|
+
prompt: claudeContinuationInput(record) }), fakeClaudePath(home));
|
|
768
|
+
assert.equal(first.status, 0, first.stderr);
|
|
769
|
+
assert.match(first.stdout, /^## Throughline: 自動継続の文脈\n/);
|
|
770
|
+
assert.match(first.stdout, /\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: part06 から part09 をもう一度読んで\n/);
|
|
771
|
+
assert.match(first.stdout, /\*\*止める直前に呼ぼうとして、実行されなかった道具\*\*: Read .*part06\.txt\n/);
|
|
772
|
+
|
|
773
|
+
const db = new DatabaseSync(join(home, '.throughline', 'throughline.db'));
|
|
774
|
+
assert.equal(db.prepare('SELECT merged_into FROM sessions WHERE session_id = ?').get(SESSION).merged_into, SUCCESSOR);
|
|
775
|
+
assert.equal(db.prepare('SELECT project_path FROM sessions WHERE session_id = ?').get(SESSION).project_path, project);
|
|
776
|
+
db.close();
|
|
777
|
+
}));
|
|
778
|
+
|
|
720
779
|
test('hook: 自動継続が無効な時、自動圧縮を止めず、何も残さない', () => withDir(home => {
|
|
721
780
|
const project = join(home, 'project');
|
|
722
781
|
mkdirSync(project, { recursive: true });
|
|
@@ -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;
|
|
@@ -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 {
|