Skip to content

Commit ed2474c

Browse files
fix(acp): recover from persist() failure instead of cascading (#3383)
Closes #3366. persist() now catches errors, resets writeChain, and logs failures. Subsequent writes get a fresh attempt.
1 parent 9243cb9 commit ed2474c

2 files changed

Lines changed: 166 additions & 6 deletions

File tree

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* Regression tests for #3366: ACP local storage persist() failure cascade.
3+
*
4+
* Bug: FileAcpLocalStorageProfile.persist() chains writes onto writeChain
5+
* without error recovery. A single failed write poisons all subsequent writes
6+
* because every .then() chains onto the rejected promise.
7+
*
8+
* Fix: persist() now catches errors, resets the chain, and logs the failure.
9+
* Subsequent writes get a fresh attempt.
10+
*/
11+
import { readFile } from 'node:fs/promises';
12+
import fs from 'node:fs';
13+
import path from 'node:path';
14+
import os from 'node:os';
15+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
16+
17+
import { createFileAcpLocalStorageProfile } from '../services/acp/local-storage.js';
18+
19+
function makeSession(id: string) {
20+
const now = Date.now();
21+
return {
22+
id,
23+
conversationId: `conv-${id}`,
24+
transcriptId: `transcript-${id}`,
25+
tenantId: 'test-tenant',
26+
ownerKeyId: 'test-key',
27+
runnerType: 'acp' as const,
28+
status: 'initializing' as const,
29+
createdAt: now,
30+
updatedAt: now,
31+
metadata: {},
32+
};
33+
}
34+
35+
describe('Issue #3366: ACP local storage persist() failure cascade', () => {
36+
let tmpDir: string;
37+
let filePath: string;
38+
39+
beforeEach(async () => {
40+
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'aegis-3366-'));
41+
filePath = path.join(tmpDir, 'acp-local-storage.json');
42+
});
43+
44+
afterEach(async () => {
45+
// Restore permissions so cleanup can succeed
46+
await fs.promises.chmod(tmpDir, 0o755).catch(() => {});
47+
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
48+
});
49+
50+
it('recovers from a failed persist — subsequent writes succeed', async () => {
51+
const profile = createFileAcpLocalStorageProfile({ filePath });
52+
await profile.start();
53+
expect(profile.getPersistError()).toBeNull();
54+
55+
// Make the directory read-only to force a write failure
56+
await fs.promises.chmod(tmpDir, 0o444);
57+
58+
// Trigger persist via a mutation — should fail silently
59+
await profile.sessionStore.create(makeSession('fail-1'));
60+
expect(profile.getPersistError()).not.toBeNull();
61+
62+
// Restore write permissions
63+
await fs.promises.chmod(tmpDir, 0o755);
64+
65+
// Trigger another mutation — this should SUCCEED (not cascade!)
66+
await profile.sessionStore.create(makeSession('recover-1'));
67+
expect(profile.getPersistError()).toBeNull();
68+
69+
// Verify the file actually contains the data
70+
const content = await readFile(filePath, 'utf8');
71+
const parsed = JSON.parse(content);
72+
expect(parsed.sessions).toHaveLength(2);
73+
74+
await profile.stop();
75+
});
76+
77+
it('does NOT cascade rejection across multiple sequential failures', async () => {
78+
const profile = createFileAcpLocalStorageProfile({ filePath });
79+
await profile.start();
80+
81+
// Make dir read-only
82+
await fs.promises.chmod(tmpDir, 0o444);
83+
84+
// First failed persist
85+
await profile.sessionStore.create(makeSession('fail-1'));
86+
expect(profile.getPersistError()).not.toBeNull();
87+
88+
// Second failed persist — should NOT be chained to rejected promise
89+
await profile.sessionStore.create(makeSession('fail-2'));
90+
expect(profile.getPersistError()).not.toBeNull();
91+
92+
// Third failed persist — still no cascade
93+
await profile.sessionStore.create(makeSession('fail-3'));
94+
expect(profile.getPersistError()).not.toBeNull();
95+
96+
// Now restore and verify recovery
97+
await fs.promises.chmod(tmpDir, 0o755);
98+
await profile.sessionStore.create(makeSession('recover'));
99+
expect(profile.getPersistError()).toBeNull();
100+
101+
const content = await readFile(filePath, 'utf8');
102+
const parsed = JSON.parse(content);
103+
expect(parsed.sessions).toHaveLength(4);
104+
105+
await profile.stop();
106+
});
107+
108+
it('stop() does not throw even when writeChain has a rejection', async () => {
109+
const profile = createFileAcpLocalStorageProfile({ filePath });
110+
await profile.start();
111+
112+
// Make dir read-only so persist fails
113+
await fs.promises.chmod(tmpDir, 0o444);
114+
await profile.sessionStore.create(makeSession('doomed'));
115+
116+
// stop() should NOT throw — it catches the chain rejection
117+
await fs.promises.chmod(tmpDir, 0o755);
118+
await expect(profile.stop()).resolves.toBeUndefined();
119+
});
120+
});

