throughline 0.6.3 → 0.8.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/CHANGELOG.md +101 -1
- package/README.md +86 -28
- package/bin/throughline.mjs +20 -0
- package/docs/00_overview.md +12 -0
- package/docs/02_clear_auto_handoff_plan.md +47 -13
- package/docs/04_public_release_plan.md +1 -0
- package/docs/14_observer_completed_turn_feed_plan.md +290 -0
- package/docs/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md +9 -3
- package/docs/adr/0002-observer-claude-completion-receipt.md +42 -0
- package/docs/adr/0003-observer-completed-chain-cursor.md +34 -0
- package/docs/adr/0004-observer-db-pair-projection.md +71 -0
- package/docs/adr/0005-observer-read-pagination.md +51 -0
- package/docs/adr/0006-observer-page-offset-proof.md +33 -0
- package/docs/adr/0007-observer-read-cli-contract.md +61 -0
- package/docs/adr/0008-observer-wait-deadline-cancel.md +81 -0
- package/docs/adr/0009-observer-integration-regression-and-docs.md +37 -0
- package/docs/adr/0010-observer-o1-phase-acceptance.md +49 -0
- package/docs/adr/0011-observer-o1-control-lane-reconciliation.md +34 -0
- package/docs/adr/0012-claude-stop-transcript-flush-barrier.md +32 -0
- package/docs/adr/0013-observer-read-busy-writer-gate.md +46 -0
- package/docs/adr/0014-two-phase-handoff-ghost-baton.md +112 -0
- package/docs/adr/0015-l1-summarizer-model-effort-ratio.md +81 -0
- package/docs/adr/0016-push-pull-recall-injection.md +93 -0
- package/package.json +1 -1
- package/rag/01-hooks/hook-stdout-10k-persisted-output.md +65 -0
- package/rag/INDEX.md +4 -0
- package/src/auditor-context.mjs +92 -11
- package/src/auditor-context.test.mjs +116 -1
- package/src/baton.mjs +27 -7
- package/src/baton.test.mjs +44 -0
- package/src/body-digest.mjs +9 -0
- package/src/cli/auditor-context.test.mjs +1 -1
- package/src/cli/factory-diagnostics.mjs +1 -0
- package/src/cli/factory-diagnostics.test.mjs +6 -2
- package/src/cli/observer-read.mjs +73 -0
- package/src/cli/observer-read.test.mjs +93 -0
- package/src/cli/observer-wait.mjs +123 -0
- package/src/cli/observer-wait.test.mjs +167 -0
- package/src/cli/recall.mjs +279 -0
- package/src/cli/recall.test.mjs +269 -0
- package/src/codex-rollout-memory.mjs +13 -0
- package/src/codex-rollout-memory.test.mjs +27 -0
- package/src/codex-thread-index.mjs +1 -1
- package/src/codex-thread-index.test.mjs +18 -0
- package/src/completed-turn-receipts.mjs +374 -0
- package/src/completed-turn-receipts.test.mjs +186 -0
- package/src/db-schema.test.mjs +9 -2
- package/src/db.mjs +20 -1
- package/src/decision-log.mjs +24 -0
- package/src/haiku-summarizer.mjs +93 -16
- package/src/haiku-summarizer.test.mjs +118 -9
- package/src/handoff-executor.mjs +161 -0
- package/src/hook-entrypoints.test.mjs +192 -12
- package/src/observer-codex-projection.test.mjs +49 -0
- package/src/observer-turn-feed.mjs +392 -0
- package/src/observer-turn-feed.test.mjs +339 -0
- package/src/observer-turn-wait.mjs +102 -0
- package/src/observer-turn-wait.test.mjs +122 -0
- package/src/pending-handoff.mjs +96 -0
- package/src/pending-handoff.test.mjs +107 -0
- package/src/prompt-submit.mjs +46 -1
- package/src/resume-context.mjs +337 -60
- package/src/resume-context.test.mjs +228 -1
- package/src/runtime-error-store.mjs +2 -1
- package/src/runtime-error-store.test.mjs +1 -1
- package/src/session-start.mjs +70 -233
- package/src/transcript-reader.mjs +32 -0
- package/src/turn-backfill.mjs +3 -2
- package/src/turn-backfill.test.mjs +10 -4
- package/src/turn-processor.mjs +90 -1
- package/src/turn-processor.test.mjs +142 -0
- package/src/windows-acl-test-helper.mjs +2 -2
package/src/turn-backfill.mjs
CHANGED
|
@@ -59,12 +59,12 @@ export function deriveTranscriptPath(projectPath, sessionId) {
|
|
|
59
59
|
* @param {string} opts.originSessionId transcript を所有する origin session_id
|
|
60
60
|
* @param {string|null|undefined} opts.transcriptPath
|
|
61
61
|
* @param {number} opts.now timestamp 欠損時の fallback epoch ms
|
|
62
|
-
* @returns {{groups: number, insertedTurns: number, skippedExisting: number, lastTurnNumber: number|null}}
|
|
62
|
+
* @returns {{groups: number, insertedTurns: number, skippedExisting: number, lastTurnNumber: number|null, turnNumbers: number[]}}
|
|
63
63
|
*/
|
|
64
64
|
export function backfillBodies(db, { targetSessionId, originSessionId, transcriptPath, now }) {
|
|
65
65
|
const groups = getLogicalTurnGroups(transcriptPath);
|
|
66
66
|
if (groups.length === 0) {
|
|
67
|
-
return { groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null };
|
|
67
|
+
return { groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null, turnNumbers: [] };
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
const existing = new Set(
|
|
@@ -127,5 +127,6 @@ export function backfillBodies(db, { targetSessionId, originSessionId, transcrip
|
|
|
127
127
|
insertedTurns,
|
|
128
128
|
skippedExisting,
|
|
129
129
|
lastTurnNumber: groups[groups.length - 1].representative.index,
|
|
130
|
+
turnNumbers: groups.map((group) => group.representative.index),
|
|
130
131
|
};
|
|
131
132
|
}
|
|
@@ -58,7 +58,13 @@ test('backfillBodies: multi-fragment group uses last fragment index for both bod
|
|
|
58
58
|
const result = backfillBodies(db, {
|
|
59
59
|
targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999,
|
|
60
60
|
});
|
|
61
|
-
assert.deepEqual(result, {
|
|
61
|
+
assert.deepEqual(result, {
|
|
62
|
+
groups: 2,
|
|
63
|
+
insertedTurns: 2,
|
|
64
|
+
skippedExisting: 0,
|
|
65
|
+
lastTurnNumber: 4,
|
|
66
|
+
turnNumbers: [2, 4],
|
|
67
|
+
});
|
|
62
68
|
assert.deepEqual(
|
|
63
69
|
db.prepare('SELECT turn_number, role, text FROM bodies ORDER BY turn_number, role').all().map((row) => ({ ...row })),
|
|
64
70
|
[
|
|
@@ -103,7 +109,7 @@ test('backfillBodies: user-only group is dropped', () => {
|
|
|
103
109
|
backfillBodies(db, {
|
|
104
110
|
targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999,
|
|
105
111
|
}),
|
|
106
|
-
{ groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null },
|
|
112
|
+
{ groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null, turnNumbers: [] },
|
|
107
113
|
);
|
|
108
114
|
});
|
|
109
115
|
});
|
|
@@ -193,13 +199,13 @@ test('backfillBodies: sidechain entries and missing or empty paths produce no gr
|
|
|
193
199
|
backfillBodies(db, {
|
|
194
200
|
targetSessionId: 'target', originSessionId: 'origin', transcriptPath: null, now: 999,
|
|
195
201
|
}),
|
|
196
|
-
{ groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null },
|
|
202
|
+
{ groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null, turnNumbers: [] },
|
|
197
203
|
);
|
|
198
204
|
assert.deepEqual(
|
|
199
205
|
backfillBodies(db, {
|
|
200
206
|
targetSessionId: 'target', originSessionId: 'origin', transcriptPath: '', now: 999,
|
|
201
207
|
}),
|
|
202
|
-
{ groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null },
|
|
208
|
+
{ groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null, turnNumbers: [] },
|
|
203
209
|
);
|
|
204
210
|
},
|
|
205
211
|
);
|
package/src/turn-processor.mjs
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
import { getDb } from './db.mjs';
|
|
34
34
|
import {
|
|
35
35
|
readRawEntries,
|
|
36
|
+
readLatestLogicalTurnCompletion,
|
|
36
37
|
sliceCurrentTurnEntries,
|
|
37
38
|
extractDetailBlocks,
|
|
38
39
|
} from './transcript-reader.mjs';
|
|
@@ -44,9 +45,48 @@ import { ensureMonitorTaskFile } from './vscode-task.mjs';
|
|
|
44
45
|
import { readLatestUsage } from './transcript-usage.mjs';
|
|
45
46
|
import { pathToFileURL } from 'node:url';
|
|
46
47
|
import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
|
|
48
|
+
import { writeCompletedTurnReceipt } from './completed-turn-receipts.mjs';
|
|
47
49
|
|
|
48
50
|
/** 直近 N ターンは bodies を生で残し、それより古いものだけ L1 要約する。 */
|
|
49
51
|
export const L2_WINDOW = 20;
|
|
52
|
+
export const CLAUDE_STOP_TRANSCRIPT_FLUSH_TIMEOUT_MS = 2_000;
|
|
53
|
+
export const CLAUDE_STOP_TRANSCRIPT_FLUSH_INTERVAL_MS = 25;
|
|
54
|
+
|
|
55
|
+
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Claude Stop payloadのassistant identityがlatest user groupへ永続化されるまで待つ。
|
|
59
|
+
* markerは本文ソースにせず、transcript可視化のbarrierにだけ使う。
|
|
60
|
+
*/
|
|
61
|
+
export async function waitForClaudeStopTranscriptFlush({
|
|
62
|
+
transcriptPath,
|
|
63
|
+
lastAssistantMessage,
|
|
64
|
+
timeoutMs = CLAUDE_STOP_TRANSCRIPT_FLUSH_TIMEOUT_MS,
|
|
65
|
+
intervalMs = CLAUDE_STOP_TRANSCRIPT_FLUSH_INTERVAL_MS,
|
|
66
|
+
readCompletion = readLatestLogicalTurnCompletion,
|
|
67
|
+
now = Date.now,
|
|
68
|
+
wait = delay,
|
|
69
|
+
}) {
|
|
70
|
+
if (typeof lastAssistantMessage !== 'string' || lastAssistantMessage.length === 0) {
|
|
71
|
+
return { status: 'marker_unavailable' };
|
|
72
|
+
}
|
|
73
|
+
const deadline = now() + timeoutMs;
|
|
74
|
+
for (;;) {
|
|
75
|
+
const completion = readCompletion(transcriptPath);
|
|
76
|
+
if (completion?.assistantContent === lastAssistantMessage) {
|
|
77
|
+
return {
|
|
78
|
+
status: 'ready',
|
|
79
|
+
userTurnNumber: completion.userTurnNumber,
|
|
80
|
+
assistantTurnNumber: completion.assistantTurnNumber,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const remaining = deadline - now();
|
|
84
|
+
if (remaining <= 0) {
|
|
85
|
+
throw new Error('Claude Stop transcript completion was not visible before deadline');
|
|
86
|
+
}
|
|
87
|
+
await wait(Math.min(intervalMs, remaining));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
50
90
|
|
|
51
91
|
/**
|
|
52
92
|
* target 配下の distinct (origin_session_id, turn_number) ターン数を返す。
|
|
@@ -93,6 +133,36 @@ export function pickOldestUnsummarizedTurn(db, target) {
|
|
|
93
133
|
return row ?? null;
|
|
94
134
|
}
|
|
95
135
|
|
|
136
|
+
/**
|
|
137
|
+
* L2 commit 済みの completed pair だけを Claude receipt store へ publish する。
|
|
138
|
+
* L1/L3 より前に呼び、receipt の失敗は呼び出し元へそのまま伝える。
|
|
139
|
+
*/
|
|
140
|
+
export function publishCapturedClaudeCompletionReceipt(db, {
|
|
141
|
+
target,
|
|
142
|
+
origin,
|
|
143
|
+
turnNumber,
|
|
144
|
+
projectPath,
|
|
145
|
+
receiptOptions,
|
|
146
|
+
}) {
|
|
147
|
+
const completedPair = db.prepare(
|
|
148
|
+
`SELECT role, text, created_at FROM bodies
|
|
149
|
+
WHERE session_id = ? AND origin_session_id = ? AND turn_number = ? AND role IN ('user', 'assistant')`,
|
|
150
|
+
).all(target, origin, turnNumber);
|
|
151
|
+
const completedUser = completedPair.find((row) => row.role === 'user');
|
|
152
|
+
const completedAssistant = completedPair.find((row) => row.role === 'assistant');
|
|
153
|
+
if (!completedUser || !completedAssistant) {
|
|
154
|
+
throw new Error('completed pair was not captured before receipt publication');
|
|
155
|
+
}
|
|
156
|
+
return writeCompletedTurnReceipt({
|
|
157
|
+
projectPath,
|
|
158
|
+
targetSessionId: target,
|
|
159
|
+
originSessionId: origin,
|
|
160
|
+
userBody: completedUser.text,
|
|
161
|
+
assistantBody: completedAssistant.text,
|
|
162
|
+
completedAt: completedAssistant.created_at,
|
|
163
|
+
}, receiptOptions);
|
|
164
|
+
}
|
|
165
|
+
|
|
96
166
|
/**
|
|
97
167
|
* user と assistant のペアを結合して L2 要約用テキストを作る。
|
|
98
168
|
* @param {{content: string} | null} userTurn
|
|
@@ -121,7 +191,7 @@ export async function run() {
|
|
|
121
191
|
});
|
|
122
192
|
|
|
123
193
|
const payload = JSON.parse(raw || '{}');
|
|
124
|
-
const { session_id, transcript_path, cwd } = payload;
|
|
194
|
+
const { session_id, transcript_path, cwd, last_assistant_message } = payload;
|
|
125
195
|
if (!session_id) throw new Error('Missing session_id in Stop payload');
|
|
126
196
|
|
|
127
197
|
// VSCode で開かれたプロジェクトに .vscode/tasks.json を自動プロビジョニングする。
|
|
@@ -134,6 +204,11 @@ export async function run() {
|
|
|
134
204
|
process.stderr.write(`[vscode-task] ${msg}\n`);
|
|
135
205
|
}
|
|
136
206
|
|
|
207
|
+
await waitForClaudeStopTranscriptFlush({
|
|
208
|
+
transcriptPath: transcript_path,
|
|
209
|
+
lastAssistantMessage: last_assistant_message,
|
|
210
|
+
});
|
|
211
|
+
|
|
137
212
|
// Stop hook 時点で state ファイルを更新 → token-monitor の「アクティブ行」判定が
|
|
138
213
|
// アシスタント応答終了時刻まで追従する
|
|
139
214
|
writeSessionState({
|
|
@@ -190,6 +265,20 @@ export async function run() {
|
|
|
190
265
|
|
|
191
266
|
const turnNumber = backfill.lastTurnNumber;
|
|
192
267
|
|
|
268
|
+
// Claude Stop hook が completion boundary であることを受け、L2 の user/assistant
|
|
269
|
+
// pair が DB に commit 済みであることを確認してから全logical turnのreceiptを時系列publishする。
|
|
270
|
+
// 過去のStopでDBだけ回収済みだったpairもreceipt storeの冪等性で穴埋めする。
|
|
271
|
+
// receipt failure は Stop hook の failure として上位へ伝播させる。L1/L3/usage は
|
|
272
|
+
// receipt 後の派生処理なので、そこで失敗しても completed pair を取り消さない。
|
|
273
|
+
for (const completedTurnNumber of backfill.turnNumbers) {
|
|
274
|
+
publishCapturedClaudeCompletionReceipt(db, {
|
|
275
|
+
target,
|
|
276
|
+
origin,
|
|
277
|
+
turnNumber: completedTurnNumber,
|
|
278
|
+
projectPath: cwd ?? process.cwd(),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
193
282
|
// L1 = 遅延要約。target 配下の bodies ターン数 (distinct origin×turn) が
|
|
194
283
|
// WINDOW を超えていたら、最古の未要約ターンを 1 件だけ要約する。
|
|
195
284
|
// 20 ターン以内で終わる作業では Haiku コストゼロ。
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { appendFileSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
4
7
|
import {
|
|
5
8
|
L2_WINDOW,
|
|
9
|
+
CLAUDE_STOP_TRANSCRIPT_FLUSH_INTERVAL_MS,
|
|
10
|
+
CLAUDE_STOP_TRANSCRIPT_FLUSH_TIMEOUT_MS,
|
|
6
11
|
countDistinctBodyTurns,
|
|
7
12
|
pickOldestUnsummarizedTurn,
|
|
13
|
+
publishCapturedClaudeCompletionReceipt,
|
|
14
|
+
waitForClaudeStopTranscriptFlush,
|
|
8
15
|
} from './turn-processor.mjs';
|
|
9
16
|
|
|
10
17
|
function makeDb() {
|
|
@@ -54,6 +61,141 @@ test('L2_WINDOW is 20', () => {
|
|
|
54
61
|
assert.equal(L2_WINDOW, 20);
|
|
55
62
|
});
|
|
56
63
|
|
|
64
|
+
test('Claude Stop flush barrierはlatest userの遅延assistantを待ち、過去の同文answerを採用しない', async () => {
|
|
65
|
+
const root = mkdtempSync(join(tmpdir(), 'throughline-stop-flush-'));
|
|
66
|
+
const transcriptPath = join(root, 'transcript.jsonl');
|
|
67
|
+
const answer = 'same answer';
|
|
68
|
+
let elapsed = 0;
|
|
69
|
+
let waits = 0;
|
|
70
|
+
try {
|
|
71
|
+
writeFileSync(
|
|
72
|
+
transcriptPath,
|
|
73
|
+
[
|
|
74
|
+
{ type: 'user', message: { role: 'user', content: 'old question' } },
|
|
75
|
+
{ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: answer }] } },
|
|
76
|
+
{ type: 'user', message: { role: 'user', content: 'current question' } },
|
|
77
|
+
].map((entry) => JSON.stringify(entry)).join('\n'),
|
|
78
|
+
'utf8',
|
|
79
|
+
);
|
|
80
|
+
const result = await waitForClaudeStopTranscriptFlush({
|
|
81
|
+
transcriptPath,
|
|
82
|
+
lastAssistantMessage: answer,
|
|
83
|
+
timeoutMs: 100,
|
|
84
|
+
intervalMs: 10,
|
|
85
|
+
now: () => elapsed,
|
|
86
|
+
wait: async (milliseconds) => {
|
|
87
|
+
elapsed += milliseconds;
|
|
88
|
+
waits++;
|
|
89
|
+
if (waits === 1) {
|
|
90
|
+
appendFileSync(
|
|
91
|
+
transcriptPath,
|
|
92
|
+
`\n${JSON.stringify({
|
|
93
|
+
type: 'assistant',
|
|
94
|
+
message: { role: 'assistant', content: [{ type: 'text', text: answer }] },
|
|
95
|
+
})}`,
|
|
96
|
+
'utf8',
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
assert.deepEqual(result, { status: 'ready', userTurnNumber: 2, assistantTurnNumber: 3 });
|
|
102
|
+
assert.equal(waits, 1, 'past identical answer must not satisfy the current user group');
|
|
103
|
+
} finally {
|
|
104
|
+
rmSync(root, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('Claude Stop flush barrierはmarker不一致をdeadlineで明示失敗する', async () => {
|
|
109
|
+
let elapsed = 0;
|
|
110
|
+
await assert.rejects(
|
|
111
|
+
waitForClaudeStopTranscriptFlush({
|
|
112
|
+
transcriptPath: '/missing',
|
|
113
|
+
lastAssistantMessage: 'expected',
|
|
114
|
+
timeoutMs: 30,
|
|
115
|
+
intervalMs: 10,
|
|
116
|
+
readCompletion: () => ({ userTurnNumber: 4, assistantTurnNumber: null, assistantContent: null }),
|
|
117
|
+
now: () => elapsed,
|
|
118
|
+
wait: async (milliseconds) => { elapsed += milliseconds; },
|
|
119
|
+
}),
|
|
120
|
+
/not visible before deadline/,
|
|
121
|
+
);
|
|
122
|
+
assert.equal(elapsed, 30);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('Claude Stop flush barrierはmarkerなし旧payloadをone-shot互換へ残す', async () => {
|
|
126
|
+
let reads = 0;
|
|
127
|
+
const result = await waitForClaudeStopTranscriptFlush({
|
|
128
|
+
transcriptPath: '/unused',
|
|
129
|
+
lastAssistantMessage: undefined,
|
|
130
|
+
readCompletion: () => { reads++; return null; },
|
|
131
|
+
});
|
|
132
|
+
assert.deepEqual(result, { status: 'marker_unavailable' });
|
|
133
|
+
assert.equal(reads, 0);
|
|
134
|
+
assert.equal(CLAUDE_STOP_TRANSCRIPT_FLUSH_TIMEOUT_MS, 2_000);
|
|
135
|
+
assert.equal(CLAUDE_STOP_TRANSCRIPT_FLUSH_INTERVAL_MS, 25);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('publishCapturedClaudeCompletionReceipt: L2 capture済みpairをL1/L3より先にprivate receiptへ固定する', () => {
|
|
139
|
+
const db = makeDb();
|
|
140
|
+
const root = mkdtempSync(join(tmpdir(), 'throughline-turn-receipt-'));
|
|
141
|
+
const storePath = join(root, 'state', 'completed-turn-receipts.json');
|
|
142
|
+
try {
|
|
143
|
+
insertTurn(db, { session: 'target', origin: 'origin', turn: 7, createdAt: 1234 });
|
|
144
|
+
db.prepare(
|
|
145
|
+
`UPDATE bodies SET text = CASE role WHEN 'user' THEN ' request\r\n' ELSE 'answer' END
|
|
146
|
+
WHERE session_id = 'target' AND origin_session_id = 'origin' AND turn_number = 7`,
|
|
147
|
+
).run();
|
|
148
|
+
const first = publishCapturedClaudeCompletionReceipt(db, {
|
|
149
|
+
target: 'target', origin: 'origin', turnNumber: 7, projectPath: '/repo',
|
|
150
|
+
receiptOptions: { storePath },
|
|
151
|
+
});
|
|
152
|
+
const second = publishCapturedClaudeCompletionReceipt(db, {
|
|
153
|
+
target: 'target', origin: 'origin', turnNumber: 7, projectPath: '/repo',
|
|
154
|
+
receiptOptions: { storePath },
|
|
155
|
+
});
|
|
156
|
+
assert.equal(first.sequence, 1);
|
|
157
|
+
assert.deepEqual(second, first, 'Stop retry must return the original receipt');
|
|
158
|
+
assert.equal(first.completed_at, 1234);
|
|
159
|
+
if (process.platform !== 'win32') {
|
|
160
|
+
// POSIX permission 契約 (0700/0600) の表現形式検査。Windows の秘匿は stat mode
|
|
161
|
+
// ではなく owner-only ACL で担保され、writeCompletedTurnReceipt 内部の
|
|
162
|
+
// applyAndVerifyWindowsAcl が書き込みのたびに apply + verify して失敗を throw
|
|
163
|
+
// するため、win32 ではこのテストが receipt を作れた時点で ACL 検証済み。
|
|
164
|
+
assert.equal(statSync(join(root, 'state')).mode & 0o777, 0o700);
|
|
165
|
+
assert.equal(statSync(storePath).mode & 0o777, 0o600);
|
|
166
|
+
}
|
|
167
|
+
const bytes = readFileSync(storePath, 'utf8');
|
|
168
|
+
assert.doesNotMatch(bytes, /request|answer|\/repo/);
|
|
169
|
+
assert.match(bytes, /"host":"claude"/);
|
|
170
|
+
} finally {
|
|
171
|
+
db.close();
|
|
172
|
+
rmSync(root, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('publishCapturedClaudeCompletionReceipt: incomplete DB pairはreceiptを作らず失敗する', () => {
|
|
177
|
+
const db = makeDb();
|
|
178
|
+
const root = mkdtempSync(join(tmpdir(), 'throughline-turn-receipt-'));
|
|
179
|
+
const storePath = join(root, 'state', 'completed-turn-receipts.json');
|
|
180
|
+
try {
|
|
181
|
+
db.prepare(
|
|
182
|
+
`INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
|
|
183
|
+
VALUES ('target', 'origin', 8, 'user', 'only user', 1, 1)`,
|
|
184
|
+
).run();
|
|
185
|
+
assert.throws(
|
|
186
|
+
() => publishCapturedClaudeCompletionReceipt(db, {
|
|
187
|
+
target: 'target', origin: 'origin', turnNumber: 8, projectPath: '/repo',
|
|
188
|
+
receiptOptions: { storePath },
|
|
189
|
+
}),
|
|
190
|
+
/completed pair was not captured/,
|
|
191
|
+
);
|
|
192
|
+
assert.throws(() => statSync(storePath), { code: 'ENOENT' });
|
|
193
|
+
} finally {
|
|
194
|
+
db.close();
|
|
195
|
+
rmSync(root, { recursive: true, force: true });
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
57
199
|
test('countDistinctBodyTurns: 2 ロール行 = 1 ターンとして数える', () => {
|
|
58
200
|
const db = makeDb();
|
|
59
201
|
insertTurn(db, { session: 'S', origin: 'S', turn: 1, createdAt: 100 });
|
|
@@ -17,7 +17,7 @@ if($isDir){[System.IO.Directory]::SetAccessControl($target,$acl)}else{[System.IO
|
|
|
17
17
|
'-NoProfile', '-NonInteractive', '-Command', script,
|
|
18
18
|
], {
|
|
19
19
|
encoding: 'utf8',
|
|
20
|
-
timeout:
|
|
20
|
+
timeout: 15_000,
|
|
21
21
|
windowsHide: true,
|
|
22
22
|
env: {
|
|
23
23
|
...process.env,
|
|
@@ -46,7 +46,7 @@ if($rule.IdentityReference.Value -ne $sid -or $rule.AccessControlType -ne 'Allow
|
|
|
46
46
|
'-NoProfile', '-NonInteractive', '-Command', script,
|
|
47
47
|
], {
|
|
48
48
|
encoding: 'utf8',
|
|
49
|
-
timeout:
|
|
49
|
+
timeout: 15_000,
|
|
50
50
|
windowsHide: true,
|
|
51
51
|
env: {
|
|
52
52
|
...process.env,
|