throughline 0.16.4 → 0.16.6
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 +49 -1
- package/README.ja.md +2 -2
- package/README.md +2 -2
- package/docs/adr/0042-codex-capture-writes-only-changed-tail.md +62 -0
- package/package.json +1 -1
- package/src/cli/doctor.mjs +4 -3
- package/src/cli/doctor.test.mjs +24 -0
- package/src/codex-auto-handoff.mjs +2 -1
- package/src/codex-auto-handoff.test.mjs +22 -0
- package/src/codex-capture.mjs +92 -21
- package/src/codex-capture.test.mjs +152 -0
- package/src/db-contention.test.mjs +44 -0
- package/src/db.mjs +4 -0
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.6] — 2026-10-08
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- WindowsのCodex Desktopで、自動継続が1回も引き継げなかったのを直す。`PreCompact(auto)`のhookが、会話記録の場所を確かめる所で`EISDIR: illegal operation on a directory, lstat 'C:'`で落ち、旧ターンだけを止めて、引き継ぎを作らずに終わっていた。Desktopが起動したhookの中では、Node.jsのJS実装の`fs.realpathSync`がdriveの根を読めない。OSの`realpath`(`fs.realpathSync.native`)を使う。製品のほかの所は、すでにこちらを使っていた。
|
|
18
|
+
- Windowsの端末の実測(Codex Desktop 26.930、同梱CLI 0.160.1、Node.js 24.20.0)。同じhookの中で、JS実装は13回とも同じ理由で落ち、OSの`realpath`は13回とも通った。
|
|
19
|
+
- Windowsで自動継続を有効にしていた端末は、今まで無い(既定は無効。有効にする前の確認で見つけた)。
|
|
20
|
+
- `throughline doctor --codex`が、Windowsで承認済みのhookを「0/3 trusted - accept hooks in Codex menu」と表示していたのを直す。Codexは、`\`を含むkey(Windowsのpath)を`config.toml`へTOMLのliteral string(`[hooks.state.'C:\Users\…']`)で書く。doctorは`"…"`の形しか読んでいなかった。hookの動きと、Codexの公式API(`hooks/list`)が返す承認状態は、今までも正しかった。
|
|
21
|
+
|
|
22
|
+
### 確認した範囲
|
|
23
|
+
|
|
24
|
+
- WindowsのCodex Desktopで、公式の`PreCompact(auto)`を実際に発火させ、A→B→Cの2回の引き継ぎを人の操作なしで通した([実測](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-08-windows-codex-auto-handoff.md))。2回とも`continued`。3つの工程の値は元の指示と一致し、3つ目のタスクが最初のタスクのL3を取得して完了した。圧縮の記録は0件。後継の名前は`<project名>|<作業の概要>(自動引き継ぎ)`で付いた。
|
|
25
|
+
|
|
26
|
+
### 変わらない事
|
|
27
|
+
|
|
28
|
+
- macOSの自動継続の動き。schema(v12)。Claude Code・Grok・Cursor。
|
|
29
|
+
|
|
30
|
+
### 確認していない範囲
|
|
31
|
+
|
|
32
|
+
- Windowsで、Desktopの画面から人が始めたタスクを最初の旧タスクにした引き継ぎ。実測の最初のタスクは、製品が後継を作るのと同じ道(Desktop同梱CLIのapp-server)で作り、Desktopで開いて実行させた。
|
|
33
|
+
- Windowsで、容量の既定の上限まで進んだ長い会話の引き継ぎ(実測は、試験用フォルダだけ上限を60,000 tokensへ下げた)。
|
|
34
|
+
- Windowsの、失敗した時の説明ページの表示と、`resume`。
|
|
35
|
+
|
|
36
|
+
## [0.16.5] — 2026-10-08
|
|
37
|
+
|
|
38
|
+
### 修正
|
|
39
|
+
|
|
40
|
+
- Codexのhookが、道具を1回使うたびに、その会話のL3(`details`)を全部消して入れ直していたのを直す([ADR 0042](docs/adr/0042-codex-capture-writes-only-changed-tail.md))。DBにある行とrolloutの行を先頭から本文まで比べ、最初に食い違った行から後ろだけを書く。取り込んだ後の中身と並びは、全部入れ直した時と同じ。
|
|
41
|
+
- Windowsの端末で、L3が3,552行・約1.47億字(rolloutは288MB)の会話のhookが、1回でWALへ152.9MBを書いていた。その端末の`throughline.db-wal`は12.8GBまで伸びていた(DB本体は230MB)。本物のDBの写しで測ると、変化の無いhookは0.3MB、89行が増えたhookは12.5MBになる。
|
|
42
|
+
- `getDb()`が`PRAGMA journal_size_limit`を64MBに設定する。WALが使い直された後の最初のcommitで、ファイルを64MBまで切り詰める。今までは、一度伸びたWALは中身が空になっても縮まなかった。
|
|
43
|
+
|
|
44
|
+
### 変更
|
|
45
|
+
|
|
46
|
+
- rolloutに時刻の無いL3の行は、最初に取り込んだ時刻のまま残る。今までは、取り込むたびにその時の時刻へ変わっていた。
|
|
47
|
+
- `codex-capture --json`の結果へ、`keptDetails`・`writtenDetails`・`removedDetails`を足す。
|
|
48
|
+
|
|
49
|
+
### 変わらない事
|
|
50
|
+
|
|
51
|
+
- schema(v12)。`bodies`(L2)と`skeletons`(L1)の扱い。
|
|
52
|
+
- Claude Code・Grok・Cursorの取り込み。
|
|
53
|
+
|
|
54
|
+
### 確認していない範囲
|
|
55
|
+
|
|
56
|
+
- 複数のCodexの会話が同時に動く本物の端末で、WALが伸びなくなるか。公開の後に端末で読む。
|
|
57
|
+
- hookは今までどおり毎回rolloutの全体を読む。上の会話では約1.1秒かかる。Codexの画面での待ちとして見えているかは調べていない。
|
|
58
|
+
|
|
13
59
|
## [0.16.4] — 2026-10-07
|
|
14
60
|
|
|
15
61
|
### 変更
|
|
@@ -1959,7 +2005,9 @@ two attempts, instrument first instead of patching again.
|
|
|
1959
2005
|
|
|
1960
2006
|
---
|
|
1961
2007
|
|
|
1962
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.
|
|
2008
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.6...HEAD
|
|
2009
|
+
[0.16.6]: https://github.com/kitepon/Throughline/compare/v0.16.5...v0.16.6
|
|
2010
|
+
[0.16.5]: https://github.com/kitepon/Throughline/compare/v0.16.4...v0.16.5
|
|
1963
2011
|
[0.16.4]: https://github.com/kitepon/Throughline/compare/v0.16.3...v0.16.4
|
|
1964
2012
|
[0.16.3]: https://github.com/kitepon/Throughline/compare/v0.16.2...v0.16.3
|
|
1965
2013
|
[0.16.2]: https://github.com/kitepon/Throughline/compare/v0.16.1...v0.16.2
|
package/README.ja.md
CHANGED
|
@@ -27,7 +27,7 @@ throughline auto-handoff status --json
|
|
|
27
27
|
throughline auto-handoff disable
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
既定では無効です。`enable`は必要な`PreCompact(auto)`フックを登録し、そのフックの承認と有効状態を公式APIで確認します。`--project`を省略すると全projectを対象にします。macOSのCodex Desktop
|
|
30
|
+
既定では無効です。`enable`は必要な`PreCompact(auto)`フックを登録し、そのフックの承認と有効状態を公式APIで確認します。`--project`を省略すると全projectを対象にします。macOSとWindowsのCodex Desktopで実機確認済みです(Windowsは0.16.6から)。VS Code・CLI・Linuxの自動継続は未検証です。
|
|
31
31
|
|
|
32
32
|
自動圧縮の開始前に旧ターンを停止し、同じprojectの新しいタスクへ記憶を注入して表示します。新しいタスクには、前任のタスクの題から`<project名>|<作業の概要>(自動引き継ぎ)`の名前を付けます。元のモデル・推論強度・権限・モードなどが表示後も一致することを確認してから、継続指示を一度だけ送ります。後継でその入力と作業の進捗を観測して完了とします。手動`/compact`は発火対象に含めません。
|
|
33
33
|
|
|
@@ -42,7 +42,7 @@ throughline auto-handoff resume --operation <handoff-id> --json
|
|
|
42
42
|
|
|
43
43
|
原因を解消した後の`resume`は同じ引き継ぎと既知の後継を再利用します。配送後の結果不明は実際の入力・開始を観測した場合だけ回復し、作成結果が不明な後継は再作成しません。従来の手動`$throughline`とcurrent-thread実験の設定は独立しています。
|
|
44
44
|
|
|
45
|
-
[実測と検証範囲](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-03-codex-auto-continuation.md)を参照してください。
|
|
45
|
+
[実測と検証範囲](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-03-codex-auto-continuation.md)と、[Windowsの実測](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-08-windows-codex-auto-handoff.md)を参照してください。
|
|
46
46
|
|
|
47
47
|
## Claude Codeの自動継続
|
|
48
48
|
|
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ throughline auto-handoff status --json
|
|
|
26
26
|
throughline auto-handoff disable
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
既定では無効です。`enable`は必要な`PreCompact(auto)`フックを登録し、そのフックの承認と有効状態を公式APIで確認します。`--project`を省略すると全projectを対象にします。macOSのCodex Desktop
|
|
29
|
+
既定では無効です。`enable`は必要な`PreCompact(auto)`フックを登録し、そのフックの承認と有効状態を公式APIで確認します。`--project`を省略すると全projectを対象にします。macOSとWindowsのCodex Desktopで実機確認済みです(Windowsは0.16.6から)。VS Code・CLI・Linuxの自動継続は未検証です。
|
|
30
30
|
|
|
31
31
|
自動圧縮の開始前に旧ターンを停止し、同じprojectの新しいタスクへ記憶を注入して表示します。新しいタスクには、前任のタスクの題から`<project名>|<作業の概要>(自動引き継ぎ)`の名前を付けます。元のモデル・推論強度・権限・モードなどが表示後も一致することを確認してから、継続指示を一度だけ送ります。後継でその入力と作業の進捗を観測して完了とします。手動`/compact`は発火対象に含めません。
|
|
32
32
|
|
|
@@ -41,7 +41,7 @@ throughline auto-handoff resume --operation <handoff-id> --json
|
|
|
41
41
|
|
|
42
42
|
原因を解消した後の`resume`は同じ引き継ぎと既知の後継を再利用します。配送後の結果不明は実際の入力・開始を観測した場合だけ回復し、作成結果が不明な後継は再作成しません。従来の手動`$throughline`とcurrent-thread実験の設定は独立しています。
|
|
43
43
|
|
|
44
|
-
[実測と検証範囲](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-03-codex-auto-continuation.md)を参照してください。
|
|
44
|
+
[実測と検証範囲](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-03-codex-auto-continuation.md)と、[Windowsの実測](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-08-windows-codex-auto-handoff.md)を参照してください。
|
|
45
45
|
|
|
46
46
|
## Claude Codeの自動継続
|
|
47
47
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# ADR 0042: Codex の取り込みは、L3 の変わった所から後ろだけを書く。WAL の大きさに上限を置く
|
|
2
|
+
|
|
3
|
+
日付: 2026-10-08
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
Codex の hook(UserPromptSubmit・PostToolUse・Stop・PreCompact)は、毎回 `captureCodexRolloutToDb` を呼ぶ。
|
|
8
|
+
0.16.4 までは、その会話の `bodies` と `details` を全部消し、rollout を読み直した結果を全部入れ直していた。
|
|
9
|
+
PostToolUse は道具を1回使うたびに走るので、会話が長いほど、1回の hook が会話全体と同じ量を WAL へ書く。
|
|
10
|
+
|
|
11
|
+
2026-10-08 に Windows の端末で次の状態を見た。
|
|
12
|
+
|
|
13
|
+
- `throughline.db` は 230MB。`throughline.db-wal` は 12.8GB。
|
|
14
|
+
- Codex の会話1つが、L3 に 3,552 行・約 1.47 億字を持つ(rollout は 288MB)。
|
|
15
|
+
- その会話の hook を、本物の DB の写しへ当てて測ると、1回で WAL へ 152.9MB を書いた。rollout に変化が無い時も同じ量を書く。
|
|
16
|
+
- hook が続く間、44 秒で wal-index が約 600MB 分伸びた。
|
|
17
|
+
|
|
18
|
+
WAL は、checkpoint が全部を本体へ書き戻し、読んでいる接続が無い時にだけ、先頭から使い直される。1回の
|
|
19
|
+
hook が百数十 MB を書き、別の会話の hook が続けて来ると、使い直す隙が出来ず、WAL は伸び続ける。また、
|
|
20
|
+
SQLite の既定(`journal_size_limit = -1`)では、一度伸びた WAL のファイルは、中身が空になっても縮まない。
|
|
21
|
+
|
|
22
|
+
rollout は追記で伸びる。普通の hook の間で変わるのは、L3 の末尾の数行だけ。
|
|
23
|
+
|
|
24
|
+
## Decision
|
|
25
|
+
|
|
26
|
+
1. `captureCodexRolloutToDb` は、`details` を次の形で書く。
|
|
27
|
+
- DB にある行(`id` の順)と、rollout から作った行を、先頭から1行ずつ比べる。
|
|
28
|
+
- 比べるのは `origin_session_id`・`turn_number`・`tool_name`・`kind`・`source_id`・`token_count`・`created_at` と、
|
|
29
|
+
`input_text`・`output_text` の本文。本文は SQLite の中で比べる(`input_text IS ? AND output_text IS ?`)。
|
|
30
|
+
- 最初に食い違った行から後ろを消し、rollout の同じ位置から後ろを入れる。食い違いが無ければ、増えた行だけを入れる。
|
|
31
|
+
2. 比べる前に、rollout から作った行を、DB に入る形へ揃える。同じ `source_id` の2件目は
|
|
32
|
+
`uq_details_source` と `INSERT OR IGNORE` が捨てるので、先に除く。
|
|
33
|
+
3. rollout に時刻が無い行は、取り込んだ時刻が `created_at` に入る。その行は `created_at` を比べず、
|
|
34
|
+
最初に取り込んだ時刻のまま残す。0.16.4 までは、取り込むたびにその時の時刻へ変わっていた。
|
|
35
|
+
4. `bodies` は、今までどおり全部消して入れ直す。会話の本文は小さい(上の会話で 98 行・約 7.7 万字)。
|
|
36
|
+
`skeletons`(L1)の扱いも変えない。
|
|
37
|
+
5. `getDb()` は、開いた接続へ `PRAGMA journal_size_limit = 67108864`(64MB)を設定する。WAL が使い直された
|
|
38
|
+
後の最初の commit で、SQLite がファイルを 64MB まで切り詰める。切り詰めに失敗しても、SQLite はその
|
|
39
|
+
失敗を無視して書き込みを続ける。
|
|
40
|
+
6. `captureCodexRolloutToDb` の結果へ、`keptDetails`・`writtenDetails`・`removedDetails` を足す。
|
|
41
|
+
|
|
42
|
+
## Consequences
|
|
43
|
+
|
|
44
|
+
- 取り込んだ後の `details` の中身と並び(`id` の順)は、全部入れ直した時と同じになる。3 の `created_at` だけが違う。
|
|
45
|
+
- 変わっていない行の `id` は、hook をまたいで変わらなくなる。0.16.4 までは、毎回新しい `id` になっていた。
|
|
46
|
+
- 上の会話の写しで測ると、変化の無い hook が WAL へ書く量は 152.9MB から 0.3MB になる。89 行が増えた hook は 12.5MB。
|
|
47
|
+
- hook は、今までどおり毎回 rollout の全体を読む。上の会話では、読むのに約 1.1 秒、比べるのに約 0.3 秒かかる。
|
|
48
|
+
本文を比べるために、DB にあるその会話の L3 を毎回読む。書く量は減るが、読む量は減らない。
|
|
49
|
+
- rollout の途中の行が変わった時(rollback の後など)は、その行から後ろを入れ直す。先頭の行が変われば、
|
|
50
|
+
今までと同じく全部を入れ直す。
|
|
51
|
+
- 同じ会話の hook が2つ同時に走った時は、後で commit した方の rollout の読み取りが残る。今までと同じ。
|
|
52
|
+
- すでに伸びている WAL は、この版の接続が、使い直された WAL へ最初に commit した時に縮む。それまでは元の大きさのまま。
|
|
53
|
+
- `journal_size_limit` は接続ごとの設定で、DB には残らない。読み取り専用の接続(`openReadOnlyDb`)には設定しない。
|
|
54
|
+
- schema は変えない(v12 のまま)。
|
|
55
|
+
|
|
56
|
+
## 確認していない範囲
|
|
57
|
+
|
|
58
|
+
- WAL が使い直されない状態(checkpoint が終わる前に次の書き込みが来る状態)が、この版で実際に起きなくなるか。
|
|
59
|
+
書く量が減った分、起きにくくなるが、本物の端末で複数の会話が同時に動いた時の WAL の大きさは、公開の後に読む。
|
|
60
|
+
- rollout を毎回全部読む時間が、Codex の画面で道具の後の待ちとして見えているか。
|
|
61
|
+
- Claude Code・Grok・Cursor の取り込みは、ターンごとに `INSERT OR IGNORE` で足す形で、この変更の対象ではない。
|
|
62
|
+
これらの host の長い会話での書く量は測っていない。
|
package/package.json
CHANGED
package/src/cli/doctor.mjs
CHANGED
|
@@ -405,14 +405,15 @@ function parseCodexTrustedHookState(configText) {
|
|
|
405
405
|
let currentTrusted = false;
|
|
406
406
|
|
|
407
407
|
function flush() {
|
|
408
|
-
if (currentKey && currentTrusted) trustedKeys.add(
|
|
408
|
+
if (currentKey && currentTrusted) for (const key of currentKey) trustedKeys.add(key);
|
|
409
409
|
}
|
|
410
410
|
|
|
411
411
|
for (const line of configText.split(/\r?\n/)) {
|
|
412
|
-
|
|
412
|
+
// Codexは、`\` を含むkey(Windowsのpath)をTOMLのliteral string('…')で書く。basic string("…")の時は `\\` を戻す。
|
|
413
|
+
const section = line.match(/^\s*\[hooks\.state\.(?:"((?:[^"\\]|\\.)+)"|'([^']+)')\]\s*$/);
|
|
413
414
|
if (section) {
|
|
414
415
|
flush();
|
|
415
|
-
currentKey = section[1];
|
|
416
|
+
currentKey = section[2] != null ? [section[2]] : [section[1], section[1].replace(/\\(["\\])/g, '$1')];
|
|
416
417
|
currentTrusted = false;
|
|
417
418
|
continue;
|
|
418
419
|
}
|
package/src/cli/doctor.test.mjs
CHANGED
|
@@ -456,6 +456,30 @@ test('readCodexHookDiagnosis detects Codex prompt and Stop hooks', () => {
|
|
|
456
456
|
}
|
|
457
457
|
});
|
|
458
458
|
|
|
459
|
+
test('readCodexHookDiagnosis: Windowsのpathのkey(TOMLのliteral string・escapeしたbasic string)の承認を読む', () => {
|
|
460
|
+
const codexHome = mkdtempSync(join(tmpdir(), 'throughline-doctor-codex-trust-'));
|
|
461
|
+
try {
|
|
462
|
+
const hook = (name, timeout) => [{ hooks: [{ type: 'command', command: `/usr/bin/node /pkg/bin/throughline.mjs codex-hook ${name}`, timeout, async: false }] }];
|
|
463
|
+
writeFileSync(join(codexHome, 'hooks.json'), JSON.stringify({ hooks: {
|
|
464
|
+
UserPromptSubmit: hook('user-prompt-submit', 30), PostToolUse: hook('post-tool-use', 30), Stop: hook('stop', 300) } }) + '\n');
|
|
465
|
+
// foxの実測(2026-10-08): Codex 0.160 は `[hooks.state.'C:\Users\…\hooks.json:stop:0:0']` と書く。
|
|
466
|
+
const hooksPath = join(codexHome, 'hooks.json');
|
|
467
|
+
const hash = 'trusted_hash = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"';
|
|
468
|
+
writeFileSync(join(codexHome, 'config.toml'), ['[features]', 'hooks = true', '',
|
|
469
|
+
`[hooks.state.'${hooksPath}:user_prompt_submit:0:0']`, hash, '',
|
|
470
|
+
`[hooks.state.${JSON.stringify(`${hooksPath}:post_tool_use:0:0`)}]`, hash, '',
|
|
471
|
+
`[hooks.state.'${hooksPath}:stop:0:0']`, 'enabled = true', ''].join('\n'));
|
|
472
|
+
const diagnosis = readCodexHookDiagnosis(codexHome);
|
|
473
|
+
assert.equal(diagnosis.managedPromptHooks[0].throughlineDoctorTrusted, true);
|
|
474
|
+
assert.equal(diagnosis.managedPostToolUseHooks[0].throughlineDoctorTrusted, true);
|
|
475
|
+
// trusted_hash の無い項目は、承認済みに数えない。
|
|
476
|
+
assert.equal(diagnosis.managedStopHooks[0].throughlineDoctorTrusted, false);
|
|
477
|
+
assert.equal(diagnosis.managedHookTrust.trustedCount, 2);
|
|
478
|
+
} finally {
|
|
479
|
+
rmSync(codexHome, { recursive: true, force: true });
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
|
|
459
483
|
// ─── formatAgo ──────────────────────────────────────────────────────
|
|
460
484
|
|
|
461
485
|
test('formatAgo: 60 秒未満は秒表示', () => {
|
|
@@ -164,7 +164,8 @@ export async function requestCodexAutoHandoff({ payload, db = null,
|
|
|
164
164
|
typeof payload.transcript_path !== 'string' || !isAbsolute(payload.transcript_path) || !isAbsolute(payload.cwd ?? '')) {
|
|
165
165
|
throw error('handoff_hook_identity_invalid');
|
|
166
166
|
}
|
|
167
|
-
|
|
167
|
+
// JS実装のrealpathは、WindowsのCodex Desktopが起動したhookの中で `EISDIR: lstat 'C:'` で落ちる。OSのrealpathを使う。
|
|
168
|
+
const rolloutPath = realpathSync.native(payload.transcript_path);
|
|
168
169
|
const state = readCodexHandoffState(rolloutPath, { threadId, turnId });
|
|
169
170
|
if (state.meta.originator !== 'Codex Desktop' || state.meta.source !== 'vscode') return { status: 'skipped', reason: 'handoff_host_unsupported' };
|
|
170
171
|
if (!sameProjectPath(state.meta.cwd, payload.cwd) || state.latestTurnId !== turnId) throw error('handoff_hook_source_mismatch');
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import { test } from 'node:test';
|
|
3
3
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { syncBuiltinESMExports } from 'node:module';
|
|
4
6
|
import { spawnSync } from 'node:child_process';
|
|
5
7
|
import { fileURLToPath } from 'node:url';
|
|
6
8
|
import { tmpdir } from 'node:os';
|
|
@@ -216,6 +218,26 @@ test('PreCompactの重複ではworkerを増やさず、manualと別projectでは
|
|
|
216
218
|
assert.equal((await requestCodexAutoHandoff({ ...args, config: { ...config, projects: ['/other'] } })).status, 'skipped');
|
|
217
219
|
}));
|
|
218
220
|
|
|
221
|
+
test('PreCompactの入口は、JS実装のrealpathが使えない環境(WindowsのCodex Desktopのhook)でも引き継ぎを作る', async () => withDb(async (db, home) => {
|
|
222
|
+
const thread = '11111111-1111-1111-1111-111111111111', turn = '22222222-2222-2222-2222-222222222222';
|
|
223
|
+
const dir = join(home, 'codex', 'sessions'); mkdirSync(dir, { recursive: true });
|
|
224
|
+
const file = join(dir, 'rollout.jsonl');
|
|
225
|
+
writeFileSync(file, [{ type: 'session_meta', payload: { id: thread, originator: 'Codex Desktop', source: 'vscode', cwd: home } },
|
|
226
|
+
{ type: 'event_msg', payload: { type: 'task_started', turn_id: turn } }].map(JSON.stringify).join('\n') + '\n');
|
|
227
|
+
// foxの実測(2026-10-08): Desktopが起動したhookの中では、JS実装が drive の根を lstat できずに落ちた。
|
|
228
|
+
const original = fs.realpathSync;
|
|
229
|
+
const broken = () => { throw Object.assign(new Error("EISDIR: illegal operation on a directory, lstat 'C:'"), { code: 'EISDIR' }); };
|
|
230
|
+
broken.native = original.native;
|
|
231
|
+
fs.realpathSync = broken; syncBuiltinESMExports();
|
|
232
|
+
try {
|
|
233
|
+
let launched = 0;
|
|
234
|
+
const result = await requestCodexAutoHandoff({ db, config: { enabled: true, projects: [home], openHost: 'desktop' },
|
|
235
|
+
payload: { trigger: 'auto', cwd: home, session_id: thread, turn_id: turn, transcript_path: file },
|
|
236
|
+
launchWorker: async () => { launched++; } });
|
|
237
|
+
assert.equal(result.status, 'ok'); assert.equal(result.inserted, true); assert.equal(launched, 1);
|
|
238
|
+
} finally { fs.realpathSync = original; syncBuiltinESMExports(); }
|
|
239
|
+
}));
|
|
240
|
+
|
|
219
241
|
function desktopSource(home, thread, turn) {
|
|
220
242
|
const dir = join(home, 'codex', 'sessions'); mkdirSync(dir, { recursive: true });
|
|
221
243
|
const file = join(dir, `rollout-${thread}.jsonl`);
|
package/src/codex-capture.mjs
CHANGED
|
@@ -58,6 +58,7 @@ export function captureCodexRolloutToDb(
|
|
|
58
58
|
now,
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
+
let detailSync = null;
|
|
61
62
|
db.exec('BEGIN IMMEDIATE');
|
|
62
63
|
try {
|
|
63
64
|
db.prepare(
|
|
@@ -80,19 +81,12 @@ export function captureCodexRolloutToDb(
|
|
|
80
81
|
}
|
|
81
82
|
}
|
|
82
83
|
db.prepare('DELETE FROM bodies WHERE session_id = ?').run(sessionId);
|
|
83
|
-
db.prepare('DELETE FROM details WHERE session_id = ?').run(sessionId);
|
|
84
84
|
|
|
85
85
|
const insertBody = db.prepare(
|
|
86
86
|
`INSERT INTO bodies
|
|
87
87
|
(session_id, origin_session_id, turn_number, role, text, token_count, created_at)
|
|
88
88
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
89
89
|
);
|
|
90
|
-
const insertDetail = db.prepare(
|
|
91
|
-
`INSERT OR IGNORE INTO details
|
|
92
|
-
(session_id, origin_session_id, turn_number, tool_name, input_text, output_text,
|
|
93
|
-
token_count, created_at, kind, source_id)
|
|
94
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
95
|
-
);
|
|
96
90
|
|
|
97
91
|
for (const row of rows) {
|
|
98
92
|
insertBody.run(
|
|
@@ -105,20 +99,7 @@ export function captureCodexRolloutToDb(
|
|
|
105
99
|
row.createdAt,
|
|
106
100
|
);
|
|
107
101
|
}
|
|
108
|
-
|
|
109
|
-
insertDetail.run(
|
|
110
|
-
row.sessionId,
|
|
111
|
-
row.originSessionId,
|
|
112
|
-
row.turnNumber,
|
|
113
|
-
row.toolName,
|
|
114
|
-
row.inputText,
|
|
115
|
-
row.outputText,
|
|
116
|
-
row.tokenCount,
|
|
117
|
-
row.createdAt,
|
|
118
|
-
row.kind,
|
|
119
|
-
row.sourceId,
|
|
120
|
-
);
|
|
121
|
-
}
|
|
102
|
+
detailSync = syncDetailRows(db, sessionId, detailRows, now);
|
|
122
103
|
|
|
123
104
|
db.exec('COMMIT');
|
|
124
105
|
} catch (err) {
|
|
@@ -137,10 +118,100 @@ export function captureCodexRolloutToDb(
|
|
|
137
118
|
capturedTurns: parsed.activeTurnCount,
|
|
138
119
|
capturedRows: rows.length,
|
|
139
120
|
capturedDetails: detailRows.length,
|
|
121
|
+
keptDetails: detailSync.kept,
|
|
122
|
+
writtenDetails: detailSync.inserted,
|
|
123
|
+
removedDetails: detailSync.removed,
|
|
140
124
|
stats: parsed.stats,
|
|
141
125
|
};
|
|
142
126
|
}
|
|
143
127
|
|
|
128
|
+
/**
|
|
129
|
+
* L3 (details) を rollout の今の中身へ合わせる。変わっていない先頭の行はそのまま残し、
|
|
130
|
+
* 最初に食い違った行から後ろだけを消して入れ直す (ADR 0042)。
|
|
131
|
+
*
|
|
132
|
+
* hook は道具を1回使うたびに走る。会話の全行を消して入れ直すと、長い会話では1回の hook が
|
|
133
|
+
* 会話全体と同じ量を WAL へ書く。rollout は追記で伸びるので、普通は末尾の数行だけが変わる。
|
|
134
|
+
*
|
|
135
|
+
* 残った行の並び (id の順) は、全部入れ直した時と同じになる。食い違いは本文まで比べて決める。
|
|
136
|
+
*/
|
|
137
|
+
function syncDetailRows(db, sessionId, detailRows, now) {
|
|
138
|
+
const wanted = dropIgnoredDuplicates(detailRows);
|
|
139
|
+
const existing = db
|
|
140
|
+
.prepare(
|
|
141
|
+
`SELECT id, origin_session_id, turn_number, tool_name, kind, source_id, token_count, created_at
|
|
142
|
+
FROM details WHERE session_id = ? ORDER BY id`,
|
|
143
|
+
)
|
|
144
|
+
.all(sessionId);
|
|
145
|
+
const sameText = db.prepare(
|
|
146
|
+
'SELECT 1 AS same FROM details WHERE id = ? AND input_text IS ? AND output_text IS ?',
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
let kept = 0;
|
|
150
|
+
while (kept < existing.length && kept < wanted.length) {
|
|
151
|
+
const have = existing[kept];
|
|
152
|
+
const want = wanted[kept];
|
|
153
|
+
if (!sameDetailColumns(have, want, now)) break;
|
|
154
|
+
if (!sameText.get(have.id, want.inputText, want.outputText)) break;
|
|
155
|
+
kept++;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (kept < existing.length) {
|
|
159
|
+
db.prepare('DELETE FROM details WHERE session_id = ? AND id >= ?').run(sessionId, existing[kept].id);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const insertDetail = db.prepare(
|
|
163
|
+
`INSERT OR IGNORE INTO details
|
|
164
|
+
(session_id, origin_session_id, turn_number, tool_name, input_text, output_text,
|
|
165
|
+
token_count, created_at, kind, source_id)
|
|
166
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
167
|
+
);
|
|
168
|
+
for (const row of wanted.slice(kept)) {
|
|
169
|
+
insertDetail.run(
|
|
170
|
+
row.sessionId,
|
|
171
|
+
row.originSessionId,
|
|
172
|
+
row.turnNumber,
|
|
173
|
+
row.toolName,
|
|
174
|
+
row.inputText,
|
|
175
|
+
row.outputText,
|
|
176
|
+
row.tokenCount,
|
|
177
|
+
row.createdAt,
|
|
178
|
+
row.kind,
|
|
179
|
+
row.sourceId,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { kept, removed: existing.length - kept, inserted: wanted.length - kept };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// uq_details_source (session_id, origin_session_id, source_id) は、同じ source_id の2件目を
|
|
187
|
+
// INSERT OR IGNORE で捨てる。DB に入る並びと比べるため、入れる前に同じ形へ揃える。
|
|
188
|
+
function dropIgnoredDuplicates(detailRows) {
|
|
189
|
+
const seen = new Set();
|
|
190
|
+
const rows = [];
|
|
191
|
+
for (const row of detailRows) {
|
|
192
|
+
if (row.sourceId !== null) {
|
|
193
|
+
const key = `${row.originSessionId}\0${row.sourceId}`;
|
|
194
|
+
if (seen.has(key)) continue;
|
|
195
|
+
seen.add(key);
|
|
196
|
+
}
|
|
197
|
+
rows.push(row);
|
|
198
|
+
}
|
|
199
|
+
return rows;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function sameDetailColumns(have, want, now) {
|
|
203
|
+
return (
|
|
204
|
+
have.origin_session_id === want.originSessionId &&
|
|
205
|
+
have.turn_number === want.turnNumber &&
|
|
206
|
+
have.tool_name === want.toolName &&
|
|
207
|
+
have.kind === want.kind &&
|
|
208
|
+
have.source_id === want.sourceId &&
|
|
209
|
+
have.token_count === want.tokenCount &&
|
|
210
|
+
// rollout に時刻が無い行は取り込んだ時刻が入る。その行は最初に取り込んだ時刻のまま残す。
|
|
211
|
+
(have.created_at === want.createdAt || want.createdAt === now)
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
144
215
|
function bodyTurnTexts(rows) {
|
|
145
216
|
const turns = new Map();
|
|
146
217
|
for (const row of rows) {
|
|
@@ -459,6 +459,158 @@ test('同じ会話の再captureはL1を保持し、ユーザー本文が変わ
|
|
|
459
459
|
}
|
|
460
460
|
});
|
|
461
461
|
|
|
462
|
+
// 本物の schema と同じく、同じ source_id の2件目を INSERT OR IGNORE が捨てる DB。
|
|
463
|
+
function makeDbWithSourceIndex() {
|
|
464
|
+
const db = makeDb();
|
|
465
|
+
db.exec(`CREATE UNIQUE INDEX uq_details_source
|
|
466
|
+
ON details(session_id, origin_session_id, source_id) WHERE source_id IS NOT NULL`);
|
|
467
|
+
return db;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function detailSnapshot(db) {
|
|
471
|
+
return db
|
|
472
|
+
.prepare(
|
|
473
|
+
`SELECT origin_session_id, turn_number, tool_name, input_text, output_text, token_count,
|
|
474
|
+
created_at, kind, source_id
|
|
475
|
+
FROM details ORDER BY id`,
|
|
476
|
+
)
|
|
477
|
+
.all();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function toolCall(callId, input, output) {
|
|
481
|
+
return [
|
|
482
|
+
responseItem({ type: 'function_call', call_id: callId, name: 'shell', arguments: input }),
|
|
483
|
+
responseItem({ type: 'function_call_output', call_id: callId, output }),
|
|
484
|
+
];
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
test('同じ会話の再captureは、変わっていないL3の行を書き直さず、増えた行だけを足す', () => {
|
|
488
|
+
const db = makeDbWithSourceIndex();
|
|
489
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-codex-incr-'));
|
|
490
|
+
const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
|
|
491
|
+
const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
|
|
492
|
+
try {
|
|
493
|
+
const head = [
|
|
494
|
+
event('task_started'), event('user_message', { message: '作業を続ける' }),
|
|
495
|
+
...toolCall('call-1', '{"cmd":"pwd"}', '/work'),
|
|
496
|
+
...toolCall('call-2', '{"cmd":"ls"}', 'a\nb'),
|
|
497
|
+
];
|
|
498
|
+
const rollout = writeRollout(home, { id: threadId, cwd: project, events: head });
|
|
499
|
+
const first = captureCodexRolloutToDb(db, { threadId, codexHome: home, projectPath: project, now: 1 });
|
|
500
|
+
assert.deepEqual([first.keptDetails, first.writtenDetails, first.removedDetails], [0, 4, 0]);
|
|
501
|
+
const idsBefore = db.prepare('SELECT id FROM details ORDER BY id').all().map((row) => row.id);
|
|
502
|
+
|
|
503
|
+
// 道具をもう1回使った後の hook。前の4行はそのまま残り、2行だけ増える。
|
|
504
|
+
writeRolloutRows(rollout, [sessionMeta(threadId, project), ...head, ...toolCall('call-3', '{"cmd":"date"}', 'now')]);
|
|
505
|
+
const second = captureCodexRolloutToDb(db, { threadId, codexHome: home, projectPath: project, now: 2 });
|
|
506
|
+
assert.deepEqual([second.keptDetails, second.writtenDetails, second.removedDetails], [4, 2, 0]);
|
|
507
|
+
const idsAfter = db.prepare('SELECT id FROM details ORDER BY id').all().map((row) => row.id);
|
|
508
|
+
assert.deepEqual(idsAfter.slice(0, 4), idsBefore);
|
|
509
|
+
assert.equal(idsAfter.length, 6);
|
|
510
|
+
assert.ok(idsAfter[4] > idsBefore[3]);
|
|
511
|
+
|
|
512
|
+
// 何も増えていない hook は、L3 を1行も書かない。
|
|
513
|
+
const third = captureCodexRolloutToDb(db, { threadId, codexHome: home, projectPath: project, now: 3 });
|
|
514
|
+
assert.deepEqual([third.keptDetails, third.writtenDetails, third.removedDetails], [6, 0, 0]);
|
|
515
|
+
assert.deepEqual(db.prepare('SELECT id FROM details ORDER BY id').all().map((row) => row.id), idsAfter);
|
|
516
|
+
} finally {
|
|
517
|
+
db.close(); rmSync(home, { recursive: true, force: true }); rmSync(project, { recursive: true, force: true });
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test('L3の再captureは、どの変わり方でも、全部入れ直した時と同じ中身と並びになる', () => {
|
|
522
|
+
const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
|
|
523
|
+
const turn = (message, ...items) => [
|
|
524
|
+
event('task_started'), event('user_message', { message }), ...items.flat(), event('task_complete'),
|
|
525
|
+
];
|
|
526
|
+
const orphanOutput = (output) => responseItem({ type: 'function_call_output', output });
|
|
527
|
+
const base = [
|
|
528
|
+
...turn('1つ目', toolCall('call-1', '{"cmd":"pwd"}', '/work'), [orphanOutput('aaaa')]),
|
|
529
|
+
...turn('2つ目', toolCall('call-2', '{"cmd":"ls"}', 'file-a'), toolCall('call-3', '{"cmd":"date"}', 'now')),
|
|
530
|
+
];
|
|
531
|
+
const cases = {
|
|
532
|
+
// 末尾へ足されただけ。前の6行は残る。
|
|
533
|
+
'末尾へ行が増えた': { next: [...base, ...turn('3つ目', toolCall('call-4', '{"cmd":"id"}', 'kite'))], kept: 7 },
|
|
534
|
+
// 同じ source_id・同じ長さで、本文だけが違う。その行から後ろを入れ直す。
|
|
535
|
+
'途中の行の本文が同じ長さで変わった': {
|
|
536
|
+
next: [
|
|
537
|
+
...turn('1つ目', toolCall('call-1', '{"cmd":"pwd"}', '/WORK'), [orphanOutput('aaaa')]),
|
|
538
|
+
...turn('2つ目', toolCall('call-2', '{"cmd":"ls"}', 'file-a'), toolCall('call-3', '{"cmd":"date"}', 'now')),
|
|
539
|
+
],
|
|
540
|
+
kept: 1,
|
|
541
|
+
},
|
|
542
|
+
// source_id の無い行は、本文でしか区別できない。
|
|
543
|
+
'source_idの無い行の本文が変わった': {
|
|
544
|
+
next: [
|
|
545
|
+
...turn('1つ目', toolCall('call-1', '{"cmd":"pwd"}', '/work'), [orphanOutput('bbbb')]),
|
|
546
|
+
...turn('2つ目', toolCall('call-2', '{"cmd":"ls"}', 'file-a'), toolCall('call-3', '{"cmd":"date"}', 'now')),
|
|
547
|
+
],
|
|
548
|
+
kept: 2,
|
|
549
|
+
},
|
|
550
|
+
// 出力が後から届いた(NULL だった所へ入る形ではなく、行が増える)。
|
|
551
|
+
'途中へ行が差し込まれた': {
|
|
552
|
+
next: [
|
|
553
|
+
...turn('1つ目', toolCall('call-1', '{"cmd":"pwd"}', '/work'), toolCall('call-1b', '{"cmd":"x"}', 'y'), [orphanOutput('aaaa')]),
|
|
554
|
+
...turn('2つ目', toolCall('call-2', '{"cmd":"ls"}', 'file-a'), toolCall('call-3', '{"cmd":"date"}', 'now')),
|
|
555
|
+
],
|
|
556
|
+
kept: 2,
|
|
557
|
+
},
|
|
558
|
+
// rollback で最後のターンが消えた。
|
|
559
|
+
'最後のターンが巻き戻された': { next: [...base, event('thread_rolled_back', { num_turns: 1 })], kept: 3 },
|
|
560
|
+
// 同じ call_id がもう一度出た。2件目は捨てられるので、並びは変わらない。
|
|
561
|
+
'同じsource_idがもう一度出た': {
|
|
562
|
+
next: [
|
|
563
|
+
...turn('1つ目', toolCall('call-1', '{"cmd":"pwd"}', '/work'), [orphanOutput('aaaa')]),
|
|
564
|
+
...turn('2つ目', toolCall('call-2', '{"cmd":"ls"}', 'file-a'), toolCall('call-2', '{"cmd":"ls"}', 'file-a'),
|
|
565
|
+
toolCall('call-3', '{"cmd":"date"}', 'now')),
|
|
566
|
+
],
|
|
567
|
+
kept: 7,
|
|
568
|
+
},
|
|
569
|
+
'全部無くなった': { next: [...turn('1つ目')], kept: 0 },
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
for (const [name, { next, kept }] of Object.entries(cases)) {
|
|
573
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-codex-incr-'));
|
|
574
|
+
const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
|
|
575
|
+
const db = makeDbWithSourceIndex();
|
|
576
|
+
const fresh = makeDbWithSourceIndex();
|
|
577
|
+
try {
|
|
578
|
+
const rollout = writeRollout(home, { id: threadId, cwd: project, events: base });
|
|
579
|
+
captureCodexRolloutToDb(db, { threadId, codexHome: home, projectPath: project, now: 1 });
|
|
580
|
+
assert.equal(detailSnapshot(db).length, 7, name);
|
|
581
|
+
|
|
582
|
+
writeRolloutRows(rollout, [sessionMeta(threadId, project), ...next]);
|
|
583
|
+
const again = captureCodexRolloutToDb(db, { threadId, codexHome: home, projectPath: project, now: 2 });
|
|
584
|
+
captureCodexRolloutToDb(fresh, { threadId, codexHome: home, projectPath: project, now: 2 });
|
|
585
|
+
|
|
586
|
+
assert.deepEqual(detailSnapshot(db), detailSnapshot(fresh), name);
|
|
587
|
+
assert.equal(again.keptDetails, kept, name);
|
|
588
|
+
} finally {
|
|
589
|
+
db.close(); fresh.close();
|
|
590
|
+
rmSync(home, { recursive: true, force: true }); rmSync(project, { recursive: true, force: true });
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test('rolloutに時刻の無いL3の行は、再captureしても最初に取り込んだ時刻のまま残る', () => {
|
|
596
|
+
const db = makeDbWithSourceIndex();
|
|
597
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-codex-incr-'));
|
|
598
|
+
const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
|
|
599
|
+
const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
|
|
600
|
+
try {
|
|
601
|
+
const untimed = { type: 'response_item', payload: { type: 'function_call', call_id: 'call-1', name: 'shell', arguments: '{}' } };
|
|
602
|
+
writeRollout(home, { id: threadId, cwd: project, events: [
|
|
603
|
+
event('task_started'), event('user_message', { message: '作業を続ける' }), untimed,
|
|
604
|
+
] });
|
|
605
|
+
captureCodexRolloutToDb(db, { threadId, codexHome: home, projectPath: project, now: 1000 });
|
|
606
|
+
const again = captureCodexRolloutToDb(db, { threadId, codexHome: home, projectPath: project, now: 2000 });
|
|
607
|
+
assert.equal(again.keptDetails, 1);
|
|
608
|
+
assert.equal(db.prepare('SELECT created_at FROM details').get().created_at, 1000);
|
|
609
|
+
} finally {
|
|
610
|
+
db.close(); rmSync(home, { recursive: true, force: true }); rmSync(project, { recursive: true, force: true });
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
|
|
462
614
|
function writeRollout(home, { id, cwd, events }) {
|
|
463
615
|
const dir = join(home, 'sessions', '2026', '05', '06');
|
|
464
616
|
mkdirSync(dir, { recursive: true });
|
|
@@ -52,6 +52,50 @@ test('getDb configures a bounded SQLite busy timeout for concurrent hook process
|
|
|
52
52
|
}
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
+
test('getDbは、膨らんだWALを次の書き込みで上限まで切り詰める', () => {
|
|
56
|
+
const home = mkdtempSync(join(tmpdir(), 'throughline-db-wal-limit-'));
|
|
57
|
+
try {
|
|
58
|
+
const env = { ...process.env, HOME: home, USERPROFILE: home };
|
|
59
|
+
// 上限を持たない接続で、checkpoint を止めたまま WAL を膨らませ、そのまま閉じずに終わる。
|
|
60
|
+
const grow = spawnSync(process.execPath, ['--input-type=module', '-e', `
|
|
61
|
+
import { statSync } from 'node:fs';
|
|
62
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
63
|
+
import { getDb, DB_PATH, WAL_SIZE_LIMIT_BYTES } from ${JSON.stringify(DB_MODULE_URL)};
|
|
64
|
+
getDb().close();
|
|
65
|
+
const db = new DatabaseSync(DB_PATH);
|
|
66
|
+
db.exec('PRAGMA wal_autocheckpoint = 0');
|
|
67
|
+
db.exec('CREATE TABLE filler (id INTEGER PRIMARY KEY, body TEXT)');
|
|
68
|
+
const insert = db.prepare('INSERT INTO filler (body) VALUES (?)');
|
|
69
|
+
const body = 'x'.repeat(1024 * 1024);
|
|
70
|
+
while (statSync(DB_PATH + '-wal').size <= WAL_SIZE_LIMIT_BYTES + 8 * 1024 * 1024) insert.run(body);
|
|
71
|
+
db.exec('DELETE FROM filler');
|
|
72
|
+
console.log(JSON.stringify({ wal: statSync(DB_PATH + '-wal').size, limit: WAL_SIZE_LIMIT_BYTES }));
|
|
73
|
+
process.exit(0);
|
|
74
|
+
`], { env, encoding: 'utf8' });
|
|
75
|
+
assert.equal(grow.status, 0, grow.stderr);
|
|
76
|
+
const grown = JSON.parse(grow.stdout);
|
|
77
|
+
assert.ok(grown.wal > grown.limit, `WAL が膨らんでいない: ${grow.stdout}`);
|
|
78
|
+
|
|
79
|
+
// 製品の入口で開いた接続が、checkpoint の後の最初の書き込みで切り詰める。
|
|
80
|
+
const shrink = spawnSync(process.execPath, ['--input-type=module', '-e', `
|
|
81
|
+
import { statSync } from 'node:fs';
|
|
82
|
+
import { getDb, DB_PATH } from ${JSON.stringify(DB_MODULE_URL)};
|
|
83
|
+
const db = getDb();
|
|
84
|
+
const limit = db.prepare('PRAGMA journal_size_limit').get().journal_size_limit;
|
|
85
|
+
db.exec('PRAGMA wal_checkpoint(PASSIVE)');
|
|
86
|
+
db.prepare("INSERT INTO sessions (session_id, project_path, status, created_at, updated_at) VALUES ('wal-limit', '/tmp', 'active', 1, 1)").run();
|
|
87
|
+
console.log(JSON.stringify({ limit, wal: statSync(DB_PATH + '-wal').size }));
|
|
88
|
+
process.exit(0);
|
|
89
|
+
`], { env, encoding: 'utf8' });
|
|
90
|
+
assert.equal(shrink.status, 0, shrink.stderr);
|
|
91
|
+
const shrunk = JSON.parse(shrink.stdout);
|
|
92
|
+
assert.equal(shrunk.limit, grown.limit);
|
|
93
|
+
assert.ok(shrunk.wal <= grown.limit, `WAL が上限を超えたまま: ${shrink.stdout}`);
|
|
94
|
+
} finally {
|
|
95
|
+
rmSync(home, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
55
99
|
test('getDb reuses an existing WAL database while another process owns the writer lock', async () => {
|
|
56
100
|
const home = mkdtempSync(join(tmpdir(), 'throughline-db-contention-'));
|
|
57
101
|
const env = { ...process.env, HOME: home, USERPROFILE: home };
|
package/src/db.mjs
CHANGED
|
@@ -10,6 +10,9 @@ import { join } from 'path';
|
|
|
10
10
|
const DB_DIR = join(homedir(), '.throughline');
|
|
11
11
|
export const DB_PATH = join(DB_DIR, 'throughline.db');
|
|
12
12
|
export const DB_BUSY_TIMEOUT_MS = 5_000;
|
|
13
|
+
// WAL が checkpoint で巻き戻った後、次の書き込みでファイルをこの大きさまで切り詰める (ADR 0042)。
|
|
14
|
+
// 既定 (-1) は切り詰めないので、一度膨らんだ WAL は中身が空でも元の大きさのまま残る。
|
|
15
|
+
export const WAL_SIZE_LIMIT_BYTES = 64 * 1024 * 1024;
|
|
13
16
|
export const CURRENT_VERSION = 12;
|
|
14
17
|
|
|
15
18
|
let _db = null;
|
|
@@ -485,6 +488,7 @@ export function getDb() {
|
|
|
485
488
|
db.exec(`PRAGMA busy_timeout = ${DB_BUSY_TIMEOUT_MS}`);
|
|
486
489
|
settleFirstRead(db);
|
|
487
490
|
ensureWalJournalMode(db);
|
|
491
|
+
db.exec(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES}`);
|
|
488
492
|
db.exec('PRAGMA foreign_keys = ON');
|
|
489
493
|
initSchema(db);
|
|
490
494
|
_db = db;
|