Skip to content
Closed
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
81910f9
feat: one crash-safe owner for durable state writes
snimu Sep 4, 2026
1884b9d
fix: settings first-write races and auth-migration ordering
snimu Sep 4, 2026
fbe6ec4
fix: rename-based bootstrap lock via one shared dir-lock helper
snimu Sep 4, 2026
6fae8af
fix: repair crash-damaged session files at open
snimu Sep 4, 2026
13b7ebe
fix: session leases survive Windows errnos and unreadable owners
snimu Sep 4, 2026
8818734
fix: atomic harness-state saves in the Python runtime
snimu Sep 4, 2026
bbac378
fix: harden the atomic-write owner per review
snimu Sep 4, 2026
c734fd3
test: pin symlink-alias repair with preserved mode
snimu Sep 4, 2026
382f1a0
fix: close round-two persistence gaps
snimu Sep 4, 2026
2974016
fix: strict pid parse in the dir-lock owner check
snimu Sep 4, 2026
4184a03
docs: state the fully-formed lock-publish invariant
snimu Sep 4, 2026
3278cd3
style(coding-agent): fix test indentation
snimu Sep 4, 2026
79ba09b
Merge remote-tracking branch 'origin/main' into fix/atomic-persistenc…
snimu Sep 4, 2026
99549de
fix: link-published lock file, suspicion-gated repair, symlink write …
snimu Sep 4, 2026
0306213
fix: lock hygiene - unreadable owners, cleanup masking, moved-entry type
snimu Sep 4, 2026
bbb08a2
fix: sweep abandoned lock candidates on acquire
snimu Sep 4, 2026
b4ac0f2
chore: dedupe two literal comment pairs
snimu Sep 4, 2026
3abb3d9
fix: restore no-silent-catch comments and survive a swept candidate
snimu Sep 4, 2026
f9a2dd1
fix: end the error-collapse class in the lock probes
snimu Sep 4, 2026
db23008
fix: symlink parity for the settings cleanup and dangling aliases
snimu Sep 4, 2026
6748a9d
fix: immutable inode identity for the lock reclaim
snimu Sep 4, 2026
107e90f
fix: pid-reuse pin and physical-parent resolution for dangling links
snimu Sep 4, 2026
2a57e9e
fix: restore by the moved entry's own shape; lost-reply falls to verify
snimu Sep 4, 2026
7637cc5
fix: link-only restore for unreadable shapes; fd-pin the judged inode
snimu Sep 4, 2026
a63c81a
fix: umask-proof auth init, blank-tail gate, dangling migration target
snimu Sep 4, 2026
f0f02d2
refactor: consolidate pins and tighten comments
snimu Sep 4, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Made every durable JSON/JSONL state write crash-safe through one shared atomic-write owner (temp file + rename, Windows rename retry): auth.json is no longer written in place (an interrupted write can no longer log you out everywhere), the auth migration writes its destination before destroying its sources, racing first-time settings writers no longer silently discard each other, and the kernel bootstrap lock can no longer be stolen mid-reclaim. Session files now repair crash damage (torn tails, zero-filled records) at open instead of silently losing the next message, and a session lease whose owner file is momentarily unreadable is no longer treated as stale and destroyed.
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
20 changes: 12 additions & 8 deletions packages/coding-agent/src/core/auth-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ import {
type OAuthProviderId,
} from "@earendil-works/pi-ai";
import { getOAuthApiKey, getOAuthProvider, getOAuthProviders } from "@earendil-works/pi-ai/oauth";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname, join } from "path";
import lockfile from "proper-lockfile";
import { getAgentDir } from "../config.js";
import { realpathIfPresentSync, writeFileAtomicSync } from "../utils/atomic-file.js";
import {
clearPrimeCliCredentials,
getPrimeCliConfigPath,
Expand Down Expand Up @@ -116,9 +117,14 @@ export class FileAuthStorageBackend implements AuthStorageBackend {
}

private ensureFileExists(): void {
if (!existsSync(this.authPath)) {
writeFileSync(this.authPath, "{}", "utf-8");
chmodSync(this.authPath, 0o600);
try {
// Exclusive create: a racing initializer must never replace credentials another
// process saved between an existence check and this write.
writeFileSync(this.authPath, "{}", { flag: "wx", mode: 0o600 });
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
throw error;
}
}
}

Expand Down Expand Up @@ -171,8 +177,7 @@ export class FileAuthStorageBackend implements AuthStorageBackend {
const current = existsSync(this.authPath) ? readFileSync(this.authPath, "utf-8") : undefined;
const { result, next } = fn(current);
if (next !== undefined) {
writeFileSync(this.authPath, next, "utf-8");
chmodSync(this.authPath, 0o600);
writeFileAtomicSync(realpathIfPresentSync(this.authPath), next, { mode: 0o600 });
}
return result;
} finally {
Expand Down Expand Up @@ -216,8 +221,7 @@ export class FileAuthStorageBackend implements AuthStorageBackend {
const { result, next } = await fn(current);
throwIfCompromised();
if (next !== undefined) {
writeFileSync(this.authPath, next, "utf-8");
chmodSync(this.authPath, 0o600);
writeFileAtomicSync(realpathIfPresentSync(this.authPath), next, { mode: 0o600 });
}
throwIfCompromised();
return result;
Expand Down
35 changes: 4 additions & 31 deletions packages/coding-agent/src/core/cron-jobs.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
import { randomUUID } from "node:crypto";
import {
closeSync,
existsSync,
fsyncSync,
mkdirSync,
openSync,
readFileSync,
renameSync,
writeFileSync,
} from "node:fs";
import { existsSync, mkdirSync, readFileSync, renameSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { lockSync } from "proper-lockfile";
import { writeFileAtomicSync } from "../utils/atomic-file.js";
import { getSessionArtifactPathForFile } from "./session-manager.js";

export type AgentCronJobStatus = "active" | "paused" | "completed" | "cancelled";
Expand Down Expand Up @@ -1558,27 +1550,8 @@ function writeJobsFile(path: string, jobs: readonly AgentCronJob[], mergeCurrent
}

function writeJobsState(path: string, state: CronJobsState): void {
const directory = dirname(path);
mkdirSync(directory, { recursive: true, mode: 0o700 });
const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
const descriptor = openSync(tempPath, "w", 0o600);
try {
writeFileSync(descriptor, `${JSON.stringify(state, null, 2)}\n`, "utf-8");
fsyncSync(descriptor);
} finally {
closeSync(descriptor);
}
renameSync(tempPath, path);
try {
const directoryDescriptor = openSync(directory, "r");
try {
fsyncSync(directoryDescriptor);
} finally {
closeSync(directoryDescriptor);
}
} catch {
// Directory fsync is unavailable on some platforms; the atomic rename still protects readers.
}
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
writeFileAtomicSync(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600, fsync: true, fsyncDir: true });
}

function claimDueInState(state: CronJobsState, dueAt: Date, claimedAt: Date): AgentCronDispatch[] {
Expand Down
29 changes: 7 additions & 22 deletions packages/coding-agent/src/core/kernel/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { createInterface } from "node:readline/promises";
import { setTimeout as sleep } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { getPackageDir } from "../../config.js";
import { tryAcquireDirLock } from "../../utils/dir-lock.js";
import type { PythonSkillRuntimeInfo } from "../skills.js";

const BOOTSTRAP_SCHEMA = 9;
Expand Down Expand Up @@ -449,16 +450,6 @@ function processIsRunning(pid: number): boolean {
}
}

async function readLockPid(lockDir: string): Promise<number | null> {
try {
const raw = await readFile(path.join(lockDir, "pid"), "utf8");
const pid = Number.parseInt(raw.trim(), 10);
return Number.isInteger(pid) && pid > 0 ? pid : null;
} catch {
return null;
}
}

async function lockMissingPidIsStale(lockDir: string): Promise<boolean> {
try {
const lockStat = await stat(lockDir);
Expand All @@ -473,19 +464,13 @@ async function acquireBootstrapLock(venv: string): Promise<() => Promise<void>>
await mkdir(path.dirname(lockDir), { recursive: true });

for (;;) {
try {
await mkdir(lockDir);
await writeFile(path.join(lockDir, "pid"), `${process.pid}\n`, "utf8");
const attempt = await tryAcquireDirLock(lockDir, async (ownerPid) =>
ownerPid === undefined ? !(await lockMissingPidIsStale(lockDir)) : processIsRunning(ownerPid),
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
);
if (attempt === "acquired") {
return () => rm(lockDir, { recursive: true, force: true });
} catch (error) {
if (!isNodeError(error, "EEXIST")) throw error;

const pid = await readLockPid(lockDir);
if (pid === null ? await lockMissingPidIsStale(lockDir) : !processIsRunning(pid)) {
await rm(lockDir, { recursive: true, force: true });
continue;
}

}
if (attempt === "held") {
await sleep(BOOTSTRAP_LOCK_RETRY_MS);
}
}
Expand Down
7 changes: 3 additions & 4 deletions packages/coding-agent/src/core/model-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ import {
} from "@earendil-works/pi-ai";
import { registerBuiltinMcpOAuthProviders } from "@earendil-works/pi-ai/mcp";
import { registerOAuthProvider, resetOAuthProviders } from "@earendil-works/pi-ai/oauth";
import { existsSync, readFileSync, renameSync, writeFileSync } from "fs";
import { existsSync, readFileSync } from "fs";
import { dirname, join } from "path";
import { type Static, type TProperties, Type } from "typebox";
import type { Validator } from "typebox/compile";
import type { TLocalizedValidationError } from "typebox/error";
import { getAgentDir } from "../config.js";
import { writeFileAtomicSync } from "../utils/atomic-file.js";
import type { AuthSourceToken, AuthStatus, AuthStorage } from "./auth-storage.js";
import { PRIME_INFERENCE_PROVIDER_ID } from "./prime-inference-auth.js";
import {
Expand Down Expand Up @@ -926,9 +927,7 @@ export class ModelRegistry {
return;
}
try {
const tmpPath = `${cachePath}.${process.pid}.tmp`;
writeFileSync(tmpPath, JSON.stringify({ ...cache, modelIds: [...cache.modelIds] }), { mode: 0o600 });
renameSync(tmpPath, cachePath);
writeFileAtomicSync(cachePath, JSON.stringify({ ...cache, modelIds: [...cache.modelIds] }), { mode: 0o600 });
} catch {
// A failed cache write only requires a later refetch.
}
Expand Down
26 changes: 5 additions & 21 deletions packages/coding-agent/src/core/refinement/refinement.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,10 @@
import { randomUUID } from "node:crypto";
import {
appendFileSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { Model } from "@earendil-works/pi-ai";
import { completeSimple } from "@earendil-works/pi-ai";
import { getAgentDir } from "../../config.js";
import { realpathIfPresentSync, writeFileAtomicSync } from "../../utils/atomic-file.js";
import { serializeConversation } from "../compaction/utils.js";
import { convertToLlm } from "../messages.js";
import type { CustomEntry } from "../session-manager.js";
Expand Down Expand Up @@ -344,17 +335,10 @@ export function mergeHarnessStates(globalState: HarnessState, localState?: Harne

export function saveHarnessState(harnessStateDir: string, state: HarnessState): string {
const statePath = getHarnessStatePath(harnessStateDir);
const tempPath = `${statePath}.${process.pid}.${randomUUID()}.tmp`;
mkdirSync(harnessStateDir, { recursive: true });
try {
const mode = existsSync(statePath) ? statSync(statePath).mode & 0o777 : 0o600;
writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", mode });
renameSync(tempPath, statePath);
} finally {
if (existsSync(tempPath)) {
unlinkSync(tempPath);
}
}
const targetPath = realpathIfPresentSync(statePath);
const mode = existsSync(targetPath) ? statSync(targetPath).mode & 0o777 : 0o600;
writeFileAtomicSync(targetPath, `${JSON.stringify(state, null, 2)}\n`, { mode });
return statePath;
}

Expand Down
27 changes: 19 additions & 8 deletions packages/coding-agent/src/core/session-lease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export class SessionLease {
try {
withLeaseGuard(this.directory, () => {
const owner = readLeaseOwner(this.directory);
if (owner?.token === this.token) {
if (typeof owner === "object" && owner.token === this.token) {
rmSync(this.directory, { recursive: true, force: true });
}
});
Expand Down Expand Up @@ -83,21 +83,28 @@ export function canonicalSessionPath(sessionPath: string): string {
}
}

function readLeaseOwner(directory: string): SessionLeaseOwner | undefined {
// "absent" (missing/garbage) is safely stale; "unreadable" may be a LIVE lease and must never be reclaimed.
function readLeaseOwner(directory: string): SessionLeaseOwner | "absent" | "unreadable" {
let raw: string;
try {
const parsed = JSON.parse(readFileSync(join(directory, "owner.json"), "utf8")) as Partial<SessionLeaseOwner>;
raw = readFileSync(join(directory, "owner.json"), "utf8");
} catch (error) {
return (error as NodeJS.ErrnoException).code === "ENOENT" ? "absent" : "unreadable";
}
try {
const parsed = JSON.parse(raw) as Partial<SessionLeaseOwner>;
if (
parsed.version !== 1 ||
typeof parsed.token !== "string" ||
typeof parsed.pid !== "number" ||
typeof parsed.sessionPath !== "string" ||
typeof parsed.createdAt !== "string"
) {
return undefined;
return "absent";
}
return parsed as SessionLeaseOwner;
} catch {
return undefined;
return "absent";
}
}

Expand Down Expand Up @@ -298,19 +305,23 @@ export function acquireSessionLease(
} catch (error) {
rmSync(candidateDirectory, { recursive: true, force: true });
const code = (error as NodeJS.ErrnoException).code;
if (code !== "EEXIST" && code !== "ENOTEMPTY") {
// win32 reports rename-onto-existing-directory as EPERM/EACCES, not EEXIST.
if (code !== "EEXIST" && code !== "ENOTEMPTY" && code !== "EPERM" && code !== "EACCES") {
throw error;
}
const existingOwner = readLeaseOwner(directory);
if (existingOwner && isLeaseOwnerAlive(existingOwner)) {
if (existingOwner === "unreadable") {
continue;
}
if (existingOwner !== "absent" && isLeaseOwnerAlive(existingOwner)) {
throw new SessionAlreadyActiveError(canonicalPath, existingOwner.activeSessionId);
}
reclaimStaleLease(directory);
}
}

const owner = existsSync(directory) ? readLeaseOwner(directory) : undefined;
if (owner && isLeaseOwnerAlive(owner)) {
if (typeof owner === "object" && isLeaseOwnerAlive(owner)) {
throw new SessionAlreadyActiveError(canonicalPath, owner.activeSessionId);
}
throw new Error(`Could not acquire session lease: ${canonicalPath}`);
Expand Down
Loading
Loading