throughline 0.15.0 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,23 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.15.1] — 2026-10-05
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- Claudeの席で、完了ターンの控えが256件に達した後、`observer-read`・`observer-wait`へ前に受け取った位置(`--after-cursor`)を渡すと、1ターン増えるたびに`resync_required`が返る不具合を直す([ADR 0035](docs/adr/0035-observer-cursor-prefix-only.md))。位置の検証が、位置に入っている控えの下限の番号と今の下限の番号を比べていた。上限に達すると1ターンごとに古い1件が落ちて下限が1上がるので、追いついている位置も必ず無効になった。下限はproject全体で1つなので、落ちた控えが別の古い会話のものでも同じだった。
|
|
18
|
+
- 位置の検証は、位置が指す会話の先頭から読んだ分が今も同じかどうか(`prefix_sha256`の照合)だけで行う。別の古い会話の控えが落ちても、今の会話の位置は有効なままで、`append`・`delta`が返る。
|
|
19
|
+
- 位置の形、snapshotの返し方、`resync_required`を終了コード0で返すことは変わらない。0.15.0以前が出した位置は、そのまま使える。
|
|
20
|
+
|
|
21
|
+
### 残る制限
|
|
22
|
+
|
|
23
|
+
- 1つの会話だけで控えが256件を超えると、その会話の先頭が落ちるので、その会話の位置は今までどおり1ターンごとに`resync_required`になる。
|
|
24
|
+
|
|
25
|
+
### 確認した範囲
|
|
26
|
+
|
|
27
|
+
- 古い会話の控えで上限まで埋まったprojectで、新しい会話のターンを1つずつ足す再現。0.15.0は2ターン目から毎回`resync_required`、0.15.1は毎回`append`と、足したターンだけの`delta`を返す。
|
|
28
|
+
- BellTeamのコンテナで、控えが上限に達している席(連番は329まで進み、保持は256件、会話は27本、最新の会話の控えは14件)の控えを一時ファイルへ写し、最新の会話へ3ターン足した。読み終えた位置を毎回取り直しても、0.15.0は3回とも`resync_required`、0.15.1は3回とも`append`を返す。控えはhashとsession idだけで、本文は含まない。
|
|
29
|
+
|
|
13
30
|
## [0.15.0] — 2026-10-05
|
|
14
31
|
|
|
15
32
|
### 追加
|
|
@@ -1754,7 +1771,8 @@ two attempts, instrument first instead of patching again.
|
|
|
1754
1771
|
|
|
1755
1772
|
---
|
|
1756
1773
|
|
|
1757
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.15.
|
|
1774
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.15.1...HEAD
|
|
1775
|
+
[0.15.1]: https://github.com/kitepon/Throughline/compare/v0.15.0...v0.15.1
|
|
1758
1776
|
[0.15.0]: https://github.com/kitepon/Throughline/compare/v0.14.3...v0.15.0
|
|
1759
1777
|
[0.14.3]: https://github.com/kitepon/Throughline/compare/v0.14.2...v0.14.3
|
|
1760
1778
|
[0.14.2]: https://github.com/kitepon/Throughline/compare/v0.14.1...v0.14.2
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# ADR 0035: Observer の位置は、下限の番号を比べず、先頭からの照合だけで検証する
|
|
2
|
+
|
|
3
|
+
日付: 2026-10-05
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
ADR 0003 は、Claude の完了ターンの控え(project ごとに 256 件まで、`COMPLETED_TURN_RECEIPT_LIMIT`)から
|
|
8
|
+
作る chain に位置(cursor)を結び、「保持の下限より古い位置は resync を求める」と決めた。位置には、
|
|
9
|
+
発行した時点の控えの下限の番号(`history_floor`)、会話の chain の長さ(`length`)、先頭から `length` 件の
|
|
10
|
+
digest(`prefix_sha256`)が入る。
|
|
11
|
+
|
|
12
|
+
実装は「位置の `history_floor` < 今の `history_floor`」なら `resync_required` にしていた。控えが 256 件に
|
|
13
|
+
達すると、1 ターン増えるたびに古い 1 件が落ちて下限の番号が 1 上がる。そのため、追いついている位置も、
|
|
14
|
+
次の 1 ターンで必ず無効になった。下限は project 全体で 1 つなので、落ちた控えが別の古い会話のものでも、
|
|
15
|
+
今の会話の位置が無効になった。
|
|
16
|
+
|
|
17
|
+
BellTeam(0.14.3、2026-10-05)で、控えが 323 件ある席の読み出しが、ターン 10 回すべてで `resync_required` に
|
|
18
|
+
なった。BellTeam は `resync_required` のたびに今の位置から読み直していたので、その間に終わったターンの
|
|
19
|
+
回答が画面に出なかった(同じ日に 2 回)。BellTeam のビスケットとトロニーが見つけた。
|
|
20
|
+
|
|
21
|
+
`history_floor` の比較が無くても、位置が指す会話の先頭の控えが落ちたことは分かる。先頭が k 件落ちると、
|
|
22
|
+
今の chain の先頭から `length` 件は、発行した時の先頭から `length` 件と違う控えになり、`prefix_sha256` が
|
|
23
|
+
合わない(各項目は控えの連番から作る `source_sha256` を含む)。chain が `length` より短くなった時も無効になる。
|
|
24
|
+
|
|
25
|
+
## Decision
|
|
26
|
+
|
|
27
|
+
1. 位置の検証から `history_floor` の比較を外す。位置が有効なのは、その会話の chain が `length` 件以上あり、
|
|
28
|
+
先頭から `length` 件の digest が `prefix_sha256` と一致する時だけとする。`resolveObserverTurnFeed` と
|
|
29
|
+
`readObserverTurnPage`(`validateCursorCandidate`)の両方。
|
|
30
|
+
2. 位置の形は変えない。`history_floor` は今までどおり入れる(発行した時点の控えの下限の番号)。
|
|
31
|
+
検証には使わない。0.15.0 以前が出した位置は、そのまま使える。
|
|
32
|
+
3. snapshot の返し方、`resync_required` を終了コード 0 で返すこと、page token の作りは変えない。
|
|
33
|
+
|
|
34
|
+
## Consequences
|
|
35
|
+
|
|
36
|
+
- 別の古い会話の控えが落ちても、今の会話の位置は有効なままで、`append`・`delta` が返る。
|
|
37
|
+
- 位置が指す会話そのものの先頭が落ちた時は、今までどおり `resync_required` になる。1 つの会話だけで
|
|
38
|
+
控えが 256 件を超えると、その会話の位置は 1 ターンごとに無効になる。これを無くすには、位置を
|
|
39
|
+
先頭からの digest ではなく控えの連番に結ぶ必要があり、位置の作りが変わるので、別に決める。
|
|
40
|
+
- `history_floor` だけを書き換えた位置は、先頭からの照合が合えば受け入れる。位置は認可の鍵ではなく
|
|
41
|
+
(ADR 0003)、控えそのものと照合するので、読める範囲は変わらない。
|
|
42
|
+
- ADR 0003 の「cursor below `history_floor` … returns `resync_required`」は、この ADR で置き換える。
|
|
43
|
+
「保持で失われたことを黙って隠さない」は、先頭からの照合で保つ。
|
|
44
|
+
|
|
45
|
+
## 確認した範囲
|
|
46
|
+
|
|
47
|
+
- `src/observer-turn-feed.test.mjs`: 古い会話の控えで上限まで埋まった project で、新しい会話のターンを
|
|
48
|
+
1 つずつ足し、毎回 `append` と、足したターンだけの `delta` が返ること。同じ会話の先頭が落ちた時は
|
|
49
|
+
`resync_required` になること。修理前のコードでは 2 件とも失敗する。
|
package/package.json
CHANGED
|
@@ -47,7 +47,7 @@ export function resolveObserverTurnFeed({ projectPath, cursor = null, codexHome,
|
|
|
47
47
|
? emptyCandidate()
|
|
48
48
|
: candidates.find((item) => item.host === prior.host && item.threadHash === prior.thread_sha256);
|
|
49
49
|
if (!priorCandidate || prior.host !== null &&
|
|
50
|
-
(prior.
|
|
50
|
+
(prior.length > priorCandidate.chain.length ||
|
|
51
51
|
prefixDigest(priorCandidate.chain.slice(0, prior.length)) !== prior.prefix_sha256)) {
|
|
52
52
|
return { schema: OBSERVER_CURSOR_SCHEMA, status: 'resync_required', afterCursor: cursor, throughCursor: null };
|
|
53
53
|
}
|
|
@@ -254,7 +254,7 @@ function decodeCursorForRead(token, projectSha256) {
|
|
|
254
254
|
function validateCursorCandidate(cursor, candidates) {
|
|
255
255
|
if (cursor.host === null) return emptyCandidate();
|
|
256
256
|
const current = candidates.find((item) => item.host === cursor.host && item.threadHash === cursor.thread_sha256);
|
|
257
|
-
if (!current || cursor.
|
|
257
|
+
if (!current || cursor.length > current.chain.length ||
|
|
258
258
|
prefixDigest(current.chain.slice(0, cursor.length)) !== cursor.prefix_sha256) return null;
|
|
259
259
|
return current;
|
|
260
260
|
}
|
|
@@ -126,11 +126,16 @@ test('observer feed: Claude history floor, host/thread switch, cross-host tie, a
|
|
|
126
126
|
try {
|
|
127
127
|
const options = box.receiptOptions;
|
|
128
128
|
seedCompletedTurnReceiptStore({ projectPath: box.project, receiptOptions: options, targetSessionId: 'claude-session' });
|
|
129
|
+
const full = resolveObserverTurnFeed({ projectPath: box.project, receiptOptions: options, codexHome: box.home });
|
|
129
130
|
writeCompletedTurnReceipt({ projectPath: box.project, targetSessionId: 'claude-session', originSessionId: 'o257', userBody: 'user-body-257', assistantBody: 'assistant-body-257', completedAt: 257 }, options);
|
|
131
|
+
// 同じ会話の先頭の控えが落ちた時は、先頭からの照合が合わなくなるので resync(ADR 0035)
|
|
132
|
+
assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: full.throughCursor, receiptOptions: options, codexHome: box.home }).status, 'resync_required');
|
|
130
133
|
const latest = resolveObserverTurnFeed({ projectPath: box.project, receiptOptions: options, codexHome: box.home });
|
|
131
134
|
const decoded = decodeObserverCursor(latest.throughCursor);
|
|
132
|
-
|
|
133
|
-
|
|
135
|
+
assert.equal(decoded.history_floor, 2);
|
|
136
|
+
// 下限の番号は位置に入れたままだが、検証には使わない。先頭からの照合が合えば有効
|
|
137
|
+
const olderFloor = encodeObserverCursor({ ...decoded, history_floor: 1 });
|
|
138
|
+
assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: olderFloor, receiptOptions: options, codexHome: box.home }).status, 'unchanged');
|
|
134
139
|
assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: `${latest.throughCursor}x`, receiptOptions: options, codexHome: box.home }).status, 'resync_required');
|
|
135
140
|
const oldVersion = `tlc1.${Buffer.from(JSON.stringify({ ...decoded, schema: 'throughline.observer_cursor.v0' })).toString('base64url')}`;
|
|
136
141
|
assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: oldVersion, receiptOptions: options, codexHome: box.home }).status, 'resync_required');
|
|
@@ -157,6 +162,51 @@ test('observer feed: Claude history floor, host/thread switch, cross-host tie, a
|
|
|
157
162
|
} finally { rmSync(box.root, { recursive: true, force: true }); }
|
|
158
163
|
});
|
|
159
164
|
|
|
165
|
+
test('observer feed: 控えが上限に達して別の古い会話の控えが落ちても、今の会話の位置は有効なまま', () => {
|
|
166
|
+
const box = fixture();
|
|
167
|
+
const dbPath = join(box.root, 'throughline.db');
|
|
168
|
+
let db;
|
|
169
|
+
try {
|
|
170
|
+
// 古い会話の控えで上限まで埋まっている席で、新しい会話が始まる
|
|
171
|
+
seedCompletedTurnReceiptStore({ projectPath: box.project, receiptOptions: box.receiptOptions, targetSessionId: 'old-session' });
|
|
172
|
+
db = createProjectionDb(dbPath, box.project, 'live-session');
|
|
173
|
+
const read = (afterCursor) => readObserverTurnPage({
|
|
174
|
+
projectPath: box.project, afterCursor, codexHome: box.home, receiptOptions: box.receiptOptions, dbPath,
|
|
175
|
+
});
|
|
176
|
+
const floorOf = (cursor) => decodeObserverCursor(cursor).history_floor;
|
|
177
|
+
|
|
178
|
+
addClaudeTurn(box, db, { sessionId: 'live-session', origin: 'live-1', turn: 1, user: 'user-1', assistant: 'assistant-1', at: 1001 });
|
|
179
|
+
let cursor = read(null).throughCursor;
|
|
180
|
+
assert.equal(floorOf(cursor), 2);
|
|
181
|
+
for (let turn = 2; turn <= 5; turn++) {
|
|
182
|
+
// 1 ターンごとに古い会話の控えが1件落ち、控え全体の下限が1上がる
|
|
183
|
+
addClaudeTurn(box, db, { sessionId: 'live-session', origin: `live-${turn}`, turn, user: `user-${turn}`, assistant: `assistant-${turn}`, at: 1000 + turn });
|
|
184
|
+
assert.equal(resolveObserverTurnFeed({
|
|
185
|
+
projectPath: box.project, cursor, codexHome: box.home, receiptOptions: box.receiptOptions,
|
|
186
|
+
}).status, 'append');
|
|
187
|
+
const page = read(cursor);
|
|
188
|
+
assert.equal(page.status, 'delta');
|
|
189
|
+
assert.deepEqual(page.turns.map((item) => item.user), [`user-${turn}`]);
|
|
190
|
+
assert.equal(page.page.complete, true);
|
|
191
|
+
assert.equal(floorOf(page.throughCursor), turn + 1);
|
|
192
|
+
cursor = page.throughCursor;
|
|
193
|
+
}
|
|
194
|
+
// 追いついた位置は、次のターンが来るまで unchanged
|
|
195
|
+
assert.equal(resolveObserverTurnFeed({
|
|
196
|
+
projectPath: box.project, cursor, codexHome: box.home, receiptOptions: box.receiptOptions,
|
|
197
|
+
}).status, 'unchanged');
|
|
198
|
+
// 読み遅れた位置(2 ターン前)からも、残りを続きとして読める
|
|
199
|
+
addClaudeTurn(box, db, { sessionId: 'live-session', origin: 'live-6', turn: 6, user: 'user-6', assistant: 'assistant-6', at: 1006 });
|
|
200
|
+
addClaudeTurn(box, db, { sessionId: 'live-session', origin: 'live-7', turn: 7, user: 'user-7', assistant: 'assistant-7', at: 1007 });
|
|
201
|
+
const late = read(cursor);
|
|
202
|
+
assert.equal(late.status, 'delta');
|
|
203
|
+
assert.deepEqual(late.turns.map((item) => item.user), ['user-6', 'user-7']);
|
|
204
|
+
} finally {
|
|
205
|
+
db?.close();
|
|
206
|
+
rmSync(box.root, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
160
210
|
test('observer feed: Codex pair hashes reuse DB capture role aggregation', () => {
|
|
161
211
|
const box = fixture();
|
|
162
212
|
try {
|