diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index f3d7eb1a0f..c03f4a9601 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -41,6 +41,24 @@ # changes +## 2026-09-05 - Read project trust without writer-lock contention + +### What changed + +- `packages/coding-agent/src/core/trust-manager.ts`: project-trust reads load the last published `trust.json` snapshot without acquiring the writer lock; writes remain serialized by proper-lockfile and now publish through a same-directory temporary file plus atomic rename. + +### Why + +- A trust read during another process's write previously retried the writer lock synchronously and then threw `ELOCKED`, interrupting startup and session-state projection even though the prior complete trust snapshot was safe to read. + +### Why an extension could not handle it + +- Project trust is loaded by core startup, package-command, interactive, and RPC paths before or below extension interception; only the store can separate snapshot reads from serialized read-modify-write publication. + +### Expected merge conflict zones + +- LOW: `packages/coding-agent/src/core/trust-manager.ts` read and write helpers. + ## 2026-09-05 - Require explicit fallback chains ### What changed diff --git a/packages/coding-agent/src/core/trust-manager.ts b/packages/coding-agent/src/core/trust-manager.ts index 0a560f7f92..cc28ad5aba 100644 --- a/packages/coding-agent/src/core/trust-manager.ts +++ b/packages/coding-agent/src/core/trust-manager.ts @@ -1,4 +1,5 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import lockfile from "proper-lockfile"; @@ -131,7 +132,21 @@ function writeTrustFile(path: string, data: TrustFile): void { } } mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, `${JSON.stringify(sorted, null, 2)}\n`, "utf-8"); + const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`; + try { + writeFileSync(tempPath, `${JSON.stringify(sorted, null, 2)}\n`, "utf-8"); + renameSync(tempPath, path); + } catch (publicationError) { + try { + rmSync(tempPath, { force: true }); + } catch (cleanupError) { + throw new AggregateError( + [publicationError, cleanupError], + "Failed to publish and clean up project trust snapshot", + ); + } + throw publicationError; + } } function acquireTrustLockSync(path: string): () => void { @@ -218,10 +233,8 @@ export class ProjectTrustStore { } getEntry(cwd: string): ProjectTrustStoreEntry | null { - return withTrustFileLock(this.trustPath, () => { - const data = readTrustFile(this.trustPath); - return findNearestTrustEntry(data, cwd); - }); + const data = readTrustFile(this.trustPath); + return findNearestTrustEntry(data, cwd); } set(cwd: string, decision: ProjectTrustDecision): void { diff --git a/packages/coding-agent/test/suite/regressions/project-trust-lock-contention.test.ts b/packages/coding-agent/test/suite/regressions/project-trust-lock-contention.test.ts new file mode 100644 index 0000000000..491ce1462c --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/project-trust-lock-contention.test.ts @@ -0,0 +1,43 @@ +import { EventEmitter, once } from "node:events"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import lockfile from "proper-lockfile"; +import { expect, test } from "vitest"; +import { ProjectTrustStore } from "../../../src/core/trust-manager.ts"; + +// Regression: https://github.com/code-yeongyu/senpi/issues/1393 +test("project trust reads the published snapshot while a writer lock is held", async () => { + // Given a persisted trust decision and a real writer holding the store lock. + const tempDir = mkdtempSync(join(tmpdir(), "senpi-project-trust-lock-")); + const agentDir = join(tempDir, "agent"); + const projectDir = join(tempDir, "project"); + const store = new ProjectTrustStore(agentDir); + store.set(projectDir, true); + + const writerEvents = new EventEmitter(); + const lockHeld = once(writerEvents, "lock-held"); + const releaseRequested = once(writerEvents, "release-requested"); + const writer = (async () => { + const release = await lockfile.lock(agentDir, { + realpath: false, + lockfilePath: join(agentDir, "trust.json.lock"), + }); + writerEvents.emit("lock-held"); + await releaseRequested; + await release(); + })(); + + await lockHeld; + try { + // When the reader loads trust during the writer's critical section. + const decision = store.get(projectDir); + + // Then it sees the last atomically published snapshot without contending on the lock. + expect(decision).toBe(true); + } finally { + writerEvents.emit("release-requested"); + await writer; + rmSync(tempDir, { recursive: true, force: true }); + } +});