throughline 0.16.27 → 0.16.28
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 +38 -1
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/docs/adr/0059-codex-handoff-carries-thread-goal.md +96 -0
- package/package.json +1 -1
- package/src/cli/auto-handoff.mjs +9 -5
- package/src/cli/grok-continue.test.mjs +9 -4
- package/src/cli/runtime-errors.test.mjs +2 -3
- package/src/codex-auto-handoff.mjs +137 -5
- package/src/codex-auto-handoff.test.mjs +255 -3
- package/src/haiku-summarizer.test.mjs +8 -1
- package/src/hosts/codex-handoff-state.mjs +20 -0
- package/src/room-context.test.mjs +2 -1
- package/src/runtime-error-hook.test.mjs +3 -3
- package/src/runtime-error-report.test.mjs +3 -3
- package/src/runtime-error-store.test.mjs +3 -3
- package/src/spike-transcript-writer.test.mjs +3 -3
- package/src/test-temp-dir.mjs +24 -0
- package/src/transcript-reader.test.mjs +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,42 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.16.28] — 2026-10-14
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- **Codex Desktopで、ゴール(`/goal`)を持つタスクを引き継いだ後、旧タスクが止まらず、「タスク終了」の通知と後継への画面の切り替えが続いていた**([ADR 0059](docs/adr/0059-codex-handoff-carries-thread-goal.md))。Windowsの実機で2026-10-13に起きた。
|
|
18
|
+
- Codexは、タスクが手すきになるたびにゴールを読み、実行中なら自分で次のターンを起こす。旧タスクにゴールが実行中のまま残っていたので、止めるたびに次のターンが始まった。22:04から1〜2秒おきに2,067回。入力は全部Codex自身の続行の指示で、人の入力は無かった。Throughlineはそのたびにターンを止めて、後継を画面に開いていた。
|
|
19
|
+
- 後継にはゴールが運ばれていなかった。ゴールで動かしていた作業は、引き継ぎを境に、自分では続かなくなっていた。
|
|
20
|
+
- 直した後は、引き継ぎの最初に旧タスクのゴールを一時停止にし、後継が作業を続けたのを確かめた後で、ゴールを後継へ運ぶ。目標の文はそのまま、予算は使った分を引いた残りを渡す。動いていたゴールは後継でも動き、利用者が止めていたゴールは止めたまま移る。旧タスクのゴールは、一時停止のまま残す。
|
|
21
|
+
- 完了したゴールと、Codexが上限や行き詰まりで止めたゴールは運ばない。後継にもうゴールがあれば書き換えない。
|
|
22
|
+
- ゴールを読めない・書けない時も、引き継ぎは止めない。
|
|
23
|
+
- 引き継ぎが途中で止まった時は、旧タスクのゴールを実行中へ戻さない(戻すと、続行のターンと引き継ぎの失敗が繰り返される)。失敗の画面に、ゴールを止めてある事を書く。
|
|
24
|
+
- **引き継ぎ済みの旧タスクへ、人の入力ではないターンが来た時にも、後継を画面に開いていた**(同じADR)。ターンの入力がCodex自身の文脈だけの時は、止めて後継を示すが、画面は切り替えない。
|
|
25
|
+
- **0.16.27までの版が引き継いだ旧タスクに、ゴールが実行中のまま残っている時の片付け**(同じADR)。引き継ぎ済みの旧タスクへターンが来た時に、別のprocessで旧タスクのゴールを確かめる。実行中なら一時停止にし、今の後継にゴールが無ければ運ぶ。同じ旧タスクへは30秒に1回まで。片付けた事は`~/.throughline/codex-auto-handoff/goal-settles.jsonl`へ1行残す(時刻・ID・付けた状態。目標の文は書かない)。
|
|
26
|
+
|
|
27
|
+
### 変わる動き
|
|
28
|
+
|
|
29
|
+
- ゴールを持つタスクの引き継ぎで、旧タスクのゴールが「一時停止」になり、後継に同じゴールが付く。後継のゴールの使用量は0から数え直す。
|
|
30
|
+
- 後から片付けたゴール(上の3つめ)は、後継が手すきの時は、その場では動き出さない。後継の次のターンが終わった時からCodexが続ける。
|
|
31
|
+
- 旧タスクの画面のゴールの表示は、Codex Desktopがそのタスクを読み直すまで、古いまま見える事がある。
|
|
32
|
+
|
|
33
|
+
### 対応の範囲
|
|
34
|
+
|
|
35
|
+
| | macOS | Linux | Windows |
|
|
36
|
+
|---|---|---|---|
|
|
37
|
+
| Codex | 直した。単体試験と、実機で、直した版の関数(止める・運ぶ・片付ける)をCodex Desktop同梱のCodex(0.162.0-alpha.17.2)へ通した(25項目)。止めなければCodexが次のターンを起こし続ける事、止めた後は起きない事、後継がゴールを続けて完了する事まで | 直した。同じ確認を、codex-cli 0.162.1(コンテナ)で。本物のCodex Desktop(同梱の0.162.0-alpha.2)で、ゴールを持つタスクを自動圧縮の手前まで進める通しも流した(入っている0.16.27へ、直した3ファイルを一時だけ重ねた)。8回続けて引き継ぎ、毎回、旧タスクのゴールは一時停止・後継のゴールは実行中になり、最後の後継が作業を終えてゴールを完了した。引き継いだ後の旧タスクへ来たターンは0回(引き継ぎの最中に、1秒以内に4〜5回) | 直した。起きた端末。旧タスクのゴールを一時停止にして、連続が止まるのを見た。同じ確認を、Codex Desktop同梱のCodex(0.162.0-alpha.17.2)で(25項目)。起きた旧タスクの本物の記録へ、直した版のhookの入口を読むだけで当てた(止める・後継を開かない・片付けを起こす、の答え) |
|
|
38
|
+
| Claude Code | 変更なし(タスクごとのゴールが無い) | 同じ | 同じ |
|
|
39
|
+
| Grok | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
40
|
+
| Cursor | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
41
|
+
|
|
42
|
+
### 確かめていない事
|
|
43
|
+
|
|
44
|
+
- macOSとWindowsの本物のCodex Desktopの画面で、ゴールを持つタスクが自動圧縮の手前まで進んで引き継がれる通し。通したのはLinuxのCodex Desktopだけ。macOSとWindowsは、Desktop同梱のCodexへ関数を通す所までで、画面のタスクは使っていない。
|
|
45
|
+
- 0.16.27までの版が引き継いだ旧タスクの片付け(別のprocessを起こす所)を、本物のhookから通す事。関数と、起こす引数と、起きた旧タスクの記録に対するhookの答えは確かめた。Windowsの起きた旧タスクは、手で一時停止にしてあるので、もうこの道を通らない。
|
|
46
|
+
- Codex Desktopは、完了したゴールを自分で消す(Linuxの実機で、完了の直後に画面の側が`thread/goal/clear`を送った)。完了の前に画面の側がゴールを書き換える場面があるかは見ていない。
|
|
47
|
+
- ゴールの続行の指示(Codex自身の文脈)は、記憶では今までどおり利用者の発言として残る。この版では変えていない。
|
|
48
|
+
|
|
13
49
|
## [0.16.27] — 2026-10-13
|
|
14
50
|
|
|
15
51
|
### 修正
|
|
@@ -2522,7 +2558,8 @@ two attempts, instrument first instead of patching again.
|
|
|
2522
2558
|
|
|
2523
2559
|
---
|
|
2524
2560
|
|
|
2525
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.
|
|
2561
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.28...HEAD
|
|
2562
|
+
[0.16.28]: https://github.com/kitepon/Throughline/compare/v0.16.27...v0.16.28
|
|
2526
2563
|
[0.16.27]: https://github.com/kitepon/Throughline/compare/v0.16.26...v0.16.27
|
|
2527
2564
|
[0.16.26]: https://github.com/kitepon/Throughline/compare/v0.16.25...v0.16.26
|
|
2528
2565
|
[0.16.25]: https://github.com/kitepon/Throughline/compare/v0.16.24...v0.16.25
|
package/README.ja.md
CHANGED
|
@@ -29,7 +29,7 @@ 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`で付け直せます。名前の無い後継にも名前が付きます。`--dry-run`で、付ける名前だけを先に見られます)。一覧で、印の無い1本が今の続きです。元のモデル・推論強度・権限・モードなどが表示後も一致することを確認してから、継続指示を一度だけ送ります。後継でその入力と作業の進捗を観測して完了とします。手動`/compact
|
|
32
|
+
自動圧縮の開始前に旧ターンを停止し、同じprojectの新しいタスクへ記憶を注入して表示します。新しいタスクには、前任のタスクの題から`<project名>|<作業の概要>(自動引き継ぎ)`の名前を付けます。後継が作業を続けたのを確かめた後、旧タスクの名前の頭へ`【引き継ぎ済み】`を付けます(0.16.10から。それより前の版が引き継いだ分は`throughline auto-handoff mark-sources`で付け直せます。名前の無い後継にも名前が付きます。`--dry-run`で、付ける名前だけを先に見られます)。一覧で、印の無い1本が今の続きです。元のモデル・推論強度・権限・モードなどが表示後も一致することを確認してから、継続指示を一度だけ送ります。後継でその入力と作業の進捗を観測して完了とします。手動`/compact`は発火対象に含めません。旧タスクにCodexのゴール(`/goal`)があれば、引き継ぎの最初に一時停止にし、後継が作業を続けた後で後継へ運びます(0.16.28から。動いていたゴールは後継でも動き、止めていたゴールは止めたまま移ります)。
|
|
33
33
|
|
|
34
34
|
記憶は引き継ぎ全体の直近20ターンをL2全文、それより古いターンをL1で渡します。元のユーザー依頼と中断地点を保持し、tool結果とThinkingは注入しません。本文内の`auto-handoff detail`コマンドで、凍結したL2/L3を引き継ぎID・origin・turnから取得できます。A→B→Cの連続引き継ぎでも祖先の詳細を取得できます。
|
|
35
35
|
|
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ 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`で付け直せます。名前の無い後継にも名前が付きます。`--dry-run`で、付ける名前だけを先に見られます)。一覧で、印の無い1本が今の続きです。元のモデル・推論強度・権限・モードなどが表示後も一致することを確認してから、継続指示を一度だけ送ります。後継でその入力と作業の進捗を観測して完了とします。手動`/compact
|
|
31
|
+
自動圧縮の開始前に旧ターンを停止し、同じprojectの新しいタスクへ記憶を注入して表示します。新しいタスクには、前任のタスクの題から`<project名>|<作業の概要>(自動引き継ぎ)`の名前を付けます。後継が作業を続けたのを確かめた後、旧タスクの名前の頭へ`【引き継ぎ済み】`を付けます(0.16.10から。それより前の版が引き継いだ分は`throughline auto-handoff mark-sources`で付け直せます。名前の無い後継にも名前が付きます。`--dry-run`で、付ける名前だけを先に見られます)。一覧で、印の無い1本が今の続きです。元のモデル・推論強度・権限・モードなどが表示後も一致することを確認してから、継続指示を一度だけ送ります。後継でその入力と作業の進捗を観測して完了とします。手動`/compact`は発火対象に含めません。旧タスクにCodexのゴール(`/goal`)があれば、引き継ぎの最初に一時停止にし、後継が作業を続けた後で後継へ運びます(0.16.28から。動いていたゴールは後継でも動き、止めていたゴールは止めたまま移ります)。
|
|
32
32
|
|
|
33
33
|
記憶は引き継ぎ全体の直近20ターンをL2全文、それより古いターンをL1で渡します。元のユーザー依頼と中断地点を保持し、tool結果とThinkingは注入しません。本文内の`auto-handoff detail`コマンドで、凍結したL2/L3を引き継ぎID・origin・turnから取得できます。A→B→Cの連続引き継ぎでも祖先の詳細を取得できます。
|
|
34
34
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# ADR 0059: Codex の旧タスクのゴールは、引き継ぎの初めに止めて、後継へ運ぶ
|
|
2
|
+
|
|
3
|
+
日付: 2026-10-13
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
Codex には、タスクごとのゴール(`/goal`)がある。目標の文を決めておくと、タスクが手すきになるたびに Codex が自分で
|
|
8
|
+
次のターンを起こし、目標が終わるまで続ける。
|
|
9
|
+
|
|
10
|
+
2026-10-13、Windows の Codex Desktop(同梱の Codex 0.162.0-alpha.17.2、Throughline 0.16.27)で、自動継続が
|
|
11
|
+
引き継いだ後の旧タスクが止まらなかった。
|
|
12
|
+
|
|
13
|
+
- 22:04:09 に旧タスクを止めて引き継ぎを始め、22:05:44 に後継が作業を続けた(`continued`)。引き継ぎ自体は成功している。
|
|
14
|
+
- 旧タスクには、20:10 に利用者が付けたゴールが「実行中」のまま残っていた。後継にはゴールが無かった。
|
|
15
|
+
- 旧タスクでは、22:04:10 から 1〜2 秒おきに新しいターンが始まり続けた。23:43:35 までに 2,067 回。
|
|
16
|
+
どのターンも入力は Codex 自身の文脈(`<codex_internal_context source="goal">` で始まる続行の指示)だけで、人の入力は 0。
|
|
17
|
+
- ターンが始まるたびに、文脈が上限を超えているので `PreCompact(auto)` が来る。Throughline は「引き継ぎ済みの旧タスクへ
|
|
18
|
+
新しい入力が来た」(ADR 0039)として、ターンを止め、後継を画面に開いた。
|
|
19
|
+
- 利用者には、「タスク終了」の通知が続き、画面が後継へ切り替わり続ける様に見えた。旧タスクの記録(rollout)は
|
|
20
|
+
約34MB まで増えた。モデルは呼ばれていない(止めるのはモデルを呼ぶ前)。
|
|
21
|
+
- 引き継ぎの最中(22:04:10〜22:05:44)にも同じターンが来ていた。こちらは「引き継ぎの最中の入力」(ADR 0040)として
|
|
22
|
+
止まっていた。
|
|
23
|
+
|
|
24
|
+
原因は、Throughline がゴールを扱っていなかった事にある。
|
|
25
|
+
|
|
26
|
+
- Codex は、タスクが手すきになるたびに DB のゴールを読み、`active` なら続行の指示でターンを始める
|
|
27
|
+
(`codex-rs/ext/goal/src/extension.rs` の `on_thread_idle` → `runtime.rs` の `continue_if_idle`)。
|
|
28
|
+
hook が止めたターン(`turn_aborted`、`interrupted`)の後も同じ。利用者が自分で中断した時だけの持ち越しの印は、hook の止めでは付かない。
|
|
29
|
+
- 後継は新しいタスクなので、ゴールを持たない。後継は継続の指示の1ターンを終えると、そこで止まる。
|
|
30
|
+
ゴールで動かしていた作業が、引き継ぎを境に自分では続かなくなっていた。
|
|
31
|
+
|
|
32
|
+
Codex の app-server には、ゴールを読む・変える・消す口がある(`thread/goal/get`・`thread/goal/set`・`thread/goal/clear`。
|
|
33
|
+
experimental)。別の process から使える。確かめた事(Linux の Codex 0.162.1 と、Windows の実機):
|
|
34
|
+
|
|
35
|
+
- 別の process が `status: 'paused'` を書くと、タスクを開いている process は次の手すきで続行を起こさない
|
|
36
|
+
(Windows の実機で、連続が1ターンの内に止まった)。
|
|
37
|
+
- ゴールの無いタスクへ、別の process が目標の文つきで `status: 'active'` を書ける。そのタスクがターンを動かしている間に
|
|
38
|
+
書くと、タスクを開いている process は、そのターンの後から続行を起こす。
|
|
39
|
+
- 手すきのタスクへ `active` を書いても、その場では続行が起きない。次にそのタスクのターンが終わった時から起きる。
|
|
40
|
+
- `status: 'paused'` と目標の文を一緒に渡して、止まった状態のゴールを新しく作れる。
|
|
41
|
+
|
|
42
|
+
## Decision
|
|
43
|
+
|
|
44
|
+
1. worker は、引き継ぎの最初に、旧タスクのゴールを読む。`active` なら `paused` にする(`pauseAutoHandoffSourceGoal`)。
|
|
45
|
+
ゴールは消さない。止めたかどうかを、引き継ぎの記録(`runtime_json.goalCarry.paused`)へ書く。目標の文は記録へ写さない。
|
|
46
|
+
2. 後継が作業を続けたのを確かめた後(`continued` の直後、順番待ちを運ぶより先)に、旧タスクのゴールを後継へ運ぶ
|
|
47
|
+
(`carryAutoHandoffGoal`)。後継の最初のターン(継続の指示)が動いている間に付けるので、そのターンの後から Codex が続ける。
|
|
48
|
+
- 目標の文はそのまま写す。予算(`tokenBudget`)があれば、旧タスクで使った分を引いた残りを渡す。
|
|
49
|
+
- この製品が止めたゴールは、後継では `active` にする。利用者が止めていたゴール(`paused`)は、`paused` のまま運ぶ。
|
|
50
|
+
- 完了したゴール(`complete`)と、Codex が上限や行き詰まりで止めたゴール(`budgetLimited`・`usageLimited`・`blocked`)は運ばない。
|
|
51
|
+
- 後継にもうゴールがあれば、書き換えない。
|
|
52
|
+
- 運ぶ時に旧タスクのゴールが `active` なら(最初の一時停止が効かなかった)、先に `paused` にしてから運ぶ。
|
|
53
|
+
3. 旧タスクのゴールは `paused` のまま残す。旧タスクは引き継ぎ済みなので、そのゴールは動かない。
|
|
54
|
+
4. ゴールを読めない・書けない時も、引き継ぎは止めない(ゴールの口が無い版の Codex を含む)。理由は worker のログに残す。
|
|
55
|
+
5. 途中で止まった引き継ぎ(`failed`・`unknown`)では、ゴールを後継へ運ばず、旧タスクのゴールも `active` へ戻さない。
|
|
56
|
+
戻すと、止まったままの旧タスクで、続行のターンと引き継ぎの失敗が繰り返される。失敗の画面に、ゴールを止めてある事と、
|
|
57
|
+
旧タスクで続ける時の戻し方を書く。やり直しで `continued` になった時に運ぶ。同じ旧タスクから新しい引き継ぎを作った時は、
|
|
58
|
+
前の引き継ぎが止めていた事を引き継ぐ(後継では `active` にする)。
|
|
59
|
+
6. 引き継ぎ済みの旧タスクへターンが来た時(ADR 0039 の道)に、次の2つを足す。
|
|
60
|
+
- そのターンの入力が Codex 自身の文脈だけなら(`<codex_internal_context` で始まる物だけ)、後継を画面に開かない。
|
|
61
|
+
人が入力していないのに画面を切り替えない。ターンを止めて後継を示す事は変えない。
|
|
62
|
+
- 旧タスクにゴールが動いたまま残っていないかを、別の process で確かめる(`auto-handoff settle-goal`。hook は待たない)。
|
|
63
|
+
動いていれば `paused` にし、今の一番先の後継にゴールが無ければ `active` で運ぶ。同じ旧タスクへは30秒に1回まで。
|
|
64
|
+
0.16.27 までの版が引き継いだ旧タスクと、引き継いだ後で利用者が旧タスクのゴールを再開した時が当たる。
|
|
65
|
+
片付けた事は `~/.throughline/codex-auto-handoff/goal-settles.jsonl` へ1行残す(時刻・ID・付けた状態。目標の文は書かない)。
|
|
66
|
+
|
|
67
|
+
## Consequences
|
|
68
|
+
|
|
69
|
+
- ゴールで動かしていたタスクは、引き継いだ後も後継がゴールで続ける。旧タスクは止まる。
|
|
70
|
+
- 引き継ぎの最中に来る続行のターンは、worker がゴールを止めるまでの数秒の分だけになる。
|
|
71
|
+
- 後継のゴールの使用量(`tokensUsed`・`timeUsedSeconds`)は 0 から数え直しになる。予算は残りを渡すので、合計の上限は変わらない。
|
|
72
|
+
- 6 の片付けで運んだゴールは、後継が手すきの時は、その場では動き出さない。後継の次のターンが終わった時から Codex が続ける。
|
|
73
|
+
- 旧タスクの画面のゴールの表示は、別の process が書いた変更なので、Codex Desktop がそのタスクを読み直すまで古いまま見える事がある。
|
|
74
|
+
- `continued` を記録した直後に worker が落ちると、ゴールは旧タスクで止まったまま、後継へは運ばれない。旧タスクは動かないので
|
|
75
|
+
連続は起きない。利用者が後継でゴールを付け直す必要がある。
|
|
76
|
+
- `blocked` などで止まっているゴールは運ばない。旧タスクに残る。
|
|
77
|
+
- 引き継ぎ1回につき、Codex の app-server への接続が2回増える(最初の読み書きと、運ぶ時)。
|
|
78
|
+
- Claude Code の自動継続は変えない。Claude Code にはタスクごとのゴールが無い。
|
|
79
|
+
|
|
80
|
+
## 確かめた範囲
|
|
81
|
+
|
|
82
|
+
| | macOS | Linux | Windows |
|
|
83
|
+
|---|---|---|---|
|
|
84
|
+
| Codex Desktop | 直した版の関数(止める・運ぶ・片付ける)を、Desktop 同梱の Codex(0.162.0-alpha.17.2)へ通した(25項目) | 同じ確認を codex-cli 0.162.1 で。本物の Codex Desktop(同梱の 0.162.0-alpha.2)で、ゴールを持つタスクの引き継ぎを8回続けて通した | 起きた端末。旧タスクのゴールを `paused` にして、連続が止まるのを見た。関数の確認(25項目)を Desktop 同梱の Codex(0.162.0-alpha.17.2)で。起きた旧タスクの本物の記録へ、直した版の hook の入口を読むだけで当てた |
|
|
85
|
+
| Claude Code | 変更なし(ゴールが無い) | 同じ | 同じ |
|
|
86
|
+
| Grok | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
87
|
+
| Cursor | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
88
|
+
|
|
89
|
+
Linux の通し(2026-10-14 00:05〜00:14 JST)で見た事:
|
|
90
|
+
|
|
91
|
+
- タスクを Codex Desktop で開くと、実行中のゴールを持つタスクは、入力を待たずに続行のターンを始めた。
|
|
92
|
+
- 引き継ぎのたびに、止めたターンの直後から続行のターンが 0.15 秒ほどの間隔で来た。worker がゴールを止めるまでの1秒以内に 4〜5 回で、
|
|
93
|
+
その後は来ていない。8回とも `goalCarry` は `{"paused":true,"carried":"active"}`。
|
|
94
|
+
- 最後の後継が作業を終えるとゴールは完了になり、Codex Desktop の画面の側が `thread/goal/clear` を送ってゴールを消した。
|
|
95
|
+
|
|
96
|
+
macOS と Windows の本物の Codex Desktop の画面で、ゴールを持つタスクが引き継がれる通しは、公開の時点では確かめていない。
|
package/package.json
CHANGED
package/src/cli/auto-handoff.mjs
CHANGED
|
@@ -5,7 +5,8 @@ import { getDb, openReadOnlyDb, DB_PATH, CURRENT_VERSION } from '../db.mjs';
|
|
|
5
5
|
import { listAutoHandoffs, getAutoHandoff } from '../codex-auto-handoff-store.mjs';
|
|
6
6
|
import { renderFrozenDetail } from '../codex-auto-handoff-memory.mjs';
|
|
7
7
|
import { readAutoHandoffConfig, writeAutoHandoffConfig } from '../codex-auto-handoff-config.mjs';
|
|
8
|
-
import { autoHandoffDeliveryProfile, runAutoHandoffWorker, markAutoHandoffSources, resolveAutoHandoffSuccessor
|
|
8
|
+
import { autoHandoffDeliveryProfile, runAutoHandoffWorker, markAutoHandoffSources, resolveAutoHandoffSuccessor,
|
|
9
|
+
settleAutoHandoffSourceGoal } from '../codex-auto-handoff.mjs';
|
|
9
10
|
import { installCodexAutoHandoffHook, installClaudeAutoHandoffHooks, removeClaudeAutoHandoffHooks,
|
|
10
11
|
claudeAutoHandoffHooksRegistered } from './install.mjs';
|
|
11
12
|
import { sameProjectPath } from '../project-path.mjs';
|
|
@@ -19,9 +20,9 @@ const CLAUDE_ACTIONS = ['enable', 'disable', 'status', 'worker', 'desktop-open',
|
|
|
19
20
|
|
|
20
21
|
export function parseAutoHandoffArgs(args) {
|
|
21
22
|
const out = { action: args[0] ?? 'status', host: 'codex', operation: null, project: null, origin: null, turn: null, database: null, thread: null, json: false, dryRun: false };
|
|
22
|
-
if (!['enable', 'disable', 'status', 'resume', 'detail', 'worker', 'desktop-open', 'mark-sources', 'successor'].includes(out.action)) throw Error('auto_handoff_action_invalid');
|
|
23
|
+
if (!['enable', 'disable', 'status', 'resume', 'detail', 'worker', 'desktop-open', 'mark-sources', 'successor', 'settle-goal'].includes(out.action)) throw Error('auto_handoff_action_invalid');
|
|
23
24
|
const allowed = ['host', ...{ enable: ['project'], disable: [], status: ['project', 'operation'],
|
|
24
|
-
resume: ['operation'], worker: ['operation'], 'desktop-open': ['operation'], 'mark-sources': [], successor: ['thread'], detail: ['operation', 'origin', 'turn', 'database'] }[out.action]];
|
|
25
|
+
resume: ['operation'], worker: ['operation'], 'desktop-open': ['operation'], 'mark-sources': [], successor: ['thread'], 'settle-goal': ['operation', 'thread'], detail: ['operation', 'origin', 'turn', 'database'] }[out.action]];
|
|
25
26
|
const seen = new Set();
|
|
26
27
|
for (let i = 1; i < args.length; i++) {
|
|
27
28
|
if (seen.has(args[i])) throw Error('auto_handoff_argument_invalid');
|
|
@@ -36,8 +37,8 @@ export function parseAutoHandoffArgs(args) {
|
|
|
36
37
|
if (out.host === 'claude' ? !CLAUDE_ACTIONS.includes(out.action) : out.action === 'desktop-open') throw Error('auto_handoff_action_unsupported');
|
|
37
38
|
if (out.project && !isAbsolute(out.project)) out.project = resolve(out.project);
|
|
38
39
|
if (out.project && out.operation) throw Error('auto_handoff_argument_invalid');
|
|
39
|
-
if (['worker', 'resume', 'detail', 'desktop-open'].includes(out.action) && !out.operation) throw Error('auto_handoff_operation_required');
|
|
40
|
-
if (out.action
|
|
40
|
+
if (['worker', 'resume', 'detail', 'desktop-open', 'settle-goal'].includes(out.action) && !out.operation) throw Error('auto_handoff_operation_required');
|
|
41
|
+
if (['successor', 'settle-goal'].includes(out.action) && !out.thread) throw Error('auto_handoff_thread_required');
|
|
41
42
|
if (out.action === 'detail') {
|
|
42
43
|
out.turn = Number(out.turn);
|
|
43
44
|
if (!out.origin || !Number.isSafeInteger(out.turn) || out.turn < 1) throw Error('auto_handoff_detail_identity_required');
|
|
@@ -146,6 +147,9 @@ export async function run(args = []) {
|
|
|
146
147
|
// 0.16.9 までの版が引き継いだ分の名前を付け直す(名前の無い後継へ名前、旧タスクへ引き継ぎ済みの印)。何度流しても同じ結果になる。
|
|
147
148
|
const db = getDb();
|
|
148
149
|
result = { status: 'ok', dry_run: parsed.dryRun, ...await markAutoHandoffSources(db, { dryRun: parsed.dryRun }) };
|
|
150
|
+
} else if (parsed.action === 'settle-goal') {
|
|
151
|
+
// 引き継ぎ済みの旧タスクに動いたまま残ったゴールを止めて、今の後継へ運ぶ(hook が起こす。ADR 0059)。
|
|
152
|
+
result = await settleAutoHandoffSourceGoal(getDb(), { operationId: parsed.operation, threadId: parsed.thread });
|
|
149
153
|
} else if (parsed.action === 'worker') {
|
|
150
154
|
result = publicOperation(await runAutoHandoffWorker(parsed.operation));
|
|
151
155
|
} else if (parsed.action === 'resume') {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { readFileSync, rmSync } from 'node:fs';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
|
-
import { join } from 'node:path';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
|
|
9
9
|
import {
|
|
@@ -141,7 +141,7 @@ test('missing grok binary does not spawn', () => {
|
|
|
141
141
|
assert.equal(spawned.length, 0);
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
-
test('successful continue spawn uses source project cwd and waits', () => {
|
|
144
|
+
test('successful continue spawn uses source project cwd and waits', (t) => {
|
|
145
145
|
const io = capture();
|
|
146
146
|
const spawned = [];
|
|
147
147
|
const uuid = '22222222-2222-4222-8222-222222222222';
|
|
@@ -164,6 +164,7 @@ test('successful continue spawn uses source project cwd and waits', () => {
|
|
|
164
164
|
assert.equal(spawned.length, 1);
|
|
165
165
|
assert.equal(io.out, `grok:${uuid}\n`);
|
|
166
166
|
const { grokArgv, launchFile, cwd } = spawned[0];
|
|
167
|
+
t.after(() => rmSync(dirname(launchFile), { recursive: true, force: true }));
|
|
167
168
|
assert.equal(cwd, REPO_ROOT);
|
|
168
169
|
assert.equal(grokArgv.includes('--rules'), false);
|
|
169
170
|
assert.ok(grokArgv.at(-1).includes(CONTEXT));
|
|
@@ -232,7 +233,11 @@ test('bin usage error is exit 2 without spawning a real grok', () => {
|
|
|
232
233
|
const result = spawnSync(process.execPath, [BIN_PATH, 'grok-continue'], {
|
|
233
234
|
cwd: REPO_ROOT,
|
|
234
235
|
encoding: 'utf8',
|
|
235
|
-
env: {
|
|
236
|
+
env: {
|
|
237
|
+
...process.env,
|
|
238
|
+
HOME: join(tmpdir(), 'tl-grok-continue-missing-home'),
|
|
239
|
+
USERPROFILE: join(tmpdir(), 'tl-grok-continue-missing-home'),
|
|
240
|
+
},
|
|
236
241
|
});
|
|
237
242
|
assert.equal(result.status, 2);
|
|
238
243
|
assert.match(result.stderr, /Usage: throughline grok-continue --session <id>/);
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import { mkdtempSync } from 'node:fs';
|
|
5
|
-
import { tmpdir } from 'node:os';
|
|
6
4
|
import { join } from 'node:path';
|
|
5
|
+
import { testTempDir } from '../test-temp-dir.mjs';
|
|
7
6
|
import { fileURLToPath } from 'node:url';
|
|
8
7
|
|
|
9
8
|
import { parseArgs, run } from './runtime-errors.mjs';
|
|
@@ -43,7 +42,7 @@ test('runtime-errors CLI: internal failure is fixed and does not reflect excepti
|
|
|
43
42
|
});
|
|
44
43
|
|
|
45
44
|
test('runtime-errors CLI: snapshot and diagnostics are JSON-only and contain no state path', () => {
|
|
46
|
-
const root =
|
|
45
|
+
const root = testTempDir('throughline-runtime-cli-');
|
|
47
46
|
const env = {
|
|
48
47
|
...process.env,
|
|
49
48
|
HOME: root,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, openSync, closeSync, writeFileSync, appendFileSync, realpathSync } from 'node:fs';
|
|
1
|
+
import { mkdirSync, openSync, closeSync, writeFileSync, appendFileSync, realpathSync, statSync } 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';
|
|
@@ -73,6 +73,30 @@ export async function launchAutoHandoffWorker(id, { resume = false } = {}) {
|
|
|
73
73
|
} finally { closeSync(log); }
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// 同じ旧タスクのゴールを確かめる process を起こす間隔。ゴールの続行は1〜2秒おきに来るので、来るたびには起こさない。
|
|
77
|
+
const GOAL_SETTLE_INTERVAL_MS = 30_000;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 引き継ぎ済みの旧タスクにゴールが動いたまま残っていないかを、別の process に確かめさせる(hook は待たない)。
|
|
81
|
+
* 同じ旧タスクへは GOAL_SETTLE_INTERVAL_MS に1回まで。
|
|
82
|
+
* @returns {Promise<boolean>} 起こしたか
|
|
83
|
+
*/
|
|
84
|
+
export async function launchAutoHandoffGoalSettle(threadId, operation, { now = Date.now(), spawn = spawnPortable } = {}) {
|
|
85
|
+
const dir = join(stateRoot(), 'goal-settle');
|
|
86
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
87
|
+
const marker = join(dir, threadId);
|
|
88
|
+
try { if (now - statSync(marker).mtimeMs < GOAL_SETTLE_INTERVAL_MS) return false; } catch { /* まだ1回も起こしていない */ }
|
|
89
|
+
writeFileSync(marker, '', { mode: 0o600 });
|
|
90
|
+
const log = openSync(join(stateRoot(), `${operation.handoff_id}.log`), 'a', 0o600);
|
|
91
|
+
try {
|
|
92
|
+
const child = spawn(process.execPath, [CLI_PATH, 'auto-handoff', 'settle-goal', '--operation', operation.handoff_id,
|
|
93
|
+
'--thread', threadId, '--json'], { detached: true, stdio: ['ignore', log, log], windowsHide: true });
|
|
94
|
+
await new Promise((resolveSpawn, reject) => { child.once('spawn', resolveSpawn); child.once('error', reject); });
|
|
95
|
+
child.unref();
|
|
96
|
+
return true;
|
|
97
|
+
} finally { closeSync(log); }
|
|
98
|
+
}
|
|
99
|
+
|
|
76
100
|
function liveSuccessors(db, threadId, options, seen) {
|
|
77
101
|
const found = [];
|
|
78
102
|
for (const operation of listDeliveredAutoHandoffsForSource(db, threadId)) {
|
|
@@ -144,6 +168,12 @@ function recordAutoHandoffRedirect(entry) {
|
|
|
144
168
|
appendFileSync(join(stateRoot(), 'redirects.jsonl'), JSON.stringify(entry) + '\n', { mode: 0o600 });
|
|
145
169
|
}
|
|
146
170
|
|
|
171
|
+
/** 引き継ぎ済みの旧タスクに残っていたゴールを片付けた記録。会話の本文も、ゴールの目標の文も書かない。 */
|
|
172
|
+
function recordGoalSettle(entry) {
|
|
173
|
+
mkdirSync(stateRoot(), { recursive: true, mode: 0o700 });
|
|
174
|
+
appendFileSync(join(stateRoot(), 'goal-settles.jsonl'), JSON.stringify(entry) + '\n', { mode: 0o600 });
|
|
175
|
+
}
|
|
176
|
+
|
|
147
177
|
// workerを起動してから、workerが自分のprocessを記録へ名乗るまでの間。この間の引き継ぎは、進んでいる物として扱う。
|
|
148
178
|
const WORKER_START_GRACE_MS = 15_000;
|
|
149
179
|
// workerが1回の引き継ぎに掛ける時間の上限(各段の待ち時間の合計より長い)。process一覧を読めない時だけ使う。
|
|
@@ -192,6 +222,7 @@ function settleUndeliveredAutoHandoff(db, threadId, { rolloutPath, processes, re
|
|
|
192
222
|
export async function requestCodexAutoHandoff({ payload, db = null,
|
|
193
223
|
config = readAutoHandoffConfig(), launchWorker = launchAutoHandoffWorker,
|
|
194
224
|
findThread = findCodexThreadCandidate, openThread = openSuccessor, recordRedirect = recordAutoHandoffRedirect,
|
|
225
|
+
settleGoal = launchAutoHandoffGoalSettle,
|
|
195
226
|
recordInternalCompaction = recordInternalCompactionStop, recordUnrecordedCompaction = recordUnrecordedCompactionPass,
|
|
196
227
|
processes = readRuntimeProcesses, readTarget = targetState, now = Date.now() } = {}) {
|
|
197
228
|
if (payload.trigger !== 'auto' || !autoHandoffEnabledFor(config, payload.cwd)) return { status: 'skipped' };
|
|
@@ -229,9 +260,16 @@ export async function requestCodexAutoHandoff({ payload, db = null,
|
|
|
229
260
|
const sameTurn = findAutoHandoffForTurn(actualDb, threadId, turnId);
|
|
230
261
|
const successor = sameTurn ? null : findLiveAutoHandoffSuccessor(actualDb, threadId, { findThread });
|
|
231
262
|
if (successor) {
|
|
232
|
-
|
|
263
|
+
// Codex が自分で起こしたターン(ゴールの続行など)では、後継を開かない。人が入力していないのに、画面が後継へ
|
|
264
|
+
// 切り替わり続ける(2026-10-13、Windows。旧タスクに残ったゴールが1〜2秒おきにターンを起こし、約2,000回)。
|
|
265
|
+
const internal = state.internalInputOnly === true;
|
|
266
|
+
const opened = internal ? false : openThread(successor);
|
|
267
|
+
// 旧タスクにゴールが動いたまま残っていると、止めてもすぐ次のターンが来る。別の process で止めて、後継へ運ぶ (ADR 0059)。
|
|
268
|
+
let goalSettle = false;
|
|
269
|
+
try { goalSettle = await settleGoal(threadId, successor, { now }); } catch { /* 起こせなくても、このターンは止める */ }
|
|
233
270
|
recordRedirect({ at: new Date().toISOString(), source_thread_id: threadId, source_turn_id: turnId,
|
|
234
|
-
handoff_id: successor.handoff_id, target_thread_id: successor.target_thread_id, opened
|
|
271
|
+
handoff_id: successor.handoff_id, target_thread_id: successor.target_thread_id, opened,
|
|
272
|
+
...(internal ? { internal: true } : {}), ...(goalSettle ? { goal_settle: true } : {}) });
|
|
235
273
|
return { status: 'ok', operationId: successor.handoff_id, inserted: false, redirected: true,
|
|
236
274
|
targetThreadId: successor.target_thread_id, opened, continue: false,
|
|
237
275
|
stopReason: `このタスクはThroughlineが新しいタスクへ引き継ぎ済みです(引き継ぎID: ${successor.handoff_id})。` +
|
|
@@ -446,6 +484,88 @@ export async function carryAutoHandoffQueue(db, operation, runtime, { connect =
|
|
|
446
484
|
return summary();
|
|
447
485
|
}
|
|
448
486
|
|
|
487
|
+
// Codex のゴール(`/goal`。目標が終わるまで、タスクが手すきになるたびに Codex が自分で次のターンを起こす)のうち、
|
|
488
|
+
// 後継へ運ぶ状態。動いている物と、利用者が止めている物だけ。完了した物と、Codex が上限や行き詰まりで止めた物は運ばない。
|
|
489
|
+
const CARRIED_GOAL_STATUSES = new Set(['active', 'paused']);
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* 旧タスクのゴールを一時停止にする。動いたままのゴールは、hook が止めたターンの直後から、Codex が次のターンを
|
|
493
|
+
* 起こし続ける。ゴールは消さない(後継へ運ぶ元にする。旧タスクで続けたい時は、利用者が再開できる)。
|
|
494
|
+
* ゴールを扱えなくても引き継ぎは止めない(理由は worker のログに残す)。
|
|
495
|
+
* @returns {Promise<'paused'|'idle'|'none'|'failed'>} idle は動いていないゴールがある、none はゴールが無い
|
|
496
|
+
*/
|
|
497
|
+
export async function pauseAutoHandoffSourceGoal(operation, runtime, { connect = withCodexReceiver } = {}) {
|
|
498
|
+
const threadId = operation.source_thread_id;
|
|
499
|
+
try {
|
|
500
|
+
return await connect(autoHandoffDeliveryProfile, { thread_id: threadId, codex_home: operation.codex_home }, async request => {
|
|
501
|
+
const goal = (await request('thread/goal/get', { threadId })).goal;
|
|
502
|
+
if (!goal) return 'none';
|
|
503
|
+
if (goal.status !== 'active') return 'idle';
|
|
504
|
+
await request('thread/goal/set', { threadId, status: 'paused' });
|
|
505
|
+
return 'paused';
|
|
506
|
+
}, runtime);
|
|
507
|
+
} catch (cause) {
|
|
508
|
+
process.stderr.write(`[auto-handoff] source goal pause failed: ${cause?.delivery_code ?? cause?.code ?? 'unknown'}\n`);
|
|
509
|
+
return 'failed';
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* 旧タスクのゴールを後継へ運ぶ。目標の文と、予算の残りを写す。旧タスクで動いていたゴールは後継でも動かし、
|
|
515
|
+
* 利用者が止めていたゴールは止めたまま運ぶ。後継にもうゴールがあれば書き換えない。旧タスクのゴールは消さない。
|
|
516
|
+
* 運べなくても引き継ぎは止めない(後継はもう作業を続けている。理由は worker のログに残す)。
|
|
517
|
+
* @param {{wasActive?: boolean, onlyActive?: boolean, sourceThreadId?: string}} [options] wasActive は、引き継ぎの初めに
|
|
518
|
+
* この製品が一時停止にしたゴール(後継では動かす)。onlyActive は、今も動いているゴールだけを運ぶ(止まっている物には触らない)。
|
|
519
|
+
* @returns {Promise<'active'|'paused'|'kept'|'none'|'failed'>} active・paused は後継へ付けた状態、kept は後継にもうゴールがある
|
|
520
|
+
*/
|
|
521
|
+
export async function carryAutoHandoffGoal(operation, runtime, { connect = withCodexReceiver, wasActive = false,
|
|
522
|
+
onlyActive = false, sourceThreadId = operation.source_thread_id } = {}) {
|
|
523
|
+
const targetId = operation.target_thread_id;
|
|
524
|
+
try {
|
|
525
|
+
return await connect(autoHandoffDeliveryProfile, { thread_id: sourceThreadId, codex_home: operation.codex_home }, async request => {
|
|
526
|
+
const goal = (await request('thread/goal/get', { threadId: sourceThreadId })).goal;
|
|
527
|
+
if (!goal || !CARRIED_GOAL_STATUSES.has(goal.status) || typeof goal.objective !== 'string') return 'none';
|
|
528
|
+
let active = wasActive;
|
|
529
|
+
if (goal.status === 'active') {
|
|
530
|
+
// 初めの一時停止が効かなかった、または引き継いだ後で動き出した。旧タスクを先に止める。
|
|
531
|
+
await request('thread/goal/set', { threadId: sourceThreadId, status: 'paused' });
|
|
532
|
+
active = true;
|
|
533
|
+
} else if (onlyActive) return 'none';
|
|
534
|
+
if ((await request('thread/goal/get', { threadId: targetId })).goal) return 'kept';
|
|
535
|
+
const status = active ? 'active' : 'paused';
|
|
536
|
+
// 予算は、旧タスクで使った分を引いた残りを渡す(使い切ったゴールは Codex が budgetLimited にするので、ここへ来ない)。
|
|
537
|
+
const budget = Number.isSafeInteger(goal.tokenBudget)
|
|
538
|
+
? Math.max(1, goal.tokenBudget - (Number.isSafeInteger(goal.tokensUsed) ? goal.tokensUsed : 0)) : null;
|
|
539
|
+
await request('thread/goal/set', { threadId: targetId, objective: goal.objective, status,
|
|
540
|
+
...(budget == null ? {} : { tokenBudget: budget }) });
|
|
541
|
+
return status;
|
|
542
|
+
}, runtime);
|
|
543
|
+
} catch (cause) {
|
|
544
|
+
process.stderr.write(`[auto-handoff] goal carry failed: ${cause?.delivery_code ?? cause?.code ?? 'unknown'}\n`);
|
|
545
|
+
return 'failed';
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* 引き継ぎ済みの旧タスクにゴールが動いたまま残っていた時の片付け(hook が起こす)。旧タスクのゴールを一時停止にし、
|
|
551
|
+
* 今の後継にゴールが無ければ運ぶ。0.16.27 までの版が引き継いだ旧タスクと、引き継いだ後で利用者が再開したゴールが当たる。
|
|
552
|
+
* @returns {Promise<{status: 'settled'|'skipped', goal?: string, reason?: string}>}
|
|
553
|
+
*/
|
|
554
|
+
export async function settleAutoHandoffSourceGoal(db, { operationId, threadId }, { executable = () => platformDesktopFinder()(),
|
|
555
|
+
carryGoal = carryAutoHandoffGoal, findThread = findCodexThreadCandidate, record = recordGoalSettle } = {}) {
|
|
556
|
+
if (!CODEX_NATIVE_ID_PATTERN.test(threadId ?? '')) throw error('handoff_thread_id_invalid');
|
|
557
|
+
// 起こされてからここまでの間に、後継がさらに引き継いでいる事がある。今の一番先の後継へ運ぶ。
|
|
558
|
+
const successor = findLiveAutoHandoffSuccessor(db, threadId, { findThread });
|
|
559
|
+
if (!successor || !getAutoHandoff(db, operationId)) return { status: 'skipped', reason: 'handoff_successor_unavailable' };
|
|
560
|
+
const goal = await carryGoal(successor, { executable: executable(), timeout_ms: 30_000 }, { sourceThreadId: threadId, onlyActive: true });
|
|
561
|
+
if (goal !== 'none') {
|
|
562
|
+
record({ at: new Date().toISOString(), source_thread_id: threadId, handoff_id: successor.handoff_id,
|
|
563
|
+
target_thread_id: successor.target_thread_id, goal });
|
|
564
|
+
process.stderr.write(`[auto-handoff] goal settled: ${goal}\n`);
|
|
565
|
+
}
|
|
566
|
+
return { status: 'settled', goal };
|
|
567
|
+
}
|
|
568
|
+
|
|
449
569
|
// 受け取った記憶をそのまま運ぶ上限。超えた分は切り、切った事を記録する(後継の記憶に出す)。
|
|
450
570
|
const INHERITED_MEMORY_MAX_CHARS = 120_000;
|
|
451
571
|
|
|
@@ -606,6 +726,7 @@ export function showAutoHandoffFailure(operation) {
|
|
|
606
726
|
<p><a href="codex://threads/${escape(operation.source_thread_id)}">旧タスクを開く</a>${operation.target_thread_id ? ` / <a href="codex://threads/${escape(operation.target_thread_id)}">後継タスクを開く</a>` : ''}</p>
|
|
607
727
|
<p>理由: <code>${escape(operation.error_code)}</code></p><p>引き継ぎID: <code>${escape(operation.handoff_id)}</code></p>
|
|
608
728
|
${operation.error_code === 'handoff_summarizer_backend_failed' ? '<p>記憶の要約に使うCodex CLIが失敗しました。通信の断、利用上限、認証切れなどで起きます。会話の記録と入力は失われていません。Codex CLIが使える様になった後、旧タスクへ入力すると、引き継ぎをもう一度始めます。</p>' : ''}
|
|
729
|
+
${operation.runtime?.goalCarry?.paused ? '<p>旧タスクのゴール(/goal)は、同じターンが繰り返されない様に一時停止にしてあります。引き継ぎを再開して後継が動けば、ゴールは後継へ運ばれます。旧タスクのまま続ける時は、旧タスクでゴールを再開してください。</p>' : ''}
|
|
609
730
|
<p>状態: ${escape(operation.state)} / 停止した工程: ${escape(operation.resume_state)}</p>
|
|
610
731
|
<p>確認: <code>throughline auto-handoff status --operation ${escape(operation.handoff_id)}</code></p>
|
|
611
732
|
<p>原因を解消した後の再開: <code>throughline auto-handoff resume --operation ${escape(operation.handoff_id)}</code></p>
|
|
@@ -618,7 +739,7 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
|
|
|
618
739
|
const deps = { processes: readRuntimeProcesses, readSource: sourceState,
|
|
619
740
|
nativeState: sourceNativeState, capture: captureCodexRolloutToDb, summaries: ensureAutoHandoffSummaries,
|
|
620
741
|
createTarget, readTarget: targetState, openTarget, verify: verifyCodexParent, markSource: markAutoHandoffSource,
|
|
621
|
-
carryQueue: carryAutoHandoffQueue,
|
|
742
|
+
carryQueue: carryAutoHandoffQueue, pauseGoal: pauseAutoHandoffSourceGoal, carryGoal: carryAutoHandoffGoal,
|
|
622
743
|
submit: submitCodexParentAnswer, notify: showAutoHandoffFailure, wait: waitForHandoff,
|
|
623
744
|
executable: () => platformDesktopFinder()(), liveSuccessor: findLiveAutoHandoffSuccessor, ...dependencies };
|
|
624
745
|
let operation = getAutoHandoff(db, id);
|
|
@@ -644,6 +765,13 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
|
|
|
644
765
|
throw error('handoff_mutation_outcome_unknown');
|
|
645
766
|
}
|
|
646
767
|
const runtime = { executable: deps.executable(), timeout_ms: 30_000 };
|
|
768
|
+
// 旧タスクのゴールを先に止める。動いたままだと、止めたターンの直後から Codex が次のターンを起こし続ける。
|
|
769
|
+
// 同じ旧タスクの前の引き継ぎ(途中で止まった物)が止めていたゴールは、動いていた物として後継へ運ぶ (ADR 0059)。
|
|
770
|
+
if (!operation.runtime?.goalCarry) {
|
|
771
|
+
const paused = await deps.pauseGoal(operation, runtime) === 'paused' ||
|
|
772
|
+
listUndeliveredAutoHandoffsForSource(db, operation.source_thread_id).some(other => other.runtime?.goalCarry?.paused);
|
|
773
|
+
update({ runtime_json: { ...(operation.runtime ?? {}), goalCarry: { paused } } });
|
|
774
|
+
}
|
|
647
775
|
if (operation.state === 'requested') {
|
|
648
776
|
const state = await deps.wait(() => {
|
|
649
777
|
const state = deps.readSource(operation);
|
|
@@ -663,7 +791,7 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
|
|
|
663
791
|
}
|
|
664
792
|
const native = await deps.nativeState(operation, runtime);
|
|
665
793
|
update({ state: 'source_stopped', settings_json: state.settings,
|
|
666
|
-
runtime_json: { ...native, databasePath: DB_PATH, ...(inheritedMemory ? { inheritedMemory } : {}) } });
|
|
794
|
+
runtime_json: { ...(operation.runtime ?? {}), ...native, databasePath: DB_PATH, ...(inheritedMemory ? { inheritedMemory } : {}) } });
|
|
667
795
|
}
|
|
668
796
|
// 最中に来た入力のturnが止まり切るまでの間は、境界がまだ見えない。短く待ってから判定する。
|
|
669
797
|
const confirmSourceBoundary = () => deps.wait(() => sourceBoundary(operation, deps.readSource(operation)) || null,
|
|
@@ -737,6 +865,10 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
|
|
|
737
865
|
return target.correlatedTurnId && target.progress ? target : null;
|
|
738
866
|
}, { timeoutMs: 180_000, timeoutCode: 'handoff_target_progress_unconfirmed' });
|
|
739
867
|
update({ state: 'continued', started_turn_id: target.correlatedTurnId, resume_state: null, error_code: null });
|
|
868
|
+
// 旧タスクのゴールを後継へ運ぶ。後継の最初のターンが動いている間に付けると、そのターンの後から Codex が続ける。
|
|
869
|
+
const goal = await deps.carryGoal(operation, runtime, { wasActive: operation.runtime?.goalCarry?.paused === true });
|
|
870
|
+
update({ runtime_json: { ...(getAutoHandoff(db, id).runtime ?? {}), goalCarry: { ...(operation.runtime?.goalCarry ?? {}), carried: goal } } });
|
|
871
|
+
if (goal !== 'none') globalThis.process.stderr.write(`[auto-handoff] goal carried: ${goal}\n`);
|
|
740
872
|
// 旧タスクの順番待ちに残った入力を後継へ運ぶ。後継の最初のターン(継続の指示)が始まった後なので、順番は変わらない。
|
|
741
873
|
await deps.carryQueue(db, operation, runtime);
|
|
742
874
|
// 後継が作業を続けたのを確かめてから付ける。途中で止まった引き継ぎの旧タスクには付けない。
|
|
@@ -7,6 +7,8 @@ import { spawnSync } from 'node:child_process';
|
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { tmpdir } from 'node:os';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
|
+
import { EventEmitter } from 'node:events';
|
|
11
|
+
import { testTempDir } from './test-temp-dir.mjs';
|
|
10
12
|
import { requestAutoHandoff, updateAutoHandoff, getAutoHandoff, freezeCodexMemory,
|
|
11
13
|
claimAutoHandoff, continuationInput } from './codex-auto-handoff-store.mjs';
|
|
12
14
|
import { collectAutoHandoffMemory, ensureAutoHandoffSummaries, renderAutoHandoffMemory,
|
|
@@ -15,7 +17,8 @@ import { readCodexHandoffState, settingsMatch, threadStartSettings, resolvePrepa
|
|
|
15
17
|
import { runAutoHandoffWorker, requestCodexAutoHandoff, sourceBoundary,
|
|
16
18
|
autoHandoffTargetName, nameAutoHandoffTarget, findLiveAutoHandoffSuccessor,
|
|
17
19
|
markAutoHandoffSource, markAutoHandoffSources, autoHandoffSourceFallbackTitle, carryAutoHandoffQueue,
|
|
18
|
-
untrackedInheritedMemory, resolveAutoHandoffSuccessor, hookIdentityShape
|
|
20
|
+
untrackedInheritedMemory, resolveAutoHandoffSuccessor, hookIdentityShape,
|
|
21
|
+
pauseAutoHandoffSourceGoal, carryAutoHandoffGoal, settleAutoHandoffSourceGoal, launchAutoHandoffGoalSettle } from './codex-auto-handoff.mjs';
|
|
19
22
|
import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
|
|
20
23
|
|
|
21
24
|
async function withDb(fn) {
|
|
@@ -117,6 +120,10 @@ function workerDependencies(db, settings, submit, counters) {
|
|
|
117
120
|
readTarget: operation => ({ settings, latestTurnId: ['submitted', 'unknown'].includes(operation.state) ? 'new-turn' : null,
|
|
118
121
|
correlatedTurnId: ['submitted', 'unknown'].includes(operation.state) ? 'new-turn' : null, progress: true }),
|
|
119
122
|
openTarget: () => {}, verify: async () => {}, submit,
|
|
123
|
+
pauseGoal: async operation => { (counters.goal ??= []).push(`pause:${operation.state}`); return counters.goalPause ?? 'none'; },
|
|
124
|
+
carryGoal: async (operation, runtime, options) => {
|
|
125
|
+
(counters.goal ??= []).push(`carry:${operation.state}:${options.wasActive}`); return counters.goalCarry ?? 'none';
|
|
126
|
+
},
|
|
120
127
|
carryQueue: async (db, operation) => { (counters.carried ??= []).push(`${operation.source_thread_id}:${operation.state}`); return { moved: 0, unknown: 0, left: 0 }; },
|
|
121
128
|
markSource: async (operation, runtime, options) => {
|
|
122
129
|
(counters.marked ??= []).push(`${operation.source_thread_id}:${operation.state}`);
|
|
@@ -193,7 +200,7 @@ test('実行中の子がある時は後継を作らない', async () => withDb(a
|
|
|
193
200
|
}));
|
|
194
201
|
|
|
195
202
|
test('rollout の developer 記憶から、受け取った Throughline の記憶の本文を最初の1つだけ読む', () => {
|
|
196
|
-
const dir =
|
|
203
|
+
const dir = testTempDir('tl-inherited-');
|
|
197
204
|
const file = join(dir, 'rollout.jsonl');
|
|
198
205
|
const developer = text => ({ type: 'response_item', payload: { type: 'message', role: 'developer', content: [{ type: 'input_text', text }] } });
|
|
199
206
|
const rows = [{ type: 'session_meta', payload: { id: 'T', cwd: '/project', originator: 'Codex Desktop', source: 'vscode' } },
|
|
@@ -445,7 +452,7 @@ test('引き継ぎ済みの旧タスクへ新しい入力が来ても後継を
|
|
|
445
452
|
const payload = desktopSource(home, thread, '33333333-3333-3333-3333-333333333333');
|
|
446
453
|
const config = { enabled: true, projects: [home], openHost: 'desktop' };
|
|
447
454
|
const seen = { launched: 0, opened: [], recorded: [], asked: [] };
|
|
448
|
-
const args = { db, config, payload, launchWorker: async () => { seen.launched++; },
|
|
455
|
+
const args = { db, config, payload, launchWorker: async () => { seen.launched++; }, settleGoal: async () => false,
|
|
449
456
|
findThread: query => { seen.asked.push(query); return { mtimeMs: 1 }; },
|
|
450
457
|
openThread: operation => { seen.opened.push(operation.target_thread_id); return true; },
|
|
451
458
|
recordRedirect: entry => { seen.recorded.push(entry); } };
|
|
@@ -1025,3 +1032,248 @@ test('要約のbackendが使えない時、workerは固定の理由で止まり
|
|
|
1025
1032
|
assert.equal(counters.carried, undefined, '途中で止まった引き継ぎでは、順番待ちを動かさない');
|
|
1026
1033
|
}
|
|
1027
1034
|
}));
|
|
1035
|
+
|
|
1036
|
+
// Codex のゴールの口の代わり。goals は thread id ごとの今のゴール(無ければ null)。
|
|
1037
|
+
function goalReceiver(goals, sent, fail = null) {
|
|
1038
|
+
return async (profile, parent, action) => action(async (method, params) => {
|
|
1039
|
+
sent.push([method, params]);
|
|
1040
|
+
if (method === fail) throw Object.assign(new Error('rejected'), { delivery_code: 'CODEX_RECEIVER_REJECTED' });
|
|
1041
|
+
if (method === 'thread/goal/get') return { goal: goals[params.threadId] ?? null };
|
|
1042
|
+
if (method === 'thread/goal/set') {
|
|
1043
|
+
const { threadId, ...fields } = params;
|
|
1044
|
+
goals[threadId] = { tokenBudget: null, tokensUsed: 0, ...(goals[threadId] ?? {}), ...fields };
|
|
1045
|
+
return { goal: goals[threadId] };
|
|
1046
|
+
}
|
|
1047
|
+
throw new Error(`想定していない要求: ${method}`);
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
test('旧タスクの動いているゴールを一時停止にし、止まっているゴールと無いゴールには触らない。扱えなくても引き継ぎは止めない', async () => {
|
|
1052
|
+
const operation = { source_thread_id: 'S', codex_home: '/home' };
|
|
1053
|
+
let sent = []; let goals = { S: { status: 'active', objective: '目標' } };
|
|
1054
|
+
assert.equal(await pauseAutoHandoffSourceGoal(operation, {}, { connect: goalReceiver(goals, sent) }), 'paused');
|
|
1055
|
+
assert.deepEqual(sent, [['thread/goal/get', { threadId: 'S' }], ['thread/goal/set', { threadId: 'S', status: 'paused' }]]);
|
|
1056
|
+
assert.equal(goals.S.objective, '目標', '目標の文は消さない');
|
|
1057
|
+
|
|
1058
|
+
for (const [goal, expected] of [[null, 'none'], [{ status: 'paused', objective: '目標' }, 'idle'], [{ status: 'complete', objective: '目標' }, 'idle']]) {
|
|
1059
|
+
sent = [];
|
|
1060
|
+
assert.equal(await pauseAutoHandoffSourceGoal(operation, {}, { connect: goalReceiver({ S: goal }, sent) }), expected);
|
|
1061
|
+
assert.equal(sent.length, 1, '読むだけ');
|
|
1062
|
+
}
|
|
1063
|
+
// ゴールの口が無い版の Codex、口が断った時。
|
|
1064
|
+
assert.equal(await pauseAutoHandoffSourceGoal(operation, {}, { connect: goalReceiver({}, [], 'thread/goal/get') }), 'failed');
|
|
1065
|
+
assert.equal(await pauseAutoHandoffSourceGoal(operation, {}, { connect: goalReceiver({ S: { status: 'active', objective: 'x' } }, [], 'thread/goal/set') }), 'failed');
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
test('旧タスクのゴールを後継へ運ぶ。動いていた物は動かし、利用者が止めていた物は止めたまま運び、予算は残りを渡す', async () => {
|
|
1069
|
+
const operation = { source_thread_id: 'S', target_thread_id: 'T', codex_home: '/home' };
|
|
1070
|
+
// この製品が引き継ぎの初めに止めたゴール。後継では動かす。旧タスクのゴールは止めたまま残す。
|
|
1071
|
+
let goals = { S: { status: 'paused', objective: '目標', tokenBudget: 1000, tokensUsed: 300 } }; let sent = [];
|
|
1072
|
+
assert.equal(await carryAutoHandoffGoal(operation, {}, { connect: goalReceiver(goals, sent), wasActive: true }), 'active');
|
|
1073
|
+
assert.deepEqual(sent.at(-1), ['thread/goal/set', { threadId: 'T', objective: '目標', status: 'active', tokenBudget: 700 }]);
|
|
1074
|
+
assert.equal(goals.S.status, 'paused'); assert.equal(goals.S.objective, '目標');
|
|
1075
|
+
|
|
1076
|
+
// 利用者が止めていたゴール。止めたまま運ぶ。予算の無いゴールは、予算を付けない。
|
|
1077
|
+
goals = { S: { status: 'paused', objective: '目標', tokenBudget: null, tokensUsed: 300 } }; sent = [];
|
|
1078
|
+
assert.equal(await carryAutoHandoffGoal(operation, {}, { connect: goalReceiver(goals, sent) }), 'paused');
|
|
1079
|
+
assert.deepEqual(sent.at(-1), ['thread/goal/set', { threadId: 'T', objective: '目標', status: 'paused' }]);
|
|
1080
|
+
|
|
1081
|
+
// 初めの一時停止が効いていなかった時は、ここで旧タスクを先に止めてから、後継で動かす。
|
|
1082
|
+
goals = { S: { status: 'active', objective: '目標' } }; sent = [];
|
|
1083
|
+
assert.equal(await carryAutoHandoffGoal(operation, {}, { connect: goalReceiver(goals, sent) }), 'active');
|
|
1084
|
+
assert.deepEqual(sent.map(([method, params]) => `${method}:${params.threadId}:${params.status ?? ''}`),
|
|
1085
|
+
['thread/goal/get:S:', 'thread/goal/set:S:paused', 'thread/goal/get:T:', 'thread/goal/set:T:active']);
|
|
1086
|
+
|
|
1087
|
+
// 後継にもうゴールがある時は書き換えない。
|
|
1088
|
+
goals = { S: { status: 'paused', objective: '目標' }, T: { status: 'active', objective: '別の目標' } }; sent = [];
|
|
1089
|
+
assert.equal(await carryAutoHandoffGoal(operation, {}, { connect: goalReceiver(goals, sent), wasActive: true }), 'kept');
|
|
1090
|
+
assert.equal(goals.T.objective, '別の目標'); assert.ok(!sent.some(([method]) => method === 'thread/goal/set'));
|
|
1091
|
+
|
|
1092
|
+
// ゴールが無い、完了した、Codex が上限や行き詰まりで止めた。運ばない。
|
|
1093
|
+
for (const goal of [null, { status: 'complete', objective: '目標' }, { status: 'budgetLimited', objective: '目標' },
|
|
1094
|
+
{ status: 'usageLimited', objective: '目標' }, { status: 'blocked', objective: '目標' }]) {
|
|
1095
|
+
sent = [];
|
|
1096
|
+
assert.equal(await carryAutoHandoffGoal(operation, {}, { connect: goalReceiver({ S: goal }, sent), wasActive: true }), 'none');
|
|
1097
|
+
assert.equal(sent.length, 1, '読むだけ');
|
|
1098
|
+
}
|
|
1099
|
+
// 今も動いているゴールだけを運ぶ指定では、止まっているゴールに触らない。
|
|
1100
|
+
goals = { S: { status: 'paused', objective: '目標' } }; sent = [];
|
|
1101
|
+
assert.equal(await carryAutoHandoffGoal(operation, {}, { connect: goalReceiver(goals, sent), onlyActive: true }), 'none');
|
|
1102
|
+
assert.equal(sent.length, 1); assert.equal(goals.T, undefined);
|
|
1103
|
+
// 別の旧タスク(後継のさらに前のタスク)を指定できる。
|
|
1104
|
+
goals = { OLD: { status: 'active', objective: '目標' } }; sent = [];
|
|
1105
|
+
assert.equal(await carryAutoHandoffGoal(operation, {}, { connect: goalReceiver(goals, sent), onlyActive: true, sourceThreadId: 'OLD' }), 'active');
|
|
1106
|
+
assert.equal(goals.OLD.status, 'paused'); assert.equal(goals.T.status, 'active');
|
|
1107
|
+
// 運べなくても例外にしない。
|
|
1108
|
+
assert.equal(await carryAutoHandoffGoal(operation, {}, { connect: goalReceiver({ S: { status: 'paused', objective: 'x' } }, [], 'thread/goal/set') }), 'failed');
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
// 1つのDBで何度も引き継ぐ試験では、後継のIDを旧タスクごとに変える(後継のIDは一意)。
|
|
1112
|
+
function uniqueTarget(deps, source) {
|
|
1113
|
+
const createTarget = deps.createTarget;
|
|
1114
|
+
deps.createTarget = async (db, operation) => {
|
|
1115
|
+
await createTarget(db, operation);
|
|
1116
|
+
updateAutoHandoff(db, operation.handoff_id, { target_thread_id: `target-${source}` });
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
test('workerは、最初に旧タスクのゴールを止め、後継が作業を続けた後でゴールを運ぶ。順番待ちを運ぶより先', async () => withDb(async db => {
|
|
1121
|
+
const run = async (source, counters, before = () => {}) => {
|
|
1122
|
+
const operation = request(db, source, { now: 1 }).operation;
|
|
1123
|
+
before(operation);
|
|
1124
|
+
const deps = workerDependencies(db, {}, async () => ({ queued_submission_id: 'receipt' }), counters);
|
|
1125
|
+
uniqueTarget(deps, source);
|
|
1126
|
+
const order = []; const carryQueue = deps.carryQueue, carryGoal = deps.carryGoal;
|
|
1127
|
+
deps.carryQueue = async (...args) => { order.push('queue'); return carryQueue(...args); };
|
|
1128
|
+
deps.carryGoal = async (...args) => { order.push('goal'); return carryGoal(...args); };
|
|
1129
|
+
return { result: await runAutoHandoffWorker(operation.handoff_id, { db, dependencies: deps }), order };
|
|
1130
|
+
};
|
|
1131
|
+
// 動いているゴールがあった。
|
|
1132
|
+
let counters = { created: 0, notified: 0, goalPause: 'paused', goalCarry: 'active' };
|
|
1133
|
+
let { result, order } = await run('goal-active', counters);
|
|
1134
|
+
assert.equal(result.state, 'continued');
|
|
1135
|
+
assert.deepEqual(counters.goal, ['pause:requested', 'carry:continued:true']);
|
|
1136
|
+
assert.deepEqual(order, ['goal', 'queue']);
|
|
1137
|
+
assert.deepEqual(getAutoHandoff(db, result.handoff_id).runtime.goalCarry, { paused: true, carried: 'active' });
|
|
1138
|
+
assert.equal(getAutoHandoff(db, result.handoff_id).runtime.targetPrepared, true, 'ゴールの記録で、ほかの記録を消さない');
|
|
1139
|
+
|
|
1140
|
+
// ゴールが無い・扱えない時も引き継ぎは進む。
|
|
1141
|
+
for (const [index, goalPause] of ['none', 'idle', 'failed'].entries()) {
|
|
1142
|
+
counters = { created: 0, notified: 0, goalPause, goalCarry: goalPause === 'idle' ? 'paused' : 'none' };
|
|
1143
|
+
({ result } = await run(`goal-${index}`, counters));
|
|
1144
|
+
assert.equal(result.state, 'continued'); assert.equal(counters.notified, 0);
|
|
1145
|
+
assert.deepEqual(counters.goal, ['pause:requested', 'carry:continued:false']);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// 途中で止まった引き継ぎをやり直す時は、ゴールをもう一度は読まず、最初に止めた事を覚えている。
|
|
1149
|
+
counters = { created: 0, notified: 0, goalPause: 'paused', goalCarry: 'active' };
|
|
1150
|
+
const operation = request(db, 'goal-resume', { now: 1 }).operation;
|
|
1151
|
+
const deps = workerDependencies(db, {}, async () => ({ queued_submission_id: 'receipt' }), counters);
|
|
1152
|
+
uniqueTarget(deps, 'goal-resume');
|
|
1153
|
+
deps.summaries = () => { throw new Error('別の失敗'); };
|
|
1154
|
+
const failed = await runAutoHandoffWorker(operation.handoff_id, { db, dependencies: deps });
|
|
1155
|
+
assert.equal(failed.state, 'failed'); assert.deepEqual(failed.runtime.goalCarry, { paused: true });
|
|
1156
|
+
assert.deepEqual(counters.goal, ['pause:requested'], '途中で止まった引き継ぎでは、ゴールを後継へ運ばない');
|
|
1157
|
+
deps.summaries = () => {}; counters.goalPause = 'idle';
|
|
1158
|
+
const resumed = await runAutoHandoffWorker(operation.handoff_id, { db, resume: true, dependencies: deps });
|
|
1159
|
+
assert.equal(resumed.state, 'continued');
|
|
1160
|
+
assert.deepEqual(counters.goal, ['pause:requested', 'carry:continued:true']);
|
|
1161
|
+
|
|
1162
|
+
// 同じ旧タスクの前の引き継ぎ(途中で止まった物)が止めていたゴールは、新しい引き継ぎでも動いていた物として運ぶ。
|
|
1163
|
+
counters = { created: 0, notified: 0, goalPause: 'idle', goalCarry: 'active' };
|
|
1164
|
+
({ result } = await run('goal-earlier', counters, current => {
|
|
1165
|
+
const earlier = requestAutoHandoff(db, { threadId: 'goal-earlier', turnId: 'earlier-turn', projectPath: '/project',
|
|
1166
|
+
rolloutPath: '/codex/rollout.jsonl', codexHome: '/codex', openHost: 'desktop', now: 0 }).operation;
|
|
1167
|
+
updateAutoHandoff(db, earlier.handoff_id, { state: 'failed', runtime_json: { goalCarry: { paused: true } } });
|
|
1168
|
+
assert.notEqual(earlier.handoff_id, current.handoff_id);
|
|
1169
|
+
}));
|
|
1170
|
+
assert.deepEqual(counters.goal, ['pause:requested', 'carry:continued:true']);
|
|
1171
|
+
}));
|
|
1172
|
+
|
|
1173
|
+
// Codex がゴールの続行で起こして、hook が止めたターンの実物の並び(Windows の Codex Desktop 0.162.0-alpha.17.2、2026-10-13)。
|
|
1174
|
+
function goalContinuation(turn, at) {
|
|
1175
|
+
return [
|
|
1176
|
+
{ timestamp: `${at}.460Z`, type: 'event_msg', payload: { type: 'task_started', turn_id: turn } },
|
|
1177
|
+
{ timestamp: `${at}.782Z`, type: 'response_item', payload: { type: 'message', role: 'user',
|
|
1178
|
+
content: [{ type: 'input_text', text: '<codex_internal_context source="goal">\nContinue working toward the active thread goal.\n</codex_internal_context>' }] } },
|
|
1179
|
+
{ timestamp: `${at}.802Z`, type: 'event_msg', payload: { type: 'turn_aborted', turn_id: turn, reason: 'interrupted' } },
|
|
1180
|
+
];
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
test('rollout から、ターンの入力が Codex 自身の文脈だけだったかを読む', async () => withDb((db, home) => {
|
|
1184
|
+
const thread = '11111111-1111-1111-1111-111111111111';
|
|
1185
|
+
const file = sourceRollout(home, thread, 'turn-a', [...goalContinuation('turn-goal', '2026-10-07T00:50:00'),
|
|
1186
|
+
...stoppedInput('turn-person', '2026-10-07T00:51:00')]);
|
|
1187
|
+
const read = turnId => readCodexHandoffState(file, { threadId: thread, turnId }).internalInputOnly;
|
|
1188
|
+
assert.equal(read('turn-goal'), true); assert.equal(read('turn-person'), false); assert.equal(read('turn-a'), false);
|
|
1189
|
+
assert.equal(readCodexHandoffState(file, { threadId: thread }).internalInputOnly, false, 'turn を指定しない時は最後の turn');
|
|
1190
|
+
// 入力が turn の開始より前に記録される並びでも同じ。
|
|
1191
|
+
const [started, input, aborted] = goalContinuation('turn-goal-2', '2026-10-07T00:52:00');
|
|
1192
|
+
const early = sourceRollout(home, '22222222-2222-2222-2222-222222222222', 'turn-a', [input, started, aborted]);
|
|
1193
|
+
assert.equal(readCodexHandoffState(early, { threadId: '22222222-2222-2222-2222-222222222222', turnId: 'turn-goal-2' }).internalInputOnly, true);
|
|
1194
|
+
// ターンの途中で Codex が足す環境の知らせ(日付が変わった時など)は、入力の出どころに数えない(macOS の Codex Desktop 0.162.0-alpha.17.2 の実物)。
|
|
1195
|
+
const [started3, input3, aborted3] = goalContinuation('turn-goal-3', '2026-10-07T00:53:00');
|
|
1196
|
+
const environment = { timestamp: '2026-10-07T00:53:00.790Z', type: 'response_item', payload: { type: 'message', role: 'user',
|
|
1197
|
+
content: [{ type: 'input_text', text: '<environment_context>\n <current_date>2026-10-08</current_date>\n</environment_context>' }] } };
|
|
1198
|
+
const dated = sourceRollout(home, '33333333-3333-3333-3333-333333333333', 'turn-a', [started3, input3, environment, aborted3]);
|
|
1199
|
+
assert.equal(readCodexHandoffState(dated, { threadId: '33333333-3333-3333-3333-333333333333', turnId: 'turn-goal-3' }).internalInputOnly, true);
|
|
1200
|
+
}));
|
|
1201
|
+
|
|
1202
|
+
test('引き継ぎ済みの旧タスクへゴールの続行が来た時は、止めるが後継を開かず、ゴールの片付けを起こす', async () => withDb(async (db, home) => {
|
|
1203
|
+
const thread = '11111111-1111-1111-1111-111111111111', turnA = '22222222-2222-2222-2222-222222222222';
|
|
1204
|
+
const goalTurn = '33333333-3333-3333-3333-333333333333', personTurn = '44444444-4444-4444-4444-444444444444';
|
|
1205
|
+
const first = delivered(db, home, thread, turnA, 'successor-1', 1);
|
|
1206
|
+
const config = { enabled: true, projects: [home], openHost: 'desktop' };
|
|
1207
|
+
const seen = { opened: [], recorded: [], settled: [] };
|
|
1208
|
+
const args = { db, config, launchWorker: async () => { throw new Error('後継を増やしてはいけない'); }, findThread: () => ({ mtimeMs: 1 }),
|
|
1209
|
+
openThread: operation => { seen.opened.push(operation.target_thread_id); return true; },
|
|
1210
|
+
recordRedirect: entry => { seen.recorded.push(entry); },
|
|
1211
|
+
settleGoal: async (threadId, operation) => { seen.settled.push([threadId, operation.handoff_id]); return seen.settled.length === 1; } };
|
|
1212
|
+
const payload = (turn, later) => ({ trigger: 'auto', cwd: home, session_id: thread, turn_id: turn,
|
|
1213
|
+
transcript_path: sourceRollout(home, thread, turnA, later) });
|
|
1214
|
+
|
|
1215
|
+
const goal = await requestCodexAutoHandoff({ ...args, payload: payload(goalTurn, goalContinuation(goalTurn, '2026-10-07T00:50:00')) });
|
|
1216
|
+
assert.equal(goal.continue, false); assert.equal(goal.redirected, true); assert.equal(goal.opened, false);
|
|
1217
|
+
assert.deepEqual(seen.opened, [], '人が入力していないターンでは、画面を後継へ切り替えない');
|
|
1218
|
+
assert.deepEqual(seen.settled, [[thread, first.handoff_id]]);
|
|
1219
|
+
assert.deepEqual({ ...seen.recorded[0], at: null }, { at: null, source_thread_id: thread, source_turn_id: goalTurn,
|
|
1220
|
+
handoff_id: first.handoff_id, target_thread_id: 'successor-1', opened: false, internal: true, goal_settle: true });
|
|
1221
|
+
|
|
1222
|
+
// 人の入力では、今までどおり後継を開く。ゴールの片付けは、起こす側が間隔を空ける(ここでは2回めを起こさない)。
|
|
1223
|
+
const person = await requestCodexAutoHandoff({ ...args, payload: payload(personTurn, stoppedInput(personTurn, '2026-10-07T00:51:00')) });
|
|
1224
|
+
assert.equal(person.opened, true); assert.deepEqual(seen.opened, ['successor-1']);
|
|
1225
|
+
assert.equal(seen.recorded[1].internal, undefined); assert.equal(seen.recorded[1].goal_settle, undefined);
|
|
1226
|
+
// 片付けを起こせなくても、ターンは止める。
|
|
1227
|
+
const broken = await requestCodexAutoHandoff({ ...args, settleGoal: async () => { throw new Error('起こせません'); },
|
|
1228
|
+
payload: payload(goalTurn, goalContinuation(goalTurn, '2026-10-07T00:50:00')) });
|
|
1229
|
+
assert.equal(broken.continue, false); assert.equal(broken.redirected, true);
|
|
1230
|
+
}));
|
|
1231
|
+
|
|
1232
|
+
test('ゴールの片付けは、同じ旧タスクへは間隔を空けて1つだけ起こす', async () => withDb(async () => {
|
|
1233
|
+
const spawned = [];
|
|
1234
|
+
const spawn = (command, args, options) => {
|
|
1235
|
+
spawned.push({ args, detached: options.detached });
|
|
1236
|
+
const child = new EventEmitter(); child.unref = () => {}; child.pid = 1;
|
|
1237
|
+
queueMicrotask(() => child.emit('spawn'));
|
|
1238
|
+
return child;
|
|
1239
|
+
};
|
|
1240
|
+
const operation = { handoff_id: 'handoff-1' };
|
|
1241
|
+
assert.equal(await launchAutoHandoffGoalSettle('thread-a', operation, { spawn }), true);
|
|
1242
|
+
assert.deepEqual(spawned[0].args.slice(1), ['auto-handoff', 'settle-goal', '--operation', 'handoff-1', '--thread', 'thread-a', '--json']);
|
|
1243
|
+
assert.equal(spawned[0].detached, true);
|
|
1244
|
+
assert.equal(await launchAutoHandoffGoalSettle('thread-a', operation, { spawn }), false, '続けて来ても起こさない');
|
|
1245
|
+
assert.equal(await launchAutoHandoffGoalSettle('thread-b', operation, { spawn }), true, '別の旧タスクは別に数える');
|
|
1246
|
+
assert.equal(await launchAutoHandoffGoalSettle('thread-a', operation, { spawn, now: Date.now() + 31_000 }), true, '間隔が空けば、もう一度起こす');
|
|
1247
|
+
assert.equal(spawned.length, 3);
|
|
1248
|
+
}));
|
|
1249
|
+
|
|
1250
|
+
test('片付けは、旧タスクの動いているゴールを止めて、今の一番先の後継へ運び、記録を1行残す', async () => withDb(async (db, home) => {
|
|
1251
|
+
const thread = '11111111-1111-1111-1111-111111111111';
|
|
1252
|
+
const first = delivered(db, home, thread, 'turn-a', 'successor-1', 1);
|
|
1253
|
+
const second = delivered(db, home, 'successor-1', 'turn-b', 'successor-2', 2);
|
|
1254
|
+
const seen = { carried: [], recorded: [] };
|
|
1255
|
+
const options = goal => ({ executable: () => '公式Desktop試験fixture', findThread: () => ({ mtimeMs: 1 }),
|
|
1256
|
+
carryGoal: async (operation, runtime, carry) => { seen.carried.push([operation.handoff_id, carry, runtime.executable]); return goal; },
|
|
1257
|
+
record: entry => { seen.recorded.push(entry); } });
|
|
1258
|
+
const settled = await settleAutoHandoffSourceGoal(db, { operationId: first.handoff_id, threadId: thread }, options('active'));
|
|
1259
|
+
assert.deepEqual(settled, { status: 'settled', goal: 'active' });
|
|
1260
|
+
assert.deepEqual(seen.carried, [[second.handoff_id, { sourceThreadId: thread, onlyActive: true }, '公式Desktop試験fixture']]);
|
|
1261
|
+
assert.deepEqual({ ...seen.recorded[0], at: null }, { at: null, source_thread_id: thread, handoff_id: second.handoff_id,
|
|
1262
|
+
target_thread_id: 'successor-2', goal: 'active' });
|
|
1263
|
+
// 動いているゴールが無ければ、記録を増やさない。
|
|
1264
|
+
assert.deepEqual(await settleAutoHandoffSourceGoal(db, { operationId: first.handoff_id, threadId: thread }, options('none')), { status: 'settled', goal: 'none' });
|
|
1265
|
+
assert.equal(seen.recorded.length, 1);
|
|
1266
|
+
// 後継が残っていない旧タスクは、今の続きなので触らない。
|
|
1267
|
+
const gone = await settleAutoHandoffSourceGoal(db, { operationId: first.handoff_id, threadId: thread }, { ...options('active'), findThread: () => null });
|
|
1268
|
+
assert.equal(gone.status, 'skipped'); assert.equal(seen.carried.length, 2);
|
|
1269
|
+
await assert.rejects(settleAutoHandoffSourceGoal(db, { operationId: first.handoff_id, threadId: 'x y' }, options('active')), /handoff_thread_id_invalid/);
|
|
1270
|
+
}));
|
|
1271
|
+
|
|
1272
|
+
test('公開CLIのsettle-goalは、引き継ぎIDと旧タスクのIDの両方を要る', () => {
|
|
1273
|
+
const parsed = parseAutoHandoffArgs(['settle-goal', '--operation', 'id', '--thread', 'thread', '--json']);
|
|
1274
|
+
assert.equal(parsed.action, 'settle-goal'); assert.equal(parsed.operation, 'id'); assert.equal(parsed.thread, 'thread');
|
|
1275
|
+
for (const args of [['settle-goal', '--operation', 'id'], ['settle-goal', '--thread', 'thread'],
|
|
1276
|
+
['settle-goal', '--operation', 'id', '--thread', 'thread', '--host', 'claude']]) {
|
|
1277
|
+
assert.throws(() => parseAutoHandoffArgs(args));
|
|
1278
|
+
}
|
|
1279
|
+
});
|
|
@@ -3,7 +3,14 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { delimiter, join } from 'node:path';
|
|
6
|
-
import {
|
|
6
|
+
import { testTempDir } from './test-temp-dir.mjs';
|
|
7
|
+
|
|
8
|
+
// haiku-summarizer は import の時に homedir() から隔離 cwd の道を決める。
|
|
9
|
+
// 試験が動かす利用者の本物の home へ ~/.throughline を作らないよう、先に home を差し替える。
|
|
10
|
+
const TEST_HOME = testTempDir('tl-l1-home-');
|
|
11
|
+
process.env.HOME = TEST_HOME;
|
|
12
|
+
process.env.USERPROFILE = TEST_HOME;
|
|
13
|
+
const { summarizeToL1, resolveL1Ratio } = await import('./haiku-summarizer.mjs');
|
|
7
14
|
|
|
8
15
|
function makeBin(dir, name, body) {
|
|
9
16
|
const script = join(dir, `${name}.mjs`);
|
|
@@ -95,6 +95,11 @@ export function threadStartSettings(settings) {
|
|
|
95
95
|
...permissions, config: { model_reasoning_effort: settings.effort } };
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
// Codex が自分でターンへ入れる文脈の頭(`codex-rs/core/src/context/internal_model_context.rs` の `CONTEXT_START_MARKER`)。
|
|
99
|
+
const CODEX_INTERNAL_CONTEXT_PATTERN = /^\s*<codex_internal_context[\s>]/;
|
|
100
|
+
// Codex がターンの途中で足す環境の知らせ(日付や作業フォルダが変わった時)。入力の出どころには数えない。
|
|
101
|
+
const CODEX_ENVIRONMENT_CONTEXT_PATTERN = /^\s*<environment_context>/;
|
|
102
|
+
|
|
98
103
|
export function readCodexHandoffState(path, { threadId = null, turnId = null, deliveryText = null } = {}) {
|
|
99
104
|
const rows = decodeRows(path);
|
|
100
105
|
let meta = null, rawSettings = null, preparedSettings = null, context = null, latestTurnId = null;
|
|
@@ -110,6 +115,15 @@ export function readCodexHandoffState(path, { threadId = null, turnId = null, de
|
|
|
110
115
|
// activity はモデルの発言・道具の呼び出し・圧縮・完了のどれかがあったか。入力を受けただけで止まった turn は activity が false。
|
|
111
116
|
const laterTurns = [];
|
|
112
117
|
let sourceSeen = false, later = null;
|
|
118
|
+
// 見ている turn(turnId を指定した時はその turn、しない時は最後の turn)の入力の出どころ。Codex が自分で入れる文脈
|
|
119
|
+
// (ゴールの続行の指示など。`<codex_internal_context …>` で始まる)と、それ以外の入力(人やほかの製品が送った物)を分ける。
|
|
120
|
+
const inputKinds = { turn: { internal: false, other: false }, pending: { internal: false, other: false } };
|
|
121
|
+
const noteInput = text => {
|
|
122
|
+
if (CODEX_ENVIRONMENT_CONTEXT_PATTERN.test(text)) return;
|
|
123
|
+
const watched = !turnId || latestTurnId === turnId;
|
|
124
|
+
const kinds = turnOpen ? (watched ? inputKinds.turn : null) : inputKinds.pending;
|
|
125
|
+
if (kinds) kinds[CODEX_INTERNAL_CONTEXT_PATTERN.test(text) ? 'internal' : 'other'] = true;
|
|
126
|
+
};
|
|
113
127
|
for (const row of rows) {
|
|
114
128
|
const payload = row.payload;
|
|
115
129
|
if (later && (row.type === 'compacted' || row.type === 'token_usage_record')) later.activity = true;
|
|
@@ -126,6 +140,8 @@ export function readCodexHandoffState(path, { threadId = null, turnId = null, de
|
|
|
126
140
|
if (turnId && latestTurnId === turnId) sourceSeen = true;
|
|
127
141
|
else if (turnId && sourceSeen) { later = { turnId: latestTurnId, closed: false, activity: false }; laterTurns.push(later); }
|
|
128
142
|
hasTurnInput = pendingUser != null;
|
|
143
|
+
if (!turnId || latestTurnId === turnId) inputKinds.turn = inputKinds.pending;
|
|
144
|
+
inputKinds.pending = { internal: false, other: false };
|
|
129
145
|
if (!turnId || latestTurnId === turnId) {
|
|
130
146
|
turnStartAt = Date.parse(row.timestamp); pendingCalls.clear(); completedTools = 0;
|
|
131
147
|
stoppedAt = null; stoppedReason = null; completedAt = null;
|
|
@@ -158,6 +174,7 @@ export function readCodexHandoffState(path, { threadId = null, turnId = null, de
|
|
|
158
174
|
const text = (payload.content ?? []).filter(c => typeof c.text === 'string').map(c => c.text).join('\n');
|
|
159
175
|
const input = text.trim() && !text.startsWith('# AGENTS.md instructions') && !text.startsWith('<hook_prompt');
|
|
160
176
|
if (input) {
|
|
177
|
+
noteInput(text);
|
|
161
178
|
if (turnOpen) hasTurnInput = true;
|
|
162
179
|
else pendingUser = text;
|
|
163
180
|
}
|
|
@@ -193,6 +210,7 @@ export function readCodexHandoffState(path, { threadId = null, turnId = null, de
|
|
|
193
210
|
else pendingUser = payload.message;
|
|
194
211
|
}
|
|
195
212
|
if (row.type === 'event_msg' && payload?.type === 'user_message') {
|
|
213
|
+
noteInput(typeof payload.message === 'string' ? payload.message : '');
|
|
196
214
|
if (turnOpen) hasTurnInput = true;
|
|
197
215
|
else pendingUser = payload.message;
|
|
198
216
|
}
|
|
@@ -218,5 +236,7 @@ export function readCodexHandoffState(path, { threadId = null, turnId = null, de
|
|
|
218
236
|
return { meta, settings, rawSettings, preparedSettings, context, latestTurnId, turnStartAt, stoppedAt, stoppedReason,
|
|
219
237
|
completedAt, correlatedTurnId, hasTurnInput, progress, completedTools, pendingCallCount: pendingCalls.size,
|
|
220
238
|
pendingNativeSessionCount: nativeSessions.size, inheritedThroughlineMemory, inheritedThroughlineMemoryText, laterTurns,
|
|
239
|
+
// 見ている turn の入力が、Codex が自分で入れた文脈だけだった(人の入力ではない)。
|
|
240
|
+
internalInputOnly: inputKinds.turn.internal && !inputKinds.turn.other,
|
|
221
241
|
compactedRows: rows.filter(r => r.type === 'compacted').length };
|
|
222
242
|
}
|
|
@@ -11,7 +11,8 @@ const bin = fileURLToPath(new URL('../bin/throughline.mjs', import.meta.url));
|
|
|
11
11
|
test('公開CLIは部屋ごとの直近3発言を現在の発言まで返す', () => {
|
|
12
12
|
const home = mkdtempSync(join(tmpdir(), 'tl-room-context-'));
|
|
13
13
|
const run = (input) => spawnSync(process.execPath, [bin, 'room-context', '--json'], {
|
|
14
|
-
|
|
14
|
+
// Windows は USERPROFILE で home を決める。HOME だけだと、動かす利用者の本物の DB を開く。
|
|
15
|
+
env: { ...process.env, HOME: home, USERPROFILE: home }, input: JSON.stringify(input), encoding: 'utf8',
|
|
15
16
|
});
|
|
16
17
|
const base = { projectPath: '/srv/bellteam', roomId: 'room-a', speaker: 'クオ', text: '発言' };
|
|
17
18
|
try {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
4
|
-
import { chmodSync, existsSync, mkdirSync,
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { DatabaseSync } from 'node:sqlite';
|
|
6
|
-
import { tmpdir } from 'node:os';
|
|
7
6
|
import { dirname, join } from 'node:path';
|
|
7
|
+
import { testTempDir } from './test-temp-dir.mjs';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { defaultRuntimeErrorConfigPath, defaultRuntimeErrorStorePath } from './runtime-error-store.mjs';
|
|
10
10
|
import { applyWindowsPrivateAcl } from './os/windows-acl-test-helper.mjs';
|
|
@@ -12,7 +12,7 @@ import { applyWindowsPrivateAcl } from './os/windows-acl-test-helper.mjs';
|
|
|
12
12
|
const BIN = fileURLToPath(new URL('../bin/throughline.mjs', import.meta.url));
|
|
13
13
|
|
|
14
14
|
function createEnabledEnvironment(prefix) {
|
|
15
|
-
const root =
|
|
15
|
+
const root = testTempDir(prefix);
|
|
16
16
|
const env = {
|
|
17
17
|
...process.env,
|
|
18
18
|
HOME: root,
|
|
@@ -2,10 +2,10 @@ import { test } from 'node:test';
|
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { spawn, spawnSync } from 'node:child_process';
|
|
4
4
|
import { createHash, createHmac } from 'node:crypto';
|
|
5
|
-
import { chmodSync, existsSync,
|
|
5
|
+
import { chmodSync, existsSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { createServer } from 'node:http';
|
|
7
|
-
import { tmpdir } from 'node:os';
|
|
8
7
|
import { join } from 'node:path';
|
|
8
|
+
import { testTempDir } from './test-temp-dir.mjs';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
|
|
11
11
|
import {
|
|
@@ -37,7 +37,7 @@ const KEY_ID = 'test-host.throughline';
|
|
|
37
37
|
const NOW = Date.parse('2026-10-03T08:00:00.000Z');
|
|
38
38
|
|
|
39
39
|
function createEnvironment(prefix = 'throughline-report-') {
|
|
40
|
-
const root =
|
|
40
|
+
const root = testTempDir(prefix);
|
|
41
41
|
const env = {
|
|
42
42
|
...process.env,
|
|
43
43
|
HOME: root,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import childProcess, { spawn } from 'node:child_process';
|
|
4
|
-
import { chmodSync, existsSync,
|
|
5
|
-
import { tmpdir } from 'node:os';
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
6
5
|
import { dirname, join } from 'node:path';
|
|
6
|
+
import { testTempDir } from './test-temp-dir.mjs';
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
acknowledgeRuntimeErrors,
|
|
@@ -22,7 +22,7 @@ import { applyWindowsPrivateAcl, verifyWindowsPrivateAcl } from './os/windows-ac
|
|
|
22
22
|
const TEST_PLATFORM = process.platform === 'win32' ? 'win32' : 'darwin';
|
|
23
23
|
|
|
24
24
|
function sandbox() {
|
|
25
|
-
const root =
|
|
25
|
+
const root = testTempDir('throughline-runtime-errors-');
|
|
26
26
|
const env = {
|
|
27
27
|
HOME: root,
|
|
28
28
|
USERPROFILE: root,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
-
import {
|
|
5
|
-
import { tmpdir } from 'node:os';
|
|
4
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
6
5
|
import { join } from 'node:path';
|
|
6
|
+
import { testTempDir } from './test-temp-dir.mjs';
|
|
7
7
|
import { spikeInject, generateSpikeTracer } from './spike-transcript-writer.mjs';
|
|
8
8
|
|
|
9
9
|
function makeDb() {
|
|
@@ -76,7 +76,7 @@ function seedSession(db, { newSessionId = 'NEW', originId = 'ORIG' } = {}) {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
function makeTempJsonl() {
|
|
79
|
-
const dir =
|
|
79
|
+
const dir = testTempDir('spike-writer-test-');
|
|
80
80
|
return join(dir, 'session.jsonl');
|
|
81
81
|
}
|
|
82
82
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// 試験が作る一時フォルダを、その試験 file の process が終わる時に消す。
|
|
2
|
+
// CI のランナーは使い捨てではないので、消さないと回ごとに OS の一時フォルダへ積もる
|
|
3
|
+
// (rabbit の /tmp に 3,026 個。2026-10-13 の計測)。
|
|
4
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
const created = [];
|
|
9
|
+
|
|
10
|
+
process.on('exit', () => {
|
|
11
|
+
for (const dir of created) {
|
|
12
|
+
try {
|
|
13
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
|
|
14
|
+
} catch {
|
|
15
|
+
// 開いたままの file が残る時(Windows)は消せない。試験の成否は変えない。
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export function testTempDir(prefix) {
|
|
21
|
+
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
22
|
+
created.push(dir);
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import {
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
3
|
+
import { writeFileSync } from 'node:fs';
|
|
5
4
|
import { join } from 'node:path';
|
|
5
|
+
import { testTempDir } from './test-temp-dir.mjs';
|
|
6
6
|
import {
|
|
7
7
|
normalizeTerminalText,
|
|
8
8
|
normalizeToolResultContent,
|
|
@@ -36,7 +36,7 @@ test('normalizeTerminalText: CRLF を LF にし、行内 CR は最後の上書
|
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
function writeTranscript(entries) {
|
|
39
|
-
const dir =
|
|
39
|
+
const dir = testTempDir('tl-transcript-');
|
|
40
40
|
const path = join(dir, 't.jsonl');
|
|
41
41
|
writeFileSync(path, entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
|
|
42
42
|
return path;
|