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.
- package/CHANGELOG.md +35 -3
- package/README.ja.md +27 -0
- package/README.md +58 -31
- package/bin/throughline.mjs +4 -2
- package/codex/skills/throughline/SKILL.md +30 -29
- package/codex/skills/throughline/agents/openai.yaml +2 -2
- package/docs/THROUGHLINE_CODEX_DUAL_SUPPORT.md +1 -1
- package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +19 -19
- package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +6 -6
- package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +28 -17
- package/docs/throughline-rollback-context-trim-insight.md +15 -9
- package/package.json +2 -2
- package/src/cli/codex-handoff-model-smoke.mjs +7 -7
- package/src/cli/codex-handoff-smoke.mjs +7 -7
- package/src/cli/codex-handoff-start.mjs +184 -10
- package/src/cli/codex-handoff-start.test.mjs +80 -1
- package/src/cli/codex-hook.mjs +37 -110
- package/src/cli/codex-hook.test.mjs +147 -32
- package/src/cli/codex-resume.mjs +7 -7
- package/src/cli/codex-summarize.mjs +7 -7
- package/src/cli/handoff-preview.mjs +6 -6
- package/src/cli/help.test.mjs +3 -1
- package/src/cli/install.mjs +42 -6
- package/src/cli/install.test.mjs +39 -5
- package/src/cli/trim.mjs +0 -9
- package/src/codex-app-server.mjs +216 -19
- package/src/codex-app-server.test.mjs +53 -0
- package/src/codex-auto-refresh.mjs +361 -29
- package/src/codex-auto-refresh.test.mjs +357 -0
- package/src/codex-thread-index.mjs +8 -2
- package/src/project-path.mjs +20 -0
- package/src/test-env.mjs +4 -0
- package/src/trim-cli.test.mjs +19 -11
- package/src/trim-model.mjs +6 -6
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/test-env.mjs
ADDED
package/src/trim-cli.test.mjs
CHANGED
|
@@ -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
|
|
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:
|
|
857
|
+
turnCount: 6,
|
|
857
858
|
});
|
|
858
859
|
const { script, log } = makeFakeCodexAppServer(project, {
|
|
859
860
|
allowMutation: true,
|
|
860
861
|
threadId,
|
|
861
|
-
turnCount:
|
|
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
|
-
'--
|
|
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.
|
|
885
|
-
assert.equal(payload.
|
|
886
|
-
assert.equal(payload.execution.
|
|
887
|
-
assert.equal(payload.execution.
|
|
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.
|
|
894
|
-
assert.
|
|
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 });
|
package/src/trim-model.mjs
CHANGED
|
@@ -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
|
|
35
|
+
const rows = db
|
|
35
36
|
.prepare(
|
|
36
|
-
`SELECT session_id
|
|
37
|
+
`SELECT session_id, project_path
|
|
37
38
|
FROM sessions
|
|
38
|
-
|
|
39
|
-
ORDER BY updated_at DESC
|
|
40
|
-
LIMIT 1`,
|
|
39
|
+
ORDER BY updated_at DESC`,
|
|
41
40
|
)
|
|
42
|
-
.
|
|
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;
|