throughline 0.16.1 → 0.16.3
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 +51 -1
- package/docs/05_codex_first_roadmap.md +5 -0
- package/docs/adr/0039-codex-auto-handoff-one-successor-per-source.md +58 -0
- package/docs/adr/0040-auto-handoff-no-second-successor-while-pending.md +99 -0
- package/package.json +1 -1
- package/src/claude-auto-handoff.mjs +25 -1
- package/src/claude-auto-handoff.test.mjs +28 -0
- package/src/codex-auto-handoff-store.mjs +48 -1
- package/src/codex-auto-handoff.mjs +161 -12
- package/src/codex-auto-handoff.test.mjs +296 -1
- package/src/hosts/codex-handoff-state.mjs +14 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,54 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.16.3] — 2026-10-07
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- Codexの自動継続で、引き継ぎの最中(継続の指示を送る前)に旧タスクへ入力が来ると、同じ旧タスクから2つ目の引き継ぎが立っていたのを直す([ADR 0040](docs/adr/0040-auto-handoff-no-second-successor-while-pending.md))。1つ目は`handoff_source_advanced`で失敗して失敗の画面を出し、記憶だけが入った後継が残っていた。workerが動いている引き継ぎがある間は、新しい引き継ぎを作らず、ターンを止めて進んでいる引き継ぎのIDを示す。入力を受けただけで止まったturnは、元turnの境界を進めた物として数えない。
|
|
18
|
+
- 後継を作った後、配送の前に止まった引き継ぎは、次の入力で同じ引き継ぎをやり直す(同じID、出来ている後継を使う)。今までは別の引き継ぎを作り、後継が2つになっていた。後継を利用者がもう使っている時、後継が消えている時、旧タスクが進んでいる時、後継を作る前に止まった時は、今までどおり新しい引き継ぎを作る。
|
|
19
|
+
- 2つの入力がほぼ同時に来た時は、先に記録した方だけが引き継ぎを作る。
|
|
20
|
+
- 引き継ぎの最中に旧タスクへ来た入力は、後継へ渡す記憶に入れない。
|
|
21
|
+
- Claude Codeの自動継続で、引き継ぎの記録を24時間で掃除していたため、その後に同じ会話からもう1つ後継が立っていたのを直す。後継の会話が残っている間は、記録を消さない。
|
|
22
|
+
|
|
23
|
+
### 変わらない事
|
|
24
|
+
|
|
25
|
+
- 後継が残っていない(消した、アーカイブした)旧タスクからは、今までどおり新しい後継を立てる。
|
|
26
|
+
- 止める場所は`PreCompact(auto)`のまま。旧タスクが圧縮に掛からない間は、旧タスクはそのまま動く。
|
|
27
|
+
- DBの形は変えない(v12)。
|
|
28
|
+
|
|
29
|
+
### 確認した範囲
|
|
30
|
+
|
|
31
|
+
- macOSで、公開するtarballを切り離したHOMEへ入れ、2026-10-07の実物のrolloutと`codex_handoffs`の写しへ当てた。止まった入力が続くrolloutで境界が進まない事、最中の入力で行もworkerも増えない事、引き継ぎ済みの旧タスクと引き継いでいないタスクが0.16.2と同じ動きである事。
|
|
32
|
+
- 同じ旧タスクへ6つの入力を同じ時刻に入れて、引き継ぎが1つだけ出来る事(LinuxとmacOS、本物のprocess)。
|
|
33
|
+
|
|
34
|
+
### 確認していない範囲
|
|
35
|
+
|
|
36
|
+
- 本物のCodex Desktopでの通しと、画面で最中の入力を止めた理由がどう見えるか。
|
|
37
|
+
- 同じ引き継ぎのやり直しを、本物の後継のタスクで。
|
|
38
|
+
- Claude Codeの記録を残す事を、本物のClaude Codeの会話で。
|
|
39
|
+
|
|
40
|
+
## [0.16.2] — 2026-10-07
|
|
41
|
+
|
|
42
|
+
### 修正
|
|
43
|
+
|
|
44
|
+
- Codexの自動継続で、引き継ぎ済みの旧タスクへ新しい入力が届くたびに、同じ作業を引き継いだ後継がもう1つ立っていたのを直す([ADR 0039](docs/adr/0039-codex-auto-handoff-one-successor-per-source.md))。重複を見ていたのは元のタスクと元のターンの組で、旧タスクに新しいターンが出来ると別の引き継ぎとして受けていた。継続の指示が届いた後継が残っている間は、新しい引き継ぎを作らず、ターンを止めて、止めた理由に引き継ぎID・後継の名前・`codex://threads/<後継>`を書き、Desktopで後継を開く。後継がさらに引き継いでいれば、その先を示す。
|
|
45
|
+
- workerは、継続の指示を送る直前に、同じ旧タスクの別の引き継ぎが先に指示を送っていないかを確かめる。送られていれば`handoff_source_already_continued`で止まり、後継へ指示を送らない。
|
|
46
|
+
- 止めた事を`~/.throughline/codex-auto-handoff/redirects.jsonl`に1行残す(会話の本文は書かない)。
|
|
47
|
+
|
|
48
|
+
### 変わらない事
|
|
49
|
+
|
|
50
|
+
- 配送の前に失敗した引き継ぎしか無い旧タスクと、後継が残っていない(消した、アーカイブした)旧タスクからは、今までどおり新しい後継を立てる。
|
|
51
|
+
- 旧タスクへ打った入力は後継へ渡さない。後継で打ち直す。
|
|
52
|
+
|
|
53
|
+
### 確認した範囲
|
|
54
|
+
|
|
55
|
+
- macOSのCodex Desktop(0.16.1)で2026-10-07に起きた記録(同じ旧タスクから3つの引き継ぎ)を読んだ。
|
|
56
|
+
|
|
57
|
+
### 確認していない範囲
|
|
58
|
+
|
|
59
|
+
- Codex Desktopの画面で、旧タスクに止めた理由がどう見えるか。
|
|
60
|
+
|
|
13
61
|
## [0.16.1] — 2026-10-07
|
|
14
62
|
|
|
15
63
|
### 修正
|
|
@@ -1892,7 +1940,9 @@ two attempts, instrument first instead of patching again.
|
|
|
1892
1940
|
|
|
1893
1941
|
---
|
|
1894
1942
|
|
|
1895
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.
|
|
1943
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.3...HEAD
|
|
1944
|
+
[0.16.3]: https://github.com/kitepon/Throughline/compare/v0.16.2...v0.16.3
|
|
1945
|
+
[0.16.2]: https://github.com/kitepon/Throughline/compare/v0.16.1...v0.16.2
|
|
1896
1946
|
[0.16.1]: https://github.com/kitepon/Throughline/compare/v0.16.0...v0.16.1
|
|
1897
1947
|
[0.16.0]: https://github.com/kitepon/Throughline/compare/v0.15.5...v0.16.0
|
|
1898
1948
|
[0.15.5]: https://github.com/kitepon/Throughline/compare/v0.15.4...v0.15.5
|
|
@@ -160,6 +160,11 @@ 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))。
|
|
165
|
+
継続の指示を送る前の引き継ぎがある旧タスクへ新しいturnが来た時も、要求を作らない。workerが動いていれば
|
|
166
|
+
進んでいる引き継ぎを返して止め、後継を作ったまま止まっていれば同じ引き継ぎをやり直す
|
|
167
|
+
([ADR 0040](adr/0040-auto-handoff-no-second-successor-while-pending.md))。
|
|
163
168
|
schema変更は `src/db.mjs` と製品所有migrationへ置き、
|
|
164
169
|
schemaの版とmigrationは同じ製品releaseで更新する。
|
|
165
170
|
|
|
@@ -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つ増えていた。
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# ADR 0040: 引き継ぎの最中と、止まった引き継ぎから、2つ目の後継を立てない
|
|
2
|
+
|
|
3
|
+
日付: 2026-10-07
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
ADR 0039(0.16.2)は、継続の指示が届いた後継が残っている旧タスクから、2つ目の後継を立てない様にした。
|
|
8
|
+
オーナーは、1つのタスクや会話で自動継続が2回以上発動しない事を求めた(2026-10-07)。0.16.2 のコードを動かして、
|
|
9
|
+
同じ旧タスクから2つ目の後継が立つ道が残っている事を確かめた。
|
|
10
|
+
|
|
11
|
+
Codex:
|
|
12
|
+
|
|
13
|
+
1. **引き継ぎの最中の入力。** worker が継続の指示を送る前(macOS の実績で、要求から継続の確認まで 9〜74 秒)に
|
|
14
|
+
旧タスクへ入力が来ると、`PreCompact(auto)` がもう一度発火する。ADR 0039 の判定は「指示が届いた引き継ぎ」だけを
|
|
15
|
+
見るので、別の引き継ぎを作った。1つ目の worker は、元の turn が最新でなくなった事を `handoff_source_advanced` と
|
|
16
|
+
判定して失敗し、失敗の画面を出す。1つ目が作った後継(記憶だけが入り、指示は無い)が残り、2つ目の引き継ぎが
|
|
17
|
+
別の後継を立てる。
|
|
18
|
+
2. **後継を作った後、配送の前に止まった引き継ぎ。** 次の入力で、やり直しではなく別の引き継ぎを作った。
|
|
19
|
+
前の後継が残り、後継が2つになる。
|
|
20
|
+
|
|
21
|
+
Claude Code:
|
|
22
|
+
|
|
23
|
+
3. **記録の期限。** 記録は24時間で掃除していた(ADR 0033 の10)。掃除の後、引き継ぎ済みの会話で自動圧縮が起きると、
|
|
24
|
+
同じ会話からもう1つ後継が立つ。
|
|
25
|
+
|
|
26
|
+
hook が止めた入力の turn は、Codex Desktop 0.160.1 の rollout に次の並びで残る(2026-10-07 の実物)。
|
|
27
|
+
`thread_settings_applied`、`task_started`、user の `message`、`item_completed`、約1秒後の `turn_aborted`。
|
|
28
|
+
モデルの発言も道具の呼び出しも無い。
|
|
29
|
+
|
|
30
|
+
オーナーは3つの案から「止める場所は今のまま、穴だけ塞ぐ」を選んだ(2026-10-07)。入力を受けた時点で断る案
|
|
31
|
+
(`UserPromptSubmit` の `decision: "block"`。Codex 0.160.1 と Claude Code 2.1.292 の実物で、モデルが呼ばれない事を
|
|
32
|
+
確かめた)と、DB の一意を元タスクだけにする案は採らない。
|
|
33
|
+
|
|
34
|
+
## Decision
|
|
35
|
+
|
|
36
|
+
1. Codex の `PreCompact(auto)` は、その旧タスクに、継続の指示をまだ送っていない引き継ぎがあり、その worker が
|
|
37
|
+
動いている時、新しい引き継ぎを作らない。`continue: false` でターンを止め、止めた理由に進んでいる引き継ぎの ID を書く。
|
|
38
|
+
worker が動いているかは、記録の worker の process(pid と誕生時刻)が process 一覧にあるかで見る。
|
|
39
|
+
worker を起動してから worker が名乗るまでの間(15 秒)は、記録の更新時刻で見る。
|
|
40
|
+
process 一覧を読めない時は、記録の更新から 10 分の間だけ、動いている物として扱う(後継を2つにしない側へ倒す)。
|
|
41
|
+
2. 入力を受けただけで止まった turn は、元 turn の境界を進めない。worker は、元 turn より後の turn がどれも
|
|
42
|
+
「止まっていて、モデルの発言・道具の呼び出し・完了・圧縮が無い」時、今までどおり引き継ぎを続ける。
|
|
43
|
+
後の turn に動きがあれば、今までどおり `handoff_source_advanced` で止まる。まだ止まり切っていない turn は、
|
|
44
|
+
止まるまで待つ(30 秒。過ぎれば `handoff_source_stop_unconfirmed`)。
|
|
45
|
+
3. 止めた後で旧タスクへ来た入力は、凍結する記憶へ入れない。後継へは渡さない入力(ADR 0039 の Consequences)。
|
|
46
|
+
4. 後継を作った後、配送の前に止まった引き継ぎは、次の入力で同じ引き継ぎをやり直す。条件は、後継のタスクが
|
|
47
|
+
残っていて、後継にまだ turn が無く、止まった後の旧タスクに動きが無い事。`failed` は止まった工程から再開し、
|
|
48
|
+
worker が居なくなった途中の記録はその状態から続ける。
|
|
49
|
+
条件に合わない時(後継を利用者がもう使っている、後継が消えている、結果不明、旧タスクが進んでいる)と、
|
|
50
|
+
後継を作る前に止まった引き継ぎからは、今までどおり新しい引き継ぎを作る。後継を作る前の失敗は、残る物が無い。
|
|
51
|
+
5. 2つの入力がほぼ同時に来た時は、先に記録した方だけが引き継ぎを作る。「継続の指示をまだ送っていない引き継ぎが
|
|
52
|
+
増えていないか」の確認と記録を、1つの transaction で行う。後から来た方は、1の扱いになる。
|
|
53
|
+
ADR 0039 の5(worker が指示を送る直前の確認)は残す。
|
|
54
|
+
6. 1と4で止めた事は、`~/.throughline/codex-auto-handoff/redirects.jsonl` に `kind`(`in_flight`・`retry`)を付けて残す。
|
|
55
|
+
ADR 0039 の行(後継を示した回)には `kind` を付けない。
|
|
56
|
+
7. Claude Code の記録は、後継の会話が残っている間は、24時間を過ぎても掃除しない。後継が残っているかは、
|
|
57
|
+
受領が残った引き継ぎ(`sent`)について、後継の transcript があるかで見る。transcript は、旧い会話の transcript の
|
|
58
|
+
場所から Claude の projects をたどり、その下のフォルダを順に見る(フォルダ名の付け方は OS で違う)。
|
|
59
|
+
後継が消えた記録、受領を確かめられなかった記録、失敗した記録は、今までどおり期限で掃除する。
|
|
60
|
+
8. DB の形は変えない(v12)。ADR 0039 の3(後継が残っていない旧タスクからは、新しい引き継ぎを作る)は変えない。
|
|
61
|
+
|
|
62
|
+
ADR 0039 の2のうち「配送の前に失敗した引き継ぎからは、今までどおり新しい引き継ぎを作る」は、4で置き換える。
|
|
63
|
+
ADR 0033 の10のうち「24時間を過ぎたものは次の引き継ぎの時に掃除する」は、7で置き換える。
|
|
64
|
+
|
|
65
|
+
## Consequences
|
|
66
|
+
|
|
67
|
+
- 引き継ぎの最中に旧タスクへ打った入力は、止まる。後継へは渡らない。後継が開いてから、後継で打ち直す。
|
|
68
|
+
- 引き継ぎの最中に来た入力では、失敗の画面は出ない。記憶だけが入った後継も残らない。
|
|
69
|
+
- 後継を消した(アーカイブした)旧タスクからは、今までどおり新しい後継が立つ。1つの旧タスクから後継が2つ出来るのは、
|
|
70
|
+
この時と、4の条件に合わなかった時だけになる。
|
|
71
|
+
- 旧タスクが圧縮に掛からない間(手動の `/compact` の後など)は、旧タスクはそのまま動く。止めるのは `PreCompact(auto)` の時だけ。
|
|
72
|
+
- Claude Code の引き継ぎ済みの会話は、後継が残っている間、道具と圧縮を止め続ける。道具を呼ばない会話は続けられるが、
|
|
73
|
+
圧縮されないので、続けると文脈の上限に当たる(0.16.2 までも、記録が残っている間は同じ)。
|
|
74
|
+
- worker が動いているかを見るために、`PreCompact(auto)` の hook が process 一覧を読む事がある。読むのは、その旧タスクに
|
|
75
|
+
途中の引き継ぎがあり、worker が名乗っている時だけ。
|
|
76
|
+
|
|
77
|
+
## 確認した範囲
|
|
78
|
+
|
|
79
|
+
- 0.16.2 のコードで、1・2・3の道を再現した(切り離した HOME と空の DB)。
|
|
80
|
+
- hook が止めた turn の並びは、macOS の Codex Desktop(同梱の Codex 0.160.1)で 2026-10-07 に起きた2回の記録を読んだ。
|
|
81
|
+
- macOS(公開する tarball、切り離した HOME、本物の `codex_handoffs` の写し)。
|
|
82
|
+
- 2026-10-07 の実物の rollout(元 turn の後に、hook が止めた入力が2つ)で、境界の判定は「止まったまま」になった。
|
|
83
|
+
0.16.2 は同じ rollout を `handoff_source_advanced` と判定する。
|
|
84
|
+
- 1つ目の引き継ぎを指示を送る前の状態へ戻した写しへ、本物の入口(`codex-hook pre-compact`)で新しい turn を渡した。
|
|
85
|
+
`continue: false`、新しい行 0、worker の起動 0、Desktop を開く呼び出し 0。worker が名乗る前(更新時刻)と、
|
|
86
|
+
worker が動いている時(macOS の process 一覧)の両方で同じ。worker が居なくなり後継も残っていない時は、新しい引き継ぎを作った。
|
|
87
|
+
- 引き継ぎ済みの旧タスクへの入力(ADR 0039)と、引き継いでいないタスクは、0.16.2 と同じ動き。
|
|
88
|
+
- 同じ旧タスクへ6つの入力を同じ時刻に入れた(本物の process、Linux は5回、macOS は3回)。どの回も、引き継ぎの行は1つ、
|
|
89
|
+
worker の起動は1回。0.16.2 は6つずつ出来る。
|
|
90
|
+
- hook の本体と worker の本体を通した(Linux、Codex Desktop に触る所だけ差し替え)。最中に来て止まった入力では、
|
|
91
|
+
1つ目の引き継ぎが `continued` まで進み、失敗の画面は出ず、凍結した記憶に後から来た入力は入らなかった。
|
|
92
|
+
旧タスクでモデルが答えた時は、後継を作る前に `handoff_source_advanced` で止まった。
|
|
93
|
+
|
|
94
|
+
## 確認していない範囲
|
|
95
|
+
|
|
96
|
+
- 本物の Codex Desktop での通し(引き継ぎの最中に、画面から旧タスクへ打つ)。画面で、止めた理由がどう見えるか。
|
|
97
|
+
- 4(同じ引き継ぎのやり直し)を、本物の後継のタスクで。後継にまだ turn が無い状態は、試験の差し替えでだけ見た。
|
|
98
|
+
- Windows と Linux の Codex Desktop。Codex の自動継続は macOS でだけ使われている。
|
|
99
|
+
- 7(Claude Code の記録を残す事)を、本物の Claude Code の会話で。試験で、transcript のフォルダが同じ時と別の時を見た。
|
package/package.json
CHANGED
|
@@ -136,6 +136,23 @@ export function publicClaudeAutoHandoff(record) {
|
|
|
136
136
|
requested_at, updated_at };
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* 後継の会話が残っているか。受領が残った引き継ぎ(後継の session id が分かっている物)だけを見る。
|
|
141
|
+
* 後継の transcript は、Claude の projects の下の、後継を起動した project のフォルダに出来る。フォルダ名の付け方は
|
|
142
|
+
* OS で違うので、旧い会話の transcript の場所から projects をたどり、その下のフォルダを順に見る。
|
|
143
|
+
*/
|
|
144
|
+
function successorRemains(record) {
|
|
145
|
+
const successorId = record?.successor?.session_id;
|
|
146
|
+
if (record?.state !== 'sent' || !validSessionId(successorId) || typeof record.transcript_path !== 'string') return false;
|
|
147
|
+
const file = `${successorId}.jsonl`;
|
|
148
|
+
const own = dirname(record.transcript_path);
|
|
149
|
+
if (existsSync(join(own, file))) return true;
|
|
150
|
+
let folders;
|
|
151
|
+
try { folders = readdirSync(dirname(own), { withFileTypes: true }); }
|
|
152
|
+
catch { return false; }
|
|
153
|
+
return folders.some(entry => entry.isDirectory() && existsSync(join(dirname(own), entry.name, file)));
|
|
154
|
+
}
|
|
155
|
+
|
|
139
156
|
function removeStale(dir, now) {
|
|
140
157
|
for (const folder of [dir, targetsDir(dir)]) {
|
|
141
158
|
let names;
|
|
@@ -153,7 +170,14 @@ function removeStale(dir, now) {
|
|
|
153
170
|
if (error.code === 'ENOENT') continue; // 別の hook が同時に消した
|
|
154
171
|
throw error;
|
|
155
172
|
}
|
|
156
|
-
if (now - modifiedAt
|
|
173
|
+
if (now - modifiedAt <= STALE_RECORD_MS) continue;
|
|
174
|
+
// 後継が残っている会話の記録は消さない。消すと、旧い会話からもう1つ後継が立つ。
|
|
175
|
+
if (folder === dir && name.endsWith('.json')) {
|
|
176
|
+
let record = null;
|
|
177
|
+
try { record = readJson(path); } catch { /* 読めない記録は、期限どおり消す */ }
|
|
178
|
+
if (successorRemains(record)) continue;
|
|
179
|
+
}
|
|
180
|
+
rmSync(path, { force: true });
|
|
157
181
|
}
|
|
158
182
|
}
|
|
159
183
|
}
|
|
@@ -174,6 +174,34 @@ test('PreCompact: session id が無い・pathを含む時は失敗し、期限
|
|
|
174
174
|
assert.deepEqual(readdirSync(join(dir, 'targets')), []);
|
|
175
175
|
}));
|
|
176
176
|
|
|
177
|
+
test('PreCompact: 後継の会話が残っている引き継ぎの記録は、期限を過ぎても消さない', () => withDir(dir => {
|
|
178
|
+
const now = Date.parse('2026-10-04T12:00:00Z');
|
|
179
|
+
const old = new Date(now - 25 * 3_600_000);
|
|
180
|
+
const projects = join(dir, 'claude', 'projects');
|
|
181
|
+
mkdirSync(join(projects, '-work-app'), { recursive: true }); mkdirSync(join(projects, '-moved-app'), { recursive: true });
|
|
182
|
+
const record = (id, fields) => {
|
|
183
|
+
writeFileSync(join(dir, `${id}.json`), JSON.stringify({ schema: 'throughline.claude-auto-handoff.v2', handoff_id: `h-${id}`,
|
|
184
|
+
source_session_id: id, project_path: '/work/app', transcript_path: join(projects, '-work-app', `${id}.jsonl`),
|
|
185
|
+
state: 'sent', successor: { short_id: 'bbbbbbbb', session_id: `bbbbbbbb-${id.slice(9)}` }, ...fields }));
|
|
186
|
+
utimesSync(join(dir, `${id}.json`), old, old);
|
|
187
|
+
return fields?.successor?.session_id ?? `bbbbbbbb-${id.slice(9)}`;
|
|
188
|
+
};
|
|
189
|
+
const kept = 'aaaaaaaa-0000-4000-8000-000000000011', moved = 'aaaaaaaa-0000-4000-8000-000000000012';
|
|
190
|
+
const gone = 'aaaaaaaa-0000-4000-8000-000000000013', unconfirmed = 'aaaaaaaa-0000-4000-8000-000000000014';
|
|
191
|
+
const failed = 'aaaaaaaa-0000-4000-8000-000000000015';
|
|
192
|
+
// 後継の transcript が、旧い会話と同じフォルダにある。別の project のフォルダにある(会話を移していた時)。
|
|
193
|
+
writeFileSync(join(projects, '-work-app', `${record(kept)}.jsonl`), '');
|
|
194
|
+
writeFileSync(join(projects, '-moved-app', `${record(moved)}.jsonl`), '');
|
|
195
|
+
// 後継が消されている。受領を確かめられなかった(後継の session id が無い)。立ち上げに失敗した。
|
|
196
|
+
record(gone);
|
|
197
|
+
record(unconfirmed, { state: 'unknown', successor: { short_id: 'bbbbbbbb', session_id: null } });
|
|
198
|
+
writeFileSync(join(projects, '-work-app', `${record(failed, { state: 'failed' })}.jsonl`), '');
|
|
199
|
+
request(dir, { now });
|
|
200
|
+
assert.deepEqual(readdirSync(dir).filter(name => name.endsWith('.json')).sort(), [`${SESSION}.json`, `${kept}.json`, `${moved}.json`].sort());
|
|
201
|
+
// 残した記録の会話は、圧縮と道具を止め続ける。同じ会話から2つ目の後継は立たない。
|
|
202
|
+
assert.equal(request(dir, { now, payload: { session_id: kept } }).result.status, 'already_requested');
|
|
203
|
+
}));
|
|
204
|
+
|
|
177
205
|
test('PreToolUse: 記録のある会話の道具を実行させずに止め、止めた時点の依頼と設定を写して worker を1回だけ起動する', () => withDir(async dir => {
|
|
178
206
|
const transcriptPath = join(dir, 'transcript.jsonl');
|
|
179
207
|
writeFileSync(transcriptPath, jsonl([
|
|
@@ -22,6 +22,31 @@ 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
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 継続の指示をまだ送っていない引き継ぎ(新しい順)。進んでいる最中の物と、配送の前に止まった物。
|
|
42
|
+
* 同じ旧タスクへ次の入力が来た時に、別の引き継ぎを作る前にここを見る。
|
|
43
|
+
*/
|
|
44
|
+
export function listUndeliveredAutoHandoffsForSource(db, threadId) {
|
|
45
|
+
return db.prepare(`SELECT * FROM codex_handoffs WHERE source_thread_id = ? AND queued_submission_id IS NULL
|
|
46
|
+
AND state NOT IN ('submitted', 'continued') AND (mutation_stage IS NULL OR mutation_stage <> 'submit')
|
|
47
|
+
ORDER BY created_at DESC, rowid DESC`).all(threadId).map(decode);
|
|
48
|
+
}
|
|
49
|
+
|
|
25
50
|
export function listAutoHandoffs(db, { projectPath = null, limit = 20 } = {}) {
|
|
26
51
|
const rows = projectPath == null
|
|
27
52
|
? db.prepare('SELECT * FROM codex_handoffs ORDER BY created_at DESC LIMIT ?').all(limit)
|
|
@@ -91,13 +116,35 @@ export function releaseAutoHandoff(db, id, identity) {
|
|
|
91
116
|
.run(id, JSON.stringify(identity));
|
|
92
117
|
}
|
|
93
118
|
|
|
119
|
+
/**
|
|
120
|
+
* 元turnを止めた後で旧タスクへ来た入力のturn番号。hookが止めたturnで、入力だけが残っている。
|
|
121
|
+
* 末尾から、止めた時刻より後に始まり、userの発言だけで、道具の記録が無いturnを、後続turnの数まで数える。
|
|
122
|
+
*/
|
|
123
|
+
function turnsAfterSourceStop(bodies, details, source) {
|
|
124
|
+
const after = new Set();
|
|
125
|
+
let remaining = source.laterTurns?.length ?? 0;
|
|
126
|
+
if (!remaining || !Number.isFinite(source.stoppedAt)) return after;
|
|
127
|
+
for (const number of [...new Set(bodies.map(row => row.turn_number))].sort((a, b) => b - a)) {
|
|
128
|
+
const rows = bodies.filter(row => row.turn_number === number);
|
|
129
|
+
if (rows.some(row => row.role !== 'user' || !(row.created_at > source.stoppedAt)) ||
|
|
130
|
+
details.some(row => row.turn_number === number)) break;
|
|
131
|
+
after.add(number);
|
|
132
|
+
if (--remaining === 0) break;
|
|
133
|
+
}
|
|
134
|
+
return after;
|
|
135
|
+
}
|
|
136
|
+
|
|
94
137
|
export function freezeCodexMemory(db, operation, settings, source) {
|
|
95
138
|
const session = db.prepare('SELECT session_id, project_path FROM sessions WHERE session_id = ?')
|
|
96
139
|
.get(operation.source_session_id);
|
|
97
140
|
if (!session || session.project_path !== operation.project_path) throw new Error('handoff_memory_source_mismatch');
|
|
98
141
|
const load = table => db.prepare(`SELECT * FROM ${table} WHERE session_id = ? ORDER BY created_at, id`)
|
|
99
142
|
.all(operation.source_session_id);
|
|
100
|
-
const
|
|
143
|
+
const all = { bodies: load('bodies'), skeletons: load('skeletons'), details: load('details') };
|
|
144
|
+
// 引き継ぎの最中に旧タスクへ来た入力は、後継へ渡さない。記憶は元turnまでで凍結する。
|
|
145
|
+
const after = turnsAfterSourceStop(all.bodies, all.details, source);
|
|
146
|
+
const keep = row => !after.has(row.turn_number);
|
|
147
|
+
const bodies = all.bodies.filter(keep), skeletons = all.skeletons.filter(keep), details = all.details.filter(keep);
|
|
101
148
|
return {
|
|
102
149
|
version: 1, session: { ...session }, settings,
|
|
103
150
|
sourceTurnId: operation.source_turn_id,
|
|
@@ -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,9 @@ 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, listUndeliveredAutoHandoffsForSource,
|
|
18
|
+
HANDOFF_TERMINAL_STATES } from './codex-auto-handoff-store.mjs';
|
|
17
19
|
import { ensureAutoHandoffSummaries, renderAutoHandoffMemory } from './codex-auto-handoff-memory.mjs';
|
|
18
20
|
import { CODEX_NATIVE_ID_PATTERN, CodexHandoffError, readCodexHandoffState,
|
|
19
21
|
settingsMatch, threadStartSettings } from './hosts/codex-handoff-state.mjs';
|
|
@@ -37,7 +39,12 @@ const sourceState = (operation, options = {}) => readCodexHandoffState(operation
|
|
|
37
39
|
{ threadId: operation.source_thread_id, turnId: operation.source_turn_id, ...options });
|
|
38
40
|
|
|
39
41
|
export function sourceBoundary(operation, state) {
|
|
40
|
-
if (state.latestTurnId !== operation.source_turn_id)
|
|
42
|
+
if (state.latestTurnId !== operation.source_turn_id) {
|
|
43
|
+
// 引き継ぎの最中に旧タスクへ来た入力は、hookが止める。入力を受けただけで止まったturnは、元turnの境界を進めない。
|
|
44
|
+
const later = state.laterTurns ?? [];
|
|
45
|
+
if (!later.length || later.some(turn => turn.activity)) throw error('handoff_source_advanced');
|
|
46
|
+
if (later.some(turn => !turn.closed)) return false;
|
|
47
|
+
}
|
|
41
48
|
if (state.pendingCallCount) throw error('handoff_source_operation_pending');
|
|
42
49
|
if (state.pendingNativeSessionCount) throw error('handoff_native_session_pending');
|
|
43
50
|
return Number.isFinite(state.stoppedAt) && state.stoppedAt >= operation.created_at;
|
|
@@ -54,11 +61,11 @@ export async function waitForHandoff(check, { timeoutMs = 120_000, intervalMs =
|
|
|
54
61
|
throw error(timeoutCode);
|
|
55
62
|
}
|
|
56
63
|
|
|
57
|
-
export async function launchAutoHandoffWorker(id) {
|
|
64
|
+
export async function launchAutoHandoffWorker(id, { resume = false } = {}) {
|
|
58
65
|
mkdirSync(stateRoot(), { recursive: true, mode: 0o700 });
|
|
59
66
|
const log = openSync(join(stateRoot(), `${id}.log`), 'a', 0o600);
|
|
60
67
|
try {
|
|
61
|
-
const child = spawnPortable(process.execPath, [CLI_PATH, 'auto-handoff', 'worker', '--operation', id, '--json'],
|
|
68
|
+
const child = spawnPortable(process.execPath, [CLI_PATH, 'auto-handoff', resume ? 'resume' : 'worker', '--operation', id, '--json'],
|
|
62
69
|
{ detached: true, stdio: ['ignore', log, log], windowsHide: true });
|
|
63
70
|
await new Promise((resolveSpawn, reject) => { child.once('spawn', resolveSpawn); child.once('error', reject); });
|
|
64
71
|
child.unref();
|
|
@@ -66,8 +73,91 @@ export async function launchAutoHandoffWorker(id) {
|
|
|
66
73
|
} finally { closeSync(log); }
|
|
67
74
|
}
|
|
68
75
|
|
|
76
|
+
function liveSuccessors(db, threadId, options, seen) {
|
|
77
|
+
const found = [];
|
|
78
|
+
for (const operation of listDeliveredAutoHandoffsForSource(db, threadId)) {
|
|
79
|
+
if (operation.handoff_id === options.exceptHandoffId || seen.has(operation.handoff_id)) continue;
|
|
80
|
+
seen.add(operation.handoff_id);
|
|
81
|
+
const deeper = liveSuccessors(db, operation.target_thread_id, options, seen);
|
|
82
|
+
if (deeper.length) { found.push(...deeper); continue; }
|
|
83
|
+
const thread = options.findThread({ threadId: operation.target_thread_id, codexHome: operation.codex_home,
|
|
84
|
+
projectPath: operation.project_path, requireProjectMatch: true });
|
|
85
|
+
if (thread) found.push({ operation, mtimeMs: thread.mtimeMs ?? 0 });
|
|
86
|
+
}
|
|
87
|
+
return found;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 旧タスクの作業を今持っている後継の引き継ぎ。継続の指示が届いた引き継ぎをたどり、後継がさらに引き継いで
|
|
92
|
+
* いればその先へ進む。後継のrolloutが無い枝(消した、アーカイブした)は数えない。無ければ null。
|
|
93
|
+
* 0.16.1 までに同じ旧タスクから複数の後継が立っていた時は、最後に動いた後継を返す。
|
|
94
|
+
*/
|
|
95
|
+
export function findLiveAutoHandoffSuccessor(db, threadId,
|
|
96
|
+
{ exceptHandoffId = null, findThread = findCodexThreadCandidate } = {}) {
|
|
97
|
+
return liveSuccessors(db, threadId, { exceptHandoffId, findThread }, new Set())
|
|
98
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)[0]?.operation ?? null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function openSuccessor(operation) {
|
|
102
|
+
const result = openUrlWithOsHandler(`codex://threads/${encodeURIComponent(operation.target_thread_id)}`);
|
|
103
|
+
return !result.error && result.status === 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 引き継ぎ済みの旧タスクを止めた記録。引き継ぎの行は増えないので、起きた事をここに残す。会話の本文は書かない。 */
|
|
107
|
+
function recordAutoHandoffRedirect(entry) {
|
|
108
|
+
mkdirSync(stateRoot(), { recursive: true, mode: 0o700 });
|
|
109
|
+
appendFileSync(join(stateRoot(), 'redirects.jsonl'), JSON.stringify(entry) + '\n', { mode: 0o600 });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// workerを起動してから、workerが自分のprocessを記録へ名乗るまでの間。この間の引き継ぎは、進んでいる物として扱う。
|
|
113
|
+
const WORKER_START_GRACE_MS = 15_000;
|
|
114
|
+
// workerが1回の引き継ぎに掛ける時間の上限(各段の待ち時間の合計より長い)。process一覧を読めない時だけ使う。
|
|
115
|
+
const WORKER_RUN_LIMIT_MS = 10 * 60_000;
|
|
116
|
+
|
|
117
|
+
/** その引き継ぎのworkerが動いているか。名乗る前は、記録の更新時刻で見る。 */
|
|
118
|
+
function workerActive(operation, processes, now) {
|
|
119
|
+
const identity = operation.worker_identity;
|
|
120
|
+
if (!identity) return now - operation.updated_at < WORKER_START_GRACE_MS;
|
|
121
|
+
let table;
|
|
122
|
+
// process一覧を読めない時は、後継を2つにしない側へ倒す。記録が新しい間は、動いている物として扱う。
|
|
123
|
+
try { table = processes(); } catch { return now - operation.updated_at < WORKER_RUN_LIMIT_MS; }
|
|
124
|
+
return table.some(p => p.pid === identity.pid && p.started_identity === identity.started_identity);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 配送の前に止まった引き継ぎを、同じ引き継ぎとしてやり直せるか。後継のタスクが出来ていて、まだ誰も使っておらず、
|
|
129
|
+
* 止まった後の旧タスクに動きが無い時だけ。後継が出来る前に止まった物は残る物が無いので、新しい引き継ぎで足りる。
|
|
130
|
+
*/
|
|
131
|
+
function resumableAfterStop(operation, rolloutPath, readTarget) {
|
|
132
|
+
if (operation.mutation_stage || !operation.target_thread_id) return false;
|
|
133
|
+
try {
|
|
134
|
+
const target = readTarget(operation);
|
|
135
|
+
if (!target || target.latestTurnId) return false;
|
|
136
|
+
const later = readCodexHandoffState(rolloutPath,
|
|
137
|
+
{ threadId: operation.source_thread_id, turnId: operation.source_turn_id }).laterTurns;
|
|
138
|
+
return later.length > 0 && !later.some(turn => turn.activity);
|
|
139
|
+
} catch { return false; }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 同じ旧タスクの、継続の指示をまだ送っていない引き継ぎの扱い。workerが動いていれば「最中」、後継が出来たまま
|
|
144
|
+
* 止まっていれば「やり直し」。どちらでもなければ null(新しい引き継ぎを作る)。
|
|
145
|
+
*/
|
|
146
|
+
function settleUndeliveredAutoHandoff(db, threadId, { rolloutPath, processes, readTarget, now }) {
|
|
147
|
+
const undelivered = listUndeliveredAutoHandoffsForSource(db, threadId);
|
|
148
|
+
let table = null;
|
|
149
|
+
const running = () => (table ??= processes());
|
|
150
|
+
const inFlight = undelivered.find(operation => !HANDOFF_TERMINAL_STATES.has(operation.state) && workerActive(operation, running, now));
|
|
151
|
+
if (inFlight) return { kind: 'in_flight', operation: inFlight, seen: undelivered };
|
|
152
|
+
const stopped = undelivered.find(operation => operation.state !== 'unknown' && resumableAfterStop(operation, rolloutPath, readTarget));
|
|
153
|
+
if (stopped) return { kind: 'retry', operation: stopped, seen: undelivered };
|
|
154
|
+
return { kind: null, seen: undelivered };
|
|
155
|
+
}
|
|
156
|
+
|
|
69
157
|
export async function requestCodexAutoHandoff({ payload, db = null,
|
|
70
|
-
config = readAutoHandoffConfig(), launchWorker = launchAutoHandoffWorker
|
|
158
|
+
config = readAutoHandoffConfig(), launchWorker = launchAutoHandoffWorker,
|
|
159
|
+
findThread = findCodexThreadCandidate, openThread = openSuccessor, recordRedirect = recordAutoHandoffRedirect,
|
|
160
|
+
processes = readRuntimeProcesses, readTarget = targetState, now = Date.now() } = {}) {
|
|
71
161
|
if (payload.trigger !== 'auto' || !autoHandoffEnabledFor(config, payload.cwd)) return { status: 'skipped' };
|
|
72
162
|
const threadId = payload.session_id, turnId = payload.turn_id;
|
|
73
163
|
if (!CODEX_NATIVE_ID_PATTERN.test(threadId ?? '') || !CODEX_NATIVE_ID_PATTERN.test(turnId ?? '') ||
|
|
@@ -82,8 +172,55 @@ export async function requestCodexAutoHandoff({ payload, db = null,
|
|
|
82
172
|
if (!sessionPart) throw error('handoff_codex_home_unavailable');
|
|
83
173
|
const codexHome = rolloutPath.slice(0, sessionPart.index);
|
|
84
174
|
const actualDb = db ?? getDb();
|
|
85
|
-
|
|
86
|
-
|
|
175
|
+
// 引き継ぎ済みの旧タスクへ、新しい入力が来た。作業は後継が持っているので、後継を増やさずに止めて後継を示す。
|
|
176
|
+
const sameTurn = findAutoHandoffForTurn(actualDb, threadId, turnId);
|
|
177
|
+
const successor = sameTurn ? null : findLiveAutoHandoffSuccessor(actualDb, threadId, { findThread });
|
|
178
|
+
if (successor) {
|
|
179
|
+
const opened = openThread(successor);
|
|
180
|
+
recordRedirect({ at: new Date().toISOString(), source_thread_id: threadId, source_turn_id: turnId,
|
|
181
|
+
handoff_id: successor.handoff_id, target_thread_id: successor.target_thread_id, opened });
|
|
182
|
+
return { status: 'ok', operationId: successor.handoff_id, inserted: false, redirected: true,
|
|
183
|
+
targetThreadId: successor.target_thread_id, opened, continue: false,
|
|
184
|
+
stopReason: `このタスクはThroughlineが新しいタスクへ引き継ぎ済みです(引き継ぎID: ${successor.handoff_id})。` +
|
|
185
|
+
`続きは後継のタスク「${autoHandoffTargetName(actualDb, successor)}」で行ってください: ` +
|
|
186
|
+
`codex://threads/${successor.target_thread_id}` };
|
|
187
|
+
}
|
|
188
|
+
// 引き継ぎの最中(継続の指示を送る前)と、後継が出来たまま止まった旧タスクへ、新しい入力が来た。
|
|
189
|
+
// 別の引き継ぎを作ると後継が2つになるので、進んでいる引き継ぎを返すか、同じ引き継ぎをやり直す。
|
|
190
|
+
let settled = sameTurn ? { kind: null, seen: [] }
|
|
191
|
+
: settleUndeliveredAutoHandoff(actualDb, threadId, { rolloutPath, processes, readTarget, now });
|
|
192
|
+
let created = null;
|
|
193
|
+
if (!settled.kind) {
|
|
194
|
+
// 2つの入力がほぼ同時に来た時は、先に記録した方だけが引き継ぎを作る。確認と記録を1つのtransactionで行う。
|
|
195
|
+
actualDb.exec('BEGIN IMMEDIATE');
|
|
196
|
+
try {
|
|
197
|
+
const seen = new Set(settled.seen.map(operation => operation.handoff_id));
|
|
198
|
+
const raced = sameTurn ? null : listUndeliveredAutoHandoffsForSource(actualDb, threadId)
|
|
199
|
+
.find(operation => !seen.has(operation.handoff_id) && !HANDOFF_TERMINAL_STATES.has(operation.state));
|
|
200
|
+
if (raced) settled = { kind: 'in_flight', operation: raced };
|
|
201
|
+
else created = requestAutoHandoff(actualDb, { threadId, turnId, projectPath: state.meta.cwd,
|
|
202
|
+
rolloutPath, codexHome, openHost: config.openHost });
|
|
203
|
+
actualDb.exec('COMMIT');
|
|
204
|
+
} catch (cause) {
|
|
205
|
+
actualDb.exec('ROLLBACK');
|
|
206
|
+
throw cause;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (settled.kind) {
|
|
210
|
+
const pending = settled.operation;
|
|
211
|
+
if (settled.kind === 'retry') {
|
|
212
|
+
try { await launchWorker(pending.handoff_id, { resume: pending.state === 'failed' }); }
|
|
213
|
+
catch { throw error('handoff_worker_start_failed'); }
|
|
214
|
+
}
|
|
215
|
+
recordRedirect({ at: new Date().toISOString(), kind: settled.kind, source_thread_id: threadId, source_turn_id: turnId,
|
|
216
|
+
handoff_id: pending.handoff_id, target_thread_id: pending.target_thread_id ?? null, opened: false });
|
|
217
|
+
return { status: 'ok', operationId: pending.handoff_id, inserted: false, continue: false,
|
|
218
|
+
...(settled.kind === 'retry' ? { resumed: true } : { inFlight: true }),
|
|
219
|
+
stopReason: settled.kind === 'retry'
|
|
220
|
+
? `Throughlineが、途中で止まった引き継ぎをやり直します(引き継ぎID: ${pending.handoff_id})。後継のタスクが開いたら、続きはそちらで行ってください。`
|
|
221
|
+
: `Throughlineが、このタスクを新しいタスクへ引き継いでいる最中です(引き継ぎID: ${pending.handoff_id})。後継のタスクが開いたら、続きはそちらで行ってください。` };
|
|
222
|
+
}
|
|
223
|
+
const { operation, inserted } = created;
|
|
87
224
|
if (inserted) {
|
|
88
225
|
try { await launchWorker(operation.handoff_id); }
|
|
89
226
|
catch {
|
|
@@ -218,7 +355,7 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
|
|
|
218
355
|
nativeState: sourceNativeState, capture: captureCodexRolloutToDb, summaries: ensureAutoHandoffSummaries,
|
|
219
356
|
createTarget, readTarget: targetState, openTarget, verify: verifyCodexParent,
|
|
220
357
|
submit: submitCodexParentAnswer, notify: showAutoHandoffFailure, wait: waitForHandoff,
|
|
221
|
-
executable: () => platformDesktopFinder()(), ...dependencies };
|
|
358
|
+
executable: () => platformDesktopFinder()(), liveSuccessor: findLiveAutoHandoffSuccessor, ...dependencies };
|
|
222
359
|
let operation = getAutoHandoff(db, id);
|
|
223
360
|
if (!operation) throw error('handoff_not_found');
|
|
224
361
|
if (operation.state === 'continued') return operation;
|
|
@@ -259,8 +396,11 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
|
|
|
259
396
|
const native = await deps.nativeState(operation, runtime);
|
|
260
397
|
update({ state: 'source_stopped', settings_json: state.settings, runtime_json: { ...native, databasePath: DB_PATH } });
|
|
261
398
|
}
|
|
399
|
+
// 最中に来た入力のturnが止まり切るまでの間は、境界がまだ見えない。短く待ってから判定する。
|
|
400
|
+
const confirmSourceBoundary = () => deps.wait(() => sourceBoundary(operation, deps.readSource(operation)) || null,
|
|
401
|
+
{ timeoutMs: 30_000, timeoutCode: 'handoff_source_stop_unconfirmed' });
|
|
262
402
|
if (operation.state === 'source_stopped') {
|
|
263
|
-
|
|
403
|
+
await confirmSourceBoundary();
|
|
264
404
|
if (!operation.snapshot) {
|
|
265
405
|
const captured = deps.capture(db, { threadId: operation.source_thread_id, codexHome: operation.codex_home,
|
|
266
406
|
projectPath: operation.project_path });
|
|
@@ -293,13 +433,22 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
|
|
|
293
433
|
update({ state: 'target_ready' });
|
|
294
434
|
}
|
|
295
435
|
if (operation.state === 'target_ready') {
|
|
296
|
-
|
|
436
|
+
await confirmSourceBoundary();
|
|
297
437
|
await deps.nativeState(operation, runtime);
|
|
298
438
|
const target = deps.readTarget(operation);
|
|
299
439
|
if (target?.latestTurnId || !target?.settings || !settingsMatch(operation.runtime.preparedSettings, target.settings)) throw error('handoff_target_advanced');
|
|
300
440
|
const parent = { thread_id: operation.target_thread_id, codex_home: operation.codex_home };
|
|
301
441
|
await deps.verify(autoHandoffDeliveryProfile, parent, runtime);
|
|
302
|
-
|
|
442
|
+
// 同じ旧タスクの別の引き継ぎが先に指示を送っていたら、後継を増やさない。確認と記録を1つのtransactionで行う。
|
|
443
|
+
db.exec('BEGIN IMMEDIATE');
|
|
444
|
+
try {
|
|
445
|
+
if (deps.liveSuccessor(db, operation.source_thread_id, { exceptHandoffId: id })) throw error('handoff_source_already_continued');
|
|
446
|
+
update({ mutation_stage: 'submit' });
|
|
447
|
+
db.exec('COMMIT');
|
|
448
|
+
} catch (cause) {
|
|
449
|
+
db.exec('ROLLBACK');
|
|
450
|
+
throw cause;
|
|
451
|
+
}
|
|
303
452
|
const receipt = await deps.submit(autoHandoffDeliveryProfile, parent, operation.delivery_id, continuationInput(operation), runtime);
|
|
304
453
|
update({ state: 'submitted', queued_submission_id: receipt.queued_submission_id, mutation_stage: null });
|
|
305
454
|
}
|
|
@@ -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'],
|
|
@@ -319,3 +429,188 @@ test('後継タスクの名前は project 名・前任の題・自動引き継
|
|
|
319
429
|
const rejected = async () => { throw Object.assign(new Error('unknown method'), { delivery_code: 'CODEX_RECEIVER_REJECTED' }); };
|
|
320
430
|
assert.equal(await nameAutoHandoffTarget(rejected, 'T', 'x'), false, '名前を付けられなくても、引き継ぎは止めない');
|
|
321
431
|
}));
|
|
432
|
+
|
|
433
|
+
// 元turn(止めた)の後に、旧タスクへ来た入力のturnが続くrollout。laterは後続turnの行。
|
|
434
|
+
function sourceRollout(home, thread, turn, later = [], stoppedAt = '2026-10-07T00:49:01.000Z') {
|
|
435
|
+
const dir = join(home, 'codex', 'sessions'); mkdirSync(dir, { recursive: true });
|
|
436
|
+
const file = join(dir, `rollout-${thread}.jsonl`);
|
|
437
|
+
const rows = [
|
|
438
|
+
{ timestamp: '2026-10-07T00:47:00.000Z', type: 'session_meta', payload: { id: thread, originator: 'Codex Desktop', source: 'vscode', cwd: home } },
|
|
439
|
+
{ timestamp: '2026-10-07T00:47:08.000Z', type: 'event_msg', payload: { type: 'task_started', turn_id: turn } },
|
|
440
|
+
{ timestamp: '2026-10-07T00:47:09.000Z', type: 'response_item', payload: { type: 'message', role: 'user', content: [{ text: '進めて' }] } },
|
|
441
|
+
{ timestamp: '2026-10-07T00:47:15.000Z', type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ text: '進めるね' }] } },
|
|
442
|
+
{ timestamp: stoppedAt, type: 'event_msg', payload: { type: 'turn_aborted', turn_id: turn, reason: 'interrupted' } },
|
|
443
|
+
...later,
|
|
444
|
+
];
|
|
445
|
+
writeFileSync(file, rows.map(row => JSON.stringify(row)).join('\n') + '\n');
|
|
446
|
+
return file;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// hookが止めたturnの、Codex Desktop 0.160.1 の実物の並び(入力を受けて、1秒ほどで止まる)。
|
|
450
|
+
function stoppedInput(turn, at, { closed = true } = {}) {
|
|
451
|
+
return [
|
|
452
|
+
{ timestamp: `${at}.071Z`, type: 'event_msg', payload: { type: 'thread_settings_applied', thread_settings: {
|
|
453
|
+
model: 'model', cwd: '/project', model_provider_id: 'openai', approval_policy: 'never', permission_profile: { type: 'disabled' } } } },
|
|
454
|
+
{ timestamp: `${at}.078Z`, type: 'event_msg', payload: { type: 'task_started', turn_id: turn } },
|
|
455
|
+
{ timestamp: `${at}.206Z`, type: 'response_item', payload: { type: 'message', role: 'user', content: [{ text: '動いてる?' }] } },
|
|
456
|
+
{ timestamp: `${at}.207Z`, type: 'event_msg', payload: { type: 'item_completed', turn_id: turn, item: { type: 'UserMessage' } } },
|
|
457
|
+
...(closed ? [{ timestamp: `${at}.213Z`, type: 'event_msg', payload: { type: 'turn_aborted', turn_id: turn, reason: 'interrupted' } }] : []),
|
|
458
|
+
];
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
test('入力を受けただけで止まった後続turnは元turnの境界を進めず、動きのある後続turnは進んだと判定する', async () => withDb((db, home) => {
|
|
462
|
+
const thread = '11111111-1111-1111-1111-111111111111', turn = '22222222-2222-2222-2222-222222222222';
|
|
463
|
+
const operation = { source_thread_id: thread, source_turn_id: turn, created_at: Date.parse('2026-10-07T00:49:00.000Z') };
|
|
464
|
+
const read = later => readCodexHandoffState(sourceRollout(home, thread, turn, later), { threadId: thread, turnId: turn });
|
|
465
|
+
assert.deepEqual(read([]).laterTurns, []);
|
|
466
|
+
assert.equal(sourceBoundary(operation, read([])), true);
|
|
467
|
+
// 止まった入力が2つ続いても、元turnの境界のまま。
|
|
468
|
+
const two = read([...stoppedInput('turn-b', '2026-10-07T00:56:48'), ...stoppedInput('turn-c', '2026-10-07T00:56:58')]);
|
|
469
|
+
assert.deepEqual(two.laterTurns, [{ turnId: 'turn-b', closed: true, activity: false }, { turnId: 'turn-c', closed: true, activity: false }]);
|
|
470
|
+
assert.equal(two.latestTurnId, 'turn-c'); assert.equal(two.stoppedAt, Date.parse('2026-10-07T00:49:01.000Z'));
|
|
471
|
+
assert.equal(sourceBoundary(operation, two), true);
|
|
472
|
+
// まだ止まり切っていない入力は、止まるまで待つ(境界は未確認)。
|
|
473
|
+
assert.equal(sourceBoundary(operation, read(stoppedInput('turn-b', '2026-10-07T00:56:48', { closed: false }))), false);
|
|
474
|
+
// モデルの発言・道具の呼び出し・完了・圧縮があれば、旧タスクは進んでいる。
|
|
475
|
+
const activity = [
|
|
476
|
+
{ timestamp: '2026-10-07T00:56:50.000Z', type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ text: '動いてるよ' }] } },
|
|
477
|
+
{ timestamp: '2026-10-07T00:56:50.000Z', type: 'response_item', payload: { type: 'function_call', call_id: 'c1', name: 'exec_command', arguments: '{}' } },
|
|
478
|
+
{ timestamp: '2026-10-07T00:56:50.000Z', type: 'response_item', payload: { type: 'reasoning' } },
|
|
479
|
+
{ timestamp: '2026-10-07T00:56:50.000Z', type: 'event_msg', payload: { type: 'agent_message', message: '動いてるよ' } },
|
|
480
|
+
{ timestamp: '2026-10-07T00:56:50.000Z', type: 'event_msg', payload: { type: 'task_complete', turn_id: 'turn-b' } },
|
|
481
|
+
{ timestamp: '2026-10-07T00:56:50.000Z', type: 'compacted', payload: {} },
|
|
482
|
+
];
|
|
483
|
+
for (const row of activity) {
|
|
484
|
+
const state = read([...stoppedInput('turn-b', '2026-10-07T00:56:48', { closed: false }), row]);
|
|
485
|
+
assert.equal(state.laterTurns[0].activity, true, JSON.stringify(row.payload));
|
|
486
|
+
assert.throws(() => sourceBoundary(operation, state), /handoff_source_advanced/);
|
|
487
|
+
}
|
|
488
|
+
// 元turnの開始が見えないrolloutで最新turnが違う時は、今までどおり進んだと判定する。
|
|
489
|
+
assert.throws(() => sourceBoundary(operation, { latestTurnId: 'other', stoppedAt: Date.now(), pendingCallCount: 0 }), /handoff_source_advanced/);
|
|
490
|
+
}));
|
|
491
|
+
|
|
492
|
+
test('引き継ぎの最中に旧タスクへ入力が来ても、別の引き継ぎを作らず、進んでいる引き継ぎを返して止める', async () => withDb(async (db, home) => {
|
|
493
|
+
const thread = '11111111-1111-1111-1111-111111111111', turnA = '22222222-2222-2222-2222-222222222222';
|
|
494
|
+
const turnB = '33333333-3333-3333-3333-333333333333', turnC = '44444444-4444-4444-4444-444444444444';
|
|
495
|
+
const config = { enabled: true, projects: [home], openHost: 'desktop' };
|
|
496
|
+
const seen = { launched: [], recorded: [], opened: 0 };
|
|
497
|
+
const worker = { pid: 4242, started_identity: 'worker-born' };
|
|
498
|
+
const base = { db, config, launchWorker: async (id, options) => { seen.launched.push([id, options?.resume ?? false]); },
|
|
499
|
+
findThread: () => null, openThread: () => { seen.opened++; return true; }, recordRedirect: entry => { seen.recorded.push(entry); },
|
|
500
|
+
processes: () => [worker], readTarget: () => null };
|
|
501
|
+
const first = await requestCodexAutoHandoff({ ...base, payload: desktopSource(home, thread, turnA) });
|
|
502
|
+
assert.equal(first.inserted, true); assert.equal(seen.launched.length, 1);
|
|
503
|
+
const count = () => db.prepare('SELECT COUNT(*) AS n FROM codex_handoffs').get().n;
|
|
504
|
+
|
|
505
|
+
// workerが名乗る前(起動した直後)に来た入力。
|
|
506
|
+
const early = await requestCodexAutoHandoff({ ...base, payload: desktopSource(home, thread, turnB) });
|
|
507
|
+
assert.equal(early.inFlight, true); assert.equal(early.inserted, false); assert.equal(early.continue, false);
|
|
508
|
+
assert.equal(early.operationId, first.operationId); assert.match(early.stopReason, /引き継いでいる最中/);
|
|
509
|
+
assert.equal(count(), 1); assert.equal(seen.launched.length, 1);
|
|
510
|
+
|
|
511
|
+
// workerが後継を作った後(継続の指示を送る前)に来た入力。時間が経っていても、workerが動いていれば最中。
|
|
512
|
+
updateAutoHandoff(db, first.operationId, { state: 'memory_ready', target_thread_id: 'target-1', worker_identity_json: worker });
|
|
513
|
+
const later = await requestCodexAutoHandoff({ ...base, now: Date.now() + 3_600_000, payload: desktopSource(home, thread, turnC) });
|
|
514
|
+
assert.equal(later.inFlight, true); assert.equal(later.operationId, first.operationId);
|
|
515
|
+
assert.equal(count(), 1); assert.equal(seen.launched.length, 1); assert.equal(seen.opened, 0);
|
|
516
|
+
assert.deepEqual(seen.recorded.map(entry => [entry.kind, entry.source_turn_id, entry.handoff_id, entry.target_thread_id, entry.opened]),
|
|
517
|
+
[['in_flight', turnB, first.operationId, null, false], ['in_flight', turnC, first.operationId, 'target-1', false]]);
|
|
518
|
+
|
|
519
|
+
// 同じturnのhookがもう一度来た時は、今までどおり同じ引き継ぎを返す(最中の扱いにしない)。
|
|
520
|
+
const again = await requestCodexAutoHandoff({ ...base, payload: desktopSource(home, thread, turnA) });
|
|
521
|
+
assert.equal(again.operationId, first.operationId); assert.equal(again.inFlight, undefined); assert.equal(again.inserted, false);
|
|
522
|
+
|
|
523
|
+
// process一覧を読めない時は、記録が新しい間だけ最中として扱う(後継を2つにしない側へ倒す)。
|
|
524
|
+
const unreadable = () => { throw new Error('process一覧を読めません'); };
|
|
525
|
+
assert.equal((await requestCodexAutoHandoff({ ...base, processes: unreadable, payload: desktopSource(home, thread, turnC) })).inFlight, true);
|
|
526
|
+
assert.equal(count(), 1);
|
|
527
|
+
|
|
528
|
+
// workerが居なくなり、後継も残っていない引き継ぎは、最中に数えない。今までどおり新しい引き継ぎを作る。
|
|
529
|
+
const dead = await requestCodexAutoHandoff({ ...base, processes: () => [], payload: desktopSource(home, thread, turnC) });
|
|
530
|
+
assert.equal(dead.inserted, true); assert.notEqual(dead.operationId, first.operationId); assert.equal(count(), 2);
|
|
531
|
+
}));
|
|
532
|
+
|
|
533
|
+
test('後継が出来たまま止まった引き継ぎは、次の入力で同じ引き継ぎをやり直し、別の後継を作らない', async () => withDb(async (db, home) => {
|
|
534
|
+
const thread = '11111111-1111-1111-1111-111111111111', turnA = '22222222-2222-2222-2222-222222222222';
|
|
535
|
+
const turnB = '33333333-3333-3333-3333-333333333333';
|
|
536
|
+
const config = { enabled: true, projects: [home], openHost: 'desktop' };
|
|
537
|
+
const seen = { launched: [], recorded: [] };
|
|
538
|
+
const base = { db, config, launchWorker: async (id, options) => { seen.launched.push([id, options?.resume ?? false]); },
|
|
539
|
+
findThread: () => null, openThread: () => { throw new Error('開かない'); }, recordRedirect: entry => { seen.recorded.push(entry); },
|
|
540
|
+
processes: () => [] };
|
|
541
|
+
const count = () => db.prepare('SELECT COUNT(*) AS n FROM codex_handoffs').get().n;
|
|
542
|
+
const fail = (turn, fields) => {
|
|
543
|
+
const rollout = sourceRollout(home, thread, turn);
|
|
544
|
+
const operation = requestAutoHandoff(db, { threadId: thread, turnId: turn, projectPath: home, rolloutPath: rollout,
|
|
545
|
+
codexHome: join(home, 'codex'), openHost: 'desktop', now: 1 }).operation;
|
|
546
|
+
return updateAutoHandoff(db, operation.handoff_id, { target_thread_id: 'target-1', state: 'failed',
|
|
547
|
+
resume_state: 'memory_ready', error_code: 'handoff_target_not_loaded', ...fields }).operation;
|
|
548
|
+
};
|
|
549
|
+
const input = (turn, later) => {
|
|
550
|
+
const file = sourceRollout(home, thread, turnA, later);
|
|
551
|
+
return { trigger: 'auto', cwd: home, session_id: thread, turn_id: turn, transcript_path: file };
|
|
552
|
+
};
|
|
553
|
+
const failed = fail(turnA, {});
|
|
554
|
+
const open = stoppedInput(turnB, '2026-10-07T00:56:48', { closed: false });
|
|
555
|
+
|
|
556
|
+
// 後継が残っていて、まだ誰も使っておらず、旧タスクに動きが無い。同じ引き継ぎを再開する。
|
|
557
|
+
const resumed = await requestCodexAutoHandoff({ ...base, readTarget: () => ({ latestTurnId: null }), payload: input(turnB, open) });
|
|
558
|
+
assert.equal(resumed.resumed, true); assert.equal(resumed.inserted, false); assert.equal(resumed.continue, false);
|
|
559
|
+
assert.equal(resumed.operationId, failed.handoff_id); assert.match(resumed.stopReason, /やり直します/);
|
|
560
|
+
assert.deepEqual(seen.launched, [[failed.handoff_id, true]]); assert.equal(count(), 1);
|
|
561
|
+
assert.deepEqual(seen.recorded.map(entry => [entry.kind, entry.handoff_id, entry.target_thread_id]), [['retry', failed.handoff_id, 'target-1']]);
|
|
562
|
+
|
|
563
|
+
// 後継を利用者がもう使っている・後継が消えている・結果不明・旧タスクが進んでいる時は、今までどおり新しい引き継ぎを作る。
|
|
564
|
+
const fresh = async (fields, readTarget, later = open) => {
|
|
565
|
+
db.prepare('DELETE FROM codex_handoffs').run(); seen.launched.length = 0;
|
|
566
|
+
const row = fail(turnA, fields);
|
|
567
|
+
const result = await requestCodexAutoHandoff({ ...base, readTarget, payload: input(turnB, later) });
|
|
568
|
+
assert.equal(result.inserted, true); assert.notEqual(result.operationId, row.handoff_id);
|
|
569
|
+
assert.deepEqual(seen.launched, [[result.operationId, false]]); assert.equal(count(), 2);
|
|
570
|
+
};
|
|
571
|
+
await fresh({}, () => ({ latestTurnId: 'used-by-user' }));
|
|
572
|
+
await fresh({}, () => null);
|
|
573
|
+
await fresh({ state: 'unknown', mutation_stage: 'inject' }, () => ({ latestTurnId: null }));
|
|
574
|
+
await fresh({ target_thread_id: null }, () => ({ latestTurnId: null }));
|
|
575
|
+
await fresh({}, () => ({ latestTurnId: null }), [
|
|
576
|
+
...stoppedInput('turn-x', '2026-10-07T00:55:00', { closed: false }),
|
|
577
|
+
{ timestamp: '2026-10-07T00:55:02.000Z', type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ text: '続けたよ' }] } },
|
|
578
|
+
{ timestamp: '2026-10-07T00:55:03.000Z', type: 'event_msg', payload: { type: 'task_complete', turn_id: 'turn-x' } },
|
|
579
|
+
...open,
|
|
580
|
+
]);
|
|
581
|
+
}));
|
|
582
|
+
|
|
583
|
+
test('workerは、最中に来て止まった入力では失敗せず、旧タスクが進んだ時だけ止まる', async () => withDb(async db => {
|
|
584
|
+
const run = async (source, laterTurns) => {
|
|
585
|
+
const operation = request(db, source, { now: 1 }).operation;
|
|
586
|
+
const counters = { created: 0, submitted: 0, notified: 0 };
|
|
587
|
+
const deps = workerDependencies(db, {}, async () => { counters.submitted++; return { queued_submission_id: 'receipt' }; }, counters);
|
|
588
|
+
const read = deps.readSource;
|
|
589
|
+
deps.readSource = item => ({ ...read(item), latestTurnId: 'input-during-handoff', laterTurns });
|
|
590
|
+
return { result: await runAutoHandoffWorker(operation.handoff_id, { db, dependencies: deps }), counters };
|
|
591
|
+
};
|
|
592
|
+
const stopped = await run('stopped-input', [{ turnId: 'input-during-handoff', closed: true, activity: false }]);
|
|
593
|
+
assert.equal(stopped.result.state, 'continued'); assert.equal(stopped.counters.submitted, 1); assert.equal(stopped.counters.notified, 0);
|
|
594
|
+
const advanced = await run('advanced', [{ turnId: 'input-during-handoff', closed: true, activity: true }]);
|
|
595
|
+
assert.equal(advanced.result.state, 'failed'); assert.equal(advanced.result.error_code, 'handoff_source_advanced');
|
|
596
|
+
assert.equal(advanced.counters.created, 0); assert.equal(advanced.counters.submitted, 0);
|
|
597
|
+
}));
|
|
598
|
+
|
|
599
|
+
test('引き継ぎの最中に旧タスクへ来た入力は、凍結する記憶へ入れない', async () => withDb(db => {
|
|
600
|
+
const operation = request(db, 'frozen-source', { now: 1 }).operation;
|
|
601
|
+
const session = operation.source_session_id;
|
|
602
|
+
db.prepare('INSERT INTO sessions (session_id,project_path,created_at,updated_at) VALUES (?, ?, 1, 1)').run(session, '/project');
|
|
603
|
+
const body = db.prepare('INSERT INTO bodies (session_id,origin_session_id,turn_number,role,text,created_at) VALUES (?, ?, ?, ?, ?, ?)');
|
|
604
|
+
body.run(session, session, 1, 'user', '進めて', 100); body.run(session, session, 1, 'assistant', '途中まで進めた', 100);
|
|
605
|
+
body.run(session, session, 2, 'user', '動いてる?', 300); body.run(session, session, 3, 'user', 'ん?', 310);
|
|
606
|
+
db.prepare('INSERT INTO details (session_id,origin_session_id,turn_number,tool_name,kind,output_text,created_at) VALUES (?, ?, 1, ?, ?, ?, ?)')
|
|
607
|
+
.run(session, session, 'exec_command', 'tool_output', '結果', 999);
|
|
608
|
+
const later = [{ turnId: 'b', closed: true, activity: false }, { turnId: 'c', closed: true, activity: false }];
|
|
609
|
+
const frozen = freezeCodexMemory(db, operation, {}, { stoppedAt: 200, laterTurns: later });
|
|
610
|
+
assert.deepEqual(frozen.bodies.map(row => row.text), ['進めて', '途中まで進めた']);
|
|
611
|
+
assert.equal(frozen.sourceTurnNumber, 1); assert.equal(frozen.details.length, 1);
|
|
612
|
+
// 後続turnが無い時と、止める前からあった発言は、今までどおり全部入れる。
|
|
613
|
+
assert.equal(freezeCodexMemory(db, operation, {}, { stoppedAt: 200 }).bodies.length, 4);
|
|
614
|
+
assert.equal(freezeCodexMemory(db, operation, {}, { stoppedAt: 305, laterTurns: later }).bodies.length, 3);
|
|
615
|
+
assert.equal(freezeCodexMemory(db, operation, {}, { stoppedAt: 200, laterTurns: [later[0]] }).bodies.length, 3);
|
|
616
|
+
}));
|
|
@@ -102,8 +102,13 @@ export function readCodexHandoffState(path, { threadId = null, turnId = null, de
|
|
|
102
102
|
const nativeSessions = new Set();
|
|
103
103
|
let inheritedThroughlineMemory = false;
|
|
104
104
|
let completedTools = 0;
|
|
105
|
+
// turnId を指定した時だけ、その turn より後に始まった turn を並べる。closed は止まった(turn_aborted)か完了したか、
|
|
106
|
+
// activity はモデルの発言・道具の呼び出し・圧縮・完了のどれかがあったか。入力を受けただけで止まった turn は activity が false。
|
|
107
|
+
const laterTurns = [];
|
|
108
|
+
let sourceSeen = false, later = null;
|
|
105
109
|
for (const row of rows) {
|
|
106
110
|
const payload = row.payload;
|
|
111
|
+
if (later && (row.type === 'compacted' || row.type === 'token_usage_record')) later.activity = true;
|
|
107
112
|
// forkの先頭は子自身のmetadata。後続にコピーされた親のmetadataは識別へ使わない。
|
|
108
113
|
if (row.type === 'session_meta') { meta ??= payload; continue; }
|
|
109
114
|
if (row.type === 'turn_context' && (!turnId || payload.turn_id === turnId)) context = payload;
|
|
@@ -114,6 +119,8 @@ export function readCodexHandoffState(path, { threadId = null, turnId = null, de
|
|
|
114
119
|
if (row.type === 'event_msg' && payload?.type === 'task_started') {
|
|
115
120
|
latestTurnId = payload.turn_id;
|
|
116
121
|
turnOpen = true;
|
|
122
|
+
if (turnId && latestTurnId === turnId) sourceSeen = true;
|
|
123
|
+
else if (turnId && sourceSeen) { later = { turnId: latestTurnId, closed: false, activity: false }; laterTurns.push(later); }
|
|
117
124
|
hasTurnInput = pendingUser != null;
|
|
118
125
|
if (!turnId || latestTurnId === turnId) {
|
|
119
126
|
turnStartAt = Date.parse(row.timestamp); pendingCalls.clear(); completedTools = 0;
|
|
@@ -126,6 +133,12 @@ export function readCodexHandoffState(path, { threadId = null, turnId = null, de
|
|
|
126
133
|
stoppedAt = Date.parse(row.timestamp); stoppedReason = payload.reason;
|
|
127
134
|
}
|
|
128
135
|
if (row.type === 'event_msg' && ['turn_aborted', 'task_complete'].includes(payload?.type) && payload.turn_id === latestTurnId) turnOpen = false;
|
|
136
|
+
if (later && row.type === 'event_msg' && payload?.turn_id === later.turnId) {
|
|
137
|
+
if (payload.type === 'turn_aborted') later.closed = true;
|
|
138
|
+
if (payload.type === 'task_complete') { later.closed = true; later.activity = true; }
|
|
139
|
+
}
|
|
140
|
+
if (later && row.type === 'event_msg' && payload?.type === 'agent_message') later.activity = true;
|
|
141
|
+
if (later && row.type === 'response_item' && !(payload?.type === 'message' && payload.role !== 'assistant')) later.activity = true;
|
|
129
142
|
if (row.type === 'event_msg' && payload?.type === 'task_complete' && payload.turn_id === (turnId ?? latestTurnId)) {
|
|
130
143
|
completedAt = Date.parse(row.timestamp);
|
|
131
144
|
}
|
|
@@ -197,6 +210,6 @@ export function readCodexHandoffState(path, { threadId = null, turnId = null, de
|
|
|
197
210
|
}
|
|
198
211
|
return { meta, settings, rawSettings, preparedSettings, context, latestTurnId, turnStartAt, stoppedAt, stoppedReason,
|
|
199
212
|
completedAt, correlatedTurnId, hasTurnInput, progress, completedTools, pendingCallCount: pendingCalls.size,
|
|
200
|
-
pendingNativeSessionCount: nativeSessions.size, inheritedThroughlineMemory,
|
|
213
|
+
pendingNativeSessionCount: nativeSessions.size, inheritedThroughlineMemory, laterTurns,
|
|
201
214
|
compactedRows: rows.filter(r => r.type === 'compacted').length };
|
|
202
215
|
}
|