fix(cli): preserve live task-isolation sandboxes on worktree clear

`omp worktree clear` (without `--all`) removed every task-isolation dir
under the worktree base, including sandboxes owned by subagents running
right now, and the "no live task owns it" reason was asserted from the
mere presence of the `m` mount dir with no ownership check.

`ensureIsolation` now stamps each sandbox base dir with a pid-bearing
ownership marker before the backend materialises `m`, and the worktree
scanner classifies a sandbox as live while its owning process is alive,
so `clear` reclaims only crashed leftovers.

Fixes #6761
This commit is contained in:
roboomp
2026-07-27 03:42:33 +00:00
parent 7d3f9d382c
commit eeca809193
5 changed files with 151 additions and 3 deletions
@@ -0,0 +1,58 @@
/**
* Ownership marker for task-isolation sandboxes under `~/.omp/wt/`.
*
* Each isolation base dir (`ensureIsolation` in {@link ./worktree}) holds a
* compact `m` mount plus this marker file naming the omp process that created
* it. `omp worktree clear` consults the marker so it can distinguish a live
* subagent's sandbox from a crashed run's leftover instead of deleting both.
*/
import * as path from "node:path";
/** Marker file written into a task-isolation base dir identifying its owner. */
export const ISOLATION_OWNER_FILE = ".omp-isolation-owner.json";
/** Recorded owner of a task-isolation sandbox. */
export interface IsolationOwner {
/** PID of the omp process that created and owns the sandbox. */
pid: number;
/** Task id the sandbox was materialised for. */
id: string;
}
/**
* Record the current process as owner of the sandbox rooted at `baseDir`.
*
* Written before the isolation backend materialises `m` so a concurrent
* `omp worktree clear` never sees an owner-less sandbox mid-creation.
*/
export async function writeIsolationOwner(baseDir: string, id: string): Promise<void> {
const owner: IsolationOwner = { pid: process.pid, id };
await Bun.write(path.join(baseDir, ISOLATION_OWNER_FILE), JSON.stringify(owner));
}
/**
* Whether a live omp process still owns the sandbox at `baseDir`.
*
* A missing or malformed marker means no verifiable owner — a crashed run or a
* sandbox from before markers existed, both safe to reclaim. `process.kill(pid,
* 0)` can fail with `EPERM` even when the process is alive, so only an explicit
* `ESRCH` ("no such process") counts as dead; any other error is treated as
* alive to avoid deleting a sandbox that is actually in use.
*/
export async function hasLiveIsolationOwner(baseDir: string): Promise<boolean> {
let decoded: unknown;
try {
decoded = await Bun.file(path.join(baseDir, ISOLATION_OWNER_FILE)).json();
} catch {
return false;
}
if (typeof decoded !== "object" || decoded === null || !("pid" in decoded)) return false;
const pid = decoded.pid;
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (err) {
return (err as NodeJS.ErrnoException).code !== "ESRCH";
}
}
@@ -6,6 +6,7 @@ import * as natives from "@oh-my-pi/pi-natives";
import { getWorktreeDir, logger, Snowflake } from "@oh-my-pi/pi-utils";
import * as git from "../utils/git";
import * as jj from "../utils/jj";
import { writeIsolationOwner } from "./isolation-ownership";
import { mapWithConcurrencyLimit } from "./parallel";
const { IsoBackendKind } = natives;
@@ -434,6 +435,13 @@ export async function ensureIsolation(
for (const candidate of candidates) {
await fs.rm(baseDir, { recursive: true, force: true });
// Claim ownership before the backend materialises `m`. Backends only
// create/replace `mergedDir` (and overlay upper/work), never the base
// dir, so the marker survives `isoStart` — and a concurrent
// `omp worktree clear` never sees this sandbox without a live owner,
// even while a large clone is still in progress.
await fs.mkdir(baseDir, { recursive: true });
await writeIsolationOwner(baseDir, id);
try {
await natives.isoStart(candidate, repoRoot, mergedDir);
// Sever the isolation's git metadata from the source checkout. Copy