src/services/acp/local-storage.ts

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
1+
import { mkdir, readFile, writeFile, rename, unlink } from 'node:fs/promises';
22
import path from 'node:path';
33

4+
import { logger } from '../../logger.js';
45
import type { ServiceHealth } from '../../container.js';
56
import {
67
AcpDurableIdentityError,
@@ -48,6 +49,7 @@ export interface AcpLocalStorageProfile {
4849
start(): Promise<void>;
4950
stop(signal?: AbortSignal): Promise<void>;
5051
health(): Promise<ServiceHealth>;
52+
getPersistError(): Error | null;
5153
}
5254

5355
export interface FileAcpLocalStorageProfileConfig {
@@ -101,12 +103,17 @@ export class MemoryAcpLocalStorageProfile implements AcpLocalStorageProfile {
101103
async health(): Promise<ServiceHealth> {
102104
return { healthy: true, details: 'memory ACP local storage profile ok' };
103105
}
106+
107+
getPersistError(): Error | null {
108+
return null;
109+
}
104110
}
105111

106112
export class FileAcpLocalStorageProfile implements AcpLocalStorageProfile {
107113
private state = createEmptyState();
108114
private started = false;
109115
private writeChain: Promise<void> = Promise.resolve();
116+
private persistError: Error | null = null;
110117
private readonly memorySessionStore: MemoryAcpSessionStore;
111118
private readonly memoryEventStore: MemoryAcpEventStore;
112119
private readonly memoryActionQueue: MemoryAcpActionQueue;
@@ -143,7 +150,8 @@ export class FileAcpLocalStorageProfile implements AcpLocalStorageProfile {
143150

144151
async stop(_signal?: AbortSignal): Promise<void> {
145152
if (!this.started) return;
146-
await this.writeChain;
153+
// Best-effort final persist — swallow errors so shutdown completes
154+
await this.writeChain.catch(() => {});
147155
this.started = false;
148156
}
149157

@@ -156,16 +164,48 @@ export class FileAcpLocalStorageProfile implements AcpLocalStorageProfile {
156164

157165
private async persist(): Promise<void> {
158166
if (!this.started) {
159-
throw new Error('FileAcpLocalStorageProfile: start() must be called before use');
167+
throw new Error('FileAcpLocalStorageProfile: persist() called before start()');
160168
}
161169
// Issue #3045: atomic write to prevent truncation on SIGTERM/OOM kill
170+
// Issue #3366: error recovery — a failed write must not poison subsequent writes
162171
const content = `${JSON.stringify(serializeState(this.state), null, 2)}\n`;
163172
const tmpFile = `${this.config.filePath}.tmp.${process.pid}`;
164-
this.writeChain = this.writeChain.then(
165-
() => writeFile(tmpFile, content, 'utf8').then(() => rename(tmpFile, this.config.filePath)),
166-
);
173+
174+
const prevChain = this.writeChain;
175+
this.writeChain = prevChain
176+
.then(
177+
// Previous write succeeded — do this write
178+
() => writeFile(tmpFile, content, 'utf8').then(() => rename(tmpFile, this.config.filePath)),
179+
// Previous write failed — still attempt this write
180+
() => writeFile(tmpFile, content, 'utf8').then(() => rename(tmpFile, this.config.filePath)),
181+
)
182+
.then(() => {
183+
this.persistError = null;
184+
})
185+
.catch((err: Error) => {
186+
this.persistError = err;
187+
logger.error({
188+
component: 'acp-local-storage',
189+
operation: 'persist',
190+
errorCode: 'PERSIST_FAILED',
191+
attributes: { error: err.message, filePath: this.config.filePath },
192+
});
193+
// Clean up stale tmp file if it exists
194+
unlink(tmpFile).catch(() => {});
195+
// Reset chain so next persist() is not chained to a rejected promise
196+
this.writeChain = Promise.resolve();
197+
});
198+
167199
await this.writeChain;
168200
}
201+
202+
/**
203+
* Returns the last persist error, or null if all writes succeeded.
204+
* Useful for diagnostics and health checks.
205+
*/
206+
getPersistError(): Error | null {
207+
return this.persistError;
208+
}
169209
}
170210

171211
export class MemoryAcpSessionStore implements AcpSessionStore {

0 commit comments

Comments
 (0)