throughline 0.1.0 → 0.3.0
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/README.md +91 -33
- package/bin/throughline.mjs +15 -7
- package/package.json +1 -1
- package/src/baton.mjs +121 -0
- package/src/baton.test.mjs +144 -0
- package/src/cli/install.mjs +7 -2
- package/src/cli/save-inflight.mjs +81 -0
- package/src/constants.mjs +1 -0
- package/src/db.mjs +26 -1
- package/src/prompt-submit.mjs +87 -0
- package/src/resume-context.mjs +99 -21
- package/src/session-merger.mjs +38 -29
- package/src/session-merger.test.mjs +72 -41
- package/src/session-start.mjs +57 -13
- package/src/transcript-reader.mjs +14 -3
- package/src/transcript-reader.test.mjs +61 -3
package/src/db.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { join } from 'path';
|
|
|
9
9
|
|
|
10
10
|
const DB_DIR = join(homedir(), '.throughline');
|
|
11
11
|
const DB_PATH = join(DB_DIR, 'throughline.db');
|
|
12
|
-
const CURRENT_VERSION =
|
|
12
|
+
const CURRENT_VERSION = 7;
|
|
13
13
|
|
|
14
14
|
let _db = null;
|
|
15
15
|
|
|
@@ -177,6 +177,31 @@ function initSchema(db) {
|
|
|
177
177
|
`);
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
// v5 → v6: handoff_batons テーブル追加(/tl スラッシュコマンドによる明示的引き継ぎ指名用)
|
|
181
|
+
// - project_path ごとに最新 1 件のみ (PRIMARY KEY)
|
|
182
|
+
// - SessionStart で読み出し、TTL 以内なら merge して DELETE
|
|
183
|
+
// - docs/INHERITANCE_ON_CLEAR_ONLY.md 参照: 案 D (時間差) 撤去、バトン方式へ移行
|
|
184
|
+
if (version < 6) {
|
|
185
|
+
db.exec(`
|
|
186
|
+
CREATE TABLE IF NOT EXISTS handoff_batons (
|
|
187
|
+
project_path TEXT PRIMARY KEY,
|
|
188
|
+
session_id TEXT NOT NULL,
|
|
189
|
+
created_at INTEGER NOT NULL
|
|
190
|
+
);
|
|
191
|
+
`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// v6 → v7: handoff_batons に memo_text 列追加(/tl 発動時に現行 Claude 自身が
|
|
195
|
+
// 書き込む in-flight メモ。「次の一手」「現在の方針」「未解決」「進行中 TODO」
|
|
196
|
+
// の短い Markdown テキスト。次セッションの SessionStart が resume-context の
|
|
197
|
+
// 先頭に注入して「中断地点からの再開」感を復元する)
|
|
198
|
+
if (version < 7) {
|
|
199
|
+
const batonCols = db.prepare('PRAGMA table_info(handoff_batons)').all();
|
|
200
|
+
if (!batonCols.some((c) => c.name === 'memo_text')) {
|
|
201
|
+
db.exec('ALTER TABLE handoff_batons ADD COLUMN memo_text TEXT');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
180
205
|
if (version < CURRENT_VERSION) {
|
|
181
206
|
db.exec(`PRAGMA user_version = ${CURRENT_VERSION}`);
|
|
182
207
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* UserPromptSubmit hook — /tl スラッシュコマンド検出 + バトン書き込み
|
|
4
|
+
*
|
|
5
|
+
* stdin: { session_id, cwd, prompt, hook_event_name, ... }
|
|
6
|
+
*
|
|
7
|
+
* 動作:
|
|
8
|
+
* - prompt が /tl (単独 or /tl ... 形式) で始まっていればバトンを書き込んで終了
|
|
9
|
+
* - それ以外は何もせず exit 0(プロンプトはそのまま Claude に渡る)
|
|
10
|
+
* - 本 hook は注入を一切行わない (SessionStart の引き継ぎ注入と二重にならないため)
|
|
11
|
+
*
|
|
12
|
+
* 設計背景: docs/INHERITANCE_ON_CLEAR_ONLY.md バトン方式
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { getDb } from './db.mjs';
|
|
16
|
+
import { writeBaton } from './baton.mjs';
|
|
17
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
18
|
+
import { join, dirname } from 'node:path';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
|
|
21
|
+
function logBaton(entry) {
|
|
22
|
+
const path = join(homedir(), '.throughline', 'logs', 'baton-write.log');
|
|
23
|
+
try {
|
|
24
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
25
|
+
appendFileSync(path, JSON.stringify(entry) + '\n', 'utf8');
|
|
26
|
+
} catch (err) {
|
|
27
|
+
const msg = err instanceof Error ? err.message : 'unknown';
|
|
28
|
+
process.stderr.write(`[prompt-submit:log] ${msg}\n`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* プロンプトが /tl バトン発動コマンドか判定する。
|
|
34
|
+
* 許容: "/tl", "/tl\n", "/tl 何か" (前後空白は trim 済み前提)
|
|
35
|
+
*/
|
|
36
|
+
export function isBatonCommand(prompt) {
|
|
37
|
+
if (typeof prompt !== 'string') return false;
|
|
38
|
+
const trimmed = prompt.trim();
|
|
39
|
+
if (trimmed === '/tl') return true;
|
|
40
|
+
if (trimmed.startsWith('/tl ') || trimmed.startsWith('/tl\n')) return true;
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function main() {
|
|
45
|
+
let raw = '';
|
|
46
|
+
await new Promise((resolve) => {
|
|
47
|
+
process.stdin.setEncoding('utf8');
|
|
48
|
+
process.stdin.on('data', (chunk) => {
|
|
49
|
+
raw += chunk;
|
|
50
|
+
});
|
|
51
|
+
process.stdin.on('end', resolve);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const payload = JSON.parse(raw);
|
|
55
|
+
const { session_id, cwd, prompt } = payload;
|
|
56
|
+
|
|
57
|
+
if (!isBatonCommand(prompt)) {
|
|
58
|
+
process.exit(0);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!session_id) {
|
|
63
|
+
process.stderr.write('[prompt-submit] missing session_id in payload\n');
|
|
64
|
+
process.exit(0);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const projectPath = cwd ?? process.cwd();
|
|
69
|
+
const db = getDb();
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
|
|
72
|
+
writeBaton(db, { projectPath, sessionId: session_id, now });
|
|
73
|
+
|
|
74
|
+
logBaton({
|
|
75
|
+
ts: new Date(now).toISOString(),
|
|
76
|
+
session_id,
|
|
77
|
+
project_path: projectPath,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
main().catch((err) => {
|
|
84
|
+
const msg = err instanceof Error ? err.message : 'unknown';
|
|
85
|
+
process.stderr.write(`[prompt-submit] error: ${msg}\n`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
});
|
package/src/resume-context.mjs
CHANGED
|
@@ -1,23 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* resume-context.mjs —
|
|
2
|
+
* resume-context.mjs — 中断地点からの再開注入テキストを組み立てる共有モジュール
|
|
3
3
|
*
|
|
4
4
|
* 呼び出し元:
|
|
5
5
|
* - session-start.mjs (isInheritance=true, 引き継ぎヘッダ)
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* 設計(schema v7 対応):
|
|
8
|
+
* - 注入順: ヘッダ → [in-flight メモ] → [中断直前の思考] → L1 要約 → L2 本文 → フッタ
|
|
9
|
+
* - in-flight メモ: /tl 発動時に現行 Claude が書いた「次の一手 / 方針 / 未解決 / TODO」
|
|
10
|
+
* - 中断直前の思考: 最終ターンの assistant extended thinking (details kind='thinking')
|
|
8
11
|
* - 直近 N=20 ターンは bodies から L2 全文を注入
|
|
9
12
|
* - それ以前は skeletons から L1 要約のみ注入
|
|
10
|
-
* - 各行頭に [HH:MM:SS] 時刻プレフィックス(
|
|
13
|
+
* - 各行頭に [HH:MM:SS] 時刻プレフィックス(created_at ベース、DB 永続)
|
|
11
14
|
* - 末尾に /sc-detail <時刻> ガイドを追記
|
|
12
|
-
* - judgments セクションは廃止
|
|
13
15
|
* - 現セッションのターンは注入しない(Claude Code 本体のコンテキストに既にあるため)
|
|
16
|
+
* - フレーミングを「過去の記憶」から「中断した作業の再開」に変更 (B 案)
|
|
14
17
|
*/
|
|
15
18
|
|
|
16
19
|
const N_RECENT_L2 = 20;
|
|
17
20
|
|
|
18
21
|
const RESUME_HEADER_TEMPLATE = (turnCount) =>
|
|
19
|
-
`## Throughline:
|
|
20
|
-
|
|
22
|
+
`## Throughline: 中断した作業の再開(${turnCount} ターン分の文脈を保持)\n` +
|
|
23
|
+
`\n` +
|
|
24
|
+
`**前セッションで進行中だった作業を、この新セッションで引き継いでいます。以下が中断時点の状態です:**\n` +
|
|
25
|
+
`- 中断直前の in-flight メモ(前セッション末尾で Claude 自身が書いた「次の一手・方針・未解決・TODO」)\n` +
|
|
26
|
+
`- 中断直前の思考 (最終ターンの extended thinking)\n` +
|
|
27
|
+
`- 直近 ${N_RECENT_L2} ターンの会話本文 (L2)\n` +
|
|
28
|
+
`- それ以前の要約 (L1)\n` +
|
|
29
|
+
`\n` +
|
|
30
|
+
`応答の冒頭でユーザーに「前の作業を ${turnCount} ターン分引き継ぎました」と報告してください。` +
|
|
31
|
+
`作業方針は前セッションのものを踏襲し、中断地点から自然に続行してください。`;
|
|
21
32
|
|
|
22
33
|
const NORMAL_HEADER = '## Throughline: セッション記憶';
|
|
23
34
|
|
|
@@ -25,11 +36,11 @@ const FOOTER_GUIDE =
|
|
|
25
36
|
'---\n' +
|
|
26
37
|
'**[Claude 向け — 記憶の使い方]**\n' +
|
|
27
38
|
'上の L1 要約や L2 本文を読んで「具体的なコマンドやツール出力、ファイル内容を確認したい」と感じたら、' +
|
|
28
|
-
'推測せずに **Bash ツールで `throughline detail <時刻>` を実行** して L3(ツール入出力・hook
|
|
39
|
+
'推測せずに **Bash ツールで `throughline detail <時刻>` を実行** して L3(ツール入出力・hook 出力・thinking)を取得してください。\n' +
|
|
29
40
|
'- 単一時刻: `throughline detail 14:23:05`\n' +
|
|
30
41
|
'- 時刻範囲: `throughline detail 14:23-14:30`\n' +
|
|
31
42
|
'\n' +
|
|
32
|
-
'返る内容: 指定ターンの L2 会話本文 + L3(tool_input / tool_output / system 別にグループ化)。\n' +
|
|
43
|
+
'返る内容: 指定ターンの L2 会話本文 + L3(tool_input / tool_output / system / thinking 別にグループ化)。\n' +
|
|
33
44
|
'ユーザーに「詳細を見せて」と言われた時だけでなく、**ユーザー発言の文脈が過去ターンに依存しているのに L1/L2 だけでは情報不足だと判断した時**に、Claude 自身の判断で呼び出して構いません。';
|
|
34
45
|
|
|
35
46
|
/**
|
|
@@ -44,29 +55,75 @@ function formatTime(unixMs) {
|
|
|
44
55
|
}
|
|
45
56
|
|
|
46
57
|
/**
|
|
47
|
-
*
|
|
58
|
+
* 最新ターン番号 (= 中断直前) の thinking ブロックを details から取り出す。
|
|
59
|
+
* origin 除外がある場合はそれも考慮する。
|
|
60
|
+
*
|
|
61
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
62
|
+
* @param {string} sessionId
|
|
63
|
+
* @param {string | null} excludeOriginId
|
|
64
|
+
* @returns {Array<{ output_text: string, created_at: number }>}
|
|
48
65
|
*/
|
|
49
|
-
function
|
|
50
|
-
|
|
51
|
-
|
|
66
|
+
function loadLatestThinking(db, sessionId, excludeOriginId) {
|
|
67
|
+
const hasExclude = Boolean(excludeOriginId);
|
|
68
|
+
|
|
69
|
+
// 最新 (origin_session_id, turn_number) を bodies から特定
|
|
70
|
+
const latestQuery = hasExclude
|
|
71
|
+
? `SELECT origin_session_id, turn_number, created_at
|
|
72
|
+
FROM bodies
|
|
73
|
+
WHERE session_id = ? AND origin_session_id != ? AND role = 'assistant'
|
|
74
|
+
ORDER BY created_at DESC
|
|
75
|
+
LIMIT 1`
|
|
76
|
+
: `SELECT origin_session_id, turn_number, created_at
|
|
77
|
+
FROM bodies
|
|
78
|
+
WHERE session_id = ? AND role = 'assistant'
|
|
79
|
+
ORDER BY created_at DESC
|
|
80
|
+
LIMIT 1`;
|
|
81
|
+
|
|
82
|
+
let latest;
|
|
83
|
+
try {
|
|
84
|
+
latest = hasExclude
|
|
85
|
+
? db.prepare(latestQuery).get(sessionId, excludeOriginId)
|
|
86
|
+
: db.prepare(latestQuery).get(sessionId);
|
|
87
|
+
} catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
if (!latest) return [];
|
|
91
|
+
|
|
92
|
+
// その (origin_session_id, turn_number) に紐づく kind='thinking' を取り出す
|
|
93
|
+
try {
|
|
94
|
+
const rows = db
|
|
95
|
+
.prepare(
|
|
96
|
+
`SELECT output_text, created_at FROM details
|
|
97
|
+
WHERE session_id = ? AND origin_session_id = ? AND turn_number = ? AND kind = 'thinking'
|
|
98
|
+
ORDER BY created_at ASC`,
|
|
99
|
+
)
|
|
100
|
+
.all(sessionId, latest.origin_session_id, latest.turn_number);
|
|
101
|
+
return rows.filter((r) => typeof r.output_text === 'string' && r.output_text.length > 0);
|
|
102
|
+
} catch {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
52
105
|
}
|
|
53
106
|
|
|
54
107
|
/**
|
|
55
108
|
* L1+L2 注入テキストを組み立てる。
|
|
56
109
|
*
|
|
57
110
|
* @param {import('node:sqlite').DatabaseSync} db
|
|
58
|
-
* @param {{
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
111
|
+
* @param {{
|
|
112
|
+
* sessionId: string,
|
|
113
|
+
* isInheritance: boolean,
|
|
114
|
+
* excludeOriginId?: string | null,
|
|
115
|
+
* inflightMemo?: string | null,
|
|
116
|
+
* }} params
|
|
62
117
|
* @returns {string | null}
|
|
63
118
|
*/
|
|
64
|
-
export function buildResumeContext(
|
|
119
|
+
export function buildResumeContext(
|
|
120
|
+
db,
|
|
121
|
+
{ sessionId, isInheritance, excludeOriginId = null, inflightMemo = null },
|
|
122
|
+
) {
|
|
65
123
|
if (!sessionId) return null;
|
|
66
124
|
|
|
67
125
|
const hasExclude = Boolean(excludeOriginId);
|
|
68
126
|
|
|
69
|
-
// 直近 N 件の bodies を取得
|
|
70
127
|
const bodiesQuery = hasExclude
|
|
71
128
|
? `SELECT origin_session_id, turn_number, role, text, created_at
|
|
72
129
|
FROM bodies
|
|
@@ -92,7 +149,7 @@ export function buildResumeContext(db, { sessionId, isInheritance, excludeOrigin
|
|
|
92
149
|
}
|
|
93
150
|
const bodyRows = bodyRowsDesc.reverse(); // ASC に戻す
|
|
94
151
|
|
|
95
|
-
// 古い側の L1(bodies
|
|
152
|
+
// 古い側の L1(bodies に既に含まれるターンを除いたもの)
|
|
96
153
|
const bodySet = new Set(
|
|
97
154
|
bodyRows.map((r) => `${r.origin_session_id}\x00${r.turn_number}`),
|
|
98
155
|
);
|
|
@@ -115,7 +172,14 @@ export function buildResumeContext(db, { sessionId, isInheritance, excludeOrigin
|
|
|
115
172
|
(s) => !bodySet.has(`${s.origin_session_id}\x00${s.turn_number}`),
|
|
116
173
|
);
|
|
117
174
|
|
|
118
|
-
|
|
175
|
+
const thinkingRows = loadLatestThinking(db, sessionId, excludeOriginId);
|
|
176
|
+
|
|
177
|
+
if (
|
|
178
|
+
bodyRows.length === 0 &&
|
|
179
|
+
l1Rows.length === 0 &&
|
|
180
|
+
thinkingRows.length === 0 &&
|
|
181
|
+
!inflightMemo
|
|
182
|
+
) {
|
|
119
183
|
return null;
|
|
120
184
|
}
|
|
121
185
|
|
|
@@ -123,12 +187,26 @@ export function buildResumeContext(db, { sessionId, isInheritance, excludeOrigin
|
|
|
123
187
|
const header = isInheritance ? RESUME_HEADER_TEMPLATE(turnCount) : NORMAL_HEADER;
|
|
124
188
|
const lines = [header];
|
|
125
189
|
|
|
190
|
+
if (inflightMemo && inflightMemo.trim().length > 0) {
|
|
191
|
+
lines.push('');
|
|
192
|
+
lines.push('### 中断直前の in-flight メモ(前セッションの Claude 自身による要約)');
|
|
193
|
+
lines.push(inflightMemo.trim());
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (thinkingRows.length > 0) {
|
|
197
|
+
lines.push('');
|
|
198
|
+
lines.push('### 中断直前の思考 (最終ターンの extended thinking)');
|
|
199
|
+
for (const r of thinkingRows) {
|
|
200
|
+
lines.push(`[${formatTime(r.created_at)}] ${r.output_text}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
126
204
|
if (l1Rows.length > 0) {
|
|
127
205
|
lines.push('');
|
|
128
206
|
lines.push('### それ以前の要約 (L1)');
|
|
129
207
|
for (const r of l1Rows) {
|
|
130
208
|
if (!r.summary || r.summary === '(no content)') continue;
|
|
131
|
-
lines.push(`[${formatTime(r.created_at)}] ${
|
|
209
|
+
lines.push(`[${formatTime(r.created_at)}] ${r.summary.replace(/\n+/g, ' ').trim()}`);
|
|
132
210
|
}
|
|
133
211
|
}
|
|
134
212
|
|
package/src/session-merger.mjs
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
* session-merger.mjs — 記憶張り替え + merged_into チェーン解決
|
|
3
3
|
*
|
|
4
4
|
* 用途:
|
|
5
|
-
* - SessionStart hook:
|
|
6
|
-
* - Stop
|
|
5
|
+
* - SessionStart hook: バトンで指名された旧セッションを mergeSpecificPredecessor で新セッションに張り替え
|
|
6
|
+
* - Stop hook: resolveMergeTarget で「入力 session_id → 実書き込み先」を解決
|
|
7
7
|
*
|
|
8
|
-
* 設計背景: docs/SESSION_LINKING_DESIGN.md
|
|
8
|
+
* 設計背景: docs/SESSION_LINKING_DESIGN.md, docs/INHERITANCE_ON_CLEAR_ONLY.md (バトン方式採用)
|
|
9
|
+
*
|
|
10
|
+
* 旧実装 (案 D: 時間差ヒューリスティック / 自動前任選択) は撤去済み。
|
|
11
|
+
* 引き継ぎはユーザーが /tl を打って書いたバトンによる明示的指名のみで発火する。
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
14
|
const MAX_CHAIN_DEPTH = 10;
|
|
@@ -16,8 +19,6 @@ const MAX_CHAIN_DEPTH = 10;
|
|
|
16
19
|
* @param {import('node:sqlite').DatabaseSync} db
|
|
17
20
|
* @param {string} sessionId
|
|
18
21
|
* @returns {{ target: string, origin: string }}
|
|
19
|
-
* target: 実書き込み先 session_id(合流先)
|
|
20
|
-
* origin: 入力 session_id そのもの(INSERT 時の origin_session_id に使う)
|
|
21
22
|
*/
|
|
22
23
|
export function resolveMergeTarget(db, sessionId) {
|
|
23
24
|
const origin = sessionId;
|
|
@@ -46,43 +47,53 @@ export function resolveMergeTarget(db, sessionId) {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
/**
|
|
49
|
-
*
|
|
50
|
+
* バトンで指名された特定の旧セッションを新セッションに張り替える。
|
|
50
51
|
*
|
|
51
52
|
* 実行順序(BEGIN IMMEDIATE トランザクション内):
|
|
52
|
-
* 1.
|
|
53
|
+
* 1. 前任の妥当性チェック(存在する / 自分自身ではない / 既に合流済みでない / created_at が古い)
|
|
53
54
|
* 2. skeletons / details / bodies の session_id を new に UPDATE
|
|
54
|
-
* (bodies は schema v4 で追加された L2 テーブル。v3 DB でも UPDATE は no-op で害なし)
|
|
55
55
|
* 3. 前任 sessions.merged_into = new
|
|
56
56
|
* 4. 新セッション sessions.updated_at = now
|
|
57
57
|
*
|
|
58
58
|
* @param {import('node:sqlite').DatabaseSync} db
|
|
59
|
-
* @param {{ newSessionId: string,
|
|
60
|
-
* @returns {{
|
|
59
|
+
* @param {{ newSessionId: string, predecessorId: string, now?: number }} params
|
|
60
|
+
* @returns {{
|
|
61
|
+
* merged: boolean,
|
|
62
|
+
* predecessorId?: string,
|
|
63
|
+
* rowCounts?: { sk: number, dt: number, bd: number },
|
|
64
|
+
* skipReason?: 'self_handoff' | 'predecessor_not_found' | 'already_merged' | 'predecessor_not_older',
|
|
65
|
+
* }}
|
|
61
66
|
*/
|
|
62
|
-
export function
|
|
67
|
+
export function mergeSpecificPredecessor(db, { newSessionId, predecessorId, now = Date.now() }) {
|
|
68
|
+
if (newSessionId === predecessorId) {
|
|
69
|
+
return { merged: false, skipReason: 'self_handoff' };
|
|
70
|
+
}
|
|
71
|
+
|
|
63
72
|
db.exec('BEGIN IMMEDIATE');
|
|
64
73
|
try {
|
|
65
|
-
// 時系列単調制約: 前任は新セッションより created_at が古いものに限る。
|
|
66
|
-
// これにより merge chain は厳密に時系列順となり、循環参照が構造的に発生不可能になる。
|
|
67
|
-
// (同時刻に複数 SessionStart が発火しても、自分自身より新しいセッションは選ばない)
|
|
68
74
|
const pred = db
|
|
69
|
-
.prepare(
|
|
70
|
-
|
|
71
|
-
WHERE lower(project_path) = lower(?)
|
|
72
|
-
AND session_id != ?
|
|
73
|
-
AND merged_into IS NULL
|
|
74
|
-
AND created_at < (SELECT created_at FROM sessions WHERE session_id = ?)
|
|
75
|
-
ORDER BY updated_at DESC
|
|
76
|
-
LIMIT 1`,
|
|
77
|
-
)
|
|
78
|
-
.get(projectPath, newSessionId, newSessionId);
|
|
75
|
+
.prepare('SELECT session_id, created_at, merged_into FROM sessions WHERE session_id = ?')
|
|
76
|
+
.get(predecessorId);
|
|
79
77
|
|
|
80
78
|
if (!pred) {
|
|
81
79
|
db.exec('COMMIT');
|
|
82
|
-
return { merged: false };
|
|
80
|
+
return { merged: false, skipReason: 'predecessor_not_found' };
|
|
81
|
+
}
|
|
82
|
+
if (pred.merged_into) {
|
|
83
|
+
db.exec('COMMIT');
|
|
84
|
+
return { merged: false, skipReason: 'already_merged' };
|
|
83
85
|
}
|
|
84
86
|
|
|
85
|
-
const
|
|
87
|
+
const self = db
|
|
88
|
+
.prepare('SELECT created_at FROM sessions WHERE session_id = ?')
|
|
89
|
+
.get(newSessionId);
|
|
90
|
+
|
|
91
|
+
// 時系列単調制約: 前任は新セッションより created_at が古いこと。
|
|
92
|
+
// バトンが自分より新しい session を指していたら(異常データ)merge しない。
|
|
93
|
+
if (self && pred.created_at >= self.created_at) {
|
|
94
|
+
db.exec('COMMIT');
|
|
95
|
+
return { merged: false, skipReason: 'predecessor_not_older' };
|
|
96
|
+
}
|
|
86
97
|
|
|
87
98
|
const sk = db
|
|
88
99
|
.prepare('UPDATE skeletons SET session_id = ? WHERE session_id = ?')
|
|
@@ -90,14 +101,12 @@ export function mergePredecessorInto(db, { newSessionId, projectPath }) {
|
|
|
90
101
|
const dt = db
|
|
91
102
|
.prepare('UPDATE details SET session_id = ? WHERE session_id = ?')
|
|
92
103
|
.run(newSessionId, predecessorId);
|
|
93
|
-
// bodies は schema v4 以降のみ存在。v3 DB では 0 changes で害なし
|
|
94
104
|
let bd = { changes: 0 };
|
|
95
105
|
try {
|
|
96
106
|
bd = db
|
|
97
107
|
.prepare('UPDATE bodies SET session_id = ? WHERE session_id = ?')
|
|
98
108
|
.run(newSessionId, predecessorId);
|
|
99
109
|
} catch (err) {
|
|
100
|
-
// bodies テーブルが未作成の場合は無視(schema v3 DB 互換)
|
|
101
110
|
if (!/no such table/i.test(err.message || '')) throw err;
|
|
102
111
|
}
|
|
103
112
|
|
|
@@ -106,7 +115,7 @@ export function mergePredecessorInto(db, { newSessionId, projectPath }) {
|
|
|
106
115
|
predecessorId,
|
|
107
116
|
);
|
|
108
117
|
db.prepare('UPDATE sessions SET updated_at = ? WHERE session_id = ?').run(
|
|
109
|
-
|
|
118
|
+
now,
|
|
110
119
|
newSessionId,
|
|
111
120
|
);
|
|
112
121
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
-
import { resolveMergeTarget,
|
|
4
|
+
import { resolveMergeTarget, mergeSpecificPredecessor } from './session-merger.mjs';
|
|
5
5
|
|
|
6
6
|
function makeDb() {
|
|
7
7
|
const db = new DatabaseSync(':memory:');
|
|
@@ -81,7 +81,7 @@ test('resolveMergeTarget: detects cycle and throws', () => {
|
|
|
81
81
|
assert.throws(() => resolveMergeTarget(db, 'A'), /cycle detected/);
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
-
test('
|
|
84
|
+
test('mergeSpecificPredecessor: moves rows from named predecessor to new session', () => {
|
|
85
85
|
const db = makeDb();
|
|
86
86
|
insertSession(db, 'old', 100);
|
|
87
87
|
db.prepare(
|
|
@@ -90,7 +90,12 @@ test('mergePredecessorInto: picks older predecessor and moves rows', () => {
|
|
|
90
90
|
).run();
|
|
91
91
|
insertSession(db, 'new', 200);
|
|
92
92
|
|
|
93
|
-
const result =
|
|
93
|
+
const result = mergeSpecificPredecessor(db, {
|
|
94
|
+
newSessionId: 'new',
|
|
95
|
+
predecessorId: 'old',
|
|
96
|
+
now: 200,
|
|
97
|
+
});
|
|
98
|
+
|
|
94
99
|
assert.equal(result.merged, true);
|
|
95
100
|
assert.equal(result.predecessorId, 'old');
|
|
96
101
|
assert.equal(result.rowCounts.sk, 1);
|
|
@@ -102,50 +107,76 @@ test('mergePredecessorInto: picks older predecessor and moves rows', () => {
|
|
|
102
107
|
assert.equal(oldRow.merged_into, 'new');
|
|
103
108
|
});
|
|
104
109
|
|
|
105
|
-
test('
|
|
110
|
+
test('mergeSpecificPredecessor: self-handoff is refused', () => {
|
|
111
|
+
const db = makeDb();
|
|
112
|
+
insertSession(db, 'A', 100);
|
|
113
|
+
|
|
114
|
+
const result = mergeSpecificPredecessor(db, {
|
|
115
|
+
newSessionId: 'A',
|
|
116
|
+
predecessorId: 'A',
|
|
117
|
+
now: 100,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
assert.equal(result.merged, false);
|
|
121
|
+
assert.equal(result.skipReason, 'self_handoff');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('mergeSpecificPredecessor: predecessor not in sessions table', () => {
|
|
125
|
+
const db = makeDb();
|
|
126
|
+
insertSession(db, 'new', 200);
|
|
127
|
+
|
|
128
|
+
const result = mergeSpecificPredecessor(db, {
|
|
129
|
+
newSessionId: 'new',
|
|
130
|
+
predecessorId: 'nonexistent',
|
|
131
|
+
now: 200,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
assert.equal(result.merged, false);
|
|
135
|
+
assert.equal(result.skipReason, 'predecessor_not_found');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('mergeSpecificPredecessor: predecessor already merged into third session', () => {
|
|
139
|
+
const db = makeDb();
|
|
140
|
+
insertSession(db, 'old', 100, 'middle');
|
|
141
|
+
insertSession(db, 'middle', 150);
|
|
142
|
+
insertSession(db, 'new', 200);
|
|
143
|
+
|
|
144
|
+
const result = mergeSpecificPredecessor(db, {
|
|
145
|
+
newSessionId: 'new',
|
|
146
|
+
predecessorId: 'old',
|
|
147
|
+
now: 200,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
assert.equal(result.merged, false);
|
|
151
|
+
assert.equal(result.skipReason, 'already_merged');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('mergeSpecificPredecessor: refuses predecessor with created_at >= self', () => {
|
|
106
155
|
const db = makeDb();
|
|
107
|
-
// new session created at t=100
|
|
108
156
|
insertSession(db, 'new', 100);
|
|
109
|
-
// another session was created LATER at t=200 (e.g. a parallel window that started after)
|
|
110
157
|
insertSession(db, 'newer', 200);
|
|
111
158
|
|
|
112
|
-
const result =
|
|
113
|
-
|
|
159
|
+
const result = mergeSpecificPredecessor(db, {
|
|
160
|
+
newSessionId: 'new',
|
|
161
|
+
predecessorId: 'newer',
|
|
162
|
+
now: 100,
|
|
163
|
+
});
|
|
114
164
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
.get('newer');
|
|
118
|
-
assert.equal(newerRow.merged_into, null);
|
|
165
|
+
assert.equal(result.merged, false);
|
|
166
|
+
assert.equal(result.skipReason, 'predecessor_not_older');
|
|
119
167
|
});
|
|
120
168
|
|
|
121
|
-
test('
|
|
169
|
+
test('mergeSpecificPredecessor: updates new session updated_at to provided now', () => {
|
|
122
170
|
const db = makeDb();
|
|
123
|
-
|
|
124
|
-
insertSession(db, '
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
);
|
|
135
|
-
|
|
136
|
-
mergePredecessorInto(db, { newSessionId: 'C', projectPath: '/proj' });
|
|
137
|
-
// C should have absorbed B (A is already merged, so not a candidate)
|
|
138
|
-
assert.equal(
|
|
139
|
-
db.prepare('SELECT merged_into FROM sessions WHERE session_id = ?').get('B').merged_into,
|
|
140
|
-
'C',
|
|
141
|
-
);
|
|
142
|
-
|
|
143
|
-
// Re-firing SessionStart for A must not create a cycle (A cannot absorb newer B or C)
|
|
144
|
-
const redundant = mergePredecessorInto(db, { newSessionId: 'A', projectPath: '/proj' });
|
|
145
|
-
assert.equal(redundant.merged, false);
|
|
146
|
-
|
|
147
|
-
// Verify no cycle: resolveMergeTarget from any node terminates at C
|
|
148
|
-
assert.equal(resolveMergeTarget(db, 'A').target, 'C');
|
|
149
|
-
assert.equal(resolveMergeTarget(db, 'B').target, 'C');
|
|
150
|
-
assert.equal(resolveMergeTarget(db, 'C').target, 'C');
|
|
171
|
+
insertSession(db, 'old', 100);
|
|
172
|
+
insertSession(db, 'new', 200);
|
|
173
|
+
|
|
174
|
+
mergeSpecificPredecessor(db, {
|
|
175
|
+
newSessionId: 'new',
|
|
176
|
+
predecessorId: 'old',
|
|
177
|
+
now: 500,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const newRow = db.prepare('SELECT updated_at FROM sessions WHERE session_id = ?').get('new');
|
|
181
|
+
assert.equal(newRow.updated_at, 500);
|
|
151
182
|
});
|