throughline 0.16.27 → 0.16.29
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 +57 -1
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/docs/adr/0055-stop-flush-barrier-skips-queued-inputs.md +6 -0
- package/docs/adr/0059-codex-handoff-carries-thread-goal.md +96 -0
- package/docs/adr/0060-claude-mid-turn-input-and-host-meta-rows.md +123 -0
- package/package.json +1 -1
- package/src/cli/auto-handoff.mjs +9 -5
- package/src/cli/grok-continue.test.mjs +9 -4
- package/src/cli/runtime-errors.test.mjs +2 -3
- package/src/codex-auto-handoff.mjs +137 -5
- package/src/codex-auto-handoff.test.mjs +255 -3
- package/src/haiku-summarizer.test.mjs +8 -1
- package/src/hosts/codex-handoff-state.mjs +20 -0
- package/src/room-context.test.mjs +2 -1
- package/src/runtime-error-hook.test.mjs +3 -3
- package/src/runtime-error-report.test.mjs +3 -3
- package/src/runtime-error-store.test.mjs +3 -3
- package/src/spike-transcript-writer.test.mjs +3 -3
- package/src/test-temp-dir.mjs +24 -0
- package/src/transcript-reader.mjs +94 -8
- package/src/transcript-reader.test.mjs +162 -3
- package/src/turn-backfill.test.mjs +60 -0
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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
|
+
});
|
|
@@ -3,7 +3,14 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { delimiter, join } from 'node:path';
|
|
6
|
-
import {
|
|
6
|
+
import { testTempDir } from './test-temp-dir.mjs';
|
|
7
|
+
|
|
8
|
+
// haiku-summarizer は import の時に homedir() から隔離 cwd の道を決める。
|
|
9
|
+
// 試験が動かす利用者の本物の home へ ~/.throughline を作らないよう、先に home を差し替える。
|
|
10
|
+
const TEST_HOME = testTempDir('tl-l1-home-');
|
|
11
|
+
process.env.HOME = TEST_HOME;
|
|
12
|
+
process.env.USERPROFILE = TEST_HOME;
|
|
13
|
+
const { summarizeToL1, resolveL1Ratio } = await import('./haiku-summarizer.mjs');
|
|
7
14
|
|
|
8
15
|
function makeBin(dir, name, body) {
|
|
9
16
|
const script = join(dir, `${name}.mjs`);
|
|
@@ -95,6 +95,11 @@ export function threadStartSettings(settings) {
|
|
|
95
95
|
...permissions, config: { model_reasoning_effort: settings.effort } };
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
// Codex が自分でターンへ入れる文脈の頭(`codex-rs/core/src/context/internal_model_context.rs` の `CONTEXT_START_MARKER`)。
|
|
99
|
+
const CODEX_INTERNAL_CONTEXT_PATTERN = /^\s*<codex_internal_context[\s>]/;
|
|
100
|
+
// Codex がターンの途中で足す環境の知らせ(日付や作業フォルダが変わった時)。入力の出どころには数えない。
|
|
101
|
+
const CODEX_ENVIRONMENT_CONTEXT_PATTERN = /^\s*<environment_context>/;
|
|
102
|
+
|
|
98
103
|
export function readCodexHandoffState(path, { threadId = null, turnId = null, deliveryText = null } = {}) {
|
|
99
104
|
const rows = decodeRows(path);
|
|
100
105
|
let meta = null, rawSettings = null, preparedSettings = null, context = null, latestTurnId = null;
|
|
@@ -110,6 +115,15 @@ export function readCodexHandoffState(path, { threadId = null, turnId = null, de
|
|
|
110
115
|
// activity はモデルの発言・道具の呼び出し・圧縮・完了のどれかがあったか。入力を受けただけで止まった turn は activity が false。
|
|
111
116
|
const laterTurns = [];
|
|
112
117
|
let sourceSeen = false, later = null;
|
|
118
|
+
// 見ている turn(turnId を指定した時はその turn、しない時は最後の turn)の入力の出どころ。Codex が自分で入れる文脈
|
|
119
|
+
// (ゴールの続行の指示など。`<codex_internal_context …>` で始まる)と、それ以外の入力(人やほかの製品が送った物)を分ける。
|
|
120
|
+
const inputKinds = { turn: { internal: false, other: false }, pending: { internal: false, other: false } };
|
|
121
|
+
const noteInput = text => {
|
|
122
|
+
if (CODEX_ENVIRONMENT_CONTEXT_PATTERN.test(text)) return;
|
|
123
|
+
const watched = !turnId || latestTurnId === turnId;
|
|
124
|
+
const kinds = turnOpen ? (watched ? inputKinds.turn : null) : inputKinds.pending;
|
|
125
|
+
if (kinds) kinds[CODEX_INTERNAL_CONTEXT_PATTERN.test(text) ? 'internal' : 'other'] = true;
|
|
126
|
+
};
|
|
113
127
|
for (const row of rows) {
|
|
114
128
|
const payload = row.payload;
|
|
115
129
|
if (later && (row.type === 'compacted' || row.type === 'token_usage_record')) later.activity = true;
|
|
@@ -126,6 +140,8 @@ export function readCodexHandoffState(path, { threadId = null, turnId = null, de
|
|
|
126
140
|
if (turnId && latestTurnId === turnId) sourceSeen = true;
|
|
127
141
|
else if (turnId && sourceSeen) { later = { turnId: latestTurnId, closed: false, activity: false }; laterTurns.push(later); }
|
|
128
142
|
hasTurnInput = pendingUser != null;
|
|
143
|
+
if (!turnId || latestTurnId === turnId) inputKinds.turn = inputKinds.pending;
|
|
144
|
+
inputKinds.pending = { internal: false, other: false };
|
|
129
145
|
if (!turnId || latestTurnId === turnId) {
|
|
130
146
|
turnStartAt = Date.parse(row.timestamp); pendingCalls.clear(); completedTools = 0;
|
|
131
147
|
stoppedAt = null; stoppedReason = null; completedAt = null;
|
|
@@ -158,6 +174,7 @@ export function readCodexHandoffState(path, { threadId = null, turnId = null, de
|
|
|
158
174
|
const text = (payload.content ?? []).filter(c => typeof c.text === 'string').map(c => c.text).join('\n');
|
|
159
175
|
const input = text.trim() && !text.startsWith('# AGENTS.md instructions') && !text.startsWith('<hook_prompt');
|
|
160
176
|
if (input) {
|
|
177
|
+
noteInput(text);
|
|
161
178
|
if (turnOpen) hasTurnInput = true;
|
|
162
179
|
else pendingUser = text;
|
|
163
180
|
}
|
|
@@ -193,6 +210,7 @@ export function readCodexHandoffState(path, { threadId = null, turnId = null, de
|
|
|
193
210
|
else pendingUser = payload.message;
|
|
194
211
|
}
|
|
195
212
|
if (row.type === 'event_msg' && payload?.type === 'user_message') {
|
|
213
|
+
noteInput(typeof payload.message === 'string' ? payload.message : '');
|
|
196
214
|
if (turnOpen) hasTurnInput = true;
|
|
197
215
|
else pendingUser = payload.message;
|
|
198
216
|
}
|
|
@@ -218,5 +236,7 @@ export function readCodexHandoffState(path, { threadId = null, turnId = null, de
|
|
|
218
236
|
return { meta, settings, rawSettings, preparedSettings, context, latestTurnId, turnStartAt, stoppedAt, stoppedReason,
|
|
219
237
|
completedAt, correlatedTurnId, hasTurnInput, progress, completedTools, pendingCallCount: pendingCalls.size,
|
|
220
238
|
pendingNativeSessionCount: nativeSessions.size, inheritedThroughlineMemory, inheritedThroughlineMemoryText, laterTurns,
|
|
239
|
+
// 見ている turn の入力が、Codex が自分で入れた文脈だけだった(人の入力ではない)。
|
|
240
|
+
internalInputOnly: inputKinds.turn.internal && !inputKinds.turn.other,
|
|
221
241
|
compactedRows: rows.filter(r => r.type === 'compacted').length };
|
|
222
242
|
}
|
|
@@ -11,7 +11,8 @@ const bin = fileURLToPath(new URL('../bin/throughline.mjs', import.meta.url));
|
|
|
11
11
|
test('公開CLIは部屋ごとの直近3発言を現在の発言まで返す', () => {
|
|
12
12
|
const home = mkdtempSync(join(tmpdir(), 'tl-room-context-'));
|
|
13
13
|
const run = (input) => spawnSync(process.execPath, [bin, 'room-context', '--json'], {
|
|
14
|
-
|
|
14
|
+
// Windows は USERPROFILE で home を決める。HOME だけだと、動かす利用者の本物の DB を開く。
|
|
15
|
+
env: { ...process.env, HOME: home, USERPROFILE: home }, input: JSON.stringify(input), encoding: 'utf8',
|
|
15
16
|
});
|
|
16
17
|
const base = { projectPath: '/srv/bellteam', roomId: 'room-a', speaker: 'クオ', text: '発言' };
|
|
17
18
|
try {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
4
|
-
import { chmodSync, existsSync, mkdirSync,
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { DatabaseSync } from 'node:sqlite';
|
|
6
|
-
import { tmpdir } from 'node:os';
|
|
7
6
|
import { dirname, join } from 'node:path';
|
|
7
|
+
import { testTempDir } from './test-temp-dir.mjs';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { defaultRuntimeErrorConfigPath, defaultRuntimeErrorStorePath } from './runtime-error-store.mjs';
|
|
10
10
|
import { applyWindowsPrivateAcl } from './os/windows-acl-test-helper.mjs';
|
|
@@ -12,7 +12,7 @@ import { applyWindowsPrivateAcl } from './os/windows-acl-test-helper.mjs';
|
|
|
12
12
|
const BIN = fileURLToPath(new URL('../bin/throughline.mjs', import.meta.url));
|
|
13
13
|
|
|
14
14
|
function createEnabledEnvironment(prefix) {
|
|
15
|
-
const root =
|
|
15
|
+
const root = testTempDir(prefix);
|
|
16
16
|
const env = {
|
|
17
17
|
...process.env,
|
|
18
18
|
HOME: root,
|