Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
05d14ec
fix: serialize stale worker-lock recovery through atomic admission
imxyanua Nov 2, 2026
71f98f8
fix: preserve worker ownership when lock metadata is unreadable
imxyanua Nov 2, 2026
35ba6cc
fix: serialize state-file recovery attempts and refuse unlocked mutation
imxyanua Nov 2, 2026
a638041
fix: recover stranded worker lock admission safely
imxyanua Nov 3, 2026
e5c7b71
fix: recover state lock after admission release failure
imxyanua Nov 3, 2026
68fab84
test: accept buffer paths during state lock cleanup
imxyanua Nov 3, 2026
48a7f83
fix: preserve work errors when lock cleanup also fails
imxyanua Nov 3, 2026
ba5237f
fix: unlink dead state owners under admission
imxyanua Nov 3, 2026
db07038
Merge C:\Users\xyanua\AppData\Local\Temp\evolver-review-28-7b06e3d523…
imxyanua Nov 3, 2026
9733bd5
fix: preserve unreadable shared state and surface hook failures
imxyanua Nov 3, 2026
25a7227
fix: flush the hook diagnostic before exiting
imxyanua Nov 3, 2026
d1fc44f
Recover admission without spawning on uncontended locks
imxyanua Nov 3, 2026
8015ec5
Reuse worker admission fixes for state lock recovery
imxyanua Nov 3, 2026
b815a6d
Make abandoned admission guards recoverable across processes
imxyanua Nov 4, 2026
41aee5e
Reclaim stale worker locks after confirmed PID reuse
imxyanua Nov 4, 2026
0da5ebc
Merge upstream main into shared state preservation
imxyanua Nov 5, 2026
b66fdc4
Refuse unlocked state updates and clean up owned partial replacements
imxyanua Nov 5, 2026
6677c34
Merge safe state persistence into exclusive state-lock recovery
imxyanua Nov 5, 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
3 changes: 2 additions & 1 deletion lib/agent/driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,8 @@ export function createDriver({ profile, ports, transcripts, env = process.env, l
const at = { sessionId, projectDir: profile.payload.projectDir(payload, context) };
const waitLeft = (budgetOf = 'prompt') => waitBudgetOf(profile.budgets, budgetOf, { lifetime, startedAt });
return await HANDLERS[event]({ profile, ports, hostEvent, payload, context, at, waitLeft, startedAt, options });
} catch {
} catch (error) {
options.onError?.(error);
return profile.reply.empty(hostEvent, payload);
}
},
Expand Down
13 changes: 11 additions & 2 deletions lib/channels/hook/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { readPayload } from './stdin.js';
* One command-hook invocation: the host's payload on stdin, the agent's answer as JSON on stdout, exit 0. Inside
* this plugin's own headless distillation the hook answers the agent's empty reply and does nothing else.
*/
export async function runHookProcess(agent, hostEvent, { stdin = process.stdin, stdout = process.stdout, env = process.env, exit = process.exit, profileOf = profileNamed } = {}) {
export async function runHookProcess(agent, hostEvent, { stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, env = process.env, exit = process.exit, profileOf = profileNamed } = {}) {
const startedAt = Date.now();
const write = (answer) => stdout.write(JSON.stringify(answer ?? {}), () => exit(0));
const profile = await profileOf(agent).catch(() => null);
Expand All @@ -22,5 +22,14 @@ export async function runHookProcess(agent, hostEvent, { stdin = process.stdin,
if (isHeadlessRun(env)) return write(profile.reply.empty(hostEvent));
const { ports } = environmentFor({ profile, env, lifetime: EPHEMERAL, runBackground: runBackgroundTasks });
const driver = createDriver({ profile, ports, transcripts, env, lifetime: EPHEMERAL, startedAt });
return write(await driver.handle(hostEvent, payload));
let diagnostic;
const answer = await driver.handle(hostEvent, payload, {
onError(error) {
if (['EINVALIDSTATE', 'ESTATEIO', 'ELOCKED'].includes(error?.code)) {
diagnostic = `Evolver hook skipped a state operation (${error.code}). Check the plugin state directory and retry.\n`;
}
},
});
if (diagnostic) return stderr.write(diagnostic, () => write(answer));
return write(answer);
}
28 changes: 26 additions & 2 deletions lib/runtime/process/owner.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,32 @@ import { readlinkSync } from 'node:fs';
import path from 'node:path';

const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish', 'cmd.exe', 'powershell', 'pwsh']);
const PS_TIMEOUT_MS = 1000;
export const PROCESS_START_TIMEOUT_MS = 1_000;
// POSIX ps reports start times to the nearest second.
export const PROCESS_START_TOLERANCE_MS = 2_000;
const MAX_ANCESTORS = 16;
const ownStartTime = Math.round(Date.now() - process.uptime() * 1000);

/** A bounded process start-time probe for lock recovery. The hot path needs no subprocess. */
export function processStartTime(pid, timeout = PROCESS_START_TIMEOUT_MS) {
if (pid === process.pid) return ownStartTime;
if (!Number.isSafeInteger(pid) || pid <= 0 || timeout <= 0) return null;
const windows = process.platform === 'win32';
const systemRoot = process.env.SystemRoot ?? 'C:\\Windows';
if (windows && !path.isAbsolute(systemRoot)) return null;
const executable = windows ? path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') : '/bin/ps';
const args = windows
? ['-NoProfile', '-NonInteractive', '-Command', `([DateTimeOffset] (Get-Process -Id ${pid} -ErrorAction Stop).StartTime).ToUnixTimeMilliseconds()`]
: ['-p', String(pid), '-o', 'lstart='];
const env = windows ? process.env : { ...process.env, LC_ALL: 'C', TZ: 'UTC' };
const result = spawnSync(executable, args, {
env, encoding: 'utf8', windowsHide: true, timeout, killSignal: 'SIGKILL',
});
const started = result.status === 0 ? result.stdout.trim() : '';
if (!started) return null;
const time = windows ? Number(started) : Date.parse(started + ' GMT');
return Number.isSafeInteger(time) && time > 0 ? time : null;
}

/**
* Whether the process that wrote a state entry has exited. Signal 0 only probes: ESRCH means no such process;
Expand Down Expand Up @@ -35,7 +59,7 @@ function executablePathOf(pid, command) {
}

export function processInfo(pid) {
const probe = spawnSync('ps', ['-o', 'ppid=,comm=', '-p', String(pid)], { encoding: 'utf8', timeout: PS_TIMEOUT_MS });
const probe = spawnSync('ps', ['-o', 'ppid=,comm=', '-p', String(pid)], { encoding: 'utf8', timeout: PROCESS_START_TIMEOUT_MS });
const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(probe.stdout ?? '');
if (!match) return null;
const command = match[2].replace(/^-/, '');
Expand Down
137 changes: 137 additions & 0 deletions lib/runtime/store/admission.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 EvoMap

import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';

import { ownerIsGone, processStartTime, PROCESS_START_TIMEOUT_MS, PROCESS_START_TOLERANCE_MS } from '../process/owner.js';

export const LOCK_WAIT_MS = 2_000;
export const LOCK_RETRY_MS = 20;
export const LOCK_ACCESS_ERRORS = new Set(['EPERM', 'EBUSY', 'EACCES']);
const ADMISSION_IDENTITY_CHECK_MS = 10_000;
const ABANDONED_PREFIX = 'abandoned-';
const abandonedAdmissions = new Map();

export function pause(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}

function reclaimAdmission(admission, deadline, starts) {
try {
const info = fs.lstatSync(admission);
if (!info.isDirectory()) return false;
const entries = fs.readdirSync(admission);
if (entries.length > 0) {
const marker = entries.find((name) => name.startsWith(ABANDONED_PREFIX));
const ownerName = marker ? marker.slice(ABANDONED_PREFIX.length) : entries[0];
if (entries.some((name) => name !== ownerName && name !== marker)) return false;
const owner = /^owner-(\d+)-[a-f0-9]+(?:-([a-f0-9]{24}|[0-9]{13}))?$/.exec(ownerName);
if (!owner) return false;
const pid = Number(owner[1]);
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
const abandonedHere = abandonedAdmissions.get(admission) === ownerName;
const markerPath = marker && path.join(admission, marker);
if (markerPath && !fs.lstatSync(markerPath).isFile()) return false;
if (!markerPath && !abandonedHere && !ownerIsGone(pid)) {
const birth = Number(owner[2]);
const remaining = deadline - Date.now();
if (owner[2]?.length !== 13 || !Number.isSafeInteger(birth) || Date.now() - info.mtimeMs < ADMISSION_IDENTITY_CHECK_MS || remaining <= 0) return false;
if (!starts.has(pid)) starts.set(pid, processStartTime(pid, Math.min(remaining, PROCESS_START_TIMEOUT_MS)));
const actualBirth = starts.get(pid);
// Only a clearly newer process proves reuse. ps timestamps round to whole seconds.
if (!actualBirth || actualBirth <= birth + PROCESS_START_TOLERANCE_MS) return false;
}
if (entries.includes(ownerName)) fs.unlinkSync(path.join(admission, ownerName));
if (markerPath) fs.unlinkSync(markerPath);
if (abandonedHere) abandonedAdmissions.delete(admission);
}
// Only the inspected unique owner is unlinked. A fresh guard is already nonempty, so rmdir cannot
// remove it even if another reclaimer replaced the directory after our read.
fs.rmdirSync(admission);
return true;
} catch {
return false;
}
}

function enterAdmission(lockDir, deadline, starts) {
const admission = `${lockDir}.admission`;
const owner = `owner-${process.pid}-${crypto.randomBytes(12).toString('hex')}-${processStartTime(process.pid)}`;
const staging = `${admission}.${owner}.claim`;
fs.mkdirSync(path.dirname(admission), { recursive: true, mode: 0o700 });
fs.mkdirSync(staging, { mode: 0o700 });
let published = false;
try {
fs.writeFileSync(path.join(staging, owner), '', { mode: 0o600, flag: 'wx' });
// Publishing a prepopulated directory never exposes an empty fresh guard to a stale reclaimer.
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
fs.renameSync(staging, admission);
published = true;
abandonedAdmissions.delete(admission);
return { admission, owner };
} catch {
if (attempt > 0 || !reclaimAdmission(admission, deadline, starts)) return null;
}
}
return null;
} finally {
if (!published) fs.rmSync(staging, { recursive: true, force: true });
}
}

export function retryLockAccess(access) {
for (let attempt = 0; ; attempt += 1) {
try {
return access();
} catch (error) {
if (attempt === 2 || !LOCK_ACCESS_ERRORS.has(error?.code)) throw error;
pause(LOCK_RETRY_MS);
}
}
}

function releaseAdmission({ admission, owner }) {
try {
retryLockAccess(() => fs.unlinkSync(path.join(admission, owner)));
} catch (cause) {
if (cause?.code !== 'ENOENT') {
throw Object.assign(new Error(`Could not release admission ${admission}`, { cause }), { code: 'ELOCKED' });
}
}
// The guard is released when its unique owner is gone. An empty directory can be reclaimed.
try { retryLockAccess(() => fs.rmdirSync(admission)); } catch {}
}

function abandonAdmission({ admission, owner }) {
// A separate marker can be written even when Windows denies access to the owner file.
try {
retryLockAccess(() => fs.writeFileSync(path.join(admission, ABANDONED_PREFIX + owner), '', { mode: 0o600, flag: 'wx' }));
} catch {
abandonedAdmissions.set(admission, owner);
}
}

// Every directory mutation uses this short, nonblocking admission. No work or await belongs inside it.
export function admitted(lockDir, mutate, rollback, deadline = Date.now() + LOCK_WAIT_MS, starts = new Map()) {
const admission = enterAdmission(lockDir, deadline, starts);
if (!admission) return null;
let result;
try {
result = mutate();
} catch (error) {
try { releaseAdmission(admission); } catch { abandonAdmission(admission); }
throw error;
}
try {
releaseAdmission(admission);
} catch (error) {
// Publish only after rollback, so another process cannot enter while it runs.
try { rollback?.(); } catch (rollbackError) { error.rollbackError = rollbackError; }
abandonAdmission(admission);
if (rollback) throw error;
}
return result;
}
Loading
Loading