throughline 0.12.6 → 0.12.8
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 +21 -1
- package/README.md +5 -3
- package/docs/adr/0012-claude-stop-transcript-flush-barrier.md +1 -0
- package/docs/adr/0025-runtime-error-reporting.md +1 -1
- package/docs/adr/0029-stop-flush-barrier-absent-transcript.md +62 -0
- package/docs/adr/0030-report-installed-version-once.md +48 -0
- package/package.json +1 -1
- package/src/hook-failure-log.mjs +7 -0
- package/src/hook-failure-log.test.mjs +20 -0
- package/src/runtime-error-hook.test.mjs +46 -1
- package/src/runtime-error-report.mjs +15 -3
- package/src/runtime-error-report.test.mjs +70 -0
- package/src/turn-processor.mjs +37 -1
- package/src/turn-processor.test.mjs +44 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,24 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.12.8] — 2026-10-04
|
|
14
|
+
|
|
15
|
+
### 変更
|
|
16
|
+
|
|
17
|
+
- runtime errorの送信を有効にした端末は、未受領の記録が無くても、受け口へ最後に届いた版と今の版が違う時に、`runtime_errors`と`resolutions`が空のreportを1回送る([ADR 0030](docs/adr/0030-report-installed-version-once.md))。reportは`installed_version`を持つので、受け口は記録が出ていない端末の版も分かる。届いた後は、版が変わるまで送らない。reportの形、署名、送る時機と間隔は変えない。送信を有効にしていない端末(既定)と、収集が無効な端末は、今までどおり何も送らない。
|
|
18
|
+
- 0.12.7以前は、未受領の記録が無ければ通信しなかった。更新した端末で何も起きなければ、受け口は古い版を持ち続けていた。
|
|
19
|
+
|
|
20
|
+
## [0.12.7] — 2026-10-04
|
|
21
|
+
|
|
22
|
+
### 修正
|
|
23
|
+
|
|
24
|
+
- Claude Stop hookで、transcriptのファイルが無い会話を`HOOK_PROCESS_TURN_FAILED`(Claude Stop hook processing failed・high)として数えていた不具合を直す([ADR 0029](docs/adr/0029-stop-flush-barrier-absent-transcript.md))。Claude Codeを`--no-session-persistence`で起動すると、hookへ`transcript_path`は渡るが、ファイルは最後まで作られない。保存する元が無いので、Stopは期限(2秒)まで待った後、`~/.throughline/logs/backfill.log`に`skipped: "transcript_absent"`を1行残して終了code 0で終わる。ファイルがあるのに完了が見えない時と、`transcript_path`がpayloadに無い時は、今までどおり失敗として数える。
|
|
25
|
+
- BellTeamコンテナ(0.12.6)で2026-10-04に2回起きた失敗は、transcriptがディスクに無い1会話のStopだった。Claude Code 2.1.289を`--no-session-persistence`で動かして、同じ文面の失敗を再現した。その2回の会話がこの引数で起動されたことを示す記録は無い。
|
|
26
|
+
|
|
27
|
+
### 変更
|
|
28
|
+
|
|
29
|
+
- `~/.throughline/logs/hook-failures.log`に、Stopが失敗した会話の`session_id`と`transcript_path`を足す。理由の文面だけでは、どの会話のStopかを端末の他の記録から探すことになるため。端末内にだけ残し、外へは送らない。
|
|
30
|
+
|
|
13
31
|
## [0.12.6] — 2026-10-04
|
|
14
32
|
|
|
15
33
|
### 修正
|
|
@@ -1603,7 +1621,9 @@ two attempts, instrument first instead of patching again.
|
|
|
1603
1621
|
|
|
1604
1622
|
---
|
|
1605
1623
|
|
|
1606
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.12.
|
|
1624
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.12.8...HEAD
|
|
1625
|
+
[0.12.8]: https://github.com/kitepon/Throughline/compare/v0.12.7...v0.12.8
|
|
1626
|
+
[0.12.7]: https://github.com/kitepon/Throughline/compare/v0.12.6...v0.12.7
|
|
1607
1627
|
[0.12.6]: https://github.com/kitepon/Throughline/compare/v0.12.5...v0.12.6
|
|
1608
1628
|
[0.12.5]: https://github.com/kitepon/Throughline/compare/v0.12.4...v0.12.5
|
|
1609
1629
|
[0.12.4]: https://github.com/kitepon/Throughline/compare/v0.12.3...v0.12.4
|
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,
|
|
@@ -23,6 +23,7 @@ turn後に現行parserで読むとlatest logical groupは1件だった。Claude
|
|
|
23
23
|
3. latest user groupを必須にし、過去の同文assistantや前turnを一致として採用しない。
|
|
24
24
|
(2026-10-03 [ADR 0026](0026-stop-flush-barrier-next-user.md)で、Stop直後に次のuser行が届いた時の未捕捉の1つ前のgroupだけを例外にした。)
|
|
25
25
|
4. deadlineまで一致しなければ明示errorと`HOOK_PROCESS_TURN_FAILED`を返し、completionなしへ丸めない。
|
|
26
|
+
(2026-10-04 [ADR 0029](0029-stop-flush-barrier-absent-transcript.md)で、deadlineまでtranscriptのファイル自体が無い時だけ、失敗に数えず`transcript_absent`として見送ることにした。)
|
|
26
27
|
5. markerを持たない旧Claude hostは既存one-shot transcript parser契約を維持する。
|
|
27
28
|
6. Claude Stop hookの`async: true`、DB schema、receipt wire、Observer cursorは変更しない。
|
|
28
29
|
|
|
@@ -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,62 @@
|
|
|
1
|
+
# ADR 0029: transcript のファイルが無い Claude Stop は、hook 処理の失敗に数えない
|
|
2
|
+
|
|
3
|
+
日付: 2026-10-04
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
ADR 0012 の flush barrier は、Stop payload の `last_assistant_message` が transcript に見えるまで最大2秒待ち、
|
|
8
|
+
見えなければ `HOOK_PROCESS_TURN_FAILED`(`Throughline Claude Stop hook processing failed`、severity `high`)で
|
|
9
|
+
明示失敗する。
|
|
10
|
+
|
|
11
|
+
2026-10-04、BellTeam コンテナ(0.12.6)で、前日に解決登録した `HOOK_PROCESS_TURN_FAILED` が再発した。
|
|
12
|
+
00:36:19.037Z と 00:36:24.188Z の2回で、理由はどちらも
|
|
13
|
+
`Claude Stop transcript completion was not visible before deadline`。
|
|
14
|
+
|
|
15
|
+
失敗した会話は `/tmp` で 00:35:39Z から 00:36:24Z まで動いた Claude Code(2.1.289)の1会話。
|
|
16
|
+
SessionStart と UserPromptSubmit の hook は `transcript_path` を受け取っているが、その path に
|
|
17
|
+
ファイルは無い。`~/.claude/history.jsonl` にもこの会話の入力は残っていない。
|
|
18
|
+
前日までに同じ内容の会話が5回 `/tmp` で動いていて、その時は transcript が残り、Stop は同じ実行で保存している。
|
|
19
|
+
|
|
20
|
+
同じ端末で、Claude Code を `-p --input-format stream-json --no-session-persistence` で1 turn 動かして再現した。
|
|
21
|
+
Stop payload には `transcript_path` と `last_assistant_message` が渡るが、ファイルは最後まで作られず、
|
|
22
|
+
Stop は応答の 2.08 秒後に同じ文面で失敗した。payload に、保存しない起動であることを示す項目は無い。
|
|
23
|
+
`--no-session-persistence` を外した同じ会話は、同じ Stop で 1 turn を保存した。
|
|
24
|
+
|
|
25
|
+
Throughline が保存する本文の正本は transcript だけ(ADR 0012 Decision 2)。transcript が作られない会話には、
|
|
26
|
+
保存する元が無い。これは host を起動した側の指定で、Throughline の修理では直らない。
|
|
27
|
+
`HOOK_PROCESS_TURN_FAILED` の回数からは、保存できたはずの会話を落としたのか、保存する元が無かったのかを
|
|
28
|
+
区別できない。
|
|
29
|
+
|
|
30
|
+
## Decision
|
|
31
|
+
|
|
32
|
+
1. flush barrier は、期限まで待っても完了が見えず、かつ `transcript_path` のファイルが存在しない時、
|
|
33
|
+
`transcript_absent` を返す。失敗にしない。
|
|
34
|
+
2. 存在の確認は期限に達した時だけ行う。遅れて書かれる transcript は、今までどおり期限まで待つ。
|
|
35
|
+
3. ファイルが存在するのに完了が見えない時は、今までどおり明示失敗して `HOOK_PROCESS_TURN_FAILED` を数える
|
|
36
|
+
(ADR 0012 Decision 4)。`transcript_path` が payload に無い時も、ファイルが無いことを確かめられないので
|
|
37
|
+
失敗のままにする。
|
|
38
|
+
4. `transcript_absent` の Stop は、`~/.throughline/logs/backfill.log` に
|
|
39
|
+
`{"hook":"stop","session_id":…,"transcript_path":…,"skipped":"transcript_absent"}` を1行残し、
|
|
40
|
+
終了 code 0 で終わる。DB・state file・完了受領には何も書かない。runtime error store にも数えない。
|
|
41
|
+
5. hook が失敗した時の `hook-failures.log`(ADR 0027)に、Stop の `session_id` と `transcript_path` を足す。
|
|
42
|
+
今回は理由の文面だけが残り、どの会話の Stop かを他の記録から探すことになった。
|
|
43
|
+
このログは端末内にだけ置き、外へは送らない。
|
|
44
|
+
|
|
45
|
+
## Consequences
|
|
46
|
+
|
|
47
|
+
- transcript を残さない起動(`--no-session-persistence`)の会話は、Throughline の記憶に入らない。
|
|
48
|
+
見送った Stop は `backfill.log` で数えられる。
|
|
49
|
+
- その会話の Stop hook は、今までと同じく期限(2秒)まで待ってから終わる。
|
|
50
|
+
- host が transcript の置き場を変え、hook に渡す path にファイルが無くなった場合も、失敗としては数えなくなる。
|
|
51
|
+
その時は `backfill.log` に `transcript_absent` が並び、`inserted_turns` が出なくなる。
|
|
52
|
+
- 期限の後に初めてファイルが作られる会話は、その Stop では保存されない。同じ会話の次の Stop が、
|
|
53
|
+
未捕捉の turn をまとめて保存する(backfill)。最後の turn で起きた時は保存されない。
|
|
54
|
+
- `HOOK_PROCESS_TURN_FAILED` の fingerprint は変わらない。過去の回数は移さない。
|
|
55
|
+
|
|
56
|
+
## 確認できていないこと
|
|
57
|
+
|
|
58
|
+
- 2026-10-04 の2回の会話を、誰がどの引数で起動したかは特定できていない。確かめたのは、その会話の
|
|
59
|
+
transcript がディスクに無いことと、`--no-session-persistence` で起動した会話が同じ文面で失敗すること。
|
|
60
|
+
transcript が後から消された可能性は、記録からは否定できない。
|
|
61
|
+
- `--no-session-persistence` 以外に、path は渡るがファイルが作られない起動の仕方があるかは調べていない。
|
|
62
|
+
- 実測は Linux の Claude Code 2.1.289 だけ。macOS と Windows では確かめていない。
|
|
@@ -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
|
+
- 既定の利用者(送信を有効にしていない端末)の動作は変わらない。
|
package/package.json
CHANGED
package/src/hook-failure-log.mjs
CHANGED
|
@@ -36,6 +36,13 @@ export function logHookFailure(code, error, { home, now = Date.now(), stderr = p
|
|
|
36
36
|
name: error instanceof Error ? error.name : typeof error,
|
|
37
37
|
message: message.slice(0, HOOK_FAILURE_MESSAGE_LIMIT),
|
|
38
38
|
};
|
|
39
|
+
// どの会話の失敗かを残す(errorが `hookContext` を持つ時)。回数と文面だけでは、失敗した会話を
|
|
40
|
+
// 端末の他の記録から探すことになる。
|
|
41
|
+
const context = error?.hookContext;
|
|
42
|
+
if (typeof context?.session_id === 'string' && context.session_id) entry.session_id = context.session_id;
|
|
43
|
+
if (typeof context?.transcript_path === 'string' && context.transcript_path) {
|
|
44
|
+
entry.transcript_path = context.transcript_path;
|
|
45
|
+
}
|
|
39
46
|
// 外部 CLI の失敗は、message が終了 code だけになる。理由(reason)と CLI の stderr の末尾も残す
|
|
40
47
|
// (CLI は先頭に起動情報を出し、失敗の理由は末尾に出す)。
|
|
41
48
|
if (typeof error?.reason === 'string' && error.reason) entry.reason = error.reason;
|
|
@@ -41,6 +41,26 @@ test('logHookFailureは、外部CLIの失敗の理由とstderrの末尾を残す
|
|
|
41
41
|
}
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
test('logHookFailureは、どの会話の失敗かを残す', () => {
|
|
45
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
|
|
46
|
+
try {
|
|
47
|
+
const withContext = new Error('with context');
|
|
48
|
+
withContext.hookContext = { session_id: 'session-a', transcript_path: '/claude/projects/-tmp/session-a.jsonl' };
|
|
49
|
+
logHookFailure('HOOK_PROCESS_TURN_FAILED', withContext, { home });
|
|
50
|
+
// 文字列でない値と空文字は残さない(payloadを読む前の失敗では、手がかりがまだ無い)。
|
|
51
|
+
const withoutContext = new Error('without context');
|
|
52
|
+
withoutContext.hookContext = { session_id: '', transcript_path: undefined };
|
|
53
|
+
logHookFailure('HOOK_PROCESS_TURN_FAILED', withoutContext, { home });
|
|
54
|
+
const [first, second] = readFileSync(hookFailureLogPath({ home }), 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
55
|
+
assert.equal(first.session_id, 'session-a');
|
|
56
|
+
assert.equal(first.transcript_path, '/claude/projects/-tmp/session-a.jsonl');
|
|
57
|
+
assert.equal('session_id' in second, false);
|
|
58
|
+
assert.equal('transcript_path' in second, false);
|
|
59
|
+
} finally {
|
|
60
|
+
rmSync(home, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
44
64
|
test('logHookFailureは、長い文面を上限で切る', () => {
|
|
45
65
|
const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
|
|
46
66
|
try {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
4
|
-
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { DatabaseSync } from 'node:sqlite';
|
|
6
6
|
import { tmpdir } from 'node:os';
|
|
7
7
|
import { dirname, join } from 'node:path';
|
|
@@ -70,6 +70,51 @@ test('top-level hook owners record one fixed aggregate per failure without repla
|
|
|
70
70
|
assert.equal(store.records.find((record) => record.error_code === 'HOOK_PROCESS_TURN_FAILED').count, 2);
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
+
test('Claude Stopで、transcriptのファイルが無い会話は失敗に数えず、ファイルがあって完了が見えない時だけ数える', () => {
|
|
74
|
+
const { root, env } = createEnabledEnvironment('throughline-runtime-stop-absent-');
|
|
75
|
+
const storePath = defaultRuntimeErrorStorePath(env);
|
|
76
|
+
const stop = (payload) => spawnSync(process.execPath, [BIN, 'process-turn'], {
|
|
77
|
+
env, cwd: root, input: JSON.stringify(payload), encoding: 'utf8',
|
|
78
|
+
});
|
|
79
|
+
const readLog = (name) => readFileSync(join(root, '.throughline', 'logs', name), 'utf8')
|
|
80
|
+
.trim().split('\n').map((line) => JSON.parse(line));
|
|
81
|
+
|
|
82
|
+
// `--no-session-persistence` のClaude Code(2.1.289で実測): pathは渡るが、ファイルは作られない。
|
|
83
|
+
const absentPath = join(root, 'never-written.jsonl');
|
|
84
|
+
const absent = stop({
|
|
85
|
+
session_id: 'a1b2c3d4-0000-4000-8000-000000000001', transcript_path: absentPath, cwd: root,
|
|
86
|
+
hook_event_name: 'Stop', stop_hook_active: false, last_assistant_message: 'PROBE_OK',
|
|
87
|
+
});
|
|
88
|
+
assert.equal(absent.status, 0, absent.stderr);
|
|
89
|
+
assert.equal(absent.stderr, '');
|
|
90
|
+
assert.equal(existsSync(storePath), false, 'an absent transcript must not be counted as a hook failure');
|
|
91
|
+
assert.equal(existsSync(join(root, '.throughline', 'logs', 'hook-failures.log')), false);
|
|
92
|
+
assert.deepEqual(readLog('backfill.log').map(({ ts, ...entry }) => entry), [{
|
|
93
|
+
hook: 'stop',
|
|
94
|
+
session_id: 'a1b2c3d4-0000-4000-8000-000000000001',
|
|
95
|
+
target: 'a1b2c3d4-0000-4000-8000-000000000001',
|
|
96
|
+
origin: 'a1b2c3d4-0000-4000-8000-000000000001',
|
|
97
|
+
transcript_path: absentPath,
|
|
98
|
+
skipped: 'transcript_absent',
|
|
99
|
+
}]);
|
|
100
|
+
|
|
101
|
+
// ファイルはあるのに、Stopが渡した応答がそこに無い。保存できなかった会話なので、失敗のまま数える。
|
|
102
|
+
const presentPath = join(root, 'present.jsonl');
|
|
103
|
+
writeFileSync(presentPath, JSON.stringify({ type: 'user', message: { role: 'user', content: 'request' } }) + '\n');
|
|
104
|
+
const present = stop({
|
|
105
|
+
session_id: 'a1b2c3d4-0000-4000-8000-000000000002', transcript_path: presentPath, cwd: root,
|
|
106
|
+
hook_event_name: 'Stop', stop_hook_active: false, last_assistant_message: 'PROBE_OK',
|
|
107
|
+
});
|
|
108
|
+
assert.equal(present.status, 1);
|
|
109
|
+
assert.match(present.stderr, /not visible before deadline/);
|
|
110
|
+
const store = JSON.parse(readFileSync(storePath, 'utf8'));
|
|
111
|
+
assert.deepEqual(store.records.map((record) => [record.error_code, record.count]), [['HOOK_PROCESS_TURN_FAILED', 1]]);
|
|
112
|
+
const [failure] = readLog('hook-failures.log');
|
|
113
|
+
assert.equal(failure.code, 'HOOK_PROCESS_TURN_FAILED');
|
|
114
|
+
assert.equal(failure.session_id, 'a1b2c3d4-0000-4000-8000-000000000002');
|
|
115
|
+
assert.equal(failure.transcript_path, presentPath);
|
|
116
|
+
});
|
|
117
|
+
|
|
73
118
|
function makeBin(dir, name, body) {
|
|
74
119
|
const script = join(dir, `${name}.mjs`);
|
|
75
120
|
writeFileSync(script, body);
|
|
@@ -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;
|
package/src/turn-processor.mjs
CHANGED
|
@@ -44,6 +44,7 @@ import { summarizeToL1 } from './haiku-summarizer.mjs';
|
|
|
44
44
|
import { ensureMonitorTaskFile } from './vscode-task.mjs';
|
|
45
45
|
import { readLatestUsage } from './transcript-usage.mjs';
|
|
46
46
|
import { pathToFileURL } from 'node:url';
|
|
47
|
+
import { existsSync } from 'node:fs';
|
|
47
48
|
import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
|
|
48
49
|
import { logHookFailure } from './hook-failure-log.mjs';
|
|
49
50
|
import { writeCompletedTurnReceipt } from './completed-turn-receipts.mjs';
|
|
@@ -69,6 +70,10 @@ const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
|
|
|
69
70
|
*
|
|
70
71
|
* markerとtranscriptの本文は、前後の空白を除いて比べる(ADR 0027)。Claude Codeはmarkerを
|
|
71
72
|
* `.trim()` して渡すので、本文が空白や改行で始まる(終わる)turnは、そのままでは一致しない。
|
|
73
|
+
*
|
|
74
|
+
* 期限まで待ってもtranscriptのファイル自体が無い時は、失敗にせず `transcript_absent` を返す
|
|
75
|
+
* (ADR 0029)。Claude Codeを `--no-session-persistence` で起動すると、hookへ `transcript_path` は
|
|
76
|
+
* 渡るが、ファイルは最後まで作られない。ファイルがあるのに完了が見えない時は、今までどおり失敗にする。
|
|
72
77
|
*/
|
|
73
78
|
export async function waitForClaudeStopTranscriptFlush({
|
|
74
79
|
transcriptPath,
|
|
@@ -78,6 +83,7 @@ export async function waitForClaudeStopTranscriptFlush({
|
|
|
78
83
|
readCompletion,
|
|
79
84
|
readCompletions = readLatestLogicalTurnCompletions,
|
|
80
85
|
isTurnCaptured = () => true,
|
|
86
|
+
transcriptExists = existsSync,
|
|
81
87
|
now = Date.now,
|
|
82
88
|
wait = delay,
|
|
83
89
|
}) {
|
|
@@ -106,6 +112,9 @@ export async function waitForClaudeStopTranscriptFlush({
|
|
|
106
112
|
}
|
|
107
113
|
const remaining = deadline - now();
|
|
108
114
|
if (remaining <= 0) {
|
|
115
|
+
if (typeof transcriptPath === 'string' && transcriptPath && !transcriptExists(transcriptPath)) {
|
|
116
|
+
return { status: 'transcript_absent' };
|
|
117
|
+
}
|
|
109
118
|
throw new Error('Claude Stop transcript completion was not visible before deadline');
|
|
110
119
|
}
|
|
111
120
|
await wait(Math.min(intervalMs, remaining));
|
|
@@ -212,7 +221,20 @@ function buildL2ForSummary(userTurn, assistantTurn) {
|
|
|
212
221
|
return parts.join('\n\n');
|
|
213
222
|
}
|
|
214
223
|
|
|
224
|
+
/** 失敗した時に hook-failures.log へ残す、どの会話のStopだったかの手がかり。 */
|
|
225
|
+
let failureContext = {};
|
|
226
|
+
|
|
215
227
|
export async function run() {
|
|
228
|
+
try {
|
|
229
|
+
await processStop();
|
|
230
|
+
} catch (err) {
|
|
231
|
+
// 失敗を記録するのは呼び出し側(bin/throughline.mjs と下の直接実行)。errorに手がかりを持たせて渡す。
|
|
232
|
+
if (err instanceof Error) err.hookContext = failureContext;
|
|
233
|
+
throw err;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function processStop() {
|
|
216
238
|
if (process.env.THROUGHLINE_IN_HAIKU_SUBPROCESS === '1') {
|
|
217
239
|
process.exit(0);
|
|
218
240
|
}
|
|
@@ -229,6 +251,7 @@ export async function run() {
|
|
|
229
251
|
const payload = parseHookPayload(raw || '{}', { env: process.env });
|
|
230
252
|
const { session_id, transcript_path, cwd, last_assistant_message } = payload;
|
|
231
253
|
if (!session_id) throw new Error('Missing session_id in Stop payload');
|
|
254
|
+
failureContext = { session_id, transcript_path };
|
|
232
255
|
|
|
233
256
|
// VSCode で開かれたプロジェクトに .vscode/tasks.json を自動プロビジョニングする。
|
|
234
257
|
// 2 回目以降は冪等性チェックで即 return するので毎ターン走っても安全。
|
|
@@ -245,11 +268,24 @@ export async function run() {
|
|
|
245
268
|
const { target, origin } = resolveMergeTarget(db, session_id);
|
|
246
269
|
|
|
247
270
|
if (hostAdapterForSessionId(session_id).waitsForStopTranscriptFlush) {
|
|
248
|
-
await waitForClaudeStopTranscriptFlush({
|
|
271
|
+
const flush = await waitForClaudeStopTranscriptFlush({
|
|
249
272
|
transcriptPath: transcript_path,
|
|
250
273
|
lastAssistantMessage: last_assistant_message,
|
|
251
274
|
isTurnCaptured: (fragmentTurnNumbers) => isLogicalTurnCaptured(db, origin, fragmentTurnNumbers),
|
|
252
275
|
});
|
|
276
|
+
if (flush.status === 'transcript_absent') {
|
|
277
|
+
// 保存する元が無い。失敗には数えず、どの会話のStopを見送ったかを端末内に残す(ADR 0029)。
|
|
278
|
+
logBackfill({
|
|
279
|
+
ts: new Date().toISOString(),
|
|
280
|
+
hook: 'stop',
|
|
281
|
+
session_id,
|
|
282
|
+
target,
|
|
283
|
+
origin,
|
|
284
|
+
transcript_path,
|
|
285
|
+
skipped: 'transcript_absent',
|
|
286
|
+
});
|
|
287
|
+
process.exit(0);
|
|
288
|
+
}
|
|
253
289
|
}
|
|
254
290
|
|
|
255
291
|
// Stop hook 時点で state ファイルを更新 → token-monitor の「アクティブ行」判定が
|
|
@@ -222,6 +222,7 @@ test('Claude Stop flush barrierはmarker不一致をdeadlineで明示失敗す
|
|
|
222
222
|
timeoutMs: 30,
|
|
223
223
|
intervalMs: 10,
|
|
224
224
|
readCompletion: () => ({ userTurnNumber: 4, assistantTurnNumber: null, assistantContent: null }),
|
|
225
|
+
transcriptExists: () => true,
|
|
225
226
|
now: () => elapsed,
|
|
226
227
|
wait: async (milliseconds) => { elapsed += milliseconds; },
|
|
227
228
|
}),
|
|
@@ -230,6 +231,49 @@ test('Claude Stop flush barrierはmarker不一致をdeadlineで明示失敗す
|
|
|
230
231
|
assert.equal(elapsed, 30);
|
|
231
232
|
});
|
|
232
233
|
|
|
234
|
+
test('Claude Stop flush barrierは、期限までtranscriptのファイルが無い時だけtranscript_absentを返す', async () => {
|
|
235
|
+
const root = mkdtempSync(join(tmpdir(), 'throughline-stop-absent-'));
|
|
236
|
+
const transcriptPath = join(root, 'never-written.jsonl');
|
|
237
|
+
let elapsed = 0;
|
|
238
|
+
const clock = { now: () => elapsed, wait: async (milliseconds) => { elapsed += milliseconds; } };
|
|
239
|
+
try {
|
|
240
|
+
// `--no-session-persistence` のClaude Code: pathは渡るが、ファイルは最後まで作られない。期限までは待つ。
|
|
241
|
+
assert.deepEqual(await waitForClaudeStopTranscriptFlush({
|
|
242
|
+
transcriptPath, lastAssistantMessage: 'PROBE_OK', timeoutMs: 30, intervalMs: 10, ...clock,
|
|
243
|
+
}), { status: 'transcript_absent' });
|
|
244
|
+
assert.equal(elapsed, 30, 'a transcript that is written late must still get the whole deadline');
|
|
245
|
+
|
|
246
|
+
// 待っている間にファイルが作られ、完了が見えた時は、今までどおり採用する。
|
|
247
|
+
elapsed = 0;
|
|
248
|
+
let reads = 0;
|
|
249
|
+
assert.deepEqual(await waitForClaudeStopTranscriptFlush({
|
|
250
|
+
transcriptPath, lastAssistantMessage: 'late answer', timeoutMs: 100, intervalMs: 10, ...clock,
|
|
251
|
+
readCompletions: () => {
|
|
252
|
+
reads++;
|
|
253
|
+
if (reads < 3) return { latest: null, previous: null };
|
|
254
|
+
return { latest: { userTurnNumber: 0, assistantTurnNumber: 1, assistantContent: 'late answer', fragmentTurnNumbers: [1] }, previous: null };
|
|
255
|
+
},
|
|
256
|
+
}), { status: 'ready', userTurnNumber: 0, assistantTurnNumber: 1 });
|
|
257
|
+
|
|
258
|
+
// ファイルはあるのに完了が見えない時は、失敗のまま。
|
|
259
|
+
writeFileSync(transcriptPath, JSON.stringify({ type: 'user', message: { role: 'user', content: 'request' } }), 'utf8');
|
|
260
|
+
elapsed = 0;
|
|
261
|
+
await assert.rejects(waitForClaudeStopTranscriptFlush({
|
|
262
|
+
transcriptPath, lastAssistantMessage: 'PROBE_OK', timeoutMs: 30, intervalMs: 10, ...clock,
|
|
263
|
+
}), /not visible before deadline/);
|
|
264
|
+
|
|
265
|
+
// pathが渡らないpayloadは、ファイルが無いことを確かめられない。失敗のまま。
|
|
266
|
+
for (const missingPath of [undefined, null, '']) {
|
|
267
|
+
elapsed = 0;
|
|
268
|
+
await assert.rejects(waitForClaudeStopTranscriptFlush({
|
|
269
|
+
transcriptPath: missingPath, lastAssistantMessage: 'PROBE_OK', timeoutMs: 30, intervalMs: 10, ...clock,
|
|
270
|
+
}), /not visible before deadline/);
|
|
271
|
+
}
|
|
272
|
+
} finally {
|
|
273
|
+
rmSync(root, { recursive: true, force: true });
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
233
277
|
test('Claude Stop flush barrierはmarkerなし旧payloadをone-shot互換へ残す', async () => {
|
|
234
278
|
let reads = 0;
|
|
235
279
|
const result = await waitForClaudeStopTranscriptFlush({
|