throughline 0.16.1 → 0.16.2

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 CHANGED
@@ -10,6 +10,27 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.16.2] — 2026-10-07
14
+
15
+ ### 修正
16
+
17
+ - Codexの自動継続で、引き継ぎ済みの旧タスクへ新しい入力が届くたびに、同じ作業を引き継いだ後継がもう1つ立っていたのを直す([ADR 0039](docs/adr/0039-codex-auto-handoff-one-successor-per-source.md))。重複を見ていたのは元のタスクと元のターンの組で、旧タスクに新しいターンが出来ると別の引き継ぎとして受けていた。継続の指示が届いた後継が残っている間は、新しい引き継ぎを作らず、ターンを止めて、止めた理由に引き継ぎID・後継の名前・`codex://threads/<後継>`を書き、Desktopで後継を開く。後継がさらに引き継いでいれば、その先を示す。
18
+ - workerは、継続の指示を送る直前に、同じ旧タスクの別の引き継ぎが先に指示を送っていないかを確かめる。送られていれば`handoff_source_already_continued`で止まり、後継へ指示を送らない。
19
+ - 止めた事を`~/.throughline/codex-auto-handoff/redirects.jsonl`に1行残す(会話の本文は書かない)。
20
+
21
+ ### 変わらない事
22
+
23
+ - 配送の前に失敗した引き継ぎしか無い旧タスクと、後継が残っていない(消した、アーカイブした)旧タスクからは、今までどおり新しい後継を立てる。
24
+ - 旧タスクへ打った入力は後継へ渡さない。後継で打ち直す。
25
+
26
+ ### 確認した範囲
27
+
28
+ - macOSのCodex Desktop(0.16.1)で2026-10-07に起きた記録(同じ旧タスクから3つの引き継ぎ)を読んだ。
29
+
30
+ ### 確認していない範囲
31
+
32
+ - Codex Desktopの画面で、旧タスクに止めた理由がどう見えるか。
33
+
13
34
  ## [0.16.1] — 2026-10-07
14
35
 
15
36
  ### 修正
@@ -1892,7 +1913,8 @@ two attempts, instrument first instead of patching again.
1892
1913
 
1893
1914
  ---
1894
1915
 
1895
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.1...HEAD
1916
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.2...HEAD
1917
+ [0.16.2]: https://github.com/kitepon/Throughline/compare/v0.16.1...v0.16.2
1896
1918
  [0.16.1]: https://github.com/kitepon/Throughline/compare/v0.16.0...v0.16.1
1897
1919
  [0.16.0]: https://github.com/kitepon/Throughline/compare/v0.15.5...v0.16.0
1898
1920
  [0.15.5]: https://github.com/kitepon/Throughline/compare/v0.15.4...v0.15.5
@@ -160,6 +160,8 @@ sequenceDiagram
160
160
  DBには引き継ぎID、元thread/turn、project/Codex環境、snapshot参照、
161
161
  前任引き継ぎID、新thread ID、配送ID、受付ID、状態、開始証拠、失敗理由を保存する。
162
162
  元thread/turnの要求は一意にし、フックの重複で後継を増やさない。
163
+ 継続の指示が届いた旧タスクへ新しいturnが来た時は、後継が残っている間は要求を作らず、
164
+ ターンを止めて今の後継を示して開く([ADR 0039](adr/0039-codex-auto-handoff-one-successor-per-source.md))。
163
165
  schema変更は `src/db.mjs` と製品所有migrationへ置き、
164
166
  schemaの版とmigrationは同じ製品releaseで更新する。
165
167
 
