throughline 0.6.2 → 0.7.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 +79 -4
- package/README.md +77 -25
- package/bin/throughline.mjs +14 -0
- package/docs/00_overview.md +12 -0
- package/docs/02_clear_auto_handoff_plan.md +39 -13
- package/docs/04_public_release_plan.md +2 -1
- package/docs/13_native_factory_diagnostics_plan.md +4 -2
- package/docs/14_observer_completed_turn_feed_plan.md +290 -0
- package/docs/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md +31 -4
- 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/package.json +1 -1
- package/rag/01-hooks/hook-stdout-10k-persisted-output.md +48 -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 +5 -2
- package/src/cli/factory-diagnostics.test.mjs +31 -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/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 +373 -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/factory-diagnostics.mjs +0 -1
- package/src/factory-diagnostics.test.mjs +18 -0
- package/src/haiku-summarizer.mjs +93 -16
- package/src/haiku-summarizer.test.mjs +118 -9
- package/src/handoff-executor.mjs +159 -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 +226 -62
- package/src/resume-context.test.mjs +134 -1
- package/src/runtime-error-store.mjs +74 -32
- package/src/runtime-error-store.test.mjs +51 -3
- 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 +141 -0
- package/src/windows-acl-test-helper.mjs +29 -0
|
@@ -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,140 @@ 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 契約。Windows の stat mode は 0o666 系で chmod 契約を
|
|
161
|
+
// 表現できない (receipt store の Windows private 化は runtime-error-store の
|
|
162
|
+
// ACL 方式に倣う Observer 側の未着手課題)。
|
|
163
|
+
assert.equal(statSync(join(root, 'state')).mode & 0o777, 0o700);
|
|
164
|
+
assert.equal(statSync(storePath).mode & 0o777, 0o600);
|
|
165
|
+
}
|
|
166
|
+
const bytes = readFileSync(storePath, 'utf8');
|
|
167
|
+
assert.doesNotMatch(bytes, /request|answer|\/repo/);
|
|
168
|
+
assert.match(bytes, /"host":"claude"/);
|
|
169
|
+
} finally {
|
|
170
|
+
db.close();
|
|
171
|
+
rmSync(root, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('publishCapturedClaudeCompletionReceipt: incomplete DB pairはreceiptを作らず失敗する', () => {
|
|
176
|
+
const db = makeDb();
|
|
177
|
+
const root = mkdtempSync(join(tmpdir(), 'throughline-turn-receipt-'));
|
|
178
|
+
const storePath = join(root, 'state', 'completed-turn-receipts.json');
|
|
179
|
+
try {
|
|
180
|
+
db.prepare(
|
|
181
|
+
`INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
|
|
182
|
+
VALUES ('target', 'origin', 8, 'user', 'only user', 1, 1)`,
|
|
183
|
+
).run();
|
|
184
|
+
assert.throws(
|
|
185
|
+
() => publishCapturedClaudeCompletionReceipt(db, {
|
|
186
|
+
target: 'target', origin: 'origin', turnNumber: 8, projectPath: '/repo',
|
|
187
|
+
receiptOptions: { storePath },
|
|
188
|
+
}),
|
|
189
|
+
/completed pair was not captured/,
|
|
190
|
+
);
|
|
191
|
+
assert.throws(() => statSync(storePath), { code: 'ENOENT' });
|
|
192
|
+
} finally {
|
|
193
|
+
db.close();
|
|
194
|
+
rmSync(root, { recursive: true, force: true });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
57
198
|
test('countDistinctBodyTurns: 2 ロール行 = 1 ターンとして数える', () => {
|
|
58
199
|
const db = makeDb();
|
|
59
200
|
insertTurn(db, { session: 'S', origin: 'S', turn: 1, createdAt: 100 });
|
|
@@ -27,3 +27,32 @@ if($isDir){[System.IO.Directory]::SetAccessControl($target,$acl)}else{[System.IO
|
|
|
27
27
|
});
|
|
28
28
|
if (result.status !== 0) throw new Error(result.stderr || 'Windows ACL fixture setup failed');
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
export function verifyWindowsPrivateAcl(path, directory = false) {
|
|
32
|
+
if (process.platform !== 'win32') return;
|
|
33
|
+
const script = String.raw`
|
|
34
|
+
$ErrorActionPreference='Stop'
|
|
35
|
+
$target=$env:THROUGHLINE_TEST_ACL_PATH; $isDir=$env:THROUGHLINE_TEST_ACL_DIRECTORY -eq '1'
|
|
36
|
+
$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
|
37
|
+
$acl=if($isDir){[System.IO.Directory]::GetAccessControl($target)}else{[System.IO.File]::GetAccessControl($target)}
|
|
38
|
+
$owner=$acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
|
|
39
|
+
if($owner -ne $sid){exit 41}
|
|
40
|
+
$rules=@($acl.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier]))
|
|
41
|
+
if($rules.Count -ne 1){exit 42}
|
|
42
|
+
$rule=$rules[0]
|
|
43
|
+
if($rule.IdentityReference.Value -ne $sid -or $rule.AccessControlType -ne 'Allow' -or $rule.IsInherited -or ($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl){exit 43}
|
|
44
|
+
`;
|
|
45
|
+
const result = spawnSync('powershell.exe', [
|
|
46
|
+
'-NoProfile', '-NonInteractive', '-Command', script,
|
|
47
|
+
], {
|
|
48
|
+
encoding: 'utf8',
|
|
49
|
+
timeout: 3_000,
|
|
50
|
+
windowsHide: true,
|
|
51
|
+
env: {
|
|
52
|
+
...process.env,
|
|
53
|
+
THROUGHLINE_TEST_ACL_PATH: path,
|
|
54
|
+
THROUGHLINE_TEST_ACL_DIRECTORY: directory ? '1' : '0',
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
if (result.status !== 0) throw new Error(result.stderr || 'Windows ACL fixture verification failed');
|
|
58
|
+
}
|