throughline 0.16.13 → 0.16.15

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,50 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.16.15] — 2026-10-10
14
+
15
+ ### 追加
16
+
17
+ - `throughline auto-handoff successor --thread <Codexの会話のID> --json`。あるCodexのタスクの「今の続き」を返す、読むだけの入口。ほかの製品が、旧タスクあての連絡を今の続きへ渡す時に使う。`auto-handoff status --json`は新しい20件だけを返すので、古い引き継ぎの続きを引けなかった。
18
+ - 返す物: `handed_off`(残っている後継があるか)、`current_thread_id`(引き継ぎを先までたどった、残っている一番先の後継。無ければ渡したID自身)、`chain`(そこまでの引き継ぎ)、`pending`(今の続きのタスクで、後継へ指示を送る前の引き継ぎ。`in_flight`がtrueならworkerが動いている最中、falseなら止まっていて`error_code`と`resume_state`が入る)。
19
+ - 消された後継とアーカイブ済みの後継は、無い物として扱う(旧タスクへ入力が来た時に開く後継と同じ決め方)。
20
+ - 記録が無い端末では、引き継ぎが1つも無い時と同じ答えを返す。記録は読み取り専用で開く。
21
+
22
+ ### 対応の範囲
23
+
24
+ | | macOS | Linux | Windows |
25
+ |---|---|---|---|
26
+ | Codex(Desktop) | 追加した。実機の記録で確認(引き継ぎ済みのタスク2つから今の続きが返り、引き継ぎの無いタスクは自分自身が返った) | 追加した。実機の記録で確認(後継がアーカイブ済みの系列は、旧タスク自身が返った) | 追加した。実機の記録で確認(16本続いた系列の最初と途中から、同じ今の続きが返った。止まったタスクは`pending`に理由が出た) |
27
+ | Claude Code | 対象外。Claude Codeの自動継続の記録は別の形で、`auto-handoff status --host claude --json`が全件を返す | 同じ | 同じ |
28
+ | Grok | 対象外。自動継続の機能が無い | 同じ | 同じ |
29
+ | Cursor | 対象外。自動継続の機能が無い | 同じ | 同じ |
30
+
31
+ ### 変わらない事
32
+
33
+ - 引き継ぎの動き。`auto-handoff status --json`の出力。
34
+
35
+ ## [0.16.14] — 2026-10-10
36
+
37
+ ### 修正
38
+
39
+ - 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))。
40
+ - 圧縮を止める動きは今までと同じ。引き継ぎは、文脈が上限に近づいた利用者のターンで今までどおり起きる。
41
+ - それ以外の不正なID(会話のIDがUUIDでない、記録の場所が無い、知らない形のターンのID)は、今までどおり失敗として記録する。
42
+
43
+ ### 対応の範囲
44
+
45
+ | | macOS | Linux | Windows |
46
+ |---|---|---|---|
47
+ | Codex(Desktop) | 直した。単体試験と、切り離した置き場での本物のhookの命令で確認(内部のIDは失敗0件・記録1行、不正なIDは失敗1件)。CIのmacOSも成功。**本物のCodex Desktopが内部の文脈からhookを呼ぶ場面は、狙って起こせないので未確認** | 直した。単体試験とCI。実機(同梱0.162.0-alpha.2)では、この呼び出しが起きていない | 直した。単体試験とCI。実機では、この失敗の記録が無い |
48
+ | Claude Code | 対象外。Claude Codeの`PreCompact`は別の実装で、ターンのIDを検査しない | 同じ | 同じ |
49
+ | Grok | 対象外。自動継続の機能が無く、`PreCompact`のhookを登録していない | 同じ | 同じ |
50
+ | Cursor | 対象外。自動継続の機能が無く、`PreCompact`のhookを登録していない | 同じ | 同じ |
51
+
52
+ ### 確認していない範囲
53
+
54
+ - 本物のCodex Desktopが内部の文脈からhookを呼ぶ場面での、新しい版の動き(次に起きた時に、失敗の記録が増えず、`internal-compactions.jsonl`に行が出る事で確かめる)。
55
+ - 内部の文脈からの圧縮が、Codexのどの処理から始まるか(公開ソースでIDの形と渡り方だけを確かめた)。
56
+
13
57
  ## [0.16.13] — 2026-10-10