@@ -0,0 +1,58 @@
1
+ # ADR 0039: 引き継ぎ済みの Codex タスクへ新しい入力が来ても、後継を増やさない
2
+
3
+ 日付: 2026-10-07
4
+
5
+ ## Context
6
+
7
+ 2026-10-07、macOS の Codex Desktop(Throughline 0.16.1、自動継続は全 project で有効)で、同じ作業を引き継いだ
8
+ 後継タスクが3つ出来た。時刻は JST。
9
+
10
+ - 09:49:01 旧タスク `01a1136a` の自動圧縮を止め、後継 `01a113d5` を立てた(`continued`)。後継は 09:54:40 に
11
+ ターンを終え、利用者の操作を待っていた。
12
+ - 09:56:48 旧タスク `01a1136a` へ、利用者の新しい入力(1行)が届いた。旧タスクは閾値を超えたままなので、
13
+ `PreCompact(auto)` がもう一度発火した。Throughline はそのターンを止め、2つ目の後継 `01a113dc` を立てた。
14
+ - 09:56:58 同じ旧タスクへもう1行届き、3つ目の後継 `01a113dd` を立てた。
15
+
16
+ 3つの引き継ぎは、どれも元が同じ旧タスクで、前任の引き継ぎも同じだった。2つ目と3つ目の後継は、1つ目の
17
+ 後継が 09:49〜09:54 に進めた作業を知らないまま、同じ作業(本番の認証の確認)を始めた。3つ目が自分で
18
+ 降りたので、操作は重複しなかった。
19
+
20
+ 設計の受入条件は「同じ旧タスクから後継を重複作成せず、旧・新タスクが同じ操作を重複実行しない」
21
+ ([05_codex_first_roadmap.md](../05_codex_first_roadmap.md))。実装が一意にしていたのは元 thread と元 turn の
22
+ 組で、同じ旧タスクの別の turn は別の要求として受けていた。hook が同じ turn で2回走った時は増えないが、
23
+ 旧タスクに新しい turn が出来るたびに後継が増える。
24
+
25
+ Claude Code の自動継続(ADR 0033)は、記録を旧い会話ごとに1つ持つ。引き継ぎ済みの会話で道具が呼ばれた時は、
26
+ 後継を増やさずに止め、止めた理由に後継を書く。Codex の側をこの形にそろえる。
27
+
28
+ ## Decision
29
+
30
+ 1. Codex の `PreCompact(auto)` は、その旧タスクから継続の指示が届いた(届いたかもしれない)引き継ぎがあり、
31
+ その後継のタスクが残っている時、新しい引き継ぎを作らない。`continue: false` でターンを止め、止めた理由に
32
+ 引き継ぎ ID、後継の名前、`codex://threads/<後継>` を書く。Desktop で後継を開く。
33
+ 2. 「指示が届いた(かもしれない)」は、受付 ID が保存されている、状態が `submitted`・`continued`、
34
+ または配送の途中(`mutation_stage` が `submit`。結果不明で止まった物を含む)のどれか。
35
+ 配送の前に失敗した引き継ぎは数えない。その旧タスクからは、今までどおり新しい引き継ぎを作る。
36
+ 3. 示す後継は、今その作業を持っているタスクにする。後継がさらに引き継いでいれば、その先をたどる。
37
+ 後継の rollout が `sessions` に無い時(消した、アーカイブした)は、その後継は無い物として扱う。
38
+ 残っている後継が1つも無ければ、今までどおり旧タスクから新しい引き継ぎを作る。
39
+ 4. 0.16.1 までに同じ旧タスクから複数の後継が立っている記録では、rollout が最後に書かれた後継を示す。
40
+ 5. worker は、継続の指示を送る直前に、同じ旧タスクの別の引き継ぎが先に指示を送っていないかを確かめる。
41
+ 確認と「配送の途中」の記録は1つの transaction で行う。先に送られていれば、送らずに
42
+ `handoff_source_already_continued` で失敗にする。2つの入力がほぼ同時に来て、hook の判定をどちらも
43
+ 通った時のための確認。
44
+ 6. 止めた事は `~/.throughline/codex-auto-handoff/redirects.jsonl` に1行残す(時刻、旧タスクと turn、
45
+ 引き継ぎ ID、後継、開けたか)。会話の本文は書かない。引き継ぎの行は増えないので、起きた事はここで数える。
46
+ 7. 同じ turn の hook がもう一度来た時は、今までどおり同じ引き継ぎを返す。手動の `/compact` は、
47
+ 今までどおり止めない。
48
+
49
+ ## Consequences
50
+
51
+ - 旧タスクへ打った入力は、後継へは渡らない。旧タスクでターンが止まり、後継が開く。利用者は後継で
52
+ 打ち直す。入力を後継へ自動で送る事はしない(継続の指示は1回だけ送る、という配送の契約を変えないため)。
53
+ - 引き継ぎ済みの旧タスクは、後継が残っている間、そのタスクで作業を続けられない。旧タスクで続けたい時は、
54
+ 旧タスクで手動の `/compact` を使う。
55
+ - 2つの入力がほぼ同時に来て5に当たった時は、後に来た方の後継(記憶だけが入った、指示の無いタスク)が
56
+ 残り、失敗の表示が出る。
57
+ - 外から旧タスクへ届く文(旧タスクが開いた通話への返信など)も、同じように止まる。0.16.1 までは、
58
+ 1通ごとに後継が1つ増えていた。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.16.1",
3
+ "version": "0.16.2",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -22,6 +22,21 @@ export function findAutoHandoffForTarget(db, threadId) {
22
22
  return decode(db.prepare('SELECT * FROM codex_handoffs WHERE target_thread_id = ?').get(threadId));
23
23
  }
