throughline 0.16.11 → 0.16.13
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 +52 -1
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/docs/adr/0045-codex-handoff-carries-pending-queue.md +52 -0
- package/docs/adr/0046-codex-handoff-carries-untracked-inherited-memory.md +39 -0
- package/package.json +1 -1
- package/src/codex-auto-handoff-memory.mjs +10 -0
- package/src/codex-auto-handoff.mjs +76 -3
- package/src/codex-auto-handoff.test.mjs +129 -7
- package/src/hosts/codex-handoff-state.mjs +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,55 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.16.13] — 2026-10-10
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- Codex Desktopの自動継続が、手動の引き継ぎ(`$throughline`)などで始めたタスクを`handoff_memory_lineage_untracked`で止め、そのタスクでは何度入力しても同じ理由で止まり続けた。macOSの実機で1つのタスクが止まり、ほかの道具からの返信も始まらなくなった。前任をさかのぼれない記憶を受け取ったタスクも引き継ぎ、受け取った記憶の本文をそのまま後継へ渡す([ADR 0046](docs/adr/0046-codex-handoff-carries-untracked-inherited-memory.md))。
|
|
18
|
+
- 後継の記憶に「系列の最初のタスクが受け取っていた記憶(そのまま)」の節として載せる。その後継がさらに引き継ぐ時も載せる。120,000字を超える分は切り、切った事を書く。
|
|
19
|
+
- 0.16.12までで止まっていたタスクは、次の入力か`throughline auto-handoff resume --operation <ID>`で引き継がれる。
|
|
20
|
+
|
|
21
|
+
### 対応の範囲
|
|
22
|
+
|
|
23
|
+
| | macOS | Linux | Windows |
|
|
24
|
+
|---|---|---|---|
|
|
25
|
+
| Codex(Desktop) | 直した。実機の止まっていたタスクの記録を読めた(受け取った記憶10,006字)。再開の結果は公開の後に確かめる | 直した。本物のCodex Desktopで、手動の引き継ぎの記憶を持つタスクから2回続けて引き継ぎ、記憶が2つの後継へ渡った | 直した。本物のCodex Desktopでは流していない。CIのWindowsで単体試験が通った |
|
|
26
|
+
| Claude Code | 対象外。Claude Codeの自動継続には、この理由で止める所が無い(記憶は通常のrecallで取る) | 同じ | 同じ |
|
|
27
|
+
| Grok | 対象外。自動継続の機能が無い | 同じ | 同じ |
|
|
28
|
+
| Cursor | 対象外。自動継続の機能が無い | 同じ | 同じ |
|
|
29
|
+
|
|
30
|
+
### 変わらない事
|
|
31
|
+
|
|
32
|
+
- 自動継続の記録に前任があるタスクの記憶の集め方。実行中の子agent・設定の不一致で止まる扱い。
|
|
33
|
+
|
|
34
|
+
### 確認していない範囲
|
|
35
|
+
|
|
36
|
+
- Windowsの本物のCodex Desktopでの通し。
|
|
37
|
+
- 受け取った記憶が120,000字を超える実物(試験では作り物で確かめた)。
|
|
38
|
+
|
|
39
|
+
## [0.16.12] — 2026-10-10
|
|
40
|
+
|
|
41
|
+
### 修正
|
|
42
|
+
|
|
43
|
+
- Codex Desktopの自動継続が、旧タスクの順番待ちに入力が残っていると`handoff_source_input_pending`で止まり、そのタスクでは何度入力しても同じ理由で止まり続けた。Windowsの実機で、2つのタスクに7回続き、どちらもそのまま使われなくなった。止めたタスクではCodexが順番待ちを動かさないので、順番待ちは空にならない。
|
|
44
|
+
- 引き継ぎを断らない。後継が作業を続けたのを確かめた後に、旧タスクの順番待ちの入力を、順番を保って後継の順番待ちへ運ぶ([ADR 0045](docs/adr/0045-codex-handoff-carries-pending-queue.md))。
|
|
45
|
+
- 二重に送らない。後継へ入れた結果が分からない入力は入れ直さず、旧タスクの順番待ちに残す。運べなくても引き継ぎは止めない。
|
|
46
|
+
- 0.16.11までで止まっていたタスクは、次の入力でやり直しが動き、引き継がれる。その時点で順番待ちに残っている入力は、全部後継へ運ばれる。
|
|
47
|
+
|
|
48
|
+
### 変わらない事
|
|
49
|
+
|
|
50
|
+
- 実行中の子agent、設定の不一致、追跡されていない旧handoff記憶で止まる扱い。引き継ぎ済みの旧タスクへ後から届く入力の扱い。Claude Codeの自動継続。
|
|
51
|
+
|
|
52
|
+
### 確認した範囲
|
|
53
|
+
|
|
54
|
+
- Linux(Codex Desktop同梱のCodex 0.162.0-alpha.2)の試験用フォルダで、本物のCodex Desktopに自動継続を2回続けて流した。最初のターンが動いている間に、順番待ちへ入力を1つ入れた。2回とも引き継ぎは`continued`になり、入力は後継から次の後継へ運ばれ、最後の後継で継続の指示のターンの後に1回だけ実行された。旧い2本のrolloutには出ていない。順番待ちは3本とも空になった。
|
|
55
|
+
- macOS(同梱のCodex 0.162.0-alpha.2)とWindowsの同梱のCodexで、試験タスク2本の間で順番待ちを運んだ。入力2つが、先に入っていた1つの後ろへ元の順番で並び、旧い方は空になった。もう一度呼んでも入れ直さなかった。この2つの端末では、引き継ぎの通しは流していない。
|
|
56
|
+
|
|
57
|
+
### 確認していない範囲
|
|
58
|
+
|
|
59
|
+
- 0.16.11までで止まっていた実機のタスクが、次の入力で引き継がれる所(利用者の入力が要る)。
|
|
60
|
+
- 後継へ入れる応答が本当に切れた時の動き(試験では作り物の応答で確かめた)。
|
|
61
|
+
|
|
13
62
|
## [0.16.11] — 2026-10-09
|
|
14
63
|
|
|
15
64
|
### 修正
|
|
@@ -2124,7 +2173,9 @@ two attempts, instrument first instead of patching again.
|
|
|
2124
2173
|
|
|
2125
2174
|
---
|
|
2126
2175
|
|
|
2127
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.
|
|
2176
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.13...HEAD
|
|
2177
|
+
[0.16.13]: https://github.com/kitepon/Throughline/compare/v0.16.12...v0.16.13
|
|
2178
|
+
[0.16.12]: https://github.com/kitepon/Throughline/compare/v0.16.11...v0.16.12
|
|
2128
2179
|
[0.16.11]: https://github.com/kitepon/Throughline/compare/v0.16.10...v0.16.11
|
|
2129
2180
|
[0.16.10]: https://github.com/kitepon/Throughline/compare/v0.16.9...v0.16.10
|
|
2130
2181
|
[0.16.9]: https://github.com/kitepon/Throughline/compare/v0.16.8...v0.16.9
|
package/README.ja.md
CHANGED
|
@@ -33,7 +33,7 @@ throughline auto-handoff disable
|
|
|
33
33
|
|
|
34
34
|
記憶は引き継ぎ全体の直近20ターンをL2全文、それより古いターンをL1で渡します。元のユーザー依頼と中断地点を保持し、tool結果とThinkingは注入しません。本文内の`auto-handoff detail`コマンドで、凍結したL2/L3を引き継ぎID・origin・turnから取得できます。A→B→Cの連続引き継ぎでも祖先の詳細を取得できます。
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
設定不一致、実行中の子agentや既知のnativeコマンドでは固定理由を残して停止します。手動の引き継ぎなど、前任をさかのぼれない記憶を受け取ったタスクは、受け取った記憶をそのまま後継へ渡して引き継ぎます(0.16.13から。それより前の版は、ここで引き継ぎを止めていました)。旧タスクの順番待ちに残っている入力(まだ送られていない物)は、後継が作業を続けた後に、順番を保って後継の順番待ちへ運びます(0.16.12から。それより前の版は、ここで引き継ぎを止めていました)。失敗時はローカルの説明ページを開きます。新タスクだけで容量が尽きる場合も、新タスクを増やし続けません。結果不明の指示は自動で再送しません。
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
39
|
throughline auto-handoff status --operation <handoff-id> --json
|
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ throughline auto-handoff disable
|
|
|
32
32
|
|
|
33
33
|
記憶は引き継ぎ全体の直近20ターンをL2全文、それより古いターンをL1で渡します。元のユーザー依頼と中断地点を保持し、tool結果とThinkingは注入しません。本文内の`auto-handoff detail`コマンドで、凍結したL2/L3を引き継ぎID・origin・turnから取得できます。A→B→Cの連続引き継ぎでも祖先の詳細を取得できます。
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
設定不一致、実行中の子agentや既知のnativeコマンドでは固定理由を残して停止します。手動の引き継ぎなど、前任をさかのぼれない記憶を受け取ったタスクは、受け取った記憶をそのまま後継へ渡して引き継ぎます(0.16.13から。それより前の版は、ここで引き継ぎを止めていました)。旧タスクの順番待ちに残っている入力(まだ送られていない物)は、後継が作業を続けた後に、順番を保って後継の順番待ちへ運びます(0.16.12から。それより前の版は、ここで引き継ぎを止めていました)。失敗時はローカルの説明ページを開きます。新タスクだけで容量が尽きる場合も、新タスクを増やし続けません。結果不明の指示は自動で再送しません。
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
38
|
throughline auto-handoff status --operation <handoff-id> --json
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# ADR 0045: Codex の旧タスクの順番待ちに残った入力は、引き継ぎを断らずに後継へ運ぶ
|
|
2
|
+
|
|
3
|
+
日付: 2026-10-10
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
Codex Desktop の自動継続は、旧タスクの公式の順番待ち(`thread/queue/list`)に入力が残っていると、
|
|
8
|
+
`handoff_source_input_pending` で引き継ぎを止めていた。後継を作る前に、未処理の入力を取りこぼさないための決まりだった。
|
|
9
|
+
|
|
10
|
+
2026-10-09、Windows の実機で、この理由の失敗が2つのタスクに7回続いた。どちらのタスクも、そのまま使われなくなった。
|
|
11
|
+
|
|
12
|
+
- 1つ目のタスク: 13:17 と 14:43 に止まり、14:45 が最後の書き込み。
|
|
13
|
+
- 2つ目のタスク: 20:55 に止まり、21:06〜21:07 の4回の入力も全部止まった。21:08 が最後の書き込み。
|
|
14
|
+
|
|
15
|
+
原因は、止める順番と Codex の順番待ちの決まりの組み合わせにあった。
|
|
16
|
+
|
|
17
|
+
- 自動継続は、`PreCompact(auto)` の hook でターンを止めてから、worker が順番待ちを見る。
|
|
18
|
+
- Codex は、順番待ちの入力を「その入力でターンを始めた時」に消す(`codex-rs/ext/queue/src/service.rs` の
|
|
19
|
+
`dispatch_if_idle`。`StartIfIdleSubmission::Started` の後に `delete_locked`)。順番待ちに残っている入力は、まだ送られていない。
|
|
20
|
+
- 途中で止めたタスク(`AgentStatus::Interrupted`)では、Codex は順番待ちを動かさない(同じ file の `wake_if_loaded` と
|
|
21
|
+
`on_thread_idle`)。次に人が送ったターンが普通に終わるまで動かない。
|
|
22
|
+
- 旧タスクは文脈が上限に達しているので、次に人が送ったターンも `PreCompact(auto)` で止まる。順番待ちは空にならず、
|
|
23
|
+
引き継ぎは同じ理由で止まり続ける。利用者には、何を打ってもタスクが止まるように見える。
|
|
24
|
+
|
|
25
|
+
順番待ちに入力が入るのは、ターンが動いている間に届いた物(利用者が続けて打った文、ほかの道具が
|
|
26
|
+
`thread/queue/add` で入れた通知や返信)。長いターンの間に1つ届くだけで、このタスクは引き継げなくなる。
|
|
27
|
+
|
|
28
|
+
Codex の app-server には、順番待ちを読む・入れる・消す口がある(`thread/queue/list`・`thread/queue/add`・
|
|
29
|
+
`thread/queue/delete`)。別の process からも使える。
|
|
30
|
+
|
|
31
|
+
## Decision
|
|
32
|
+
|
|
33
|
+
1. 旧タスクの順番待ちに入力が残っていても、引き継ぎを断らない。`handoff_source_input_pending` は出さない。
|
|
34
|
+
2. 後継が作業を続けたのを確かめた後(`continued` の直後)に、旧タスクの順番待ちの入力を、順番を保って後継の順番待ちへ運ぶ。
|
|
35
|
+
後継へ `thread/queue/add`(`input` と `clientUserMessageId` はそのまま)で入れてから、旧タスクから `thread/queue/delete` で消す。
|
|
36
|
+
後継の最初のターン(継続の指示)は始まっているので、運んだ入力はその後ろに並び、ターンが終わるたびに1つずつ始まる。
|
|
37
|
+
3. 二重に送らない。運ぶ入力の ID を、入れる前に引き継ぎの記録(`runtime_json.queueCarry.moving`)へ書き、入った後に
|
|
38
|
+
`moved` へ移す。入れた結果が分からない入力(応答が切れた、途中で worker が終わった)は `unknown` へ入れ、入れ直さない。
|
|
39
|
+
その入力は旧タスクの順番待ちに残る。Codex がはっきり断った入力も旧タスクに残す。
|
|
40
|
+
4. 運べなくても、引き継ぎの `state` は変えない。後継はもう作業を続けている。理由と数は worker のログに残す。
|
|
41
|
+
5. 後継がさらに引き継ぐ時も同じ。運んだ入力がまだ後継の順番待ちに残っていれば、次の後継へ運ぶ。
|
|
42
|
+
6. 途中で止まった引き継ぎ(`failed`・`unknown`)では、順番待ちを動かさない。やり直しで `continued` になった時に運ぶ。
|
|
43
|
+
7. 引き継ぎ済みの旧タスクへ後から届く入力の扱い(ADR 0039)は変えない。この ADR が扱うのは、引き継ぎの時点で残っていた入力だけ。
|
|
44
|
+
|
|
45
|
+
## Consequences
|
|
46
|
+
|
|
47
|
+
- 順番待ちに入力があるタスクも引き継げる。0.16.11 までで止まっていたタスクは、次の入力でやり直しが動き、引き継がれる
|
|
48
|
+
(その時点で順番待ちに残っている入力は、全部後継へ運ばれる)。
|
|
49
|
+
- 運んだ入力は、後継の継続の指示のターンが終わった後に実行される。旧タスクで待っていた順番と同じ。
|
|
50
|
+
- ほかの道具が旧タスクの ID で持っている配送の記録は、後継の ID へは書き換わらない。入力そのものは後継で実行される。
|
|
51
|
+
- 入れた結果が分からない入力は、旧タスクの順番待ちに残る。旧タスクは引き継ぎ済みなので、その入力は実行されない。
|
|
52
|
+
数は `auto-handoff status --operation <ID>` では出ないので、worker のログ(`queue carried`)で読む。
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# ADR 0046: 前任をさかのぼれない記憶を受け取った Codex のタスクも引き継ぎ、受け取った記憶をそのまま後継へ渡す
|
|
2
|
+
|
|
3
|
+
日付: 2026-10-10
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
Codex Desktop の自動継続は、旧タスクが Throughline の記憶(`## Throughline: 自動継続文脈`・`Active Work Context`・
|
|
8
|
+
`New Codex Thread Handoff` で始まる developer の文)を受け取っていて、その前任が自動継続の記録に無い時、
|
|
9
|
+
`handoff_memory_lineage_untracked` で引き継ぎを止めていた。後継へ渡す記憶は、自動継続の記録をさかのぼって集める。
|
|
10
|
+
さかのぼれない前任の作業は集められないので、黙って落とさないための決まりだった。
|
|
11
|
+
|
|
12
|
+
2026-10-10、macOS の実機で、手動の引き継ぎ(`$throughline`。`New Codex Thread Handoff`)で 10/8 に始めたタスクが、
|
|
13
|
+
この理由で止まった。止めた順番が問題だった。
|
|
14
|
+
|
|
15
|
+
- 自動継続は、`PreCompact(auto)` の hook でターンを止めてから、worker が引き継げるかを調べる。断った時、止めたターンは戻らない。
|
|
16
|
+
- 途中で止めたタスクは、Codex が順番待ちを動かさない。ほかの道具が入れた返信も始まらない(ADR 0045 と同じ)。
|
|
17
|
+
- 次に人が入力しても、文脈は上限のままなので `PreCompact(auto)` でまた止まり、同じ理由で断る。受け取った記憶は消えないので、
|
|
18
|
+
このタスクは何度入力しても引き継げない。
|
|
19
|
+
|
|
20
|
+
受け取った記憶の本文は、旧タスクの rollout の最初の developer の文に残っている(実機の例は 10,006 字)。
|
|
21
|
+
旧タスクのモデルが、それより前の作業について持っていた記憶は、この本文だけだった。
|
|
22
|
+
|
|
23
|
+
## Decision
|
|
24
|
+
|
|
25
|
+
1. 前任をさかのぼれない記憶を受け取ったタスクも、引き継ぐ。`handoff_memory_lineage_untracked` は出さない。
|
|
26
|
+
2. 受け取った記憶の本文(最初の1つ)を、引き継ぎの記録(`runtime_json.inheritedMemory`)へ残す。120,000 字を超える分は切り、
|
|
27
|
+
元の字数と切った事を残す。
|
|
28
|
+
3. 後継へ渡す記憶に、「系列の最初のタスクが受け取っていた記憶(そのまま)」の節として載せる。L1・L2 より前に置く。
|
|
29
|
+
元の `## Throughline: …` の見出しは「(元の見出し: …)」へ書き換え、後継の記憶の見出しを1つに保つ。
|
|
30
|
+
4. その後継がさらに引き継ぐ時も、系列をさかのぼって同じ本文を載せる。
|
|
31
|
+
5. 凍結した詳細の取得(`auto-handoff detail`)の対象には入れない。本文そのものを載せているので、取得する物が無い。
|
|
32
|
+
6. 自動継続の記録に前任があるタスクの扱い(記録をさかのぼって L1・L2 を集める)は変えない。
|
|
33
|
+
|
|
34
|
+
## Consequences
|
|
35
|
+
|
|
36
|
+
- 手動の引き継ぎで始めたタスク、ほかの端末や消えた記録から続いたタスクも、自動継続で引き継げる。
|
|
37
|
+
- 後継の記憶は、受け取った記憶の分だけ長くなる(上限 120,000 字)。引き継ぎを重ねても、同じ本文を1回載せるだけで増えない。
|
|
38
|
+
- 受け取った記憶より前の作業の L2・L3 は、後継から取得できない。旧タスクでも取得できなかったので、失う物は無い。
|
|
39
|
+
- 0.16.12 までで、この理由で止まっていたタスクは、次の入力か `auto-handoff resume --operation <ID>` で引き継がれる。
|
package/package.json
CHANGED
|
@@ -95,6 +95,16 @@ export function renderAutoHandoffMemory(db, operation) {
|
|
|
95
95
|
lines.push('### 中断地点の取得',
|
|
96
96
|
reference({ handoffId: operation.handoff_id, origin_session_id: operation.source_session_id, turn_number: latestTurn }, operation), '');
|
|
97
97
|
}
|
|
98
|
+
// 系列の最初のタスクが、前任をさかのぼれない記憶を受け取っていた。その記憶より前の作業は、この本文にしか無い。
|
|
99
|
+
const inherited = memory.lineage.map(entry => entry.operation.runtime?.inheritedMemory).find(Boolean);
|
|
100
|
+
if (inherited) {
|
|
101
|
+
lines.push('### 系列の最初のタスクが受け取っていた記憶(そのまま)',
|
|
102
|
+
'この系列より前の作業の記憶です。取得コマンドの対象には入っていません。下の L1・L2 より古い内容です。');
|
|
103
|
+
if (inherited.truncated) lines.push(`(長いので先頭だけ載せています。元は ${inherited.chars} 字)`);
|
|
104
|
+
lines.push(inherited.text
|
|
105
|
+
? inherited.text.replace(/^## Throughline: ([^\n]*)$/gm, '(元の見出し: $1)')
|
|
106
|
+
: '(本文を読めませんでした)', '');
|
|
107
|
+
}
|
|
98
108
|
if (memory.olderSummaries.length) {
|
|
99
109
|
lines.push('### 以前の記憶(L1)');
|
|
100
110
|
for (const row of memory.olderSummaries) lines.push(`[${new Date(row.created_at).toISOString()}] ${row.summary} (詳細: ${reference(row, operation)})`);
|
|
@@ -241,9 +241,11 @@ async function sourceNativeState(operation, runtime) {
|
|
|
241
241
|
if (thread?.id !== operation.source_thread_id || thread.parentThreadId || thread.canAcceptDirectInput === false ||
|
|
242
242
|
thread.source?.subAgent) throw error('handoff_source_not_primary');
|
|
243
243
|
if (!sameProjectPath(thread.cwd, operation.project_path)) throw error('handoff_source_project_mismatch');
|
|
244
|
+
// 順番待ちに残っている入力は、まだ送られていない(Codex は、ターンを始めた時に順番待ちから消す)。
|
|
245
|
+
// 止めた旧タスクでは順番待ちが動かないので、ここで断ると何度入力しても同じ理由で止まり続ける。
|
|
246
|
+
// 断らずに進み、後継が作業を続けた後で後継の順番待ちへ運ぶ(carryAutoHandoffQueue)。
|
|
244
247
|
const queue = await request('thread/queue/list', { threadId: operation.source_thread_id, limit: 1 });
|
|
245
248
|
if (!Array.isArray(queue.data)) throw error('handoff_queue_shape_invalid');
|
|
246
|
-
if (queue.data.length) throw error('handoff_source_input_pending');
|
|
247
249
|
const children = await request('thread/list', { ancestorThreadId: operation.source_thread_id,
|
|
248
250
|
sourceKinds: ['subAgent'], limit: 100, archived: false });
|
|
249
251
|
if (!Array.isArray(children.data) || children.nextCursor) throw error('handoff_children_unavailable');
|
|
@@ -306,6 +308,70 @@ export async function markAutoHandoffSource(operation, runtime, { connect = with
|
|
|
306
308
|
}
|
|
307
309
|
}
|
|
308
310
|
|
|
311
|
+
/**
|
|
312
|
+
* 止めた時に旧タスクの順番待ちに残っていた入力(まだ送られていない)を、順番を保って後継の順番待ちへ運ぶ。
|
|
313
|
+
* 後継へ入れてから旧タスクの順番待ちから消す。後継へ入れた結果が分からない入力は、二重に送らないために
|
|
314
|
+
* 入れ直さず、旧タスクの順番待ちに残す。運べなくても引き継ぎは止めない(後継はもう作業を続けている)。
|
|
315
|
+
* @returns {Promise<{moved: number, unknown: number, left: number}>} left は、断られて旧タスクに残った数
|
|
316
|
+
*/
|
|
317
|
+
export async function carryAutoHandoffQueue(db, operation, runtime, { connect = withCodexReceiver } = {}) {
|
|
318
|
+
const sourceId = operation.source_thread_id; const targetId = operation.target_thread_id;
|
|
319
|
+
const stored = getAutoHandoff(db, operation.handoff_id).runtime?.queueCarry ?? {};
|
|
320
|
+
const carry = { moved: [...(stored.moved ?? [])], unknown: [...(stored.unknown ?? [])], left: 0 };
|
|
321
|
+
// 前の worker が、後継へ入れる途中で終わった。入ったかどうかが分からない。
|
|
322
|
+
if (stored.moving && !carry.moved.includes(stored.moving)) carry.unknown.push(stored.moving);
|
|
323
|
+
const save = moving => updateAutoHandoff(db, operation.handoff_id, { runtime_json: {
|
|
324
|
+
...(getAutoHandoff(db, operation.handoff_id).runtime ?? {}), queueCarry: { moved: carry.moved, unknown: carry.unknown, moving } } });
|
|
325
|
+
const summary = () => ({ moved: carry.moved.length, unknown: carry.unknown.length, left: carry.left });
|
|
326
|
+
try {
|
|
327
|
+
if (stored.moving) save(null);
|
|
328
|
+
await connect(autoHandoffDeliveryProfile, { thread_id: sourceId, codex_home: operation.codex_home }, async request => {
|
|
329
|
+
const items = [];
|
|
330
|
+
for (let cursor = null, page = 0; page < 50; page++) {
|
|
331
|
+
const listed = await request('thread/queue/list', { threadId: sourceId, cursor, limit: 100 });
|
|
332
|
+
if (!Array.isArray(listed.data)) throw error('handoff_queue_shape_invalid');
|
|
333
|
+
items.push(...listed.data);
|
|
334
|
+
cursor = listed.nextCursor ?? null;
|
|
335
|
+
if (!cursor) break;
|
|
336
|
+
}
|
|
337
|
+
for (const item of items) {
|
|
338
|
+
if (carry.unknown.includes(item.id)) continue;
|
|
339
|
+
if (!carry.moved.includes(item.id)) {
|
|
340
|
+
save(item.id);
|
|
341
|
+
try {
|
|
342
|
+
await request('thread/queue/add', { threadId: targetId, input: item.input, clientUserMessageId: item.clientUserMessageId });
|
|
343
|
+
} catch (cause) {
|
|
344
|
+
// はっきり断られた入力は後継に入っていない。旧タスクに残す。それ以外は結果不明として扱う。
|
|
345
|
+
if (cause?.delivery_code === 'CODEX_RECEIVER_REJECTED') { carry.left++; save(null); continue; }
|
|
346
|
+
carry.unknown.push(item.id); save(null);
|
|
347
|
+
throw cause;
|
|
348
|
+
}
|
|
349
|
+
carry.moved.push(item.id);
|
|
350
|
+
save(null);
|
|
351
|
+
}
|
|
352
|
+
await request('thread/queue/delete', { threadId: sourceId, queuedSubmissionId: item.id });
|
|
353
|
+
}
|
|
354
|
+
}, runtime);
|
|
355
|
+
} catch (cause) {
|
|
356
|
+
process.stderr.write(`[auto-handoff] queue carry failed: ${cause?.delivery_code ?? cause?.code ?? 'unknown'}\n`);
|
|
357
|
+
}
|
|
358
|
+
if (carry.moved.length || carry.unknown.length || carry.left) {
|
|
359
|
+
process.stderr.write(`[auto-handoff] queue carried: ${JSON.stringify(summary())}\n`);
|
|
360
|
+
}
|
|
361
|
+
return summary();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// 受け取った記憶をそのまま運ぶ上限。超えた分は切り、切った事を記録する(後継の記憶に出す)。
|
|
365
|
+
const INHERITED_MEMORY_MAX_CHARS = 120_000;
|
|
366
|
+
|
|
367
|
+
/** 前任をさかのぼれないタスクが最初に受け取っていた記憶を、引き継ぎの記録へ残す形にする。 */
|
|
368
|
+
export function untrackedInheritedMemory(text) {
|
|
369
|
+
const source = typeof text === 'string' ? text : '';
|
|
370
|
+
const chars = Array.from(source);
|
|
371
|
+
return { text: chars.length > INHERITED_MEMORY_MAX_CHARS ? chars.slice(0, INHERITED_MEMORY_MAX_CHARS).join('') : source,
|
|
372
|
+
chars: chars.length, truncated: chars.length > INHERITED_MEMORY_MAX_CHARS };
|
|
373
|
+
}
|
|
374
|
+
|
|
309
375
|
/** 旧タスクが、名前を付けない版の作った後継だった時の名前。その後継を作った引き継ぎから、後継の名前を作り直す。 */
|
|
310
376
|
export function autoHandoffSourceFallbackTitle(db, operation) {
|
|
311
377
|
const creator = findAutoHandoffForTarget(db, operation.source_thread_id);
|
|
@@ -467,6 +533,7 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
|
|
|
467
533
|
const deps = { processes: readRuntimeProcesses, readSource: sourceState,
|
|
468
534
|
nativeState: sourceNativeState, capture: captureCodexRolloutToDb, summaries: ensureAutoHandoffSummaries,
|
|
469
535
|
createTarget, readTarget: targetState, openTarget, verify: verifyCodexParent, markSource: markAutoHandoffSource,
|
|
536
|
+
carryQueue: carryAutoHandoffQueue,
|
|
470
537
|
submit: submitCodexParentAnswer, notify: showAutoHandoffFailure, wait: waitForHandoff,
|
|
471
538
|
executable: () => platformDesktopFinder()(), liveSuccessor: findLiveAutoHandoffSuccessor, ...dependencies };
|
|
472
539
|
let operation = getAutoHandoff(db, id);
|
|
@@ -498,7 +565,10 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
|
|
|
498
565
|
return sourceBoundary(operation, state) ? state : null;
|
|
499
566
|
}, { timeoutMs: 30_000, timeoutCode: 'handoff_source_stop_unconfirmed' });
|
|
500
567
|
if (!state.settings) throw error('handoff_settings_unavailable');
|
|
501
|
-
|
|
568
|
+
// 前任をさかのぼれない記憶を受け取ったタスク(手動の引き継ぎで始めたタスクなど)。断ると、止めたターンが
|
|
569
|
+
// そのまま残り、次の入力も同じ理由で止まる。受け取った記憶をそのまま後継へ渡して引き継ぐ(ADR 0046)。
|
|
570
|
+
const inheritedMemory = state.inheritedThroughlineMemory && !operation.previous_handoff_id
|
|
571
|
+
? untrackedInheritedMemory(state.inheritedThroughlineMemoryText) : null;
|
|
502
572
|
if (operation.previous_handoff_id) {
|
|
503
573
|
const previous = getAutoHandoff(db, operation.previous_handoff_id);
|
|
504
574
|
const continuation = deps.readSource(operation, { deliveryText: continuationInput(previous) });
|
|
@@ -507,7 +577,8 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
|
|
|
507
577
|
}
|
|
508
578
|
}
|
|
509
579
|
const native = await deps.nativeState(operation, runtime);
|
|
510
|
-
update({ state: 'source_stopped', settings_json: state.settings,
|
|
580
|
+
update({ state: 'source_stopped', settings_json: state.settings,
|
|
581
|
+
runtime_json: { ...native, databasePath: DB_PATH, ...(inheritedMemory ? { inheritedMemory } : {}) } });
|
|
511
582
|
}
|
|
512
583
|
// 最中に来た入力のturnが止まり切るまでの間は、境界がまだ見えない。短く待ってから判定する。
|
|
513
584
|
const confirmSourceBoundary = () => deps.wait(() => sourceBoundary(operation, deps.readSource(operation)) || null,
|
|
@@ -581,6 +652,8 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
|
|
|
581
652
|
return target.correlatedTurnId && target.progress ? target : null;
|
|
582
653
|
}, { timeoutMs: 180_000, timeoutCode: 'handoff_target_progress_unconfirmed' });
|
|
583
654
|
update({ state: 'continued', started_turn_id: target.correlatedTurnId, resume_state: null, error_code: null });
|
|
655
|
+
// 旧タスクの順番待ちに残った入力を後継へ運ぶ。後継の最初のターン(継続の指示)が始まった後なので、順番は変わらない。
|
|
656
|
+
await deps.carryQueue(db, operation, runtime);
|
|
584
657
|
// 後継が作業を続けたのを確かめてから付ける。途中で止まった引き継ぎの旧タスクには付けない。
|
|
585
658
|
await deps.markSource(operation, runtime, { fallbackTitle: autoHandoffSourceFallbackTitle(db, operation) });
|
|
586
659
|
}
|
|
@@ -14,7 +14,8 @@ import { collectAutoHandoffMemory, ensureAutoHandoffSummaries, renderAutoHandoff
|
|
|
14
14
|
import { readCodexHandoffState, settingsMatch, threadStartSettings, resolvePreparedSettings } from './hosts/codex-handoff-state.mjs';
|
|
15
15
|
import { runAutoHandoffWorker, requestCodexAutoHandoff, sourceBoundary,
|
|
16
16
|
autoHandoffTargetName, nameAutoHandoffTarget, findLiveAutoHandoffSuccessor,
|
|
17
|
-
markAutoHandoffSource, markAutoHandoffSources, autoHandoffSourceFallbackTitle
|
|
17
|
+
markAutoHandoffSource, markAutoHandoffSources, autoHandoffSourceFallbackTitle, carryAutoHandoffQueue,
|
|
18
|
+
untrackedInheritedMemory } from './codex-auto-handoff.mjs';
|
|
18
19
|
import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
|
|
19
20
|
|
|
20
21
|
async function withDb(fn) {
|
|
@@ -116,6 +117,7 @@ function workerDependencies(db, settings, submit, counters) {
|
|
|
116
117
|
readTarget: operation => ({ settings, latestTurnId: ['submitted', 'unknown'].includes(operation.state) ? 'new-turn' : null,
|
|
117
118
|
correlatedTurnId: ['submitted', 'unknown'].includes(operation.state) ? 'new-turn' : null, progress: true }),
|
|
118
119
|
openTarget: () => {}, verify: async () => {}, submit,
|
|
120
|
+
carryQueue: async (db, operation) => { (counters.carried ??= []).push(`${operation.source_thread_id}:${operation.state}`); return { moved: 0, unknown: 0, left: 0 }; },
|
|
119
121
|
markSource: async (operation, runtime, options) => {
|
|
120
122
|
(counters.marked ??= []).push(`${operation.source_thread_id}:${operation.state}`);
|
|
121
123
|
assert.ok('fallbackTitle' in options);
|
|
@@ -135,6 +137,7 @@ test('停止→記憶→作成→配送→進捗を確認し、重複workerは
|
|
|
135
137
|
await runAutoHandoffWorker(operation.handoff_id, { db, dependencies: deps });
|
|
136
138
|
assert.equal(counters.created, 1); assert.equal(counters.submitted, 1); assert.equal(counters.notified, 0);
|
|
137
139
|
assert.deepEqual(counters.marked, ['worker-source:continued'], '後継が作業を続けたのを確かめた後に、旧タスクへ印を1回だけ付ける');
|
|
140
|
+
assert.deepEqual(counters.carried, ['worker-source:continued'], '後継が作業を続けたのを確かめた後に、順番待ちを1回だけ運ぶ');
|
|
138
141
|
}));
|
|
139
142
|
|
|
140
143
|
test('配送結果不明は再送せず、実際の入力・開始を観測して同じ後継から回復する', async () => withDb(async db => {
|
|
@@ -177,21 +180,80 @@ test('作成応答を失った時は結果不明を保存し、再開でも後
|
|
|
177
180
|
assert.equal(counters.created, 1); assert.equal(counters.submitted, 0);
|
|
178
181
|
}));
|
|
179
182
|
|
|
180
|
-
test('
|
|
181
|
-
for (const code of ['
|
|
183
|
+
test('実行中の子がある時は後継を作らない', async () => withDb(async db => {
|
|
184
|
+
for (const code of ['handoff_native_child_pending']) {
|
|
182
185
|
const op = request(db, code, { now: 1 }).operation;
|
|
183
186
|
const counters = { created: 0, submitted: 0, notified: 0 };
|
|
184
187
|
const deps = workerDependencies(db, {}, async () => { counters.submitted++; }, counters);
|
|
185
|
-
|
|
186
|
-
const read = deps.readSource;
|
|
187
|
-
deps.readSource = item => ({ ...read(item), inheritedThroughlineMemory: true });
|
|
188
|
-
} else deps.nativeState = async () => { throw Object.assign(Error(code), { code }); };
|
|
188
|
+
deps.nativeState = async () => { throw Object.assign(Error(code), { code }); };
|
|
189
189
|
const result = await runAutoHandoffWorker(op.handoff_id, { db, dependencies: deps });
|
|
190
190
|
assert.equal(result.error_code, code); assert.equal(result.state, 'failed');
|
|
191
191
|
assert.equal(counters.created, 0); assert.equal(counters.submitted, 0);
|
|
192
192
|
}
|
|
193
193
|
}));
|
|
194
194
|
|
|
195
|
+
test('rollout の developer 記憶から、受け取った Throughline の記憶の本文を最初の1つだけ読む', () => {
|
|
196
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-inherited-'));
|
|
197
|
+
const file = join(dir, 'rollout.jsonl');
|
|
198
|
+
const developer = text => ({ type: 'response_item', payload: { type: 'message', role: 'developer', content: [{ type: 'input_text', text }] } });
|
|
199
|
+
const rows = [{ type: 'session_meta', payload: { id: 'T', cwd: '/project', originator: 'Codex Desktop', source: 'vscode' } },
|
|
200
|
+
developer('ふつうの developer 文'),
|
|
201
|
+
developer('## Throughline: New Codex Thread Handoff\n最初に受け取った記憶'),
|
|
202
|
+
developer('## Throughline: Active Work Context\n後から入った別の記憶')];
|
|
203
|
+
writeFileSync(file, rows.map(row => JSON.stringify({ timestamp: '2026-10-08T00:00:01.000Z', ...row })).join('\n') + '\n');
|
|
204
|
+
const state = readCodexHandoffState(file, { threadId: 'T' });
|
|
205
|
+
assert.equal(state.inheritedThroughlineMemory, true);
|
|
206
|
+
assert.equal(state.inheritedThroughlineMemoryText, '## Throughline: New Codex Thread Handoff\n最初に受け取った記憶');
|
|
207
|
+
writeFileSync(file, rows.slice(0, 2).map(row => JSON.stringify({ timestamp: '2026-10-08T00:00:01.000Z', ...row })).join('\n') + '\n');
|
|
208
|
+
const plain = readCodexHandoffState(file, { threadId: 'T' });
|
|
209
|
+
assert.equal(plain.inheritedThroughlineMemory, false); assert.equal(plain.inheritedThroughlineMemoryText, null);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('前任をさかのぼれない記憶を受け取ったタスクも引き継ぎ、受け取った記憶をそのまま後継へ渡す', async () => withDb(async db => {
|
|
213
|
+
const op = request(db, 'manual-origin', { now: 1 }).operation;
|
|
214
|
+
const counters = { created: 0, submitted: 0, notified: 0 };
|
|
215
|
+
const deps = workerDependencies(db, {}, async () => { counters.submitted++; return { queued_submission_id: 'receipt' }; }, counters);
|
|
216
|
+
const read = deps.readSource;
|
|
217
|
+
const inheritedText = '## Throughline: New Codex Thread Handoff\n手動の引き継ぎで受け取った記憶。前のタスクの合意: 4イベントを申請する';
|
|
218
|
+
deps.readSource = (item, options) => ({ ...read(item, options), inheritedThroughlineMemory: true, inheritedThroughlineMemoryText: inheritedText });
|
|
219
|
+
const result = await runAutoHandoffWorker(op.handoff_id, { db, dependencies: deps });
|
|
220
|
+
assert.equal(result.state, 'continued', '断らずに引き継ぐ');
|
|
221
|
+
assert.equal(result.error_code, null); assert.equal(counters.created, 1); assert.equal(counters.submitted, 1); assert.equal(counters.notified, 0);
|
|
222
|
+
assert.deepEqual(result.runtime.inheritedMemory, { text: inheritedText, chars: Array.from(inheritedText).length, truncated: false });
|
|
223
|
+
}));
|
|
224
|
+
|
|
225
|
+
test('受け取った記憶は系列の最初の引き継ぎに残り、その先の後継の記憶にも出る。見出しは入れ子にしない', async () => withDb(db => {
|
|
226
|
+
let a = snapshot(db, request(db, 'A').operation, 2);
|
|
227
|
+
a = updateAutoHandoff(db, a.handoff_id, { target_thread_id: 'B', state: 'continued',
|
|
228
|
+
runtime_json: { inheritedMemory: untrackedInheritedMemory('## Throughline: New Codex Thread Handoff\n前のタスクの合意: 4イベントを申請する') } }).operation;
|
|
229
|
+
const first = renderAutoHandoffMemory(db, a);
|
|
230
|
+
assert.ok(first.includes('### 系列の最初のタスクが受け取っていた記憶(そのまま)'));
|
|
231
|
+
assert.ok(first.includes('前のタスクの合意: 4イベントを申請する'));
|
|
232
|
+
assert.ok(first.includes('(元の見出し: New Codex Thread Handoff)'));
|
|
233
|
+
assert.equal(first.match(/^## Throughline: /gm).length, 1, '見出しは後継の記憶の1つだけ');
|
|
234
|
+
assert.ok(first.indexOf('系列の最初のタスクが受け取っていた記憶') < first.indexOf('直近'), '古い記憶を先に置く');
|
|
235
|
+
|
|
236
|
+
let b = snapshot(db, request(db, 'B').operation, 2, 100, continuationInput(a));
|
|
237
|
+
b = updateAutoHandoff(db, b.handoff_id, { target_thread_id: 'C' }).operation;
|
|
238
|
+
assert.equal(b.previous_handoff_id, a.handoff_id);
|
|
239
|
+
assert.ok(renderAutoHandoffMemory(db, b).includes('前のタスクの合意: 4イベントを申請する'), '次の後継にも渡る');
|
|
240
|
+
|
|
241
|
+
// 受け取った記憶が無い系列には、この節を出さない
|
|
242
|
+
const plain = snapshot(db, request(db, 'P').operation, 2);
|
|
243
|
+
assert.ok(!renderAutoHandoffMemory(db, plain).includes('受け取っていた記憶'));
|
|
244
|
+
}));
|
|
245
|
+
|
|
246
|
+
test('長すぎる受け取った記憶は先頭だけを運び、切った事を後継の記憶に書く', async () => withDb(db => {
|
|
247
|
+
const long = untrackedInheritedMemory('あ'.repeat(120_001));
|
|
248
|
+
assert.equal(Array.from(long.text).length, 120_000); assert.equal(long.chars, 120_001); assert.equal(long.truncated, true);
|
|
249
|
+
assert.deepEqual(untrackedInheritedMemory(null), { text: '', chars: 0, truncated: false });
|
|
250
|
+
const a = updateAutoHandoff(db, snapshot(db, request(db, 'A').operation, 1).handoff_id, { runtime_json: { inheritedMemory: long } }).operation;
|
|
251
|
+
const text = renderAutoHandoffMemory(db, a);
|
|
252
|
+
assert.ok(text.includes('(長いので先頭だけ載せています。元は 120001 字)'));
|
|
253
|
+
const empty = updateAutoHandoff(db, snapshot(db, request(db, 'E').operation, 1).handoff_id, { runtime_json: { inheritedMemory: untrackedInheritedMemory(null) } }).operation;
|
|
254
|
+
assert.ok(renderAutoHandoffMemory(db, empty).includes('(本文を読めませんでした)'));
|
|
255
|
+
}));
|
|
256
|
+
|
|
195
257
|
test('開始eventからユーザー入力の保存までの間を別入力の混入と判定しない', async () => withDb(async db => {
|
|
196
258
|
const op = request(db, 'gap-source', { now: 1 }).operation;
|
|
197
259
|
const counters = { created: 0, submitted: 0, notified: 0 };
|
|
@@ -584,6 +646,65 @@ test('名前の無い旧タスク(名前を付けない版が作った後継
|
|
|
584
646
|
assert.deepEqual(sent.at(-1), ['thread/name/set', { threadId: 'B', name: '【引き継ぎ済み】BellTeam|ベルチームの改良(自動引き継ぎ)' }]);
|
|
585
647
|
}));
|
|
586
648
|
|
|
649
|
+
test('旧タスクの順番待ちに残った入力を、順番を保って後継へ運び、旧タスクから消す', async () => withDb(async db => {
|
|
650
|
+
const operation = updateAutoHandoff(db, request(db, 'S').operation.handoff_id, { state: 'continued', target_thread_id: 'T' }).operation;
|
|
651
|
+
const queues = { S: [{ id: 'q1', input: [{ type: 'text', text: '1つ目' }], clientUserMessageId: 'c1' },
|
|
652
|
+
{ id: 'q2', input: [{ type: 'text', text: '2つ目' }], clientUserMessageId: 'c2' }], T: [{ id: 't0', input: [], clientUserMessageId: 'continuation' }] };
|
|
653
|
+
const calls = [];
|
|
654
|
+
const receiver = (fail = () => null) => async (profile, parent, action) => action(async (method, params) => {
|
|
655
|
+
calls.push(`${method}:${params.threadId}:${params.queuedSubmissionId ?? params.clientUserMessageId ?? ''}`);
|
|
656
|
+
const failure = fail(method, params);
|
|
657
|
+
if (failure) throw failure;
|
|
658
|
+
if (method === 'thread/queue/list') return { data: queues[params.threadId], nextCursor: null };
|
|
659
|
+
if (method === 'thread/queue/add') { queues[params.threadId].push({ id: `t-${params.clientUserMessageId}`, input: params.input, clientUserMessageId: params.clientUserMessageId }); return { queuedSubmission: {} }; }
|
|
660
|
+
if (method === 'thread/queue/delete') { queues[params.threadId] = queues[params.threadId].filter(item => item.id !== params.queuedSubmissionId); return {}; }
|
|
661
|
+
throw Error(method);
|
|
662
|
+
});
|
|
663
|
+
assert.deepEqual(await carryAutoHandoffQueue(db, operation, {}, { connect: receiver() }), { moved: 2, unknown: 0, left: 0 });
|
|
664
|
+
assert.deepEqual(calls, ['thread/queue/list:S:', 'thread/queue/add:T:c1', 'thread/queue/delete:S:q1', 'thread/queue/add:T:c2', 'thread/queue/delete:S:q2']);
|
|
665
|
+
assert.deepEqual(queues.T.map(item => item.clientUserMessageId), ['continuation', 'c1', 'c2'], '継続の指示の後ろへ、元の順番で並ぶ');
|
|
666
|
+
assert.deepEqual(queues.S, []);
|
|
667
|
+
assert.deepEqual(getAutoHandoff(db, operation.handoff_id).runtime.queueCarry, { moved: ['q1', 'q2'], unknown: [], moving: null });
|
|
668
|
+
assert.equal(getAutoHandoff(db, operation.handoff_id).state, 'continued');
|
|
669
|
+
|
|
670
|
+
// もう一度呼んでも、運んだ入力を入れ直さない
|
|
671
|
+
calls.length = 0;
|
|
672
|
+
assert.deepEqual(await carryAutoHandoffQueue(db, operation, {}, { connect: receiver() }), { moved: 2, unknown: 0, left: 0 });
|
|
673
|
+
assert.deepEqual(calls, ['thread/queue/list:S:']);
|
|
674
|
+
}));
|
|
675
|
+
|
|
676
|
+
test('後継へ入れた結果が分からない入力は入れ直さず、断られた入力は旧タスクに残す。どちらも引き継ぎは止めない', async () => withDb(async db => {
|
|
677
|
+
const make = name => updateAutoHandoff(db, request(db, name).operation.handoff_id, { state: 'continued', target_thread_id: `${name}-T` }).operation;
|
|
678
|
+
const queue = () => [{ id: 'q1', input: [], clientUserMessageId: 'c1' }, { id: 'q2', input: [], clientUserMessageId: 'c2' }];
|
|
679
|
+
const receiver = (items, added, fail) => async (profile, parent, action) => action(async (method, params) => {
|
|
680
|
+
if (method === 'thread/queue/list') return { data: items, nextCursor: null };
|
|
681
|
+
if (method === 'thread/queue/add') { const failure = fail(params); if (failure) throw failure; added.push(params.clientUserMessageId); return {}; }
|
|
682
|
+
if (method === 'thread/queue/delete') { items.splice(items.findIndex(item => item.id === params.queuedSubmissionId), 1); return {}; }
|
|
683
|
+
throw Error(method);
|
|
684
|
+
});
|
|
685
|
+
// 応答が途中で切れた(入ったかどうかが分からない)。そこで止め、次に呼ばれても入れ直さない
|
|
686
|
+
const lost = make('lost'); let items = queue(); let added = [];
|
|
687
|
+
const timeout = () => Object.assign(new Error('timeout'), { delivery_code: 'CODEX_RECEIVER_TIMEOUT' });
|
|
688
|
+
assert.deepEqual(await carryAutoHandoffQueue(db, lost, {}, { connect: receiver(items, added, params => params.clientUserMessageId === 'c1' ? timeout() : null) }),
|
|
689
|
+
{ moved: 0, unknown: 1, left: 0 });
|
|
690
|
+
assert.deepEqual(await carryAutoHandoffQueue(db, lost, {}, { connect: receiver(items, added, () => null) }), { moved: 1, unknown: 1, left: 0 });
|
|
691
|
+
assert.deepEqual(added, ['c2']); assert.deepEqual(items.map(item => item.id), ['q1'], '結果不明の入力は旧タスクに残る');
|
|
692
|
+
assert.equal(getAutoHandoff(db, lost.handoff_id).state, 'continued');
|
|
693
|
+
|
|
694
|
+
// 前の worker が、入れる途中で終わっていた
|
|
695
|
+
const crashed = make('crashed'); items = queue(); added = [];
|
|
696
|
+
updateAutoHandoff(db, crashed.handoff_id, { runtime_json: { ...(crashed.runtime ?? {}), queueCarry: { moved: [], unknown: [], moving: 'q1' } } });
|
|
697
|
+
assert.deepEqual(await carryAutoHandoffQueue(db, crashed, {}, { connect: receiver(items, added, () => null) }), { moved: 1, unknown: 1, left: 0 });
|
|
698
|
+
assert.deepEqual(added, ['c2']);
|
|
699
|
+
|
|
700
|
+
// はっきり断られた入力は後継に入っていない。旧タスクに残し、次の入力は運ぶ
|
|
701
|
+
const rejected = make('rejected'); items = queue(); added = [];
|
|
702
|
+
const reject = () => Object.assign(new Error('rejected'), { delivery_code: 'CODEX_RECEIVER_REJECTED' });
|
|
703
|
+
assert.deepEqual(await carryAutoHandoffQueue(db, rejected, {}, { connect: receiver(items, added, params => params.clientUserMessageId === 'c1' ? reject() : null) }),
|
|
704
|
+
{ moved: 1, unknown: 0, left: 1 });
|
|
705
|
+
assert.deepEqual(added, ['c2']); assert.deepEqual(items.map(item => item.id), ['q1']);
|
|
706
|
+
}));
|
|
707
|
+
|
|
587
708
|
// 元turn(止めた)の後に、旧タスクへ来た入力のturnが続くrollout。laterは後続turnの行。
|
|
588
709
|
function sourceRollout(home, thread, turn, later = [], stoppedAt = '2026-10-07T00:49:01.000Z') {
|
|
589
710
|
const dir = join(home, 'codex', 'sessions'); mkdirSync(dir, { recursive: true });
|
|
@@ -784,5 +905,6 @@ test('要約のbackendが使えない時、workerは固定の理由で止まり
|
|
|
784
905
|
assert.equal(result.state, 'failed'); assert.equal(result.error_code, expected); assert.equal(result.resume_state, 'source_stopped');
|
|
785
906
|
assert.equal(counters.created, 0); assert.equal(counters.submitted, 0); assert.equal(counters.notified, 1);
|
|
786
907
|
assert.equal(counters.marked, undefined, '途中で止まった引き継ぎの旧タスクには印を付けない');
|
|
908
|
+
assert.equal(counters.carried, undefined, '途中で止まった引き継ぎでは、順番待ちを動かさない');
|
|
787
909
|
}
|
|
788
910
|
}));
|
|
@@ -103,6 +103,8 @@ export function readCodexHandoffState(path, { threadId = null, turnId = null, de
|
|
|
103
103
|
const pendingCalls = new Map();
|
|
104
104
|
const nativeSessions = new Set();
|
|
105
105
|
let inheritedThroughlineMemory = false;
|
|
106
|
+
// 受け取った記憶の本文(最初の1つ)。前任をさかのぼれないタスクを引き継ぐ時に、そのまま後継へ渡す。
|
|
107
|
+
let inheritedThroughlineMemoryText = null;
|
|
106
108
|
let completedTools = 0;
|
|
107
109
|
// turnId を指定した時だけ、その turn より後に始まった turn を並べる。closed は止まった(turn_aborted)か完了したか、
|
|
108
110
|
// activity はモデルの発言・道具の呼び出し・圧縮・完了のどれかがあったか。入力を受けただけで止まった turn は activity が false。
|
|
@@ -147,7 +149,10 @@ export function readCodexHandoffState(path, { threadId = null, turnId = null, de
|
|
|
147
149
|
if (row.type === 'response_item') {
|
|
148
150
|
if (payload?.type === 'message' && payload.role === 'developer') {
|
|
149
151
|
const text = (payload.content ?? []).map(c => c.text ?? '').join('\n');
|
|
150
|
-
if (/^## Throughline: (?:自動継続文脈|Active Work Context|New Codex Thread Handoff)/m.test(text))
|
|
152
|
+
if (/^## Throughline: (?:自動継続文脈|Active Work Context|New Codex Thread Handoff)/m.test(text)) {
|
|
153
|
+
inheritedThroughlineMemory = true;
|
|
154
|
+
inheritedThroughlineMemoryText ??= text;
|
|
155
|
+
}
|
|
151
156
|
}
|
|
152
157
|
if (payload?.type === 'message' && payload.role === 'user') {
|
|
153
158
|
const text = (payload.content ?? []).filter(c => typeof c.text === 'string').map(c => c.text).join('\n');
|
|
@@ -212,6 +217,6 @@ export function readCodexHandoffState(path, { threadId = null, turnId = null, de
|
|
|
212
217
|
}
|
|
213
218
|
return { meta, settings, rawSettings, preparedSettings, context, latestTurnId, turnStartAt, stoppedAt, stoppedReason,
|
|
214
219
|
completedAt, correlatedTurnId, hasTurnInput, progress, completedTools, pendingCallCount: pendingCalls.size,
|
|
215
|
-
pendingNativeSessionCount: nativeSessions.size, inheritedThroughlineMemory, laterTurns,
|
|
220
|
+
pendingNativeSessionCount: nativeSessions.size, inheritedThroughlineMemory, inheritedThroughlineMemoryText, laterTurns,
|
|
216
221
|
compactedRows: rows.filter(r => r.type === 'compacted').length };
|
|
217
222
|
}
|