14
58
 
15
59
  ### 修正
@@ -2173,7 +2217,9 @@ two attempts, instrument first instead of patching again.
2173
2217
 
2174
2218
  ---
2175
2219
 
2176
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.13...HEAD
2220
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.15...HEAD
2221
+ [0.16.15]: https://github.com/kitepon/Throughline/compare/v0.16.14...v0.16.15
2222
+ [0.16.14]: https://github.com/kitepon/Throughline/compare/v0.16.13...v0.16.14
2177
2223
  [0.16.13]: https://github.com/kitepon/Throughline/compare/v0.16.12...v0.16.13
2178
2224
  [0.16.12]: https://github.com/kitepon/Throughline/compare/v0.16.11...v0.16.12
2179
2225
  [0.16.11]: https://github.com/kitepon/Throughline/compare/v0.16.10...v0.16.11
package/README.ja.md CHANGED
@@ -40,6 +40,14 @@ throughline auto-handoff status --operation <handoff-id> --json
40
40
  throughline auto-handoff resume --operation <handoff-id> --json
41
41
  ```
42
42
 
43
+ あるタスクの今の続きは、次の命令で引けます(読むだけ。ほかの道具が、旧タスクあての連絡を今の続きへ渡す時に使います)。
44
+
45
+ ```bash
46
+ throughline auto-handoff successor --thread <codex-thread-id> --json
47
+ ```
48
+
49
+ `current_thread_id`が今の続きです(後継が無ければ渡したID自身)。`pending.in_flight`がtrueの間は、引き継ぎの途中です。
50
+
43
51
  原因を解消した後の`resume`は同じ引き継ぎと既知の後継を再利用します。配送後の結果不明は実際の入力・開始を観測した場合だけ回復し、作成結果が不明な後継は再作成しません。従来の手動`$throughline`とcurrent-thread実験の設定は独立しています。
44
52
 
45
53
  [実測と検証範囲](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-03-codex-auto-continuation.md)、[Windowsの実測](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-08-windows-codex-auto-handoff.md)、[Linuxの実測](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-08-linux-codex-auto-handoff.md)を参照してください。
package/README.md CHANGED
@@ -39,6 +39,14 @@ throughline auto-handoff status --operation <handoff-id> --json
39
39
  throughline auto-handoff resume --operation <handoff-id> --json
40
40
  ```
41
41
 
42
+ あるタスクの今の続きは、次の命令で引けます(読むだけ。ほかの道具が、旧タスクあての連絡を今の続きへ渡す時に使います)。
43
+
44
+ ```bash
45
+ throughline auto-handoff successor --thread <codex-thread-id> --json
46
+ ```
47
+
48
+ `current_thread_id`が今の続きです(後継が無ければ渡したID自身)。`pending.in_flight`がtrueの間は、引き継ぎの途中です。
49
+
42
50
  原因を解消した後の`resume`は同じ引き継ぎと既知の後継を再利用します。配送後の結果不明は実際の入力・開始を観測した場合だけ回復し、作成結果が不明な後継は再作成しません。従来の手動`$throughline`とcurrent-thread実験の設定は独立しています。
43
51
 
44
52
  [実測と検証範囲](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-03-codex-auto-continuation.md)、[Windowsの実測](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-08-windows-codex-auto-handoff.md)、[Linuxの実測](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-08-linux-codex-auto-handoff.md)を参照してください。
@@ -412,6 +412,8 @@ Usage:
412
412
  引き継ぎの状態と固定理由を確認
413
413
  throughline auto-handoff resume --operation <id> --json
