throughline 0.16.26 → 0.16.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,8 @@ import { getDb, openReadOnlyDb, DB_PATH, CURRENT_VERSION } from '../db.mjs';
5
5
  import { listAutoHandoffs, getAutoHandoff } from '../codex-auto-handoff-store.mjs';
6
6
  import { renderFrozenDetail } from '../codex-auto-handoff-memory.mjs';
7
7
  import { readAutoHandoffConfig, writeAutoHandoffConfig } from '../codex-auto-handoff-config.mjs';
8
- import { autoHandoffDeliveryProfile, runAutoHandoffWorker, markAutoHandoffSources, resolveAutoHandoffSuccessor } from '../codex-auto-handoff.mjs';
8
+ import { autoHandoffDeliveryProfile, runAutoHandoffWorker, markAutoHandoffSources, resolveAutoHandoffSuccessor,
9
+ settleAutoHandoffSourceGoal } from '../codex-auto-handoff.mjs';
9
10
  import { installCodexAutoHandoffHook, installClaudeAutoHandoffHooks, removeClaudeAutoHandoffHooks,
10
11
  claudeAutoHandoffHooksRegistered } from './install.mjs';
11
12
  import { sameProjectPath } from '../project-path.mjs';
@@ -19,9 +20,9 @@ const CLAUDE_ACTIONS = ['enable', 'disable', 'status', 'worker', 'desktop-open',
19
20
 
20
21
  export function parseAutoHandoffArgs(args) {
21
22
  const out = { action: args[0] ?? 'status', host: 'codex', operation: null, project: null, origin: null, turn: null, database: null, thread: null, json: false, dryRun: false };
22
- if (!['enable', 'disable', 'status', 'resume', 'detail', 'worker', 'desktop-open', 'mark-sources', 'successor'].includes(out.action)) throw Error('auto_handoff_action_invalid');
23
+ if (!['enable', 'disable', 'status', 'resume', 'detail', 'worker', 'desktop-open', 'mark-sources', 'successor', 'settle-goal'].includes(out.action)) throw Error('auto_handoff_action_invalid');
23
24
  const allowed = ['host', ...{ enable: ['project'], disable: [], status: ['project', 'operation'],
24
- resume: ['operation'], worker: ['operation'], 'desktop-open': ['operation'], 'mark-sources': [], successor: ['thread'], detail: ['operation', 'origin', 'turn', 'database'] }[out.action]];
25
+ resume: ['operation'], worker: ['operation'], 'desktop-open': ['operation'], 'mark-sources': [], successor: ['thread'], 'settle-goal': ['operation', 'thread'], detail: ['operation', 'origin', 'turn', 'database'] }[out.action]];
25
26
  const seen = new Set();
