throughline 0.16.12 → 0.16.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -1
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/docs/adr/0046-codex-handoff-carries-untracked-inherited-memory.md +39 -0
- package/docs/adr/0047-codex-internal-compaction-is-stopped-without-error.md +40 -0
- package/package.json +1 -1
- package/src/codex-auto-handoff-memory.mjs +10 -0
- package/src/codex-auto-handoff.mjs +36 -5
- package/src/codex-auto-handoff.test.mjs +94 -7
- package/src/hosts/codex-handoff-state.mjs +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,54 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.16.14] — 2026-10-10
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- Codex Desktop(同梱のCodex 0.162.0-alpha.17.2)が、内部の文脈(ターンのIDが`auto-compact-N`)から`PreCompact(auto)`のhookを呼ぶ時、Throughlineが`handoff_hook_identity_invalid`の失敗として記録していた。macOSの実機で3回。圧縮は止まっていて、タスクにも影響は無かったが、起きていない失敗がhighで記録された。この形の呼び出しは、失敗にせず圧縮だけを止め、`~/.throughline/codex-auto-handoff/internal-compactions.jsonl`に1行残す([ADR 0047](docs/adr/0047-codex-internal-compaction-is-stopped-without-error.md))。
|
|
18
|
+
- 圧縮を止める動きは今までと同じ。引き継ぎは、文脈が上限に近づいた利用者のターンで今までどおり起きる。
|
|
19
|
+
- それ以外の不正なID(会話のIDがUUIDでない、記録の場所が無い、知らない形のターンのID)は、今までどおり失敗として記録する。
|
|
20
|
+
|
|
21
|
+
### 対応の範囲
|
|
22
|
+
|
|
23
|
+
| | macOS | Linux | Windows |
|
|
24
|
+
|---|---|---|---|
|
|
25
|
+
| Codex(Desktop) | 直した。単体試験と、切り離した置き場での本物のhookの命令で確認(内部のIDは失敗0件・記録1行、不正なIDは失敗1件)。CIのmacOSも成功。**本物のCodex Desktopが内部の文脈からhookを呼ぶ場面は、狙って起こせないので未確認** | 直した。単体試験とCI。実機(同梱0.162.0-alpha.2)では、この呼び出しが起きていない | 直した。単体試験とCI。実機では、この失敗の記録が無い |
|
|
26
|
+
| Claude Code | 対象外。Claude Codeの`PreCompact`は別の実装で、ターンのIDを検査しない | 同じ | 同じ |
|
|
27
|
+
| Grok | 対象外。自動継続の機能が無く、`PreCompact`のhookを登録していない | 同じ | 同じ |
|
|
28
|
+
| Cursor | 対象外。自動継続の機能が無く、`PreCompact`のhookを登録していない | 同じ | 同じ |
|
|
29
|
+
|
|
30
|
+
### 確認していない範囲
|
|
31
|
+
|
|
32
|
+
- 本物のCodex Desktopが内部の文脈からhookを呼ぶ場面での、新しい版の動き(次に起きた時に、失敗の記録が増えず、`internal-compactions.jsonl`に行が出る事で確かめる)。
|
|
33
|
+
- 内部の文脈からの圧縮が、Codexのどの処理から始まるか(公開ソースでIDの形と渡り方だけを確かめた)。
|
|
34
|
+
|
|
35
|
+
## [0.16.13] — 2026-10-10
|
|
36
|
+
|
|
37
|
+
### 修正
|
|
38
|
+
|
|
39
|
+
- Codex Desktopの自動継続が、手動の引き継ぎ(`$throughline`)などで始めたタスクを`handoff_memory_lineage_untracked`で止め、そのタスクでは何度入力しても同じ理由で止まり続けた。macOSの実機で1つのタスクが止まり、ほかの道具からの返信も始まらなくなった。前任をさかのぼれない記憶を受け取ったタスクも引き継ぎ、受け取った記憶の本文をそのまま後継へ渡す([ADR 0046](docs/adr/0046-codex-handoff-carries-untracked-inherited-memory.md))。
|
|
40
|
+
- 後継の記憶に「系列の最初のタスクが受け取っていた記憶(そのまま)」の節として載せる。その後継がさらに引き継ぐ時も載せる。120,000字を超える分は切り、切った事を書く。
|
|
41
|
+
- 0.16.12までで止まっていたタスクは、次の入力か`throughline auto-handoff resume --operation <ID>`で引き継がれる。
|
|
42
|
+
|
|
43
|
+
### 対応の範囲
|
|
44
|
+
|
|
45
|
+
| | macOS | Linux | Windows |
|
|
46
|
+
|---|---|---|---|
|
|
47
|
+
| Codex(Desktop) | 直した。実機の止まっていたタスクの記録を読めた(受け取った記憶10,006字)。再開の結果は公開の後に確かめる | 直した。本物のCodex Desktopで、手動の引き継ぎの記憶を持つタスクから2回続けて引き継ぎ、記憶が2つの後継へ渡った | 直した。本物のCodex Desktopでは流していない。CIのWindowsで単体試験が通った |
|
|
48
|
+
| Claude Code | 対象外。Claude Codeの自動継続には、この理由で止める所が無い(記憶は通常のrecallで取る) | 同じ | 同じ |
|
|
49
|
+
| Grok | 対象外。自動継続の機能が無い | 同じ | 同じ |
|
|
50
|
+
| Cursor | 対象外。自動継続の機能が無い | 同じ | 同じ |
|
|
51
|
+
|
|
52
|
+
### 変わらない事
|
|
53
|
+
|
|
54
|
+
- 自動継続の記録に前任があるタスクの記憶の集め方。実行中の子agent・設定の不一致で止まる扱い。
|
|
55
|
+
|
|
56
|
+
### 確認していない範囲
|
|
57
|
+
|
|
58
|
+
- Windowsの本物のCodex Desktopでの通し。
|
|
59
|
+
- 受け取った記憶が120,000字を超える実物(試験では作り物で確かめた)。
|
|
60
|
+
|
|
13
61
|
## [0.16.12] — 2026-10-10
|
|
14
62
|
|
|
15
63
|
### 修正
|
|
@@ -2147,7 +2195,9 @@ two attempts, instrument first instead of patching again.
|
|
|
2147
2195
|
|
|
2148
2196
|
---
|
|
2149
2197
|
|
|
2150
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.
|
|
2198
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.14...HEAD
|
|
2199
|
+
[0.16.14]: https://github.com/kitepon/Throughline/compare/v0.16.13...v0.16.14
|
|
2200
|
+
[0.16.13]: https://github.com/kitepon/Throughline/compare/v0.16.12...v0.16.13
|
|
2151
2201
|
[0.16.12]: https://github.com/kitepon/Throughline/compare/v0.16.11...v0.16.12
|
|
2152
2202
|
[0.16.11]: https://github.com/kitepon/Throughline/compare/v0.16.10...v0.16.11
|
|
2153
2203
|
[0.16.10]: https://github.com/kitepon/Throughline/compare/v0.16.9...v0.16.10
|
package/README.ja.md
CHANGED
|
@@ -33,7 +33,7 @@ throughline auto-handoff disable
|
|
|
33
33
|
|
|
34
34
|
記憶は引き継ぎ全体の直近20ターンをL2全文、それより古いターンをL1で渡します。元のユーザー依頼と中断地点を保持し、tool結果とThinkingは注入しません。本文内の`auto-handoff detail`コマンドで、凍結したL2/L3を引き継ぎID・origin・turnから取得できます。A→B→Cの連続引き継ぎでも祖先の詳細を取得できます。
|
|
35
35
|
|
|
36
|
-
設定不一致、実行中の子agentや既知のnative
|
|
36
|
+
設定不一致、実行中の子agentや既知のnativeコマンドでは固定理由を残して停止します。手動の引き継ぎなど、前任をさかのぼれない記憶を受け取ったタスクは、受け取った記憶をそのまま後継へ渡して引き継ぎます(0.16.13から。それより前の版は、ここで引き継ぎを止めていました)。旧タスクの順番待ちに残っている入力(まだ送られていない物)は、後継が作業を続けた後に、順番を保って後継の順番待ちへ運びます(0.16.12から。それより前の版は、ここで引き継ぎを止めていました)。失敗時はローカルの説明ページを開きます。新タスクだけで容量が尽きる場合も、新タスクを増やし続けません。結果不明の指示は自動で再送しません。
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
39
|
throughline auto-handoff status --operation <handoff-id> --json
|
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ throughline auto-handoff disable
|
|
|
32
32
|
|
|
33
33
|
記憶は引き継ぎ全体の直近20ターンをL2全文、それより古いターンをL1で渡します。元のユーザー依頼と中断地点を保持し、tool結果とThinkingは注入しません。本文内の`auto-handoff detail`コマンドで、凍結したL2/L3を引き継ぎID・origin・turnから取得できます。A→B→Cの連続引き継ぎでも祖先の詳細を取得できます。
|
|
34
34
|
|
|
35
|
-
設定不一致、実行中の子agentや既知のnative
|
|
35
|
+
設定不一致、実行中の子agentや既知のnativeコマンドでは固定理由を残して停止します。手動の引き継ぎなど、前任をさかのぼれない記憶を受け取ったタスクは、受け取った記憶をそのまま後継へ渡して引き継ぎます(0.16.13から。それより前の版は、ここで引き継ぎを止めていました)。旧タスクの順番待ちに残っている入力(まだ送られていない物)は、後継が作業を続けた後に、順番を保って後継の順番待ちへ運びます(0.16.12から。それより前の版は、ここで引き継ぎを止めていました)。失敗時はローカルの説明ページを開きます。新タスクだけで容量が尽きる場合も、新タスクを増やし続けません。結果不明の指示は自動で再送しません。
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
38
|
throughline auto-handoff status --operation <handoff-id> --json
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# ADR 0046: 前任をさかのぼれない記憶を受け取った Codex のタスクも引き継ぎ、受け取った記憶をそのまま後継へ渡す
|
|
2
|
+
|
|
3
|
+
日付: 2026-10-10
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
Codex Desktop の自動継続は、旧タスクが Throughline の記憶(`## Throughline: 自動継続文脈`・`Active Work Context`・
|
|
8
|
+
`New Codex Thread Handoff` で始まる developer の文)を受け取っていて、その前任が自動継続の記録に無い時、
|
|
9
|
+
`handoff_memory_lineage_untracked` で引き継ぎを止めていた。後継へ渡す記憶は、自動継続の記録をさかのぼって集める。
|
|
10
|
+
さかのぼれない前任の作業は集められないので、黙って落とさないための決まりだった。
|
|
11
|
+
|
|
12
|
+
2026-10-10、macOS の実機で、手動の引き継ぎ(`$throughline`。`New Codex Thread Handoff`)で 10/8 に始めたタスクが、
|
|
13
|
+
この理由で止まった。止めた順番が問題だった。
|
|
14
|
+
|
|
15
|
+
- 自動継続は、`PreCompact(auto)` の hook でターンを止めてから、worker が引き継げるかを調べる。断った時、止めたターンは戻らない。
|
|
16
|
+
- 途中で止めたタスクは、Codex が順番待ちを動かさない。ほかの道具が入れた返信も始まらない(ADR 0045 と同じ)。
|
|
17
|
+
- 次に人が入力しても、文脈は上限のままなので `PreCompact(auto)` でまた止まり、同じ理由で断る。受け取った記憶は消えないので、
|
|
18
|
+
このタスクは何度入力しても引き継げない。
|
|
19
|
+
|
|
20
|
+
受け取った記憶の本文は、旧タスクの rollout の最初の developer の文に残っている(実機の例は 10,006 字)。
|
|
21
|
+
旧タスクのモデルが、それより前の作業について持っていた記憶は、この本文だけだった。
|
|
22
|
+
|
|
23
|
+
## Decision
|
|
24
|
+
|
|
25
|
+
1. 前任をさかのぼれない記憶を受け取ったタスクも、引き継ぐ。`handoff_memory_lineage_untracked` は出さない。
|
|
26
|
+
2. 受け取った記憶の本文(最初の1つ)を、引き継ぎの記録(`runtime_json.inheritedMemory`)へ残す。120,000 字を超える分は切り、
|
|
27
|
+
元の字数と切った事を残す。
|
|
28
|
+
3. 後継へ渡す記憶に、「系列の最初のタスクが受け取っていた記憶(そのまま)」の節として載せる。L1・L2 より前に置く。
|
|
29
|
+
元の `## Throughline: …` の見出しは「(元の見出し: …)」へ書き換え、後継の記憶の見出しを1つに保つ。
|
|
30
|
+
4. その後継がさらに引き継ぐ時も、系列をさかのぼって同じ本文を載せる。
|
|
31
|
+
5. 凍結した詳細の取得(`auto-handoff detail`)の対象には入れない。本文そのものを載せているので、取得する物が無い。
|
|
32
|
+
6. 自動継続の記録に前任があるタスクの扱い(記録をさかのぼって L1・L2 を集める)は変えない。
|
|
33
|
+
|
|
34
|
+
## Consequences
|
|
35
|
+
|
|
36
|
+
- 手動の引き継ぎで始めたタスク、ほかの端末や消えた記録から続いたタスクも、自動継続で引き継げる。
|
|
37
|
+
- 後継の記憶は、受け取った記憶の分だけ長くなる(上限 120,000 字)。引き継ぎを重ねても、同じ本文を1回載せるだけで増えない。
|
|
38
|
+
- 受け取った記憶より前の作業の L2・L3 は、後継から取得できない。旧タスクでも取得できなかったので、失う物は無い。
|
|
39
|
+
- 0.16.12 までで、この理由で止まっていたタスクは、次の入力か `auto-handoff resume --operation <ID>` で引き継がれる。
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# ADR 0047: Codex の内部の文脈からの自動圧縮は、失敗にせず止める
|
|
2
|
+
|
|
3
|
+
日付: 2026-10-10
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
Codex Desktop の自動継続は、`PreCompact(auto)` の hook が受け取った会話の ID とターンの ID が UUID の形でない時、
|
|
8
|
+
`handoff_hook_identity_invalid` の例外にしていた。例外になった hook は、失敗の記録(`HOOK_CODEX_FAILED`、high)を残し、
|
|
9
|
+
`continue: false` を返して圧縮を止める。
|
|
10
|
+
|
|
11
|
+
2026-10-10、macOS の実機(Codex Desktop 26.1007、同梱の Codex 0.162.0-alpha.17.2)で、この失敗が3回記録された。
|
|
12
|
+
|
|
13
|
+
- 13:04:46: 引き継ぎのためにターンを止めた 2.5 秒後。
|
|
14
|
+
- 13:24:01: 別のタスクのターンが始まった 3.3 秒後。
|
|
15
|
+
- 13:25:25: そのタスクを引き継ぎのために止めた 4.7 秒後。
|
|
16
|
+
|
|
17
|
+
Codex は、利用者のターンとは別に、自分で作る内部の文脈を持つ。その文脈のターンの ID は `auto-compact-N` の形になる
|
|
18
|
+
(`codex-rs/core/src/session/mod.rs` の `next_internal_sub_id`)。`PreCompact` の hook へ渡るターンの ID は、その時の文脈の ID
|
|
19
|
+
(`codex-rs/core/src/hook_runtime.rs` の `run_pre_compact_hooks`)なので、内部の文脈から圧縮が始まると、UUID でない ID が届く。
|
|
20
|
+
同梱の Codex が 0.162.0-alpha.2 の端末(Linux)では、この呼び出しは起きていない。
|
|
21
|
+
|
|
22
|
+
3回とも、hook は圧縮を止めていた。2つのタスクの rollout に圧縮の行は無く、使用量も減っていない。13:24:01 に止めた後、
|
|
23
|
+
ターンはそのまま進み、80 秒後の `PreCompact`(利用者のターンの ID)で引き継いで `continued` になった。
|
|
24
|
+
起きていた害は、起きていない失敗が high で記録された事だった。
|
|
25
|
+
|
|
26
|
+
## Decision
|
|
27
|
+
|
|
28
|
+
1. 会話の ID と記録の場所が正しく、ターンの ID が `auto-compact-<数字>` の形の `PreCompact(auto)` は、失敗にしない。
|
|
29
|
+
`continue: false` を返して圧縮を止める(今までと同じ結果)。失敗の記録は残さない。
|
|
30
|
+
2. その時に引き継ぎは作らない。内部の文脈は利用者のターンではなく、止める所と続ける作業が決まらない。文脈を残しておけば、
|
|
31
|
+
文脈が上限に近づいた利用者のターンの `PreCompact` で、今までどおり引き継ぐ。
|
|
32
|
+
3. 止めた事を `~/.throughline/codex-auto-handoff/internal-compactions.jsonl` に1行残す(時刻・会話の ID・ターンの ID)。
|
|
33
|
+
4. それ以外の形(会話の ID が UUID でない、記録の場所が無い、ターンの ID が知らない形)は、今までどおり
|
|
34
|
+
`handoff_hook_identity_invalid` の失敗にする。
|
|
35
|
+
|
|
36
|
+
## Consequences
|
|
37
|
+
|
|
38
|
+
- 内部の文脈からの圧縮で、失敗の記録が増えなくなる。圧縮を止める動きは変わらない。
|
|
39
|
+
- 内部の文脈からの圧縮が何度も起きるタスクでも、圧縮は通らない。利用者のターンの `PreCompact` が来るまで、文脈はそのまま残る。
|
|
40
|
+
- Codex が内部の ID の形を変えると、また失敗として記録される。その時は記録の message で気づける。
|
package/package.json
CHANGED
|
@@ -95,6 +95,16 @@ export function renderAutoHandoffMemory(db, operation) {
|
|
|
95
95
|
lines.push('### 中断地点の取得',
|
|
96
96
|
reference({ handoffId: operation.handoff_id, origin_session_id: operation.source_session_id, turn_number: latestTurn }, operation), '');
|
|
97
97
|
}
|
|
98
|
+
// 系列の最初のタスクが、前任をさかのぼれない記憶を受け取っていた。その記憶より前の作業は、この本文にしか無い。
|
|
99
|
+
const inherited = memory.lineage.map(entry => entry.operation.runtime?.inheritedMemory).find(Boolean);
|
|
100
|
+
if (inherited) {
|
|
101
|
+
lines.push('### 系列の最初のタスクが受け取っていた記憶(そのまま)',
|
|
102
|
+
'この系列より前の作業の記憶です。取得コマンドの対象には入っていません。下の L1・L2 より古い内容です。');
|
|
103
|
+
if (inherited.truncated) lines.push(`(長いので先頭だけ載せています。元は ${inherited.chars} 字)`);
|
|
104
|
+
lines.push(inherited.text
|
|
105
|
+
? inherited.text.replace(/^## Throughline: ([^\n]*)$/gm, '(元の見出し: $1)')
|
|
106
|
+
: '(本文を読めませんでした)', '');
|
|
107
|
+
}
|
|
98
108
|
if (memory.olderSummaries.length) {
|
|
99
109
|
lines.push('### 以前の記憶(L1)');
|
|
100
110
|
for (const row of memory.olderSummaries) lines.push(`[${new Date(row.created_at).toISOString()}] ${row.summary} (詳細: ${reference(row, operation)})`);
|
|
@@ -103,6 +103,15 @@ function openSuccessor(operation) {
|
|
|
103
103
|
return !result.error && result.status === 0;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// Codex が内部の文脈へ付けるターンの ID(`codex-rs/core/src/session/mod.rs` の `next_internal_sub_id`)。
|
|
107
|
+
const INTERNAL_COMPACTION_TURN_ID_PATTERN = /^auto-compact-\d+$/;
|
|
108
|
+
|
|
109
|
+
/** ターンの外の自動圧縮を止めた記録。引き継ぎの行は増えないので、起きた事をここに残す。会話の本文は書かない。 */
|
|
110
|
+
function recordInternalCompactionStop(entry) {
|
|
111
|
+
mkdirSync(stateRoot(), { recursive: true, mode: 0o700 });
|
|
112
|
+
appendFileSync(join(stateRoot(), 'internal-compactions.jsonl'), JSON.stringify(entry) + '\n', { mode: 0o600 });
|
|
113
|
+
}
|
|
114
|
+
|
|
106
115
|
/** 引き継ぎ済みの旧タスクを止めた記録。引き継ぎの行は増えないので、起きた事をここに残す。会話の本文は書かない。 */
|
|
107
116
|
function recordAutoHandoffRedirect(entry) {
|
|
108
117
|
mkdirSync(stateRoot(), { recursive: true, mode: 0o700 });
|
|
@@ -157,13 +166,20 @@ function settleUndeliveredAutoHandoff(db, threadId, { rolloutPath, processes, re
|
|
|
157
166
|
export async function requestCodexAutoHandoff({ payload, db = null,
|
|
158
167
|
config = readAutoHandoffConfig(), launchWorker = launchAutoHandoffWorker,
|
|
159
168
|
findThread = findCodexThreadCandidate, openThread = openSuccessor, recordRedirect = recordAutoHandoffRedirect,
|
|
169
|
+
recordInternalCompaction = recordInternalCompactionStop,
|
|
160
170
|
processes = readRuntimeProcesses, readTarget = targetState, now = Date.now() } = {}) {
|
|
161
171
|
if (payload.trigger !== 'auto' || !autoHandoffEnabledFor(config, payload.cwd)) return { status: 'skipped' };
|
|
162
172
|
const threadId = payload.session_id, turnId = payload.turn_id;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
173
|
+
const located = CODEX_NATIVE_ID_PATTERN.test(threadId ?? '') &&
|
|
174
|
+
typeof payload.transcript_path === 'string' && isAbsolute(payload.transcript_path) && isAbsolute(payload.cwd ?? '');
|
|
175
|
+
// Codex が自分で作った内部の文脈(ターンの ID が `auto-compact-N`)からの圧縮。利用者のターンではないので、
|
|
176
|
+
// 引き継ぎの起点にできない。例外にせず圧縮だけを止める(文脈を残せば、次の利用者のターンの圧縮の合図で引き継げる)。
|
|
177
|
+
if (located && INTERNAL_COMPACTION_TURN_ID_PATTERN.test(turnId ?? '')) {
|
|
178
|
+
recordInternalCompaction({ at: new Date(now).toISOString(), thread_id: threadId, turn_id: turnId });
|
|
179
|
+
return { status: 'ok', inserted: false, internalCompaction: true, continue: false,
|
|
180
|
+
stopReason: 'Throughlineが、ターンの外の自動圧縮を止めました。文脈が上限に近づいたターンで、新しいタスクへ引き継ぎます。' };
|
|
166
181
|
}
|
|
182
|
+
if (!located || !CODEX_NATIVE_ID_PATTERN.test(turnId ?? '')) throw error('handoff_hook_identity_invalid');
|
|
167
183
|
// JS実装のrealpathは、WindowsのCodex Desktopが起動したhookの中で `EISDIR: lstat 'C:'` で落ちる。OSのrealpathを使う。
|
|
168
184
|
const rolloutPath = realpathSync.native(payload.transcript_path);
|
|
169
185
|
const state = readCodexHandoffState(rolloutPath, { threadId, turnId });
|
|
@@ -361,6 +377,17 @@ export async function carryAutoHandoffQueue(db, operation, runtime, { connect =
|
|
|
361
377
|
return summary();
|
|
362
378
|
}
|
|
363
379
|
|
|
380
|
+
// 受け取った記憶をそのまま運ぶ上限。超えた分は切り、切った事を記録する(後継の記憶に出す)。
|
|
381
|
+
const INHERITED_MEMORY_MAX_CHARS = 120_000;
|
|
382
|
+
|
|
383
|
+
/** 前任をさかのぼれないタスクが最初に受け取っていた記憶を、引き継ぎの記録へ残す形にする。 */
|
|
384
|
+
export function untrackedInheritedMemory(text) {
|
|
385
|
+
const source = typeof text === 'string' ? text : '';
|
|
386
|
+
const chars = Array.from(source);
|
|
387
|
+
return { text: chars.length > INHERITED_MEMORY_MAX_CHARS ? chars.slice(0, INHERITED_MEMORY_MAX_CHARS).join('') : source,
|
|
388
|
+
chars: chars.length, truncated: chars.length > INHERITED_MEMORY_MAX_CHARS };
|
|
389
|
+
}
|
|
390
|
+
|
|
364
391
|
/** 旧タスクが、名前を付けない版の作った後継だった時の名前。その後継を作った引き継ぎから、後継の名前を作り直す。 */
|
|
365
392
|
export function autoHandoffSourceFallbackTitle(db, operation) {
|
|
366
393
|
const creator = findAutoHandoffForTarget(db, operation.source_thread_id);
|
|
@@ -554,7 +581,10 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
|
|
|
554
581
|
return sourceBoundary(operation, state) ? state : null;
|
|
555
582
|
}, { timeoutMs: 30_000, timeoutCode: 'handoff_source_stop_unconfirmed' });
|
|
556
583
|
if (!state.settings) throw error('handoff_settings_unavailable');
|
|
557
|
-
|
|
584
|
+
// 前任をさかのぼれない記憶を受け取ったタスク(手動の引き継ぎで始めたタスクなど)。断ると、止めたターンが
|
|
585
|
+
// そのまま残り、次の入力も同じ理由で止まる。受け取った記憶をそのまま後継へ渡して引き継ぐ(ADR 0046)。
|
|
586
|
+
const inheritedMemory = state.inheritedThroughlineMemory && !operation.previous_handoff_id
|
|
587
|
+
? untrackedInheritedMemory(state.inheritedThroughlineMemoryText) : null;
|
|
558
588
|
if (operation.previous_handoff_id) {
|
|
559
589
|
const previous = getAutoHandoff(db, operation.previous_handoff_id);
|
|
560
590
|
const continuation = deps.readSource(operation, { deliveryText: continuationInput(previous) });
|
|
@@ -563,7 +593,8 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
|
|
|
563
593
|
}
|
|
564
594
|
}
|
|
565
595
|
const native = await deps.nativeState(operation, runtime);
|
|
566
|
-
update({ state: 'source_stopped', settings_json: state.settings,
|
|
596
|
+
update({ state: 'source_stopped', settings_json: state.settings,
|
|
597
|
+
runtime_json: { ...native, databasePath: DB_PATH, ...(inheritedMemory ? { inheritedMemory } : {}) } });
|
|
567
598
|
}
|
|
568
599
|
// 最中に来た入力のturnが止まり切るまでの間は、境界がまだ見えない。短く待ってから判定する。
|
|
569
600
|
const confirmSourceBoundary = () => deps.wait(() => sourceBoundary(operation, deps.readSource(operation)) || null,
|
|
@@ -14,7 +14,8 @@ import { collectAutoHandoffMemory, ensureAutoHandoffSummaries, renderAutoHandoff
|
|
|
14
14
|
import { readCodexHandoffState, settingsMatch, threadStartSettings, resolvePreparedSettings } from './hosts/codex-handoff-state.mjs';
|
|
15
15
|
import { runAutoHandoffWorker, requestCodexAutoHandoff, sourceBoundary,
|
|
16
16
|
autoHandoffTargetName, nameAutoHandoffTarget, findLiveAutoHandoffSuccessor,
|
|
17
|
-
markAutoHandoffSource, markAutoHandoffSources, autoHandoffSourceFallbackTitle, carryAutoHandoffQueue
|
|
17
|
+
markAutoHandoffSource, markAutoHandoffSources, autoHandoffSourceFallbackTitle, carryAutoHandoffQueue,
|
|
18
|
+
untrackedInheritedMemory } from './codex-auto-handoff.mjs';
|
|
18
19
|
import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
|
|
19
20
|
|
|
20
21
|
async function withDb(fn) {
|
|
@@ -179,21 +180,80 @@ test('作成応答を失った時は結果不明を保存し、再開でも後
|
|
|
179
180
|
assert.equal(counters.created, 1); assert.equal(counters.submitted, 0);
|
|
180
181
|
}));
|
|
181
182
|
|
|
182
|
-
test('
|
|
183
|
-
for (const code of ['handoff_native_child_pending'
|
|
183
|
+
test('実行中の子がある時は後継を作らない', async () => withDb(async db => {
|
|
184
|
+
for (const code of ['handoff_native_child_pending']) {
|
|
184
185
|
const op = request(db, code, { now: 1 }).operation;
|
|
185
186
|
const counters = { created: 0, submitted: 0, notified: 0 };
|
|
186
187
|
const deps = workerDependencies(db, {}, async () => { counters.submitted++; }, counters);
|
|
187
|
-
|
|
188
|
-
const read = deps.readSource;
|
|
189
|
-
deps.readSource = item => ({ ...read(item), inheritedThroughlineMemory: true });
|
|
190
|
-
} else deps.nativeState = async () => { throw Object.assign(Error(code), { code }); };
|
|
188
|
+
deps.nativeState = async () => { throw Object.assign(Error(code), { code }); };
|
|
191
189
|
const result = await runAutoHandoffWorker(op.handoff_id, { db, dependencies: deps });
|
|
192
190
|
assert.equal(result.error_code, code); assert.equal(result.state, 'failed');
|
|
193
191
|
assert.equal(counters.created, 0); assert.equal(counters.submitted, 0);
|
|
194
192
|
}
|
|
195
193
|
}));
|
|
196
194
|
|
|
195
|
+
test('rollout の developer 記憶から、受け取った Throughline の記憶の本文を最初の1つだけ読む', () => {
|
|
196
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-inherited-'));
|
|
197
|
+
const file = join(dir, 'rollout.jsonl');
|
|
198
|
+
const developer = text => ({ type: 'response_item', payload: { type: 'message', role: 'developer', content: [{ type: 'input_text', text }] } });
|
|
199
|
+
const rows = [{ type: 'session_meta', payload: { id: 'T', cwd: '/project', originator: 'Codex Desktop', source: 'vscode' } },
|
|
200
|
+
developer('ふつうの developer 文'),
|
|
201
|
+
developer('## Throughline: New Codex Thread Handoff\n最初に受け取った記憶'),
|
|
202
|
+
developer('## Throughline: Active Work Context\n後から入った別の記憶')];
|
|
203
|
+
writeFileSync(file, rows.map(row => JSON.stringify({ timestamp: '2026-10-08T00:00:01.000Z', ...row })).join('\n') + '\n');
|
|
204
|
+
const state = readCodexHandoffState(file, { threadId: 'T' });
|
|
205
|
+
assert.equal(state.inheritedThroughlineMemory, true);
|
|
206
|
+
assert.equal(state.inheritedThroughlineMemoryText, '## Throughline: New Codex Thread Handoff\n最初に受け取った記憶');
|
|
207
|
+
writeFileSync(file, rows.slice(0, 2).map(row => JSON.stringify({ timestamp: '2026-10-08T00:00:01.000Z', ...row })).join('\n') + '\n');
|
|
208
|
+
const plain = readCodexHandoffState(file, { threadId: 'T' });
|
|
209
|
+
assert.equal(plain.inheritedThroughlineMemory, false); assert.equal(plain.inheritedThroughlineMemoryText, null);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('前任をさかのぼれない記憶を受け取ったタスクも引き継ぎ、受け取った記憶をそのまま後継へ渡す', async () => withDb(async db => {
|
|
213
|
+
const op = request(db, 'manual-origin', { now: 1 }).operation;
|
|
214
|
+
const counters = { created: 0, submitted: 0, notified: 0 };
|
|
215
|
+
const deps = workerDependencies(db, {}, async () => { counters.submitted++; return { queued_submission_id: 'receipt' }; }, counters);
|
|
216
|
+
const read = deps.readSource;
|
|
217
|
+
const inheritedText = '## Throughline: New Codex Thread Handoff\n手動の引き継ぎで受け取った記憶。前のタスクの合意: 4イベントを申請する';
|
|
218
|
+
deps.readSource = (item, options) => ({ ...read(item, options), inheritedThroughlineMemory: true, inheritedThroughlineMemoryText: inheritedText });
|
|
219
|
+
const result = await runAutoHandoffWorker(op.handoff_id, { db, dependencies: deps });
|
|
220
|
+
assert.equal(result.state, 'continued', '断らずに引き継ぐ');
|
|
221
|
+
assert.equal(result.error_code, null); assert.equal(counters.created, 1); assert.equal(counters.submitted, 1); assert.equal(counters.notified, 0);
|
|
222
|
+
assert.deepEqual(result.runtime.inheritedMemory, { text: inheritedText, chars: Array.from(inheritedText).length, truncated: false });
|
|
223
|
+
}));
|
|
224
|
+
|
|
225
|
+
test('受け取った記憶は系列の最初の引き継ぎに残り、その先の後継の記憶にも出る。見出しは入れ子にしない', async () => withDb(db => {
|
|
226
|
+
let a = snapshot(db, request(db, 'A').operation, 2);
|
|
227
|
+
a = updateAutoHandoff(db, a.handoff_id, { target_thread_id: 'B', state: 'continued',
|
|
228
|
+
runtime_json: { inheritedMemory: untrackedInheritedMemory('## Throughline: New Codex Thread Handoff\n前のタスクの合意: 4イベントを申請する') } }).operation;
|
|
229
|
+
const first = renderAutoHandoffMemory(db, a);
|
|
230
|
+
assert.ok(first.includes('### 系列の最初のタスクが受け取っていた記憶(そのまま)'));
|
|
231
|
+
assert.ok(first.includes('前のタスクの合意: 4イベントを申請する'));
|
|
232
|
+
assert.ok(first.includes('(元の見出し: New Codex Thread Handoff)'));
|
|
233
|
+
assert.equal(first.match(/^## Throughline: /gm).length, 1, '見出しは後継の記憶の1つだけ');
|
|
234
|
+
assert.ok(first.indexOf('系列の最初のタスクが受け取っていた記憶') < first.indexOf('直近'), '古い記憶を先に置く');
|
|
235
|
+
|
|
236
|
+
let b = snapshot(db, request(db, 'B').operation, 2, 100, continuationInput(a));
|
|
237
|
+
b = updateAutoHandoff(db, b.handoff_id, { target_thread_id: 'C' }).operation;
|
|
238
|
+
assert.equal(b.previous_handoff_id, a.handoff_id);
|
|
239
|
+
assert.ok(renderAutoHandoffMemory(db, b).includes('前のタスクの合意: 4イベントを申請する'), '次の後継にも渡る');
|
|
240
|
+
|
|
241
|
+
// 受け取った記憶が無い系列には、この節を出さない
|
|
242
|
+
const plain = snapshot(db, request(db, 'P').operation, 2);
|
|
243
|
+
assert.ok(!renderAutoHandoffMemory(db, plain).includes('受け取っていた記憶'));
|
|
244
|
+
}));
|
|
245
|
+
|
|
246
|
+
test('長すぎる受け取った記憶は先頭だけを運び、切った事を後継の記憶に書く', async () => withDb(db => {
|
|
247
|
+
const long = untrackedInheritedMemory('あ'.repeat(120_001));
|
|
248
|
+
assert.equal(Array.from(long.text).length, 120_000); assert.equal(long.chars, 120_001); assert.equal(long.truncated, true);
|
|
249
|
+
assert.deepEqual(untrackedInheritedMemory(null), { text: '', chars: 0, truncated: false });
|
|
250
|
+
const a = updateAutoHandoff(db, snapshot(db, request(db, 'A').operation, 1).handoff_id, { runtime_json: { inheritedMemory: long } }).operation;
|
|
251
|
+
const text = renderAutoHandoffMemory(db, a);
|
|
252
|
+
assert.ok(text.includes('(長いので先頭だけ載せています。元は 120001 字)'));
|
|
253
|
+
const empty = updateAutoHandoff(db, snapshot(db, request(db, 'E').operation, 1).handoff_id, { runtime_json: { inheritedMemory: untrackedInheritedMemory(null) } }).operation;
|
|
254
|
+
assert.ok(renderAutoHandoffMemory(db, empty).includes('(本文を読めませんでした)'));
|
|
255
|
+
}));
|
|
256
|
+
|
|
197
257
|
test('開始eventからユーザー入力の保存までの間を別入力の混入と判定しない', async () => withDb(async db => {
|
|
198
258
|
const op = request(db, 'gap-source', { now: 1 }).operation;
|
|
199
259
|
const counters = { created: 0, submitted: 0, notified: 0 };
|
|
@@ -227,6 +287,33 @@ test('PreCompactの重複ではworkerを増やさず、manualと別projectでは
|
|
|
227
287
|
assert.equal((await requestCodexAutoHandoff({ ...args, config: { ...config, projects: ['/other'] } })).status, 'skipped');
|
|
228
288
|
}));
|
|
229
289
|
|
|
290
|
+
test('Codexの内部の文脈(auto-compact-N)からの圧縮は、例外にせず止めて記録する。引き継ぎは作らない', async () => withDb(async (db, home) => {
|
|
291
|
+
const thread = '11111111-1111-1111-1111-111111111111';
|
|
292
|
+
const file = join(home, 'rollout.jsonl'); writeFileSync(file, '');
|
|
293
|
+
const payload = { trigger: 'auto', cwd: home, session_id: thread, turn_id: 'auto-compact-3', transcript_path: file };
|
|
294
|
+
const config = { enabled: true, projects: [home], openHost: 'desktop' };
|
|
295
|
+
let launched = 0; const recorded = [];
|
|
296
|
+
const args = { db, config, payload, launchWorker: async () => { launched++; }, recordInternalCompaction: entry => recorded.push(entry), now: Date.parse('2026-10-10T04:24:01.175Z') };
|
|
297
|
+
const result = await requestCodexAutoHandoff(args);
|
|
298
|
+
assert.equal(result.status, 'ok'); assert.equal(result.continue, false); assert.equal(result.internalCompaction, true);
|
|
299
|
+
assert.match(result.stopReason, /ターンの外の自動圧縮を止めました/);
|
|
300
|
+
assert.equal(launched, 0);
|
|
301
|
+
assert.equal(db.prepare('SELECT COUNT(*) n FROM codex_handoffs').get().n, 0, '引き継ぎの行は作らない');
|
|
302
|
+
assert.deepEqual(recorded, [{ at: '2026-10-10T04:24:01.175Z', thread_id: thread, turn_id: 'auto-compact-3' }]);
|
|
303
|
+
|
|
304
|
+
// 無効な project と手動の圧縮は、今までどおり何もしない
|
|
305
|
+
assert.equal((await requestCodexAutoHandoff({ ...args, config: { ...config, projects: ['/other'] } })).status, 'skipped');
|
|
306
|
+
assert.equal((await requestCodexAutoHandoff({ ...args, payload: { ...payload, trigger: 'manual' } })).status, 'skipped');
|
|
307
|
+
assert.equal(recorded.length, 1);
|
|
308
|
+
|
|
309
|
+
// 形の決まっていない ID と、場所の分からない呼び出しは、今までどおり失敗として扱う
|
|
310
|
+
for (const broken of [{ turn_id: 'something-else' }, { turn_id: undefined }, { turn_id: 'auto-compact-' }, { session_id: 'not-a-uuid' },
|
|
311
|
+
{ transcript_path: null }, { transcript_path: 'relative/rollout.jsonl' }]) {
|
|
312
|
+
await assert.rejects(requestCodexAutoHandoff({ ...args, payload: { ...payload, ...broken } }), /handoff_hook_identity_invalid/);
|
|
313
|
+
}
|
|
314
|
+
assert.equal(recorded.length, 1);
|
|
315
|
+
}));
|
|
316
|
+
|
|
230
317
|
test('PreCompactの入口は、JS実装のrealpathが使えない環境(WindowsのCodex Desktopのhook)でも引き継ぎを作る', async () => withDb(async (db, home) => {
|
|
231
318
|
const thread = '11111111-1111-1111-1111-111111111111', turn = '22222222-2222-2222-2222-222222222222';
|
|
232
319
|
const dir = join(home, 'codex', 'sessions'); mkdirSync(dir, { recursive: true });
|
|
@@ -103,6 +103,8 @@ export function readCodexHandoffState(path, { threadId = null, turnId = null, de
|
|
|
103
103
|
const pendingCalls = new Map();
|
|
104
104
|
const nativeSessions = new Set();
|
|
105
105
|
let inheritedThroughlineMemory = false;
|
|
106
|
+
// 受け取った記憶の本文(最初の1つ)。前任をさかのぼれないタスクを引き継ぐ時に、そのまま後継へ渡す。
|
|
107
|
+
let inheritedThroughlineMemoryText = null;
|
|
106
108
|
let completedTools = 0;
|
|
107
109
|
// turnId を指定した時だけ、その turn より後に始まった turn を並べる。closed は止まった(turn_aborted)か完了したか、
|
|
108
110
|
// activity はモデルの発言・道具の呼び出し・圧縮・完了のどれかがあったか。入力を受けただけで止まった turn は activity が false。
|
|
@@ -147,7 +149,10 @@ export function readCodexHandoffState(path, { threadId = null, turnId = null, de
|
|
|
147
149
|
if (row.type === 'response_item') {
|
|
148
150
|
if (payload?.type === 'message' && payload.role === 'developer') {
|
|
149
151
|
const text = (payload.content ?? []).map(c => c.text ?? '').join('\n');
|
|
150
|
-
if (/^## Throughline: (?:自動継続文脈|Active Work Context|New Codex Thread Handoff)/m.test(text))
|
|
152
|
+
if (/^## Throughline: (?:自動継続文脈|Active Work Context|New Codex Thread Handoff)/m.test(text)) {
|
|
153
|
+
inheritedThroughlineMemory = true;
|
|
154
|
+
inheritedThroughlineMemoryText ??= text;
|
|
155
|
+
}
|
|
151
156
|
}
|
|
152
157
|
if (payload?.type === 'message' && payload.role === 'user') {
|
|
153
158
|
const text = (payload.content ?? []).filter(c => typeof c.text === 'string').map(c => c.text).join('\n');
|
|
@@ -212,6 +217,6 @@ export function readCodexHandoffState(path, { threadId = null, turnId = null, de
|
|
|
212
217
|
}
|
|
213
218
|
return { meta, settings, rawSettings, preparedSettings, context, latestTurnId, turnStartAt, stoppedAt, stoppedReason,
|
|
214
219
|
completedAt, correlatedTurnId, hasTurnInput, progress, completedTools, pendingCallCount: pendingCalls.size,
|
|
215
|
-
pendingNativeSessionCount: nativeSessions.size, inheritedThroughlineMemory, laterTurns,
|
|
220
|
+
pendingNativeSessionCount: nativeSessions.size, inheritedThroughlineMemory, inheritedThroughlineMemoryText, laterTurns,
|
|
216
221
|
compactedRows: rows.filter(r => r.type === 'compacted').length };
|
|
217
222
|
}
|