throughline 0.12.5 → 0.12.7
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 +29 -1
- package/docs/adr/0012-claude-stop-transcript-flush-barrier.md +1 -0
- package/docs/adr/0015-l1-summarizer-model-effort-ratio.md +2 -0
- package/docs/adr/0028-l1-summarizer-backend-failure-code.md +59 -0
- package/docs/adr/0029-stop-flush-barrier-absent-transcript.md +62 -0
- package/package.json +1 -1
- package/src/cli/codex-hook.mjs +19 -2
- package/src/cli/codex-hook.test.mjs +14 -0
- package/src/hook-failure-log.mjs +13 -0
- package/src/hook-failure-log.test.mjs +38 -0
- package/src/observer-turn-feed.test.mjs +7 -2
- package/src/runtime-error-hook.test.mjs +118 -1
- package/src/runtime-error-store.mjs +7 -0
- package/src/turn-processor.mjs +37 -1
- package/src/turn-processor.test.mjs +44 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,32 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.12.7] — 2026-10-04
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- 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に無い時は、今までどおり失敗として数える。
|
|
18
|
+
- BellTeamコンテナ(0.12.6)で2026-10-04に2回起きた失敗は、transcriptがディスクに無い1会話のStopだった。Claude Code 2.1.289を`--no-session-persistence`で動かして、同じ文面の失敗を再現した。その2回の会話がこの引数で起動されたことを示す記録は無い。
|
|
19
|
+
|
|
20
|
+
### 変更
|
|
21
|
+
|
|
22
|
+
- `~/.throughline/logs/hook-failures.log`に、Stopが失敗した会話の`session_id`と`transcript_path`を足す。理由の文面だけでは、どの会話のStopかを端末の他の記録から探すことになるため。端末内にだけ残し、外へは送らない。
|
|
23
|
+
|
|
24
|
+
## [0.12.6] — 2026-10-04
|
|
25
|
+
|
|
26
|
+
### 修正
|
|
27
|
+
|
|
28
|
+
- Codex Stop hookで、会話の取り込みが済んだ後のL1要約backend(Codex CLI)の失敗を、`HOOK_CODEX_FAILED`(Codex hook processing failed・high)ではなく`L1_SUMMARIZER_BACKEND_FAILED`(L1 summarizer backend failed・warn)として数える([ADR 0028](docs/adr/0028-l1-summarizer-backend-failure-code.md))。Codex CLIが利用上限や認証切れで非0終了すると、取り込みは成功しているのにhook処理の失敗として記録されていた。hookの終了codeとstderrは変えない。要約は次のStopが同じturnからやり直す。
|
|
29
|
+
- 0.9.0と0.12.5の両方で、20 turnを超えるスレッドのStopがCodex CLIの失敗で`HOOK_CODEX_FAILED`を記録することを再現した。Macに残っていた1151回の最後の1回は、そのスレッドが利用上限に当たった時刻と一致する。1151回すべてがこの形だったことを示す記録は無い。
|
|
30
|
+
|
|
31
|
+
### 変更
|
|
32
|
+
|
|
33
|
+
- `~/.throughline/logs/hook-failures.log`に、errorが持つ`reason`と、外部CLIの`stderr`の末尾(1000字まで)を足す。`Codex CLI summarizer failed: exit 1`だけでは、利用上限か認証切れかが分からないため。端末内にだけ残し、外へは送らない。
|
|
34
|
+
|
|
35
|
+
### 試験
|
|
36
|
+
|
|
37
|
+
- Observerのcursorがprojectのpath・session id・本文を持たないことを確かめる試験が、偶然落ちることがあった。sha256の16進に目印の`a257`が現れると失敗し、Windowsではpathをそのまま正規表現にしていた。目印を16進に現れない文字列にし、値をそのまま探す形に直す。製品の動作は変えていない。
|
|
38
|
+
|
|
13
39
|
## [0.12.5] — 2026-10-04
|
|
14
40
|
|
|
15
41
|
### 修正
|
|
@@ -1588,7 +1614,9 @@ two attempts, instrument first instead of patching again.
|
|
|
1588
1614
|
|
|
1589
1615
|
---
|
|
1590
1616
|
|
|
1591
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.12.
|
|
1617
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.12.7...HEAD
|
|
1618
|
+
[0.12.7]: https://github.com/kitepon/Throughline/compare/v0.12.6...v0.12.7
|
|
1619
|
+
[0.12.6]: https://github.com/kitepon/Throughline/compare/v0.12.5...v0.12.6
|
|
1592
1620
|
[0.12.5]: https://github.com/kitepon/Throughline/compare/v0.12.4...v0.12.5
|
|
1593
1621
|
[0.12.4]: https://github.com/kitepon/Throughline/compare/v0.12.3...v0.12.4
|
|
1594
1622
|
[0.12.3]: https://github.com/kitepon/Throughline/compare/v0.12.2...v0.12.3
|
|
@@ -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
|
|
|
@@ -66,6 +66,8 @@ none/low/medium/high/xhigh)のため none を下端とした。
|
|
|
66
66
|
4. claude-primary の backend 順序を **codex-sidecar → Codex CLI (luna@low) → Haiku →
|
|
67
67
|
raw L2** にする。各段の失敗理由は結果 (`sidecarReason` / `codexCliReason`) に記録
|
|
68
68
|
する宣言済み fallback。codex-primary は従来どおり Codex CLI 一本で explicit error。
|
|
69
|
+
(2026-10-04 [ADR 0028](0028-l1-summarizer-backend-failure-code.md)で、この explicit error を runtime error store では
|
|
70
|
+
`L1_SUMMARIZER_BACKEND_FAILED` として数えることにした。hook の終了 code と stderr は変えていない。)
|
|
69
71
|
(実測で Haiku は timeout 完全失敗があり、luna は同等品質・半分以下のレイテンシ・
|
|
70
72
|
失敗ゼロのため、Haiku より先に置く。)
|
|
71
73
|
5. sidecar `summarize-l1` preset のモデルは codex-sidecar(別repo)の所有。本 ADR では
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# ADR 0028: Codex Stop の L1 要約 backend の失敗は、hook 処理の失敗と別の code で数える
|
|
2
|
+
|
|
3
|
+
日付: 2026-10-04
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
Codex Stop hook は、rollout を DB へ取り込んで commit した後、bodies が L2 window(20 turn)を超えていれば、
|
|
8
|
+
最古の未要約 turn を Codex CLI(`codex exec`)で L1 に要約する。codex-primary の要約は Codex CLI 一本で、
|
|
9
|
+
CLI の失敗は explicit error にする(ADR 0015 Decision 4)。
|
|
10
|
+
|
|
11
|
+
この error は hook の一番外の catch まで届き、`HOOK_CODEX_FAILED`(`Throughline Codex hook processing failed`、
|
|
12
|
+
severity `high`)として runtime error store に数えられていた。
|
|
13
|
+
|
|
14
|
+
Mac の runtime error store には `HOOK_CODEX_FAILED` が 1151 回あった(2026-07-13 〜 2026-08-07、最後の版は 0.9.0)。
|
|
15
|
+
最後の1回は 2026-08-07T06:23:58.298Z で、その Codex スレッドは同じ秒に次の turn が
|
|
16
|
+
`You've hit your usage limit` で終わっている。当時の stderr はどこにも残っていない。
|
|
17
|
+
|
|
18
|
+
0.9.0 と 0.12.5 の両方で、次を再現した。20 turn を超えるスレッドに Stop を送り、Codex CLI が非0で終わると、
|
|
19
|
+
取り込みは成功したまま hook が `Codex CLI summarizer failed: exit N`(reason `codex_cli_failed`)で失敗し、
|
|
20
|
+
`HOOK_CODEX_FAILED` が1回増える。UserPromptSubmit と PostToolUse は要約をしないので失敗しない。
|
|
21
|
+
|
|
22
|
+
`HOOK_CODEX_FAILED` という code からは、取り込みが壊れたのか、外部 CLI が使えなかっただけなのかを区別できない。
|
|
23
|
+
後者は Codex 側の利用上限・認証切れ・model の失敗で起き、Throughline の修理では直らない。
|
|
24
|
+
|
|
25
|
+
## Decision
|
|
26
|
+
|
|
27
|
+
1. Codex hook の失敗のうち、L1 要約 backend の失敗だけを `L1_SUMMARIZER_BACKEND_FAILED` として数える。
|
|
28
|
+
component は `codex_l1_summarizer`、template は `Throughline L1 summarizer backend (Codex CLI) failed`、
|
|
29
|
+
severity は `warn`。
|
|
30
|
+
2. 対象は `source` が `codex-cli` で、`reason` が `codex_cli_failed`(非0終了・timeout・起動失敗)か
|
|
31
|
+
`empty_output` の error だけ。`missing_project_path` と `recursion_guard` は製品側の誤りなので、
|
|
32
|
+
`HOOK_CODEX_FAILED` のままにする。要約以外の失敗も `HOOK_CODEX_FAILED` のまま。
|
|
33
|
+
3. hook の終了 code(1)と stderr(`[codex-hook] Codex CLI summarizer failed: exit N`)は変えない。
|
|
34
|
+
ADR 0015 の explicit error 契約と、失敗を隠さない原則はそのまま。変えるのは数える code だけ。
|
|
35
|
+
4. `hook-failures.log`(ADR 0027)に、error が持つ `reason` と、外部 CLI の `stderr` の末尾(1000字まで)を足す。
|
|
36
|
+
`Codex CLI summarizer failed: exit 1` だけでは、利用上限なのか認証切れなのかが分からないため。
|
|
37
|
+
このログは端末内にだけ残し、外へは送らない。runtime error store には今までどおり定型 code と回数だけを入れる。
|
|
38
|
+
5. severity を `warn` にする理由: 会話の取り込みは commit 済みで、記憶は失われていない。要約は次の Stop が
|
|
39
|
+
同じ turn からやり直す。L1 が無い間、recall は L2 の本文を返す(ADR 0016)。
|
|
40
|
+
BugHub が受ける severity は `fatal`・`high`・`warn`・`info` の4つで、他の値は report 全体が 422 になる
|
|
41
|
+
(2026-10-04、BugHub の `PRODUCT_REPORTING.md` と `factory-report-v1.schema.json` をBugHubの担当が確認)。
|
|
42
|
+
|
|
43
|
+
## Consequences
|
|
44
|
+
|
|
45
|
+
- 外部 CLI が使えない端末は、BugHub で「L1 summarizer backend failed」(warn、日次のまとめ)として見える。
|
|
46
|
+
「Codex hook processing failed」(high、即時通知)は、取り込みや hook 処理そのものの失敗だけになる。
|
|
47
|
+
- fingerprint は code ごとに決まるので、新しい code は BugHub で新しい issue になる。過去の `HOOK_CODEX_FAILED` の
|
|
48
|
+
回数は移らない。
|
|
49
|
+
- runtime error store は、知らない code を持つ記録を schema invalid として拒否する。この版で
|
|
50
|
+
`L1_SUMMARIZER_BACKEND_FAILED` を記録した端末を 0.12.5 以前へ戻すと、store を読めなくなる。
|
|
51
|
+
- Claude Stop は対象外。claude-primary の要約は Codex CLI → Haiku → raw L2 の宣言済み fallback で、
|
|
52
|
+
backend の失敗では throw しない。
|
|
53
|
+
|
|
54
|
+
## 確認できていないこと
|
|
55
|
+
|
|
56
|
+
- Mac の 1151 回がすべてこの形だったことを示す記録は無い。確認できたのは、同じ code を同じ版で再現できたことと、
|
|
57
|
+
最後の1回が利用上限に当たった時刻と一致することだけ。外した仮説は、製品の試験からの漏れ、rollout の大きさ、
|
|
58
|
+
入力の形、同時実行、スレッド全体の順次再生。
|
|
59
|
+
- 要約が失敗し続ける端末では、Codex の Stop hook が毎回 exit 1 になる。Codex 側の表示への影響は調べていない。
|
|
@@ -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 では確かめていない。
|
package/package.json
CHANGED
package/src/cli/codex-hook.mjs
CHANGED
|
@@ -360,6 +360,22 @@ async function runCodexContextRefreshInstructionHook({
|
|
|
360
360
|
};
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
+
const L1_SUMMARIZER_BACKEND_REASONS = new Set(['codex_cli_failed', 'empty_output']);
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Codex hook が失敗した時に記録する runtime error の code を決める(ADR 0028)。
|
|
367
|
+
* L1 要約の backend(Codex CLI)が非0で終わった、または空を返した失敗は、取り込みの後に起きる
|
|
368
|
+
* 外部 CLI の失敗なので、hook 処理の失敗とは別の code で数える。hook の終了 code と stderr は変えない。
|
|
369
|
+
* @param {unknown} error
|
|
370
|
+
* @returns {'HOOK_CODEX_FAILED' | 'L1_SUMMARIZER_BACKEND_FAILED'}
|
|
371
|
+
*/
|
|
372
|
+
export function codexHookFailureCode(error) {
|
|
373
|
+
if (error?.source === 'codex-cli' && L1_SUMMARIZER_BACKEND_REASONS.has(error?.reason)) {
|
|
374
|
+
return 'L1_SUMMARIZER_BACKEND_FAILED';
|
|
375
|
+
}
|
|
376
|
+
return 'HOOK_CODEX_FAILED';
|
|
377
|
+
}
|
|
378
|
+
|
|
363
379
|
export async function run(argv = []) {
|
|
364
380
|
suppressExperimentalWarnings();
|
|
365
381
|
let parsed;
|
|
@@ -417,8 +433,9 @@ export async function run(argv = []) {
|
|
|
417
433
|
// continue=falseは、明示した失敗理由を含めてhostへ停止を届けた返答。背景処理の成否は引き継ぎ状態へ記録する。
|
|
418
434
|
process.exit(result.continue === false || result.status === 'ok' || result.status === 'skipped' ? 0 : 1);
|
|
419
435
|
} catch (err) {
|
|
420
|
-
|
|
421
|
-
|
|
436
|
+
const code = codexHookFailureCode(err);
|
|
437
|
+
recordRuntimeErrorBestEffort(code, { env: process.env });
|
|
438
|
+
logHookFailure(code, err);
|
|
422
439
|
const msg = err instanceof Error ? err.message : 'unknown';
|
|
423
440
|
if (parsed.json) {
|
|
424
441
|
process.stdout.write(
|
|
@@ -6,6 +6,7 @@ import { test } from 'node:test';
|
|
|
6
6
|
import { DatabaseSync } from 'node:sqlite';
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
+
codexHookFailureCode,
|
|
9
10
|
runCodexPostToolUseHook,
|
|
10
11
|
runCodexStopHook,
|
|
11
12
|
runCodexUserPromptSubmitHook,
|
|
@@ -497,6 +498,19 @@ test('codex-hook stop skips cleanly when Codex thread id is unavailable', async
|
|
|
497
498
|
}
|
|
498
499
|
});
|
|
499
500
|
|
|
501
|
+
test('codexHookFailureCodeは、L1要約backendの失敗だけを別のcodeにする', () => {
|
|
502
|
+
const backend = (reason) => Object.assign(new Error('x'), { source: 'codex-cli', reason });
|
|
503
|
+
assert.equal(codexHookFailureCode(backend('codex_cli_failed')), 'L1_SUMMARIZER_BACKEND_FAILED');
|
|
504
|
+
assert.equal(codexHookFailureCode(backend('empty_output')), 'L1_SUMMARIZER_BACKEND_FAILED');
|
|
505
|
+
// 製品側の誤り(projectPath 欠落・再帰)と、要約以外の失敗は hook 処理の失敗のまま。
|
|
506
|
+
assert.equal(codexHookFailureCode(backend('missing_project_path')), 'HOOK_CODEX_FAILED');
|
|
507
|
+
assert.equal(codexHookFailureCode(backend('recursion_guard')), 'HOOK_CODEX_FAILED');
|
|
508
|
+
assert.equal(codexHookFailureCode(Object.assign(new Error('x'), { reason: 'codex_cli_failed' })), 'HOOK_CODEX_FAILED');
|
|
509
|
+
assert.equal(codexHookFailureCode(new Error('database is locked')), 'HOOK_CODEX_FAILED');
|
|
510
|
+
assert.equal(codexHookFailureCode('thrown string'), 'HOOK_CODEX_FAILED');
|
|
511
|
+
assert.equal(codexHookFailureCode(null), 'HOOK_CODEX_FAILED');
|
|
512
|
+
});
|
|
513
|
+
|
|
500
514
|
test('codexHomeFromTranscriptPath infers CODEX_HOME from rollout path', () => {
|
|
501
515
|
const path = '/tmp/codex-home/sessions/2026/05/06/rollout-2026-05-06T09-40-50-id.jsonl';
|
|
502
516
|
assert.equal(_internal.codexHomeFromTranscriptPath(path), '/tmp/codex-home');
|
package/src/hook-failure-log.mjs
CHANGED
|
@@ -36,6 +36,19 @@ 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
|
+
}
|
|
46
|
+
// 外部 CLI の失敗は、message が終了 code だけになる。理由(reason)と CLI の stderr の末尾も残す
|
|
47
|
+
// (CLI は先頭に起動情報を出し、失敗の理由は末尾に出す)。
|
|
48
|
+
if (typeof error?.reason === 'string' && error.reason) entry.reason = error.reason;
|
|
49
|
+
if (typeof error?.stderr === 'string' && error.stderr) {
|
|
50
|
+
entry.stderr = error.stderr.slice(-HOOK_FAILURE_MESSAGE_LIMIT);
|
|
51
|
+
}
|
|
39
52
|
const path = hookFailureLogPath(home === undefined ? {} : { home });
|
|
40
53
|
try {
|
|
41
54
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -23,6 +23,44 @@ test('logHookFailureは、失敗の理由を1行のJSONで追記する', () => {
|
|
|
23
23
|
}
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
test('logHookFailureは、外部CLIの失敗の理由とstderrの末尾を残す', () => {
|
|
27
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
|
|
28
|
+
try {
|
|
29
|
+
const err = new Error('Codex CLI summarizer failed: exit 1');
|
|
30
|
+
err.reason = 'codex_cli_failed';
|
|
31
|
+
err.stderr = `${'banner '.repeat(300)}ERROR: usage limit`;
|
|
32
|
+
logHookFailure('L1_SUMMARIZER_BACKEND_FAILED', err, { home });
|
|
33
|
+
const [entry] = readFileSync(hookFailureLogPath({ home }), 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
34
|
+
assert.equal(entry.code, 'L1_SUMMARIZER_BACKEND_FAILED');
|
|
35
|
+
assert.equal(entry.message, 'Codex CLI summarizer failed: exit 1');
|
|
36
|
+
assert.equal(entry.reason, 'codex_cli_failed');
|
|
37
|
+
assert.equal(entry.stderr.length, HOOK_FAILURE_MESSAGE_LIMIT);
|
|
38
|
+
assert.match(entry.stderr, /ERROR: usage limit$/);
|
|
39
|
+
} finally {
|
|
40
|
+
rmSync(home, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
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
|
+
|
|
26
64
|
test('logHookFailureは、長い文面を上限で切る', () => {
|
|
27
65
|
const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
|
|
28
66
|
try {
|
|
@@ -126,7 +126,7 @@ test('observer feed: Claude history floor, host/thread switch, cross-host tie, a
|
|
|
126
126
|
try {
|
|
127
127
|
const options = box.receiptOptions;
|
|
128
128
|
seedCompletedTurnReceiptStore({ projectPath: box.project, receiptOptions: options, targetSessionId: 'claude-session' });
|
|
129
|
-
writeCompletedTurnReceipt({ projectPath: box.project, targetSessionId: 'claude-session', originSessionId: 'o257', userBody: '
|
|
129
|
+
writeCompletedTurnReceipt({ projectPath: box.project, targetSessionId: 'claude-session', originSessionId: 'o257', userBody: 'user-body-257', assistantBody: 'assistant-body-257', completedAt: 257 }, options);
|
|
130
130
|
const latest = resolveObserverTurnFeed({ projectPath: box.project, receiptOptions: options, codexHome: box.home });
|
|
131
131
|
const decoded = decodeObserverCursor(latest.throughCursor);
|
|
132
132
|
const beforeFloor = encodeObserverCursor({ ...decoded, history_floor: 1 });
|
|
@@ -137,7 +137,12 @@ test('observer feed: Claude history floor, host/thread switch, cross-host tie, a
|
|
|
137
137
|
const changedPrefix = encodeObserverCursor({ ...decoded, prefix_sha256: 'f'.repeat(64) });
|
|
138
138
|
assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: changedPrefix, receiptOptions: options, codexHome: box.home }).status, 'resync_required');
|
|
139
139
|
assert.throws(() => encodeObserverCursor({ ...decoded, length: 0 }), /cursor invalid/);
|
|
140
|
-
|
|
140
|
+
// cursor は project の path・session id・本文を持たない。目印は16進に現れない文字列にし、正規表現を使わず
|
|
141
|
+
// 値をそのまま探す(sha256 の16進に `a257` が偶然現れる、Windows の path が正規表現として壊れる、の2つを避ける)。
|
|
142
|
+
const cursorValues = Object.values(decoded).map(String).join('\n');
|
|
143
|
+
for (const secret of [box.project, 'claude-session', 'user-body-257', 'assistant-body-257']) {
|
|
144
|
+
assert.equal(cursorValues.includes(secret), false, secret);
|
|
145
|
+
}
|
|
141
146
|
const id = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
|
|
142
147
|
writeRollout(box.home, box.project, id, completeEvents('2026-07-15T00:03:00.000Z'));
|
|
143
148
|
const switched = resolveObserverTurnFeed({ projectPath: box.project, cursor: latest.throughCursor, receiptOptions: options, codexHome: box.home });
|
|
@@ -1,7 +1,8 @@
|
|
|
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 { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
6
|
import { tmpdir } from 'node:os';
|
|
6
7
|
import { dirname, join } from 'node:path';
|
|
7
8
|
import { fileURLToPath } from 'node:url';
|
|
@@ -69,6 +70,122 @@ test('top-level hook owners record one fixed aggregate per failure without repla
|
|
|
69
70
|
assert.equal(store.records.find((record) => record.error_code === 'HOOK_PROCESS_TURN_FAILED').count, 2);
|
|
70
71
|
});
|
|
71
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
|
+
|
|
118
|
+
function makeBin(dir, name, body) {
|
|
119
|
+
const script = join(dir, `${name}.mjs`);
|
|
120
|
+
writeFileSync(script, body);
|
|
121
|
+
if (process.platform === 'win32') {
|
|
122
|
+
const command = join(dir, `${name}.cmd`);
|
|
123
|
+
writeFileSync(command, `@echo off\r\n${JSON.stringify(process.execPath)} ${JSON.stringify(script)} %*\r\n`);
|
|
124
|
+
writeFileSync(join(dir, `${name}.ps1`), `& ${JSON.stringify(process.execPath)} ${JSON.stringify(script)} @args\nexit $LASTEXITCODE\n`);
|
|
125
|
+
return command;
|
|
126
|
+
}
|
|
127
|
+
const command = join(dir, name);
|
|
128
|
+
writeFileSync(command, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(script)} "$@"\n`);
|
|
129
|
+
chmodSync(command, 0o755);
|
|
130
|
+
return command;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
test('Codex StopでL1要約backendが失敗した時、取り込みは残り、別のcodeで1回数える', () => {
|
|
134
|
+
const { root, env } = createEnabledEnvironment('throughline-runtime-hook-summarizer-');
|
|
135
|
+
const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
|
|
136
|
+
const project = join(root, 'project');
|
|
137
|
+
const codexHome = join(root, 'codex');
|
|
138
|
+
const rolloutDir = join(codexHome, 'sessions', '2026', '05', '06');
|
|
139
|
+
mkdirSync(project, { recursive: true });
|
|
140
|
+
mkdirSync(rolloutDir, { recursive: true });
|
|
141
|
+
const at = '2026-05-06T00:41:00.000Z';
|
|
142
|
+
const event = (type, payload = {}) => ({ timestamp: at, type: 'event_msg', payload: { type, ...payload } });
|
|
143
|
+
const rows = [{ timestamp: at, type: 'session_meta',
|
|
144
|
+
payload: { id: threadId, timestamp: at, cwd: project, source: 'vscode', cli_version: '0.128.0-alpha.1' } }];
|
|
145
|
+
// L2 window(20 turn)を超えると、Stop は最古の turn を Codex CLI で要約する。
|
|
146
|
+
for (let turn = 0; turn < 22; turn += 1) {
|
|
147
|
+
rows.push(event('user_message', { message: `request ${turn}` }), event('task_started'),
|
|
148
|
+
{ timestamp: at, type: 'turn_context', payload: { model: 'gpt-5.5', cwd: project } },
|
|
149
|
+
event('agent_message', { message: `answer ${turn}` }), event('task_complete'));
|
|
150
|
+
}
|
|
151
|
+
const rolloutPath = join(rolloutDir, `rollout-2026-05-06T09-40-50-${threadId}.jsonl`);
|
|
152
|
+
writeFileSync(rolloutPath, rows.map((row) => JSON.stringify(row)).join('\n') + '\n');
|
|
153
|
+
const binDir = join(root, 'bin');
|
|
154
|
+
mkdirSync(binDir, { recursive: true });
|
|
155
|
+
const codex = makeBin(binDir, 'codex', "process.stderr.write('ERROR: usage limit reached\\n');\nprocess.exit(42);\n");
|
|
156
|
+
|
|
157
|
+
const result = spawnSync(process.execPath, [BIN, 'codex-hook', 'stop'], {
|
|
158
|
+
env: { ...env, CODEX_HOME: codexHome, THROUGHLINE_CODEX_CLI_BIN: codex },
|
|
159
|
+
input: JSON.stringify({ session_id: threadId, transcript_path: rolloutPath, cwd: project }),
|
|
160
|
+
encoding: 'utf8',
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// hook は明示して失敗する(終了 code と stderr は変えない)。
|
|
164
|
+
assert.equal(result.status, 1, result.stderr);
|
|
165
|
+
assert.match(result.stderr, /\[codex-hook\] Codex CLI summarizer failed: exit 42/);
|
|
166
|
+
|
|
167
|
+
const store = JSON.parse(readFileSync(defaultRuntimeErrorStorePath(env), 'utf8'));
|
|
168
|
+
assert.deepEqual(store.records.map((record) => [record.error_code, record.component, record.severity, record.count]), [
|
|
169
|
+
['L1_SUMMARIZER_BACKEND_FAILED', 'codex_l1_summarizer', 'warn', 1],
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
// 取り込みは要約の前に commit 済み。
|
|
173
|
+
const db = new DatabaseSync(join(root, '.throughline', 'throughline.db'));
|
|
174
|
+
try {
|
|
175
|
+
const { n } = db.prepare("SELECT COUNT(*) AS n FROM bodies WHERE session_id = ? AND role = 'assistant'")
|
|
176
|
+
.get(`codex:${threadId}`);
|
|
177
|
+
assert.equal(n, 22);
|
|
178
|
+
} finally {
|
|
179
|
+
db.close();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const [entry] = readFileSync(join(root, '.throughline', 'logs', 'hook-failures.log'), 'utf8')
|
|
183
|
+
.trim().split('\n').map((line) => JSON.parse(line));
|
|
184
|
+
assert.equal(entry.code, 'L1_SUMMARIZER_BACKEND_FAILED');
|
|
185
|
+
assert.equal(entry.reason, 'codex_cli_failed');
|
|
186
|
+
assert.match(entry.stderr, /usage limit reached/);
|
|
187
|
+
});
|
|
188
|
+
|
|
72
189
|
test('store failure preserves product failure and emits only fixed storage diagnostic', () => {
|
|
73
190
|
const { env } = createEnabledEnvironment('throughline-runtime-hook-store-fail-');
|
|
74
191
|
const storePath = defaultRuntimeErrorStorePath(env);
|
|
@@ -52,6 +52,13 @@ const DEFINITIONS = Object.freeze({
|
|
|
52
52
|
severity: 'high',
|
|
53
53
|
template: 'Throughline Codex hook processing failed',
|
|
54
54
|
}),
|
|
55
|
+
// Codex Stop の取り込みは済み、L1 要約の backend(Codex CLI)だけが失敗した(ADR 0028)。
|
|
56
|
+
L1_SUMMARIZER_BACKEND_FAILED: Object.freeze({
|
|
57
|
+
component: 'codex_l1_summarizer',
|
|
58
|
+
// BugHub が受ける severity は fatal・high・warn・info だけ。他の値は report 全体が 422 になる。
|
|
59
|
+
severity: 'warn',
|
|
60
|
+
template: 'Throughline L1 summarizer backend (Codex CLI) failed',
|
|
61
|
+
}),
|
|
55
62
|
});
|
|
56
63
|
|
|
57
64
|
export function defaultRuntimeErrorConfigPath(env = process.env) {
|
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({
|