26
27
  for (let i = 1; i < args.length; i++) {
27
28
  if (seen.has(args[i])) throw Error('auto_handoff_argument_invalid');
@@ -36,8 +37,8 @@ export function parseAutoHandoffArgs(args) {
36
37
  if (out.host === 'claude' ? !CLAUDE_ACTIONS.includes(out.action) : out.action === 'desktop-open') throw Error('auto_handoff_action_unsupported');
37
38
  if (out.project && !isAbsolute(out.project)) out.project = resolve(out.project);
38
39
  if (out.project && out.operation) throw Error('auto_handoff_argument_invalid');
39
- if (['worker', 'resume', 'detail', 'desktop-open'].includes(out.action) && !out.operation) throw Error('auto_handoff_operation_required');
40
- if (out.action === 'successor' && !out.thread) throw Error('auto_handoff_thread_required');
40
+ if (['worker', 'resume', 'detail', 'desktop-open', 'settle-goal'].includes(out.action) && !out.operation) throw Error('auto_handoff_operation_required');
41
+ if (['successor', 'settle-goal'].includes(out.action) && !out.thread) throw Error('auto_handoff_thread_required');
41
42
  if (out.action === 'detail') {
42
43
  out.turn = Number(out.turn);
43
44
  if (!out.origin || !Number.isSafeInteger(out.turn) || out.turn < 1) throw Error('auto_handoff_detail_identity_required');
@@ -146,6 +147,9 @@ export async function run(args = []) {
146
147
  // 0.16.9 までの版が引き継いだ分の名前を付け直す(名前の無い後継へ名前、旧タスクへ引き継ぎ済みの印)。何度流しても同じ結果になる。
147
148
  const db = getDb();
148
149
  result = { status: 'ok', dry_run: parsed.dryRun, ...await markAutoHandoffSources(db, { dryRun: parsed.dryRun }) };
150
+ } else if (parsed.action === 'settle-goal') {
151
+ // 引き継ぎ済みの旧タスクに動いたまま残ったゴールを止めて、今の後継へ運ぶ(hook が起こす。ADR 0059)。
152
+ result = await settleAutoHandoffSourceGoal(getDb(), { operationId: parsed.operation, threadId: parsed.thread });
149
153
  } else if (parsed.action === 'worker') {
150
154
  result = publicOperation(await runAutoHandoffWorker(parsed.operation));
151
155
  } else if (parsed.action === 'resume') {
@@ -1,9 +1,9 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { spawnSync } from 'node:child_process';
4
- import { readFileSync } from 'node:fs';
4
+ import { readFileSync, rmSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
- import { join } from 'node:path';
6
+ import { dirname, join } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
 
9
9
  import {
@@ -141,7 +141,7 @@ test('missing grok binary does not spawn', () => {
141
141
  assert.equal(spawned.length, 0);
142
142
  });
143
143
 
144
- test('successful continue spawn uses source project cwd and waits', () => {
144
+ test('successful continue spawn uses source project cwd and waits', (t) => {
145
145
  const io = capture();
146
146
  const spawned = [];
147
147
  const uuid = '22222222-2222-4222-8222-222222222222';
@@ -164,6 +164,7 @@ test('successful continue spawn uses source project cwd and waits', () => {
164
164
  assert.equal(spawned.length, 1);
165
165
  assert.equal(io.out, `grok:${uuid}\n`);
166
166
  const { grokArgv, launchFile, cwd } = spawned[0];
167
+ t.after(() => rmSync(dirname(launchFile), { recursive: true, force: true }));
167
168
  assert.equal(cwd, REPO_ROOT);
168
169
  assert.equal(grokArgv.includes('--rules'), false);
169
170
  assert.ok(grokArgv.at(-1).includes(CONTEXT));
@@ -232,7 +233,11 @@ test('bin usage error is exit 2 without spawning a real grok', () => {
232
233
  const result = spawnSync(process.execPath, [BIN_PATH, 'grok-continue'], {
233
234
  cwd: REPO_ROOT,
234
235
  encoding: 'utf8',
235
- env: { ...process.env, HOME: join(tmpdir(), 'tl-grok-continue-missing-home') },
236
+ env: {
237
+ ...process.env,
238
+ HOME: join(tmpdir(), 'tl-grok-continue-missing-home'),
239
+ USERPROFILE: join(tmpdir(), 'tl-grok-continue-missing-home'),
240
+ },
236
241
  });
237
242
  assert.equal(result.status, 2);
238
243
  assert.match(result.stderr, /Usage: throughline grok-continue --session <id>/);
@@ -1,9 +1,8 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { spawnSync } from 'node:child_process';
4
- import { mkdtempSync } from 'node:fs';
5
- import { tmpdir } from 'node:os';
6
4
  import { join } from 'node:path';
5
+ import { testTempDir } from '../test-temp-dir.mjs';
7
6
  import { fileURLToPath } from 'node:url';
8
7
 
9
8
  import { parseArgs, run } from './runtime-errors.mjs';
@@ -43,7 +42,7 @@ test('runtime-errors CLI: internal failure is fixed and does not reflect excepti
43
42
  });
44
43
 
45
44
  test('runtime-errors CLI: snapshot and diagnostics are JSON-only and contain no state path', () => {
46
- const root = mkdtempSync(join(tmpdir(), 'throughline-runtime-cli-'));
45
+ const root = testTempDir('throughline-runtime-cli-');
47
46
  const env = {
48
47
  ...process.env,
49
48
  HOME: root,
@@ -1,4 +1,4 @@
1
- import { mkdirSync, openSync, closeSync, writeFileSync, appendFileSync, realpathSync } from 'node:fs';
1
+ import { mkdirSync, openSync, closeSync, writeFileSync, appendFileSync, realpathSync, statSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, join, resolve, relative, isAbsolute } from 'node:path';
4
4
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -73,6 +73,30 @@ export async function launchAutoHandoffWorker(id, { resume = false } = {}) {
73
73
  } finally { closeSync(log); }
74
74
  }
75
75
 
76
+ // 同じ旧タスクのゴールを確かめる process を起こす間隔。ゴールの続行は1〜2秒おきに来るので、来るたびには起こさない。
77
+ const GOAL_SETTLE_INTERVAL_MS = 30_000;
78
+
79
+ /**
80
+ * 引き継ぎ済みの旧タスクにゴールが動いたまま残っていないかを、別の process に確かめさせる(hook は待たない)。
81
+ * 同じ旧タスクへは GOAL_SETTLE_INTERVAL_MS に1回まで。
82
+ * @returns {Promise<boolean>} 起こしたか
83
+ */
84
+ export async function launchAutoHandoffGoalSettle(threadId, operation, { now = Date.now(), spawn = spawnPortable } = {}) {
85
+ const dir = join(stateRoot(), 'goal-settle');
86
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
87
+ const marker = join(dir, threadId);
88
+ try { if (now - statSync(marker).mtimeMs < GOAL_SETTLE_INTERVAL_MS) return false; } catch { /* まだ1回も起こしていない */ }
89
+ writeFileSync(marker, '', { mode: 0o600 });
90
+ const log = openSync(join(stateRoot(), `${operation.handoff_id}.log`), 'a', 0o600);
91
+ try {
92
+ const child = spawn(process.execPath, [CLI_PATH, 'auto-handoff', 'settle-goal', '--operation', operation.handoff_id,
93
+ '--thread', threadId, '--json'], { detached: true, stdio: ['ignore', log, log], windowsHide: true });
94
+ await new Promise((resolveSpawn, reject) => { child.once('spawn', resolveSpawn); child.once('error', reject); });
95
+ child.unref();
96
+ return true;
97
+ } finally { closeSync(log); }
98
+ }
99
+
76
100
  function liveSuccessors(db, threadId, options, seen) {
77
101
  const found = [];
78
102
  for (const operation of listDeliveredAutoHandoffsForSource(db, threadId)) {
@@ -144,6 +168,12 @@ function recordAutoHandoffRedirect(entry) {
144
168
  appendFileSync(join(stateRoot(), 'redirects.jsonl'), JSON.stringify(entry) + '\n', { mode: 0o600 });
145
169
  }
146
170
 
171
+ /** 引き継ぎ済みの旧タスクに残っていたゴールを片付けた記録。会話の本文も、ゴールの目標の文も書かない。 */
172
+ function recordGoalSettle(entry) {
173
+ mkdirSync(stateRoot(), { recursive: true, mode: 0o700 });
174
+ appendFileSync(join(stateRoot(), 'goal-settles.jsonl'), JSON.stringify(entry) + '\n', { mode: 0o600 });
175
+ }
176
+
147
177
  // workerを起動してから、workerが自分のprocessを記録へ名乗るまでの間。この間の引き継ぎは、進んでいる物として扱う。
148
178
  const WORKER_START_GRACE_MS = 15_000;
149
179
  // workerが1回の引き継ぎに掛ける時間の上限(各段の待ち時間の合計より長い)。process一覧を読めない時だけ使う。
@@ -192,6 +222,7 @@ function settleUndeliveredAutoHandoff(db, threadId, { rolloutPath, processes, re
192
222
  export async function requestCodexAutoHandoff({ payload, db = null,
193
223
  config = readAutoHandoffConfig(), launchWorker = launchAutoHandoffWorker,
194
224
  findThread = findCodexThreadCandidate, openThread = openSuccessor, recordRedirect = recordAutoHandoffRedirect,
225
+ settleGoal = launchAutoHandoffGoalSettle,
195
226
  recordInternalCompaction = recordInternalCompactionStop, recordUnrecordedCompaction = recordUnrecordedCompactionPass,
196
227
  processes = readRuntimeProcesses, readTarget = targetState, now = Date.now() } = {}) {
197
228
  if (payload.trigger !== 'auto' || !autoHandoffEnabledFor(config, payload.cwd)) return { status: 'skipped' };
@@ -229,9 +260,16 @@ export async function requestCodexAutoHandoff({ payload, db = null,
229
260
  const sameTurn = findAutoHandoffForTurn(actualDb, threadId, turnId);
230
261
  const successor = sameTurn ? null : findLiveAutoHandoffSuccessor(actualDb, threadId, { findThread });
231
262
  if (successor) {
232
- const opened = openThread(successor);
263
+ // Codex が自分で起こしたターン(ゴールの続行など)では、後継を開かない。人が入力していないのに、画面が後継へ
264
+ // 切り替わり続ける(2026-10-13、Windows。旧タスクに残ったゴールが1〜2秒おきにターンを起こし、約2,000回)。
265
+ const internal = state.internalInputOnly === true;
266
+ const opened = internal ? false : openThread(successor);
267
+ // 旧タスクにゴールが動いたまま残っていると、止めてもすぐ次のターンが来る。別の process で止めて、後継へ運ぶ (ADR 0059)。
268
+ let goalSettle = false;
269
+ try { goalSettle = await settleGoal(threadId, successor, { now }); } catch { /* 起こせなくても、このターンは止める */ }
233
270
  recordRedirect({ at: new Date().toISOString(), source_thread_id: threadId, source_turn_id: turnId,
234
- handoff_id: successor.handoff_id, target_thread_id: successor.target_thread_id, opened });
271
+ handoff_id: successor.handoff_id, target_thread_id: successor.target_thread_id, opened,
272
+ ...(internal ? { internal: true } : {}), ...(goalSettle ? { goal_settle: true } : {}) });
235
273
  return { status: 'ok', operationId: successor.handoff_id, inserted: false, redirected: true,
236
274
  targetThreadId: successor.target_thread_id, opened, continue: false,
237
275
  stopReason: `このタスクはThroughlineが新しいタスクへ引き継ぎ済みです(引き継ぎID: ${successor.handoff_id})。` +
@@ -446,6 +484,88 @@ export async function carryAutoHandoffQueue(db, operation, runtime, { connect =
446
484
  return summary();
447
485
  }
448
486
 
487
+ // Codex のゴール(`/goal`。目標が終わるまで、タスクが手すきになるたびに Codex が自分で次のターンを起こす)のうち、
488
+ // 後継へ運ぶ状態。動いている物と、利用者が止めている物だけ。完了した物と、Codex が上限や行き詰まりで止めた物は運ばない。
489
+ const CARRIED_GOAL_STATUSES = new Set(['active', 'paused']);
490
+
491
+ /**
492
+ * 旧タスクのゴールを一時停止にする。動いたままのゴールは、hook が止めたターンの直後から、Codex が次のターンを
493
+ * 起こし続ける。ゴールは消さない(後継へ運ぶ元にする。旧タスクで続けたい時は、利用者が再開できる)。
494
+ * ゴールを扱えなくても引き継ぎは止めない(理由は worker のログに残す)。
495
+ * @returns {Promise<'paused'|'idle'|'none'|'failed'>} idle は動いていないゴールがある、none はゴールが無い
496
+ */
497
+ export async function pauseAutoHandoffSourceGoal(operation, runtime, { connect = withCodexReceiver } = {}) {
498
+ const threadId = operation.source_thread_id;
499
+ try {
500
+ return await connect(autoHandoffDeliveryProfile, { thread_id: threadId, codex_home: operation.codex_home }, async request => {
501
+ const goal = (await request('thread/goal/get', { threadId })).goal;
502
+ if (!goal) return 'none';
503
+ if (goal.status !== 'active') return 'idle';
504
+ await request('thread/goal/set', { threadId, status: 'paused' });
505
+ return 'paused';
506
+ }, runtime);
507
+ } catch (cause) {
508
+ process.stderr.write(`[auto-handoff] source goal pause failed: ${cause?.delivery_code ?? cause?.code ?? 'unknown'}\n`);
509
+ return 'failed';
510
+ }
511
+ }
512
+
513
+ /**
514
+ * 旧タスクのゴールを後継へ運ぶ。目標の文と、予算の残りを写す。旧タスクで動いていたゴールは後継でも動かし、
515
+ * 利用者が止めていたゴールは止めたまま運ぶ。後継にもうゴールがあれば書き換えない。旧タスクのゴールは消さない。
516
+ * 運べなくても引き継ぎは止めない(後継はもう作業を続けている。理由は worker のログに残す)。
517
+ * @param {{wasActive?: boolean, onlyActive?: boolean, sourceThreadId?: string}} [options] wasActive は、引き継ぎの初めに
518
+ * この製品が一時停止にしたゴール(後継では動かす)。onlyActive は、今も動いているゴールだけを運ぶ(止まっている物には触らない)。
519
+ * @returns {Promise<'active'|'paused'|'kept'|'none'|'failed'>} active・paused は後継へ付けた状態、kept は後継にもうゴールがある
520
+ */
521
+ export async function carryAutoHandoffGoal(operation, runtime, { connect = withCodexReceiver, wasActive = false,
522
+ onlyActive = false, sourceThreadId = operation.source_thread_id } = {}) {
523
+ const targetId = operation.target_thread_id;
524
+ try {
525
+ return await connect(autoHandoffDeliveryProfile, { thread_id: sourceThreadId, codex_home: operation.codex_home }, async request => {
526
+ const goal = (await request('thread/goal/get', { threadId: sourceThreadId })).goal;
527
+ if (!goal || !CARRIED_GOAL_STATUSES.has(goal.status) || typeof goal.objective !== 'string') return 'none';
528
+ let active = wasActive;
529
+ if (goal.status === 'active') {
530
+ // 初めの一時停止が効かなかった、または引き継いだ後で動き出した。旧タスクを先に止める。
531
+ await request('thread/goal/set', { threadId: sourceThreadId, status: 'paused' });
532
+ active = true;
533
+ } else if (onlyActive) return 'none';
534
+ if ((await request('thread/goal/get', { threadId: targetId })).goal) return 'kept';
535
+ const status = active ? 'active' : 'paused';
536
+ // 予算は、旧タスクで使った分を引いた残りを渡す(使い切ったゴールは Codex が budgetLimited にするので、ここへ来ない)。
537
+ const budget = Number.isSafeInteger(goal.tokenBudget)
538
+ ? Math.max(1, goal.tokenBudget - (Number.isSafeInteger(goal.tokensUsed) ? goal.tokensUsed : 0)) : null;
539
+ await request('thread/goal/set', { threadId: targetId, objective: goal.objective, status,
540
+ ...(budget == null ? {} : { tokenBudget: budget }) });
541
+ return status;
542
+ }, runtime);
543
+ } catch (cause) {
544
+ process.stderr.write(`[auto-handoff] goal carry failed: ${cause?.delivery_code ?? cause?.code ?? 'unknown'}\n`);
545
+ return 'failed';
546
+ }
547
+ }
548
+
549
+ /**
550
+ * 引き継ぎ済みの旧タスクにゴールが動いたまま残っていた時の片付け(hook が起こす)。旧タスクのゴールを一時停止にし、
551
+ * 今の後継にゴールが無ければ運ぶ。0.16.27 までの版が引き継いだ旧タスクと、引き継いだ後で利用者が再開したゴールが当たる。
552
+ * @returns {Promise<{status: 'settled'|'skipped', goal?: string, reason?: string}>}
553
+ */
554
+ export async function settleAutoHandoffSourceGoal(db, { operationId, threadId }, { executable = () => platformDesktopFinder()(),
555
+ carryGoal = carryAutoHandoffGoal, findThread = findCodexThreadCandidate, record = recordGoalSettle } = {}) {
556
+ if (!CODEX_NATIVE_ID_PATTERN.test(threadId ?? '')) throw error('handoff_thread_id_invalid');
557
+ // 起こされてからここまでの間に、後継がさらに引き継いでいる事がある。今の一番先の後継へ運ぶ。
558
+ const successor = findLiveAutoHandoffSuccessor(db, threadId, { findThread });
559
+ if (!successor || !getAutoHandoff(db, operationId)) return { status: 'skipped', reason: 'handoff_successor_unavailable' };
560
+ const goal = await carryGoal(successor, { executable: executable(), timeout_ms: 30_000 }, { sourceThreadId: threadId, onlyActive: true });
561
+ if (goal !== 'none') {
562
+ record({ at: new Date().toISOString(), source_thread_id: threadId, handoff_id: successor.handoff_id,
563
+ target_thread_id: successor.target_thread_id, goal });
564
+ process.stderr.write(`[auto-handoff] goal settled: ${goal}\n`);
565
+ }
566
+ return { status: 'settled', goal };
567
+ }
568
+
449
569
  // 受け取った記憶をそのまま運ぶ上限。超えた分は切り、切った事を記録する(後継の記憶に出す)。
450
570
  const INHERITED_MEMORY_MAX_CHARS = 120_000;
451
571
 
@@ -606,6 +726,7 @@ export function showAutoHandoffFailure(operation) {
606
726
  <p><a href="codex://threads/${escape(operation.source_thread_id)}">旧タスクを開く</a>${operation.target_thread_id ? ` / <a href="codex://threads/${escape(operation.target_thread_id)}">後継タスクを開く</a>` : ''}</p>
607
727
  <p>理由: <code>${escape(operation.error_code)}</code></p><p>引き継ぎID: <code>${escape(operation.handoff_id)}</code></p>
608
728
  ${operation.error_code === 'handoff_summarizer_backend_failed' ? '<p>記憶の要約に使うCodex CLIが失敗しました。通信の断、利用上限、認証切れなどで起きます。会話の記録と入力は失われていません。Codex CLIが使える様になった後、旧タスクへ入力すると、引き継ぎをもう一度始めます。</p>' : ''}
729
+ ${operation.runtime?.goalCarry?.paused ? '<p>旧タスクのゴール(/goal)は、同じターンが繰り返されない様に一時停止にしてあります。引き継ぎを再開して後継が動けば、ゴールは後継へ運ばれます。旧タスクのまま続ける時は、旧タスクでゴールを再開してください。</p>' : ''}
609
730
  <p>状態: ${escape(operation.state)} / 停止した工程: ${escape(operation.resume_state)}</p>
610
731
  <p>確認: <code>throughline auto-handoff status --operation ${escape(operation.handoff_id)}</code></p>
611
732
  <p>原因を解消した後の再開: <code>throughline auto-handoff resume --operation ${escape(operation.handoff_id)}</code></p>
@@ -618,7 +739,7 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
618
739
  const deps = { processes: readRuntimeProcesses, readSource: sourceState,
619
740
  nativeState: sourceNativeState, capture: captureCodexRolloutToDb, summaries: ensureAutoHandoffSummaries,
620
741
  createTarget, readTarget: targetState, openTarget, verify: verifyCodexParent, markSource: markAutoHandoffSource,
621
- carryQueue: carryAutoHandoffQueue,
742
+ carryQueue: carryAutoHandoffQueue, pauseGoal: pauseAutoHandoffSourceGoal, carryGoal: carryAutoHandoffGoal,
622
743
  submit: submitCodexParentAnswer, notify: showAutoHandoffFailure, wait: waitForHandoff,
623
744
  executable: () => platformDesktopFinder()(), liveSuccessor: findLiveAutoHandoffSuccessor, ...dependencies };
624
745
  let operation = getAutoHandoff(db, id);
@@ -644,6 +765,13 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
644
765
  throw error('handoff_mutation_outcome_unknown');
645
766
  }
646
767
  const runtime = { executable: deps.executable(), timeout_ms: 30_000 };
768
+ // 旧タスクのゴールを先に止める。動いたままだと、止めたターンの直後から Codex が次のターンを起こし続ける。
769
+ // 同じ旧タスクの前の引き継ぎ(途中で止まった物)が止めていたゴールは、動いていた物として後継へ運ぶ (ADR 0059)。
770
+ if (!operation.runtime?.goalCarry) {
771
+ const paused = await deps.pauseGoal(operation, runtime) === 'paused' ||
772
+ listUndeliveredAutoHandoffsForSource(db, operation.source_thread_id).some(other => other.runtime?.goalCarry?.paused);
773
+ update({ runtime_json: { ...(operation.runtime ?? {}), goalCarry: { paused } } });
774
+ }
647
775
  if (operation.state === 'requested') {
648
776
  const state = await deps.wait(() => {
649
777
  const state = deps.readSource(operation);
@@ -663,7 +791,7 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
663
791
  }
664
792
  const native = await deps.nativeState(operation, runtime);
665
793
  update({ state: 'source_stopped', settings_json: state.settings,
666
- runtime_json: { ...native, databasePath: DB_PATH, ...(inheritedMemory ? { inheritedMemory } : {}) } });
794
+ runtime_json: { ...(operation.runtime ?? {}), ...native, databasePath: DB_PATH, ...(inheritedMemory ? { inheritedMemory } : {}) } });
667
795
  }
668
796
  // 最中に来た入力のturnが止まり切るまでの間は、境界がまだ見えない。短く待ってから判定する。
669
797
  const confirmSourceBoundary = () => deps.wait(() => sourceBoundary(operation, deps.readSource(operation)) || null,
@@ -737,6 +865,10 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
737
865
  return target.correlatedTurnId && target.progress ? target : null;
738
866
  }, { timeoutMs: 180_000, timeoutCode: 'handoff_target_progress_unconfirmed' });
739
867
  update({ state: 'continued', started_turn_id: target.correlatedTurnId, resume_state: null, error_code: null });
868
+ // 旧タスクのゴールを後継へ運ぶ。後継の最初のターンが動いている間に付けると、そのターンの後から Codex が続ける。
869
+ const goal = await deps.carryGoal(operation, runtime, { wasActive: operation.runtime?.goalCarry?.paused === true });
870
+ update({ runtime_json: { ...(getAutoHandoff(db, id).runtime ?? {}), goalCarry: { ...(operation.runtime?.goalCarry ?? {}), carried: goal } } });
871
+ if (goal !== 'none') globalThis.process.stderr.write(`[auto-handoff] goal carried: ${goal}\n`);
740
872
  // 旧タスクの順番待ちに残った入力を後継へ運ぶ。後継の最初のターン(継続の指示)が始まった後なので、順番は変わらない。
741
873
  await deps.carryQueue(db, operation, runtime);
742
874
  // 後継が作業を続けたのを確かめてから付ける。途中で止まった引き継ぎの旧タスクには付けない。
@@ -7,6 +7,8 @@ import { spawnSync } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
+ import { EventEmitter } from 'node:events';
11
+ import { testTempDir } from './test-temp-dir.mjs';
10
12
  import { requestAutoHandoff, updateAutoHandoff, getAutoHandoff, freezeCodexMemory,
11
13
  claimAutoHandoff, continuationInput } from './codex-auto-handoff-store.mjs';
12
14
  import { collectAutoHandoffMemory, ensureAutoHandoffSummaries, renderAutoHandoffMemory,
@@ -15,7 +17,8 @@ import { readCodexHandoffState, settingsMatch, threadStartSettings, resolvePrepa
15
17
  import { runAutoHandoffWorker, requestCodexAutoHandoff, sourceBoundary,
16
18
  autoHandoffTargetName, nameAutoHandoffTarget, findLiveAutoHandoffSuccessor,
17
19
  markAutoHandoffSource, markAutoHandoffSources, autoHandoffSourceFallbackTitle, carryAutoHandoffQueue,
18
- untrackedInheritedMemory, resolveAutoHandoffSuccessor, hookIdentityShape } from './codex-auto-handoff.mjs';
20
+ untrackedInheritedMemory, resolveAutoHandoffSuccessor, hookIdentityShape,
21
+ pauseAutoHandoffSourceGoal, carryAutoHandoffGoal, settleAutoHandoffSourceGoal, launchAutoHandoffGoalSettle } from './codex-auto-handoff.mjs';
19
22
  import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
20
23
 
21
24
  async function withDb(fn) {
@@ -117,6 +120,10 @@ function workerDependencies(db, settings, submit, counters) {
117
120
  readTarget: operation => ({ settings, latestTurnId: ['submitted', 'unknown'].includes(operation.state) ? 'new-turn' : null,
118
121
  correlatedTurnId: ['submitted', 'unknown'].includes(operation.state) ? 'new-turn' : null, progress: true }),
119
122
  openTarget: () => {}, verify: async () => {}, submit,
123
+ pauseGoal: async operation => { (counters.goal ??= []).push(`pause:${operation.state}`); return counters.goalPause ?? 'none'; },
124
+ carryGoal: async (operation, runtime, options) => {
125
+ (counters.goal ??= []).push(`carry:${operation.state}:${options.wasActive}`); return counters.goalCarry ?? 'none';
126
+ },
120
127
  carryQueue: async (db, operation) => { (counters.carried ??= []).push(`${operation.source_thread_id}:${operation.state}`); return { moved: 0, unknown: 0, left: 0 }; },
121
128
  markSource: async (operation, runtime, options) => {
122
129
  (counters.marked ??= []).push(`${operation.source_thread_id}:${operation.state}`);
@@ -193,7 +200,7 @@ test('実行中の子がある時は後継を作らない', async () => withDb(a
193
200
  }));
194
201
 
195
202
  test('rollout の developer 記憶から、受け取った Throughline の記憶の本文を最初の1つだけ読む', () => {
196
- const dir = mkdtempSync(join(tmpdir(), 'tl-inherited-'));
203
+ const dir = testTempDir('tl-inherited-');
197
204
  const file = join(dir, 'rollout.jsonl');
198
205
  const developer = text => ({ type: 'response_item', payload: { type: 'message', role: 'developer', content: [{ type: 'input_text', text }] } });
199
206
  const rows = [{ type: 'session_meta', payload: { id: 'T', cwd: '/project', originator: 'Codex Desktop', source: 'vscode' } },
@@ -445,7 +452,7 @@ test('引き継ぎ済みの旧タスクへ新しい入力が来ても後継を
445
452
  const payload = desktopSource(home, thread, '33333333-3333-3333-3333-333333333333');
446
453
  const config = { enabled: true, projects: [home], openHost: 'desktop' };
447
454
  const seen = { launched: 0, opened: [], recorded: [], asked: [] };
448
- const args = { db, config, payload, launchWorker: async () => { seen.launched++; },
455
+ const args = { db, config, payload, launchWorker: async () => { seen.launched++; }, settleGoal: async () => false,
449
456
  findThread: query => { seen.asked.push(query); return { mtimeMs: 1 }; },
450
457
  openThread: operation => { seen.opened.push(operation.target_thread_id); return true; },
451
458
  recordRedirect: entry => { seen.recorded.push(entry); } };
@@ -1025,3 +1032,248 @@ test('要約のbackendが使えない時、workerは固定の理由で止まり
1025
1032
  assert.equal(counters.carried, undefined, '途中で止まった引き継ぎでは、順番待ちを動かさない');
1026
1033
  }
1027
1034
  }));
1035
+
1036
+ // Codex のゴールの口の代わり。goals は thread id ごとの今のゴール(無ければ null)。
1037
+ function goalReceiver(goals, sent, fail = null) {
1038
+ return async (profile, parent, action) => action(async (method, params) => {
1039
+ sent.push([method, params]);
1040
+ if (method === fail) throw Object.assign(new Error('rejected'), { delivery_code: 'CODEX_RECEIVER_REJECTED' });
1041
+ if (method === 'thread/goal/get') return { goal: goals[params.threadId] ?? null };
1042
+ if (method === 'thread/goal/set') {
1043
+ const { threadId, ...fields } = params;
1044
+ goals[threadId] = { tokenBudget: null, tokensUsed: 0, ...(goals[threadId] ?? {}), ...fields };
1045
+ return { goal: goals[threadId] };
1046
+ }
1047
+ throw new Error(`想定していない要求: ${method}`);
1048
+ });
1049
+ }
1050
+
1051
+ test('旧タスクの動いているゴールを一時停止にし、止まっているゴールと無いゴールには触らない。扱えなくても引き継ぎは止めない', async () => {
1052
+ const operation = { source_thread_id: 'S', codex_home: '/home' };
1053
+ let sent = []; let goals = { S: { status: 'active', objective: '目標' } };
1054
+ assert.equal(await pauseAutoHandoffSourceGoal(operation, {}, { connect: goalReceiver(goals, sent) }), 'paused');
1055
+ assert.deepEqual(sent, [['thread/goal/get', { threadId: 'S' }], ['thread/goal/set', { threadId: 'S', status: 'paused' }]]);
1056
+ assert.equal(goals.S.objective, '目標', '目標の文は消さない');
1057
+
1058
+ for (const [goal, expected] of [[null, 'none'], [{ status: 'paused', objective: '目標' }, 'idle'], [{ status: 'complete', objective: '目標' }, 'idle']]) {
1059
+ sent = [];
1060
+ assert.equal(await pauseAutoHandoffSourceGoal(operation, {}, { connect: goalReceiver({ S: goal }, sent) }), expected);
1061
+ assert.equal(sent.length, 1, '読むだけ');
1062
+ }
1063
+ // ゴールの口が無い版の Codex、口が断った時。
1064
+ assert.equal(await pauseAutoHandoffSourceGoal(operation, {}, { connect: goalReceiver({}, [], 'thread/goal/get') }), 'failed');
1065
+ assert.equal(await pauseAutoHandoffSourceGoal(operation, {}, { connect: goalReceiver({ S: { status: 'active', objective: 'x' } }, [], 'thread/goal/set') }), 'failed');
1066
+ });
1067
+
1068
+ test('旧タスクのゴールを後継へ運ぶ。動いていた物は動かし、利用者が止めていた物は止めたまま運び、予算は残りを渡す', async () => {
1069
+ const operation = { source_thread_id: 'S', target_thread_id: 'T', codex_home: '/home' };
1070
+ // この製品が引き継ぎの初めに止めたゴール。後継では動かす。旧タスクのゴールは止めたまま残す。
1071
+ let goals = { S: { status: 'paused', objective: '目標', tokenBudget: 1000, tokensUsed: 300 } }; let sent = [];
1072
+ assert.equal(await carryAutoHandoffGoal(operation, {}, { connect: goalReceiver(goals, sent), wasActive: true }), 'active');
1073
+ assert.deepEqual(sent.at(-1), ['thread/goal/set', { threadId: 'T', objective: '目標', status: 'active', tokenBudget: 700 }]);
1074
+ assert.equal(goals.S.status, 'paused'); assert.equal(goals.S.objective, '目標');
1075
+
1076
+ // 利用者が止めていたゴール。止めたまま運ぶ。予算の無いゴールは、予算を付けない。
1077
+ goals = { S: { status: 'paused', objective: '目標', tokenBudget: null, tokensUsed: 300 } }; sent = [];
1078
+ assert.equal(await carryAutoHandoffGoal(operation, {}, { connect: goalReceiver(goals, sent) }), 'paused');
1079
+ assert.deepEqual(sent.at(-1), ['thread/goal/set', { threadId: 'T', objective: '目標', status: 'paused' }]);
1080
+
1081
+ // 初めの一時停止が効いていなかった時は、ここで旧タスクを先に止めてから、後継で動かす。
1082
+ goals = { S: { status: 'active', objective: '目標' } }; sent = [];
1083
+ assert.equal(await carryAutoHandoffGoal(operation, {}, { connect: goalReceiver(goals, sent) }), 'active');
1084
+ assert.deepEqual(sent.map(([method, params]) => `${method}:${params.threadId}:${params.status ?? ''}`),
1085
+ ['thread/goal/get:S:', 'thread/goal/set:S:paused', 'thread/goal/get:T:', 'thread/goal/set:T:active']);
1086
+
1087
+ // 後継にもうゴールがある時は書き換えない。
1088
+ goals = { S: { status: 'paused', objective: '目標' }, T: { status: 'active', objective: '別の目標' } }; sent = [];
1089
+ assert.equal(await carryAutoHandoffGoal(operation, {}, { connect: goalReceiver(goals, sent), wasActive: true }), 'kept');
1090
+ assert.equal(goals.T.objective, '別の目標'); assert.ok(!sent.some(([method]) => method === 'thread/goal/set'));
1091
+
1092
+ // ゴールが無い、完了した、Codex が上限や行き詰まりで止めた。運ばない。
1093
+ for (const goal of [null, { status: 'complete', objective: '目標' }, { status: 'budgetLimited', objective: '目標' },
1094
+ { status: 'usageLimited', objective: '目標' }, { status: 'blocked', objective: '目標' }]) {
1095
+ sent = [];
1096
+ assert.equal(await carryAutoHandoffGoal(operation, {}, { connect: goalReceiver({ S: goal }, sent), wasActive: true }), 'none');
1097
+ assert.equal(sent.length, 1, '読むだけ');
1098
+ }
1099
+ // 今も動いているゴールだけを運ぶ指定では、止まっているゴールに触らない。
1100
+ goals = { S: { status: 'paused', objective: '目標' } }; sent = [];
1101
+ assert.equal(await carryAutoHandoffGoal(operation, {}, { connect: goalReceiver(goals, sent), onlyActive: true }), 'none');
1102
+ assert.equal(sent.length, 1); assert.equal(goals.T, undefined);
1103
+ // 別の旧タスク(後継のさらに前のタスク)を指定できる。
1104
+ goals = { OLD: { status: 'active', objective: '目標' } }; sent = [];
1105
+ assert.equal(await carryAutoHandoffGoal(operation, {}, { connect: goalReceiver(goals, sent), onlyActive: true, sourceThreadId: 'OLD' }), 'active');
1106
+ assert.equal(goals.OLD.status, 'paused'); assert.equal(goals.T.status, 'active');
1107
+ // 運べなくても例外にしない。
1108
+ assert.equal(await carryAutoHandoffGoal(operation, {}, { connect: goalReceiver({ S: { status: 'paused', objective: 'x' } }, [], 'thread/goal/set') }), 'failed');
1109
+ });
1110
+
1111
+ // 1つのDBで何度も引き継ぐ試験では、後継のIDを旧タスクごとに変える(後継のIDは一意)。
1112
+ function uniqueTarget(deps, source) {
1113
+ const createTarget = deps.createTarget;
1114
+ deps.createTarget = async (db, operation) => {
1115
+ await createTarget(db, operation);
1116
+ updateAutoHandoff(db, operation.handoff_id, { target_thread_id: `target-${source}` });
1117
+ };
1118
+ }
1119
+
1120
+ test('workerは、最初に旧タスクのゴールを止め、後継が作業を続けた後でゴールを運ぶ。順番待ちを運ぶより先', async () => withDb(async db => {
1121
+ const run = async (source, counters, before = () => {}) => {
1122
+ const operation = request(db, source, { now: 1 }).operation;
1123
+ before(operation);
1124
+ const deps = workerDependencies(db, {}, async () => ({ queued_submission_id: 'receipt' }), counters);
1125
+ uniqueTarget(deps, source);
1126
+ const order = []; const carryQueue = deps.carryQueue, carryGoal = deps.carryGoal;
1127
+ deps.carryQueue = async (...args) => { order.push('queue'); return carryQueue(...args); };
1128
+ deps.carryGoal = async (...args) => { order.push('goal'); return carryGoal(...args); };
1129
+ return { result: await runAutoHandoffWorker(operation.handoff_id, { db, dependencies: deps }), order };
1130
+ };
1131
+ // 動いているゴールがあった。
1132
+ let counters = { created: 0, notified: 0, goalPause: 'paused', goalCarry: 'active' };
1133
+ let { result, order } = await run('goal-active', counters);
1134
+ assert.equal(result.state, 'continued');
1135
+ assert.deepEqual(counters.goal, ['pause:requested', 'carry:continued:true']);
1136
+ assert.deepEqual(order, ['goal', 'queue']);
1137
+ assert.deepEqual(getAutoHandoff(db, result.handoff_id).runtime.goalCarry, { paused: true, carried: 'active' });
1138
+ assert.equal(getAutoHandoff(db, result.handoff_id).runtime.targetPrepared, true, 'ゴールの記録で、ほかの記録を消さない');
1139
+
1140
+ // ゴールが無い・扱えない時も引き継ぎは進む。
1141
+ for (const [index, goalPause] of ['none', 'idle', 'failed'].entries()) {
1142
+ counters = { created: 0, notified: 0, goalPause, goalCarry: goalPause === 'idle' ? 'paused' : 'none' };
1143
+ ({ result } = await run(`goal-${index}`, counters));
1144
+ assert.equal(result.state, 'continued'); assert.equal(counters.notified, 0);
1145
+ assert.deepEqual(counters.goal, ['pause:requested', 'carry:continued:false']);
1146
+ }
1147
+
1148
+ // 途中で止まった引き継ぎをやり直す時は、ゴールをもう一度は読まず、最初に止めた事を覚えている。
1149
+ counters = { created: 0, notified: 0, goalPause: 'paused', goalCarry: 'active' };
1150
+ const operation = request(db, 'goal-resume', { now: 1 }).operation;
1151
+ const deps = workerDependencies(db, {}, async () => ({ queued_submission_id: 'receipt' }), counters);
1152
+ uniqueTarget(deps, 'goal-resume');
1153
+ deps.summaries = () => { throw new Error('別の失敗'); };
1154
+ const failed = await runAutoHandoffWorker(operation.handoff_id, { db, dependencies: deps });
1155
+ assert.equal(failed.state, 'failed'); assert.deepEqual(failed.runtime.goalCarry, { paused: true });
1156
+ assert.deepEqual(counters.goal, ['pause:requested'], '途中で止まった引き継ぎでは、ゴールを後継へ運ばない');
1157
+ deps.summaries = () => {}; counters.goalPause = 'idle';
1158
+ const resumed = await runAutoHandoffWorker(operation.handoff_id, { db, resume: true, dependencies: deps });
1159
+ assert.equal(resumed.state, 'continued');
1160
+ assert.deepEqual(counters.goal, ['pause:requested', 'carry:continued:true']);
1161
+
1162
+ // 同じ旧タスクの前の引き継ぎ(途中で止まった物)が止めていたゴールは、新しい引き継ぎでも動いていた物として運ぶ。
1163
+ counters = { created: 0, notified: 0, goalPause: 'idle', goalCarry: 'active' };
1164
+ ({ result } = await run('goal-earlier', counters, current => {
1165
+ const earlier = requestAutoHandoff(db, { threadId: 'goal-earlier', turnId: 'earlier-turn', projectPath: '/project',
1166
+ rolloutPath: '/codex/rollout.jsonl', codexHome: '/codex', openHost: 'desktop', now: 0 }).operation;
1167
+ updateAutoHandoff(db, earlier.handoff_id, { state: 'failed', runtime_json: { goalCarry: { paused: true } } });
1168
+ assert.notEqual(earlier.handoff_id, current.handoff_id);
1169
+ }));
1170
+ assert.deepEqual(counters.goal, ['pause:requested', 'carry:continued:true']);
1171
+ }));
1172
+
1173
+ // Codex がゴールの続行で起こして、hook が止めたターンの実物の並び(Windows の Codex Desktop 0.162.0-alpha.17.2、2026-10-13)。
1174
+ function goalContinuation(turn, at) {
1175
+ return [
1176
+ { timestamp: `${at}.460Z`, type: 'event_msg', payload: { type: 'task_started', turn_id: turn } },
1177
+ { timestamp: `${at}.782Z`, type: 'response_item', payload: { type: 'message', role: 'user',
1178
+ content: [{ type: 'input_text', text: '<codex_internal_context source="goal">\nContinue working toward the active thread goal.\n</codex_internal_context>' }] } },
1179
+ { timestamp: `${at}.802Z`, type: 'event_msg', payload: { type: 'turn_aborted', turn_id: turn, reason: 'interrupted' } },
1180
+ ];
1181
+ }
1182
+
1183
+ test('rollout から、ターンの入力が Codex 自身の文脈だけだったかを読む', async () => withDb((db, home) => {
1184
+ const thread = '11111111-1111-1111-1111-111111111111';
1185
+ const file = sourceRollout(home, thread, 'turn-a', [...goalContinuation('turn-goal', '2026-10-07T00:50:00'),
1186
+ ...stoppedInput('turn-person', '2026-10-07T00:51:00')]);
1187
+ const read = turnId => readCodexHandoffState(file, { threadId: thread, turnId }).internalInputOnly;
1188
+ assert.equal(read('turn-goal'), true); assert.equal(read('turn-person'), false); assert.equal(read('turn-a'), false);
1189
+ assert.equal(readCodexHandoffState(file, { threadId: thread }).internalInputOnly, false, 'turn を指定しない時は最後の turn');
1190
+ // 入力が turn の開始より前に記録される並びでも同じ。
1191
+ const [started, input, aborted] = goalContinuation('turn-goal-2', '2026-10-07T00:52:00');
1192
+ const early = sourceRollout(home, '22222222-2222-2222-2222-222222222222', 'turn-a', [input, started, aborted]);
1193
+ assert.equal(readCodexHandoffState(early, { threadId: '22222222-2222-2222-2222-222222222222', turnId: 'turn-goal-2' }).internalInputOnly, true);
1194
+ // ターンの途中で Codex が足す環境の知らせ(日付が変わった時など)は、入力の出どころに数えない(macOS の Codex Desktop 0.162.0-alpha.17.2 の実物)。
1195
+ const [started3, input3, aborted3] = goalContinuation('turn-goal-3', '2026-10-07T00:53:00');
1196
+ const environment = { timestamp: '2026-10-07T00:53:00.790Z', type: 'response_item', payload: { type: 'message', role: 'user',
1197
+ content: [{ type: 'input_text', text: '<environment_context>\n <current_date>2026-10-08</current_date>\n</environment_context>' }] } };
1198
+ const dated = sourceRollout(home, '33333333-3333-3333-3333-333333333333', 'turn-a', [started3, input3, environment, aborted3]);
1199
+ assert.equal(readCodexHandoffState(dated, { threadId: '33333333-3333-3333-3333-333333333333', turnId: 'turn-goal-3' }).internalInputOnly, true);
1200
+ }));
1201
+
1202
+ test('引き継ぎ済みの旧タスクへゴールの続行が来た時は、止めるが後継を開かず、ゴールの片付けを起こす', async () => withDb(async (db, home) => {
1203
+ const thread = '11111111-1111-1111-1111-111111111111', turnA = '22222222-2222-2222-2222-222222222222';
1204
+ const goalTurn = '33333333-3333-3333-3333-333333333333', personTurn = '44444444-4444-4444-4444-444444444444';
1205
+ const first = delivered(db, home, thread, turnA, 'successor-1', 1);
1206
+ const config = { enabled: true, projects: [home], openHost: 'desktop' };
1207
+ const seen = { opened: [], recorded: [], settled: [] };
1208
+ const args = { db, config, launchWorker: async () => { throw new Error('後継を増やしてはいけない'); }, findThread: () => ({ mtimeMs: 1 }),
1209
+ openThread: operation => { seen.opened.push(operation.target_thread_id); return true; },
1210
+ recordRedirect: entry => { seen.recorded.push(entry); },
1211
+ settleGoal: async (threadId, operation) => { seen.settled.push([threadId, operation.handoff_id]); return seen.settled.length === 1; } };
1212
+ const payload = (turn, later) => ({ trigger: 'auto', cwd: home, session_id: thread, turn_id: turn,
1213
+ transcript_path: sourceRollout(home, thread, turnA, later) });
1214
+
1215
+ const goal = await requestCodexAutoHandoff({ ...args, payload: payload(goalTurn, goalContinuation(goalTurn, '2026-10-07T00:50:00')) });
1216
+ assert.equal(goal.continue, false); assert.equal(goal.redirected, true); assert.equal(goal.opened, false);
1217
+ assert.deepEqual(seen.opened, [], '人が入力していないターンでは、画面を後継へ切り替えない');
1218
+ assert.deepEqual(seen.settled, [[thread, first.handoff_id]]);
1219
+ assert.deepEqual({ ...seen.recorded[0], at: null }, { at: null, source_thread_id: thread, source_turn_id: goalTurn,
1220
+ handoff_id: first.handoff_id, target_thread_id: 'successor-1', opened: false, internal: true, goal_settle: true });
1221
+
1222
+ // 人の入力では、今までどおり後継を開く。ゴールの片付けは、起こす側が間隔を空ける(ここでは2回めを起こさない)。
1223
+ const person = await requestCodexAutoHandoff({ ...args, payload: payload(personTurn, stoppedInput(personTurn, '2026-10-07T00:51:00')) });
1224
+ assert.equal(person.opened, true); assert.deepEqual(seen.opened, ['successor-1']);
1225
+ assert.equal(seen.recorded[1].internal, undefined); assert.equal(seen.recorded[1].goal_settle, undefined);
1226
+ // 片付けを起こせなくても、ターンは止める。
1227
+ const broken = await requestCodexAutoHandoff({ ...args, settleGoal: async () => { throw new Error('起こせません'); },
1228
+ payload: payload(goalTurn, goalContinuation(goalTurn, '2026-10-07T00:50:00')) });
1229
+ assert.equal(broken.continue, false); assert.equal(broken.redirected, true);
1230
+ }));
1231
+
1232
+ test('ゴールの片付けは、同じ旧タスクへは間隔を空けて1つだけ起こす', async () => withDb(async () => {
1233
+ const spawned = [];
1234
+ const spawn = (command, args, options) => {
1235
+ spawned.push({ args, detached: options.detached });
1236
+ const child = new EventEmitter(); child.unref = () => {}; child.pid = 1;
1237
+ queueMicrotask(() => child.emit('spawn'));
1238
+ return child;
1239
+ };
1240
+ const operation = { handoff_id: 'handoff-1' };
1241
+ assert.equal(await launchAutoHandoffGoalSettle('thread-a', operation, { spawn }), true);
1242
+ assert.deepEqual(spawned[0].args.slice(1), ['auto-handoff', 'settle-goal', '--operation', 'handoff-1', '--thread', 'thread-a', '--json']);
1243
+ assert.equal(spawned[0].detached, true);
1244
+ assert.equal(await launchAutoHandoffGoalSettle('thread-a', operation, { spawn }), false, '続けて来ても起こさない');
1245
+ assert.equal(await launchAutoHandoffGoalSettle('thread-b', operation, { spawn }), true, '別の旧タスクは別に数える');
1246
+ assert.equal(await launchAutoHandoffGoalSettle('thread-a', operation, { spawn, now: Date.now() + 31_000 }), true, '間隔が空けば、もう一度起こす');
1247
+ assert.equal(spawned.length, 3);
1248
+ }));
1249
+
1250
+ test('片付けは、旧タスクの動いているゴールを止めて、今の一番先の後継へ運び、記録を1行残す', async () => withDb(async (db, home) => {
1251
+ const thread = '11111111-1111-1111-1111-111111111111';
1252
+ const first = delivered(db, home, thread, 'turn-a', 'successor-1', 1);
1253
+ const second = delivered(db, home, 'successor-1', 'turn-b', 'successor-2', 2);
1254
+ const seen = { carried: [], recorded: [] };
1255
+ const options = goal => ({ executable: () => '公式Desktop試験fixture', findThread: () => ({ mtimeMs: 1 }),
1256
+ carryGoal: async (operation, runtime, carry) => { seen.carried.push([operation.handoff_id, carry, runtime.executable]); return goal; },
1257
+ record: entry => { seen.recorded.push(entry); } });
1258
+ const settled = await settleAutoHandoffSourceGoal(db, { operationId: first.handoff_id, threadId: thread }, options('active'));
1259
+ assert.deepEqual(settled, { status: 'settled', goal: 'active' });
1260
+ assert.deepEqual(seen.carried, [[second.handoff_id, { sourceThreadId: thread, onlyActive: true }, '公式Desktop試験fixture']]);
1261
+ assert.deepEqual({ ...seen.recorded[0], at: null }, { at: null, source_thread_id: thread, handoff_id: second.handoff_id,
1262
+ target_thread_id: 'successor-2', goal: 'active' });
1263
+ // 動いているゴールが無ければ、記録を増やさない。
1264
+ assert.deepEqual(await settleAutoHandoffSourceGoal(db, { operationId: first.handoff_id, threadId: thread }, options('none')), { status: 'settled', goal: 'none' });
1265
+ assert.equal(seen.recorded.length, 1);
1266
+ // 後継が残っていない旧タスクは、今の続きなので触らない。
1267
+ const gone = await settleAutoHandoffSourceGoal(db, { operationId: first.handoff_id, threadId: thread }, { ...options('active'), findThread: () => null });
1268
+ assert.equal(gone.status, 'skipped'); assert.equal(seen.carried.length, 2);
1269
+ await assert.rejects(settleAutoHandoffSourceGoal(db, { operationId: first.handoff_id, threadId: 'x y' }, options('active')), /handoff_thread_id_invalid/);
1270
+ }));
1271
+
1272
+ test('公開CLIのsettle-goalは、引き継ぎIDと旧タスクのIDの両方を要る', () => {
1273
+ const parsed = parseAutoHandoffArgs(['settle-goal', '--operation', 'id', '--thread', 'thread', '--json']);
1274
+ assert.equal(parsed.action, 'settle-goal'); assert.equal(parsed.operation, 'id'); assert.equal(parsed.thread, 'thread');
1275
+ for (const args of [['settle-goal', '--operation', 'id'], ['settle-goal', '--thread', 'thread'],
1276
+ ['settle-goal', '--operation', 'id', '--thread', 'thread', '--host', 'claude']]) {
1277
+ assert.throws(() => parseAutoHandoffArgs(args));
1278
+ }
1279
+ });