feat(HAI-020): complete Step 1 — atomic writes for config.json with config lock

This commit is contained in:
Dustin Byrne
2026-03-25 21:36:38 -04:00
parent 0e023c7b2b
commit 6d2c234d89
2 changed files with 79 additions and 12 deletions

View File

@@ -137,6 +137,36 @@ describe("TaskStore", () => {
});
});
// ── Atomic config writes ──────────────────────────────────────────
describe("atomic config writes", () => {
it("produces valid config.json with unique sequential IDs after 5 parallel createTask calls", async () => {
const promises = Array.from({ length: 5 }, (_, i) =>
store.createTask({ description: `Concurrent task ${i}` }),
);
const tasks = await Promise.all(promises);
// All IDs should be unique
const ids = tasks.map((t) => t.id);
expect(new Set(ids).size).toBe(5);
// IDs should be sequential (HAI-001 through HAI-005)
const sortedIds = [...ids].sort();
expect(sortedIds).toEqual(["HAI-001", "HAI-002", "HAI-003", "HAI-004", "HAI-005"]);
// config.json should be valid JSON with nextId = 6
const configPath = join(rootDir, ".hai", "config.json");
const raw = await readFile(configPath, "utf-8");
const config = JSON.parse(raw);
expect(config.nextId).toBe(6);
// No .tmp files left behind
const haiDir = join(rootDir, ".hai");
const files = await readdir(haiDir);
expect(files.filter((f) => f.endsWith(".tmp"))).toHaveLength(0);
});
});
// ── Concurrent stress test ───────────────────────────────────────
describe("concurrent stress", () => {

View File

@@ -31,6 +31,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private debounceMs = 150;
/** Per-task promise chain for serializing writes */
private taskLocks: Map<string, Promise<void>> = new Map();
/** Promise chain for serializing config.json read-modify-write cycles */
private configLock: Promise<void> = Promise.resolve();
constructor(private rootDir: string) {
super();
@@ -46,6 +48,26 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
}
/**
* Serialize all mutations to config.json by chaining promises.
* Concurrent callers will queue behind each other, preventing
* lost-update races on the nextId counter.
*/
private withConfigLock<T>(fn: () => Promise<T>): Promise<T> {
let resolve: () => void;
const next = new Promise<void>((r) => { resolve = r; });
const prev = this.configLock;
this.configLock = next;
return prev.then(async () => {
try {
return await fn();
} finally {
resolve!();
}
});
}
/**
* Serialize all mutations to a given task's task.json by chaining promises
* per task ID. Concurrent callers for the same ID will queue behind each other.
@@ -120,12 +142,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
async updateSettings(patch: Partial<Settings>): Promise<Settings> {
const config = await this.readConfig();
const current = { ...DEFAULT_SETTINGS, ...config.settings };
const updated = { ...current, ...patch };
config.settings = updated;
await this.writeConfig(config);
return updated;
return this.withConfigLock(async () => {
const config = await this.readConfig();
const current = { ...DEFAULT_SETTINGS, ...config.settings };
const updated = { ...current, ...patch };
config.settings = updated;
await this.writeConfig(config);
return updated;
});
}
private async readConfig(): Promise<BoardConfig> {
@@ -133,16 +157,29 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return JSON.parse(data);
}
/**
* Atomically write config.json by writing to a temp file first, then
* renaming into place. The rename is atomic on POSIX filesystems,
* preventing partial writes from corrupting the file.
*/
private async atomicWriteConfig(config: BoardConfig): Promise<void> {
const tmpPath = this.configPath + ".tmp";
await writeFile(tmpPath, JSON.stringify(config, null, 2));
await rename(tmpPath, this.configPath);
}
private async writeConfig(config: BoardConfig): Promise<void> {
await writeFile(this.configPath, JSON.stringify(config, null, 2));
await this.atomicWriteConfig(config);
}
private async allocateId(): Promise<string> {
const config = await this.readConfig();
const id = `HAI-${String(config.nextId).padStart(3, "0")}`;
config.nextId++;
await this.writeConfig(config);
return id;
return this.withConfigLock(async () => {
const config = await this.readConfig();
const id = `HAI-${String(config.nextId).padStart(3, "0")}`;
config.nextId++;
await this.writeConfig(config);
return id;
});
}
private taskDir(id: string): string {