24
24
 
25
+ export function findAutoHandoffForTurn(db, threadId, turnId) {
26
+ return decode(db.prepare('SELECT * FROM codex_handoffs WHERE source_thread_id = ? AND source_turn_id = ?')
27
+ .get(threadId, turnId));
28
+ }
29
+
30
+ /**
31
+ * 継続の指示が後継へ届いた、または届いたかもしれない引き継ぎ(新しい順)。後継が作業を持っているので、
32
+ * 同じ旧タスクから別の後継を立てない。配送の前に失敗した引き継ぎは入れない。
33
+ */
34
+ export function listDeliveredAutoHandoffsForSource(db, threadId) {
35
+ return db.prepare(`SELECT * FROM codex_handoffs WHERE source_thread_id = ? AND target_thread_id IS NOT NULL
36
+ AND (queued_submission_id IS NOT NULL OR state IN ('submitted', 'continued') OR mutation_stage = 'submit')
37
+ ORDER BY created_at DESC`).all(threadId).map(decode);
38
+ }
39
+
25
40
  export function listAutoHandoffs(db, { projectPath = null, limit = 20 } = {}) {
26
41
  const rows = projectPath == null
27
42
  ? db.prepare('SELECT * FROM codex_handoffs ORDER BY created_at DESC LIMIT ?').all(limit)
@@ -1,4 +1,4 @@
1
- import { mkdirSync, openSync, closeSync, writeFileSync, realpathSync } from 'node:fs';
1
+ import { mkdirSync, openSync, closeSync, writeFileSync, appendFileSync, realpathSync } 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';
@@ -13,7 +13,8 @@ import { findCodexThreadCandidate } from './codex-thread-index.mjs';
13
13
  import { captureCodexRolloutToDb } from './codex-capture.mjs';
14
14
  import { readAutoHandoffConfig, autoHandoffEnabledFor } from './codex-auto-handoff-config.mjs';
15
15
  import { getAutoHandoff, requestAutoHandoff, updateAutoHandoff, failAutoHandoff,
16
- claimAutoHandoff, releaseAutoHandoff, freezeCodexMemory, continuationInput } from './codex-auto-handoff-store.mjs';
16
+ claimAutoHandoff, releaseAutoHandoff, freezeCodexMemory, continuationInput,
17
+ findAutoHandoffForTurn, listDeliveredAutoHandoffsForSource } from './codex-auto-handoff-store.mjs';
17
18
  import { ensureAutoHandoffSummaries, renderAutoHandoffMemory } from './codex-auto-handoff-memory.mjs';
18
19
  import { CODEX_NATIVE_ID_PATTERN, CodexHandoffError, readCodexHandoffState,
19
20
  settingsMatch, threadStartSettings } from './hosts/codex-handoff-state.mjs';
@@ -66,8 +67,45 @@ export async function launchAutoHandoffWorker(id) {
66
67
  } finally { closeSync(log); }
67
68
  }
68
69
 
