From f20ffa912165baf2d7db8e789a6c8df82cb39df4 Mon Sep 17 00:00:00 2001 From: Jeongwook Park Date: Sat, 5 Sep 2026 23:14:46 +0900 Subject: [PATCH] =?UTF-8?q?fix(coding-agent):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=EC=8B=A0=EB=A2=B0=20=EC=9D=BD=EA=B8=B0=20?= =?UTF-8?q?=EB=9D=BD=20=EA=B2=BD=ED=95=A9=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프로젝트 신뢰 읽기가 writer lock을 기다리지 않고 마지막 원자적 snapshot을 사용하게 합니다. 쓰기는 기존 lock 직렬화를 유지하며 임시 파일 rename으로 완전한 snapshot만 게시합니다. Closes #1393 Co-Authored-By: Claude GPT-5.6 Sol Ultraworked-With: gpt-5.6-sol User-Request: OMO 락 오류를 방지하고 발생해도 작업이 중단되지 않게 수정 --- packages/coding-agent/src/core/changes.md | 18 ++++++++ .../coding-agent/src/core/trust-manager.ts | 25 ++++++++--- .../project-trust-lock-contention.test.ts | 43 +++++++++++++++++++ 3 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/project-trust-lock-contention.test.ts 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 }); + } +});