|
|
@@ -30,6 +30,13 @@ afterEach(() => {
|
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
|
});
|
|
|
|
|
|
+const LUNA = { agentName: 'explorer', model: 'openai/gpt-5.6-luna' } as const;
|
|
|
+const GPT = { agentName: 'explorer', model: 'openai/gpt-5.6' } as const;
|
|
|
+
|
|
|
+function recordLuna(): void {
|
|
|
+ recordTuiAgentModel(LUNA, tempDir);
|
|
|
+}
|
|
|
+
|
|
|
describe('tui-state persistence', () => {
|
|
|
test('persists enabled agent models', () => {
|
|
|
recordTuiAgentModels(
|
|
|
@@ -320,24 +327,147 @@ describe('tui-state persistence', () => {
|
|
|
});
|
|
|
|
|
|
test('skips the disk write when the recorded value is unchanged', () => {
|
|
|
- recordTuiAgentModel(
|
|
|
- { agentName: 'explorer', model: 'openai/gpt-5.6-luna' },
|
|
|
- tempDir,
|
|
|
- );
|
|
|
-
|
|
|
+ recordLuna();
|
|
|
const filePath = getTuiStatePath(tempDir);
|
|
|
const oldMtime = new Date('2000-01-01T00:00:00Z');
|
|
|
fs.utimesSync(filePath, oldMtime, oldMtime);
|
|
|
const baselineMtime = fs.statSync(filePath).mtimeMs;
|
|
|
+ recordLuna();
|
|
|
+ expect(fs.statSync(filePath).mtimeMs).toBe(baselineMtime);
|
|
|
+ });
|
|
|
+
|
|
|
+ test('repeated no-op updates do not touch the lock or the filesystem', async () => {
|
|
|
+ recordLuna();
|
|
|
+ const fsModule = await import('node:fs');
|
|
|
+ let openCalls = 0;
|
|
|
+ let readCalls = 0;
|
|
|
+ const lockCreateSpy = spyOn(fsModule, 'openSync').mockImplementation(
|
|
|
+ (...args: Parameters<typeof fs.openSync>) => {
|
|
|
+ openCalls += 1;
|
|
|
+ return fs.openSync(...args);
|
|
|
+ },
|
|
|
+ );
|
|
|
+ const readSpy = spyOn(fsModule, 'readFileSync').mockImplementation(
|
|
|
+ (...args: Parameters<typeof fs.readFileSync>) => {
|
|
|
+ readCalls += 1;
|
|
|
+ return fs.readFileSync(...args);
|
|
|
+ },
|
|
|
+ );
|
|
|
+ try {
|
|
|
+ recordLuna();
|
|
|
+ recordLuna();
|
|
|
+ expect(openCalls).toBe(0);
|
|
|
+ expect(readCalls).toBe(0);
|
|
|
+ } finally {
|
|
|
+ lockCreateSpy.mockRestore();
|
|
|
+ readSpy.mockRestore();
|
|
|
+ }
|
|
|
+ });
|
|
|
|
|
|
- // Same agent, same model: nothing changed, so the file must not be
|
|
|
- // rewritten (a write would bump the mtime from 2000 back to "now").
|
|
|
+ test('a real change after a no-op still reaches the disk', () => {
|
|
|
+ recordLuna();
|
|
|
+ recordLuna();
|
|
|
+ recordTuiAgentModel(GPT, tempDir);
|
|
|
+ expect(readTuiSnapshot(tempDir).agentModels.explorer).toBe(GPT.model);
|
|
|
+ });
|
|
|
+
|
|
|
+ test('external snapshot changes are not masked by the memo', () => {
|
|
|
+ recordLuna();
|
|
|
+ const external = readTuiSnapshot(tempDir);
|
|
|
+ external.agentModels.builder = GPT.model;
|
|
|
+ fs.writeFileSync(getTuiStatePath(tempDir), `${JSON.stringify(external)}\n`);
|
|
|
recordTuiAgentModel(
|
|
|
- { agentName: 'explorer', model: 'openai/gpt-5.6-luna' },
|
|
|
+ { agentName: 'build', model: 'anthropic/claude-x' },
|
|
|
tempDir,
|
|
|
);
|
|
|
+ const snapshot = readTuiSnapshot(tempDir);
|
|
|
+ expect(snapshot.agentModels.builder).toBe(GPT.model);
|
|
|
+ expect(snapshot.agentModels.build).toBe('anthropic/claude-x');
|
|
|
+ });
|
|
|
|
|
|
- expect(fs.statSync(filePath).mtimeMs).toBe(baselineMtime);
|
|
|
+ test('a failed persistence is retried, not swallowed by the memo', async () => {
|
|
|
+ recordLuna();
|
|
|
+ const fsModule = await import('node:fs');
|
|
|
+ const renameSpy = spyOn(fsModule, 'renameSync').mockImplementation(() => {
|
|
|
+ throw new Error('disk full');
|
|
|
+ });
|
|
|
+ try {
|
|
|
+ recordTuiAgentModel(GPT, tempDir);
|
|
|
+ } finally {
|
|
|
+ renameSpy.mockRestore();
|
|
|
+ }
|
|
|
+ recordTuiAgentModel(GPT, tempDir);
|
|
|
+ expect(readTuiSnapshot(tempDir).agentModels.explorer).toBe(GPT.model);
|
|
|
+ });
|
|
|
+
|
|
|
+ test('external atomic replacement invalidates the memo even with identical mtime and size', () => {
|
|
|
+ recordTuiAgentModel({ agentName: 'explorer', model: 'model-x' }, tempDir);
|
|
|
+ const filePath = getTuiStatePath(tempDir);
|
|
|
+ const external = readTuiSnapshot(tempDir);
|
|
|
+ external.agentModels.explorer = 'model-y';
|
|
|
+ const tmpPath = `${filePath}.external.tmp`;
|
|
|
+ fs.writeFileSync(tmpPath, `${JSON.stringify(external)}\n`);
|
|
|
+ const stat = fs.statSync(filePath);
|
|
|
+ fs.renameSync(tmpPath, filePath);
|
|
|
+ fs.utimesSync(filePath, stat.atime, stat.mtime);
|
|
|
+ recordTuiAgentModel({ agentName: 'explorer', model: 'model-x' }, tempDir);
|
|
|
+ expect(readTuiSnapshot(tempDir).agentModels.explorer).toBe('model-x');
|
|
|
+ });
|
|
|
+
|
|
|
+ test('in-place rewrite preserving mtime is caught by ctime', () => {
|
|
|
+ recordTuiAgentModel({ agentName: 'explorer', model: 'model-x' }, tempDir);
|
|
|
+ const filePath = getTuiStatePath(tempDir);
|
|
|
+
|
|
|
+ // Pin mtime to a whole-millisecond date (utimes cannot restore
|
|
|
+ // sub-millisecond precision), then re-prime the memo so it holds a
|
|
|
+ // stat snapshot of this exact state.
|
|
|
+ const pinned = new Date('2000-01-01T00:00:00Z');
|
|
|
+ fs.utimesSync(filePath, pinned, pinned);
|
|
|
+ recordTuiAgentModel({ agentName: 'explorer', model: 'model-x' }, tempDir);
|
|
|
+ const statBefore = fs.statSync(filePath);
|
|
|
+
|
|
|
+ // In-place rewrite (same inode, same length): mtime restored via
|
|
|
+ // utimes. ctime cannot be restored by userspace, so the memo must
|
|
|
+ // invalidate and re-record the value from the real file.
|
|
|
+ const external = readTuiSnapshot(tempDir);
|
|
|
+ external.agentModels.explorer = 'model-y';
|
|
|
+ const fd = fs.openSync(filePath, 'w');
|
|
|
+ fs.writeSync(fd, `${JSON.stringify(external)}\n`);
|
|
|
+ fs.closeSync(fd);
|
|
|
+ fs.utimesSync(filePath, statBefore.atime, statBefore.mtime);
|
|
|
+ const statAfter = fs.statSync(filePath);
|
|
|
+ expect(statAfter.ino).toBe(statBefore.ino);
|
|
|
+ expect(statAfter.mtimeMs).toBe(statBefore.mtimeMs);
|
|
|
+
|
|
|
+ recordTuiAgentModel({ agentName: 'explorer', model: 'model-x' }, tempDir);
|
|
|
+ expect(readTuiSnapshot(tempDir).agentModels.explorer).toBe('model-x');
|
|
|
+ });
|
|
|
+
|
|
|
+ test('a transient read failure does not seed the memo with an empty snapshot', async () => {
|
|
|
+ recordTuiAgentActivity(
|
|
|
+ { sessionID: 's1', agentName: 'oracle', active: true },
|
|
|
+ tempDir,
|
|
|
+ );
|
|
|
+ const filePath = getTuiStatePath(tempDir);
|
|
|
+ const fsModule = await import('node:fs');
|
|
|
+ const originalRead = fsModule.readFileSync;
|
|
|
+ const readSpy = spyOn(fsModule, 'readFileSync').mockImplementation(
|
|
|
+ (path: fs.PathOrFileDescriptor, ...args: unknown[]) => {
|
|
|
+ if (String(path) === filePath) {
|
|
|
+ const err = new Error('transient') as NodeJS.ErrnoException;
|
|
|
+ err.code = 'EACCES';
|
|
|
+ throw err;
|
|
|
+ }
|
|
|
+ return originalRead(path as fs.PathOrFileDescriptor, ...(args as []));
|
|
|
+ },
|
|
|
+ );
|
|
|
+ try {
|
|
|
+ recordTuiAgentActivity({ sessionID: 's1', active: false }, tempDir);
|
|
|
+ } finally {
|
|
|
+ readSpy.mockRestore();
|
|
|
+ }
|
|
|
+ recordTuiAgentActivity({ sessionID: 's1', active: false }, tempDir);
|
|
|
+ expect(readTuiSnapshot(tempDir).activeSessions).toEqual({});
|
|
|
});
|
|
|
|
|
|
test('keeps the final file intact when the atomic rename fails', async () => {
|