throughline 0.16.0 → 0.16.2
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 +40 -1
- package/docs/05_codex_first_roadmap.md +2 -0
- package/docs/adr/0038-stop-flush-barrier-head-lost-transcript.md +53 -0
- package/docs/adr/0039-codex-auto-handoff-one-successor-per-source.md +58 -0
- package/package.json +1 -1
- package/src/codex-auto-handoff-store.mjs +15 -0
- package/src/codex-auto-handoff.mjs +65 -5
- package/src/codex-auto-handoff.test.mjs +111 -1
- package/src/runtime-error-hook.test.mjs +14 -0
- package/src/turn-processor.mjs +18 -5
- package/src/turn-processor.test.mjs +40 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,43 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.16.2] — 2026-10-07
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- Codexの自動継続で、引き継ぎ済みの旧タスクへ新しい入力が届くたびに、同じ作業を引き継いだ後継がもう1つ立っていたのを直す([ADR 0039](docs/adr/0039-codex-auto-handoff-one-successor-per-source.md))。重複を見ていたのは元のタスクと元のターンの組で、旧タスクに新しいターンが出来ると別の引き継ぎとして受けていた。継続の指示が届いた後継が残っている間は、新しい引き継ぎを作らず、ターンを止めて、止めた理由に引き継ぎID・後継の名前・`codex://threads/<後継>`を書き、Desktopで後継を開く。後継がさらに引き継いでいれば、その先を示す。
|
|
18
|
+
- workerは、継続の指示を送る直前に、同じ旧タスクの別の引き継ぎが先に指示を送っていないかを確かめる。送られていれば`handoff_source_already_continued`で止まり、後継へ指示を送らない。
|
|
19
|
+
- 止めた事を`~/.throughline/codex-auto-handoff/redirects.jsonl`に1行残す(会話の本文は書かない)。
|
|
20
|
+
|
|
21
|
+
### 変わらない事
|
|
22
|
+
|
|
23
|
+
- 配送の前に失敗した引き継ぎしか無い旧タスクと、後継が残っていない(消した、アーカイブした)旧タスクからは、今までどおり新しい後継を立てる。
|
|
24
|
+
- 旧タスクへ打った入力は後継へ渡さない。後継で打ち直す。
|
|
25
|
+
|
|
26
|
+
### 確認した範囲
|
|
27
|
+
|
|
28
|
+
- macOSのCodex Desktop(0.16.1)で2026-10-07に起きた記録(同じ旧タスクから3つの引き継ぎ)を読んだ。
|
|
29
|
+
|
|
30
|
+
### 確認していない範囲
|
|
31
|
+
|
|
32
|
+
- Codex Desktopの画面で、旧タスクに止めた理由がどう見えるか。
|
|
33
|
+
|
|
34
|
+
## [0.16.1] — 2026-10-07
|
|
35
|
+
|
|
36
|
+
### 修正
|
|
37
|
+
|
|
38
|
+
- Claudeのターンの途中で会話記録(transcript)のフォルダが消され、Claude Codeが残りの行だけでファイルを作り直した時、Stopのhookが`Claude Stop transcript completion was not visible before deadline`で失敗し、`HOOK_PROCESS_TURN_FAILED`を数えていたのを直す([ADR 0038](docs/adr/0038-stop-flush-barrier-head-lost-transcript.md))。期限まで待って、transcriptに応答だけがあり利用者の発言が1つも無い時は、失敗に数えず、`backfill.log`に`skipped: "transcript_head_lost"`を残して終わる。ファイルが残らなかった時(`transcript_absent`、[ADR 0029](docs/adr/0029-stop-flush-barrier-absent-transcript.md))と同じ扱いにする。
|
|
39
|
+
- 利用者の発言が残っているのに完了が見えない時は、今までどおり失敗に数える。
|
|
40
|
+
|
|
41
|
+
### 確認した範囲
|
|
42
|
+
|
|
43
|
+
- Linuxのコンテナ(0.15.5、Claude Code 2.1.292)で2026-10-07に起きた失敗の、transcriptとStopのpayloadを切り離した置き場で再生した。0.15.5は同じ文面で失敗して1回数え、この版は失敗に数えず`transcript_head_lost`を記録した。
|
|
44
|
+
|
|
45
|
+
### 確認していない範囲
|
|
46
|
+
|
|
47
|
+
- 同じ朝に同じ試験の道具で数えられた残り11回の失敗は、transcriptが後で消されていて、同じ形だったかを直接は見ていない。11回とも、その会話の最後のStopで起きている。
|
|
48
|
+
- macOSとWindowsでは、この形の失敗を再現していない(足した試験はCIの3環境で走る)。
|
|
49
|
+
|
|
13
50
|
## [0.16.0] — 2026-10-07
|
|
14
51
|
|
|
15
52
|
### 変更
|
|
@@ -1876,7 +1913,9 @@ two attempts, instrument first instead of patching again.
|
|
|
1876
1913
|
|
|
1877
1914
|
---
|
|
1878
1915
|
|
|
1879
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.
|
|
1916
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.2...HEAD
|
|
1917
|
+
[0.16.2]: https://github.com/kitepon/Throughline/compare/v0.16.1...v0.16.2
|
|
1918
|
+
[0.16.1]: https://github.com/kitepon/Throughline/compare/v0.16.0...v0.16.1
|
|
1880
1919
|
[0.16.0]: https://github.com/kitepon/Throughline/compare/v0.15.5...v0.16.0
|
|
1881
1920
|
[0.15.5]: https://github.com/kitepon/Throughline/compare/v0.15.4...v0.15.5
|
|
1882
1921
|
[0.15.4]: https://github.com/kitepon/Throughline/compare/v0.15.3...v0.15.4
|
|
@@ -160,6 +160,8 @@ sequenceDiagram
|
|
|
160
160
|
DBには引き継ぎID、元thread/turn、project/Codex環境、snapshot参照、
|
|
161
161
|
前任引き継ぎID、新thread ID、配送ID、受付ID、状態、開始証拠、失敗理由を保存する。
|
|
162
162
|
元thread/turnの要求は一意にし、フックの重複で後継を増やさない。
|
|
163
|
+
継続の指示が届いた旧タスクへ新しいturnが来た時は、後継が残っている間は要求を作らず、
|
|
164
|
+
ターンを止めて今の後継を示して開く([ADR 0039](adr/0039-codex-auto-handoff-one-successor-per-source.md))。
|
|
163
165
|
schema変更は `src/db.mjs` と製品所有migrationへ置き、
|
|
164
166
|
schemaの版とmigrationは同じ製品releaseで更新する。
|
|
165
167
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# ADR 0038: 始まりを失った transcript の Claude Stop は、hook 処理の失敗に数えない
|
|
2
|
+
|
|
3
|
+
日付: 2026-10-07
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
2026-10-07 08:24:17 JST、BellTeam コンテナ(0.15.5)で、解決登録していた `HOOK_PROCESS_TURN_FAILED` が再発した
|
|
8
|
+
(7回目)。理由は `Claude Stop transcript completion was not visible before deadline`。
|
|
9
|
+
|
|
10
|
+
失敗した会話は、`/tmp/ag-fvIcWA/proj` で動いた Claude Code(2.1.292)の試験用の会話。1つ目のターンの Stop は
|
|
11
|
+
同じ会話の transcript を読んで 1 turn を保存している(08:23:59)。2つ目のターンの Stop が失敗した。
|
|
12
|
+
|
|
13
|
+
残っていた transcript は 10 行で、最初の行は 08:24:08.262 の assistant(thinking だけ)、次が 08:24:15.621 の
|
|
14
|
+
最後の応答だった。user の行は1つも無い。同じファイルの `turn_duration` は `messageCount: 29` と書いている。
|
|
15
|
+
transcript を置くフォルダ(`~/.claude/projects/-tmp-ag-fvIcWA-proj`)の作成時刻は 08:24:15.744 で、
|
|
16
|
+
会話の作業フォルダ(`/tmp/ag-fvIcWA/proj`)は 08:24:15.861 に消されている。
|
|
17
|
+
|
|
18
|
+
会話を起動した試験の道具は、回答が端末に出た直後に端末を閉じ、transcript のフォルダと作業フォルダを消す。
|
|
19
|
+
Claude Code は最後の応答の行(thinking と本文)をその後で書くので、フォルダごとファイルを作り直す。
|
|
20
|
+
Stop hook は応答の後に非同期で走り、応答だけのファイルを読んだ、と読める。
|
|
21
|
+
|
|
22
|
+
同じ道具は同じ朝に続けて動き、08:24〜08:33 の間に同じ文面の失敗が 12 回数えられた(7回目〜18回目)。
|
|
23
|
+
transcript が残っていたのは最初の1回だけで、残りの11回は後でファイルが消されている。11回とも、
|
|
24
|
+
その会話の最後の Stop で、1つ前の Stop は保存できている。
|
|
25
|
+
|
|
26
|
+
ファイルが残らなかった時は ADR 0029 の `transcript_absent` になり、失敗に数えない。今回はファイルが
|
|
27
|
+
作り直されていたので、ADR 0029 Decision 3(ファイルがあるのに完了が見えない時は失敗)に当たった。
|
|
28
|
+
どちらも「保存する元が、会話を起動した側に消された」で、違いは Claude Code が残りを書いた時刻だけ。
|
|
29
|
+
|
|
30
|
+
Throughline はターンを user の発言から数える(`readLatestLogicalTurnCompletions`)。user の発言が1つも無い
|
|
31
|
+
transcript からは、どのターンも作れない。Claude Code は会話の最初に必ず user の行を書くので、
|
|
32
|
+
assistant の発言があって user の発言が無い transcript は、始まりを失った物に限られる。
|
|
33
|
+
|
|
34
|
+
控えた transcript と同じ Stop の payload を、切り離した置き場で再生した。0.15.5 は同じ文面で失敗し、
|
|
35
|
+
`HOOK_PROCESS_TURN_FAILED` を1回数えた。
|
|
36
|
+
|
|
37
|
+
## Decision
|
|
38
|
+
|
|
39
|
+
1. flush barrier は、期限まで待っても完了が見えず、transcript に assistant の発言があって user の発言が
|
|
40
|
+
1つも無い時、`transcript_head_lost` を返す。失敗にしない。
|
|
41
|
+
2. 確認は期限に達した時だけ行う。遅れて書かれる user の行は、今までどおり期限まで待つ。
|
|
42
|
+
3. user の発言が1つでも残っている時と、発言が1つも無いファイル(書き始め)は、今までどおり明示失敗して
|
|
43
|
+
`HOOK_PROCESS_TURN_FAILED` を数える(ADR 0012 Decision 4、ADR 0029 Decision 3)。
|
|
44
|
+
4. `transcript_head_lost` の Stop は、ADR 0029 Decision 4 と同じ扱いにする。`backfill.log` に
|
|
45
|
+
`{"hook":"stop",…,"skipped":"transcript_head_lost"}` を1行残し、終了 code 0 で終わる。
|
|
46
|
+
DB・state file・完了受領・runtime error store には何も書かない。
|
|
47
|
+
|
|
48
|
+
## Consequences
|
|
49
|
+
|
|
50
|
+
- 始まりを消された会話の最後のターンは保存されない。保存する依頼がどこにも無いので、今までも保存できていない。
|
|
51
|
+
変わるのは、失敗に数えるかどうかだけ。
|
|
52
|
+
- 見送った Stop は `backfill.log` の `skipped` で数えられる。
|
|
53
|
+
- transcript の途中だけが失われ、user の発言が残っている時は、今までどおり失敗に数える。
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# ADR 0039: 引き継ぎ済みの Codex タスクへ新しい入力が来ても、後継を増やさない
|
|
2
|
+
|
|
3
|
+
日付: 2026-10-07
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
2026-10-07、macOS の Codex Desktop(Throughline 0.16.1、自動継続は全 project で有効)で、同じ作業を引き継いだ
|
|
8
|
+
後継タスクが3つ出来た。時刻は JST。
|
|
9
|
+
|
|
10
|
+
- 09:49:01 旧タスク `01a1136a` の自動圧縮を止め、後継 `01a113d5` を立てた(`continued`)。後継は 09:54:40 に
|
|
11
|
+
ターンを終え、利用者の操作を待っていた。
|
|
12
|
+
- 09:56:48 旧タスク `01a1136a` へ、利用者の新しい入力(1行)が届いた。旧タスクは閾値を超えたままなので、
|
|
13
|
+
`PreCompact(auto)` がもう一度発火した。Throughline はそのターンを止め、2つ目の後継 `01a113dc` を立てた。
|
|
14
|
+
- 09:56:58 同じ旧タスクへもう1行届き、3つ目の後継 `01a113dd` を立てた。
|
|
15
|
+
|
|
16
|
+
3つの引き継ぎは、どれも元が同じ旧タスクで、前任の引き継ぎも同じだった。2つ目と3つ目の後継は、1つ目の
|
|
17
|
+
後継が 09:49〜09:54 に進めた作業を知らないまま、同じ作業(本番の認証の確認)を始めた。3つ目が自分で
|
|
18
|
+
降りたので、操作は重複しなかった。
|
|
19
|
+
|
|
20
|
+
設計の受入条件は「同じ旧タスクから後継を重複作成せず、旧・新タスクが同じ操作を重複実行しない」
|
|
21
|
+
([05_codex_first_roadmap.md](../05_codex_first_roadmap.md))。実装が一意にしていたのは元 thread と元 turn の
|
|
22
|
+
組で、同じ旧タスクの別の turn は別の要求として受けていた。hook が同じ turn で2回走った時は増えないが、
|
|
23
|
+
旧タスクに新しい turn が出来るたびに後継が増える。
|
|
24
|
+
|
|
25
|
+
Claude Code の自動継続(ADR 0033)は、記録を旧い会話ごとに1つ持つ。引き継ぎ済みの会話で道具が呼ばれた時は、
|
|
26
|
+
後継を増やさずに止め、止めた理由に後継を書く。Codex の側をこの形にそろえる。
|
|
27
|
+
|
|
28
|
+
## Decision
|
|
29
|
+
|
|
30
|
+
1. Codex の `PreCompact(auto)` は、その旧タスクから継続の指示が届いた(届いたかもしれない)引き継ぎがあり、
|
|
31
|
+
その後継のタスクが残っている時、新しい引き継ぎを作らない。`continue: false` でターンを止め、止めた理由に
|
|
32
|
+
引き継ぎ ID、後継の名前、`codex://threads/<後継>` を書く。Desktop で後継を開く。
|
|
33
|
+
2. 「指示が届いた(かもしれない)」は、受付 ID が保存されている、状態が `submitted`・`continued`、
|
|
34
|
+
または配送の途中(`mutation_stage` が `submit`。結果不明で止まった物を含む)のどれか。
|
|
35
|
+
配送の前に失敗した引き継ぎは数えない。その旧タスクからは、今までどおり新しい引き継ぎを作る。
|
|
36
|
+
3. 示す後継は、今その作業を持っているタスクにする。後継がさらに引き継いでいれば、その先をたどる。
|
|
37
|
+
後継の rollout が `sessions` に無い時(消した、アーカイブした)は、その後継は無い物として扱う。
|
|
38
|
+
残っている後継が1つも無ければ、今までどおり旧タスクから新しい引き継ぎを作る。
|
|
39
|
+
4. 0.16.1 までに同じ旧タスクから複数の後継が立っている記録では、rollout が最後に書かれた後継を示す。
|
|
40
|
+
5. worker は、継続の指示を送る直前に、同じ旧タスクの別の引き継ぎが先に指示を送っていないかを確かめる。
|
|
41
|
+
確認と「配送の途中」の記録は1つの transaction で行う。先に送られていれば、送らずに
|
|
42
|
+
`handoff_source_already_continued` で失敗にする。2つの入力がほぼ同時に来て、hook の判定をどちらも
|
|
43
|
+
通った時のための確認。
|
|
44
|
+
6. 止めた事は `~/.throughline/codex-auto-handoff/redirects.jsonl` に1行残す(時刻、旧タスクと turn、
|
|
45
|
+
引き継ぎ ID、後継、開けたか)。会話の本文は書かない。引き継ぎの行は増えないので、起きた事はここで数える。
|
|
46
|
+
7. 同じ turn の hook がもう一度来た時は、今までどおり同じ引き継ぎを返す。手動の `/compact` は、
|
|
47
|
+
今までどおり止めない。
|
|
48
|
+
|
|
49
|
+
## Consequences
|
|
50
|
+
|
|
51
|
+
- 旧タスクへ打った入力は、後継へは渡らない。旧タスクでターンが止まり、後継が開く。利用者は後継で
|
|
52
|
+
打ち直す。入力を後継へ自動で送る事はしない(継続の指示は1回だけ送る、という配送の契約を変えないため)。
|
|
53
|
+
- 引き継ぎ済みの旧タスクは、後継が残っている間、そのタスクで作業を続けられない。旧タスクで続けたい時は、
|
|
54
|
+
旧タスクで手動の `/compact` を使う。
|
|
55
|
+
- 2つの入力がほぼ同時に来て5に当たった時は、後に来た方の後継(記憶だけが入った、指示の無いタスク)が
|
|
56
|
+
残り、失敗の表示が出る。
|
|
57
|
+
- 外から旧タスクへ届く文(旧タスクが開いた通話への返信など)も、同じように止まる。0.16.1 までは、
|
|
58
|
+
1通ごとに後継が1つ増えていた。
|
package/package.json
CHANGED
|
@@ -22,6 +22,21 @@ export function findAutoHandoffForTarget(db, threadId) {
|
|
|
22
22
|
return decode(db.prepare('SELECT * FROM codex_handoffs WHERE target_thread_id = ?').get(threadId));
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export function findAutoHandoffForTurn(db, threadId, turnId) {
|
|
26
|
+
return decode(db.prepare('SELECT * FROM codex_handoffs WHERE source_thread_id = ? AND source_turn_id = ?')
|
|
27
|
+
.get(threadId, turnId));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 継続の指示が後継へ届いた、または届いたかもしれない引き継ぎ(新しい順)。後継が作業を持っているので、
|
|
32
|
+
* 同じ旧タスクから別の後継を立てない。配送の前に失敗した引き継ぎは入れない。
|
|
33
|
+
*/
|
|
34
|
+
export function listDeliveredAutoHandoffsForSource(db, threadId) {
|
|
35
|
+
return db.prepare(`SELECT * FROM codex_handoffs WHERE source_thread_id = ? AND target_thread_id IS NOT NULL
|
|
36
|
+
AND (queued_submission_id IS NOT NULL OR state IN ('submitted', 'continued') OR mutation_stage = 'submit')
|
|
37
|
+
ORDER BY created_at DESC`).all(threadId).map(decode);
|
|
38
|
+
}
|
|
39
|
+
|
|
25
40
|
export function listAutoHandoffs(db, { projectPath = null, limit = 20 } = {}) {
|
|
26
41
|
const rows = projectPath == null
|
|
27
42
|
? db.prepare('SELECT * FROM codex_handoffs ORDER BY created_at DESC LIMIT ?').all(limit)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, openSync, closeSync, writeFileSync, realpathSync } from 'node:fs';
|
|
1
|
+
import { mkdirSync, openSync, closeSync, writeFileSync, appendFileSync, realpathSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { dirname, join, resolve, relative, isAbsolute } from 'node:path';
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
@@ -13,7 +13,8 @@ import { findCodexThreadCandidate } from './codex-thread-index.mjs';
|
|
|
13
13
|
import { captureCodexRolloutToDb } from './codex-capture.mjs';
|
|
14
14
|
import { readAutoHandoffConfig, autoHandoffEnabledFor } from './codex-auto-handoff-config.mjs';
|
|
15
15
|
import { getAutoHandoff, requestAutoHandoff, updateAutoHandoff, failAutoHandoff,
|
|
16
|
-
claimAutoHandoff, releaseAutoHandoff, freezeCodexMemory, continuationInput
|
|
16
|
+
claimAutoHandoff, releaseAutoHandoff, freezeCodexMemory, continuationInput,
|
|
17
|
+
findAutoHandoffForTurn, listDeliveredAutoHandoffsForSource } from './codex-auto-handoff-store.mjs';
|
|
17
18
|
import { ensureAutoHandoffSummaries, renderAutoHandoffMemory } from './codex-auto-handoff-memory.mjs';
|
|
18
19
|
import { CODEX_NATIVE_ID_PATTERN, CodexHandoffError, readCodexHandoffState,
|
|
19
20
|
settingsMatch, threadStartSettings } from './hosts/codex-handoff-state.mjs';
|
|
@@ -66,8 +67,45 @@ export async function launchAutoHandoffWorker(id) {
|
|
|
66
67
|
} finally { closeSync(log); }
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
function liveSuccessors(db, threadId, options, seen) {
|
|
71
|
+
const found = [];
|
|
72
|
+
for (const operation of listDeliveredAutoHandoffsForSource(db, threadId)) {
|
|
73
|
+
if (operation.handoff_id === options.exceptHandoffId || seen.has(operation.handoff_id)) continue;
|
|
74
|
+
seen.add(operation.handoff_id);
|
|
75
|
+
const deeper = liveSuccessors(db, operation.target_thread_id, options, seen);
|
|
76
|
+
if (deeper.length) { found.push(...deeper); continue; }
|
|
77
|
+
const thread = options.findThread({ threadId: operation.target_thread_id, codexHome: operation.codex_home,
|
|
78
|
+
projectPath: operation.project_path, requireProjectMatch: true });
|
|
79
|
+
if (thread) found.push({ operation, mtimeMs: thread.mtimeMs ?? 0 });
|
|
80
|
+
}
|
|
81
|
+
return found;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 旧タスクの作業を今持っている後継の引き継ぎ。継続の指示が届いた引き継ぎをたどり、後継がさらに引き継いで
|
|
86
|
+
* いればその先へ進む。後継のrolloutが無い枝(消した、アーカイブした)は数えない。無ければ null。
|
|
87
|
+
* 0.16.1 までに同じ旧タスクから複数の後継が立っていた時は、最後に動いた後継を返す。
|
|
88
|
+
*/
|
|
89
|
+
export function findLiveAutoHandoffSuccessor(db, threadId,
|
|
90
|
+
{ exceptHandoffId = null, findThread = findCodexThreadCandidate } = {}) {
|
|
91
|
+
return liveSuccessors(db, threadId, { exceptHandoffId, findThread }, new Set())
|
|
92
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)[0]?.operation ?? null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function openSuccessor(operation) {
|
|
96
|
+
const result = openUrlWithOsHandler(`codex://threads/${encodeURIComponent(operation.target_thread_id)}`);
|
|
97
|
+
return !result.error && result.status === 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 引き継ぎ済みの旧タスクを止めた記録。引き継ぎの行は増えないので、起きた事をここに残す。会話の本文は書かない。 */
|
|
101
|
+
function recordAutoHandoffRedirect(entry) {
|
|
102
|
+
mkdirSync(stateRoot(), { recursive: true, mode: 0o700 });
|
|
103
|
+
appendFileSync(join(stateRoot(), 'redirects.jsonl'), JSON.stringify(entry) + '\n', { mode: 0o600 });
|
|
104
|
+
}
|
|
105
|
+
|
|
69
106
|
export async function requestCodexAutoHandoff({ payload, db = null,
|
|
70
|
-
config = readAutoHandoffConfig(), launchWorker = launchAutoHandoffWorker
|
|
107
|
+
config = readAutoHandoffConfig(), launchWorker = launchAutoHandoffWorker,
|
|
108
|
+
findThread = findCodexThreadCandidate, openThread = openSuccessor, recordRedirect = recordAutoHandoffRedirect } = {}) {
|
|
71
109
|
if (payload.trigger !== 'auto' || !autoHandoffEnabledFor(config, payload.cwd)) return { status: 'skipped' };
|
|
72
110
|
const threadId = payload.session_id, turnId = payload.turn_id;
|
|
73
111
|
if (!CODEX_NATIVE_ID_PATTERN.test(threadId ?? '') || !CODEX_NATIVE_ID_PATTERN.test(turnId ?? '') ||
|
|
@@ -82,6 +120,19 @@ export async function requestCodexAutoHandoff({ payload, db = null,
|
|
|
82
120
|
if (!sessionPart) throw error('handoff_codex_home_unavailable');
|
|
83
121
|
const codexHome = rolloutPath.slice(0, sessionPart.index);
|
|
84
122
|
const actualDb = db ?? getDb();
|
|
123
|
+
// 引き継ぎ済みの旧タスクへ、新しい入力が来た。作業は後継が持っているので、後継を増やさずに止めて後継を示す。
|
|
124
|
+
const successor = findAutoHandoffForTurn(actualDb, threadId, turnId) ? null
|
|
125
|
+
: findLiveAutoHandoffSuccessor(actualDb, threadId, { findThread });
|
|
126
|
+
if (successor) {
|
|
127
|
+
const opened = openThread(successor);
|
|
128
|
+
recordRedirect({ at: new Date().toISOString(), source_thread_id: threadId, source_turn_id: turnId,
|
|
129
|
+
handoff_id: successor.handoff_id, target_thread_id: successor.target_thread_id, opened });
|
|
130
|
+
return { status: 'ok', operationId: successor.handoff_id, inserted: false, redirected: true,
|
|
131
|
+
targetThreadId: successor.target_thread_id, opened, continue: false,
|
|
132
|
+
stopReason: `このタスクはThroughlineが新しいタスクへ引き継ぎ済みです(引き継ぎID: ${successor.handoff_id})。` +
|
|
133
|
+
`続きは後継のタスク「${autoHandoffTargetName(actualDb, successor)}」で行ってください: ` +
|
|
134
|
+
`codex://threads/${successor.target_thread_id}` };
|
|
135
|
+
}
|
|
85
136
|
const { operation, inserted } = requestAutoHandoff(actualDb, { threadId, turnId, projectPath: state.meta.cwd,
|
|
86
137
|
rolloutPath, codexHome, openHost: config.openHost });
|
|
87
138
|
if (inserted) {
|
|
@@ -218,7 +269,7 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
|
|
|
218
269
|
nativeState: sourceNativeState, capture: captureCodexRolloutToDb, summaries: ensureAutoHandoffSummaries,
|
|
219
270
|
createTarget, readTarget: targetState, openTarget, verify: verifyCodexParent,
|
|
220
271
|
submit: submitCodexParentAnswer, notify: showAutoHandoffFailure, wait: waitForHandoff,
|
|
221
|
-
executable: () => platformDesktopFinder()(), ...dependencies };
|
|
272
|
+
executable: () => platformDesktopFinder()(), liveSuccessor: findLiveAutoHandoffSuccessor, ...dependencies };
|
|
222
273
|
let operation = getAutoHandoff(db, id);
|
|
223
274
|
if (!operation) throw error('handoff_not_found');
|
|
224
275
|
if (operation.state === 'continued') return operation;
|
|
@@ -299,7 +350,16 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
|
|
|
299
350
|
if (target?.latestTurnId || !target?.settings || !settingsMatch(operation.runtime.preparedSettings, target.settings)) throw error('handoff_target_advanced');
|
|
300
351
|
const parent = { thread_id: operation.target_thread_id, codex_home: operation.codex_home };
|
|
301
352
|
await deps.verify(autoHandoffDeliveryProfile, parent, runtime);
|
|
302
|
-
|
|
353
|
+
// 同じ旧タスクの別の引き継ぎが先に指示を送っていたら、後継を増やさない。確認と記録を1つのtransactionで行う。
|
|
354
|
+
db.exec('BEGIN IMMEDIATE');
|
|
355
|
+
try {
|
|
356
|
+
if (deps.liveSuccessor(db, operation.source_thread_id, { exceptHandoffId: id })) throw error('handoff_source_already_continued');
|
|
357
|
+
update({ mutation_stage: 'submit' });
|
|
358
|
+
db.exec('COMMIT');
|
|
359
|
+
} catch (cause) {
|
|
360
|
+
db.exec('ROLLBACK');
|
|
361
|
+
throw cause;
|
|
362
|
+
}
|
|
303
363
|
const receipt = await deps.submit(autoHandoffDeliveryProfile, parent, operation.delivery_id, continuationInput(operation), runtime);
|
|
304
364
|
update({ state: 'submitted', queued_submission_id: receipt.queued_submission_id, mutation_stage: null });
|
|
305
365
|
}
|
|
@@ -11,7 +11,7 @@ import { collectAutoHandoffMemory, ensureAutoHandoffSummaries, renderAutoHandoff
|
|
|
11
11
|
renderFrozenDetail } from './codex-auto-handoff-memory.mjs';
|
|
12
12
|
import { readCodexHandoffState, settingsMatch, threadStartSettings } from './hosts/codex-handoff-state.mjs';
|
|
13
13
|
import { runAutoHandoffWorker, requestCodexAutoHandoff, sourceBoundary,
|
|
14
|
-
autoHandoffTargetName, nameAutoHandoffTarget } from './codex-auto-handoff.mjs';
|
|
14
|
+
autoHandoffTargetName, nameAutoHandoffTarget, findLiveAutoHandoffSuccessor } from './codex-auto-handoff.mjs';
|
|
15
15
|
import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
|
|
16
16
|
|
|
17
17
|
async function withDb(fn) {
|
|
@@ -216,6 +216,116 @@ test('PreCompactの重複ではworkerを増やさず、manualと別projectでは
|
|
|
216
216
|
assert.equal((await requestCodexAutoHandoff({ ...args, config: { ...config, projects: ['/other'] } })).status, 'skipped');
|
|
217
217
|
}));
|
|
218
218
|
|
|
219
|
+
function desktopSource(home, thread, turn) {
|
|
220
|
+
const dir = join(home, 'codex', 'sessions'); mkdirSync(dir, { recursive: true });
|
|
221
|
+
const file = join(dir, `rollout-${thread}.jsonl`);
|
|
222
|
+
writeFileSync(file, [{ type: 'session_meta', payload: { id: thread, originator: 'Codex Desktop', source: 'vscode', cwd: home } },
|
|
223
|
+
{ type: 'event_msg', payload: { type: 'task_started', turn_id: turn } }].map(JSON.stringify).join('\n') + '\n');
|
|
224
|
+
return { trigger: 'auto', cwd: home, session_id: thread, turn_id: turn, transcript_path: file };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function delivered(db, home, source, turnId, target, now, fields = {}) {
|
|
228
|
+
const operation = requestAutoHandoff(db, { threadId: source, turnId, projectPath: home,
|
|
229
|
+
rolloutPath: '/codex/rollout.jsonl', codexHome: join(home, 'codex'), openHost: 'desktop', now }).operation;
|
|
230
|
+
return updateAutoHandoff(db, operation.handoff_id, { target_thread_id: target, state: 'continued',
|
|
231
|
+
queued_submission_id: `receipt-${target}`, runtime_json: { title: 'ベルチーム' }, ...fields }).operation;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
test('引き継ぎ済みの旧タスクへ新しい入力が来ても後継を増やさず、止めて今の後継を示して開く', async () => withDb(async (db, home) => {
|
|
235
|
+
const thread = '11111111-1111-1111-1111-111111111111';
|
|
236
|
+
const first = delivered(db, home, thread, '22222222-2222-2222-2222-222222222222', 'successor-1', 1);
|
|
237
|
+
const payload = desktopSource(home, thread, '33333333-3333-3333-3333-333333333333');
|
|
238
|
+
const config = { enabled: true, projects: [home], openHost: 'desktop' };
|
|
239
|
+
const seen = { launched: 0, opened: [], recorded: [], asked: [] };
|
|
240
|
+
const args = { db, config, payload, launchWorker: async () => { seen.launched++; },
|
|
241
|
+
findThread: query => { seen.asked.push(query); return { mtimeMs: 1 }; },
|
|
242
|
+
openThread: operation => { seen.opened.push(operation.target_thread_id); return true; },
|
|
243
|
+
recordRedirect: entry => { seen.recorded.push(entry); } };
|
|
244
|
+
const result = await requestCodexAutoHandoff(args);
|
|
245
|
+
assert.equal(result.continue, false); assert.equal(result.redirected, true); assert.equal(result.inserted, false);
|
|
246
|
+
assert.equal(result.operationId, first.handoff_id); assert.equal(result.targetThreadId, 'successor-1');
|
|
247
|
+
assert.match(result.stopReason, /引き継ぎ済み/);
|
|
248
|
+
assert.match(result.stopReason, /codex:\/\/threads\/successor-1/);
|
|
249
|
+
assert.ok(result.stopReason.includes('|ベルチーム(自動引き継ぎ)」'));
|
|
250
|
+
assert.equal(seen.launched, 0); assert.deepEqual(seen.opened, ['successor-1']);
|
|
251
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS n FROM codex_handoffs').get().n, 1);
|
|
252
|
+
assert.equal(seen.asked[0].threadId, 'successor-1'); assert.equal(seen.asked[0].codexHome, join(home, 'codex'));
|
|
253
|
+
assert.equal(seen.recorded.length, 1);
|
|
254
|
+
assert.deepEqual({ ...seen.recorded[0], at: null }, { at: null, source_thread_id: thread,
|
|
255
|
+
source_turn_id: payload.turn_id, handoff_id: first.handoff_id, target_thread_id: 'successor-1', opened: true });
|
|
256
|
+
|
|
257
|
+
// 後継がさらに引き継いでいれば、その先を示す。開けなくても止めて、止めた理由に後継を書く。
|
|
258
|
+
delivered(db, home, 'successor-1', 'turn-b', 'successor-2', 2);
|
|
259
|
+
const chained = await requestCodexAutoHandoff({ ...args, openThread: () => false });
|
|
260
|
+
assert.equal(chained.targetThreadId, 'successor-2'); assert.equal(chained.opened, false);
|
|
261
|
+
assert.match(chained.stopReason, /codex:\/\/threads\/successor-2/);
|
|
262
|
+
assert.equal(seen.launched, 0);
|
|
263
|
+
}));
|
|
264
|
+
|
|
265
|
+
test('後継が残っていない旧タスクと、配送の前に失敗した旧タスクからは、新しい後継を立てる', async () => withDb(async (db, home) => {
|
|
266
|
+
const thread = '11111111-1111-1111-1111-111111111111';
|
|
267
|
+
const config = { enabled: true, projects: [home], openHost: 'desktop' };
|
|
268
|
+
let launched = 0;
|
|
269
|
+
const base = { db, config, launchWorker: async () => { launched++; },
|
|
270
|
+
openThread: () => { throw new Error('開かない'); }, recordRedirect: () => { throw new Error('記録しない'); } };
|
|
271
|
+
// 作成の途中で失敗した引き継ぎ(後継はあるが、継続の指示は送っていない)。
|
|
272
|
+
const failed = requestAutoHandoff(db, { threadId: thread, turnId: 'turn-a', projectPath: home,
|
|
273
|
+
rolloutPath: '/codex/rollout.jsonl', codexHome: join(home, 'codex'), openHost: 'desktop', now: 1 }).operation;
|
|
274
|
+
updateAutoHandoff(db, failed.handoff_id, { target_thread_id: 'never-started', state: 'failed', error_code: 'handoff_target_not_loaded' });
|
|
275
|
+
const afterFailure = await requestCodexAutoHandoff({ ...base, findThread: () => ({ mtimeMs: 1 }),
|
|
276
|
+
payload: desktopSource(home, thread, '33333333-3333-3333-3333-333333333333') });
|
|
277
|
+
assert.equal(afterFailure.inserted, true); assert.equal(afterFailure.redirected, undefined); assert.equal(launched, 1);
|
|
278
|
+
// 継続の指示は届いたが、後継のrolloutが無い(消した、アーカイブした)。
|
|
279
|
+
updateAutoHandoff(db, afterFailure.operationId, { target_thread_id: 'removed', state: 'continued', queued_submission_id: 'receipt' });
|
|
280
|
+
const afterRemoval = await requestCodexAutoHandoff({ ...base, findThread: () => null,
|
|
281
|
+
payload: desktopSource(home, thread, '44444444-4444-4444-4444-444444444444') });
|
|
282
|
+
assert.equal(afterRemoval.inserted, true); assert.equal(launched, 2);
|
|
283
|
+
// 同じturnのhookがもう一度来た時は、今までどおり同じ引き継ぎを返す。
|
|
284
|
+
const again = await requestCodexAutoHandoff({ ...base, findThread: () => ({ mtimeMs: 1 }),
|
|
285
|
+
payload: desktopSource(home, thread, '44444444-4444-4444-4444-444444444444') });
|
|
286
|
+
assert.equal(again.operationId, afterRemoval.operationId); assert.equal(again.inserted, false); assert.equal(launched, 2);
|
|
287
|
+
}));
|
|
288
|
+
|
|
289
|
+
test('同じ旧タスクから複数の後継が立っていた記録では、最後に動いた後継を選ぶ', async () => withDb(async (db, home) => {
|
|
290
|
+
delivered(db, home, 'old', 'turn-1', 'first', 1);
|
|
291
|
+
const active = delivered(db, home, 'old', 'turn-2', 'second', 2);
|
|
292
|
+
delivered(db, home, 'old', 'turn-3', 'third', 3);
|
|
293
|
+
// 配送結果が不明のまま止まった引き継ぎも、指示が届いたかもしれないので数える。
|
|
294
|
+
const unknown = requestAutoHandoff(db, { threadId: 'other', turnId: 'turn', projectPath: home,
|
|
295
|
+
rolloutPath: '/codex/rollout.jsonl', codexHome: join(home, 'codex'), openHost: 'desktop', now: 4 }).operation;
|
|
296
|
+
updateAutoHandoff(db, unknown.handoff_id, { target_thread_id: 'maybe', state: 'unknown', mutation_stage: 'submit' });
|
|
297
|
+
const mtimes = { first: 10, second: 30, third: 20, maybe: 1 };
|
|
298
|
+
const findThread = ({ threadId }) => ({ mtimeMs: mtimes[threadId] });
|
|
299
|
+
assert.equal(findLiveAutoHandoffSuccessor(db, 'old', { findThread }).handoff_id, active.handoff_id);
|
|
300
|
+
assert.equal(findLiveAutoHandoffSuccessor(db, 'old', { findThread, exceptHandoffId: active.handoff_id }).target_thread_id, 'third');
|
|
301
|
+
assert.equal(findLiveAutoHandoffSuccessor(db, 'other', { findThread }).target_thread_id, 'maybe');
|
|
302
|
+
assert.equal(findLiveAutoHandoffSuccessor(db, 'second', { findThread }), null);
|
|
303
|
+
}));
|
|
304
|
+
|
|
305
|
+
test('workerは、同じ旧タスクの別の引き継ぎが先に指示を送っていたら配送しない', async () => withDb(async db => {
|
|
306
|
+
const counters = { created: 0, notified: 0, submitted: 0 };
|
|
307
|
+
const submit = async () => { counters.submitted++; return { queued_submission_id: `receipt-${counters.submitted}` }; };
|
|
308
|
+
const liveSuccessor = (database, threadId, options) =>
|
|
309
|
+
findLiveAutoHandoffSuccessor(database, threadId, { ...options, findThread: () => ({ mtimeMs: 1 }) });
|
|
310
|
+
const first = request(db, 'shared-source', { now: 1 }).operation;
|
|
311
|
+
const firstResult = await runAutoHandoffWorker(first.handoff_id,
|
|
312
|
+
{ db, dependencies: { ...workerDependencies(db, {}, submit, counters), liveSuccessor } });
|
|
313
|
+
assert.equal(firstResult.state, 'continued');
|
|
314
|
+
const second = request(db, 'shared-source', { turnId: 'turn-2', now: 2 }).operation;
|
|
315
|
+
const deps = workerDependencies(db, {}, submit, counters);
|
|
316
|
+
const result = await runAutoHandoffWorker(second.handoff_id, { db, dependencies: { ...deps, liveSuccessor,
|
|
317
|
+
readSource: () => ({ ...deps.readSource(), latestTurnId: 'turn-2' }),
|
|
318
|
+
capture: () => ({ status: 'captured' }),
|
|
319
|
+
createTarget: async (database, operation) => {
|
|
320
|
+
updateAutoHandoff(database, operation.handoff_id, { target_thread_id: 'second-target', mutation_stage: null,
|
|
321
|
+
runtime_json: { ...operation.runtime, targetPrepared: true, targetMemoryInjected: true,
|
|
322
|
+
preparedSettings: deps.readTarget(operation).settings } });
|
|
323
|
+
} } });
|
|
324
|
+
assert.equal(result.state, 'failed'); assert.equal(result.error_code, 'handoff_source_already_continued');
|
|
325
|
+
assert.equal(result.mutation_stage, null); assert.equal(result.queued_submission_id, null);
|
|
326
|
+
assert.equal(counters.submitted, 1); assert.equal(counters.notified, 1);
|
|
327
|
+
}));
|
|
328
|
+
|
|
219
329
|
test('公開CLIはactionに適合する引数だけを受け取る', () => {
|
|
220
330
|
assert.equal(parseAutoHandoffArgs(['status', '--json']).action, 'status');
|
|
221
331
|
for (const args of [['enable', '--operation', 'id'], ['disable', '--project', '/p'], ['status', '--json', '--json'],
|
|
@@ -98,6 +98,20 @@ test('Claude Stopで、transcriptのファイルが無い会話は失敗に数
|
|
|
98
98
|
skipped: 'transcript_absent',
|
|
99
99
|
}]);
|
|
100
100
|
|
|
101
|
+
// ターンの途中でtranscriptが消され、応答の行だけでファイルが作り直された。依頼が残っていないので、失敗に数えない(ADR 0038)。
|
|
102
|
+
const headLostPath = join(root, 'head-lost.jsonl');
|
|
103
|
+
writeFileSync(headLostPath, JSON.stringify({ type: 'assistant',
|
|
104
|
+
message: { role: 'assistant', stop_reason: 'end_turn', content: [{ type: 'text', text: 'PROBE_OK' }] } }) + '\n');
|
|
105
|
+
const headLost = stop({
|
|
106
|
+
session_id: 'a1b2c3d4-0000-4000-8000-000000000003', transcript_path: headLostPath, cwd: root,
|
|
107
|
+
hook_event_name: 'Stop', stop_hook_active: false, last_assistant_message: 'PROBE_OK',
|
|
108
|
+
});
|
|
109
|
+
assert.equal(headLost.status, 0, headLost.stderr);
|
|
110
|
+
assert.equal(existsSync(storePath), false, 'a transcript that lost its head must not be counted as a hook failure');
|
|
111
|
+
assert.equal(existsSync(join(root, '.throughline', 'logs', 'hook-failures.log')), false);
|
|
112
|
+
assert.deepEqual([readLog('backfill.log').at(-1).session_id, readLog('backfill.log').at(-1).skipped],
|
|
113
|
+
['a1b2c3d4-0000-4000-8000-000000000003', 'transcript_head_lost']);
|
|
114
|
+
|
|
101
115
|
// ファイルはあるのに、Stopが渡した応答がそこに無い。保存できなかった会話なので、失敗のまま数える。
|
|
102
116
|
const presentPath = join(root, 'present.jsonl');
|
|
103
117
|
writeFileSync(presentPath, JSON.stringify({ type: 'user', message: { role: 'user', content: 'request' } }) + '\n');
|
package/src/turn-processor.mjs
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
import { getDb } from './db.mjs';
|
|
34
34
|
import {
|
|
35
35
|
readRawEntries,
|
|
36
|
+
readTranscript,
|
|
36
37
|
readLatestLogicalTurnCompletions,
|
|
37
38
|
sliceCurrentTurnEntries,
|
|
38
39
|
extractDetailBlocks,
|
|
@@ -60,6 +61,12 @@ export const CLAUDE_STOP_TRANSCRIPT_FLUSH_INTERVAL_MS = 25;
|
|
|
60
61
|
|
|
61
62
|
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
62
63
|
|
|
64
|
+
/** transcript に assistant の発言だけがあり、user の発言が1つも無い(会話の始まりを失っている)。 */
|
|
65
|
+
function isTranscriptHeadLost(transcriptPath) {
|
|
66
|
+
const turns = readTranscript(transcriptPath);
|
|
67
|
+
return turns.length > 0 && !turns.some(turn => turn.role === 'user');
|
|
68
|
+
}
|
|
69
|
+
|
|
63
70
|
/**
|
|
64
71
|
* Claude Stop payloadのassistant identityがtranscriptへ永続化されるまで待つ。
|
|
65
72
|
* markerは本文ソースにせず、transcript可視化のbarrierにだけ使う。
|
|
@@ -77,6 +84,10 @@ const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
|
|
|
77
84
|
* 期限まで待ってもtranscriptのファイル自体が無い時は、失敗にせず `transcript_absent` を返す
|
|
78
85
|
* (ADR 0029)。Claude Codeを `--no-session-persistence` で起動すると、hookへ `transcript_path` は
|
|
79
86
|
* 渡るが、ファイルは最後まで作られない。ファイルがあるのに完了が見えない時は、今までどおり失敗にする。
|
|
87
|
+
*
|
|
88
|
+
* 期限まで待って、transcriptにassistantの発言だけがありuserの発言が1つも無い時は、失敗にせず
|
|
89
|
+
* `transcript_head_lost` を返す(ADR 0038)。ターンの途中でtranscriptが消されると、Claude Codeは
|
|
90
|
+
* 残りの行だけでファイルを作り直す。そのターンの依頼はどこにも残っていない。
|
|
80
91
|
*/
|
|
81
92
|
export async function waitForClaudeStopTranscriptFlush({
|
|
82
93
|
transcriptPath,
|
|
@@ -87,6 +98,7 @@ export async function waitForClaudeStopTranscriptFlush({
|
|
|
87
98
|
readCompletions = readLatestLogicalTurnCompletions,
|
|
88
99
|
isTurnCaptured = () => true,
|
|
89
100
|
transcriptExists = existsSync,
|
|
101
|
+
transcriptHeadLost = isTranscriptHeadLost,
|
|
90
102
|
now = Date.now,
|
|
91
103
|
wait = delay,
|
|
92
104
|
}) {
|
|
@@ -115,8 +127,9 @@ export async function waitForClaudeStopTranscriptFlush({
|
|
|
115
127
|
}
|
|
116
128
|
const remaining = deadline - now();
|
|
117
129
|
if (remaining <= 0) {
|
|
118
|
-
if (typeof transcriptPath === 'string' && transcriptPath
|
|
119
|
-
return { status: 'transcript_absent' };
|
|
130
|
+
if (typeof transcriptPath === 'string' && transcriptPath) {
|
|
131
|
+
if (!transcriptExists(transcriptPath)) return { status: 'transcript_absent' };
|
|
132
|
+
if (transcriptHeadLost(transcriptPath)) return { status: 'transcript_head_lost' };
|
|
120
133
|
}
|
|
121
134
|
throw new Error('Claude Stop transcript completion was not visible before deadline');
|
|
122
135
|
}
|
|
@@ -279,8 +292,8 @@ async function processStop() {
|
|
|
279
292
|
lastAssistantMessage: last_assistant_message,
|
|
280
293
|
isTurnCaptured: (fragmentTurnNumbers) => isLogicalTurnCaptured(db, origin, fragmentTurnNumbers),
|
|
281
294
|
});
|
|
282
|
-
if (flush.status === 'transcript_absent') {
|
|
283
|
-
// 保存する元が無い。失敗には数えず、どの会話のStopを見送ったかを端末内に残す(ADR 0029)。
|
|
295
|
+
if (flush.status === 'transcript_absent' || flush.status === 'transcript_head_lost') {
|
|
296
|
+
// 保存する元が無い。失敗には数えず、どの会話のStopを見送ったかを端末内に残す(ADR 0029・0038)。
|
|
284
297
|
logBackfill({
|
|
285
298
|
ts: new Date().toISOString(),
|
|
286
299
|
hook: 'stop',
|
|
@@ -288,7 +301,7 @@ async function processStop() {
|
|
|
288
301
|
target,
|
|
289
302
|
origin,
|
|
290
303
|
transcript_path,
|
|
291
|
-
skipped:
|
|
304
|
+
skipped: flush.status,
|
|
292
305
|
});
|
|
293
306
|
process.exit(0);
|
|
294
307
|
}
|
|
@@ -274,6 +274,46 @@ test('Claude Stop flush barrierは、期限までtranscriptのファイルが無
|
|
|
274
274
|
}
|
|
275
275
|
});
|
|
276
276
|
|
|
277
|
+
test('Claude Stop flush barrierは、期限までtranscriptにuserの発言が1つも無い時だけtranscript_head_lostを返す', async () => {
|
|
278
|
+
const root = mkdtempSync(join(tmpdir(), 'throughline-stop-head-lost-'));
|
|
279
|
+
const transcriptPath = join(root, 'head-lost.jsonl');
|
|
280
|
+
let elapsed = 0;
|
|
281
|
+
const clock = { now: () => elapsed, wait: async (milliseconds) => { elapsed += milliseconds; } };
|
|
282
|
+
const line = (entry) => JSON.stringify(entry);
|
|
283
|
+
const assistant = (content) => ({ type: 'assistant', message: { role: 'assistant', stop_reason: 'end_turn', content } });
|
|
284
|
+
try {
|
|
285
|
+
// ターンの途中でtranscriptのフォルダが消され、Claude Codeが残りの行だけでファイルを作り直した形(Linux 2.1.292で実測)。
|
|
286
|
+
const tail = [
|
|
287
|
+
line(assistant([{ type: 'thinking', thinking: '' }])),
|
|
288
|
+
line(assistant([{ type: 'text', text: '最後の応答' }])),
|
|
289
|
+
line({ type: 'system', subtype: 'stop_hook_summary', hookCount: 3 }),
|
|
290
|
+
line({ type: 'ai-title', aiTitle: '題' }),
|
|
291
|
+
];
|
|
292
|
+
writeFileSync(transcriptPath, tail.join('\n') + '\n', 'utf8');
|
|
293
|
+
assert.deepEqual(await waitForClaudeStopTranscriptFlush({
|
|
294
|
+
transcriptPath, lastAssistantMessage: '最後の応答', timeoutMs: 30, intervalMs: 10, ...clock,
|
|
295
|
+
}), { status: 'transcript_head_lost' });
|
|
296
|
+
assert.equal(elapsed, 30, 'a user line that is written late must still get the whole deadline');
|
|
297
|
+
|
|
298
|
+
// userの発言が残っているのに完了が見えない時は、失敗のまま(保存できたはずの会話)。
|
|
299
|
+
writeFileSync(transcriptPath,
|
|
300
|
+
[line({ type: 'user', message: { role: 'user', content: '依頼' } }), ...tail].join('\n') + '\n', 'utf8');
|
|
301
|
+
elapsed = 0;
|
|
302
|
+
await assert.rejects(waitForClaudeStopTranscriptFlush({
|
|
303
|
+
transcriptPath, lastAssistantMessage: '別の応答', timeoutMs: 30, intervalMs: 10, ...clock,
|
|
304
|
+
}), /not visible before deadline/);
|
|
305
|
+
|
|
306
|
+
// 発言が1つも無いファイル(書き始め)は、先頭を失ったとは言えない。失敗のまま。
|
|
307
|
+
writeFileSync(transcriptPath, line({ type: 'mode', mode: 'normal' }) + '\n', 'utf8');
|
|
308
|
+
elapsed = 0;
|
|
309
|
+
await assert.rejects(waitForClaudeStopTranscriptFlush({
|
|
310
|
+
transcriptPath, lastAssistantMessage: '最後の応答', timeoutMs: 30, intervalMs: 10, ...clock,
|
|
311
|
+
}), /not visible before deadline/);
|
|
312
|
+
} finally {
|
|
313
|
+
rmSync(root, { recursive: true, force: true });
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
277
317
|
test('Claude Stop flush barrierはmarkerなし旧payloadをone-shot互換へ残す', async () => {
|
|
278
318
|
let reads = 0;
|
|
279
319
|
const result = await waitForClaudeStopTranscriptFlush({
|