throughline 0.6.3 → 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 +61 -0
- package/README.md +73 -24
- 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 +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/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 +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/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/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/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
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
7
|
+
import { decodeObserverCursor, encodeObserverCursor, readObserverTurnPage, resolveObserverTurnFeed } from './observer-turn-feed.mjs';
|
|
8
|
+
import { writeCompletedTurnReceipt } from './completed-turn-receipts.mjs';
|
|
9
|
+
import { hashAuditorBody } from './body-digest.mjs';
|
|
10
|
+
|
|
11
|
+
function fixture() {
|
|
12
|
+
const root = mkdtempSync(join(tmpdir(), 'tl-observer-feed-'));
|
|
13
|
+
const project = join(root, 'project'); const other = join(root, 'other'); const home = join(root, 'codex');
|
|
14
|
+
mkdirSync(project); mkdirSync(other); mkdirSync(home);
|
|
15
|
+
// LOCALAPPDATA も state へ向け、win32 (LOCALAPPDATA layout) と POSIX (XDG layout) で
|
|
16
|
+
// receipt store の実パスを一致させる (テスト内の削除・検査パスを OS 非依存にする)
|
|
17
|
+
return { root, project, other, home, receiptOptions: { env: { HOME: root, USERPROFILE: root, XDG_STATE_HOME: join(root, 'state'), LOCALAPPDATA: join(root, 'state') } } };
|
|
18
|
+
}
|
|
19
|
+
function writeRollout(home, project, id, events) {
|
|
20
|
+
const dir = join(home, 'sessions', '2026', '07', '15'); mkdirSync(dir, { recursive: true });
|
|
21
|
+
const path = join(dir, `rollout-2026-07-15T00-00-00-${id}.jsonl`);
|
|
22
|
+
const rows = [{ type: 'session_meta', timestamp: '2026-07-15T00:00:00.000Z', payload: { id, cwd: project } }, ...events];
|
|
23
|
+
writeFileSync(path, rows.map(JSON.stringify).join('\n'));
|
|
24
|
+
return path;
|
|
25
|
+
}
|
|
26
|
+
function event(type, message, timestamp) { return { type: 'event_msg', timestamp, payload: { type, ...(message === undefined ? {} : { message }) } }; }
|
|
27
|
+
function completeEvents(at = '2026-07-15T00:01:00.000Z') { return [event('user_message', 'request', '2026-07-15T00:00:01.000Z'), event('task_started', undefined, '2026-07-15T00:00:02.000Z'), event('agent_message', 'answer', '2026-07-15T00:00:03.000Z'), event('task_complete', undefined, at)]; }
|
|
28
|
+
function createProjectionDb(path, project, sessionId) {
|
|
29
|
+
const db = new DatabaseSync(path);
|
|
30
|
+
db.exec(`PRAGMA user_version = 9;
|
|
31
|
+
CREATE TABLE sessions (session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL);
|
|
32
|
+
CREATE TABLE bodies (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, origin_session_id TEXT NOT NULL, turn_number INTEGER NOT NULL, role TEXT NOT NULL, text TEXT NOT NULL, created_at INTEGER NOT NULL);`);
|
|
33
|
+
db.prepare('INSERT INTO sessions (session_id, project_path) VALUES (?, ?)').run(sessionId, project);
|
|
34
|
+
return db;
|
|
35
|
+
}
|
|
36
|
+
function addClaudeTurn(box, db, { sessionId = 'claude-session', origin, turn, user, assistant, at }) {
|
|
37
|
+
writeCompletedTurnReceipt({
|
|
38
|
+
projectPath: box.project, targetSessionId: sessionId, originSessionId: origin,
|
|
39
|
+
userBody: user, assistantBody: assistant, completedAt: at,
|
|
40
|
+
}, box.receiptOptions);
|
|
41
|
+
if (!db) return;
|
|
42
|
+
const insert = db.prepare('INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, created_at) VALUES (?, ?, ?, ?, ?, ?)');
|
|
43
|
+
insert.run(sessionId, origin, turn, 'user', user, at * 2);
|
|
44
|
+
insert.run(sessionId, origin, turn, 'assistant', assistant, at * 2 + 1);
|
|
45
|
+
}
|
|
46
|
+
function rewritePageToken(token, mutate) {
|
|
47
|
+
const prefix = 'tlp1.';
|
|
48
|
+
const value = JSON.parse(Buffer.from(token.slice(prefix.length), 'base64url').toString('utf8'));
|
|
49
|
+
mutate(value);
|
|
50
|
+
return `${prefix}${Buffer.from(JSON.stringify(value), 'utf8').toString('base64url')}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
test('observer feed: in-flight DB recordとStop continuationは最終task_completeまでchainを進めない', () => {
|
|
54
|
+
const box = fixture();
|
|
55
|
+
const dbPath = join(box.root, 'throughline.db');
|
|
56
|
+
let db;
|
|
57
|
+
try {
|
|
58
|
+
const id = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
|
|
59
|
+
const sessionId = `codex:${id}`;
|
|
60
|
+
db = createProjectionDb(dbPath, box.project, sessionId);
|
|
61
|
+
const insert = db.prepare('INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, created_at) VALUES (?, ?, ?, ?, ?, ?)');
|
|
62
|
+
insert.run(sessionId, sessionId, 1, 'user', 'in-flight request', 1);
|
|
63
|
+
insert.run(sessionId, sessionId, 1, 'assistant', 'in-flight answer', 2);
|
|
64
|
+
writeRollout(box.home, box.project, id, completeEvents().slice(0, 3));
|
|
65
|
+
const dbAhead = resolveObserverTurnFeed({ projectPath: box.project, codexHome: box.home, receiptOptions: box.receiptOptions, dbPath });
|
|
66
|
+
assert.equal(dbAhead.status, 'snapshot');
|
|
67
|
+
assert.equal(dbAhead.chain.length, 0, 'DBにpair本文があってもrollout未完了turnをcompleted feedへ出さない');
|
|
68
|
+
const emptyCursor = decodeObserverCursor(dbAhead.throughCursor);
|
|
69
|
+
assert.equal(emptyCursor.host, null);
|
|
70
|
+
assert.equal(emptyCursor.thread_sha256, null);
|
|
71
|
+
assert.equal(emptyCursor.length, 0);
|
|
72
|
+
|
|
73
|
+
writeRollout(box.home, box.project, id, completeEvents());
|
|
74
|
+
const baseline = resolveObserverTurnFeed({ projectPath: box.project, codexHome: box.home, receiptOptions: box.receiptOptions });
|
|
75
|
+
writeRollout(box.home, box.project, id, [
|
|
76
|
+
...completeEvents(),
|
|
77
|
+
event('user_message', 'continuation request', '2026-07-15T00:01:01.000Z'),
|
|
78
|
+
event('task_started', undefined, '2026-07-15T00:01:02.000Z'),
|
|
79
|
+
event('agent_message', 'Stop continuation', '2026-07-15T00:01:03.000Z'),
|
|
80
|
+
]);
|
|
81
|
+
const beforeFinalComplete = resolveObserverTurnFeed({ projectPath: box.project, cursor: baseline.throughCursor, codexHome: box.home, receiptOptions: box.receiptOptions });
|
|
82
|
+
assert.equal(beforeFinalComplete.status, 'unchanged');
|
|
83
|
+
assert.equal(beforeFinalComplete.throughCursor, baseline.throughCursor);
|
|
84
|
+
assert.equal(beforeFinalComplete.chain.length, 1);
|
|
85
|
+
|
|
86
|
+
writeRollout(box.home, box.project, id, [
|
|
87
|
+
...completeEvents(),
|
|
88
|
+
event('user_message', 'continuation request', '2026-07-15T00:01:01.000Z'),
|
|
89
|
+
event('task_started', undefined, '2026-07-15T00:01:02.000Z'),
|
|
90
|
+
event('agent_message', 'Stop continuation', '2026-07-15T00:01:03.000Z'),
|
|
91
|
+
event('task_complete', undefined, '2026-07-15T00:01:04.000Z'),
|
|
92
|
+
]);
|
|
93
|
+
const afterFinalComplete = resolveObserverTurnFeed({ projectPath: box.project, cursor: baseline.throughCursor, codexHome: box.home, receiptOptions: box.receiptOptions });
|
|
94
|
+
assert.equal(afterFinalComplete.status, 'append');
|
|
95
|
+
assert.notEqual(afterFinalComplete.throughCursor, baseline.throughCursor);
|
|
96
|
+
assert.equal(afterFinalComplete.chain.length, 2);
|
|
97
|
+
} finally {
|
|
98
|
+
db?.close();
|
|
99
|
+
rmSync(box.root, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('observer feed: same-thread append, rollback prefix change, and project separation', () => {
|
|
104
|
+
const box = fixture();
|
|
105
|
+
try {
|
|
106
|
+
const id = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
|
|
107
|
+
writeRollout(box.home, box.project, id, completeEvents());
|
|
108
|
+
const first = resolveObserverTurnFeed({ projectPath: box.project, codexHome: box.home, receiptOptions: box.receiptOptions });
|
|
109
|
+
writeRollout(box.home, box.project, id, [...completeEvents(), ...completeEvents('2026-07-15T00:02:00.000Z')]);
|
|
110
|
+
assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: first.throughCursor, codexHome: box.home, receiptOptions: box.receiptOptions }).status, 'append');
|
|
111
|
+
writeRollout(box.home, box.project, id, [event('user_message', 'changed', '2026-07-15T00:00:01.000Z'), ...completeEvents().slice(1)]);
|
|
112
|
+
assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: first.throughCursor, codexHome: box.home, receiptOptions: box.receiptOptions }).status, 'resync_required');
|
|
113
|
+
const child = join(box.project, 'child'); mkdirSync(child);
|
|
114
|
+
const childId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f7';
|
|
115
|
+
writeRollout(box.home, child, childId, completeEvents('2026-07-15T00:03:00.000Z'));
|
|
116
|
+
assert.equal(resolveObserverTurnFeed({ projectPath: box.project, codexHome: box.home, receiptOptions: box.receiptOptions }).chain.at(-1).completed_at, Date.parse('2026-07-15T00:03:00.000Z'), 'root配下cwdを含む');
|
|
117
|
+
assert.equal(resolveObserverTurnFeed({ projectPath: box.other, codexHome: box.home, receiptOptions: box.receiptOptions }).chain.length, 0, '別projectは除外');
|
|
118
|
+
assert.equal(resolveObserverTurnFeed({ projectPath: box.other, cursor: first.throughCursor, codexHome: box.home, receiptOptions: box.receiptOptions }).status, 'resync_required');
|
|
119
|
+
} finally { rmSync(box.root, { recursive: true, force: true }); }
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('observer feed: Claude history floor, host/thread switch, cross-host tie, and opaque cursor', () => {
|
|
123
|
+
const box = fixture();
|
|
124
|
+
try {
|
|
125
|
+
const options = box.receiptOptions;
|
|
126
|
+
for (let i = 1; i <= 257; i++) writeCompletedTurnReceipt({ projectPath: box.project, targetSessionId: 'claude-session', originSessionId: `o${i}`, userBody: `u${i}`, assistantBody: `a${i}`, completedAt: i }, options);
|
|
127
|
+
const latest = resolveObserverTurnFeed({ projectPath: box.project, receiptOptions: options, codexHome: box.home });
|
|
128
|
+
const decoded = decodeObserverCursor(latest.throughCursor);
|
|
129
|
+
const beforeFloor = encodeObserverCursor({ ...decoded, history_floor: 1 });
|
|
130
|
+
assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: beforeFloor, receiptOptions: options, codexHome: box.home }).status, 'resync_required');
|
|
131
|
+
assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: `${latest.throughCursor}x`, receiptOptions: options, codexHome: box.home }).status, 'resync_required');
|
|
132
|
+
const oldVersion = `tlc1.${Buffer.from(JSON.stringify({ ...decoded, schema: 'throughline.observer_cursor.v0' })).toString('base64url')}`;
|
|
133
|
+
assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: oldVersion, receiptOptions: options, codexHome: box.home }).status, 'resync_required');
|
|
134
|
+
const changedPrefix = encodeObserverCursor({ ...decoded, prefix_sha256: 'f'.repeat(64) });
|
|
135
|
+
assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: changedPrefix, receiptOptions: options, codexHome: box.home }).status, 'resync_required');
|
|
136
|
+
assert.throws(() => encodeObserverCursor({ ...decoded, length: 0 }), /cursor invalid/);
|
|
137
|
+
assert.doesNotMatch(JSON.stringify(decoded), new RegExp(`${box.project}|claude-session|u257|a257`));
|
|
138
|
+
const id = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
|
|
139
|
+
writeRollout(box.home, box.project, id, completeEvents('2026-07-15T00:03:00.000Z'));
|
|
140
|
+
const switched = resolveObserverTurnFeed({ projectPath: box.project, cursor: latest.throughCursor, receiptOptions: options, codexHome: box.home });
|
|
141
|
+
assert.equal(switched.status, 'host_switched');
|
|
142
|
+
const secondId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f8';
|
|
143
|
+
writeRollout(box.home, box.project, secondId, completeEvents('2026-07-15T00:04:00.000Z'));
|
|
144
|
+
assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: switched.throughCursor, receiptOptions: options, codexHome: box.home }).status, 'thread_switched');
|
|
145
|
+
writeCompletedTurnReceipt({ projectPath: box.project, targetSessionId: 'claude-tie', originSessionId: 'tie', userBody: 'tu', assistantBody: 'ta', completedAt: Date.parse('2026-07-15T00:03:00.000Z') }, options);
|
|
146
|
+
// Tie the current Codex parent, not a stale earlier one.
|
|
147
|
+
writeCompletedTurnReceipt({ projectPath: box.project, targetSessionId: 'claude-tie-current', originSessionId: 'tie-current', userBody: 'tuc', assistantBody: 'tac', completedAt: Date.parse('2026-07-15T00:04:00.000Z') }, options);
|
|
148
|
+
assert.equal(resolveObserverTurnFeed({ projectPath: box.project, receiptOptions: options, codexHome: box.home }).status, 'ambiguous_parent');
|
|
149
|
+
} finally { rmSync(box.root, { recursive: true, force: true }); }
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('observer feed: Codex pair hashes reuse DB capture role aggregation', () => {
|
|
153
|
+
const box = fixture();
|
|
154
|
+
try {
|
|
155
|
+
const id = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
|
|
156
|
+
writeRollout(box.home, box.project, id, [
|
|
157
|
+
event('user_message', 'user one', '2026-07-15T00:00:01.000Z'),
|
|
158
|
+
event('task_started', undefined, '2026-07-15T00:00:02.000Z'),
|
|
159
|
+
event('user_message', 'user two', '2026-07-15T00:00:03.000Z'),
|
|
160
|
+
event('agent_message', 'assistant one', '2026-07-15T00:00:04.000Z'),
|
|
161
|
+
event('agent_message', 'assistant two', '2026-07-15T00:00:05.000Z'),
|
|
162
|
+
event('task_complete', undefined, '2026-07-15T00:00:06.000Z'),
|
|
163
|
+
]);
|
|
164
|
+
const result = resolveObserverTurnFeed({ projectPath: box.project, codexHome: box.home, receiptOptions: box.receiptOptions });
|
|
165
|
+
assert.equal(result.chain[0].user_sha256, hashAuditorBody('user one\n\nuser two'));
|
|
166
|
+
assert.equal(result.chain[0].assistant_sha256, hashAuditorBody('assistant one\n\nassistant two'));
|
|
167
|
+
} finally { rmSync(box.root, { recursive: true, force: true }); }
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('observer feed: prior source loss never downgrades into a switch', () => {
|
|
171
|
+
const box = fixture();
|
|
172
|
+
try {
|
|
173
|
+
const primary = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
|
|
174
|
+
const backup = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f8';
|
|
175
|
+
const primaryPath = writeRollout(box.home, box.project, primary, completeEvents('2026-07-15T00:03:00.000Z'));
|
|
176
|
+
writeRollout(box.home, box.project, backup, completeEvents('2026-07-15T00:02:00.000Z'));
|
|
177
|
+
const prior = resolveObserverTurnFeed({ projectPath: box.project, codexHome: box.home, receiptOptions: box.receiptOptions });
|
|
178
|
+
rmSync(primaryPath);
|
|
179
|
+
assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: prior.throughCursor, codexHome: box.home, receiptOptions: box.receiptOptions }).status, 'resync_required', 'old同hostへ露出してもswitchにしない');
|
|
180
|
+
const backupPath = join(box.home, 'sessions', '2026', '07', '15', `rollout-2026-07-15T00-00-00-${backup}.jsonl`);
|
|
181
|
+
writeCompletedTurnReceipt({ projectPath: box.project, targetSessionId: 'claude', originSessionId: 'origin', userBody: 'u', assistantBody: 'a', completedAt: Date.parse('2026-07-15T00:04:00.000Z') }, box.receiptOptions);
|
|
182
|
+
rmSync(backupPath);
|
|
183
|
+
assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: prior.throughCursor, codexHome: box.home, receiptOptions: box.receiptOptions }).status, 'resync_required', 'prior host消失で他hostが出てもresync');
|
|
184
|
+
const claudePrior = resolveObserverTurnFeed({ projectPath: box.project, codexHome: box.home, receiptOptions: box.receiptOptions });
|
|
185
|
+
const receiptPath = join(box.root, 'state', 'throughline', 'completed-turn-receipts');
|
|
186
|
+
rmSync(receiptPath, { recursive: true, force: true });
|
|
187
|
+
assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: claudePrior.throughCursor, codexHome: box.home, receiptOptions: box.receiptOptions }).status, 'resync_required', 'source全消失もresync');
|
|
188
|
+
} finally { rmSync(box.root, { recursive: true, force: true }); }
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('observer feed: DB projection is all-or-nothing and never exposes raw session identity', () => {
|
|
192
|
+
const box = fixture();
|
|
193
|
+
const dbPath = join(box.root, 'throughline.db');
|
|
194
|
+
let db;
|
|
195
|
+
try {
|
|
196
|
+
const options = box.receiptOptions;
|
|
197
|
+
writeCompletedTurnReceipt({ projectPath: box.project, targetSessionId: 'private-session', originSessionId: 'private-origin', userBody: 'captured user', assistantBody: 'captured answer', completedAt: 1 }, options);
|
|
198
|
+
const pending = resolveObserverTurnFeed({ projectPath: box.project, codexHome: box.home, receiptOptions: options, dbPath });
|
|
199
|
+
assert.equal(pending.status, 'projection_pending');
|
|
200
|
+
assert.deepEqual(pending.turns, []);
|
|
201
|
+
db = new DatabaseSync(dbPath);
|
|
202
|
+
db.exec(`PRAGMA user_version = 9;
|
|
203
|
+
CREATE TABLE sessions (session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL);
|
|
204
|
+
CREATE TABLE bodies (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, origin_session_id TEXT NOT NULL, turn_number INTEGER NOT NULL, role TEXT NOT NULL, text TEXT NOT NULL, created_at INTEGER NOT NULL);`);
|
|
205
|
+
db.prepare('INSERT INTO sessions (session_id, project_path) VALUES (?, ?)').run('private-session', box.project);
|
|
206
|
+
db.prepare('INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, created_at) VALUES (?, ?, 1, ?, ?, ?)').run('private-session', 'private-origin', 'user', 'captured user', 1);
|
|
207
|
+
db.prepare('INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, created_at) VALUES (?, ?, 1, ?, ?, ?)').run('private-session', 'private-origin', 'assistant', 'captured answer', 2);
|
|
208
|
+
const fresh = resolveObserverTurnFeed({ projectPath: box.project, codexHome: box.home, receiptOptions: options, dbPath, maxTotalChars: 0 });
|
|
209
|
+
assert.equal(fresh.status, 'snapshot');
|
|
210
|
+
assert.equal(fresh.turns.length, 1);
|
|
211
|
+
assert.equal(fresh.turns[0].truncated, true);
|
|
212
|
+
assert.doesNotMatch(JSON.stringify(fresh), /private-session|private-origin|captured user|captured answer/);
|
|
213
|
+
} finally {
|
|
214
|
+
db?.close();
|
|
215
|
+
rmSync(box.root, { recursive: true, force: true });
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('observer read: snapshot returns only the latest bounded DB-projected turns without pagination', () => {
|
|
220
|
+
const box = fixture();
|
|
221
|
+
const dbPath = join(box.root, 'throughline.db');
|
|
222
|
+
let db;
|
|
223
|
+
try {
|
|
224
|
+
db = createProjectionDb(dbPath, box.project, 'private-claude-session');
|
|
225
|
+
for (let turn = 1; turn <= 3; turn++) {
|
|
226
|
+
addClaudeTurn(box, db, {
|
|
227
|
+
sessionId: 'private-claude-session', origin: `private-origin-${turn}`, turn,
|
|
228
|
+
user: `user-${turn}`, assistant: `assistant-${turn}`, at: turn,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
const result = readObserverTurnPage({
|
|
232
|
+
projectPath: box.project, codexHome: box.home, receiptOptions: box.receiptOptions,
|
|
233
|
+
dbPath, limit: 2,
|
|
234
|
+
});
|
|
235
|
+
assert.equal(result.schema, 'throughline.observer_read.v1');
|
|
236
|
+
assert.equal(result.status, 'snapshot');
|
|
237
|
+
assert.equal(result.host, 'claude');
|
|
238
|
+
assert.equal(result.historyTruncated, true);
|
|
239
|
+
assert.deepEqual(result.page, { complete: true, nextToken: null });
|
|
240
|
+
assert.deepEqual(result.turns.map((turn) => [turn.user, turn.assistant]), [
|
|
241
|
+
['user-2', 'assistant-2'], ['user-3', 'assistant-3'],
|
|
242
|
+
]);
|
|
243
|
+
assert.equal(result.turns.every((turn) => turn.origin_sha256 && turn.source_sha256 && Number.isInteger(turn.completed_at)), true);
|
|
244
|
+
assert.doesNotMatch(JSON.stringify(result), /private-claude-session|private-origin/);
|
|
245
|
+
for (const limit of [0, 101, 1.5]) {
|
|
246
|
+
assert.throws(() => readObserverTurnPage({
|
|
247
|
+
projectPath: box.project, codexHome: box.home, receiptOptions: box.receiptOptions, dbPath, limit,
|
|
248
|
+
}), /limit must be an integer between 1 and 100/);
|
|
249
|
+
}
|
|
250
|
+
} finally {
|
|
251
|
+
db?.close();
|
|
252
|
+
rmSync(box.root, { recursive: true, force: true });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('observer read: delta pages keep the first through boundary while newer turns arrive', () => {
|
|
257
|
+
const box = fixture();
|
|
258
|
+
const dbPath = join(box.root, 'throughline.db');
|
|
259
|
+
let db;
|
|
260
|
+
try {
|
|
261
|
+
db = createProjectionDb(dbPath, box.project, 'claude-session');
|
|
262
|
+
addClaudeTurn(box, db, { origin: 'origin-1', turn: 1, user: 'user-1', assistant: 'assistant-1', at: 1 });
|
|
263
|
+
const baseline = resolveObserverTurnFeed({ projectPath: box.project, codexHome: box.home, receiptOptions: box.receiptOptions });
|
|
264
|
+
for (let turn = 2; turn <= 4; turn++) {
|
|
265
|
+
addClaudeTurn(box, db, { origin: `origin-${turn}`, turn, user: `user-${turn}`, assistant: `assistant-${turn}`, at: turn });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const first = readObserverTurnPage({
|
|
269
|
+
projectPath: box.project, afterCursor: baseline.throughCursor, limit: 1,
|
|
270
|
+
codexHome: box.home, receiptOptions: box.receiptOptions, dbPath,
|
|
271
|
+
});
|
|
272
|
+
assert.equal(first.status, 'delta');
|
|
273
|
+
assert.equal(first.page.complete, false);
|
|
274
|
+
assert.equal(first.turns[0].user, 'user-2');
|
|
275
|
+
assert.equal(typeof first.page.nextToken, 'string');
|
|
276
|
+
assert.doesNotMatch(first.page.nextToken, /claude-session|origin-2|user-2|assistant-2/);
|
|
277
|
+
|
|
278
|
+
addClaudeTurn(box, db, { origin: 'origin-5', turn: 5, user: 'user-5', assistant: 'assistant-5', at: 5 });
|
|
279
|
+
const second = readObserverTurnPage({
|
|
280
|
+
projectPath: box.project, afterCursor: baseline.throughCursor, throughCursor: first.throughCursor,
|
|
281
|
+
pageToken: first.page.nextToken, limit: 1, codexHome: box.home,
|
|
282
|
+
receiptOptions: box.receiptOptions, dbPath,
|
|
283
|
+
});
|
|
284
|
+
const third = readObserverTurnPage({
|
|
285
|
+
projectPath: box.project, afterCursor: baseline.throughCursor, throughCursor: second.throughCursor,
|
|
286
|
+
pageToken: second.page.nextToken, limit: 1, codexHome: box.home,
|
|
287
|
+
receiptOptions: box.receiptOptions, dbPath,
|
|
288
|
+
});
|
|
289
|
+
assert.equal(second.turns[0].user, 'user-3');
|
|
290
|
+
assert.equal(third.turns[0].user, 'user-4');
|
|
291
|
+
assert.deepEqual(third.page, { complete: true, nextToken: null });
|
|
292
|
+
assert.equal(JSON.stringify([first, second, third]).includes('user-5'), false);
|
|
293
|
+
|
|
294
|
+
const tamperedOffset = rewritePageToken(first.page.nextToken, (value) => { value.offset = 2; });
|
|
295
|
+
assert.throws(() => readObserverTurnPage({
|
|
296
|
+
projectPath: box.project, afterCursor: baseline.throughCursor, throughCursor: first.throughCursor,
|
|
297
|
+
pageToken: tamperedOffset, limit: 1, codexHome: box.home,
|
|
298
|
+
receiptOptions: box.receiptOptions, dbPath,
|
|
299
|
+
}), /page token binding invalid/);
|
|
300
|
+
assert.throws(() => readObserverTurnPage({
|
|
301
|
+
projectPath: box.other, afterCursor: baseline.throughCursor, throughCursor: first.throughCursor,
|
|
302
|
+
pageToken: first.page.nextToken, limit: 1, codexHome: box.home,
|
|
303
|
+
receiptOptions: box.receiptOptions, dbPath,
|
|
304
|
+
}), /page token binding invalid/);
|
|
305
|
+
} finally {
|
|
306
|
+
db?.close();
|
|
307
|
+
rmSync(box.root, { recursive: true, force: true });
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('observer read: a missing pair on a later page returns no partial body or continuation', () => {
|
|
312
|
+
const box = fixture();
|
|
313
|
+
const dbPath = join(box.root, 'throughline.db');
|
|
314
|
+
let db;
|
|
315
|
+
try {
|
|
316
|
+
db = createProjectionDb(dbPath, box.project, 'claude-session');
|
|
317
|
+
addClaudeTurn(box, db, { origin: 'origin-1', turn: 1, user: 'user-1', assistant: 'assistant-1', at: 1 });
|
|
318
|
+
const baseline = resolveObserverTurnFeed({ projectPath: box.project, codexHome: box.home, receiptOptions: box.receiptOptions });
|
|
319
|
+
addClaudeTurn(box, db, { origin: 'origin-2', turn: 2, user: 'user-2', assistant: 'assistant-2', at: 2 });
|
|
320
|
+
addClaudeTurn(box, null, { origin: 'origin-3', turn: 3, user: 'user-3', assistant: 'assistant-3', at: 3 });
|
|
321
|
+
const first = readObserverTurnPage({
|
|
322
|
+
projectPath: box.project, afterCursor: baseline.throughCursor, limit: 1,
|
|
323
|
+
codexHome: box.home, receiptOptions: box.receiptOptions, dbPath,
|
|
324
|
+
});
|
|
325
|
+
const pending = readObserverTurnPage({
|
|
326
|
+
projectPath: box.project, afterCursor: baseline.throughCursor, throughCursor: first.throughCursor,
|
|
327
|
+
pageToken: first.page.nextToken, limit: 1, codexHome: box.home,
|
|
328
|
+
receiptOptions: box.receiptOptions, dbPath,
|
|
329
|
+
});
|
|
330
|
+
assert.equal(first.status, 'delta');
|
|
331
|
+
assert.equal(pending.status, 'projection_pending');
|
|
332
|
+
assert.deepEqual(pending.turns, []);
|
|
333
|
+
assert.equal(pending.throughCursor, null);
|
|
334
|
+
assert.deepEqual(pending.page, { complete: false, nextToken: null });
|
|
335
|
+
} finally {
|
|
336
|
+
db?.close();
|
|
337
|
+
rmSync(box.root, { recursive: true, force: true });
|
|
338
|
+
}
|
|
339
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { performance } from 'node:perf_hooks';
|
|
2
|
+
import { resolveObserverTurnFeed } from './observer-turn-feed.mjs';
|
|
3
|
+
|
|
4
|
+
export const OBSERVER_WAIT_SCHEMA = 'throughline.observer_wait.v1';
|
|
5
|
+
export const DEFAULT_OBSERVER_WAIT_TIMEOUT_SECONDS = 3600;
|
|
6
|
+
export const MAX_OBSERVER_WAIT_TIMEOUT_SECONDS = 3600;
|
|
7
|
+
const DEFAULT_POLL_INTERVAL_MS = 1000;
|
|
8
|
+
|
|
9
|
+
export class ObserverWaitCancelledError extends Error {
|
|
10
|
+
constructor() {
|
|
11
|
+
super('observer wait was cancelled');
|
|
12
|
+
this.name = 'ObserverWaitCancelledError';
|
|
13
|
+
this.code = 'E_OBSERVER_WAIT_CANCELLED';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Waits for the completed-only Observer cursor to change. The second argument
|
|
19
|
+
* is an explicit dependency boundary for deterministic tests; callers should
|
|
20
|
+
* normally omit it.
|
|
21
|
+
*/
|
|
22
|
+
export async function waitForObserverTurnChange({
|
|
23
|
+
projectPath,
|
|
24
|
+
afterCursor,
|
|
25
|
+
timeoutSeconds = DEFAULT_OBSERVER_WAIT_TIMEOUT_SECONDS,
|
|
26
|
+
signal,
|
|
27
|
+
codexHome,
|
|
28
|
+
receiptOptions,
|
|
29
|
+
} = {}, dependencies = {}) {
|
|
30
|
+
assertWaitInput({ afterCursor, timeoutSeconds, signal });
|
|
31
|
+
const resolve = dependencies.resolve ?? resolveObserverTurnFeed;
|
|
32
|
+
const now = dependencies.now ?? (() => performance.now());
|
|
33
|
+
const sleep = dependencies.sleep ?? abortableSleep;
|
|
34
|
+
const pollIntervalMs = dependencies.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
35
|
+
if (!Number.isFinite(pollIntervalMs) || pollIntervalMs <= 0) {
|
|
36
|
+
throw new TypeError('observer wait poll interval must be positive');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
throwIfAborted(signal);
|
|
40
|
+
const deadline = now() + timeoutSeconds * 1000;
|
|
41
|
+
for (;;) {
|
|
42
|
+
throwIfAborted(signal);
|
|
43
|
+
const current = await resolve({ projectPath, cursor: afterCursor, codexHome, receiptOptions });
|
|
44
|
+
throwIfAborted(signal);
|
|
45
|
+
const terminal = terminalWire(current, afterCursor);
|
|
46
|
+
if (terminal) return terminal;
|
|
47
|
+
|
|
48
|
+
const remainingMs = deadline - now();
|
|
49
|
+
if (remainingMs <= 0) return waitWire('timeout', afterCursor, afterCursor);
|
|
50
|
+
await sleep(Math.min(pollIntervalMs, remainingMs), signal);
|
|
51
|
+
throwIfAborted(signal);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function assertWaitInput({ afterCursor, timeoutSeconds, signal }) {
|
|
56
|
+
if (typeof afterCursor !== 'string' || afterCursor.length === 0) {
|
|
57
|
+
throw new TypeError('observer wait afterCursor is required');
|
|
58
|
+
}
|
|
59
|
+
if (!Number.isSafeInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > MAX_OBSERVER_WAIT_TIMEOUT_SECONDS) {
|
|
60
|
+
throw new TypeError('observer wait timeoutSeconds must be an integer between 1 and 3600');
|
|
61
|
+
}
|
|
62
|
+
if (signal !== undefined && (typeof signal !== 'object' || typeof signal.aborted !== 'boolean' || typeof signal.addEventListener !== 'function')) {
|
|
63
|
+
throw new TypeError('observer wait signal must be an AbortSignal');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function terminalWire(result, afterCursor) {
|
|
68
|
+
if (result?.status === 'resync_required') return waitWire('resync_required', afterCursor, null);
|
|
69
|
+
if (result?.status === 'ambiguous_parent') return waitWire('ambiguous_parent', afterCursor, null);
|
|
70
|
+
if (result?.status === 'unchanged') return null;
|
|
71
|
+
if (['append', 'thread_switched', 'host_switched'].includes(result?.status) && typeof result.throughCursor === 'string') {
|
|
72
|
+
return waitWire('changed', afterCursor, result.throughCursor);
|
|
73
|
+
}
|
|
74
|
+
throw new TypeError('observer wait resolver returned an invalid state');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function waitWire(status, afterCursor, throughCursor) {
|
|
78
|
+
return { schema: OBSERVER_WAIT_SCHEMA, status, afterCursor, throughCursor };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function throwIfAborted(signal) {
|
|
82
|
+
if (signal?.aborted) throw new ObserverWaitCancelledError();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function abortableSleep(delayMs, signal) {
|
|
86
|
+
throwIfAborted(signal);
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
let settled = false;
|
|
89
|
+
const cleanup = () => signal?.removeEventListener('abort', onAbort);
|
|
90
|
+
const finish = (callback) => {
|
|
91
|
+
if (settled) return;
|
|
92
|
+
settled = true;
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
cleanup();
|
|
95
|
+
callback();
|
|
96
|
+
};
|
|
97
|
+
const onAbort = () => finish(() => reject(new ObserverWaitCancelledError()));
|
|
98
|
+
const timer = setTimeout(() => finish(resolve), delayMs);
|
|
99
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
100
|
+
if (signal?.aborted) onAbort();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { getEventListeners } from 'node:events';
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
import {
|
|
5
|
+
OBSERVER_WAIT_SCHEMA,
|
|
6
|
+
ObserverWaitCancelledError,
|
|
7
|
+
waitForObserverTurnChange,
|
|
8
|
+
} from './observer-turn-wait.mjs';
|
|
9
|
+
|
|
10
|
+
const AFTER = 'tlc1.after';
|
|
11
|
+
|
|
12
|
+
function virtualDependencies(states, { pollIntervalMs = 1000 } = {}) {
|
|
13
|
+
let nowMs = 0;
|
|
14
|
+
let resolveCalls = 0;
|
|
15
|
+
let sleepCalls = 0;
|
|
16
|
+
return {
|
|
17
|
+
dependencies: {
|
|
18
|
+
now: () => nowMs,
|
|
19
|
+
pollIntervalMs,
|
|
20
|
+
resolve: () => states[Math.min(resolveCalls++, states.length - 1)],
|
|
21
|
+
sleep: async (delayMs) => { sleepCalls++; nowMs += delayMs; },
|
|
22
|
+
},
|
|
23
|
+
counts: () => ({ resolveCalls, sleepCalls, nowMs }),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('observer wait returns immediate and polled changes without exposing resolver details', async () => {
|
|
28
|
+
const immediate = virtualDependencies([{ status: 'append', throughCursor: 'tlc1.changed', chain: [{ secret: 'body' }] }]);
|
|
29
|
+
assert.deepEqual(await waitForObserverTurnChange({ projectPath: '/repo', afterCursor: AFTER }, immediate.dependencies), {
|
|
30
|
+
schema: OBSERVER_WAIT_SCHEMA,
|
|
31
|
+
status: 'changed',
|
|
32
|
+
afterCursor: AFTER,
|
|
33
|
+
throughCursor: 'tlc1.changed',
|
|
34
|
+
});
|
|
35
|
+
assert.deepEqual(immediate.counts(), { resolveCalls: 1, sleepCalls: 0, nowMs: 0 });
|
|
36
|
+
|
|
37
|
+
const polled = virtualDependencies([
|
|
38
|
+
{ status: 'unchanged', throughCursor: AFTER },
|
|
39
|
+
{ status: 'host_switched', throughCursor: 'tlc1.host' },
|
|
40
|
+
]);
|
|
41
|
+
assert.equal((await waitForObserverTurnChange({ projectPath: '/repo', afterCursor: AFTER, timeoutSeconds: 1 }, polled.dependencies)).throughCursor, 'tlc1.host');
|
|
42
|
+
assert.deepEqual(polled.counts(), { resolveCalls: 2, sleepCalls: 1, nowMs: 1000 });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('observer wait polls once at the deadline before returning timeout', async () => {
|
|
46
|
+
const timeout = virtualDependencies([{ status: 'unchanged' }]);
|
|
47
|
+
assert.deepEqual(await waitForObserverTurnChange({ projectPath: '/repo', afterCursor: AFTER, timeoutSeconds: 1 }, timeout.dependencies), {
|
|
48
|
+
schema: OBSERVER_WAIT_SCHEMA,
|
|
49
|
+
status: 'timeout',
|
|
50
|
+
afterCursor: AFTER,
|
|
51
|
+
throughCursor: AFTER,
|
|
52
|
+
});
|
|
53
|
+
assert.deepEqual(timeout.counts(), { resolveCalls: 2, sleepCalls: 1, nowMs: 1000 });
|
|
54
|
+
|
|
55
|
+
const boundary = virtualDependencies([
|
|
56
|
+
{ status: 'unchanged' },
|
|
57
|
+
{ status: 'thread_switched', throughCursor: 'tlc1.boundary' },
|
|
58
|
+
]);
|
|
59
|
+
assert.equal((await waitForObserverTurnChange({ projectPath: '/repo', afterCursor: AFTER, timeoutSeconds: 1 }, boundary.dependencies)).status, 'changed');
|
|
60
|
+
assert.deepEqual(boundary.counts(), { resolveCalls: 2, sleepCalls: 1, nowMs: 1000 });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('observer wait preserves fail-closed cursor states', async () => {
|
|
64
|
+
for (const status of ['resync_required', 'ambiguous_parent']) {
|
|
65
|
+
const box = virtualDependencies([{ status }]);
|
|
66
|
+
assert.deepEqual(await waitForObserverTurnChange({ projectPath: '/repo', afterCursor: AFTER }, box.dependencies), {
|
|
67
|
+
schema: OBSERVER_WAIT_SCHEMA,
|
|
68
|
+
status,
|
|
69
|
+
afterCursor: AFTER,
|
|
70
|
+
throughCursor: null,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('observer wait aborts before start and between polls without starting another resolver', async () => {
|
|
76
|
+
const already = new AbortController();
|
|
77
|
+
already.abort();
|
|
78
|
+
let called = 0;
|
|
79
|
+
await assert.rejects(
|
|
80
|
+
waitForObserverTurnChange({ projectPath: '/repo', afterCursor: AFTER, signal: already.signal }, { resolve: () => { called++; } }),
|
|
81
|
+
(error) => error instanceof ObserverWaitCancelledError && error.code === 'E_OBSERVER_WAIT_CANCELLED',
|
|
82
|
+
);
|
|
83
|
+
assert.equal(called, 0);
|
|
84
|
+
|
|
85
|
+
const during = new AbortController();
|
|
86
|
+
const box = virtualDependencies([{ status: 'unchanged' }]);
|
|
87
|
+
box.dependencies.sleep = async () => { during.abort(); };
|
|
88
|
+
await assert.rejects(
|
|
89
|
+
waitForObserverTurnChange({ projectPath: '/repo', afterCursor: AFTER, signal: during.signal }, box.dependencies),
|
|
90
|
+
{ code: 'E_OBSERVER_WAIT_CANCELLED' },
|
|
91
|
+
);
|
|
92
|
+
assert.equal(box.counts().resolveCalls, 1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('observer wait removes its abort listener after a pending timer is cancelled', async () => {
|
|
96
|
+
const controller = new AbortController();
|
|
97
|
+
const pending = waitForObserverTurnChange(
|
|
98
|
+
{ projectPath: '/repo', afterCursor: AFTER, signal: controller.signal },
|
|
99
|
+
{ resolve: () => ({ status: 'unchanged' }), pollIntervalMs: 50 },
|
|
100
|
+
);
|
|
101
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
102
|
+
assert.equal(getEventListeners(controller.signal, 'abort').length, 1);
|
|
103
|
+
controller.abort();
|
|
104
|
+
await assert.rejects(pending, { code: 'E_OBSERVER_WAIT_CANCELLED' });
|
|
105
|
+
assert.equal(getEventListeners(controller.signal, 'abort').length, 0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('observer wait rejects invalid public inputs and resolver states', async () => {
|
|
109
|
+
for (const input of [
|
|
110
|
+
{ projectPath: '/repo' },
|
|
111
|
+
{ projectPath: '/repo', afterCursor: AFTER, timeoutSeconds: 0 },
|
|
112
|
+
{ projectPath: '/repo', afterCursor: AFTER, timeoutSeconds: 3601 },
|
|
113
|
+
{ projectPath: '/repo', afterCursor: AFTER, timeoutSeconds: 1.5 },
|
|
114
|
+
{ projectPath: '/repo', afterCursor: AFTER, signal: {} },
|
|
115
|
+
]) {
|
|
116
|
+
await assert.rejects(waitForObserverTurnChange(input, { resolve: () => ({ status: 'unchanged' }) }), TypeError);
|
|
117
|
+
}
|
|
118
|
+
await assert.rejects(
|
|
119
|
+
waitForObserverTurnChange({ projectPath: '/repo', afterCursor: AFTER }, { resolve: () => ({ status: 'projection_pending' }) }),
|
|
120
|
+
/invalid state/,
|
|
121
|
+
);
|
|
122
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pending-handoff.mjs — 二相ハンドオフの intent 管理 (schema v9 pending_handoffs)
|
|
3
|
+
*
|
|
4
|
+
* 背景 (ADR 0014): Claude Code は同一 project_path に対して短時間 (実測 315–488ms) に
|
|
5
|
+
* 複数の SessionStart hook を発火させることがあり、その一部は transcript を一度も
|
|
6
|
+
* 生成しない「幽霊セッション」になる。SessionStart 時点では payload・DB のどこにも
|
|
7
|
+
* 実体と幽霊を判別する情報が存在しない(transcript ファイルは本物でも hook より
|
|
8
|
+
* 数百 ms 遅れて作られる)。
|
|
9
|
+
*
|
|
10
|
+
* したがって merge / 注入は「実体の証明」= 最初の UserPromptSubmit まで遅延する:
|
|
11
|
+
* - SessionStart: registerPendingHandoff で intent を登録するだけ
|
|
12
|
+
* - 最初の prompt: consumePendingHandoff → baton / auto の順で merge + 注入
|
|
13
|
+
* 幽霊はプロンプトを一度も発火しないため、pending 行が残るだけで記憶を奪えない。
|
|
14
|
+
*
|
|
15
|
+
* pending 行の created_at はセッション誕生時刻として baton TTL 判定の基準
|
|
16
|
+
* (consumeBaton の bornAt) に使う。これにより「/tl からセッション開始までの猶予 1h」
|
|
17
|
+
* という従来の TTL 意味論を、消費が初回プロンプトまで遅れても保存する。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* SessionStart から呼ぶ。intent を登録する(merge も注入もしない)。
|
|
22
|
+
* resume / compact 由来の SessionStart でも再登録してよい(INSERT OR REPLACE)。
|
|
23
|
+
* その場合 created_at は再開時刻に更新され、従来の「SessionStart 時点で消費」と
|
|
24
|
+
* 同じ適格判定が初回プロンプト時に再現される。
|
|
25
|
+
*
|
|
26
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
27
|
+
* @param {{
|
|
28
|
+
* sessionId: string,
|
|
29
|
+
* projectPath: string,
|
|
30
|
+
* source: string | null,
|
|
31
|
+
* autoPredecessorId?: string | null,
|
|
32
|
+
* now?: number,
|
|
33
|
+
* }} params
|
|
34
|
+
*/
|
|
35
|
+
export function registerPendingHandoff(
|
|
36
|
+
db,
|
|
37
|
+
{ sessionId, projectPath, source, autoPredecessorId = null, now = Date.now() },
|
|
38
|
+
) {
|
|
39
|
+
db.prepare(
|
|
40
|
+
`INSERT INTO pending_handoffs (session_id, project_path, source, auto_predecessor_id, created_at)
|
|
41
|
+
VALUES (?, ?, ?, ?, ?)
|
|
42
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
43
|
+
project_path = excluded.project_path,
|
|
44
|
+
source = excluded.source,
|
|
45
|
+
auto_predecessor_id = excluded.auto_predecessor_id,
|
|
46
|
+
created_at = excluded.created_at`,
|
|
47
|
+
).run(sessionId, projectPath, source ?? null, autoPredecessorId ?? null, now);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* UserPromptSubmit から呼ぶ。自セッションの pending 行を atomic に取り出して削除する。
|
|
52
|
+
* 行が無ければ null(= newborn ではない。ハンドオフ判定はしない)。
|
|
53
|
+
*
|
|
54
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
55
|
+
* @param {{ sessionId: string }} params
|
|
56
|
+
* @returns {{
|
|
57
|
+
* sessionId: string,
|
|
58
|
+
* projectPath: string,
|
|
59
|
+
* source: string | null,
|
|
60
|
+
* autoPredecessorId: string | null,
|
|
61
|
+
* createdAt: number,
|
|
62
|
+
* } | null}
|
|
63
|
+
*/
|
|
64
|
+
export function consumePendingHandoff(db, { sessionId }) {
|
|
65
|
+
db.exec('BEGIN IMMEDIATE');
|
|
66
|
+
try {
|
|
67
|
+
const row = db
|
|
68
|
+
.prepare(
|
|
69
|
+
`SELECT session_id, project_path, source, auto_predecessor_id, created_at
|
|
70
|
+
FROM pending_handoffs WHERE session_id = ?`,
|
|
71
|
+
)
|
|
72
|
+
.get(sessionId);
|
|
73
|
+
|
|
74
|
+
if (!row) {
|
|
75
|
+
db.exec('COMMIT');
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
db.prepare('DELETE FROM pending_handoffs WHERE session_id = ?').run(sessionId);
|
|
80
|
+
db.exec('COMMIT');
|
|
81
|
+
return {
|
|
82
|
+
sessionId: row.session_id,
|
|
83
|
+
projectPath: row.project_path,
|
|
84
|
+
source: row.source ?? null,
|
|
85
|
+
autoPredecessorId: row.auto_predecessor_id ?? null,
|
|
86
|
+
createdAt: row.created_at,
|
|
87
|
+
};
|
|
88
|
+
} catch (err) {
|
|
89
|
+
try {
|
|
90
|
+
db.exec('ROLLBACK');
|
|
91
|
+
} catch {
|
|
92
|
+
// ignore
|
|
93
|
+
}
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
}
|