Skip to content

Commit c1a9ff6

Browse files
authored
fix(core): degrade build state persistence gracefully (#695)
1 parent d81dd4c commit c1a9ff6

3 files changed

Lines changed: 141 additions & 29 deletions

File tree

.aiox-core/core/execution/build-state-manager.js

Lines changed: 103 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,8 @@ class BuildStateManager {
308308
// Internal state
309309
this._state = null;
310310
this._logBuffer = [];
311+
this._persistenceAvailable = true;
312+
this._persistenceError = null;
311313
}
312314

313315
// ─────────────────────────────────────────────────────────────────────────────────
@@ -367,14 +369,24 @@ class BuildStateManager {
367369
* @returns {Object|null} Loaded state or null if not exists
368370
*/
369371
loadState() {
372+
if (!this._persistenceAvailable) {
373+
return this._state;
374+
}
375+
370376
if (!fs.existsSync(this.stateFilePath)) {
371377
return null;
372378
}
373379

380+
let content;
374381
try {
375-
const content = fs.readFileSync(this.stateFilePath, 'utf-8');
376-
const state = JSON.parse(content);
382+
content = fs.readFileSync(this.stateFilePath, 'utf-8');
383+
} catch (error) {
384+
this._markPersistenceUnavailable(error);
385+
return this._state;
386+
}
377387

388+
try {
389+
const state = JSON.parse(content);
378390
// Validate
379391
const validation = validateBuildState(state);
380392
if (!validation.valid) {
@@ -400,11 +412,6 @@ class BuildStateManager {
400412
throw new Error('No state to save. Call createState() or loadState() first.');
401413
}
402414

403-
// Ensure directory exists
404-
if (!fs.existsSync(this.planDir)) {
405-
fs.mkdirSync(this.planDir, { recursive: true });
406-
}
407-
408415
// Only update timestamp if explicitly requested (via saveCheckpoint)
409416
if (options.updateCheckpoint) {
410417
this._state.lastCheckpoint = new Date().toISOString();
@@ -416,11 +423,24 @@ class BuildStateManager {
416423
throw new Error(`Invalid state: ${validation.errors.join(', ')}`);
417424
}
418425

419-
// Write state file
420-
fs.writeFileSync(this.stateFilePath, JSON.stringify(this._state, null, 2), 'utf-8');
426+
if (!this._persistenceAvailable) {
427+
return this._state;
428+
}
421429

422-
// Flush log buffer
423-
this._flushLogBuffer();
430+
try {
431+
// Ensure directory exists
432+
if (!fs.existsSync(this.planDir)) {
433+
fs.mkdirSync(this.planDir, { recursive: true });
434+
}
435+
436+
// Write state file
437+
fs.writeFileSync(this.stateFilePath, JSON.stringify(this._state, null, 2), 'utf-8');
438+
439+
// Flush log buffer
440+
this._flushLogBuffer();
441+
} catch (error) {
442+
this._markPersistenceUnavailable(error);
443+
}
424444

425445
return this._state;
426446
}
@@ -464,11 +484,6 @@ class BuildStateManager {
464484
throw new Error('No state loaded');
465485
}
466486

467-
// Ensure checkpoint directory exists
468-
if (!fs.existsSync(this.checkpointDir)) {
469-
fs.mkdirSync(this.checkpointDir, { recursive: true });
470-
}
471-
472487
const checkpointId = this._generateCheckpointId();
473488
const now = new Date().toISOString();
474489

@@ -499,8 +514,19 @@ class BuildStateManager {
499514
this._updateMetrics(checkpoint);
500515

501516
// Save checkpoint file
502-
const checkpointPath = path.join(this.checkpointDir, `${checkpointId}.json`);
503-
fs.writeFileSync(checkpointPath, JSON.stringify(checkpoint, null, 2), 'utf-8');
517+
if (this._persistenceAvailable) {
518+
try {
519+
// Ensure checkpoint directory exists
520+
if (!fs.existsSync(this.checkpointDir)) {
521+
fs.mkdirSync(this.checkpointDir, { recursive: true });
522+
}
523+
524+
const checkpointPath = path.join(this.checkpointDir, `${checkpointId}.json`);
525+
fs.writeFileSync(checkpointPath, JSON.stringify(checkpoint, null, 2), 'utf-8');
526+
} catch (error) {
527+
this._markPersistenceUnavailable(error);
528+
}
529+
}
504530

505531
// Save main state with checkpoint timestamp update
506532
this.saveState({ updateCheckpoint: true });
@@ -1041,6 +1067,8 @@ class BuildStateManager {
10411067
* @private
10421068
*/
10431069
_logAttempt(subtaskId, action, details = {}) {
1070+
if (!this._persistenceAvailable) return;
1071+
10441072
const entry = {
10451073
timestamp: new Date().toISOString(),
10461074
storyId: this.storyId,
@@ -1066,15 +1094,20 @@ class BuildStateManager {
10661094
*/
10671095
_flushLogBuffer() {
10681096
if (this._logBuffer.length === 0) return;
1097+
if (!this._persistenceAvailable) return;
10691098

1070-
// Ensure directory exists
1071-
if (!fs.existsSync(this.planDir)) {
1072-
fs.mkdirSync(this.planDir, { recursive: true });
1073-
}
1099+
try {
1100+
// Ensure directory exists
1101+
if (!fs.existsSync(this.planDir)) {
1102+
fs.mkdirSync(this.planDir, { recursive: true });
1103+
}
10741104

1075-
// Append to log file
1076-
fs.appendFileSync(this.logFilePath, this._logBuffer.join(''), 'utf-8');
1077-
this._logBuffer = [];
1105+
// Append to log file
1106+
fs.appendFileSync(this.logFilePath, this._logBuffer.join(''), 'utf-8');
1107+
this._logBuffer = [];
1108+
} catch (error) {
1109+
this._markPersistenceUnavailable(error);
1110+
}
10781111
}
10791112

10801113
/**
@@ -1084,11 +1117,22 @@ class BuildStateManager {
10841117
* @returns {string[]} Log lines
10851118
*/
10861119
getAttemptLog(options = {}) {
1120+
if (!this._persistenceAvailable) {
1121+
return [];
1122+
}
1123+
10871124
if (!fs.existsSync(this.logFilePath)) {
10881125
return [];
10891126
}
10901127

1091-
const content = fs.readFileSync(this.logFilePath, 'utf-8');
1128+
let content;
1129+
try {
1130+
content = fs.readFileSync(this.logFilePath, 'utf-8');
1131+
} catch (error) {
1132+
this._markPersistenceUnavailable(error);
1133+
return [];
1134+
}
1135+
10921136
let lines = content.split('\n').filter((l) => l.trim());
10931137

10941138
// Filter by subtask if specified
@@ -1266,6 +1310,39 @@ class BuildStateManager {
12661310
// Silent by default - can be overridden
12671311
}
12681312

1313+
/**
1314+
* Check if file-backed persistence is still available.
1315+
*
1316+
* @returns {boolean} True when state/checkpoint/log writes are available.
1317+
*/
1318+
isPersistenceAvailable() {
1319+
return this._persistenceAvailable;
1320+
}
1321+
1322+
/**
1323+
* Get the first persistence error that disabled file-backed writes.
1324+
*
1325+
* @returns {Error|null} Persistence error or null when persistence is available.
1326+
*/
1327+
getPersistenceError() {
1328+
return this._persistenceError;
1329+
}
1330+
1331+
/**
1332+
* Disable file-backed persistence after an I/O failure.
1333+
*
1334+
* @param {Error} error - Underlying persistence error.
1335+
* @private
1336+
*/
1337+
_markPersistenceUnavailable(error) {
1338+
if (!this._persistenceAvailable) return;
1339+
1340+
this._persistenceAvailable = false;
1341+
this._persistenceError = error instanceof Error ? error : new Error(String(error));
1342+
this._logBuffer = [];
1343+
this._log(`Persistence unavailable: ${this._persistenceError.message}`);
1344+
}
1345+
12691346
// ─────────────────────────────────────────────────────────────────────────────────
12701347
// CLI FORMATTING
12711348
// ─────────────────────────────────────────────────────────────────────────────────

.aiox-core/install-manifest.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
# - File types for categorization
99
#
1010
version: 5.1.15
11-
generated_at: "2026-05-08T04:27:49.896Z"
11+
generated_at: "2026-05-08T04:49:28.006Z"
1212
generator: scripts/generate-install-manifest.js
1313
file_count: 1111
1414
files:
@@ -409,9 +409,9 @@ files:
409409
type: core
410410
size: 33071
411411
- path: core/execution/build-state-manager.js
412-
hash: sha256:a6a3ed85ff040fd8338ec31eaf8729050e58216c873f5e0e80f2fbda89234b73
412+
hash: sha256:7a6127abb4cd23b83563e55065b6a4771764972d40d87ba08117ee8c0c48980e
413413
type: core
414-
size: 52112
414+
size: 54015
415415
- path: core/execution/context-injector.js
416416
hash: sha256:7654f6e6c3d555f3963369e71edf84b15c0fdec53bd161800b71782f78275244
417417
type: core

tests/core/build-state-manager.test.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ describe('BuildStateManager', () => {
4141
});
4242

4343
afterEach(() => {
44+
jest.restoreAllMocks();
45+
4446
// Cleanup temp directory
4547
if (testDir && fs.existsSync(testDir)) {
4648
fs.rmSync(testDir, { recursive: true, force: true });
@@ -157,6 +159,20 @@ describe('BuildStateManager', () => {
157159
expect(state2.metrics.totalSubtasks).toBe(7); // Should be original value
158160
});
159161

162+
test('should keep state in memory when persistence fails during save', () => {
163+
manager.createState({ totalSubtasks: 3 });
164+
jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {
165+
throw new Error('EACCES: permission denied');
166+
});
167+
168+
expect(() => manager.saveState()).not.toThrow();
169+
170+
expect(manager.isPersistenceAvailable()).toBe(false);
171+
expect(manager.getPersistenceError().message).toContain('EACCES');
172+
expect(manager.getState().metrics.totalSubtasks).toBe(3);
173+
expect(fs.existsSync(path.join(testDir, 'plan', 'build-state.json'))).toBe(false);
174+
});
175+
160176
test('should throw when storyId not provided', () => {
161177
expect(() => new BuildStateManager(null)).toThrow('storyId is required');
162178
});
@@ -230,6 +246,25 @@ describe('BuildStateManager', () => {
230246
expect(state.completedSubtasks).toEqual(['1.1']);
231247
expect(state.checkpoints).toHaveLength(2); // Still records checkpoint
232248
});
249+
250+
test('should keep checkpoint state in memory when checkpoint persistence fails', () => {
251+
const originalWriteFileSync = fs.writeFileSync;
252+
jest.spyOn(fs, 'writeFileSync').mockImplementation((filePath, ...args) => {
253+
if (String(filePath).includes('checkpoints')) {
254+
throw new Error('ENOSPC: no space left on device');
255+
}
256+
return originalWriteFileSync.call(fs, filePath, ...args);
257+
});
258+
259+
expect(() => manager.saveCheckpoint('1.persistence')).not.toThrow();
260+
261+
const state = manager.getState();
262+
expect(manager.isPersistenceAvailable()).toBe(false);
263+
expect(manager.getPersistenceError().message).toContain('ENOSPC');
264+
expect(state.checkpoints).toHaveLength(1);
265+
expect(state.completedSubtasks).toContain('1.persistence');
266+
expect(state.metrics.completedSubtasks).toBe(1);
267+
});
233268
});
234269

235270
// ─────────────────────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)