414
414
  同じ引き継ぎを再開(結果不明は再送しない)
415
+ throughline auto-handoff successor --thread <codex-thread-id> --json
416
+ あるCodexのタスクの今の続きを返す(読むだけ)
415
417
  throughline auto-handoff mark-sources [--dry-run] [--json]
416
418
  今までの引き継ぎの名前を付け直す(旧タスクへ印、
417
419
  名前の無い後継へ名前)
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.16.13",
3
+ "version": "0.16.15",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -5,7 +5,7 @@ import { getDb, openReadOnlyDb, DB_PATH, CURRENT_VERSION } from '../db.mjs';
5
5
  import { listAutoHandoffs, getAutoHandoff } from '../codex-auto-handoff-store.mjs';
6
6
  import { renderFrozenDetail } from '../codex-auto-handoff-memory.mjs';
7
7
  import { readAutoHandoffConfig, writeAutoHandoffConfig } from '../codex-auto-handoff-config.mjs';
8
- import { autoHandoffDeliveryProfile, runAutoHandoffWorker, markAutoHandoffSources } from '../codex-auto-handoff.mjs';
8
+ import { autoHandoffDeliveryProfile, runAutoHandoffWorker, markAutoHandoffSources, resolveAutoHandoffSuccessor } from '../codex-auto-handoff.mjs';
9
9
  import { installCodexAutoHandoffHook, installClaudeAutoHandoffHooks, removeClaudeAutoHandoffHooks,
10
10
  claudeAutoHandoffHooksRegistered } from './install.mjs';
11
11
  import { sameProjectPath } from '../project-path.mjs';
@@ -17,17 +17,17 @@ import { listClaudeAutoHandoffs, publicClaudeAutoHandoff, runClaudeAutoHandoffWo
17
17
  const CLAUDE_ACTIONS = ['enable', 'disable', 'status', 'worker', 'desktop-open'];
18
18
 
19
19
  export function parseAutoHandoffArgs(args) {
20
- const out = { action: args[0] ?? 'status', host: 'codex', operation: null, project: null, origin: null, turn: null, database: null, json: false, dryRun: false };
21
- if (!['enable', 'disable', 'status', 'resume', 'detail', 'worker', 'desktop-open', 'mark-sources'].includes(out.action)) throw Error('auto_handoff_action_invalid');
20
+ const out = { action: args[0] ?? 'status', host: 'codex', operation: null, project: null, origin: null, turn: null, database: null, thread: null, json: false, dryRun: false };
21
+ if (!['enable', 'disable', 'status', 'resume', 'detail', 'worker', 'desktop-open', 'mark-sources', 'successor'].includes(out.action)) throw Error('auto_handoff_action_invalid');
22
22
  const allowed = ['host', ...{ enable: ['project'], disable: [], status: ['project', 'operation'],
23
- resume: ['operation'], worker: ['operation'], 'desktop-open': ['operation'], 'mark-sources': [], detail: ['operation', 'origin', 'turn', 'database'] }[out.action]];
23
+ resume: ['operation'], worker: ['operation'], 'desktop-open': ['operation'], 'mark-sources': [], successor: ['thread'], detail: ['operation', 'origin', 'turn', 'database'] }[out.action]];
24
24
  const seen = new Set();
25
25
  for (let i = 1; i < args.length; i++) {
26
26
  if (seen.has(args[i])) throw Error('auto_handoff_argument_invalid');
27
27
  seen.add(args[i]);
28
28
  if (args[i] === '--json') { out.json = true; continue; }
29
29
  if (args[i] === '--dry-run' && out.action === 'mark-sources') { out.dryRun = true; continue; }
30
- const key = { '--host': 'host', '--operation': 'operation', '--project': 'project', '--origin': 'origin', '--turn': 'turn', '--database': 'database' }[args[i]];
30
+ const key = { '--host': 'host', '--operation': 'operation', '--project': 'project', '--origin': 'origin', '--turn': 'turn', '--database': 'database', '--thread': 'thread' }[args[i]];
31
31
  if (!key || !allowed.includes(key) || !args[i + 1] || args[i + 1].startsWith('--')) throw Error('auto_handoff_argument_invalid');
32
32
  out[key] = args[++i];
33
33
  }
