throughline 0.12.6 → 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 +13 -1
- package/docs/adr/0012-claude-stop-transcript-flush-barrier.md +1 -0
- package/docs/adr/0029-stop-flush-barrier-absent-transcript.md +62 -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/turn-processor.mjs +37 -1
- package/src/turn-processor.test.mjs +44 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,17 @@ 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
|
+
|
|
13
24
|
## [0.12.6] — 2026-10-04
|
|
14
25
|
|
|
15
26
|
### 修正
|
|
@@ -1603,7 +1614,8 @@ two attempts, instrument first instead of patching again.
|
|
|
1603
1614
|
|
|
1604
1615
|
---
|
|
1605
1616
|
|
|
1606
|
-
[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
|
|
1607
1619
|
[0.12.6]: https://github.com/kitepon/Throughline/compare/v0.12.5...v0.12.6
|
|
1608
1620
|
[0.12.5]: https://github.com/kitepon/Throughline/compare/v0.12.4...v0.12.5
|
|
1609
1621
|
[0.12.4]: https://github.com/kitepon/Throughline/compare/v0.12.3...v0.12.4
|
|
@@ -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
|
|
|
@@ -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/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);
|
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({
|