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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
OBSERVER_WAIT_SCHEMA,
|
|
5
|
+
DEFAULT_OBSERVER_WAIT_TIMEOUT_SECONDS,
|
|
6
|
+
waitForObserverTurnChange,
|
|
7
|
+
} from '../observer-turn-wait.mjs';
|
|
8
|
+
|
|
9
|
+
const ERRORS = Object.freeze({
|
|
10
|
+
args: { schema: OBSERVER_WAIT_SCHEMA, status: 'error', code: 'E_OBSERVER_WAIT_ARGS', message: 'invalid observer-wait arguments' },
|
|
11
|
+
input: { schema: OBSERVER_WAIT_SCHEMA, status: 'error', code: 'E_OBSERVER_WAIT_INPUT', message: 'observer wait input is invalid' },
|
|
12
|
+
cancelled: { schema: OBSERVER_WAIT_SCHEMA, status: 'error', code: 'E_OBSERVER_WAIT_CANCELLED', message: 'observer wait was cancelled' },
|
|
13
|
+
internal: { schema: OBSERVER_WAIT_SCHEMA, status: 'error', code: 'E_OBSERVER_WAIT_INTERNAL', message: 'observer wait failed' },
|
|
14
|
+
});
|
|
15
|
+
const PARENT_WATCH_INTERVAL_MS = 1000;
|
|
16
|
+
|
|
17
|
+
export function parseArgs(argv = []) {
|
|
18
|
+
const out = { projectPath: null, afterCursor: null, timeoutSeconds: DEFAULT_OBSERVER_WAIT_TIMEOUT_SECONDS, json: false };
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
for (let index = 0; index < argv.length; index++) {
|
|
21
|
+
const arg = argv[index];
|
|
22
|
+
if (arg === '--json') {
|
|
23
|
+
if (seen.has(arg)) throw new TypeError('duplicate option');
|
|
24
|
+
seen.add(arg); out.json = true; continue;
|
|
25
|
+
}
|
|
26
|
+
if (!['--project', '--after-cursor', '--timeout-seconds'].includes(arg) || seen.has(arg)) {
|
|
27
|
+
throw new TypeError('invalid option');
|
|
28
|
+
}
|
|
29
|
+
const value = argv[++index];
|
|
30
|
+
if (!value || value.startsWith('-')) throw new TypeError('missing option value');
|
|
31
|
+
seen.add(arg);
|
|
32
|
+
if (arg === '--project') out.projectPath = value;
|
|
33
|
+
else if (arg === '--after-cursor') out.afterCursor = value;
|
|
34
|
+
else out.timeoutSeconds = parseTimeout(value);
|
|
35
|
+
}
|
|
36
|
+
if (!out.json || !out.projectPath || !out.afterCursor) throw new TypeError('missing required option');
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function run(argv = [], {
|
|
41
|
+
wait = waitForObserverTurnChange,
|
|
42
|
+
validateProject = assertProjectDirectory,
|
|
43
|
+
stdout = process.stdout,
|
|
44
|
+
stderr = process.stderr,
|
|
45
|
+
processRef = process,
|
|
46
|
+
setIntervalFn = setInterval,
|
|
47
|
+
clearIntervalFn = clearInterval,
|
|
48
|
+
parentWatchIntervalMs = PARENT_WATCH_INTERVAL_MS,
|
|
49
|
+
} = {}) {
|
|
50
|
+
let args;
|
|
51
|
+
try { args = parseArgs(argv); } catch { writeJson(stderr, ERRORS.args); return 1; }
|
|
52
|
+
|
|
53
|
+
try { validateProject(args.projectPath); } catch { writeJson(stderr, ERRORS.input); return 1; }
|
|
54
|
+
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
const cleanup = installCancellation({ controller, processRef, setIntervalFn, clearIntervalFn, parentWatchIntervalMs });
|
|
57
|
+
try {
|
|
58
|
+
const result = await wait({
|
|
59
|
+
projectPath: args.projectPath,
|
|
60
|
+
afterCursor: args.afterCursor,
|
|
61
|
+
timeoutSeconds: args.timeoutSeconds,
|
|
62
|
+
signal: controller.signal,
|
|
63
|
+
});
|
|
64
|
+
writeJson(stdout, result);
|
|
65
|
+
return 0;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
writeJson(stderr, mapError(error));
|
|
68
|
+
return 1;
|
|
69
|
+
} finally {
|
|
70
|
+
cleanup();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseTimeout(value) {
|
|
75
|
+
if (!/^\d+$/.test(value)) throw new TypeError('invalid timeout');
|
|
76
|
+
const timeout = Number(value);
|
|
77
|
+
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 3600) throw new TypeError('invalid timeout');
|
|
78
|
+
return timeout;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function assertProjectDirectory(projectPath) {
|
|
82
|
+
if (!isAbsolute(projectPath) || !statSync(projectPath).isDirectory()) {
|
|
83
|
+
throw new TypeError('project must be an absolute existing directory');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function installCancellation({ controller, processRef, setIntervalFn, clearIntervalFn, parentWatchIntervalMs }) {
|
|
88
|
+
const abort = () => controller.abort();
|
|
89
|
+
const parentPid = processRef.ppid;
|
|
90
|
+
processRef.on('SIGINT', abort);
|
|
91
|
+
processRef.on('SIGTERM', abort);
|
|
92
|
+
processRef.on('disconnect', abort);
|
|
93
|
+
|
|
94
|
+
let parentTimer = null;
|
|
95
|
+
if (Number.isSafeInteger(parentPid) && parentPid > 1) {
|
|
96
|
+
const checkParent = () => {
|
|
97
|
+
if (processRef.ppid !== parentPid) return abort();
|
|
98
|
+
try {
|
|
99
|
+
processRef.kill(parentPid, 0);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error?.code === 'ESRCH') abort();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
parentTimer = setIntervalFn(checkParent, parentWatchIntervalMs);
|
|
105
|
+
parentTimer?.unref?.();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return () => {
|
|
109
|
+
processRef.removeListener('SIGINT', abort);
|
|
110
|
+
processRef.removeListener('SIGTERM', abort);
|
|
111
|
+
processRef.removeListener('disconnect', abort);
|
|
112
|
+
if (parentTimer !== null) clearIntervalFn(parentTimer);
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function mapError(error) {
|
|
117
|
+
if (error?.code === 'E_OBSERVER_WAIT_CANCELLED') return ERRORS.cancelled;
|
|
118
|
+
return ERRORS.internal;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function writeJson(stream, value) { stream.write(`${JSON.stringify(value)}\n`); }
|
|
122
|
+
|
|
123
|
+
export const _internal = { assertProjectDirectory, installCancellation, PARENT_WATCH_INTERVAL_MS };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { EventEmitter, getEventListeners } from 'node:events';
|
|
4
|
+
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import test from 'node:test';
|
|
9
|
+
import { OBSERVER_WAIT_SCHEMA } from '../observer-turn-wait.mjs';
|
|
10
|
+
import { parseArgs, run } from './observer-wait.mjs';
|
|
11
|
+
|
|
12
|
+
const REPO_ROOT = join(fileURLToPath(new URL('../..', import.meta.url)));
|
|
13
|
+
const BIN_PATH = join(REPO_ROOT, 'bin/throughline.mjs');
|
|
14
|
+
const AFTER = 'tlc1.after';
|
|
15
|
+
|
|
16
|
+
function streams() {
|
|
17
|
+
return { stdout: { text: '', write(value) { this.text += value; } }, stderr: { text: '', write(value) { this.text += value; } } };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fakeProcess({ ppid = 42, kill = () => {} } = {}) {
|
|
21
|
+
const result = new EventEmitter();
|
|
22
|
+
result.ppid = ppid;
|
|
23
|
+
result.kill = kill;
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('observer-wait strictly parses its public arguments', async () => {
|
|
28
|
+
assert.deepEqual(parseArgs(['--project', '/repo', '--after-cursor', AFTER, '--timeout-seconds', '1', '--json']), {
|
|
29
|
+
projectPath: '/repo', afterCursor: AFTER, timeoutSeconds: 1, json: true,
|
|
30
|
+
});
|
|
31
|
+
assert.equal(parseArgs(['--project', '/repo', '--after-cursor', AFTER, '--json']).timeoutSeconds, 3600);
|
|
32
|
+
for (const argv of [
|
|
33
|
+
['--project', '/repo', '--project', '/repo', '--after-cursor', AFTER, '--json'],
|
|
34
|
+
['--project', '/repo', '--after-cursor', AFTER, '--json', '--json'],
|
|
35
|
+
['--project', '/repo', '--after-cursor', AFTER, '--timeout-seconds', '0', '--json'],
|
|
36
|
+
['--project', '/repo', '--after-cursor', AFTER, '--timeout-seconds', '3601', '--json'],
|
|
37
|
+
['--project', '/repo', '--after-cursor', AFTER, 'extra', '--json'],
|
|
38
|
+
['--project', '/repo', '--json'],
|
|
39
|
+
]) {
|
|
40
|
+
const io = streams();
|
|
41
|
+
assert.equal(await run(argv, { ...io }), 1);
|
|
42
|
+
assert.equal(io.stdout.text, '');
|
|
43
|
+
assert.deepEqual(JSON.parse(io.stderr.text), { schema: OBSERVER_WAIT_SCHEMA, status: 'error', code: 'E_OBSERVER_WAIT_ARGS', message: 'invalid observer-wait arguments' });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('observer-wait returns each known core status as successful JSON', async () => {
|
|
48
|
+
for (const [status, throughCursor] of [['changed', 'tlc1.changed'], ['timeout', AFTER], ['resync_required', null], ['ambiguous_parent', null]]) {
|
|
49
|
+
const io = streams();
|
|
50
|
+
assert.equal(await run(['--project', '/repo', '--after-cursor', AFTER, '--json'], {
|
|
51
|
+
...io,
|
|
52
|
+
validateProject: () => {},
|
|
53
|
+
processRef: fakeProcess(),
|
|
54
|
+
setIntervalFn: () => ({ unref() {} }), clearIntervalFn: () => {},
|
|
55
|
+
wait: async () => ({ schema: OBSERVER_WAIT_SCHEMA, status, afterCursor: AFTER, throughCursor }),
|
|
56
|
+
}), 0);
|
|
57
|
+
assert.deepEqual(JSON.parse(io.stdout.text), { schema: OBSERVER_WAIT_SCHEMA, status, afterCursor: AFTER, throughCursor });
|
|
58
|
+
assert.equal(io.stderr.text, '');
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('observer-wait maps project, cancel, and internal failures to fixed errors without leakage', async () => {
|
|
63
|
+
for (const [dependencies, code] of [
|
|
64
|
+
[{ validateProject: () => { throw new Error('/private/project'); } }, 'E_OBSERVER_WAIT_INPUT'],
|
|
65
|
+
[{ wait: async () => { throw Object.assign(new Error('secret cursor'), { code: 'E_OBSERVER_WAIT_CANCELLED' }); } }, 'E_OBSERVER_WAIT_CANCELLED'],
|
|
66
|
+
[{ wait: async () => { throw new Error('secret /private/cursor'); } }, 'E_OBSERVER_WAIT_INTERNAL'],
|
|
67
|
+
]) {
|
|
68
|
+
const io = streams();
|
|
69
|
+
assert.equal(await run(['--project', '/repo', '--after-cursor', AFTER, '--json'], {
|
|
70
|
+
...io, validateProject: () => {}, processRef: fakeProcess(),
|
|
71
|
+
setIntervalFn: () => ({ unref() {} }), clearIntervalFn: () => {}, ...dependencies,
|
|
72
|
+
}), 1);
|
|
73
|
+
assert.equal(io.stdout.text, '');
|
|
74
|
+
assert.equal(JSON.parse(io.stderr.text).code, code);
|
|
75
|
+
assert.doesNotMatch(io.stderr.text, /private|secret|cursor/i);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('observer-wait cancels on signals and removes listeners and parent watcher', async () => {
|
|
80
|
+
for (const event of ['SIGINT', 'SIGTERM', 'disconnect']) {
|
|
81
|
+
const io = streams();
|
|
82
|
+
const processRef = fakeProcess();
|
|
83
|
+
let checkParent;
|
|
84
|
+
let cleared = 0;
|
|
85
|
+
const pending = run(['--project', '/repo', '--after-cursor', AFTER, '--json'], {
|
|
86
|
+
...io, validateProject: () => {}, processRef,
|
|
87
|
+
setIntervalFn(callback) { checkParent = callback; return { unref() {} }; },
|
|
88
|
+
clearIntervalFn() { cleared++; },
|
|
89
|
+
wait: ({ signal }) => new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(Object.assign(new Error('cancelled'), { code: 'E_OBSERVER_WAIT_CANCELLED' })), { once: true })),
|
|
90
|
+
});
|
|
91
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
92
|
+
assert.equal(getEventListeners(processRef, 'SIGINT').length, 1);
|
|
93
|
+
assert.equal(getEventListeners(processRef, 'SIGTERM').length, 1);
|
|
94
|
+
assert.equal(getEventListeners(processRef, 'disconnect').length, 1);
|
|
95
|
+
assert.equal(typeof checkParent, 'function');
|
|
96
|
+
processRef.emit(event);
|
|
97
|
+
assert.equal(await pending, 1);
|
|
98
|
+
assert.equal(JSON.parse(io.stderr.text).code, 'E_OBSERVER_WAIT_CANCELLED');
|
|
99
|
+
assert.equal(cleared, 1);
|
|
100
|
+
assert.equal(getEventListeners(processRef, 'SIGINT').length, 0);
|
|
101
|
+
assert.equal(getEventListeners(processRef, 'SIGTERM').length, 0);
|
|
102
|
+
assert.equal(getEventListeners(processRef, 'disconnect').length, 0);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('observer-wait treats changed parent or ESRCH as cancellation but not EPERM', async () => {
|
|
107
|
+
for (const [mode, kill] of [
|
|
108
|
+
['ppid', () => {}],
|
|
109
|
+
['esrch', () => { throw Object.assign(new Error('gone'), { code: 'ESRCH' }); }],
|
|
110
|
+
]) {
|
|
111
|
+
const io = streams();
|
|
112
|
+
const processRef = fakeProcess({ kill });
|
|
113
|
+
let checkParent;
|
|
114
|
+
const pending = run(['--project', '/repo', '--after-cursor', AFTER, '--json'], {
|
|
115
|
+
...io, validateProject: () => {}, processRef,
|
|
116
|
+
setIntervalFn(callback) { checkParent = callback; return { unref() {} }; }, clearIntervalFn: () => {},
|
|
117
|
+
wait: ({ signal }) => new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(Object.assign(new Error('cancelled'), { code: 'E_OBSERVER_WAIT_CANCELLED' })), { once: true })),
|
|
118
|
+
});
|
|
119
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
120
|
+
if (mode === 'ppid') processRef.ppid = 1;
|
|
121
|
+
checkParent();
|
|
122
|
+
assert.equal(await pending, 1);
|
|
123
|
+
assert.equal(JSON.parse(io.stderr.text).code, 'E_OBSERVER_WAIT_CANCELLED');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const io = streams();
|
|
127
|
+
const processRef = fakeProcess({ kill: () => { throw Object.assign(new Error('denied'), { code: 'EPERM' }); } });
|
|
128
|
+
let checkParent;
|
|
129
|
+
const pending = run(['--project', '/repo', '--after-cursor', AFTER, '--json'], {
|
|
130
|
+
...io, validateProject: () => {}, processRef,
|
|
131
|
+
setIntervalFn(callback) { checkParent = callback; return { unref() {} }; }, clearIntervalFn: () => {},
|
|
132
|
+
wait: ({ signal }) => new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(Object.assign(new Error('cancelled'), { code: 'E_OBSERVER_WAIT_CANCELLED' })), { once: true })),
|
|
133
|
+
});
|
|
134
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
135
|
+
checkParent();
|
|
136
|
+
assert.equal(io.stderr.text, '');
|
|
137
|
+
processRef.emit('disconnect');
|
|
138
|
+
assert.equal(await pending, 1);
|
|
139
|
+
assert.equal(JSON.parse(io.stderr.text).code, 'E_OBSERVER_WAIT_CANCELLED');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('observer-wait bin dispatch and help use the JSON-only contract', async () => {
|
|
143
|
+
const help = spawnSync(process.execPath, [BIN_PATH, '--help'], { cwd: REPO_ROOT, encoding: 'utf8' });
|
|
144
|
+
assert.equal(help.status, 0, help.stderr);
|
|
145
|
+
assert.match(help.stdout, /throughline observer-wait --project <absolute-directory> --after-cursor <opaque> --json/);
|
|
146
|
+
|
|
147
|
+
const root = await mkdtemp(join(tmpdir(), 'throughline-observer-wait-'));
|
|
148
|
+
const home = join(root, 'home');
|
|
149
|
+
const state = join(root, 'state');
|
|
150
|
+
const codexHome = join(root, 'codex');
|
|
151
|
+
const project = join(root, 'project');
|
|
152
|
+
try {
|
|
153
|
+
await Promise.all([mkdir(home), mkdir(state), mkdir(codexHome), mkdir(project)]);
|
|
154
|
+
const result = spawnSync(process.execPath, [BIN_PATH, 'observer-wait', '--project', project, '--after-cursor', 'invalid', '--json'], {
|
|
155
|
+
cwd: REPO_ROOT, encoding: 'utf8', timeout: 5000,
|
|
156
|
+
env: { ...process.env, HOME: home, USERPROFILE: home, XDG_STATE_HOME: state, CODEX_HOME: codexHome },
|
|
157
|
+
});
|
|
158
|
+
assert.equal(result.status, 0, result.stderr);
|
|
159
|
+
assert.equal(result.stderr, '');
|
|
160
|
+
const output = JSON.parse(result.stdout);
|
|
161
|
+
assert.equal(output.schema, OBSERVER_WAIT_SCHEMA);
|
|
162
|
+
assert.equal(output.status, 'resync_required');
|
|
163
|
+
assert.equal(output.throughCursor, null);
|
|
164
|
+
} finally {
|
|
165
|
+
await rm(root, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
@@ -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-'));
|