throughline 0.16.10 → 0.16.12
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 +47 -1
- package/README.ja.md +2 -2
- package/README.md +2 -2
- package/bin/throughline.mjs +3 -2
- package/docs/adr/0044-codex-handed-off-source-is-marked.md +8 -3
- package/docs/adr/0045-codex-handoff-carries-pending-queue.md +52 -0
- package/package.json +1 -1
- package/src/auto-handoff-title.mjs +15 -8
- package/src/auto-handoff-title.test.mjs +9 -0
- package/src/cli/auto-handoff.mjs +4 -3
- package/src/codex-auto-handoff.mjs +145 -16
- package/src/codex-auto-handoff.test.mjs +133 -15
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,50 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.16.12] — 2026-10-10
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- Codex Desktopの自動継続が、旧タスクの順番待ちに入力が残っていると`handoff_source_input_pending`で止まり、そのタスクでは何度入力しても同じ理由で止まり続けた。Windowsの実機で、2つのタスクに7回続き、どちらもそのまま使われなくなった。止めたタスクではCodexが順番待ちを動かさないので、順番待ちは空にならない。
|
|
18
|
+
- 引き継ぎを断らない。後継が作業を続けたのを確かめた後に、旧タスクの順番待ちの入力を、順番を保って後継の順番待ちへ運ぶ([ADR 0045](docs/adr/0045-codex-handoff-carries-pending-queue.md))。
|
|
19
|
+
- 二重に送らない。後継へ入れた結果が分からない入力は入れ直さず、旧タスクの順番待ちに残す。運べなくても引き継ぎは止めない。
|
|
20
|
+
- 0.16.11までで止まっていたタスクは、次の入力でやり直しが動き、引き継がれる。その時点で順番待ちに残っている入力は、全部後継へ運ばれる。
|
|
21
|
+
|
|
22
|
+
### 変わらない事
|
|
23
|
+
|
|
24
|
+
- 実行中の子agent、設定の不一致、追跡されていない旧handoff記憶で止まる扱い。引き継ぎ済みの旧タスクへ後から届く入力の扱い。Claude Codeの自動継続。
|
|
25
|
+
|
|
26
|
+
### 確認した範囲
|
|
27
|
+
|
|
28
|
+
- Linux(Codex Desktop同梱のCodex 0.162.0-alpha.2)の試験用フォルダで、本物のCodex Desktopに自動継続を2回続けて流した。最初のターンが動いている間に、順番待ちへ入力を1つ入れた。2回とも引き継ぎは`continued`になり、入力は後継から次の後継へ運ばれ、最後の後継で継続の指示のターンの後に1回だけ実行された。旧い2本のrolloutには出ていない。順番待ちは3本とも空になった。
|
|
29
|
+
- macOS(同梱のCodex 0.162.0-alpha.2)とWindowsの同梱のCodexで、試験タスク2本の間で順番待ちを運んだ。入力2つが、先に入っていた1つの後ろへ元の順番で並び、旧い方は空になった。もう一度呼んでも入れ直さなかった。この2つの端末では、引き継ぎの通しは流していない。
|
|
30
|
+
|
|
31
|
+
### 確認していない範囲
|
|
32
|
+
|
|
33
|
+
- 0.16.11までで止まっていた実機のタスクが、次の入力で引き継がれる所(利用者の入力が要る)。
|
|
34
|
+
- 後継へ入れる応答が本当に切れた時の動き(試験では作り物の応答で確かめた)。
|
|
35
|
+
|
|
36
|
+
## [0.16.11] — 2026-10-09
|
|
37
|
+
|
|
38
|
+
### 修正
|
|
39
|
+
|
|
40
|
+
- `throughline auto-handoff mark-sources`が、名前の無いタスクを残していた。後継へ名前を付けない版(0.15より前)が作った後継は、継続の指示(`Throughline自動継続 <ID>…`)が題として見える。0.16.10は、この題のタスクへ印を付けず、名前も付けなかった。macOSの実機では、一覧に名前の無いタスクが12本(今の続き6本、旧タスク6本)残った。
|
|
41
|
+
- 古い引き継ぎから順に、名前の無い後継へ`<project名>|<作業の概要>(自動引き継ぎ)`の名前を付けてから、旧タスクへ印を付ける。利用者が名前を付けたタスクは、名前を変えない。
|
|
42
|
+
- アーカイブ済みのタスクは触らない(Codexが名前の変更を断る)。0.16.10は失敗として数えていた。
|
|
43
|
+
- 名前の無い後継がさらに引き継いだ時、旧タスクへ印が付かなかった。その後継を作った引き継ぎから名前を作り直し、印を付ける。
|
|
44
|
+
|
|
45
|
+
### 追加
|
|
46
|
+
|
|
47
|
+
- `throughline auto-handoff mark-sources --dry-run`。名前を書かず、付ける名前の一覧(`changes`)だけを返す。
|
|
48
|
+
|
|
49
|
+
### 確認した範囲
|
|
50
|
+
|
|
51
|
+
- macOS(Codex Desktop同梱のCodex 0.162.0-alpha.2)の実機の引き継ぎ29件(14系列)へ流した。`--dry-run`で付ける名前を確かめてから実行し、名前の無い後継12本へ名前、旧タスク6本へ印が付いた(0.16.10で付けた19本と合わせて、一覧に在る旧タスクは全部印あり)。失敗は0、もう一度流すと変更は0。Codexの`thread/list`で、系列ごとに印の無いタスクが今の続きの1本だけになった事を確かめた。
|
|
52
|
+
|
|
53
|
+
### 確認していない範囲
|
|
54
|
+
|
|
55
|
+
- 付け直した名前が、Codex Desktopの一覧の表示へいつ出るか(画面は見ていない)。
|
|
56
|
+
|
|
13
57
|
## [0.16.10] — 2026-10-09
|
|
14
58
|
|
|
15
59
|
### 修正
|
|
@@ -2103,7 +2147,9 @@ two attempts, instrument first instead of patching again.
|
|
|
2103
2147
|
|
|
2104
2148
|
---
|
|
2105
2149
|
|
|
2106
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.
|
|
2150
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.12...HEAD
|
|
2151
|
+
[0.16.12]: https://github.com/kitepon/Throughline/compare/v0.16.11...v0.16.12
|
|
2152
|
+
[0.16.11]: https://github.com/kitepon/Throughline/compare/v0.16.10...v0.16.11
|
|
2107
2153
|
[0.16.10]: https://github.com/kitepon/Throughline/compare/v0.16.9...v0.16.10
|
|
2108
2154
|
[0.16.9]: https://github.com/kitepon/Throughline/compare/v0.16.8...v0.16.9
|
|
2109
2155
|
[0.16.8]: https://github.com/kitepon/Throughline/compare/v0.16.7...v0.16.8
|
package/README.ja.md
CHANGED
|
@@ -29,11 +29,11 @@ throughline auto-handoff disable
|
|
|
29
29
|
|
|
30
30
|
既定では無効です。`enable`は必要な`PreCompact(auto)`フックを登録し、そのフックの承認と有効状態を公式APIで確認します。`--project`を省略すると全projectを対象にします。macOS・Windows・LinuxのCodex Desktopで実機確認済みです(Windowsは0.16.6から、Linuxは0.16.7から)。Codex Desktopに同梱のCodexが0.162以上の時は、0.16.7以上が要ります。VS Code・CLIの自動継続は未検証です。
|
|
31
31
|
|
|
32
|
-
自動圧縮の開始前に旧ターンを停止し、同じprojectの新しいタスクへ記憶を注入して表示します。新しいタスクには、前任のタスクの題から`<project名>|<作業の概要>(自動引き継ぎ)`の名前を付けます。後継が作業を続けたのを確かめた後、旧タスクの名前の頭へ`【引き継ぎ済み】`を付けます(0.16.10から。それより前の版が引き継いだ分は`throughline auto-handoff mark-sources
|
|
32
|
+
自動圧縮の開始前に旧ターンを停止し、同じprojectの新しいタスクへ記憶を注入して表示します。新しいタスクには、前任のタスクの題から`<project名>|<作業の概要>(自動引き継ぎ)`の名前を付けます。後継が作業を続けたのを確かめた後、旧タスクの名前の頭へ`【引き継ぎ済み】`を付けます(0.16.10から。それより前の版が引き継いだ分は`throughline auto-handoff mark-sources`で付け直せます。名前の無い後継にも名前が付きます。`--dry-run`で、付ける名前だけを先に見られます)。一覧で、印の無い1本が今の続きです。元のモデル・推論強度・権限・モードなどが表示後も一致することを確認してから、継続指示を一度だけ送ります。後継でその入力と作業の進捗を観測して完了とします。手動`/compact`は発火対象に含めません。
|
|
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コマンド、追跡されていない旧handoff記憶では固定理由を残して停止します。旧タスクの順番待ちに残っている入力(まだ送られていない物)は、後継が作業を続けた後に、順番を保って後継の順番待ちへ運びます(0.16.12から。それより前の版は、ここで引き継ぎを止めていました)。失敗時はローカルの説明ページを開きます。新タスクだけで容量が尽きる場合も、新タスクを増やし続けません。結果不明の指示は自動で再送しません。
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
39
|
throughline auto-handoff status --operation <handoff-id> --json
|
package/README.md
CHANGED
|
@@ -28,11 +28,11 @@ throughline auto-handoff disable
|
|
|
28
28
|
|
|
29
29
|
既定では無効です。`enable`は必要な`PreCompact(auto)`フックを登録し、そのフックの承認と有効状態を公式APIで確認します。`--project`を省略すると全projectを対象にします。macOS・Windows・LinuxのCodex Desktopで実機確認済みです(Windowsは0.16.6から、Linuxは0.16.7から)。Codex Desktopに同梱のCodexが0.162以上の時は、0.16.7以上が要ります。VS Code・CLIの自動継続は未検証です。
|
|
30
30
|
|
|
31
|
-
自動圧縮の開始前に旧ターンを停止し、同じprojectの新しいタスクへ記憶を注入して表示します。新しいタスクには、前任のタスクの題から`<project名>|<作業の概要>(自動引き継ぎ)`の名前を付けます。後継が作業を続けたのを確かめた後、旧タスクの名前の頭へ`【引き継ぎ済み】`を付けます(0.16.10から。それより前の版が引き継いだ分は`throughline auto-handoff mark-sources
|
|
31
|
+
自動圧縮の開始前に旧ターンを停止し、同じprojectの新しいタスクへ記憶を注入して表示します。新しいタスクには、前任のタスクの題から`<project名>|<作業の概要>(自動引き継ぎ)`の名前を付けます。後継が作業を続けたのを確かめた後、旧タスクの名前の頭へ`【引き継ぎ済み】`を付けます(0.16.10から。それより前の版が引き継いだ分は`throughline auto-handoff mark-sources`で付け直せます。名前の無い後継にも名前が付きます。`--dry-run`で、付ける名前だけを先に見られます)。一覧で、印の無い1本が今の続きです。元のモデル・推論強度・権限・モードなどが表示後も一致することを確認してから、継続指示を一度だけ送ります。後継でその入力と作業の進捗を観測して完了とします。手動`/compact`は発火対象に含めません。
|
|
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コマンド、追跡されていない旧handoff記憶では固定理由を残して停止します。旧タスクの順番待ちに残っている入力(まだ送られていない物)は、後継が作業を続けた後に、順番を保って後継の順番待ちへ運びます(0.16.12から。それより前の版は、ここで引き継ぎを止めていました)。失敗時はローカルの説明ページを開きます。新タスクだけで容量が尽きる場合も、新タスクを増やし続けません。結果不明の指示は自動で再送しません。
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
38
|
throughline auto-handoff status --operation <handoff-id> --json
|
package/bin/throughline.mjs
CHANGED
|
@@ -412,8 +412,9 @@ Usage:
|
|
|
412
412
|
引き継ぎの状態と固定理由を確認
|
|
413
413
|
throughline auto-handoff resume --operation <id> --json
|
|
414
414
|
同じ引き継ぎを再開(結果不明は再送しない)
|
|
415
|
-
throughline auto-handoff mark-sources [--json]
|
|
416
|
-
|
|
415
|
+
throughline auto-handoff mark-sources [--dry-run] [--json]
|
|
416
|
+
今までの引き継ぎの名前を付け直す(旧タスクへ印、
|
|
417
|
+
名前の無い後継へ名前)
|
|
417
418
|
throughline auto-handoff detail --operation <id> --origin <session> --turn <n>
|
|
418
419
|
凍結したL2/L3を取得
|
|
419
420
|
throughline auto-handoff enable|disable|status --host claude [--project <path>] [--json]
|
|
@@ -35,9 +35,14 @@ Codex Desktop の自動継続は、後継のタスクに `<project 名>|<作
|
|
|
35
35
|
4. 途中で止まった引き継ぎ(`failed`・`unknown`)の旧タスクには付けない。作業はまだ旧タスクにある。
|
|
36
36
|
5. 印の付いた名前から後継の名前を作る時は、印を外す(やり直しで、印の付いた旧タスクの題を読む事がある)。
|
|
37
37
|
6. 印を付けない版(0.16.9 まで)が引き継いだ分は、`throughline auto-handoff mark-sources` で付け直す。
|
|
38
|
-
`continued`
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
`continued` の引き継ぎを古い順に見て、生きている後継がある旧タスクだけに印を付ける。後継が消されている旧タスクは、
|
|
39
|
+
今の続きなので付けない。アーカイブ済みのタスクは触らない。何度流しても同じ結果になる。`--dry-run` は名前を書かず、
|
|
40
|
+
付ける名前だけを返す。
|
|
41
|
+
7. 付け直しでは、名前の無い後継へ後継の名前(ADR 0037 の形)も付ける(0.16.11)。後継へ名前を付けない版(0.15 より前)が
|
|
42
|
+
作った後継は、継続の指示が題として見える。macOS の実機では、こういうタスクが一覧に12本あった。古い引き継ぎから順に
|
|
43
|
+
付けるので、後ろの後継の名前は、前の引き継ぎで付けた名前から作られる。利用者が名前を付けたタスクは変えない。
|
|
44
|
+
8. 名前の無い後継がさらに引き継いだ時は、その後継を作った引き継ぎから名前を作り直して、印を付ける。
|
|
45
|
+
9. 引き継ぎ済みの旧タスクへ入力した時の扱い(止めて後継を開く。ADR 0039)は変えない。
|
|
41
46
|
|
|
42
47
|
## Consequences
|
|
43
48
|
|
|
@@ -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`)で読む。
|
package/package.json
CHANGED
|
@@ -54,18 +54,25 @@ function withoutDonePrefix(text) {
|
|
|
54
54
|
return rest;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** 名前として使える1行を取る。空の値と、題として見えている継続の指示は null。 */
|
|
58
|
+
export function usableAutoHandoffTitle(title) {
|
|
59
|
+
if (typeof title !== 'string') return null;
|
|
60
|
+
const firstLine = title.split(/\r?\n/).map(oneLine).find(Boolean) ?? '';
|
|
61
|
+
return !firstLine || CONTINUATION_INPUT_PATTERN.test(firstLine) ? null : firstLine;
|
|
62
|
+
}
|
|
63
|
+
|
|
57
64
|
/**
|
|
58
65
|
* 引き継ぎが済んだ旧タスクの名前を作る。元の名前の頭に印を付けるだけで、元の名前は残す。
|
|
59
|
-
* 一覧で、印の無い1
|
|
66
|
+
* 一覧で、印の無い1本が今の続きだと読めるようにする。もう印が付いている名前は null(付け直さない)。
|
|
60
67
|
* @param {string|null|undefined} title 旧タスクの今の名前。名前が無いタスクでは、最初の依頼の文
|
|
68
|
+
* @param {string|null} [fallback] title が名前にならない時(名前を付けない版が作った後継。継続の指示が題として見える)に使う名前
|
|
61
69
|
*/
|
|
62
|
-
export function markHandedOffTitle(title) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return AUTO_HANDOFF_DONE_PREFIX + clip(firstLine, SUMMARY_MAX_CHARS + 20);
|
|
70
|
+
export function markHandedOffTitle(title, fallback = null) {
|
|
71
|
+
const firstLine = typeof title === 'string' ? title.split(/\r?\n/).map(oneLine).find(Boolean) ?? '' : '';
|
|
72
|
+
if (firstLine.startsWith(AUTO_HANDOFF_DONE_PREFIX)) return null;
|
|
73
|
+
const name = usableAutoHandoffTitle(title) ?? usableAutoHandoffTitle(fallback);
|
|
74
|
+
if (!name || name.startsWith(AUTO_HANDOFF_DONE_PREFIX)) return null;
|
|
75
|
+
return AUTO_HANDOFF_DONE_PREFIX + clip(name, SUMMARY_MAX_CHARS + 20);
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
/**
|
|
@@ -60,6 +60,15 @@ test('引き継ぎ済みの印は名前の頭に付け、元の名前を残す
|
|
|
60
60
|
}
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
test('名前にならない題(空・継続の指示)の旧タスクは、渡された名前へ印を付ける', () => {
|
|
64
|
+
const instruction = 'Throughline自動継続 39cdb98e-9e68-4dff-8a53-38d1fd861a8b';
|
|
65
|
+
assert.equal(markHandedOffTitle(instruction, 'BellTeam|ベルチーム(自動引き継ぎ)'), '【引き継ぎ済み】BellTeam|ベルチーム(自動引き継ぎ)');
|
|
66
|
+
assert.equal(markHandedOffTitle(null, 'BellTeam(自動引き継ぎ)'), '【引き継ぎ済み】BellTeam(自動引き継ぎ)');
|
|
67
|
+
assert.equal(markHandedOffTitle('利用者の題', 'BellTeam(自動引き継ぎ)'), '【引き継ぎ済み】利用者の題', '名前がある時は、今の名前を使う');
|
|
68
|
+
assert.equal(markHandedOffTitle('【引き継ぎ済み】x', 'y'), null);
|
|
69
|
+
assert.equal(markHandedOffTitle(instruction, instruction), null);
|
|
70
|
+
});
|
|
71
|
+
|
|
63
72
|
test('印の付いた旧タスクの名前から概要を取る時は、印を外す', () => {
|
|
64
73
|
assert.equal(autoHandoffSummaryOf('【引き継ぎ済み】BellTeam|ベルチーム(自動引き継ぎ)'), 'ベルチーム');
|
|
65
74
|
assert.equal(autoHandoffSummaryOf('【引き継ぎ済み】ベルチーム'), 'ベルチーム');
|
package/src/cli/auto-handoff.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { listClaudeAutoHandoffs, publicClaudeAutoHandoff, runClaudeAutoHandoffWo
|
|
|
17
17
|
const CLAUDE_ACTIONS = ['enable', 'disable', 'status', 'worker', 'desktop-open'];
|
|
18
18
|
|
|
19
19
|
export function parseAutoHandoffArgs(args) {
|
|
20
|
-
const out = { action: args[0] ?? 'status', host: 'codex', operation: null, project: null, origin: null, turn: null, database: null, json: false };
|
|
20
|
+
const out = { action: args[0] ?? 'status', host: 'codex', operation: null, project: null, origin: null, turn: null, database: null, json: false, dryRun: false };
|
|
21
21
|
if (!['enable', 'disable', 'status', 'resume', 'detail', 'worker', 'desktop-open', 'mark-sources'].includes(out.action)) throw Error('auto_handoff_action_invalid');
|
|
22
22
|
const allowed = ['host', ...{ enable: ['project'], disable: [], status: ['project', 'operation'],
|
|
23
23
|
resume: ['operation'], worker: ['operation'], 'desktop-open': ['operation'], 'mark-sources': [], detail: ['operation', 'origin', 'turn', 'database'] }[out.action]];
|
|
@@ -26,6 +26,7 @@ export function parseAutoHandoffArgs(args) {
|
|
|
26
26
|
if (seen.has(args[i])) throw Error('auto_handoff_argument_invalid');
|
|
27
27
|
seen.add(args[i]);
|
|
28
28
|
if (args[i] === '--json') { out.json = true; continue; }
|
|
29
|
+
if (args[i] === '--dry-run' && out.action === 'mark-sources') { out.dryRun = true; continue; }
|
|
29
30
|
const key = { '--host': 'host', '--operation': 'operation', '--project': 'project', '--origin': 'origin', '--turn': 'turn', '--database': 'database' }[args[i]];
|
|
30
31
|
if (!key || !allowed.includes(key) || !args[i + 1] || args[i + 1].startsWith('--')) throw Error('auto_handoff_argument_invalid');
|
|
31
32
|
out[key] = args[++i];
|
|
@@ -128,9 +129,9 @@ export async function run(args = []) {
|
|
|
128
129
|
process.stdout.write(parsed.json ? JSON.stringify({ status: 'ok', text }) + '\n' : text + '\n');
|
|
129
130
|
return 0;
|
|
130
131
|
} else if (parsed.action === 'mark-sources') {
|
|
131
|
-
//
|
|
132
|
+
// 0.16.9 までの版が引き継いだ分の名前を付け直す(名前の無い後継へ名前、旧タスクへ引き継ぎ済みの印)。何度流しても同じ結果になる。
|
|
132
133
|
const db = getDb();
|
|
133
|
-
result = { status: 'ok', ...await markAutoHandoffSources(db) };
|
|
134
|
+
result = { status: 'ok', dry_run: parsed.dryRun, ...await markAutoHandoffSources(db, { dryRun: parsed.dryRun }) };
|
|
134
135
|
} else if (parsed.action === 'worker') {
|
|
135
136
|
result = publicOperation(await runAutoHandoffWorker(parsed.operation));
|
|
136
137
|
} else if (parsed.action === 'resume') {
|
|
@@ -12,7 +12,7 @@ import { getDb, DB_PATH } from './db.mjs';
|
|
|
12
12
|
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
|
-
import { getAutoHandoff, requestAutoHandoff, updateAutoHandoff, failAutoHandoff, listContinuedAutoHandoffs,
|
|
15
|
+
import { getAutoHandoff, requestAutoHandoff, updateAutoHandoff, failAutoHandoff, listContinuedAutoHandoffs, findAutoHandoffForTarget,
|
|
16
16
|
claimAutoHandoff, releaseAutoHandoff, freezeCodexMemory, continuationInput,
|
|
17
17
|
findAutoHandoffForTurn, listDeliveredAutoHandoffsForSource, listUndeliveredAutoHandoffsForSource,
|
|
18
18
|
HANDOFF_TERMINAL_STATES } from './codex-auto-handoff-store.mjs';
|
|
@@ -20,7 +20,7 @@ import { ensureAutoHandoffSummaries, renderAutoHandoffMemory } from './codex-aut
|
|
|
20
20
|
import { CODEX_NATIVE_ID_PATTERN, CodexHandoffError, readCodexHandoffState,
|
|
21
21
|
settingsMatch, threadStartSettings } from './hosts/codex-handoff-state.mjs';
|
|
22
22
|
import { resolvePreparedSettings } from './hosts/codex-handoff-state.mjs';
|
|
23
|
-
import { composeAutoHandoffTitle, markHandedOffTitle } from './auto-handoff-title.mjs';
|
|
23
|
+
import { composeAutoHandoffTitle, markHandedOffTitle, usableAutoHandoffTitle } from './auto-handoff-title.mjs';
|
|
24
24
|
|
|
25
25
|
const CLI_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '../bin/throughline.mjs');
|
|
26
26
|
const stateRoot = () => join(homedir(), '.throughline', 'codex-auto-handoff');
|
|
@@ -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');
|
|
@@ -287,14 +289,15 @@ export async function nameAutoHandoffTarget(request, targetId, name) {
|
|
|
287
289
|
* 引き継ぎが済んだ旧タスクの名前の頭へ、引き継ぎ済みの印を付ける。同じ名前の後継が一覧に並んでも、
|
|
288
290
|
* 印の無い1本が今の続きだと分かるようにする。今の名前を読んでから付けるので、利用者が付け直した名前も残る。
|
|
289
291
|
* 名前は表示だけに使うので、付けられなくても引き継ぎは止めない(理由は worker のログに残す)。
|
|
292
|
+
* @param {{fallbackTitle?: string|null}} [options] 旧タスクに名前が無い時に使う名前(名前を付けない版が作った後継)
|
|
290
293
|
* @returns {Promise<'marked'|'kept'|'failed'>} kept はもう印がある・名前にできる値が無い
|
|
291
294
|
*/
|
|
292
|
-
export async function markAutoHandoffSource(operation, runtime, { connect = withCodexReceiver } = {}) {
|
|
295
|
+
export async function markAutoHandoffSource(operation, runtime, { connect = withCodexReceiver, fallbackTitle = null } = {}) {
|
|
293
296
|
try {
|
|
294
297
|
return await connect(autoHandoffDeliveryProfile, { thread_id: operation.source_thread_id, codex_home: operation.codex_home },
|
|
295
298
|
async request => {
|
|
296
299
|
const read = await request('thread/read', { threadId: operation.source_thread_id, includeTurns: false });
|
|
297
|
-
const name = markHandedOffTitle(read.thread?.name ?? read.thread?.preview ?? null);
|
|
300
|
+
const name = markHandedOffTitle(read.thread?.name ?? read.thread?.preview ?? null, fallbackTitle);
|
|
298
301
|
if (!name) return 'kept';
|
|
299
302
|
await request('thread/name/set', { threadId: operation.source_thread_id, name });
|
|
300
303
|
return 'marked';
|
|
@@ -306,19 +309,142 @@ export async function markAutoHandoffSource(operation, runtime, { connect = with
|
|
|
306
309
|
}
|
|
307
310
|
|
|
308
311
|
/**
|
|
309
|
-
*
|
|
310
|
-
*
|
|
312
|
+
* 止めた時に旧タスクの順番待ちに残っていた入力(まだ送られていない)を、順番を保って後継の順番待ちへ運ぶ。
|
|
313
|
+
* 後継へ入れてから旧タスクの順番待ちから消す。後継へ入れた結果が分からない入力は、二重に送らないために
|
|
314
|
+
* 入れ直さず、旧タスクの順番待ちに残す。運べなくても引き継ぎは止めない(後継はもう作業を続けている)。
|
|
315
|
+
* @returns {Promise<{moved: number, unknown: number, left: number}>} left は、断られて旧タスクに残った数
|
|
311
316
|
*/
|
|
312
|
-
export async function
|
|
313
|
-
|
|
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
|
+
export function autoHandoffSourceFallbackTitle(db, operation) {
|
|
366
|
+
const creator = findAutoHandoffForTarget(db, operation.source_thread_id);
|
|
367
|
+
return creator ? autoHandoffTargetName(db, creator) : null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function listArchivedThreadIds(request) {
|
|
371
|
+
const ids = new Set();
|
|
372
|
+
for (let cursor = null, page = 0; page < 200; page++) {
|
|
373
|
+
const listed = await request('thread/list', { archived: true, limit: 100, cursor });
|
|
374
|
+
for (const thread of listed.data ?? []) ids.add(thread.id);
|
|
375
|
+
cursor = listed.nextCursor ?? null;
|
|
376
|
+
if (!cursor) break;
|
|
377
|
+
}
|
|
378
|
+
return ids;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* 今までに済んだ引き継ぎへ、まとめて名前を付け直す(0.16.9 までの版が引き継いだ分)。古い引き継ぎから順に、
|
|
383
|
+
* (1) 名前の無い後継(名前を付けない版が作った物。継続の指示が題として見える)へ、後継の名前を付ける。
|
|
384
|
+
* (2) 生きている後継がある旧タスクの名前の頭へ、引き継ぎ済みの印を付ける。
|
|
385
|
+
* アーカイブ済みのタスクは触らない(Codex が名前の変更を断る。一覧にも出ない)。後継が消されている旧タスクは、
|
|
386
|
+
* 今の続きなので印を付けない。何度流しても同じ結果になる。dryRun は名前を書かず、付ける名前だけを返す。
|
|
387
|
+
* @returns {Promise<{marked: number, named: number, kept: number, failed: number, skipped: number, changes: Array<{thread_id: string, name: string}>}>}
|
|
388
|
+
*/
|
|
389
|
+
export async function markAutoHandoffSources(db, { executable = platformDesktopFinder()(), connect = withCodexReceiver,
|
|
390
|
+
findThread = findCodexThreadCandidate, dryRun = false } = {}) {
|
|
314
391
|
const runtime = { executable, timeout_ms: 30_000 };
|
|
315
|
-
const result = { marked: 0, kept: 0, failed: 0, skipped: 0 };
|
|
316
|
-
const
|
|
392
|
+
const result = { marked: 0, named: 0, kept: 0, failed: 0, skipped: 0, changes: [] };
|
|
393
|
+
const byHome = new Map();
|
|
317
394
|
for (const operation of listContinuedAutoHandoffs(db)) {
|
|
318
|
-
if (
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
395
|
+
if (!byHome.has(operation.codex_home)) byHome.set(operation.codex_home, []);
|
|
396
|
+
byHome.get(operation.codex_home).push(operation);
|
|
397
|
+
}
|
|
398
|
+
for (const [codexHome, operations] of byHome) {
|
|
399
|
+
await connect(autoHandoffDeliveryProfile, { codex_home: codexHome }, async request => {
|
|
400
|
+
const archived = await listArchivedThreadIds(request);
|
|
401
|
+
// この回で読んだ名前と、付けた名前。後ろの引き継ぎは、前の引き継ぎで付けた名前から作る。
|
|
402
|
+
const titles = new Map();
|
|
403
|
+
const read = async threadId => {
|
|
404
|
+
if (!titles.has(threadId)) {
|
|
405
|
+
const thread = (await request('thread/read', { threadId, includeTurns: false })).thread;
|
|
406
|
+
titles.set(threadId, { name: usableAutoHandoffTitle(thread?.name), preview: usableAutoHandoffTitle(thread?.preview) });
|
|
407
|
+
}
|
|
408
|
+
return titles.get(threadId);
|
|
409
|
+
};
|
|
410
|
+
const rename = async (threadId, name) => {
|
|
411
|
+
if (!dryRun) await request('thread/name/set', { threadId, name });
|
|
412
|
+
titles.set(threadId, { ...titles.get(threadId), name });
|
|
413
|
+
result.changes.push({ thread_id: threadId, name });
|
|
414
|
+
};
|
|
415
|
+
const failed = cause => {
|
|
416
|
+
result.failed++;
|
|
417
|
+
process.stderr.write(`[auto-handoff] mark-sources failed: ${cause?.delivery_code ?? cause?.code ?? 'unknown'}\n`);
|
|
418
|
+
};
|
|
419
|
+
const marked = new Set();
|
|
420
|
+
for (const operation of operations) {
|
|
421
|
+
const { source_thread_id: sourceId, target_thread_id: targetId } = operation;
|
|
422
|
+
let source = null;
|
|
423
|
+
if (!archived.has(sourceId)) {
|
|
424
|
+
try { source = await read(sourceId); } catch { /* 消された旧タスク。名前は読めない */ }
|
|
425
|
+
}
|
|
426
|
+
let target = null;
|
|
427
|
+
if (!archived.has(targetId)) {
|
|
428
|
+
try { target = await read(targetId); } catch { /* 消された後継。付ける相手が無い */ }
|
|
429
|
+
}
|
|
430
|
+
if (target && !target.name) {
|
|
431
|
+
try {
|
|
432
|
+
await rename(targetId, composeAutoHandoffTitle({ projectPath: operation.project_path,
|
|
433
|
+
titles: [source?.name, source?.preview] }));
|
|
434
|
+
result.named++;
|
|
435
|
+
} catch (cause) { failed(cause); }
|
|
436
|
+
}
|
|
437
|
+
if (marked.has(sourceId)) continue;
|
|
438
|
+
marked.add(sourceId);
|
|
439
|
+
if (!source || !findLiveAutoHandoffSuccessor(db, sourceId, { findThread })) { result.skipped++; continue; }
|
|
440
|
+
try {
|
|
441
|
+
const name = markHandedOffTitle(source.name ?? source.preview);
|
|
442
|
+
if (!name) { result.kept++; continue; }
|
|
443
|
+
await rename(sourceId, name);
|
|
444
|
+
result.marked++;
|
|
445
|
+
} catch (cause) { failed(cause); }
|
|
446
|
+
}
|
|
447
|
+
}, runtime);
|
|
322
448
|
}
|
|
323
449
|
return result;
|
|
324
450
|
}
|
|
@@ -396,6 +522,7 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
|
|
|
396
522
|
const deps = { processes: readRuntimeProcesses, readSource: sourceState,
|
|
397
523
|
nativeState: sourceNativeState, capture: captureCodexRolloutToDb, summaries: ensureAutoHandoffSummaries,
|
|
398
524
|
createTarget, readTarget: targetState, openTarget, verify: verifyCodexParent, markSource: markAutoHandoffSource,
|
|
525
|
+
carryQueue: carryAutoHandoffQueue,
|
|
399
526
|
submit: submitCodexParentAnswer, notify: showAutoHandoffFailure, wait: waitForHandoff,
|
|
400
527
|
executable: () => platformDesktopFinder()(), liveSuccessor: findLiveAutoHandoffSuccessor, ...dependencies };
|
|
401
528
|
let operation = getAutoHandoff(db, id);
|
|
@@ -510,8 +637,10 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
|
|
|
510
637
|
return target.correlatedTurnId && target.progress ? target : null;
|
|
511
638
|
}, { timeoutMs: 180_000, timeoutCode: 'handoff_target_progress_unconfirmed' });
|
|
512
639
|
update({ state: 'continued', started_turn_id: target.correlatedTurnId, resume_state: null, error_code: null });
|
|
640
|
+
// 旧タスクの順番待ちに残った入力を後継へ運ぶ。後継の最初のターン(継続の指示)が始まった後なので、順番は変わらない。
|
|
641
|
+
await deps.carryQueue(db, operation, runtime);
|
|
513
642
|
// 後継が作業を続けたのを確かめてから付ける。途中で止まった引き継ぎの旧タスクには付けない。
|
|
514
|
-
await deps.markSource(operation, runtime);
|
|
643
|
+
await deps.markSource(operation, runtime, { fallbackTitle: autoHandoffSourceFallbackTitle(db, operation) });
|
|
515
644
|
}
|
|
516
645
|
return operation;
|
|
517
646
|
} catch (cause) {
|
|
@@ -14,7 +14,7 @@ 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 } from './codex-auto-handoff.mjs';
|
|
17
|
+
markAutoHandoffSource, markAutoHandoffSources, autoHandoffSourceFallbackTitle, carryAutoHandoffQueue } from './codex-auto-handoff.mjs';
|
|
18
18
|
import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
|
|
19
19
|
|
|
20
20
|
async function withDb(fn) {
|
|
@@ -116,7 +116,12 @@ function workerDependencies(db, settings, submit, counters) {
|
|
|
116
116
|
readTarget: operation => ({ settings, latestTurnId: ['submitted', 'unknown'].includes(operation.state) ? 'new-turn' : null,
|
|
117
117
|
correlatedTurnId: ['submitted', 'unknown'].includes(operation.state) ? 'new-turn' : null, progress: true }),
|
|
118
118
|
openTarget: () => {}, verify: async () => {}, submit,
|
|
119
|
-
|
|
119
|
+
carryQueue: async (db, operation) => { (counters.carried ??= []).push(`${operation.source_thread_id}:${operation.state}`); return { moved: 0, unknown: 0, left: 0 }; },
|
|
120
|
+
markSource: async (operation, runtime, options) => {
|
|
121
|
+
(counters.marked ??= []).push(`${operation.source_thread_id}:${operation.state}`);
|
|
122
|
+
assert.ok('fallbackTitle' in options);
|
|
123
|
+
return 'marked';
|
|
124
|
+
},
|
|
120
125
|
notify: () => { counters.notified++; },
|
|
121
126
|
wait: async check => { const result = await check(); if (!result) throw Error('観測できません'); return result; },
|
|
122
127
|
};
|
|
@@ -131,6 +136,7 @@ test('停止→記憶→作成→配送→進捗を確認し、重複workerは
|
|
|
131
136
|
await runAutoHandoffWorker(operation.handoff_id, { db, dependencies: deps });
|
|
132
137
|
assert.equal(counters.created, 1); assert.equal(counters.submitted, 1); assert.equal(counters.notified, 0);
|
|
133
138
|
assert.deepEqual(counters.marked, ['worker-source:continued'], '後継が作業を続けたのを確かめた後に、旧タスクへ印を1回だけ付ける');
|
|
139
|
+
assert.deepEqual(counters.carried, ['worker-source:continued'], '後継が作業を続けたのを確かめた後に、順番待ちを1回だけ運ぶ');
|
|
134
140
|
}));
|
|
135
141
|
|
|
136
142
|
test('配送結果不明は再送せず、実際の入力・開始を観測して同じ後継から回復する', async () => withDb(async db => {
|
|
@@ -173,8 +179,8 @@ test('作成応答を失った時は結果不明を保存し、再開でも後
|
|
|
173
179
|
assert.equal(counters.created, 1); assert.equal(counters.submitted, 0);
|
|
174
180
|
}));
|
|
175
181
|
|
|
176
|
-
test('
|
|
177
|
-
for (const code of ['
|
|
182
|
+
test('実行中の子・出所不明の継承記憶では後継を作らない', async () => withDb(async db => {
|
|
183
|
+
for (const code of ['handoff_native_child_pending', 'handoff_memory_lineage_untracked']) {
|
|
178
184
|
const op = request(db, code, { now: 1 }).operation;
|
|
179
185
|
const counters = { created: 0, submitted: 0, notified: 0 };
|
|
180
186
|
const deps = workerDependencies(db, {}, async () => { counters.submitted++; }, counters);
|
|
@@ -513,19 +519,130 @@ test('印を付けた旧タスクから作る後継の名前には、印を持
|
|
|
513
519
|
assert.equal(autoHandoffTargetName(db, operation), 'BellTeam|ベルチーム(自動引き継ぎ)');
|
|
514
520
|
}));
|
|
515
521
|
|
|
516
|
-
test('
|
|
517
|
-
const done = (source, target, fields = {}) => updateAutoHandoff(db, request(db, source).operation.handoff_id,
|
|
522
|
+
test('今までの引き継ぎへまとめて名前を付け直す。名前の無い後継へ名前を付け、生きている後継がある旧タスクへ印を付ける', async () => withDb(async db => {
|
|
523
|
+
const done = (source, target, fields = {}) => updateAutoHandoff(db, request(db, source, { projectPath: '/Users/kite/Developer/BellTeam' }).operation.handoff_id,
|
|
518
524
|
{ state: 'continued', target_thread_id: target, queued_submission_id: `receipt-${target}`, ...fields }).operation;
|
|
519
|
-
|
|
520
|
-
done('B', 'C');
|
|
521
|
-
done('X', 'gone');
|
|
525
|
+
// A(利用者の題)→ B(名前を付けない版が作った。継続の指示が題に見える)→ C(同じ)。X の後継は消えた。Y は旧タスクがアーカイブ済み。
|
|
526
|
+
const ab = done('A', 'B'); const bc = done('B', 'C');
|
|
527
|
+
done('X', 'gone'); done('Y', 'Y2');
|
|
522
528
|
updateAutoHandoff(db, request(db, 'F').operation.handoff_id, { state: 'failed', target_thread_id: 'F2', error_code: 'handoff_target_not_loaded' });
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
+
const threads = new Map([
|
|
530
|
+
['A', { name: 'ベルチームの改良', preview: '最初の依頼' }],
|
|
531
|
+
['B', { name: null, preview: continuationInput(ab) }],
|
|
532
|
+
['C', { name: null, preview: continuationInput(bc) }],
|
|
533
|
+
['X', { name: 'Xの題', preview: 'x' }],
|
|
534
|
+
['Y', { name: 'Yの題', preview: 'y' }], ['Y2', { name: '利用者が付けた名前', preview: 'y2' }],
|
|
535
|
+
]);
|
|
536
|
+
const calls = [];
|
|
537
|
+
const connect = async (profile, parent, action) => action(async (method, params) => {
|
|
538
|
+
if (method === 'thread/list') { assert.equal(params.archived, true); return { data: [{ id: 'Y' }], nextCursor: null }; }
|
|
539
|
+
if (method === 'thread/read') {
|
|
540
|
+
if (!threads.has(params.threadId)) throw Object.assign(new Error('no rollout'), { delivery_code: 'CODEX_RECEIVER_REJECTED' });
|
|
541
|
+
return { thread: { id: params.threadId, ...threads.get(params.threadId) } };
|
|
542
|
+
}
|
|
543
|
+
assert.equal(method, 'thread/name/set');
|
|
544
|
+
assert.notEqual(params.threadId, 'Y', 'アーカイブ済みのタスクは触らない');
|
|
545
|
+
calls.push(`${params.threadId}=${params.name}`);
|
|
546
|
+
threads.set(params.threadId, { ...threads.get(params.threadId), name: params.name });
|
|
547
|
+
return {};
|
|
548
|
+
});
|
|
549
|
+
const options = { executable: 'fixture', connect,
|
|
550
|
+
findThread: ({ threadId }) => threadId === 'gone' ? null : { rolloutPath: `/r/${threadId}`, mtimeMs: 1 } };
|
|
551
|
+
// dryRun は名前を書かず、付ける名前だけを返す
|
|
552
|
+
const preview = await markAutoHandoffSources(db, { ...options, dryRun: true });
|
|
553
|
+
assert.deepEqual(calls, []);
|
|
554
|
+
assert.deepEqual(preview.changes.map(change => `${change.thread_id}=${change.name}`), [
|
|
555
|
+
'B=BellTeam|ベルチームの改良(自動引き継ぎ)', 'A=【引き継ぎ済み】ベルチームの改良',
|
|
556
|
+
'C=BellTeam|ベルチームの改良(自動引き継ぎ)', 'B=【引き継ぎ済み】BellTeam|ベルチームの改良(自動引き継ぎ)']);
|
|
557
|
+
const { changes, ...result } = await markAutoHandoffSources(db, options);
|
|
558
|
+
assert.equal(changes.length, 4);
|
|
559
|
+
assert.deepEqual(calls, [
|
|
560
|
+
'B=BellTeam|ベルチームの改良(自動引き継ぎ)', 'A=【引き継ぎ済み】ベルチームの改良',
|
|
561
|
+
'C=BellTeam|ベルチームの改良(自動引き継ぎ)', 'B=【引き継ぎ済み】BellTeam|ベルチームの改良(自動引き継ぎ)']);
|
|
562
|
+
// X は後継が消えているので付けない。Y はアーカイブ済みなので付けない。
|
|
563
|
+
assert.deepEqual(result, { marked: 2, named: 2, kept: 0, failed: 0, skipped: 2 });
|
|
564
|
+
assert.equal(threads.get('C').name, 'BellTeam|ベルチームの改良(自動引き継ぎ)', '印の無い1本が今の続き');
|
|
565
|
+
assert.equal(threads.get('Y2').name, '利用者が付けた名前');
|
|
566
|
+
|
|
567
|
+
// もう一度流しても、何も付け直さない
|
|
568
|
+
calls.length = 0;
|
|
569
|
+
assert.deepEqual(await markAutoHandoffSources(db, options), { marked: 0, named: 0, kept: 2, failed: 0, skipped: 2, changes: [] });
|
|
570
|
+
assert.deepEqual(calls, []);
|
|
571
|
+
}));
|
|
572
|
+
|
|
573
|
+
test('名前の無い旧タスク(名前を付けない版が作った後継)がさらに引き継いだ時は、作り直した後継の名前へ印を付ける', async () => withDb(async db => {
|
|
574
|
+
const first = updateAutoHandoff(db, request(db, 'A', { projectPath: '/Users/kite/Developer/BellTeam' }).operation.handoff_id,
|
|
575
|
+
{ state: 'continued', target_thread_id: 'B', queued_submission_id: 'receipt-B', runtime_json: { projectId: null, title: 'ベルチームの改良' } }).operation;
|
|
576
|
+
const second = request(db, 'B', { projectPath: '/Users/kite/Developer/BellTeam' }).operation;
|
|
577
|
+
assert.equal(autoHandoffSourceFallbackTitle(db, first), null, '最初の旧タスクは、どの引き継ぎの後継でもない');
|
|
578
|
+
const fallbackTitle = autoHandoffSourceFallbackTitle(db, second);
|
|
579
|
+
assert.equal(fallbackTitle, 'BellTeam|ベルチームの改良(自動引き継ぎ)');
|
|
580
|
+
const sent = [];
|
|
581
|
+
const connect = async (profile, parent, action) => action(async (method, params) => {
|
|
582
|
+
sent.push([method, params]);
|
|
583
|
+
return method === 'thread/read' ? { thread: { name: null, preview: continuationInput(first) } } : {};
|
|
584
|
+
});
|
|
585
|
+
assert.equal(await markAutoHandoffSource(second, {}, { connect, fallbackTitle }), 'marked');
|
|
586
|
+
assert.deepEqual(sent.at(-1), ['thread/name/set', { threadId: 'B', name: '【引き継ぎ済み】BellTeam|ベルチームの改良(自動引き継ぎ)' }]);
|
|
587
|
+
}));
|
|
588
|
+
|
|
589
|
+
test('旧タスクの順番待ちに残った入力を、順番を保って後継へ運び、旧タスクから消す', async () => withDb(async db => {
|
|
590
|
+
const operation = updateAutoHandoff(db, request(db, 'S').operation.handoff_id, { state: 'continued', target_thread_id: 'T' }).operation;
|
|
591
|
+
const queues = { S: [{ id: 'q1', input: [{ type: 'text', text: '1つ目' }], clientUserMessageId: 'c1' },
|
|
592
|
+
{ id: 'q2', input: [{ type: 'text', text: '2つ目' }], clientUserMessageId: 'c2' }], T: [{ id: 't0', input: [], clientUserMessageId: 'continuation' }] };
|
|
593
|
+
const calls = [];
|
|
594
|
+
const receiver = (fail = () => null) => async (profile, parent, action) => action(async (method, params) => {
|
|
595
|
+
calls.push(`${method}:${params.threadId}:${params.queuedSubmissionId ?? params.clientUserMessageId ?? ''}`);
|
|
596
|
+
const failure = fail(method, params);
|
|
597
|
+
if (failure) throw failure;
|
|
598
|
+
if (method === 'thread/queue/list') return { data: queues[params.threadId], nextCursor: null };
|
|
599
|
+
if (method === 'thread/queue/add') { queues[params.threadId].push({ id: `t-${params.clientUserMessageId}`, input: params.input, clientUserMessageId: params.clientUserMessageId }); return { queuedSubmission: {} }; }
|
|
600
|
+
if (method === 'thread/queue/delete') { queues[params.threadId] = queues[params.threadId].filter(item => item.id !== params.queuedSubmissionId); return {}; }
|
|
601
|
+
throw Error(method);
|
|
602
|
+
});
|
|
603
|
+
assert.deepEqual(await carryAutoHandoffQueue(db, operation, {}, { connect: receiver() }), { moved: 2, unknown: 0, left: 0 });
|
|
604
|
+
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']);
|
|
605
|
+
assert.deepEqual(queues.T.map(item => item.clientUserMessageId), ['continuation', 'c1', 'c2'], '継続の指示の後ろへ、元の順番で並ぶ');
|
|
606
|
+
assert.deepEqual(queues.S, []);
|
|
607
|
+
assert.deepEqual(getAutoHandoff(db, operation.handoff_id).runtime.queueCarry, { moved: ['q1', 'q2'], unknown: [], moving: null });
|
|
608
|
+
assert.equal(getAutoHandoff(db, operation.handoff_id).state, 'continued');
|
|
609
|
+
|
|
610
|
+
// もう一度呼んでも、運んだ入力を入れ直さない
|
|
611
|
+
calls.length = 0;
|
|
612
|
+
assert.deepEqual(await carryAutoHandoffQueue(db, operation, {}, { connect: receiver() }), { moved: 2, unknown: 0, left: 0 });
|
|
613
|
+
assert.deepEqual(calls, ['thread/queue/list:S:']);
|
|
614
|
+
}));
|
|
615
|
+
|
|
616
|
+
test('後継へ入れた結果が分からない入力は入れ直さず、断られた入力は旧タスクに残す。どちらも引き継ぎは止めない', async () => withDb(async db => {
|
|
617
|
+
const make = name => updateAutoHandoff(db, request(db, name).operation.handoff_id, { state: 'continued', target_thread_id: `${name}-T` }).operation;
|
|
618
|
+
const queue = () => [{ id: 'q1', input: [], clientUserMessageId: 'c1' }, { id: 'q2', input: [], clientUserMessageId: 'c2' }];
|
|
619
|
+
const receiver = (items, added, fail) => async (profile, parent, action) => action(async (method, params) => {
|
|
620
|
+
if (method === 'thread/queue/list') return { data: items, nextCursor: null };
|
|
621
|
+
if (method === 'thread/queue/add') { const failure = fail(params); if (failure) throw failure; added.push(params.clientUserMessageId); return {}; }
|
|
622
|
+
if (method === 'thread/queue/delete') { items.splice(items.findIndex(item => item.id === params.queuedSubmissionId), 1); return {}; }
|
|
623
|
+
throw Error(method);
|
|
624
|
+
});
|
|
625
|
+
// 応答が途中で切れた(入ったかどうかが分からない)。そこで止め、次に呼ばれても入れ直さない
|
|
626
|
+
const lost = make('lost'); let items = queue(); let added = [];
|
|
627
|
+
const timeout = () => Object.assign(new Error('timeout'), { delivery_code: 'CODEX_RECEIVER_TIMEOUT' });
|
|
628
|
+
assert.deepEqual(await carryAutoHandoffQueue(db, lost, {}, { connect: receiver(items, added, params => params.clientUserMessageId === 'c1' ? timeout() : null) }),
|
|
629
|
+
{ moved: 0, unknown: 1, left: 0 });
|
|
630
|
+
assert.deepEqual(await carryAutoHandoffQueue(db, lost, {}, { connect: receiver(items, added, () => null) }), { moved: 1, unknown: 1, left: 0 });
|
|
631
|
+
assert.deepEqual(added, ['c2']); assert.deepEqual(items.map(item => item.id), ['q1'], '結果不明の入力は旧タスクに残る');
|
|
632
|
+
assert.equal(getAutoHandoff(db, lost.handoff_id).state, 'continued');
|
|
633
|
+
|
|
634
|
+
// 前の worker が、入れる途中で終わっていた
|
|
635
|
+
const crashed = make('crashed'); items = queue(); added = [];
|
|
636
|
+
updateAutoHandoff(db, crashed.handoff_id, { runtime_json: { ...(crashed.runtime ?? {}), queueCarry: { moved: [], unknown: [], moving: 'q1' } } });
|
|
637
|
+
assert.deepEqual(await carryAutoHandoffQueue(db, crashed, {}, { connect: receiver(items, added, () => null) }), { moved: 1, unknown: 1, left: 0 });
|
|
638
|
+
assert.deepEqual(added, ['c2']);
|
|
639
|
+
|
|
640
|
+
// はっきり断られた入力は後継に入っていない。旧タスクに残し、次の入力は運ぶ
|
|
641
|
+
const rejected = make('rejected'); items = queue(); added = [];
|
|
642
|
+
const reject = () => Object.assign(new Error('rejected'), { delivery_code: 'CODEX_RECEIVER_REJECTED' });
|
|
643
|
+
assert.deepEqual(await carryAutoHandoffQueue(db, rejected, {}, { connect: receiver(items, added, params => params.clientUserMessageId === 'c1' ? reject() : null) }),
|
|
644
|
+
{ moved: 1, unknown: 0, left: 1 });
|
|
645
|
+
assert.deepEqual(added, ['c2']); assert.deepEqual(items.map(item => item.id), ['q1']);
|
|
529
646
|
}));
|
|
530
647
|
|
|
531
648
|
// 元turn(止めた)の後に、旧タスクへ来た入力のturnが続くrollout。laterは後続turnの行。
|
|
@@ -728,5 +845,6 @@ test('要約のbackendが使えない時、workerは固定の理由で止まり
|
|
|
728
845
|
assert.equal(result.state, 'failed'); assert.equal(result.error_code, expected); assert.equal(result.resume_state, 'source_stopped');
|
|
729
846
|
assert.equal(counters.created, 0); assert.equal(counters.submitted, 0); assert.equal(counters.notified, 1);
|
|
730
847
|
assert.equal(counters.marked, undefined, '途中で止まった引き継ぎの旧タスクには印を付けない');
|
|
848
|
+
assert.equal(counters.carried, undefined, '途中で止まった引き継ぎでは、順番待ちを動かさない');
|
|
731
849
|
}
|
|
732
850
|
}));
|