@@ -36,6 +36,7 @@ export function parseAutoHandoffArgs(args) {
36
36
  if (out.project && !isAbsolute(out.project)) out.project = resolve(out.project);
37
37
  if (out.project && out.operation) throw Error('auto_handoff_argument_invalid');
38
38
  if (['worker', 'resume', 'detail', 'desktop-open'].includes(out.action) && !out.operation) throw Error('auto_handoff_operation_required');
39
+ if (out.action === 'successor' && !out.thread) throw Error('auto_handoff_thread_required');
39
40
  if (out.action === 'detail') {
40
41
  out.turn = Number(out.turn);
41
42
  if (!out.origin || !Number.isSafeInteger(out.turn) || out.turn < 1) throw Error('auto_handoff_detail_identity_required');
@@ -128,6 +129,14 @@ export async function run(args = []) {
128
129
  finally { db.close(); }
129
130
  process.stdout.write(parsed.json ? JSON.stringify({ status: 'ok', text }) + '\n' : text + '\n');
130
131
  return 0;
132
+ } else if (parsed.action === 'successor') {
133
+ // あるタスクの今の続きを返す。読むだけ。DB がまだ無い端末では、引き継ぎが1つも無いのと同じ答えにする。
134
+ const db = existsSync(DB_PATH) ? openReadOnlyDb() : null;
135
+ try {
136
+ if (db && db.prepare('PRAGMA user_version').get().user_version !== CURRENT_VERSION) throw Error('auto_handoff_schema_mismatch');
137
+ result = { schema: 'throughline.codex_auto_handoff_successor.v1', ...(db ? resolveAutoHandoffSuccessor(db, parsed.thread)
138
+ : { thread_id: parsed.thread, handed_off: false, current_thread_id: parsed.thread, chain: [], pending: null }) };
139
+ } finally { db?.close(); }
131
140
  } else if (parsed.action === 'mark-sources') {
132
141
  // 0.16.9 までの版が引き継いだ分の名前を付け直す(名前の無い後継へ名前、旧タスクへ引き継ぎ済みの印)。何度流しても同じ結果になる。
133
142
  const db = getDb();
@@ -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
- if (!CODEX_NATIVE_ID_PATTERN.test(threadId ?? '') || !CODEX_NATIVE_ID_PATTERN.test(turnId ?? '') ||
164
- typeof payload.transcript_path !== 'string' || !isAbsolute(payload.transcript_path) || !isAbsolute(payload.cwd ?? '')) {
165
- throw error('handoff_hook_identity_invalid');
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 });
@@ -285,6 +301,39 @@ export async function nameAutoHandoffTarget(request, targetId, name) {
285
301
  }
286
302
  }
287
303
 
304
+ /**
305
+ * あるタスクの「今の続き」を引く(ほかの製品が、旧タスクあての連絡を今の続きへ渡すための入口)。読むだけ。
306
+ * 引き継ぎを先までたどり、残っている一番先の後継を返す。消された後継は無い物として扱う。
307
+ * @returns {{
308
+ * thread_id: string, handed_off: boolean, current_thread_id: string,
309
+ * chain: Array<{handoff_id: string, source_thread_id: string, target_thread_id: string, state: string, created_at: number, updated_at: number}>,
310
+ * pending: null | {handoff_id: string, source_thread_id: string, state: string, error_code: string|null, resume_state: string|null,
311
+ * in_flight: boolean, created_at: number, updated_at: number}
312
+ * }} current_thread_id は、後継が無ければ thread_id 自身。pending は、今の続きのタスクで、後継へ指示を送る前の引き継ぎ
313
+ * (一番新しい1つ)。in_flight が true なら worker が動いている最中、false なら止まっている(error_code と resume_state を見る)。
314
+ */
315
+ export function resolveAutoHandoffSuccessor(db, threadId, { findThread = findCodexThreadCandidate,
316
+ processes = readRuntimeProcesses, now = Date.now() } = {}) {
317
+ if (!CODEX_NATIVE_ID_PATTERN.test(threadId ?? '')) throw error('handoff_thread_id_invalid');
318
+ // 残っている一番先の後継(旧タスクへ入力が来た時に開く物と同じ)。そこから threadId まで、作った引き継ぎをさかのぼる。
319
+ const leaf = findLiveAutoHandoffSuccessor(db, threadId, { findThread });
320
+ const path = [];
321
+ for (let operation = leaf, depth = 0; operation && depth < 200; depth++) {
322
+ path.unshift(operation);
323
+ if (operation.source_thread_id === threadId) break;
324
+ operation = findAutoHandoffForTarget(db, operation.source_thread_id);
325
+ }
326
+ const chain = path.map(operation => ({ handoff_id: operation.handoff_id, source_thread_id: operation.source_thread_id,
327
+ target_thread_id: operation.target_thread_id, state: operation.state, created_at: operation.created_at, updated_at: operation.updated_at }));
328
+ const current = leaf ? leaf.target_thread_id : threadId;
329
+ const undelivered = listUndeliveredAutoHandoffsForSource(db, current)[0] ?? null;
330
+ const pending = undelivered ? { handoff_id: undelivered.handoff_id, source_thread_id: undelivered.source_thread_id,
331
+ state: undelivered.state, error_code: undelivered.error_code ?? null, resume_state: undelivered.resume_state ?? null,
332
+ in_flight: !HANDOFF_TERMINAL_STATES.has(undelivered.state) && workerActive(undelivered, processes, now),
333
+ created_at: undelivered.created_at, updated_at: undelivered.updated_at } : null;
334
+ return { thread_id: threadId, handed_off: chain.length > 0, current_thread_id: current, chain, pending };
335
+ }
336
+
288
337
  /**
289
338
  * 引き継ぎが済んだ旧タスクの名前の頭へ、引き継ぎ済みの印を付ける。同じ名前の後継が一覧に並んでも、
290
339
  * 印の無い1本が今の続きだと分かるようにする。今の名前を読んでから付けるので、利用者が付け直した名前も残る。
@@ -15,7 +15,7 @@ import { readCodexHandoffState, settingsMatch, threadStartSettings, resolvePrepa
15
15
  import { runAutoHandoffWorker, requestCodexAutoHandoff, sourceBoundary,
16
16
  autoHandoffTargetName, nameAutoHandoffTarget, findLiveAutoHandoffSuccessor,
17
17
  markAutoHandoffSource, markAutoHandoffSources, autoHandoffSourceFallbackTitle, carryAutoHandoffQueue,
18
- untrackedInheritedMemory } from './codex-auto-handoff.mjs';
18
+ untrackedInheritedMemory, resolveAutoHandoffSuccessor } from './codex-auto-handoff.mjs';
19
19
  import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
20
20
 
21
21
  async function withDb(fn) {
@@ -287,6 +287,85 @@ test('PreCompactの重複ではworkerを増やさず、manualと別projectでは
287
287
  assert.equal((await requestCodexAutoHandoff({ ...args, config: { ...config, projects: ['/other'] } })).status, 'skipped');
288
288
  }));
289
289
 
290
+ test('タスクの今の続きを引く。先までたどり、消えた後継は無い物として扱い、途中の引き継ぎと止まった引き継ぎを見分ける', async () => withDb(async db => {
291
+ const id = n => `${String(n).repeat(8)}-${String(n).repeat(4)}-${String(n).repeat(4)}-${String(n).repeat(4)}-${String(n).repeat(12)}`;
292
+ const [A, B, C, D, X, Y, Z] = [1, 2, 3, 4, 5, 6, 7].map(id);
293
+ const done = (source, target) => updateAutoHandoff(db, request(db, source).operation.handoff_id,
294
+ { state: 'continued', target_thread_id: target, queued_submission_id: `receipt-${target}` }).operation;
295
+ const gone = new Set();
296
+ const options = { findThread: ({ threadId }) => gone.has(threadId) ? null : { rolloutPath: `/r/${threadId}`, mtimeMs: 1 }, processes: () => [], now: 1_000_000 };
297
+ // 引き継ぎの無いタスクは、自分が今の続き
298
+ assert.deepEqual(resolveAutoHandoffSuccessor(db, X, options), { thread_id: X, handed_off: false, current_thread_id: X, chain: [], pending: null });
299
+
300
+ const ab = done(A, B); const bc = done(B, C);
301
+ const fromA = resolveAutoHandoffSuccessor(db, A, options);
302
+ assert.equal(fromA.handed_off, true); assert.equal(fromA.current_thread_id, C); assert.equal(fromA.pending, null);
303
+ assert.deepEqual(fromA.chain.map(item => [item.handoff_id, item.source_thread_id, item.target_thread_id, item.state]),
304
+ [[ab.handoff_id, A, B, 'continued'], [bc.handoff_id, B, C, 'continued']]);
305
+ assert.equal(resolveAutoHandoffSuccessor(db, B, options).current_thread_id, C);
306
+ assert.deepEqual(resolveAutoHandoffSuccessor(db, B, options).chain.map(item => item.handoff_id), [bc.handoff_id]);
307
+ assert.equal(resolveAutoHandoffSuccessor(db, C, options).handed_off, false);
308
+
309
+ // 今の続き C が、さらに引き継いでいる最中(worker が動いている)
310
+ const cd = updateAutoHandoff(db, request(db, C).operation.handoff_id, { state: 'memory_ready' }).operation;
311
+ db.prepare('UPDATE codex_handoffs SET worker_identity_json = ?, updated_at = ? WHERE handoff_id = ?').run(JSON.stringify({ pid: 4242, started_identity: 'w' }), 999_000, cd.handoff_id);
312
+ const running = resolveAutoHandoffSuccessor(db, A, { ...options, processes: () => [{ pid: 4242, started_identity: 'w' }] });
313
+ assert.equal(running.current_thread_id, C); assert.equal(running.pending.handoff_id, cd.handoff_id);
314
+ assert.equal(running.pending.in_flight, true); assert.equal(running.pending.state, 'memory_ready');
315
+ // worker が居なくなっていれば、途中で止まった物として返す
316
+ assert.equal(resolveAutoHandoffSuccessor(db, A, options).pending.in_flight, false);
317
+
318
+ // C の引き継ぎが失敗して止まった
319
+ updateAutoHandoff(db, cd.handoff_id, { state: 'failed', error_code: 'handoff_summarizer_backend_failed', resume_state: 'source_stopped' });
320
+ const stopped = resolveAutoHandoffSuccessor(db, A, options);
321
+ assert.equal(stopped.current_thread_id, C);
322
+ assert.deepEqual([stopped.pending.state, stopped.pending.error_code, stopped.pending.resume_state, stopped.pending.in_flight],
323
+ ['failed', 'handoff_summarizer_backend_failed', 'source_stopped', false]);
324
+
325
+ // やり直して D へ続いた。C が消されても、D が残っていれば D が今の続き
326
+ updateAutoHandoff(db, cd.handoff_id, { state: 'continued', error_code: null, resume_state: null, target_thread_id: D, queued_submission_id: 'receipt-D' });
327
+ gone.add(C);
328
+ const toD = resolveAutoHandoffSuccessor(db, A, options);
329
+ assert.equal(toD.current_thread_id, D); assert.equal(toD.chain.length, 3); assert.equal(toD.pending, null);
330
+ // 後継が全部消されたら、旧タスクが今の続きに戻る
331
+ gone.add(D); gone.add(B);
332
+ assert.deepEqual(resolveAutoHandoffSuccessor(db, A, options), { thread_id: A, handed_off: false, current_thread_id: A, chain: [], pending: null });
333
+
334
+ // 後継が出来る前に失敗したタスク(後継なし)
335
+ updateAutoHandoff(db, request(db, Y).operation.handoff_id, { state: 'failed', error_code: 'handoff_target_not_loaded', resume_state: 'memory_ready' });
336
+ const failed = resolveAutoHandoffSuccessor(db, Y, options);
337
+ assert.equal(failed.handed_off, false); assert.equal(failed.current_thread_id, Y); assert.equal(failed.pending.error_code, 'handoff_target_not_loaded');
338
+ assert.throws(() => resolveAutoHandoffSuccessor(db, 'not-a-thread', options), /handoff_thread_id_invalid/);
339
+ void Z;
340
+ }));
341
+
342
+ test('Codexの内部の文脈(auto-compact-N)からの圧縮は、例外にせず止めて記録する。引き継ぎは作らない', async () => withDb(async (db, home) => {
343
+ const thread = '11111111-1111-1111-1111-111111111111';
344
+ const file = join(home, 'rollout.jsonl'); writeFileSync(file, '');
345
+ const payload = { trigger: 'auto', cwd: home, session_id: thread, turn_id: 'auto-compact-3', transcript_path: file };
346
+ const config = { enabled: true, projects: [home], openHost: 'desktop' };
347
+ let launched = 0; const recorded = [];
348
+ const args = { db, config, payload, launchWorker: async () => { launched++; }, recordInternalCompaction: entry => recorded.push(entry), now: Date.parse('2026-10-10T04:24:01.175Z') };
349
+ const result = await requestCodexAutoHandoff(args);
350
+ assert.equal(result.status, 'ok'); assert.equal(result.continue, false); assert.equal(result.internalCompaction, true);
351
+ assert.match(result.stopReason, /ターンの外の自動圧縮を止めました/);
352
+ assert.equal(launched, 0);
353
+ assert.equal(db.prepare('SELECT COUNT(*) n FROM codex_handoffs').get().n, 0, '引き継ぎの行は作らない');
354
+ assert.deepEqual(recorded, [{ at: '2026-10-10T04:24:01.175Z', thread_id: thread, turn_id: 'auto-compact-3' }]);
355
+
356
+ // 無効な project と手動の圧縮は、今までどおり何もしない
357
+ assert.equal((await requestCodexAutoHandoff({ ...args, config: { ...config, projects: ['/other'] } })).status, 'skipped');
358
+ assert.equal((await requestCodexAutoHandoff({ ...args, payload: { ...payload, trigger: 'manual' } })).status, 'skipped');
359
+ assert.equal(recorded.length, 1);
360
+
361
+ // 形の決まっていない ID と、場所の分からない呼び出しは、今までどおり失敗として扱う
362
+ for (const broken of [{ turn_id: 'something-else' }, { turn_id: undefined }, { turn_id: 'auto-compact-' }, { session_id: 'not-a-uuid' },
363
+ { transcript_path: null }, { transcript_path: 'relative/rollout.jsonl' }]) {
364
+ await assert.rejects(requestCodexAutoHandoff({ ...args, payload: { ...payload, ...broken } }), /handoff_hook_identity_invalid/);
365
+ }
366
+ assert.equal(recorded.length, 1);
367
+ }));
368
+
290
369
  test('PreCompactの入口は、JS実装のrealpathが使えない環境(WindowsのCodex Desktopのhook)でも引き継ぎを作る', async () => withDb(async (db, home) => {
291
370
  const thread = '11111111-1111-1111-1111-111111111111', turn = '22222222-2222-2222-2222-222222222222';
292
371
  const dir = join(home, 'codex', 'sessions'); mkdirSync(dir, { recursive: true });