throughline 0.16.17 → 0.16.18
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 +21 -1
- package/docs/adr/0047-codex-internal-compaction-is-stopped-without-error.md +15 -0
- package/package.json +1 -1
- package/src/codex-auto-handoff.mjs +23 -1
- package/src/codex-auto-handoff.test.mjs +11 -1
- package/src/hook-failure-log.mjs +7 -0
- package/src/hook-failure-log.test.mjs +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,25 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.16.18] — 2026-10-11
|
|
14
|
+
|
|
15
|
+
### 変更
|
|
16
|
+
|
|
17
|
+
- Codexの`PreCompact(auto)`のhookが`handoff_hook_identity_invalid`で失敗した時、失敗ログ(`~/.throughline/logs/hook-failures.log`)の行へ、渡された合図の形(`identity`)を残す。0.16.14で内部のID(`auto-compact-N`)を失敗から外したが、0.16.16のmacOSの実機で同じ失敗が2回出た(2026-10-10 16:55:08と16:55:15、引き継ぎ先のタスクを作った直後)。内部の圧縮の記録は0行で、0.16.14が想定した形ではなかった。失敗ログには文面と位置しか無く、どの値が不正だったかを決められなかった。
|
|
18
|
+
- 残すのは、会話のID・ターンのID・agentのID・agentの種類・モデル名・`trigger`と、`transcript_path`・`cwd`が絶対pathかどうか。pathの中身と、記号の混じる値は残さない(長さだけ)。会話の本文は入らない。
|
|
19
|
+
- 失敗の扱い(圧縮を止める・失敗として数える)は変えていない。原因が決まるまで、この失敗は今までどおり記録される。
|
|
20
|
+
|
|
21
|
+
### 対応の範囲
|
|
22
|
+
|
|
23
|
+
| | macOS | Linux | Windows |
|
|
24
|
+
|---|---|---|---|
|
|
25
|
+
| Codex(Desktop) | 足した。単体試験と、切り離した置き場での本物のhookの命令(不正な合図を渡して、失敗ログの行に`identity`が出る事)。**本物のCodex Desktopがこの失敗を起こす場面は、狙って起こせないので未確認。次に起きた行で原因を決める** | 足した。単体試験と、切り離した置き場での本物のhookの命令。実機(同梱0.162.0-alpha.2)では、この失敗が起きていない | 足した。単体試験と、切り離した置き場での本物のhookの命令。実機では、この失敗の記録が無い |
|
|
26
|
+
| Codex(CLI・席) | 自動継続の対象外(Desktop以外は、この検査の前後で何もしない)。対象外の呼び出しがこの検査で失敗した時は、同じ形が残る | 同じ。BellTeamのコンテナとmain-serverは自動継続が無効で、この処理へ入らない | 同じ |
|
|
27
|
+
| Claude Code | 変更なし。この失敗はCodexの`PreCompact`だけが出す。失敗ログの部品は共通だが、`identity`を付けるのはCodexのこの失敗だけ | 同じ | 同じ |
|
|
28
|
+
| Grok | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
29
|
+
| Cursor | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
30
|
+
|
|
31
|
+
|
|
13
32
|
## [0.16.17] — 2026-10-10
|
|
14
33
|
|
|
15
34
|
### 修正
|
|
@@ -2270,7 +2289,8 @@ two attempts, instrument first instead of patching again.
|
|
|
2270
2289
|
|
|
2271
2290
|
---
|
|
2272
2291
|
|
|
2273
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.
|
|
2292
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.18...HEAD
|
|
2293
|
+
[0.16.18]: https://github.com/kitepon/Throughline/compare/v0.16.17...v0.16.18
|
|
2274
2294
|
[0.16.17]: https://github.com/kitepon/Throughline/compare/v0.16.16...v0.16.17
|
|
2275
2295
|
[0.16.16]: https://github.com/kitepon/Throughline/compare/v0.16.15...v0.16.16
|
|
2276
2296
|
[0.16.15]: https://github.com/kitepon/Throughline/compare/v0.16.14...v0.16.15
|
|
@@ -38,3 +38,18 @@ Codex は、利用者のターンとは別に、自分で作る内部の文脈
|
|
|
38
38
|
- 内部の文脈からの圧縮で、失敗の記録が増えなくなる。圧縮を止める動きは変わらない。
|
|
39
39
|
- 内部の文脈からの圧縮が何度も起きるタスクでも、圧縮は通らない。利用者のターンの `PreCompact` が来るまで、文脈はそのまま残る。
|
|
40
40
|
- Codex が内部の ID の形を変えると、また失敗として記録される。その時は記録の message で気づける。
|
|
41
|
+
|
|
42
|
+
## 追記(2026-10-11)
|
|
43
|
+
|
|
44
|
+
0.16.16 の macOS の実機で、同じ `handoff_hook_identity_invalid` が2回出た(2026-10-10 16:55:08 と 16:55:15)。どちらも、
|
|
45
|
+
引き継ぎ先のタスクを作った直後(作成の 1.4 秒後と、最初のターンの開始の 1.5 秒後)。`internal-compactions.jsonl` は
|
|
46
|
+
0行だった。この ADR が想定した形(ターンの ID が `auto-compact-N`)の呼び出しではなかった事になる。
|
|
47
|
+
|
|
48
|
+
- Context に書いた「内部の文脈から `PreCompact(auto)` を呼ぶ」は、Codex の source を読んだ推測で、実機では確かめていない。
|
|
49
|
+
`next_internal_sub_id` の ID は、`thread/inject_items` の文脈(`new_inject_items_context`)にも使われる。
|
|
50
|
+
- hook へ渡る `transcript_path` は null になり得る(`codex-rs/hooks/src/schema.rs` の `NullableString`)。ターンの中の
|
|
51
|
+
圧縮は、ターンの ID(UUID)をそのまま渡す(`with_model` は `sub_id` を引き継ぐ)。どの値が不正だったかは、失敗ログに
|
|
52
|
+
文面と位置しか無く、決められなかった。
|
|
53
|
+
- 0.16.18 で、この失敗の行へ合図の形(`identity`。ID・種類・絶対 path かどうか)を残す。次に起きた行を読んで、
|
|
54
|
+
扱いを決める。この ADR の Decision 1〜4 は、それまで変えない。
|
|
55
|
+
|
package/package.json
CHANGED
|
@@ -106,6 +106,26 @@ function openSuccessor(operation) {
|
|
|
106
106
|
// Codex が内部の文脈へ付けるターンの ID(`codex-rs/core/src/session/mod.rs` の `next_internal_sub_id`)。
|
|
107
107
|
const INTERNAL_COMPACTION_TURN_ID_PATTERN = /^auto-compact-\d+$/;
|
|
108
108
|
|
|
109
|
+
const SAFE_IDENTITY_TEXT_PATTERN = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
110
|
+
const identityText = value => typeof value !== 'string' ? (value === null ? 'null' : typeof value)
|
|
111
|
+
: SAFE_IDENTITY_TEXT_PATTERN.test(value) ? value : `text(${value.length})`;
|
|
112
|
+
const identityPath = value => typeof value !== 'string' ? (value === null ? 'null' : typeof value)
|
|
113
|
+
: isAbsolute(value) ? 'absolute' : `relative(${value.length})`;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 引き継ぎの起点にできなかった合図の形。失敗ログへ残して、どの値が不正だったかを後から読めるようにする
|
|
117
|
+
* (2026-10-10、macOS で `handoff_hook_identity_invalid` が5回出たが、ログに形が無く原因を決められなかった)。
|
|
118
|
+
* ID・モデル名・種類だけを残す。path は絶対かどうかだけ。記号の混じる値は長さだけ。会話の本文は入らない。
|
|
119
|
+
*/
|
|
120
|
+
export function hookIdentityShape(payload = {}) {
|
|
121
|
+
return {
|
|
122
|
+
session_id: identityText(payload.session_id), turn_id: identityText(payload.turn_id),
|
|
123
|
+
transcript_path: identityPath(payload.transcript_path), cwd: identityPath(payload.cwd),
|
|
124
|
+
agent_id: identityText(payload.agent_id), agent_type: identityText(payload.agent_type),
|
|
125
|
+
model: identityText(payload.model), trigger: identityText(payload.trigger),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
109
129
|
/** ターンの外の自動圧縮を止めた記録。引き継ぎの行は増えないので、起きた事をここに残す。会話の本文は書かない。 */
|
|
110
130
|
function recordInternalCompactionStop(entry) {
|
|
111
131
|
mkdirSync(stateRoot(), { recursive: true, mode: 0o700 });
|
|
@@ -179,7 +199,9 @@ export async function requestCodexAutoHandoff({ payload, db = null,
|
|
|
179
199
|
return { status: 'ok', inserted: false, internalCompaction: true, continue: false,
|
|
180
200
|
stopReason: 'Throughlineが、ターンの外の自動圧縮を止めました。文脈が上限に近づいたターンで、新しいタスクへ引き継ぎます。' };
|
|
181
201
|
}
|
|
182
|
-
if (!located || !CODEX_NATIVE_ID_PATTERN.test(turnId ?? ''))
|
|
202
|
+
if (!located || !CODEX_NATIVE_ID_PATTERN.test(turnId ?? '')) {
|
|
203
|
+
throw Object.assign(error('handoff_hook_identity_invalid'), { hookIdentity: hookIdentityShape(payload) });
|
|
204
|
+
}
|
|
183
205
|
// JS実装のrealpathは、WindowsのCodex Desktopが起動したhookの中で `EISDIR: lstat 'C:'` で落ちる。OSのrealpathを使う。
|
|
184
206
|
const rolloutPath = realpathSync.native(payload.transcript_path);
|
|
185
207
|
const state = readCodexHandoffState(rolloutPath, { threadId, turnId });
|
|
@@ -15,7 +15,7 @@ import { readCodexHandoffState, settingsMatch, threadStartSettings, resolvePrepa
|
|
|
15
15
|
import { runAutoHandoffWorker, requestCodexAutoHandoff, sourceBoundary,
|
|
16
16
|
autoHandoffTargetName, nameAutoHandoffTarget, findLiveAutoHandoffSuccessor,
|
|
17
17
|
markAutoHandoffSource, markAutoHandoffSources, autoHandoffSourceFallbackTitle, carryAutoHandoffQueue,
|
|
18
|
-
untrackedInheritedMemory, resolveAutoHandoffSuccessor } from './codex-auto-handoff.mjs';
|
|
18
|
+
untrackedInheritedMemory, resolveAutoHandoffSuccessor, hookIdentityShape } from './codex-auto-handoff.mjs';
|
|
19
19
|
import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
|
|
20
20
|
|
|
21
21
|
async function withDb(fn) {
|
|
@@ -364,6 +364,16 @@ test('Codexの内部の文脈(auto-compact-N)からの圧縮は、例外に
|
|
|
364
364
|
await assert.rejects(requestCodexAutoHandoff({ ...args, payload: { ...payload, ...broken } }), /handoff_hook_identity_invalid/);
|
|
365
365
|
}
|
|
366
366
|
assert.equal(recorded.length, 1);
|
|
367
|
+
|
|
368
|
+
// 失敗には、どの値が不正だったかの形を付ける。path と記号の混じる値は、中身を残さない
|
|
369
|
+
const shaped = await requestCodexAutoHandoff({ ...args, payload: { ...payload, turn_id: 'something else', transcript_path: null,
|
|
370
|
+
agent_type: 'reviewer', model: 'gpt-5.5' } }).catch(error => error);
|
|
371
|
+
assert.equal(shaped.code, 'handoff_hook_identity_invalid');
|
|
372
|
+
assert.deepEqual(shaped.hookIdentity, { session_id: thread, turn_id: 'text(14)', transcript_path: 'null', cwd: 'absolute',
|
|
373
|
+
agent_id: 'undefined', agent_type: 'reviewer', model: 'gpt-5.5', trigger: 'auto' });
|
|
374
|
+
assert.deepEqual(hookIdentityShape({ session_id: thread, turn_id: 'auto-compact-', transcript_path: 'relative/rollout.jsonl', cwd: 7 }),
|
|
375
|
+
{ session_id: thread, turn_id: 'auto-compact-', transcript_path: 'relative(22)', cwd: 'number',
|
|
376
|
+
agent_id: 'undefined', agent_type: 'undefined', model: 'undefined', trigger: 'undefined' });
|
|
367
377
|
}));
|
|
368
378
|
|
|
369
379
|
test('PreCompactの入口は、JS実装のrealpathが使えない環境(WindowsのCodex Desktopのhook)でも引き継ぎを作る', async () => withDb(async (db, home) => {
|
package/src/hook-failure-log.mjs
CHANGED
|
@@ -62,6 +62,13 @@ export function logHookFailure(code, error, { home, now = Date.now(), stderr = p
|
|
|
62
62
|
if (typeof context?.transcript_path === 'string' && context.transcript_path) {
|
|
63
63
|
entry.transcript_path = context.transcript_path;
|
|
64
64
|
}
|
|
65
|
+
// 不正な合図の形(errorが `hookIdentity` を持つ時)。文面が同じ失敗でも、どの値が不正だったかを残す。
|
|
66
|
+
// 値は呼び出し側が ID・種類・「絶対 path かどうか」へ直してある。ここでは短い文字列だけを通す。
|
|
67
|
+
const identity = error?.hookIdentity;
|
|
68
|
+
if (identity && typeof identity === 'object') {
|
|
69
|
+
const kept = Object.entries(identity).filter(([, value]) => typeof value === 'string' && value.length <= 80);
|
|
70
|
+
if (kept.length > 0) entry.identity = Object.fromEntries(kept);
|
|
71
|
+
}
|
|
65
72
|
// 外部 CLI の失敗は、message が終了 code だけになる。理由(reason)と CLI の stderr の末尾も残す
|
|
66
73
|
// (CLI は先頭に起動情報を出し、失敗の理由は末尾に出す)。
|
|
67
74
|
if (typeof error?.reason === 'string' && error.reason) entry.reason = error.reason;
|
|
@@ -50,6 +50,22 @@ test('logHookFailureは、外部CLIの失敗の理由とstderrの末尾を残す
|
|
|
50
50
|
}
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
+
test('logHookFailureは、不正な合図の形を残す', () => {
|
|
54
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
|
|
55
|
+
try {
|
|
56
|
+
const shaped = new Error('handoff_hook_identity_invalid');
|
|
57
|
+
shaped.hookIdentity = { turn_id: 'auto-compact-', transcript_path: 'null', cwd: 'absolute', long: 'x'.repeat(81), count: 3 };
|
|
58
|
+
logHookFailure('HOOK_CODEX_FAILED', shaped, { home });
|
|
59
|
+
logHookFailure('HOOK_CODEX_FAILED', new Error('plain'), { home });
|
|
60
|
+
const [first, second] = readFileSync(hookFailureLogPath({ home }), 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
61
|
+
// 短い文字列だけを通す(長い値と文字列でない値は落とす)。
|
|
62
|
+
assert.deepEqual(first.identity, { turn_id: 'auto-compact-', transcript_path: 'null', cwd: 'absolute' });
|
|
63
|
+
assert.equal('identity' in second, false);
|
|
64
|
+
} finally {
|
|
65
|
+
rmSync(home, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
53
69
|
test('logHookFailureは、どの会話の失敗かを残す', () => {
|
|
54
70
|
const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
|
|
55
71
|
try {
|