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
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
9
|
+
import { parseRecallArgs, renderRecallL2, renderRecallL1 } from './recall.mjs';
|
|
10
|
+
|
|
11
|
+
const BIN = fileURLToPath(new URL('../../bin/throughline.mjs', import.meta.url));
|
|
12
|
+
|
|
13
|
+
function makeDb(path = ':memory:') {
|
|
14
|
+
const db = new DatabaseSync(path);
|
|
15
|
+
db.exec(`
|
|
16
|
+
CREATE TABLE skeletons (
|
|
17
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
18
|
+
session_id TEXT NOT NULL,
|
|
19
|
+
origin_session_id TEXT,
|
|
20
|
+
turn_number INTEGER NOT NULL,
|
|
21
|
+
role TEXT NOT NULL,
|
|
22
|
+
summary TEXT NOT NULL,
|
|
23
|
+
created_at INTEGER NOT NULL
|
|
24
|
+
);
|
|
25
|
+
CREATE TABLE bodies (
|
|
26
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
27
|
+
session_id TEXT NOT NULL,
|
|
28
|
+
origin_session_id TEXT NOT NULL,
|
|
29
|
+
turn_number INTEGER NOT NULL,
|
|
30
|
+
role TEXT NOT NULL,
|
|
31
|
+
text TEXT NOT NULL,
|
|
32
|
+
token_count INTEGER,
|
|
33
|
+
created_at INTEGER NOT NULL
|
|
34
|
+
);
|
|
35
|
+
CREATE TABLE details (
|
|
36
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
37
|
+
session_id TEXT NOT NULL,
|
|
38
|
+
origin_session_id TEXT,
|
|
39
|
+
turn_number INTEGER,
|
|
40
|
+
tool_name TEXT NOT NULL,
|
|
41
|
+
input_text TEXT,
|
|
42
|
+
output_text TEXT,
|
|
43
|
+
token_count INTEGER NOT NULL DEFAULT 0,
|
|
44
|
+
created_at INTEGER NOT NULL,
|
|
45
|
+
kind TEXT,
|
|
46
|
+
source_id TEXT
|
|
47
|
+
);
|
|
48
|
+
`);
|
|
49
|
+
return db;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function insertBody(db, { session, origin, turn, role, text, createdAt }) {
|
|
53
|
+
db.prepare(
|
|
54
|
+
`INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
|
|
55
|
+
VALUES (?, ?, ?, ?, ?, 1, ?)`,
|
|
56
|
+
).run(session, origin, turn, role, text, createdAt);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function insertSkeleton(db, { session, origin, turn, role, summary, createdAt }) {
|
|
60
|
+
db.prepare(
|
|
61
|
+
`INSERT INTO skeletons (session_id, origin_session_id, turn_number, role, summary, created_at)
|
|
62
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
63
|
+
).run(session, origin, turn, role, summary, createdAt);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// day: 2026-07-17T23:50:00Z 起点の ms を返す (深夜跨ぎテスト用)
|
|
67
|
+
const BASE = Date.parse('2026-07-17T23:50:00.000Z');
|
|
68
|
+
|
|
69
|
+
function seedTurns(db, { session = 's1', origin = 'o1', turns = 10, stepMs = 60_000 } = {}) {
|
|
70
|
+
for (let t = 1; t <= turns; t += 1) {
|
|
71
|
+
insertBody(db, {
|
|
72
|
+
session, origin, turn: t, role: 'user',
|
|
73
|
+
text: `q-${String(t).padStart(2, '0')}`, createdAt: BASE + t * stepMs,
|
|
74
|
+
});
|
|
75
|
+
insertBody(db, {
|
|
76
|
+
session, origin, turn: t, role: 'assistant',
|
|
77
|
+
text: `a-${String(t).padStart(2, '0')}`, createdAt: BASE + t * stepMs + 1_000,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
test('parseRecallArgs: mode/session/before are required, --l2 requires --last', () => {
|
|
83
|
+
assert.throws(() => parseRecallArgs([]), /--l2 または --l1/);
|
|
84
|
+
assert.throws(() => parseRecallArgs(['--l2', '--session', 's', '--last', '3']), /--before は必須/);
|
|
85
|
+
assert.throws(() => parseRecallArgs(['--l2', '--before', '2026-07-18T00:00:00Z', '--last', '3']), /--session は必須/);
|
|
86
|
+
assert.throws(
|
|
87
|
+
() => parseRecallArgs(['--l2', '--session', 's', '--before', '2026-07-18T00:00:00Z']),
|
|
88
|
+
/--last <N> が必須/,
|
|
89
|
+
);
|
|
90
|
+
assert.throws(() => parseRecallArgs(['--l2', '--l1']), /同時に指定できません/);
|
|
91
|
+
assert.throws(
|
|
92
|
+
() => parseRecallArgs(['--l2', '--session', 's', '--before', '14:02:11', '--last', '3']),
|
|
93
|
+
/解釈できません/,
|
|
94
|
+
'HH:MM:SS 単独は日付が無く受け付けない (深夜跨ぎで壊れるため ISO 必須)',
|
|
95
|
+
);
|
|
96
|
+
const ok = parseRecallArgs([
|
|
97
|
+
'--l1', '--session', 's', '--before', '2026-07-18T00:00:00.482Z', '--skip', '7',
|
|
98
|
+
]);
|
|
99
|
+
assert.equal(ok.mode, 'l1');
|
|
100
|
+
assert.equal(ok.beforeMs, Date.parse('2026-07-18T00:00:00.482Z'));
|
|
101
|
+
assert.equal(ok.skip, 7);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('recall --l2: strict less-than ms boundary, newest-first selection, oldest-first output', () => {
|
|
105
|
+
const db = makeDb();
|
|
106
|
+
seedTurns(db, { turns: 10 });
|
|
107
|
+
// 境界 = turn 8 の min(created_at) → turn 7 以前だけが対象 (turn 8 自身は含まない)
|
|
108
|
+
const boundary = BASE + 8 * 60_000;
|
|
109
|
+
|
|
110
|
+
const { text, turnCount } = renderRecallL2(db, { sessionId: 's1', beforeMs: boundary, last: 3 });
|
|
111
|
+
|
|
112
|
+
assert.equal(turnCount, 3);
|
|
113
|
+
assert.ok(!text.includes('a-08'), 'boundary turn itself must be excluded (strict less-than)');
|
|
114
|
+
assert.ok(text.includes('q-05') && text.includes('a-07'), 'turns 5..7 must be returned');
|
|
115
|
+
assert.ok(!text.includes('q-04 '), 'turns older than --last window are not returned');
|
|
116
|
+
// 古い順: q-05 が a-07 より先
|
|
117
|
+
assert.ok(text.indexOf('q-05') < text.indexOf('a-07'));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('recall --l2: same-second sibling rows are split exactly by ms boundary (no gap, no overlap)', () => {
|
|
121
|
+
const db = makeDb();
|
|
122
|
+
const sec = Date.parse('2026-07-18T01:02:03.000Z');
|
|
123
|
+
// 同一秒内に 2 ターン (ms 差のみ)
|
|
124
|
+
insertBody(db, { session: 's1', origin: 'o1', turn: 1, role: 'assistant', text: 'ms-100', createdAt: sec + 100 });
|
|
125
|
+
insertBody(db, { session: 's1', origin: 'o1', turn: 2, role: 'assistant', text: 'ms-482', createdAt: sec + 482 });
|
|
126
|
+
|
|
127
|
+
const { text } = renderRecallL2(db, { sessionId: 's1', beforeMs: sec + 482, last: 10 });
|
|
128
|
+
assert.ok(text.includes('ms-100'), 'older same-second row must be included');
|
|
129
|
+
assert.ok(!text.includes('ms-482'), 'boundary row itself must be excluded');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('recall --l2: ISO boundary works across midnight (no today-anchored resolution)', () => {
|
|
133
|
+
const db = makeDb();
|
|
134
|
+
seedTurns(db, { turns: 10 }); // BASE=23:50Z 起点、turn 10 は翌日 00:30Z 台
|
|
135
|
+
const boundary = BASE + 10 * 60_000; // 日付跨ぎ後の turn 10 の min
|
|
136
|
+
|
|
137
|
+
const { text, turnCount } = renderRecallL2(db, { sessionId: 's1', beforeMs: boundary, last: 20 });
|
|
138
|
+
assert.equal(turnCount, 9, 'all 9 turns before the post-midnight boundary must be found');
|
|
139
|
+
assert.ok(text.includes('q-01'));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('recall --l2: results do not shift when the new session appends turns (no window recomputation)', () => {
|
|
143
|
+
const db = makeDb();
|
|
144
|
+
seedTurns(db, { turns: 10 });
|
|
145
|
+
const boundary = BASE + 8 * 60_000;
|
|
146
|
+
const before = renderRecallL2(db, { sessionId: 's1', beforeMs: boundary, last: 3 });
|
|
147
|
+
|
|
148
|
+
// 新セッションのターンが同じ session_id へ大量に追記されても (merge 後の走行)、
|
|
149
|
+
// recall の結果は焼き込まれた境界だけで決まり不変
|
|
150
|
+
for (let t = 100; t < 130; t += 1) {
|
|
151
|
+
insertBody(db, {
|
|
152
|
+
session: 's1', origin: 'newborn', turn: t, role: 'assistant',
|
|
153
|
+
text: `new-${t}`, createdAt: BASE + t * 60_000,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
const after = renderRecallL2(db, { sessionId: 's1', beforeMs: boundary, last: 3 });
|
|
157
|
+
assert.equal(after.text, before.text);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('recall --l2: fewer rows than --last is announced, not silent', () => {
|
|
161
|
+
const db = makeDb();
|
|
162
|
+
seedTurns(db, { turns: 3 });
|
|
163
|
+
const { text, turnCount } = renderRecallL2(db, {
|
|
164
|
+
sessionId: 's1', beforeMs: BASE + 3 * 60_000, last: 13,
|
|
165
|
+
});
|
|
166
|
+
assert.equal(turnCount, 2);
|
|
167
|
+
assert.match(text, /--last 13 のうち DB に存在するのは 2 ターンのみ/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('recall --l2: other sessions are not mixed in', () => {
|
|
171
|
+
const db = makeDb();
|
|
172
|
+
seedTurns(db, { turns: 3 });
|
|
173
|
+
insertBody(db, {
|
|
174
|
+
session: 'codex:zzz', origin: 'codex:zzz', turn: 1, role: 'assistant',
|
|
175
|
+
text: 'codex-row', createdAt: BASE + 60_000,
|
|
176
|
+
});
|
|
177
|
+
const { text } = renderRecallL2(db, { sessionId: 's1', beforeMs: BASE + 10 * 60_000, last: 20 });
|
|
178
|
+
assert.ok(!text.includes('codex-row'));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('recall --l2: L3 suffix appears on the last row of each turn', () => {
|
|
182
|
+
const db = makeDb();
|
|
183
|
+
seedTurns(db, { turns: 3 });
|
|
184
|
+
db.prepare(
|
|
185
|
+
`INSERT INTO details (session_id, origin_session_id, turn_number, tool_name, input_text, output_text, token_count, created_at, kind, source_id)
|
|
186
|
+
VALUES ('s1', 'o1', 1, 'Bash', 'ls', 'ok', 1, ?, 'tool_input', 'tu-1')`,
|
|
187
|
+
).run(BASE + 60_000 + 500);
|
|
188
|
+
const { text } = renderRecallL2(db, { sessionId: 's1', beforeMs: BASE + 10 * 60_000, last: 20 });
|
|
189
|
+
assert.match(text, /a-01 \(詳細:Bash\)/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('recall --l1: skip jumps over the --l2 range; summarized and unsummarized turns are both listed', () => {
|
|
193
|
+
const db = makeDb();
|
|
194
|
+
seedTurns(db, { turns: 10 });
|
|
195
|
+
// turn 1, 2 だけ要約済み
|
|
196
|
+
insertSkeleton(db, { session: 's1', origin: 'o1', turn: 1, role: 'assistant', summary: 'sum-1', createdAt: BASE });
|
|
197
|
+
insertSkeleton(db, { session: 's1', origin: 'o1', turn: 2, role: 'assistant', summary: 'sum-2', createdAt: BASE });
|
|
198
|
+
|
|
199
|
+
// 境界 = turn 8 の min。--skip 3 で turn 7..5 を飛ばし、turn 4 以前が対象
|
|
200
|
+
const boundary = BASE + 8 * 60_000;
|
|
201
|
+
const { text, turnCount, summarizedCount } = renderRecallL1(db, {
|
|
202
|
+
sessionId: 's1', beforeMs: boundary, skip: 3,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
assert.equal(turnCount, 4, 'turns 1..4 must be listed');
|
|
206
|
+
assert.equal(summarizedCount, 2);
|
|
207
|
+
assert.match(text, /全4ターン \/ 要約済み 2/);
|
|
208
|
+
assert.ok(text.includes('sum-1') && text.includes('sum-2'));
|
|
209
|
+
assert.match(text, /\(未要約\) 全文: throughline detail \d{2}:\d{2}:\d{2}/);
|
|
210
|
+
assert.ok(!text.includes('q-05'), 'skipped turns (--l2 range) must not appear');
|
|
211
|
+
// 古い順: sum-1 が未要約 turn 4 より先
|
|
212
|
+
assert.ok(text.indexOf('sum-1') < text.indexOf('(未要約)'));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('recall --l1: empty result is explicit', () => {
|
|
216
|
+
const db = makeDb();
|
|
217
|
+
seedTurns(db, { turns: 2 });
|
|
218
|
+
const { text, turnCount } = renderRecallL1(db, {
|
|
219
|
+
sessionId: 's1', beforeMs: BASE + 2 * 60_000, skip: 5,
|
|
220
|
+
});
|
|
221
|
+
assert.equal(turnCount, 0);
|
|
222
|
+
assert.match(text, /該当ターンなし/);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('recall CLI: read-only contract — missing DB is an explicit error and is not created', () => {
|
|
226
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-recall-'));
|
|
227
|
+
const dbPath = join(dir, 'nope', 'throughline.db');
|
|
228
|
+
try {
|
|
229
|
+
let failed = false;
|
|
230
|
+
try {
|
|
231
|
+
execFileSync(
|
|
232
|
+
process.execPath,
|
|
233
|
+
[BIN, 'recall', '--l2', '--session', 's1',
|
|
234
|
+
'--before', '2026-07-18T00:00:00Z', '--last', '3', '--db', dbPath],
|
|
235
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
|
|
236
|
+
);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
failed = true;
|
|
239
|
+
assert.match(String(err.stderr), /DB がありません/);
|
|
240
|
+
}
|
|
241
|
+
assert.ok(failed, 'missing DB must exit non-zero');
|
|
242
|
+
assert.ok(!existsSync(dbPath), 'recall must not create the DB');
|
|
243
|
+
} finally {
|
|
244
|
+
rmSync(dir, { recursive: true, force: true });
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('recall CLI: end-to-end via bin dispatcher against a real DB file', () => {
|
|
249
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-recall-'));
|
|
250
|
+
const dbPath = join(dir, 'throughline.db');
|
|
251
|
+
try {
|
|
252
|
+
const db = makeDb(dbPath);
|
|
253
|
+
seedTurns(db, { turns: 5 });
|
|
254
|
+
db.close();
|
|
255
|
+
|
|
256
|
+
const boundaryIso = new Date(BASE + 4 * 60_000).toISOString();
|
|
257
|
+
const out = execFileSync(
|
|
258
|
+
process.execPath,
|
|
259
|
+
[BIN, 'recall', '--l2', '--session', 's1',
|
|
260
|
+
'--before', boundaryIso, '--last', '2', '--db', dbPath],
|
|
261
|
+
{ encoding: 'utf8' },
|
|
262
|
+
);
|
|
263
|
+
assert.match(out, /## Throughline recall \(L2\): 2ターン/);
|
|
264
|
+
assert.ok(out.includes('a-03') && out.includes('q-02'));
|
|
265
|
+
assert.ok(!out.includes('a-04'), 'boundary turn must be excluded');
|
|
266
|
+
} finally {
|
|
267
|
+
rmSync(dir, { recursive: true, force: true });
|
|
268
|
+
}
|
|
269
|
+
});
|
|
@@ -135,6 +135,7 @@ export function parseCodexRolloutFile(
|
|
|
135
135
|
number: `rollout-${stats.parsedRows}`,
|
|
136
136
|
messages: [],
|
|
137
137
|
details: [],
|
|
138
|
+
completedAt: null,
|
|
138
139
|
};
|
|
139
140
|
activeTurns.push(postRollbackTurn);
|
|
140
141
|
}
|
|
@@ -162,6 +163,7 @@ export function parseCodexRolloutFile(
|
|
|
162
163
|
number: stats.taskStarted + 1,
|
|
163
164
|
messages: pendingSplit.currentMessages,
|
|
164
165
|
details: pendingSplit.currentDetails,
|
|
166
|
+
completedAt: null,
|
|
165
167
|
};
|
|
166
168
|
pendingMessages = [];
|
|
167
169
|
pendingDetails = [];
|
|
@@ -175,6 +177,7 @@ export function parseCodexRolloutFile(
|
|
|
175
177
|
|
|
176
178
|
if (payload.type === 'task_complete') {
|
|
177
179
|
stats.taskComplete++;
|
|
180
|
+
if (openTurn) openTurn.completedAt = parseHostTimestamp(row.timestamp);
|
|
178
181
|
openTurn = null;
|
|
179
182
|
continue;
|
|
180
183
|
}
|
|
@@ -230,6 +233,7 @@ export function parseCodexRolloutFile(
|
|
|
230
233
|
number: `rollout-${stats.parsedRows}`,
|
|
231
234
|
messages: [],
|
|
232
235
|
details: [],
|
|
236
|
+
completedAt: null,
|
|
233
237
|
};
|
|
234
238
|
activeTurns.push(postRollbackTurn);
|
|
235
239
|
}
|
|
@@ -262,6 +266,7 @@ export function parseCodexRolloutFile(
|
|
|
262
266
|
number: `rollout-${stats.parsedRows}`,
|
|
263
267
|
messages: pendingMessages,
|
|
264
268
|
details: pendingDetails,
|
|
269
|
+
completedAt: null,
|
|
265
270
|
});
|
|
266
271
|
}
|
|
267
272
|
|
|
@@ -410,6 +415,7 @@ function splitPendingMessagesForTaskStart({ messages, details, syntheticNumber }
|
|
|
410
415
|
number: syntheticNumber,
|
|
411
416
|
messages,
|
|
412
417
|
details,
|
|
418
|
+
completedAt: null,
|
|
413
419
|
},
|
|
414
420
|
currentMessages: [],
|
|
415
421
|
currentDetails: [],
|
|
@@ -443,12 +449,19 @@ function splitPendingMessagesForTaskStart({ messages, details, syntheticNumber }
|
|
|
443
449
|
number: syntheticNumber,
|
|
444
450
|
messages: syntheticMessages,
|
|
445
451
|
details: syntheticDetails,
|
|
452
|
+
completedAt: null,
|
|
446
453
|
},
|
|
447
454
|
currentMessages,
|
|
448
455
|
currentDetails,
|
|
449
456
|
};
|
|
450
457
|
}
|
|
451
458
|
|
|
459
|
+
function parseHostTimestamp(value) {
|
|
460
|
+
if (typeof value !== 'string') return null;
|
|
461
|
+
const timestamp = Date.parse(value);
|
|
462
|
+
return Number.isSafeInteger(timestamp) && timestamp >= 0 ? timestamp : null;
|
|
463
|
+
}
|
|
464
|
+
|
|
452
465
|
function removeRolledBackTurns(activeTurns, count) {
|
|
453
466
|
return activeTurns.splice(Math.max(0, activeTurns.length - count), count);
|
|
454
467
|
}
|
|
@@ -216,6 +216,33 @@ test('parseCodexRolloutFile: keeps final pending messages as current synthetic t
|
|
|
216
216
|
}
|
|
217
217
|
});
|
|
218
218
|
|
|
219
|
+
test('parseCodexRolloutFile: completedAtは自身のtask_completeを観測した通常turnだけに付く', () => {
|
|
220
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
|
|
221
|
+
const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
|
|
222
|
+
try {
|
|
223
|
+
const rollout = writeRollout(home, {
|
|
224
|
+
id: '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9', cwd: project,
|
|
225
|
+
events: [
|
|
226
|
+
event('user_message', { message: 'completed request' }),
|
|
227
|
+
event('task_started'),
|
|
228
|
+
event('agent_message', { message: 'completed answer' }),
|
|
229
|
+
{ timestamp: '2026-05-06T00:41:00.000Z', type: 'event_msg', payload: { type: 'task_complete' } },
|
|
230
|
+
event('agent_message', { message: 'synthetic continuation' }),
|
|
231
|
+
event('user_message', { message: 'open request' }),
|
|
232
|
+
event('task_started'),
|
|
233
|
+
event('agent_message', { message: 'open answer' }),
|
|
234
|
+
],
|
|
235
|
+
});
|
|
236
|
+
const parsed = parseCodexRolloutFile(rollout);
|
|
237
|
+
assert.equal(parsed.activeTurns[0].completedAt, Date.parse('2026-05-06T00:41:00.000Z'));
|
|
238
|
+
assert.equal(parsed.activeTurns[1].completedAt, null);
|
|
239
|
+
assert.equal(parsed.activeTurns[2].completedAt, null);
|
|
240
|
+
} finally {
|
|
241
|
+
rmSync(home, { recursive: true, force: true });
|
|
242
|
+
rmSync(project, { recursive: true, force: true });
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
219
246
|
test('parseCodexRolloutFile: trim source can exclude the current in-flight turn', () => {
|
|
220
247
|
const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
|
|
221
248
|
const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
|
|
@@ -185,7 +185,7 @@ function normalizePath(value) {
|
|
|
185
185
|
} catch {
|
|
186
186
|
// Keep the lexical path when it cannot be resolved.
|
|
187
187
|
}
|
|
188
|
-
return resolved.split(sep).join('/').replace(/\/+$/, '')
|
|
188
|
+
return resolved.split(sep).join('/').replace(/\/+$/, '');
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
function isSameProjectOrDescendant(candidate, root) {
|
|
@@ -67,6 +67,24 @@ test('listCodexThreadCandidates: filters rollouts to current project and merges
|
|
|
67
67
|
}
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
+
test('listCodexThreadCandidates: POSIX project path matching preserves case', {
|
|
71
|
+
skip: process.platform === 'win32' ? 'Windows paths are case-insensitive' : undefined,
|
|
72
|
+
}, () => {
|
|
73
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
|
|
74
|
+
const root = mkdtempSync(join(tmpdir(), 'tl-codex-case-'));
|
|
75
|
+
const project = join(root, 'Project');
|
|
76
|
+
const other = join(root, 'project');
|
|
77
|
+
try {
|
|
78
|
+
writeRollout(home, { day: '06', started: '2026-05-06T09-40-50', id: '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9', cwd: project });
|
|
79
|
+
writeRollout(home, { day: '06', started: '2026-05-06T09-41-50', id: '019dfabb-1111-7111-8111-111111111111', cwd: other });
|
|
80
|
+
const candidates = listCodexThreadCandidates({ codexHome: home, projectPath: project });
|
|
81
|
+
assert.deepEqual(candidates.map((entry) => entry.cwd), [project]);
|
|
82
|
+
} finally {
|
|
83
|
+
rmSync(home, { recursive: true, force: true });
|
|
84
|
+
rmSync(root, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
70
88
|
test('listCodexThreadCandidates: sorts by rollout mtime rather than stale index updated_at', () => {
|
|
71
89
|
const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
|
|
72
90
|
const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
|