70
+ function liveSuccessors(db, threadId, options, seen) {
71
+ const found = [];
72
+ for (const operation of listDeliveredAutoHandoffsForSource(db, threadId)) {
73
+ if (operation.handoff_id === options.exceptHandoffId || seen.has(operation.handoff_id)) continue;
74
+ seen.add(operation.handoff_id);
75
+ const deeper = liveSuccessors(db, operation.target_thread_id, options, seen);
76
+ if (deeper.length) { found.push(...deeper); continue; }
77
+ const thread = options.findThread({ threadId: operation.target_thread_id, codexHome: operation.codex_home,
78
+ projectPath: operation.project_path, requireProjectMatch: true });
79
+ if (thread) found.push({ operation, mtimeMs: thread.mtimeMs ?? 0 });
80
+ }
81
+ return found;
82
+ }
83
+
84
+ /**
85
+ * 旧タスクの作業を今持っている後継の引き継ぎ。継続の指示が届いた引き継ぎをたどり、後継がさらに引き継いで
86
+ * いればその先へ進む。後継のrolloutが無い枝(消した、アーカイブした)は数えない。無ければ null。
87
+ * 0.16.1 までに同じ旧タスクから複数の後継が立っていた時は、最後に動いた後継を返す。
88
+ */
89
+ export function findLiveAutoHandoffSuccessor(db, threadId,
90
+ { exceptHandoffId = null, findThread = findCodexThreadCandidate } = {}) {
91
+ return liveSuccessors(db, threadId, { exceptHandoffId, findThread }, new Set())
92
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)[0]?.operation ?? null;
93
+ }
94
+
95
+ function openSuccessor(operation) {
96
+ const result = openUrlWithOsHandler(`codex://threads/${encodeURIComponent(operation.target_thread_id)}`);
97
+ return !result.error && result.status === 0;
98
+ }
99
+
100
+ /** 引き継ぎ済みの旧タスクを止めた記録。引き継ぎの行は増えないので、起きた事をここに残す。会話の本文は書かない。 */
101
+ function recordAutoHandoffRedirect(entry) {
102
+ mkdirSync(stateRoot(), { recursive: true, mode: 0o700 });
103
+ appendFileSync(join(stateRoot(), 'redirects.jsonl'), JSON.stringify(entry) + '\n', { mode: 0o600 });
104
+ }
105
+
69
106
  export async function requestCodexAutoHandoff({ payload, db = null,
70
- config = readAutoHandoffConfig(), launchWorker = launchAutoHandoffWorker } = {}) {
107
+ config = readAutoHandoffConfig(), launchWorker = launchAutoHandoffWorker,
108
+ findThread = findCodexThreadCandidate, openThread = openSuccessor, recordRedirect = recordAutoHandoffRedirect } = {}) {
71
109
  if (payload.trigger !== 'auto' || !autoHandoffEnabledFor(config, payload.cwd)) return { status: 'skipped' };
72
110
  const threadId = payload.session_id, turnId = payload.turn_id;
73
111
  if (!CODEX_NATIVE_ID_PATTERN.test(threadId ?? '') || !CODEX_NATIVE_ID_PATTERN.test(turnId ?? '') ||
@@ -82,6 +120,19 @@ export async function requestCodexAutoHandoff({ payload, db = null,
82
120
  if (!sessionPart) throw error('handoff_codex_home_unavailable');
83
121
  const codexHome = rolloutPath.slice(0, sessionPart.index);
84
122
  const actualDb = db ?? getDb();
123
+ // 引き継ぎ済みの旧タスクへ、新しい入力が来た。作業は後継が持っているので、後継を増やさずに止めて後継を示す。
124
+ const successor = findAutoHandoffForTurn(actualDb, threadId, turnId) ? null
125
+ : findLiveAutoHandoffSuccessor(actualDb, threadId, { findThread });
126
+ if (successor) {
127
+ const opened = openThread(successor);
128
+ recordRedirect({ at: new Date().toISOString(), source_thread_id: threadId, source_turn_id: turnId,
129
+ handoff_id: successor.handoff_id, target_thread_id: successor.target_thread_id, opened });
130
+ return { status: 'ok', operationId: successor.handoff_id, inserted: false, redirected: true,
131
+ targetThreadId: successor.target_thread_id, opened, continue: false,
132
+ stopReason: `このタスクはThroughlineが新しいタスクへ引き継ぎ済みです(引き継ぎID: ${successor.handoff_id})。` +
133
+ `続きは後継のタスク「${autoHandoffTargetName(actualDb, successor)}」で行ってください: ` +
134
+ `codex://threads/${successor.target_thread_id}` };
135
+ }
85
136
  const { operation, inserted } = requestAutoHandoff(actualDb, { threadId, turnId, projectPath: state.meta.cwd,
86
137
  rolloutPath, codexHome, openHost: config.openHost });
87
138
  if (inserted) {
@@ -218,7 +269,7 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
218
269
  nativeState: sourceNativeState, capture: captureCodexRolloutToDb, summaries: ensureAutoHandoffSummaries,
219
270
  createTarget, readTarget: targetState, openTarget, verify: verifyCodexParent,
220
271
  submit: submitCodexParentAnswer, notify: showAutoHandoffFailure, wait: waitForHandoff,
221
- executable: () => platformDesktopFinder()(), ...dependencies };
272
+ executable: () => platformDesktopFinder()(), liveSuccessor: findLiveAutoHandoffSuccessor, ...dependencies };
222
273
  let operation = getAutoHandoff(db, id);
223
274
  if (!operation) throw error('handoff_not_found');
224
275
  if (operation.state === 'continued') return operation;
@@ -299,7 +350,16 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
299
350
  if (target?.latestTurnId || !target?.settings || !settingsMatch(operation.runtime.preparedSettings, target.settings)) throw error('handoff_target_advanced');
300
351
  const parent = { thread_id: operation.target_thread_id, codex_home: operation.codex_home };
301
352
  await deps.verify(autoHandoffDeliveryProfile, parent, runtime);
302
- update({ mutation_stage: 'submit' });
353
+ // 同じ旧タスクの別の引き継ぎが先に指示を送っていたら、後継を増やさない。確認と記録を1つのtransactionで行う。
354
+ db.exec('BEGIN IMMEDIATE');
355
+ try {
356
+ if (deps.liveSuccessor(db, operation.source_thread_id, { exceptHandoffId: id })) throw error('handoff_source_already_continued');
357
+ update({ mutation_stage: 'submit' });
358
+ db.exec('COMMIT');
359
+ } catch (cause) {
360
+ db.exec('ROLLBACK');
361
+ throw cause;
362
+ }
303
363
  const receipt = await deps.submit(autoHandoffDeliveryProfile, parent, operation.delivery_id, continuationInput(operation), runtime);
304
364
  update({ state: 'submitted', queued_submission_id: receipt.queued_submission_id, mutation_stage: null });
305
365
  }
@@ -11,7 +11,7 @@ import { collectAutoHandoffMemory, ensureAutoHandoffSummaries, renderAutoHandoff
11
11
  renderFrozenDetail } from './codex-auto-handoff-memory.mjs';
12
12
  import { readCodexHandoffState, settingsMatch, threadStartSettings } from './hosts/codex-handoff-state.mjs';
13
13
  import { runAutoHandoffWorker, requestCodexAutoHandoff, sourceBoundary,
14
- autoHandoffTargetName, nameAutoHandoffTarget } from './codex-auto-handoff.mjs';
14
+ autoHandoffTargetName, nameAutoHandoffTarget, findLiveAutoHandoffSuccessor } from './codex-auto-handoff.mjs';
15
15
  import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
16
16
 
17
17
  async function withDb(fn) {
@@ -216,6 +216,116 @@ test('PreCompactの重複ではworkerを増やさず、manualと別projectでは
216
216
  assert.equal((await requestCodexAutoHandoff({ ...args, config: { ...config, projects: ['/other'] } })).status, 'skipped');
217
217
  }));
