Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/coding-agent/src/core/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 19 additions & 6 deletions packages/coding-agent/src/core/trust-manager.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 });
}
});