throughline 0.12.7 → 0.12.9
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 +23 -1
- package/README.md +5 -3
- package/docs/adr/0025-runtime-error-reporting.md +1 -1
- package/docs/adr/0030-report-installed-version-once.md +48 -0
- package/docs/adr/0031-serialized-schema-migration.md +43 -0
- package/package.json +1 -1
- package/src/db-contention.test.mjs +27 -0
- package/src/db.mjs +62 -10
- package/src/hook-entrypoints.test.mjs +3 -1
- package/src/hook-failure-log.mjs +17 -1
- package/src/hook-failure-log.test.mjs +36 -2
- package/src/hosts/cursor.test.mjs +10 -0
- package/src/hosts/index.mjs +9 -1
- package/src/runtime-error-report.mjs +15 -3
- package/src/runtime-error-report.test.mjs +70 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,26 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.12.9] — 2026-10-04
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- 新しいDBを複数のhookが同時に開くと、どれかが`no such table: judgments`か`database is locked`で落ちる不具合を直す([ADR 0031](docs/adr/0031-serialized-schema-migration.md))。WindowsのCursorの会話では同じhookが2本ほぼ同時に走るので、新しい端末の最初の会話で起きる。schemaの移行は、書き込みlockを取ってから版を読み直し、1つのtransactionで終える。WALへの切り替えは、断られたら読み直して5秒まで待つ。既に現行schemaのDBを開く時の動作は変わらない。
|
|
18
|
+
- 切り離した置き場で再現した。新しいDBを6 processが同時に開くと、Linuxで6回中3回、どれか1本が落ちた。Windowsでも、最初の1回で2本が落ちた。修理後は48回(6 process)で失敗0。
|
|
19
|
+
|
|
20
|
+
### 変更
|
|
21
|
+
|
|
22
|
+
- `~/.throughline/logs/hook-failures.log`に、失敗した位置(`stack`、先頭6行まで)を足す。`database is locked`のような文面だけでは、どの処理で落ちたかが分からないため。端末内にだけ残し、外へは送らない。
|
|
23
|
+
- hookのstdinがJSONとして読めない時の文面を`hook stdin is not valid JSON (N chars): …`にする。hostがstdinを渡さずに閉じた時(0字)と、途中で切れた時を見分けられる。失敗として数えることと、終了codeは変えない。
|
|
24
|
+
- 2026-10-04にWindows(0.12.4)で起きたSessionStartとUserPromptSubmitの失敗各1回は、原因が分かっていない。どちらもCursorの会話で2本ほぼ同時に走ったhookの片方で、もう片方は成功している。現行schemaのDBで起きたので、上の修正の形ではない。この2つの変更は、次に起きた時に理由を端末へ残すためのもの。
|
|
25
|
+
|
|
26
|
+
## [0.12.8] — 2026-10-04
|
|
27
|
+
|
|
28
|
+
### 変更
|
|
29
|
+
|
|
30
|
+
- runtime errorの送信を有効にした端末は、未受領の記録が無くても、受け口へ最後に届いた版と今の版が違う時に、`runtime_errors`と`resolutions`が空のreportを1回送る([ADR 0030](docs/adr/0030-report-installed-version-once.md))。reportは`installed_version`を持つので、受け口は記録が出ていない端末の版も分かる。届いた後は、版が変わるまで送らない。reportの形、署名、送る時機と間隔は変えない。送信を有効にしていない端末(既定)と、収集が無効な端末は、今までどおり何も送らない。
|
|
31
|
+
- 0.12.7以前は、未受領の記録が無ければ通信しなかった。更新した端末で何も起きなければ、受け口は古い版を持ち続けていた。
|
|
32
|
+
|
|
13
33
|
## [0.12.7] — 2026-10-04
|
|
14
34
|
|
|
15
35
|
### 修正
|
|
@@ -1614,7 +1634,9 @@ two attempts, instrument first instead of patching again.
|
|
|
1614
1634
|
|
|
1615
1635
|
---
|
|
1616
1636
|
|
|
1617
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.12.
|
|
1637
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.12.9...HEAD
|
|
1638
|
+
[0.12.9]: https://github.com/kitepon/Throughline/compare/v0.12.8...v0.12.9
|
|
1639
|
+
[0.12.8]: https://github.com/kitepon/Throughline/compare/v0.12.7...v0.12.8
|
|
1618
1640
|
[0.12.7]: https://github.com/kitepon/Throughline/compare/v0.12.6...v0.12.7
|
|
1619
1641
|
[0.12.6]: https://github.com/kitepon/Throughline/compare/v0.12.5...v0.12.6
|
|
1620
1642
|
[0.12.5]: https://github.com/kitepon/Throughline/compare/v0.12.4...v0.12.5
|
package/README.md
CHANGED
|
@@ -890,7 +890,7 @@ once with `npm install --global throughline@latest`, then run
|
|
|
890
890
|
| `throughline runtime-errors compact --json` | Remove only acknowledged, resolved aggregates after retention; open or unacknowledged records remain |
|
|
891
891
|
| `throughline runtime-errors report-enable --credential-file <path> --json` | Opt in to sending aggregates to the receiver named in that credential file; sending is disabled by default |
|
|
892
892
|
| `throughline runtime-errors report-disable --json` | Stop sending aggregates |
|
|
893
|
-
| `throughline runtime-errors report --json` | Send unacknowledged aggregates once now; exits 0 only for `sent` or `nothing_pending` |
|
|
893
|
+
| `throughline runtime-errors report --json` | Send unacknowledged aggregates once now (or an empty report when the receiver has not seen this version yet); exits 0 only for `sent` or `nothing_pending` |
|
|
894
894
|
| `throughline runtime-errors report-status --json` | Read the last attempt time and fixed result code without exposing the receiver, credential, or paths |
|
|
895
895
|
| `throughline handoff-preview --session <id>` | Print a Codex-facing `throughline_handoff` JSON projection |
|
|
896
896
|
| `throughline handoff-context (--session <id> \| --project <path>) --json` | Print the SessionStart inheritance context without moving memory rows. Project mode selects the newest captured session with dialogue, supports `--disclosure silent`, and returns `empty` when no dialogue exists. Session mode may add a project-bound supplement inside the same 9,500-character budget |
|
|
@@ -948,8 +948,10 @@ throughline runtime-errors report-status --json
|
|
|
948
948
|
```
|
|
949
949
|
|
|
950
950
|
Once enabled, the Claude and Codex hook entry points start a detached sender at
|
|
951
|
-
most once per hour
|
|
952
|
-
|
|
951
|
+
most once per hour; the hooks do not wait for it. It contacts the receiver when
|
|
952
|
+
unacknowledged records exist, or once after the installed version changed (a
|
|
953
|
+
report with empty `runtime_errors` and `resolutions`, so the receiver learns the
|
|
954
|
+
version; see [ADR 0030](docs/adr/0030-report-installed-version-once.md)). The body carries the public snapshot fields
|
|
953
955
|
only (fixed error code, template, count, timestamps, version, resolutions). The
|
|
954
956
|
secret is never transmitted: the body is signed with
|
|
955
957
|
`HMAC-SHA256(secret, ts + "\n" + SHA-256(body))`, redirects are not followed,
|
|
@@ -38,7 +38,7 @@ BugHub は、端末に入る CLI 製品が自分の分だけを送る受け口
|
|
|
38
38
|
7. 送る時機は hook 入口。`process-turn`・`session-start`・`prompt-submit`・`codex-hook` の開始時に、
|
|
39
39
|
送信が有効で、次に試してよい時刻を過ぎていれば、切り離した子 process を1つ起こす。
|
|
40
40
|
hook の終了は待たせない。子 process が store lock の下で時刻を先に書くので、同時に起きても送るのは1つ。
|
|
41
|
-
|
|
41
|
+
未受領の記録が無ければ通信しない(2026-10-04 [ADR 0030](0030-report-installed-version-once.md)で、受け口へ届いた版と今の版が違う時だけ、空の report を1回送ることにした)。間隔は1時間。credential や report の形で断られた時は24時間空ける。
|
|
42
42
|
`throughline runtime-errors report --json` は間隔を待たずに1回送る。
|
|
43
43
|
8. 送信の状態(最後に試した時刻・結果の固定 code・次の時刻)は `runtime-errors.report.state.json` に持ち、
|
|
44
44
|
`runtime-errors report-status --json` で読む。宛先・credential・path は出さない。
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# ADR 0030: 未受領の記録が無くても、受け口へ届いた版と今の版が違う時は、空の report を1回送る
|
|
2
|
+
|
|
3
|
+
日付: 2026-10-04
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
ADR 0025 Decision 7 は、未受領の記録(runtime error と解決記録)が無ければ通信しないと決めている。
|
|
8
|
+
report は毎回 `installed_version` を持つが、記録が無い端末からは report が出ない。
|
|
9
|
+
|
|
10
|
+
BugHub は 2026-10-04 から、工場の report を送らない端末について、製品の report の `installed_version` と
|
|
11
|
+
`observed_at` を端末の導入版として表示する。受け口は `runtime_errors` と `resolutions` が空の report も受ける
|
|
12
|
+
(BugHub の担当が確認。同じ端末×製品は1分に1回まで)。
|
|
13
|
+
|
|
14
|
+
BellTeam コンテナは工場の report を送らない。0.12.7 へ更新して未解決が0件になった後は、
|
|
15
|
+
次に runtime error か解決記録が出るまで report が出ず、BugHub の導入版は「未確認」のままになる。
|
|
16
|
+
更新した端末で何も起きなければ、受け口は古い版を持ち続ける。
|
|
17
|
+
|
|
18
|
+
オーナーは 2026-10-04 に、エラーが無い時にも版を知らせる report を送ってよいか、送るなら
|
|
19
|
+
版が変わった時に1回だけか・1時間ごとかを選び、「版が変わった時に1回だけ送る」と決めた。
|
|
20
|
+
|
|
21
|
+
## Decision
|
|
22
|
+
|
|
23
|
+
1. 送信の状態(`runtime-errors.report.state.json`)に、受け口が受領した最後の report の版
|
|
24
|
+
`last_reported_version` を持つ。書くのは、report が `sent`(ADR 0025 Decision 6 の受領がそろった時)に
|
|
25
|
+
なった時だけ。記録を載せた report でも、空の report でも同じ。
|
|
26
|
+
2. 未受領の記録が無い時、`last_reported_version` が今の版と同じなら、今までどおり通信しない(`nothing_pending`)。
|
|
27
|
+
違う時(版を上げた・下げた後、この項目を持たない旧版の state)は、`runtime_errors` と `resolutions` が
|
|
28
|
+
空の report を1回送る。
|
|
29
|
+
3. 空の report の形は ADR 0025 と同じ。項目は `schema_version`・`report_id`・`product_id`・
|
|
30
|
+
`installed_version`・`observed_at`・空の `runtime_errors`・空の `resolutions`。wire の版は変えない。
|
|
31
|
+
署名、redirect を追わないこと、受領の確認も同じ。
|
|
32
|
+
4. 送るのは、今までどおり `report-enable` で明示して有効にし、収集も有効な端末だけ。
|
|
33
|
+
送信が無効な端末と収集が無効な端末は、版も送らない。
|
|
34
|
+
5. 送る時機と間隔は変えない(hook 入口、1時間、断られた時は ADR 0025 の間隔)。
|
|
35
|
+
断られた時と届いたか分からない時は `last_reported_version` を書かず、次の送信でもう一度送る。
|
|
36
|
+
6. `runtime-errors report --json` の結果は `sent`(`runtime_errors: 0`・`resolutions: 0`)。
|
|
37
|
+
`report-status` の項目は変えない。
|
|
38
|
+
|
|
39
|
+
## Consequences
|
|
40
|
+
|
|
41
|
+
- 送信を有効にした端末は、0.12.8 へ上げた後に1回、その後は版が変わるたびに1回、空の report を送る。
|
|
42
|
+
受け口には、端末がその版で動いた時刻が1つ届く。
|
|
43
|
+
- 版が同じ間は、記録が無ければ通信しない。端末が動いているかどうかは、この report からは分からない。
|
|
44
|
+
- 版を上げた後、次の送信の時刻(最長1時間)と、その後の最初の hook までは届かない。
|
|
45
|
+
急ぐ時は `runtime-errors report --json` を回す。
|
|
46
|
+
- 0.12.7 以前へ戻すと、旧版は state を書き直す時に `last_reported_version` を落とす。
|
|
47
|
+
再び上げた時に、もう1回送る。
|
|
48
|
+
- 既定の利用者(送信を有効にしていない端末)の動作は変わらない。
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# ADR 0031: schema の移行は書き込み lock の中で版を読み直して行い、WAL への切り替えは読み直して待つ
|
|
2
|
+
|
|
3
|
+
日付: 2026-10-04
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
`getDb()` は DB を開くたびに `PRAGMA user_version` を読み、現行版より古ければ移行を走らせていた。
|
|
8
|
+
版を読むのも、移行の各 statement も、transaction の外だった。
|
|
9
|
+
|
|
10
|
+
Windows の Cursor の会話では、同じ hook が2本ほぼ同時に走る(2026-10-04 の記録では、1会話の SessionStart が
|
|
11
|
+
数ms〜320ms の間隔で2行残る。`~/.cursor/hooks.json` と `~/.claude/settings.json` の両方に Throughline の登録がある。
|
|
12
|
+
2本目がどちらの登録から来るかは確かめていない)。新しい端末の最初の会話では、2本とも版 0 を読む。片方が先に移行を終えると、もう片方は版 0 のつもりで
|
|
13
|
+
v2 の移行(`DELETE FROM judgments ...`)を走らせ、v4 で消えた表へ触って `no such table: judgments` で落ちる。
|
|
14
|
+
|
|
15
|
+
もう1つ、新しい DB の journal mode を WAL へ切り替える `PRAGMA journal_mode = WAL` は排他 lock を要る。
|
|
16
|
+
他の process が同じ DB を開いている間は、`busy_timeout` を待たずに `database is locked` で断られる。
|
|
17
|
+
|
|
18
|
+
2026-10-04 に、切り離した置き場で再現した。新しい DB を 6 process が同時に開くと、Linux(Node 26)で
|
|
19
|
+
6回中3回、どれか1本が上のどちらかで落ちた。Windows(Node 24)でも、SessionStart と UserPromptSubmit を
|
|
20
|
+
2本ずつ同時に動かした最初の1回で2本が `no such table: judgments` で落ちた。
|
|
21
|
+
既に現行 schema の DB では、どちらも起きない(版が現行なら移行は何も書かず、WAL なら切り替えない)。
|
|
22
|
+
|
|
23
|
+
## Decision
|
|
24
|
+
|
|
25
|
+
1. 移行は `BEGIN IMMEDIATE` で書き込み lock を取り、その中で `PRAGMA user_version` を読み直してから走らせ、
|
|
26
|
+
`user_version` の更新まで1つの transaction で commit する。失敗した時は rollback し、途中の schema を残さない。
|
|
27
|
+
2. lock の外で読んだ版が現行版以上なら、lock を取らない。現行 schema の DB を開く hook は、今までどおり何も書かない。
|
|
28
|
+
3. 他の process が移行している間に開いた process は、`busy_timeout`(5秒)まで lock を待ち、
|
|
29
|
+
取れた後に読み直した版が現行なら何もしない。
|
|
30
|
+
4. WAL への切り替えは、`database is locked` で断られたら 25ms 待って journal mode を読み直し、
|
|
31
|
+
`busy_timeout` と同じ時間まで繰り返す。他の process が先に切り替えれば、読み直しで WAL が見えて終わる。
|
|
32
|
+
5. `migrate --json`(ADR 0018 の正規入口)も同じ関数を通る。結果の形は変えない。
|
|
33
|
+
|
|
34
|
+
## Consequences
|
|
35
|
+
|
|
36
|
+
- 新しい端末で複数の hook が同時に最初の DB を開いても、全員が現行 schema で開ける。
|
|
37
|
+
- 古い schema からの移行中、他の hook は最長5秒待つ。移行が5秒を超えると、待った hook は
|
|
38
|
+
`database is locked` で失敗する。今までは、移行の途中の schema で走るか、同じ移行をもう一度走らせていた。
|
|
39
|
+
- 移行が途中で失敗した DB は、移行前の schema のまま残る。次に開いた時に最初からやり直す。
|
|
40
|
+
- 2026-10-04 に Windows(0.12.4)で起きた SessionStart と UserPromptSubmit の失敗各1回は、
|
|
41
|
+
現行 schema の DB で起きている。この ADR の2つの形ではなく、原因は分かっていない。
|
|
42
|
+
理由の文面は 0.12.4 では端末に残らない。0.12.9 は `hook-failures.log` に失敗した位置(stack の先頭)を足し、
|
|
43
|
+
hook の stdin が JSON として読めない時の文面に字数を入れる。
|
package/package.json
CHANGED
|
@@ -93,3 +93,30 @@ test('getDb reuses an existing WAL database while another process owns the write
|
|
|
93
93
|
rmSync(home, { recursive: true, force: true });
|
|
94
94
|
}
|
|
95
95
|
});
|
|
96
|
+
|
|
97
|
+
test('getDbは、新しいDBを複数のprocessが同時に開いても、全員が現行schemaで開ける', async () => {
|
|
98
|
+
// WindowsのCursorの会話では同じhookが2本ほぼ同時に走る。初回はどのprocessも版0を読むので、lockの外で読んだ版から
|
|
99
|
+
// 移行をやり直すと、先に進んだprocessが消した表へ触って落ちた(修理前は6本で半分ほどの確率)。
|
|
100
|
+
const rounds = 6;
|
|
101
|
+
const processes = 6;
|
|
102
|
+
for (let round = 0; round < rounds; round += 1) {
|
|
103
|
+
const home = mkdtempSync(join(tmpdir(), 'throughline-db-first-open-'));
|
|
104
|
+
const env = { ...process.env, HOME: home, USERPROFILE: home };
|
|
105
|
+
try {
|
|
106
|
+
const results = await Promise.all(Array.from({ length: processes }, () => {
|
|
107
|
+
const child = spawn(process.execPath, ['--input-type=module', '-e', `
|
|
108
|
+
import { getDb, CURRENT_VERSION } from ${JSON.stringify(DB_MODULE_URL)};
|
|
109
|
+
const db = getDb();
|
|
110
|
+
const version = db.prepare('PRAGMA user_version').get().user_version;
|
|
111
|
+
db.prepare('SELECT count(*) AS n FROM sessions').get();
|
|
112
|
+
db.close();
|
|
113
|
+
if (version !== CURRENT_VERSION) process.exit(2);
|
|
114
|
+
`], { env, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
115
|
+
return waitForExit(child);
|
|
116
|
+
}));
|
|
117
|
+
assert.deepEqual(results, Array.from({ length: processes }, () => ({ code: 0, stderr: '' })));
|
|
118
|
+
} finally {
|
|
119
|
+
rmSync(home, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
});
|
package/src/db.mjs
CHANGED
|
@@ -25,10 +25,68 @@ export function openReadOnlyDb(path = DB_PATH) {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
const WAL_SWITCH_RETRY_MS = 25;
|
|
29
|
+
|
|
30
|
+
function isSqliteBusy(error) {
|
|
31
|
+
if (typeof error?.errcode === 'number') return (error.errcode & 0xff) === 5;
|
|
32
|
+
return /database is locked/u.test(String(error?.message ?? ''));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sleepSync(ms) {
|
|
36
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* journal mode を WAL にする。既に WAL なら何も書かない。
|
|
41
|
+
*
|
|
42
|
+
* WAL への切り替えは排他 lock を要り、他の process が同じ DB を開いている間は busy_timeout を待たずに
|
|
43
|
+
* `database is locked` で断られる。新しい DB を複数の hook が同時に開く時に起きるので、
|
|
44
|
+
* busy_timeout と同じ時間まで読み直す (ADR 0031)。他の process が先に切り替えれば、読み直しで WAL が見える。
|
|
45
|
+
*/
|
|
46
|
+
function ensureWalJournalMode(db) {
|
|
47
|
+
const deadline = Date.now() + DB_BUSY_TIMEOUT_MS;
|
|
48
|
+
for (;;) {
|
|
49
|
+
try {
|
|
50
|
+
const journalMode = db.prepare('PRAGMA journal_mode').get().journal_mode;
|
|
51
|
+
if (String(journalMode).toLowerCase() === 'wal') return;
|
|
52
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
53
|
+
return;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (!isSqliteBusy(error) || Date.now() >= deadline) throw error;
|
|
56
|
+
sleepSync(WAL_SWITCH_RETRY_MS);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readSchemaVersion(db) {
|
|
62
|
+
return db.prepare('PRAGMA user_version').get().user_version ?? 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* schema を現行版へ上げる。現行版なら何も書かない。
|
|
67
|
+
*
|
|
68
|
+
* 移行は書き込み lock を取ってから版を読み直し、1つの transaction で終える (ADR 0031)。
|
|
69
|
+
* lock の外で読んだ版は、他の process が移行を進めた後では古い。古い版から移行をやり直すと、
|
|
70
|
+
* 途中の版で消した表(v4 の judgments)へ触って `no such table` で落ちる。
|
|
71
|
+
*/
|
|
28
72
|
function initSchema(db) {
|
|
29
|
-
|
|
30
|
-
|
|
73
|
+
if (readSchemaVersion(db) >= CURRENT_VERSION) return;
|
|
74
|
+
db.exec('BEGIN IMMEDIATE');
|
|
75
|
+
try {
|
|
76
|
+
const version = readSchemaVersion(db);
|
|
77
|
+
if (version < CURRENT_VERSION) applySchemaMigrations(db, version);
|
|
78
|
+
db.exec('COMMIT');
|
|
79
|
+
} catch (error) {
|
|
80
|
+
try {
|
|
81
|
+
db.exec('ROLLBACK');
|
|
82
|
+
} catch {
|
|
83
|
+
// ignore
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
31
88
|
|
|
89
|
+
function applySchemaMigrations(db, version) {
|
|
32
90
|
// v0 → v1: 全テーブル作成
|
|
33
91
|
if (version < 1) {
|
|
34
92
|
db.exec(`
|
|
@@ -353,10 +411,7 @@ export function migrateDefaultDb() {
|
|
|
353
411
|
};
|
|
354
412
|
}
|
|
355
413
|
|
|
356
|
-
|
|
357
|
-
if (String(journalMode).toLowerCase() !== 'wal') {
|
|
358
|
-
db.exec('PRAGMA journal_mode = WAL');
|
|
359
|
-
}
|
|
414
|
+
ensureWalJournalMode(db);
|
|
360
415
|
initSchema(db);
|
|
361
416
|
const afterSchemaVersion = Number(db.prepare('PRAGMA user_version').get().user_version ?? 0);
|
|
362
417
|
if (afterSchemaVersion !== CURRENT_VERSION) {
|
|
@@ -397,10 +452,7 @@ export function getDb() {
|
|
|
397
452
|
const db = new DatabaseSync(DB_PATH);
|
|
398
453
|
try {
|
|
399
454
|
db.exec(`PRAGMA busy_timeout = ${DB_BUSY_TIMEOUT_MS}`);
|
|
400
|
-
|
|
401
|
-
if (String(journalMode).toLowerCase() !== 'wal') {
|
|
402
|
-
db.exec('PRAGMA journal_mode = WAL');
|
|
403
|
-
}
|
|
455
|
+
ensureWalJournalMode(db);
|
|
404
456
|
db.exec('PRAGMA foreign_keys = ON');
|
|
405
457
|
initSchema(db);
|
|
406
458
|
_db = db;
|
|
@@ -976,7 +976,9 @@ test('a failed hook leaves its reason in the local hook failure log', () => {
|
|
|
976
976
|
['HOOK_SESSION_START_FAILED', 'Error', 'Missing session_id in SessionStart payload'],
|
|
977
977
|
]);
|
|
978
978
|
for (const entry of entries) {
|
|
979
|
-
assert.deepEqual(Object.keys(entry), ['ts', 'code', 'version', 'name', 'message']);
|
|
979
|
+
assert.deepEqual(Object.keys(entry), ['ts', 'code', 'version', 'name', 'message', 'stack']);
|
|
980
|
+
// 失敗した位置は、payloadを検査して投げたhookの入口を指す。
|
|
981
|
+
assert.match(entry.stack[0], /^at .*(turn-processor|session-start)\.mjs:\d+:\d+\)?$/);
|
|
980
982
|
assert.match(entry.ts, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
|
981
983
|
assert.match(entry.version, /^\d+\.\d+\.\d+$/);
|
|
982
984
|
}
|
package/src/hook-failure-log.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* runtime error store は外へ送れる形(定型 code と回数)だけを持つ (ADR 0025)。失敗した時の
|
|
5
5
|
* 例外の文面はどこにも残らず、後から原因を追えなかった(2026-10-04、Mac と main-server の
|
|
6
|
-
* 7〜8
|
|
6
|
+
* 7〜8月の失敗)。このログは理由と失敗した位置(stack の先頭)を端末内にだけ残す。runtime error の収集・送信の設定とは
|
|
7
7
|
* 独立で、外へは送らない。
|
|
8
8
|
* 書き込み失敗は stderr に出す(黙って握りつぶさない)。
|
|
9
9
|
*/
|
|
@@ -17,11 +17,23 @@ const require = createRequire(import.meta.url);
|
|
|
17
17
|
const PACKAGE_VERSION = require('../package.json').version;
|
|
18
18
|
|
|
19
19
|
export const HOOK_FAILURE_MESSAGE_LIMIT = 1000;
|
|
20
|
+
export const HOOK_FAILURE_STACK_FRAMES = 6;
|
|
21
|
+
export const HOOK_FAILURE_STACK_FRAME_LIMIT = 300;
|
|
20
22
|
|
|
21
23
|
export function hookFailureLogPath({ home = homedir() } = {}) {
|
|
22
24
|
return join(home, '.throughline', 'logs', 'hook-failures.log');
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
function stackFrames(error) {
|
|
28
|
+
if (!(error instanceof Error) || typeof error.stack !== 'string') return [];
|
|
29
|
+
return error.stack
|
|
30
|
+
.split('\n')
|
|
31
|
+
.map((line) => line.trim())
|
|
32
|
+
.filter((line) => line.startsWith('at '))
|
|
33
|
+
.slice(0, HOOK_FAILURE_STACK_FRAMES)
|
|
34
|
+
.map((line) => line.slice(0, HOOK_FAILURE_STACK_FRAME_LIMIT));
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
/**
|
|
26
38
|
* @param {string} code runtime error の定型 code(例: HOOK_PROCESS_TURN_FAILED)
|
|
27
39
|
* @param {unknown} error 失敗の原因
|
|
@@ -36,6 +48,10 @@ export function logHookFailure(code, error, { home, now = Date.now(), stderr = p
|
|
|
36
48
|
name: error instanceof Error ? error.name : typeof error,
|
|
37
49
|
message: message.slice(0, HOOK_FAILURE_MESSAGE_LIMIT),
|
|
38
50
|
};
|
|
51
|
+
// 失敗した位置を残す。`database is locked` のような文面だけでは、DB を開く時か書く時か、
|
|
52
|
+
// どの処理で落ちたかが分からない(2026-10-04、Windows の SessionStart と UserPromptSubmit の失敗)。
|
|
53
|
+
const frames = stackFrames(error);
|
|
54
|
+
if (frames.length > 0) entry.stack = frames;
|
|
39
55
|
// どの会話の失敗かを残す(errorが `hookContext` を持つ時)。回数と文面だけでは、失敗した会話を
|
|
40
56
|
// 端末の他の記録から探すことになる。
|
|
41
57
|
const context = error?.hookContext;
|
|
@@ -3,7 +3,13 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
HOOK_FAILURE_MESSAGE_LIMIT,
|
|
8
|
+
HOOK_FAILURE_STACK_FRAMES,
|
|
9
|
+
HOOK_FAILURE_STACK_FRAME_LIMIT,
|
|
10
|
+
hookFailureLogPath,
|
|
11
|
+
logHookFailure,
|
|
12
|
+
} from './hook-failure-log.mjs';
|
|
7
13
|
|
|
8
14
|
test('logHookFailureは、失敗の理由を1行のJSONで追記する', () => {
|
|
9
15
|
const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
|
|
@@ -12,9 +18,12 @@ test('logHookFailureは、失敗の理由を1行のJSONで追記する', () => {
|
|
|
12
18
|
logHookFailure('HOOK_CODEX_FAILED', 'thrown string', { home, now: Date.UTC(2026, 9, 4, 0, 0, 1) });
|
|
13
19
|
const lines = readFileSync(hookFailureLogPath({ home }), 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
14
20
|
assert.equal(lines.length, 2);
|
|
15
|
-
|
|
21
|
+
const { stack, ...first } = lines[0];
|
|
22
|
+
assert.deepEqual({ ...first, version: 'x' }, {
|
|
16
23
|
ts: '2026-10-04T00:00:00.005Z', code: 'HOOK_PROCESS_TURN_FAILED', version: 'x', name: 'TypeError', message: 'first reason',
|
|
17
24
|
});
|
|
25
|
+
// Errorは失敗した位置を持つ。文字列を投げた時はstackが無いので、項目ごと残さない。
|
|
26
|
+
assert.equal(Array.isArray(stack) && stack.length > 0, true);
|
|
18
27
|
assert.deepEqual({ ...lines[1], version: 'x' }, {
|
|
19
28
|
ts: '2026-10-04T00:00:01.000Z', code: 'HOOK_CODEX_FAILED', version: 'x', name: 'string', message: 'thrown string',
|
|
20
29
|
});
|
|
@@ -61,6 +70,31 @@ test('logHookFailureは、どの会話の失敗かを残す', () => {
|
|
|
61
70
|
}
|
|
62
71
|
});
|
|
63
72
|
|
|
73
|
+
test('logHookFailureは、失敗した位置をstackの先頭から残す', () => {
|
|
74
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
|
|
75
|
+
try {
|
|
76
|
+
const err = new Error('database is locked');
|
|
77
|
+
err.stack = [
|
|
78
|
+
'Error: database is locked',
|
|
79
|
+
' at getDb (file:///throughline/src/db.mjs:455:5)',
|
|
80
|
+
` at ${'x'.repeat(HOOK_FAILURE_STACK_FRAME_LIMIT + 40)}`,
|
|
81
|
+
...Array.from({ length: HOOK_FAILURE_STACK_FRAMES + 3 }, (_, index) => ` at frame${index} (file:///throughline/src/a.mjs:${index}:1)`),
|
|
82
|
+
].join('\n');
|
|
83
|
+
logHookFailure('HOOK_SESSION_START_FAILED', err, { home });
|
|
84
|
+
const noFrames = new Error('no frames');
|
|
85
|
+
noFrames.stack = 'Error: no frames';
|
|
86
|
+
logHookFailure('HOOK_SESSION_START_FAILED', noFrames, { home });
|
|
87
|
+
const [entry, second] = readFileSync(hookFailureLogPath({ home }), 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
88
|
+
assert.equal(entry.stack.length, HOOK_FAILURE_STACK_FRAMES);
|
|
89
|
+
assert.equal(entry.stack[0], 'at getDb (file:///throughline/src/db.mjs:455:5)');
|
|
90
|
+
assert.equal(entry.stack[1].length, HOOK_FAILURE_STACK_FRAME_LIMIT);
|
|
91
|
+
assert.equal(entry.stack[2], 'at frame0 (file:///throughline/src/a.mjs:0:1)');
|
|
92
|
+
assert.equal('stack' in second, false);
|
|
93
|
+
} finally {
|
|
94
|
+
rmSync(home, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
64
98
|
test('logHookFailureは、長い文面を上限で切る', () => {
|
|
65
99
|
const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
|
|
66
100
|
try {
|
|
@@ -25,6 +25,16 @@ test('parseHookPayload accepts one leading BOM and preserves BOM inside prompt t
|
|
|
25
25
|
assert.throws(() => parseHookPayload('\uFEFF{"conversation_id":'), SyntaxError);
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
test('parseHookPayloadは、stdinが空の時と途中で切れた時を、文面の字数で見分けられるようにする', () => {
|
|
29
|
+
assert.throws(() => parseHookPayload(''), (error) => {
|
|
30
|
+
assert.equal(error instanceof SyntaxError, true);
|
|
31
|
+
assert.match(error.message, /^hook stdin is not valid JSON \(0 chars\): /u);
|
|
32
|
+
assert.equal(error.cause instanceof SyntaxError, true);
|
|
33
|
+
return true;
|
|
34
|
+
});
|
|
35
|
+
assert.throws(() => parseHookPayload('\uFEFF{"conversation_id":'), /^SyntaxError: hook stdin is not valid JSON \(20 chars\): /u);
|
|
36
|
+
});
|
|
37
|
+
|
|
28
38
|
test('isCursorEnvelope detects Cursor events and cursor_version', () => {
|
|
29
39
|
assert.equal(
|
|
30
40
|
isCursorEnvelope({
|
package/src/hosts/index.mjs
CHANGED
|
@@ -29,7 +29,15 @@ const ADAPTERS = Object.freeze({
|
|
|
29
29
|
|
|
30
30
|
/** Parse hook stdin, including the leading UTF-8 BOM emitted by Windows Cursor. */
|
|
31
31
|
export function parseHookPayload(raw, options = {}) {
|
|
32
|
-
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(raw.replace(/^\uFEFF/, ''));
|
|
35
|
+
} catch (error) {
|
|
36
|
+
// host が stdin を渡さずに閉じた時(0 字)と、途中で切れた時を、理由の文面で見分けられるようにする。
|
|
37
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
38
|
+
throw new SyntaxError(`hook stdin is not valid JSON (${raw.length} chars): ${detail}`, { cause: error });
|
|
39
|
+
}
|
|
40
|
+
return normalizeHookPayload(parsed, options);
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
/**
|
|
@@ -131,22 +131,30 @@ export async function reportRuntimeErrors(options = {}) {
|
|
|
131
131
|
|
|
132
132
|
// 記録が無効な端末でも次の時刻は書く。書かないと、hookのたびに送信processが起きる。
|
|
133
133
|
const outcome = isRuntimeErrorCollectionEnabled({ env })
|
|
134
|
-
? await sendPendingRecords({
|
|
134
|
+
? await sendPendingRecords({
|
|
135
|
+
env, nowMs, fetchImpl, storeOptions,
|
|
136
|
+
credentialFile: config.reporting.credential_file,
|
|
137
|
+
lastReportedVersion: claimed.last_reported_version,
|
|
138
|
+
})
|
|
135
139
|
: result('collection_disabled');
|
|
136
140
|
updateState(lockOptions, (state) => ({
|
|
137
141
|
...state,
|
|
138
142
|
last_result: outcome.status,
|
|
139
143
|
last_success_at: outcome.status === 'sent' ? new Date(nowMs).toISOString() : state.last_success_at,
|
|
144
|
+
last_reported_version: outcome.status === 'sent' ? PACKAGE_VERSION : state.last_reported_version,
|
|
140
145
|
next_attempt_at: new Date(nowMs + (LONG_BACKOFF_STATUSES.has(outcome.status)
|
|
141
146
|
? REPORT_REJECTED_BACKOFF_MS : REPORT_INTERVAL_MS)).toISOString(),
|
|
142
147
|
}));
|
|
143
148
|
return outcome;
|
|
144
149
|
}
|
|
145
150
|
|
|
146
|
-
async function sendPendingRecords({ env, nowMs, fetchImpl, storeOptions, credentialFile }) {
|
|
151
|
+
async function sendPendingRecords({ env, nowMs, fetchImpl, storeOptions, credentialFile, lastReportedVersion }) {
|
|
147
152
|
const acknowledged = readRuntimeErrorSnapshot({ ...storeOptions, limit: 1 }).cursor.acknowledged_through;
|
|
148
153
|
const snapshot = readRuntimeErrorSnapshot({ ...storeOptions, afterCursor: acknowledged });
|
|
149
|
-
|
|
154
|
+
// 未受領の記録が無くても、受け口へ最後に届いた版と今の版が違う時は、記録が空のreportを1回送る
|
|
155
|
+
// (ADR 0030)。受け口は installed_version を端末の導入版として持つ。届いた後は、版が変わるまで送らない。
|
|
156
|
+
if (snapshot.runtime_errors.length === 0 && snapshot.resolutions.length === 0 &&
|
|
157
|
+
lastReportedVersion === PACKAGE_VERSION) {
|
|
150
158
|
return result('nothing_pending', { acknowledged_through: acknowledged });
|
|
151
159
|
}
|
|
152
160
|
|
|
@@ -321,6 +329,7 @@ function emptyState() {
|
|
|
321
329
|
last_result: null,
|
|
322
330
|
last_success_at: null,
|
|
323
331
|
next_attempt_at: null,
|
|
332
|
+
last_reported_version: null,
|
|
324
333
|
};
|
|
325
334
|
}
|
|
326
335
|
|
|
@@ -332,6 +341,9 @@ function normalizeState(value) {
|
|
|
332
341
|
if (isCanonicalTimestamp(value[key])) state[key] = value[key];
|
|
333
342
|
}
|
|
334
343
|
if (RESULT_STATUSES.has(value.last_result)) state.last_result = value.last_result;
|
|
344
|
+
if (typeof value.last_reported_version === 'string' && /^[0-9A-Za-z.+-]{1,64}$/.test(value.last_reported_version)) {
|
|
345
|
+
state.last_reported_version = value.last_reported_version;
|
|
346
|
+
}
|
|
335
347
|
return state;
|
|
336
348
|
}
|
|
337
349
|
|
|
@@ -393,6 +393,76 @@ test('runtime error report: background runs respect the interval, explicit runs
|
|
|
393
393
|
}
|
|
394
394
|
});
|
|
395
395
|
|
|
396
|
+
test('runtime error report: with nothing pending, the installed version is reported once and again only after it changes', async () => {
|
|
397
|
+
const { root, env } = createEnvironment();
|
|
398
|
+
const installed = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
399
|
+
let reply = (entry) => accept(entry);
|
|
400
|
+
const receiver = await startReceiver((entry) => reply(entry));
|
|
401
|
+
const statePath = defaultRuntimeErrorReportStatePath(env);
|
|
402
|
+
const readState = () => JSON.parse(readFileSync(statePath, 'utf8'));
|
|
403
|
+
try {
|
|
404
|
+
setRuntimeErrorCollectionEnabled(true, { env });
|
|
405
|
+
setRuntimeErrorReportingEnabled(true, { env, credentialFile: writeCredential(root, receiver.url) });
|
|
406
|
+
|
|
407
|
+
// 断られた時は届いた版として覚えない。次の送信でもう一度送る。
|
|
408
|
+
reply = () => ({ status: 429, body: { error: 'rate_limited' } });
|
|
409
|
+
assert.equal((await reportRuntimeErrors({ env, now: NOW })).status, 'rate_limited');
|
|
410
|
+
assert.equal(readState().last_reported_version, null);
|
|
411
|
+
|
|
412
|
+
// 記録が1件も無い端末(0.12.7以前から上げた直後のstateも同じ): 版と時刻だけのreportを1回送る。
|
|
413
|
+
reply = (entry) => accept(entry);
|
|
414
|
+
const first = await reportRuntimeErrors({ env, now: NOW + 1000 });
|
|
415
|
+
assert.equal(first.status, 'sent');
|
|
416
|
+
assert.equal(first.runtime_errors, 0);
|
|
417
|
+
assert.equal(first.resolutions, 0);
|
|
418
|
+
assert.equal(receiver.requests.length, 2);
|
|
419
|
+
const sent = receiver.requests[1];
|
|
420
|
+
assert.equal(sent.signatureValid, true);
|
|
421
|
+
assert.deepEqual(sent.report, {
|
|
422
|
+
schema_version: '1.0',
|
|
423
|
+
report_id: first.report_id,
|
|
424
|
+
product_id: 'throughline',
|
|
425
|
+
installed_version: installed,
|
|
426
|
+
observed_at: new Date(NOW + 1000).toISOString(),
|
|
427
|
+
runtime_errors: [],
|
|
428
|
+
resolutions: [],
|
|
429
|
+
});
|
|
430
|
+
assert.equal(readState().last_reported_version, installed);
|
|
431
|
+
assert.equal(readRuntimeErrorSnapshot({ env }).cursor.acknowledged_through, 0);
|
|
432
|
+
|
|
433
|
+
// 同じ版のままなら、記録が無い間は通信しない。
|
|
434
|
+
assert.equal((await reportRuntimeErrors({ env, now: NOW + 2000 })).status, 'nothing_pending');
|
|
435
|
+
assert.equal((await reportRuntimeErrors({ env, now: NOW + 2 * REPORT_INTERVAL_MS, background: true })).status, 'nothing_pending');
|
|
436
|
+
assert.equal(receiver.requests.length, 2);
|
|
437
|
+
|
|
438
|
+
// 記録を送ったreportも、その版を届けたことになる。
|
|
439
|
+
observeRuntimeError({ code: 'HOOK_CODEX_FAILED' }, { env });
|
|
440
|
+
assert.equal((await reportRuntimeErrors({ env, now: NOW + 3 * REPORT_INTERVAL_MS })).status, 'sent');
|
|
441
|
+
assert.equal((await reportRuntimeErrors({ env, now: NOW + 3 * REPORT_INTERVAL_MS + 1000 })).status, 'nothing_pending');
|
|
442
|
+
assert.equal(receiver.requests.length, 3);
|
|
443
|
+
|
|
444
|
+
// 版が変わった(最後に届いた版が今の版と違う)時は、もう1回だけ送る。
|
|
445
|
+
writeFileSync(statePath, JSON.stringify({ ...readState(), last_reported_version: '0.0.1' }));
|
|
446
|
+
const afterUpdate = await reportRuntimeErrors({ env, now: NOW + 4 * REPORT_INTERVAL_MS });
|
|
447
|
+
assert.equal(afterUpdate.status, 'sent');
|
|
448
|
+
assert.deepEqual(receiver.requests[3].report.runtime_errors, []);
|
|
449
|
+
assert.equal(receiver.requests[3].report.installed_version, installed);
|
|
450
|
+
assert.equal((await reportRuntimeErrors({ env, now: NOW + 4 * REPORT_INTERVAL_MS + 1000 })).status, 'nothing_pending');
|
|
451
|
+
assert.equal(receiver.requests.length, 4);
|
|
452
|
+
|
|
453
|
+
// 記録を無効にした端末と、送信を無効にした端末は、版も送らない。
|
|
454
|
+
writeFileSync(statePath, JSON.stringify({ ...readState(), last_reported_version: '0.0.1' }));
|
|
455
|
+
setRuntimeErrorCollectionEnabled(false, { env });
|
|
456
|
+
assert.equal((await reportRuntimeErrors({ env, now: NOW + 5 * REPORT_INTERVAL_MS })).status, 'collection_disabled');
|
|
457
|
+
setRuntimeErrorCollectionEnabled(true, { env });
|
|
458
|
+
setRuntimeErrorReportingEnabled(false, { env });
|
|
459
|
+
assert.equal((await reportRuntimeErrors({ env, now: NOW + 6 * REPORT_INTERVAL_MS })).status, 'reporting_disabled');
|
|
460
|
+
assert.equal(receiver.requests.length, 4);
|
|
461
|
+
} finally {
|
|
462
|
+
await receiver.close();
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
396
466
|
test('runtime error report: an unsafe or malformed credential file is never used', { skip: process.platform === 'win32' }, async () => {
|
|
397
467
|
const { root, env } = createEnvironment();
|
|
398
468
|
let calls = 0;
|