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