throughline 0.4.9 → 0.4.11

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.
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
2
2
  import { test } from 'node:test';
3
3
 
4
4
  import {
5
+ buildCodexAutoRefreshFingerprint,
5
6
  CODEX_AUTO_REFRESH_THRESHOLD,
6
7
  evaluateCodexAutoRefreshUsage,
7
8
  runCodexAutoRefresh,
@@ -48,12 +49,38 @@ test('evaluateCodexAutoRefreshUsage: estimate does not trigger mutation', () =>
48
49
  assert.equal(estimatedWindow.reason, 'estimated_context_window_not_allowed');
49
50
  });
50
51
 
52
+ test('runCodexAutoRefresh: disabled by default and does not inspect trim source', async () => {
53
+ let buildTrimSourceCalled = false;
54
+ const result = await runCodexAutoRefresh({
55
+ db: {},
56
+ threadId: '019dfaba-thread',
57
+ projectPath: '/repo',
58
+ usage: {
59
+ tokens: 240_000,
60
+ contextWindowSize: 258_400,
61
+ estimated: false,
62
+ contextWindowEstimated: false,
63
+ },
64
+ deps: {
65
+ buildTrimSource: () => {
66
+ buildTrimSourceCalled = true;
67
+ return null;
68
+ },
69
+ },
70
+ });
71
+
72
+ assert.equal(result.status, 'skipped');
73
+ assert.equal(result.reason, 'codex_auto_refresh_disabled');
74
+ assert.equal(buildTrimSourceCalled, false);
75
+ });
76
+
51
77
  test('runCodexAutoRefresh: below threshold skips before building trim source', async () => {
52
78
  let buildTrimSourceCalled = false;
53
79
  const result = await runCodexAutoRefresh({
54
80
  db: {},
55
81
  threadId: '019dfaba-thread',
56
82
  projectPath: '/repo',
83
+ enabled: true,
57
84
  usage: {
58
85
  tokens: 100_000,
59
86
  contextWindowSize: 258_400,
@@ -82,6 +109,7 @@ test('runCodexAutoRefresh: threshold reached rolls back and injects Throughline
82
109
  codexThreadIdSource: 'payload:session_id',
83
110
  projectPath: '/repo',
84
111
  sessionId: 'codex:019dfaba-thread',
112
+ enabled: true,
85
113
  usage: {
86
114
  tokens: 240_000,
87
115
  contextWindowSize: 258_400,
@@ -137,12 +165,159 @@ test('runCodexAutoRefresh: threshold reached rolls back and injects Throughline
137
165
  assert.match(runTrimExecutionArgs.memoryText, /Throughline: Active Work Context/);
138
166
  });
139
167
 
168
+ test('runCodexAutoRefresh: suppresses repeated execution for the same rollout and usage epoch', async () => {
169
+ let executionCalls = 0;
170
+ const stateStore = makeMemoryAutoRefreshStateStore();
171
+ const baseArgs = {
172
+ db: {},
173
+ threadId: '019dfaba-thread',
174
+ codexThreadIdSource: 'payload:session_id',
175
+ projectPath: '/repo',
176
+ sessionId: 'codex:019dfaba-thread',
177
+ enabled: true,
178
+ usage: {
179
+ tokens: 240_000,
180
+ contextWindowSize: 258_400,
181
+ estimated: false,
182
+ contextWindowEstimated: false,
183
+ },
184
+ autoRefreshStateStore: stateStore,
185
+ deps: {
186
+ buildTrimSource: () => ({
187
+ source: 'codex-rollout',
188
+ capturedTurns: 4,
189
+ memoryPreview: { text: 'rollout preview' },
190
+ stats: {
191
+ rollbackEvents: 0,
192
+ injectedDeveloperMessages: 0,
193
+ userMessagesAfterRollback: 0,
194
+ },
195
+ }),
196
+ buildTrimPlan: () => ({
197
+ status: 'ready',
198
+ trim: {
199
+ source: 'codex-rollout',
200
+ capturedTurns: 4,
201
+ rollbackTurns: 4,
202
+ rolloutPath: '/repo/rollout.jsonl',
203
+ rolloutStats: {
204
+ rollbackEvents: 0,
205
+ injectedDeveloperMessages: 0,
206
+ userMessagesAfterRollback: 0,
207
+ },
208
+ },
209
+ memoryPreview: {
210
+ text: '## Throughline: Active Work Context\n\nrecent work',
211
+ stats: { source: 'throughline-db' },
212
+ },
213
+ }),
214
+ runTrimExecution: async () => {
215
+ executionCalls++;
216
+ return {
217
+ rollbackSent: true,
218
+ injectSent: true,
219
+ postInjectVisibilityCheck: { status: 'match' },
220
+ };
221
+ },
222
+ },
223
+ };
224
+
225
+ const first = await runCodexAutoRefresh(baseArgs);
226
+ const second = await runCodexAutoRefresh(baseArgs);
227
+
228
+ assert.equal(first.status, 'refreshed-live');
229
+ assert.equal(second.status, 'skipped');
230
+ assert.equal(second.reason, 'auto_refresh_backoff');
231
+ assert.equal(executionCalls, 1);
232
+ });
233
+
234
+ test('runCodexAutoRefresh: durable success keeps the thread quiet after injection until a new user turn', async () => {
235
+ let executionCalls = 0;
236
+ const stateStore = makeMemoryAutoRefreshStateStore();
237
+ const makeArgs = ({ tokens, rollbackEvents, injectedDeveloperMessages, userMessagesAfterRollback }) => ({
238
+ db: {},
239
+ threadId: '019dfaba-thread',
240
+ projectPath: '/repo',
241
+ sessionId: 'codex:019dfaba-thread',
242
+ enabled: true,
243
+ usage: {
244
+ tokens,
245
+ contextWindowSize: 258_400,
246
+ estimated: false,
247
+ contextWindowEstimated: false,
248
+ },
249
+ autoRefreshStateStore: stateStore,
250
+ deps: {
251
+ buildTrimSource: () => ({
252
+ source: 'codex-rollout',
253
+ capturedTurns: 1,
254
+ memoryPreview: { text: 'rollout preview' },
255
+ stats: { rollbackEvents, injectedDeveloperMessages, userMessagesAfterRollback },
256
+ }),
257
+ buildTrimPlan: () => ({
258
+ status: 'ready',
259
+ trim: {
260
+ source: 'codex-rollout',
261
+ capturedTurns: 1,
262
+ rollbackTurns: 1,
263
+ rolloutPath: '/repo/rollout.jsonl',
264
+ rolloutStats: { rollbackEvents, injectedDeveloperMessages, userMessagesAfterRollback },
265
+ },
266
+ memoryPreview: {
267
+ text: '## Throughline: Active Work Context\n\nrecent work',
268
+ stats: { source: 'throughline-db' },
269
+ },
270
+ }),
271
+ runTrimExecution: async () => {
272
+ executionCalls++;
273
+ return {
274
+ rollbackSent: true,
275
+ injectSent: true,
276
+ postInjectVisibilityCheck: { status: 'match' },
277
+ };
278
+ },
279
+ },
280
+ });
281
+
282
+ const first = await runCodexAutoRefresh(
283
+ makeArgs({
284
+ tokens: 240_000,
285
+ rollbackEvents: 0,
286
+ injectedDeveloperMessages: 0,
287
+ userMessagesAfterRollback: 0,
288
+ }),
289
+ );
290
+ const afterInjection = await runCodexAutoRefresh(
291
+ makeArgs({
292
+ tokens: 252_000,
293
+ rollbackEvents: 1,
294
+ injectedDeveloperMessages: 1,
295
+ userMessagesAfterRollback: 0,
296
+ }),
297
+ );
298
+ const afterNewUserTurn = await runCodexAutoRefresh(
299
+ makeArgs({
300
+ tokens: 252_000,
301
+ rollbackEvents: 1,
302
+ injectedDeveloperMessages: 1,
303
+ userMessagesAfterRollback: 1,
304
+ }),
305
+ );
306
+
307
+ assert.equal(first.status, 'refreshed-live');
308
+ assert.equal(afterInjection.status, 'skipped');
309
+ assert.equal(afterInjection.reason, 'auto_refresh_backoff');
310
+ assert.equal(afterNewUserTurn.status, 'refreshed-live');
311
+ assert.equal(executionCalls, 2);
312
+ });
313
+
140
314
  test('runCodexAutoRefresh: threshold reached skips when injectable DB memory is missing', async () => {
141
315
  let runTrimExecutionCalled = false;
142
316
  const result = await runCodexAutoRefresh({
143
317
  db: {},
144
318
  threadId: '019dfaba-thread',
145
319
  projectPath: '/repo',
320
+ enabled: true,
146
321
  usage: {
147
322
  tokens: 240_000,
148
323
  contextWindowSize: 258_400,
@@ -180,3 +355,185 @@ test('runCodexAutoRefresh: threshold reached skips when injectable DB memory is
180
355
  assert.equal(result.reason, 'injectable_memory_required');
181
356
  assert.equal(runTrimExecutionCalled, false);
182
357
  });
358
+
359
+ test('runCodexAutoRefresh: noop and missing injectable memory are backed off for the same state', async () => {
360
+ const cases = [
361
+ {
362
+ name: 'nothing_to_trim',
363
+ plan: {
364
+ status: 'ready',
365
+ trim: {
366
+ source: 'codex-rollout',
367
+ capturedTurns: 2,
368
+ rollbackTurns: 0,
369
+ rolloutPath: '/repo/rollout.jsonl',
370
+ rolloutStats: { rollbackEvents: 0, injectedDeveloperMessages: 0, userMessagesAfterRollback: 0 },
371
+ },
372
+ memoryPreview: {
373
+ text: '## Throughline: Active Work Context\n\nrecent work',
374
+ stats: { source: 'throughline-db' },
375
+ },
376
+ },
377
+ },
378
+ {
379
+ name: 'injectable_memory_required',
380
+ plan: {
381
+ status: 'ready',
382
+ trim: {
383
+ source: 'codex-rollout',
384
+ capturedTurns: 2,
385
+ rollbackTurns: 2,
386
+ rolloutPath: '/repo/rollout.jsonl',
387
+ rolloutStats: { rollbackEvents: 0, injectedDeveloperMessages: 0, userMessagesAfterRollback: 0 },
388
+ },
389
+ memoryPreview: {
390
+ text: 'rollout preview only',
391
+ stats: { source: 'codex-rollout' },
392
+ },
393
+ },
394
+ },
395
+ ];
396
+
397
+ for (const fixture of cases) {
398
+ const stateStore = makeMemoryAutoRefreshStateStore();
399
+ const args = {
400
+ db: {},
401
+ threadId: `019dfaba-thread-${fixture.name}`,
402
+ projectPath: '/repo',
403
+ enabled: true,
404
+ usage: {
405
+ tokens: 240_000,
406
+ contextWindowSize: 258_400,
407
+ estimated: false,
408
+ contextWindowEstimated: false,
409
+ },
410
+ autoRefreshStateStore: stateStore,
411
+ deps: {
412
+ buildTrimSource: () => ({
413
+ source: 'codex-rollout',
414
+ capturedTurns: 2,
415
+ memoryPreview: { text: 'rollout preview' },
416
+ stats: { rollbackEvents: 0, injectedDeveloperMessages: 0, userMessagesAfterRollback: 0 },
417
+ }),
418
+ buildTrimPlan: () => fixture.plan,
419
+ runTrimExecution: async () => {
420
+ throw new Error('runTrimExecution should not be called');
421
+ },
422
+ },
423
+ };
424
+
425
+ const first = await runCodexAutoRefresh(args);
426
+ const second = await runCodexAutoRefresh(args);
427
+
428
+ assert.equal(first.status, 'skipped', fixture.name);
429
+ assert.equal(first.reason, fixture.name);
430
+ assert.equal(second.status, 'skipped', fixture.name);
431
+ assert.equal(second.reason, 'auto_refresh_backoff');
432
+ }
433
+ });
434
+
435
+ test('runCodexAutoRefresh: backoff still allows a new usage epoch or thread', async () => {
436
+ const stateStore = makeMemoryAutoRefreshStateStore();
437
+ const makeArgs = ({ threadId, tokens }) => ({
438
+ db: {},
439
+ threadId,
440
+ projectPath: '/repo',
441
+ enabled: true,
442
+ usage: {
443
+ tokens,
444
+ contextWindowSize: 258_400,
445
+ estimated: false,
446
+ contextWindowEstimated: false,
447
+ },
448
+ autoRefreshStateStore: stateStore,
449
+ deps: {
450
+ buildTrimSource: () => ({
451
+ source: 'codex-rollout',
452
+ capturedTurns: 2,
453
+ memoryPreview: { text: 'rollout preview' },
454
+ stats: { rollbackEvents: 0, injectedDeveloperMessages: 0, userMessagesAfterRollback: 0 },
455
+ }),
456
+ buildTrimPlan: () => ({
457
+ status: 'ready',
458
+ trim: {
459
+ source: 'codex-rollout',
460
+ capturedTurns: 2,
461
+ rollbackTurns: 2,
462
+ rolloutPath: '/repo/rollout.jsonl',
463
+ rolloutStats: { rollbackEvents: 0, injectedDeveloperMessages: 0, userMessagesAfterRollback: 0 },
464
+ },
465
+ memoryPreview: {
466
+ text: 'rollout preview only',
467
+ stats: { source: 'codex-rollout' },
468
+ },
469
+ }),
470
+ runTrimExecution: async () => {
471
+ throw new Error('runTrimExecution should not be called');
472
+ },
473
+ },
474
+ });
475
+
476
+ const first = await runCodexAutoRefresh(makeArgs({ threadId: 'thread-a', tokens: 240_000 }));
477
+ const repeated = await runCodexAutoRefresh(makeArgs({ threadId: 'thread-a', tokens: 240_000 }));
478
+ const usageAdvanced = await runCodexAutoRefresh(makeArgs({ threadId: 'thread-a', tokens: 252_000 }));
479
+ const newThread = await runCodexAutoRefresh(makeArgs({ threadId: 'thread-b', tokens: 240_000 }));
480
+
481
+ assert.equal(first.reason, 'injectable_memory_required');
482
+ assert.equal(repeated.reason, 'auto_refresh_backoff');
483
+ assert.equal(usageAdvanced.reason, 'injectable_memory_required');
484
+ assert.equal(newThread.reason, 'injectable_memory_required');
485
+ });
486
+
487
+ test('buildCodexAutoRefreshFingerprint: ignores live tool-loop row churn within a usage epoch', () => {
488
+ const base = {
489
+ threadId: 'thread-a',
490
+ projectPath: '/repo',
491
+ usage: {
492
+ tokens: 196_000,
493
+ contextWindowSize: 258_400,
494
+ estimated: false,
495
+ contextWindowEstimated: false,
496
+ source: 'codex-rollout-token-count-live-turn',
497
+ },
498
+ rolloutState: {
499
+ rolloutPath: '/repo/rollout.jsonl',
500
+ capturedTurns: 1,
501
+ rollbackTurns: null,
502
+ capturedRows: 2,
503
+ capturedDetails: 60,
504
+ rollbackEvents: 1,
505
+ rolledBackTurns: 2,
506
+ injectedDeveloperMessages: 1,
507
+ userMessagesAfterRollback: 0,
508
+ },
509
+ };
510
+
511
+ assert.equal(
512
+ buildCodexAutoRefreshFingerprint(base),
513
+ buildCodexAutoRefreshFingerprint({
514
+ ...base,
515
+ usage: {
516
+ ...base.usage,
517
+ tokens: 199_000,
518
+ },
519
+ rolloutState: {
520
+ ...base.rolloutState,
521
+ capturedRows: 3,
522
+ capturedDetails: 65,
523
+ },
524
+ }),
525
+ );
526
+ });
527
+
528
+ function makeMemoryAutoRefreshStateStore() {
529
+ const states = new Map();
530
+ return {
531
+ read(threadId) {
532
+ const state = states.get(threadId);
533
+ return state ? structuredClone(state) : null;
534
+ },
535
+ write(threadId, state) {
536
+ states.set(threadId, structuredClone(state));
537
+ },
538
+ };
539
+ }
@@ -1,4 +1,4 @@
1
- import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
1
+ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, realpathSync, statSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join, resolve, sep } from 'node:path';
4
4
 
@@ -169,5 +169,11 @@ function compareCandidates(a, b) {
169
169
  }
170
170
 
171
171
  function normalizePath(value) {
172
- return resolve(value).split(sep).join('/').replace(/\/+$/, '').toLowerCase();
172
+ let resolved = resolve(value);
173
+ try {
174
+ if (existsSync(resolved)) resolved = realpathSync.native(resolved);
175
+ } catch {
176
+ // Keep the lexical path when it cannot be resolved.
177
+ }
178
+ return resolved.split(sep).join('/').replace(/\/+$/, '').toLowerCase();
173
179
  }
@@ -0,0 +1,20 @@
1
+ import { existsSync, realpathSync } from 'node:fs';
2
+ import { platform } from 'node:os';
3
+ import { resolve, sep } from 'node:path';
4
+
5
+ export function normalizeProjectPathForCompare(value) {
6
+ if (!value) return '';
7
+ let resolved = resolve(String(value));
8
+ try {
9
+ if (existsSync(resolved)) resolved = realpathSync.native(resolved);
10
+ } catch {
11
+ // Fall back to the lexical path when the filesystem cannot resolve it.
12
+ }
13
+ let normalized = resolved.split(sep).join('/').replace(/\/+$/, '');
14
+ if (platform() === 'win32') normalized = normalized.toLowerCase();
15
+ return normalized;
16
+ }
17
+
18
+ export function sameProjectPath(a, b) {
19
+ return normalizeProjectPathForCompare(a) === normalizeProjectPathForCompare(b);
20
+ }
@@ -0,0 +1,4 @@
1
+ // Keep tests hermetic when they are run from inside a live Codex session.
2
+ // Individual tests that need these values set them explicitly in child env.
3
+ delete process.env.THROUGHLINE_CODEX_THREAD_ID;
4
+ delete process.env.CODEX_THREAD_ID;
@@ -137,6 +137,7 @@ rl.on('line', (line) => {
137
137
  send({ id: msg.id, error: { code: -32000, message: 'mutation must not be called' } });
138
138
  return;
139
139
  }
140
+ appendFileSync(log, 'ROLLBACK_TURNS:' + msg.params.numTurns + '\\n');
140
141
  turns = turns.slice(0, Math.max(0, turns.length - msg.params.numTurns));
141
142
  appendRollout({ type: 'event_msg', payload: { type: 'thread_rolled_back', num_turns: msg.params.numTurns } });
142
143
  send({ id: msg.id, result: { thread: { id: threadId, turns } } });
@@ -843,7 +844,7 @@ test('trim CLI preflight proceeds when compacted history already retains target
843
844
  }
844
845
  });
845
846
 
846
- test('trim CLI execute refuses before rollback when rollout and app-server turn counts differ', async () => {
847
+ test('trim CLI execute adjusts rollback turns from app-server count when counts differ', async () => {
847
848
  const home = makeTempHome();
848
849
  const codexHome = makeTempHome();
849
850
  const project = makeTempProject();
@@ -853,12 +854,12 @@ test('trim CLI execute refuses before rollback when rollout and app-server turn
853
854
  writeCodexRollout(codexHome, {
854
855
  project,
855
856
  threadId,
856
- turnCount: 22,
857
+ turnCount: 6,
857
858
  });
858
859
  const { script, log } = makeFakeCodexAppServer(project, {
859
860
  allowMutation: true,
860
861
  threadId,
861
- turnCount: 21,
862
+ turnCount: 7,
862
863
  });
863
864
  const result = runTrim(
864
865
  home,
@@ -868,8 +869,7 @@ test('trim CLI execute refuses before rollback when rollout and app-server turn
868
869
  'codex',
869
870
  '--codex-thread-id',
870
871
  threadId,
871
- '--keep-recent',
872
- '20',
872
+ '--all',
873
873
  '--execute',
874
874
  '--codex-app-server-bin',
875
875
  script,
@@ -881,17 +881,25 @@ test('trim CLI execute refuses before rollback when rollout and app-server turn
881
881
 
882
882
  assert.equal(result.status, 1);
883
883
  const payload = JSON.parse(result.stdout);
884
- assert.equal(payload.status, 'execute-refused');
885
- assert.equal(payload.reason, 'codex_rollout_app_server_turn_mismatch');
886
- assert.equal(payload.execution.rollbackSent, false);
887
- assert.equal(payload.execution.injectSent, false);
884
+ assert.notEqual(payload.status, 'execute-refused');
885
+ assert.equal(payload.execution.rollbackSent, true);
886
+ assert.equal(payload.execution.injectSent, true);
887
+ assert.equal(payload.execution.rollbackRequestedTurns, 7);
888
888
  assert.equal(payload.execution.turnCountCheck.status, 'mismatch');
889
+ assert.deepEqual(payload.execution.rollbackResolution, {
890
+ plannedRollbackTurns: 6,
891
+ requestedRollbackTurns: 7,
892
+ adjustment: 1,
893
+ basis: 'app_server_turn_count',
894
+ reason: 'adjusted_by_app_server_turn_delta',
895
+ });
889
896
 
890
897
  const calledMethods = readFileSync(log, 'utf8');
891
898
  assert.match(calledMethods, /thread\/read/);
892
899
  assert.match(calledMethods, /thread\/resume/);
893
- assert.doesNotMatch(calledMethods, /thread\/rollback/);
894
- assert.doesNotMatch(calledMethods, /thread\/inject_items/);
900
+ assert.match(calledMethods, /thread\/rollback/);
901
+ assert.match(calledMethods, /ROLLBACK_TURNS:7/);
902
+ assert.match(calledMethods, /thread\/inject_items/);
895
903
  } finally {
896
904
  rmSync(project, { recursive: true, force: true });
897
905
  rmSync(home, { recursive: true, force: true });
@@ -1,5 +1,6 @@
1
1
  import { inspectCodexPlannedRollbackRestoreSafety } from './codex-rollout-memory.mjs';
2
2
  import { buildHandoffRecord, N_RECENT_L2 } from './handoff-record.mjs';
3
+ import { sameProjectPath } from './project-path.mjs';
3
4
  import { estimateTokens } from './token-estimator.mjs';
4
5
 
5
6
  export const DEFAULT_TRIM_KEEP_RECENT = N_RECENT_L2;
@@ -31,15 +32,14 @@ function loadSession(db, sessionId) {
31
32
  export function findLatestSessionIdForProject(db, projectPath) {
32
33
  if (!projectPath) return null;
33
34
  try {
34
- const row = db
35
+ const rows = db
35
36
  .prepare(
36
- `SELECT session_id
37
+ `SELECT session_id, project_path
37
38
  FROM sessions
38
- WHERE lower(project_path) = lower(?)
39
- ORDER BY updated_at DESC
40
- LIMIT 1`,
39
+ ORDER BY updated_at DESC`,
41
40
  )
42
- .get(projectPath);
41
+ .all();
42
+ const row = rows.find((candidate) => sameProjectPath(candidate.project_path, projectPath));
43
43
  return row?.session_id ?? null;
44
44
  } catch {
45
45
  return null;