218
218
 
219
+ function desktopSource(home, thread, turn) {
220
+ const dir = join(home, 'codex', 'sessions'); mkdirSync(dir, { recursive: true });
221
+ const file = join(dir, `rollout-${thread}.jsonl`);
222
+ writeFileSync(file, [{ type: 'session_meta', payload: { id: thread, originator: 'Codex Desktop', source: 'vscode', cwd: home } },
223
+ { type: 'event_msg', payload: { type: 'task_started', turn_id: turn } }].map(JSON.stringify).join('\n') + '\n');
224
+ return { trigger: 'auto', cwd: home, session_id: thread, turn_id: turn, transcript_path: file };
225
+ }
226
+
227
+ function delivered(db, home, source, turnId, target, now, fields = {}) {
228
+ const operation = requestAutoHandoff(db, { threadId: source, turnId, projectPath: home,
229
+ rolloutPath: '/codex/rollout.jsonl', codexHome: join(home, 'codex'), openHost: 'desktop', now }).operation;
230
+ return updateAutoHandoff(db, operation.handoff_id, { target_thread_id: target, state: 'continued',
231
+ queued_submission_id: `receipt-${target}`, runtime_json: { title: 'ベルチーム' }, ...fields }).operation;
232
+ }
233
+
234
+ test('引き継ぎ済みの旧タスクへ新しい入力が来ても後継を増やさず、止めて今の後継を示して開く', async () => withDb(async (db, home) => {
235
+ const thread = '11111111-1111-1111-1111-111111111111';
236
+ const first = delivered(db, home, thread, '22222222-2222-2222-2222-222222222222', 'successor-1', 1);
237
+ const payload = desktopSource(home, thread, '33333333-3333-3333-3333-333333333333');
238
+ const config = { enabled: true, projects: [home], openHost: 'desktop' };
239
+ const seen = { launched: 0, opened: [], recorded: [], asked: [] };
240
+ const args = { db, config, payload, launchWorker: async () => { seen.launched++; },
241
+ findThread: query => { seen.asked.push(query); return { mtimeMs: 1 }; },
242
+ openThread: operation => { seen.opened.push(operation.target_thread_id); return true; },
243
+ recordRedirect: entry => { seen.recorded.push(entry); } };
244
+ const result = await requestCodexAutoHandoff(args);
245
+ assert.equal(result.continue, false); assert.equal(result.redirected, true); assert.equal(result.inserted, false);
246
+ assert.equal(result.operationId, first.handoff_id); assert.equal(result.targetThreadId, 'successor-1');
247
+ assert.match(result.stopReason, /引き継ぎ済み/);
248
+ assert.match(result.stopReason, /codex:\/\/threads\/successor-1/);
249
+ assert.ok(result.stopReason.includes('|ベルチーム(自動引き継ぎ)」'));
250
+ assert.equal(seen.launched, 0); assert.deepEqual(seen.opened, ['successor-1']);
251
+ assert.equal(db.prepare('SELECT COUNT(*) AS n FROM codex_handoffs').get().n, 1);
252
+ assert.equal(seen.asked[0].threadId, 'successor-1'); assert.equal(seen.asked[0].codexHome, join(home, 'codex'));
253
+ assert.equal(seen.recorded.length, 1);
254
+ assert.deepEqual({ ...seen.recorded[0], at: null }, { at: null, source_thread_id: thread,
255
+ source_turn_id: payload.turn_id, handoff_id: first.handoff_id, target_thread_id: 'successor-1', opened: true });
256
+
257
+ // 後継がさらに引き継いでいれば、その先を示す。開けなくても止めて、止めた理由に後継を書く。
258
+ delivered(db, home, 'successor-1', 'turn-b', 'successor-2', 2);
259
+ const chained = await requestCodexAutoHandoff({ ...args, openThread: () => false });
260
+ assert.equal(chained.targetThreadId, 'successor-2'); assert.equal(chained.opened, false);
261
+ assert.match(chained.stopReason, /codex:\/\/threads\/successor-2/);
262
+ assert.equal(seen.launched, 0);
263
+ }));
264
+
265
+ test('後継が残っていない旧タスクと、配送の前に失敗した旧タスクからは、新しい後継を立てる', async () => withDb(async (db, home) => {
266
+ const thread = '11111111-1111-1111-1111-111111111111';
267
+ const config = { enabled: true, projects: [home], openHost: 'desktop' };
268
+ let launched = 0;
269
+ const base = { db, config, launchWorker: async () => { launched++; },
270
+ openThread: () => { throw new Error('開かない'); }, recordRedirect: () => { throw new Error('記録しない'); } };
271
+ // 作成の途中で失敗した引き継ぎ(後継はあるが、継続の指示は送っていない)。
272
+ const failed = requestAutoHandoff(db, { threadId: thread, turnId: 'turn-a', projectPath: home,
273
+ rolloutPath: '/codex/rollout.jsonl', codexHome: join(home, 'codex'), openHost: 'desktop', now: 1 }).operation;
274
+ updateAutoHandoff(db, failed.handoff_id, { target_thread_id: 'never-started', state: 'failed', error_code: 'handoff_target_not_loaded' });
275
+ const afterFailure = await requestCodexAutoHandoff({ ...base, findThread: () => ({ mtimeMs: 1 }),
276
+ payload: desktopSource(home, thread, '33333333-3333-3333-3333-333333333333') });
277
+ assert.equal(afterFailure.inserted, true); assert.equal(afterFailure.redirected, undefined); assert.equal(launched, 1);
278
+ // 継続の指示は届いたが、後継のrolloutが無い(消した、アーカイブした)。
279
+ updateAutoHandoff(db, afterFailure.operationId, { target_thread_id: 'removed', state: 'continued', queued_submission_id: 'receipt' });
280
+ const afterRemoval = await requestCodexAutoHandoff({ ...base, findThread: () => null,
281
+ payload: desktopSource(home, thread, '44444444-4444-4444-4444-444444444444') });
282
+ assert.equal(afterRemoval.inserted, true); assert.equal(launched, 2);
283
+ // 同じturnのhookがもう一度来た時は、今までどおり同じ引き継ぎを返す。
284
+ const again = await requestCodexAutoHandoff({ ...base, findThread: () => ({ mtimeMs: 1 }),
285
+ payload: desktopSource(home, thread, '44444444-4444-4444-4444-444444444444') });
286
+ assert.equal(again.operationId, afterRemoval.operationId); assert.equal(again.inserted, false); assert.equal(launched, 2);
287
+ }));
288
+
289
+ test('同じ旧タスクから複数の後継が立っていた記録では、最後に動いた後継を選ぶ', async () => withDb(async (db, home) => {
290
+ delivered(db, home, 'old', 'turn-1', 'first', 1);
291
+ const active = delivered(db, home, 'old', 'turn-2', 'second', 2);
292
+ delivered(db, home, 'old', 'turn-3', 'third', 3);
293
+ // 配送結果が不明のまま止まった引き継ぎも、指示が届いたかもしれないので数える。
294
+ const unknown = requestAutoHandoff(db, { threadId: 'other', turnId: 'turn', projectPath: home,
295
+ rolloutPath: '/codex/rollout.jsonl', codexHome: join(home, 'codex'), openHost: 'desktop', now: 4 }).operation;
296
+ updateAutoHandoff(db, unknown.handoff_id, { target_thread_id: 'maybe', state: 'unknown', mutation_stage: 'submit' });
297
+ const mtimes = { first: 10, second: 30, third: 20, maybe: 1 };
298
+ const findThread = ({ threadId }) => ({ mtimeMs: mtimes[threadId] });
299
+ assert.equal(findLiveAutoHandoffSuccessor(db, 'old', { findThread }).handoff_id, active.handoff_id);
300
+ assert.equal(findLiveAutoHandoffSuccessor(db, 'old', { findThread, exceptHandoffId: active.handoff_id }).target_thread_id, 'third');
301
+ assert.equal(findLiveAutoHandoffSuccessor(db, 'other', { findThread }).target_thread_id, 'maybe');
302
+ assert.equal(findLiveAutoHandoffSuccessor(db, 'second', { findThread }), null);
303
+ }));
304
+
305
+ test('workerは、同じ旧タスクの別の引き継ぎが先に指示を送っていたら配送しない', async () => withDb(async db => {
306
+ const counters = { created: 0, notified: 0, submitted: 0 };
307
+ const submit = async () => { counters.submitted++; return { queued_submission_id: `receipt-${counters.submitted}` }; };
308
+ const liveSuccessor = (database, threadId, options) =>
309
+ findLiveAutoHandoffSuccessor(database, threadId, { ...options, findThread: () => ({ mtimeMs: 1 }) });
310
+ const first = request(db, 'shared-source', { now: 1 }).operation;
311
+ const firstResult = await runAutoHandoffWorker(first.handoff_id,
312
+ { db, dependencies: { ...workerDependencies(db, {}, submit, counters), liveSuccessor } });
313
+ assert.equal(firstResult.state, 'continued');
314
+ const second = request(db, 'shared-source', { turnId: 'turn-2', now: 2 }).operation;
315
+ const deps = workerDependencies(db, {}, submit, counters);
316
+ const result = await runAutoHandoffWorker(second.handoff_id, { db, dependencies: { ...deps, liveSuccessor,
317
+ readSource: () => ({ ...deps.readSource(), latestTurnId: 'turn-2' }),
318
+ capture: () => ({ status: 'captured' }),
319
+ createTarget: async (database, operation) => {
320
+ updateAutoHandoff(database, operation.handoff_id, { target_thread_id: 'second-target', mutation_stage: null,
321
+ runtime_json: { ...operation.runtime, targetPrepared: true, targetMemoryInjected: true,
322
+ preparedSettings: deps.readTarget(operation).settings } });
323
+ } } });
324
+ assert.equal(result.state, 'failed'); assert.equal(result.error_code, 'handoff_source_already_continued');
325
+ assert.equal(result.mutation_stage, null); assert.equal(result.queued_submission_id, null);
326
+ assert.equal(counters.submitted, 1); assert.equal(counters.notified, 1);
327
+ }));
328
+
219
329
  test('公開CLIはactionに適合する引数だけを受け取る', () => {
220
330
  assert.equal(parseAutoHandoffArgs(['status', '--json']).action, 'status');
221
331
  for (const args of [['enable', '--operation', 'id'], ['disable', '--project', '/p'], ['status', '--json', '--json'],