@bermudi/pi-delegate 0.1.4 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dispatch.ts CHANGED
@@ -293,7 +293,6 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
293
293
  const asyncEnv: TaskRunEnv = {
294
294
  signal: ticketSignal,
295
295
  modelRegistry,
296
- parentSessionManager: ctx.sessionManager,
297
296
  ticketId,
298
297
  delegateStartedAt: ticket.created,
299
298
  telemetryCallId: callSpan?.id,
@@ -426,7 +425,6 @@ export async function dispatchSync(
426
425
  const syncEnv: TaskRunEnv = {
427
426
  signal,
428
427
  modelRegistry: ctx.modelRegistry,
429
- parentSessionManager: ctx.sessionManager,
430
428
  ticketId: undefined,
431
429
  delegateStartedAt: startedAt,
432
430
  telemetryCallId: callSpan?.id,
package/lifecycle.ts CHANGED
@@ -18,7 +18,6 @@ import { isSessionBusy } from "./tickets.ts";
18
18
  import {
19
19
  createSubagentSessionManager,
20
20
  persistSessionHeader,
21
- setParentSession,
22
21
  } from "./sessions.ts";
23
22
  import { runAgentSession, formatDeadlineExceededError } from "./runner.ts";
24
23
  import { getGitChangedFiles } from "./file-tracking.ts";
@@ -329,8 +328,8 @@ async function sleepForWholeTaskRetry(
329
328
  }
330
329
 
331
330
  /** Build the AgentSession for a fresh or resumed subagent via createAgentSession.
332
- * Reuses the caller-supplied sessionManager (so parent-linking + per-task .jsonl
333
- * files stay under our control). Extension-free host deps may be cached, while
331
+ * Reuses the caller-supplied sessionManager (so per-task .jsonl files stay under
332
+ * our control). Extension-free host deps may be cached, while
334
333
  * provider-configured or allowlisted-extension deps are session-local because
335
334
  * Pi binds mutable extension callbacks onto each loader runtime. */
336
335
  async function buildDelegateSession(
@@ -484,10 +483,6 @@ async function acquireAgentSession(
484
483
  };
485
484
  }
486
485
 
487
- // Link resumed session to parent for /resume discoverability.
488
- const parentFile = env.parentSessionManager?.getSessionFile?.();
489
- if (parentFile) setParentSession(resumed, parentFile);
490
-
491
486
  const session = await buildDelegateSession(
492
487
  task,
493
488
  resumed,
@@ -508,10 +503,7 @@ async function acquireAgentSession(
508
503
  // isolation. Keep scratch transcripts in memory only.
509
504
  sessionManager = SessionManager.inMemory(task.cwd);
510
505
  } else {
511
- const fresh = createSubagentSessionManager(
512
- env.parentSessionManager,
513
- task.cwd,
514
- );
506
+ const fresh = createSubagentSessionManager(task.cwd);
515
507
  if (!fresh) {
516
508
  return {
517
509
  error: failTask(task, "Internal: could not create session file"),
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Move old pi-delegate sessions out of Pi's normal session index.
3
+ *
4
+ * This is intentionally a standalone migration, not extension startup code:
5
+ * `pi -r` indexes sessions before extensions are loaded.
6
+ *
7
+ * Usage:
8
+ * bun run migrate-delegate-sessions.ts # report only
9
+ * bun run migrate-delegate-sessions.ts --apply # unlink and move
10
+ */
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
14
+
15
+ type JsonObject = Record<string, unknown>;
16
+
17
+ const agentDir = getAgentDir();
18
+ const sourceDir = path.join(agentDir, "sessions");
19
+ const destinationDir = path.join(agentDir, "delegate-sessions");
20
+ const apply = process.argv.includes("--apply");
21
+
22
+ function isObject(value: unknown): value is JsonObject {
23
+ return typeof value === "object" && value !== null && !Array.isArray(value);
24
+ }
25
+
26
+ function isWithin(root: string, candidate: string): boolean {
27
+ const relative = path.relative(root, candidate);
28
+ return (
29
+ relative === "" ||
30
+ (!relative.startsWith(`..${path.sep}`) &&
31
+ relative !== ".." &&
32
+ !path.isAbsolute(relative))
33
+ );
34
+ }
35
+
36
+ function readSession(file: string): JsonObject[] | undefined {
37
+ try {
38
+ const lines = fs.readFileSync(file, "utf8").split(/\r?\n/);
39
+ const entries: JsonObject[] = [];
40
+ for (const line of lines) {
41
+ if (!line.trim()) continue;
42
+ const parsed: unknown = JSON.parse(line);
43
+ if (!isObject(parsed)) return undefined;
44
+ entries.push(parsed);
45
+ }
46
+ return entries;
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ }
51
+
52
+ function readSessionHeader(file: string): JsonObject | undefined {
53
+ try {
54
+ const firstLine = fs.readFileSync(file, "utf8").split(/\r?\n/, 1)[0];
55
+ const parsed: unknown = JSON.parse(firstLine);
56
+ return isObject(parsed) && parsed.type === "session" ? parsed : undefined;
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ }
61
+
62
+ function sessionHeader(entries: JsonObject[]): JsonObject | undefined {
63
+ const header = entries[0];
64
+ return header?.type === "session" ? header : undefined;
65
+ }
66
+
67
+ function entryKey(entry: JsonObject): string {
68
+ return JSON.stringify(entry);
69
+ }
70
+
71
+ /**
72
+ * Pi's forkFrom() copies every non-header entry from the source session before
73
+ * writing anything new. Old delegate sessions do not copy the parent history.
74
+ * This is the discriminator: parentSession by itself is deliberately not
75
+ * sufficient because genuine Pi forks also have it.
76
+ */
77
+ function isPiFork(
78
+ childEntries: JsonObject[],
79
+ parentEntries: JsonObject[],
80
+ ): boolean {
81
+ const childBody = childEntries.slice(1);
82
+ const parentBody = parentEntries.slice(1);
83
+ if (parentBody.length === 0 || childBody.length < parentBody.length) {
84
+ return false;
85
+ }
86
+ return parentBody.every(
87
+ (entry, index) => entryKey(entry) === entryKey(childBody[index]),
88
+ );
89
+ }
90
+
91
+ function findJsonlFiles(directory: string): string[] {
92
+ if (!fs.existsSync(directory)) return [];
93
+ const files: string[] = [];
94
+ const visit = (current: string): void => {
95
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
96
+ const candidate = path.join(current, entry.name);
97
+ if (entry.isDirectory()) visit(candidate);
98
+ else if (entry.isFile() && entry.name.endsWith(".jsonl"))
99
+ files.push(candidate);
100
+ }
101
+ };
102
+ visit(directory);
103
+ return files;
104
+ }
105
+
106
+ interface MigrationCandidate {
107
+ source: string;
108
+ destination: string;
109
+ }
110
+
111
+ const candidates: MigrationCandidate[] = [];
112
+ let skipped = 0;
113
+ const parentCache = new Map<string, JsonObject[] | undefined>();
114
+
115
+ for (const file of findJsonlFiles(sourceDir)) {
116
+ const header = readSessionHeader(file);
117
+ const parent = header?.parentSession;
118
+ if (!header || typeof parent !== "string") continue;
119
+
120
+ const parentPath = path.resolve(parent);
121
+ if (!isWithin(sourceDir, parentPath) || !fs.existsSync(parentPath)) {
122
+ skipped++;
123
+ console.warn(`skip (parent unavailable): ${file}`);
124
+ continue;
125
+ }
126
+
127
+ let parentEntries = parentCache.get(parentPath);
128
+ if (parentEntries === undefined && !parentCache.has(parentPath)) {
129
+ parentEntries = readSession(parentPath);
130
+ parentCache.set(parentPath, parentEntries);
131
+ }
132
+ const entries = readSession(file);
133
+ if (!entries) {
134
+ skipped++;
135
+ console.warn(`skip (invalid session): ${file}`);
136
+ continue;
137
+ }
138
+ if (!parentEntries || isPiFork(entries, parentEntries)) continue;
139
+
140
+ const relative = path.relative(sourceDir, file);
141
+ candidates.push({
142
+ source: file,
143
+ destination: path.join(destinationDir, relative),
144
+ });
145
+ }
146
+
147
+ console.log(
148
+ `${apply ? "Migrating" : "Found"} ${candidates.length} delegate session(s); ` +
149
+ `${skipped} skipped because their parent could not be verified.`,
150
+ );
151
+
152
+ if (!apply) {
153
+ for (const candidate of candidates) {
154
+ console.log(`would move: ${candidate.source} -> ${candidate.destination}`);
155
+ }
156
+ console.log("Nothing changed. Re-run with --apply to perform the migration.");
157
+ process.exit(0);
158
+ }
159
+
160
+ let moved = 0;
161
+ for (const candidate of candidates) {
162
+ const entries = readSession(candidate.source);
163
+ const header = entries && sessionHeader(entries);
164
+ if (!entries || !header) {
165
+ console.warn(`skip (changed during migration): ${candidate.source}`);
166
+ continue;
167
+ }
168
+
169
+ delete header.parentSession;
170
+ const parent = path.dirname(candidate.destination);
171
+ fs.mkdirSync(parent, { recursive: true });
172
+
173
+ const temporary = `${candidate.source}.delegate-migration-${process.pid}.tmp`;
174
+ try {
175
+ if (fs.existsSync(candidate.destination)) {
176
+ throw new Error("destination already exists");
177
+ }
178
+ fs.writeFileSync(
179
+ temporary,
180
+ `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`,
181
+ { flag: "wx", mode: 0o600 },
182
+ );
183
+ fs.renameSync(temporary, candidate.source);
184
+ fs.renameSync(candidate.source, candidate.destination);
185
+ console.log(`moved: ${candidate.source} -> ${candidate.destination}`);
186
+ moved++;
187
+ } catch (error) {
188
+ try {
189
+ if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
190
+ } catch {
191
+ // Preserve the original error below; the temp file is harmless and
192
+ // uniquely named for this process.
193
+ }
194
+ console.error(
195
+ `failed: ${candidate.source}: ${
196
+ error instanceof Error ? error.message : String(error)
197
+ }`,
198
+ );
199
+ }
200
+ }
201
+
202
+ console.log(`Moved ${moved}/${candidates.length} delegate session(s).`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bermudi/pi-delegate",
3
- "version": "0.1.4",
3
+ "version": "0.1.7",
4
4
  "description": "Delegate tool for the Pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
package/sessions.ts CHANGED
@@ -1,67 +1,26 @@
1
1
  import * as fs from "node:fs";
2
- import { SessionManager } from "@earendil-works/pi-coding-agent";
2
+ import { join } from "node:path";
3
+ import { SessionManager, getAgentDir } from "@earendil-works/pi-coding-agent";
3
4
 
4
- /** Link a subagent session to its parent and persist the header when possible. */
5
- export function setParentSession(sm: SessionManager, parentPath: string): void {
6
- const inner = sm as unknown as {
7
- fileEntries: Array<{ type: string; parentSession?: string }>;
8
- getSessionFile?: () => string | undefined;
9
- _rewriteFile?: () => void;
10
- };
11
- const header = inner.fileEntries[0];
12
- if (header && header.type === "session") {
13
- header.parentSession = parentPath;
14
- // For a *resumed* session the file already exists on disk and the manager
15
- // is flushed (SessionManager.open/setSessionFile sets flushed=true). The
16
- // in-memory header mutation above is otherwise lost: upstream _persist()
17
- // only *appends* new entries once flushed — it never rewrites the header.
18
- // So a resumeFrom session would never surface as a child in /resume despite
19
- // the link being set in memory. Rewrite the whole file (header + entries)
20
- // so the parentSession field is actually persisted. Fresh sessions skip
21
- // this (file doesn't exist yet); their first _persist() writes the mutated
22
- // header along with the rest, and rewriting early would trip the
23
- // duplicate-header bug in _persist()'s not-yet-flushed path.
24
- const file = inner.getSessionFile?.();
25
- if (file && fs.existsSync(file)) {
26
- try {
27
- inner._rewriteFile?.();
28
- } catch {
29
- /* best effort — link stays in-memory; not fatal */
30
- }
31
- }
32
- }
5
+ /** Persistent storage for delegate-only conversations. */
6
+ export function getDelegateSessionDir(): string {
7
+ return join(getAgentDir(), "delegate-sessions");
33
8
  }
34
9
 
35
10
  /**
36
11
  * Create a session manager for a subagent run.
37
12
  *
38
- * Always creates a standalone session file in the target cwd.
39
- * Sets `parentSession` in the header so subagent work is discoverable
40
- * as a child of the parent session in `/resume`.
41
- *
42
- * Returns the concrete `SessionManager` (ready to hand to `createAgentSession`)
43
- * and its file path (for result reporting + pool bookkeeping).
13
+ * Delegate sessions are deliberately standalone and live in their own
14
+ * directory. They are not attached to the parent's session tree.
44
15
  */
45
16
  export function createSubagentSessionManager(
46
- parentSessionManager: unknown,
47
17
  cwd: string,
48
18
  ): { manager: SessionManager; file: string } | undefined {
49
- // Resolve parent session file path for linking.
50
- const parentFile = (
51
- parentSessionManager as
52
- { getSessionFile?(): string | undefined } | undefined
53
- )?.getSessionFile?.();
54
-
55
- // Always persist subagent work so the main agent can search it later.
56
- const sm = SessionManager.create(cwd);
19
+ // Always persist subagent work separately from the parent's session tree.
20
+ const sm = SessionManager.create(cwd, getDelegateSessionDir());
57
21
  const sessionFile = sm.getSessionFile();
58
22
  if (!sessionFile) return undefined;
59
23
 
60
- // Link to parent session so subagent appears as a child in /resume.
61
- if (parentFile) {
62
- setParentSession(sm, parentFile);
63
- }
64
-
65
24
  return { manager: sm, file: sessionFile };
66
25
  }
67
26
 
package/types.ts CHANGED
@@ -251,8 +251,6 @@ export interface TaskRunEnv {
251
251
  /** Abort signal — parent's for sync, ticket's for async. May be undefined when no parent signal is available. */
252
252
  signal: AbortSignal | undefined;
253
253
  modelRegistry: ModelRegistry;
254
- /** Parent session manager — used to link subagent sessions for /resume. */
255
- parentSessionManager: { getSessionFile?(): string | undefined } | undefined;
256
254
  /** Ticket id for busy-guard self-checks. undefined for sync. */
257
255
  ticketId?: string;
258
256
  /** When the delegate started. Used for close/list progress (elapsed time). */
package/workspace.ts CHANGED
@@ -3,7 +3,9 @@ import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { scheduleDeadline } from "./timer.ts";
5
5
 
6
- const SCRATCH_PREFIX = ".pi-delegate-scratch-";
6
+ const SCRATCH_CONTAINER_NAME = ".pi-delegate-scratch";
7
+ const SCRATCH_LEASE_PREFIX = "lease-";
8
+ const SCRATCH_LEGACY_PREFIX = ".pi-delegate-scratch-";
7
9
  const SCRATCH_TREE_NAME = "project";
8
10
  const SCRATCH_OWNER_NAME = ".owner";
9
11
  const COPY_TIMEOUT_MS = 5 * 60 * 1000;
@@ -36,6 +38,8 @@ export class ScratchSetupError extends Error {}
36
38
 
37
39
  export class ScratchDeadlineError extends ScratchSetupError {}
38
40
 
41
+ class ScratchLeaseIdentityError extends Error {}
42
+
39
43
  class CommandError extends Error {
40
44
  constructor(
41
45
  message: string,
@@ -195,167 +199,381 @@ function isProcessAlive(pid: number): boolean {
195
199
  }
196
200
  }
197
201
 
202
+ function sameFileIdentity(left: fs.Stats, right: fs.Stats): boolean {
203
+ // dev+ino alone can alias after an unlink+mkdir reuses the same inode
204
+ // (observed on ext4 in CI: project replaced in the sweep race test
205
+ // reused the previous ino). Birthtime distinguishes a recreated entry
206
+ // and is stable across the chmod 0500→0700 transitions that update
207
+ // ctime. Where birthtime is unavailable (0) we fall back to dev+ino.
208
+ if (left.dev !== right.dev || left.ino !== right.ino) return false;
209
+ if (left.birthtimeMs !== 0 || right.birthtimeMs !== 0) {
210
+ return left.birthtimeMs === right.birthtimeMs;
211
+ }
212
+ return true;
213
+ }
214
+
215
+ function parseOwnerPid(content: string): number | undefined {
216
+ const value = content.trim();
217
+ if (!/^[1-9][0-9]*$/.test(value)) return undefined;
218
+ const pid = Number(value);
219
+ return Number.isSafeInteger(pid) ? pid : undefined;
220
+ }
221
+
222
+ type LeaseDeletionExpectations =
223
+ | {
224
+ hasProject: true;
225
+ lease: fs.Stats;
226
+ project: fs.Stats;
227
+ owner: fs.Stats;
228
+ }
229
+ | {
230
+ hasProject: false;
231
+ lease: fs.Stats;
232
+ owner: fs.Stats;
233
+ };
234
+
235
+ async function deleteLeaseContentsAndRmdir(
236
+ parentHandle: fs.promises.FileHandle,
237
+ leaseName: string,
238
+ leaseHandle: fs.promises.FileHandle,
239
+ expectations: LeaseDeletionExpectations,
240
+ ): Promise<void> {
241
+ const leasePath = path.join(`/proc/self/fd/${parentHandle.fd}`, leaseName);
242
+ const openLeaseStat = await leaseHandle.stat();
243
+ const currentLeaseStat = await fs.promises.lstat(leasePath);
244
+ if (
245
+ !openLeaseStat.isDirectory() ||
246
+ !sameFileIdentity(openLeaseStat, expectations.lease) ||
247
+ !sameFileIdentity(currentLeaseStat, openLeaseStat)
248
+ ) {
249
+ throw new ScratchLeaseIdentityError(
250
+ "Scratch lease was moved or replaced; refusing cleanup.",
251
+ );
252
+ }
253
+
254
+ await leaseHandle.chmod(0o700);
255
+ const ownerPath = path.join(
256
+ `/proc/self/fd/${leaseHandle.fd}`,
257
+ SCRATCH_OWNER_NAME,
258
+ );
259
+ const initialOwnerStat = await fs.promises.lstat(ownerPath);
260
+ if (!sameFileIdentity(initialOwnerStat, expectations.owner)) {
261
+ throw new ScratchLeaseIdentityError(
262
+ "Scratch owner marker was replaced; refusing cleanup.",
263
+ );
264
+ }
265
+
266
+ if (expectations.hasProject) {
267
+ const projectPath = path.join(
268
+ `/proc/self/fd/${leaseHandle.fd}`,
269
+ SCRATCH_TREE_NAME,
270
+ );
271
+ const projectHandle = await fs.promises.open(
272
+ projectPath,
273
+ fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
274
+ );
275
+ try {
276
+ const openProjectStat = await projectHandle.stat();
277
+ const currentProjectStat = await fs.promises.lstat(projectPath);
278
+ if (
279
+ !openProjectStat.isDirectory() ||
280
+ !sameFileIdentity(openProjectStat, expectations.project) ||
281
+ !sameFileIdentity(currentProjectStat, openProjectStat)
282
+ ) {
283
+ throw new ScratchLeaseIdentityError(
284
+ "Scratch project was moved or replaced; refusing cleanup.",
285
+ );
286
+ }
287
+
288
+ // Remove children through the opened project directory, not the project
289
+ // pathname. This means a replacement at `project` is never recursively
290
+ // traversed. The final rmdir is still a pathname operation; the identity
291
+ // is checked again immediately beforehand, so this is fail-closed for
292
+ // the deterministic replacement races we can observe, not an atomic
293
+ // guarantee against a cooperating same-user process.
294
+ for (const name of await fs.promises.readdir(
295
+ `/proc/self/fd/${projectHandle.fd}`,
296
+ )) {
297
+ await fs.promises.rm(
298
+ path.join(`/proc/self/fd/${projectHandle.fd}`, name),
299
+ { recursive: true, force: false },
300
+ );
301
+ }
302
+ const finalProjectStat = await fs.promises.lstat(projectPath);
303
+ if (!sameFileIdentity(finalProjectStat, openProjectStat)) {
304
+ throw new ScratchLeaseIdentityError(
305
+ "Scratch project was moved or replaced; refusing cleanup.",
306
+ );
307
+ }
308
+ await fs.promises.rmdir(projectPath);
309
+ } finally {
310
+ await projectHandle.close();
311
+ }
312
+ }
313
+
314
+ const currentOwnerStat = await fs.promises.lstat(ownerPath);
315
+ if (!sameFileIdentity(currentOwnerStat, expectations.owner)) {
316
+ throw new ScratchLeaseIdentityError(
317
+ "Scratch owner marker was replaced; refusing cleanup.",
318
+ );
319
+ }
320
+ await fs.promises.rm(ownerPath, { force: false });
321
+
322
+ const finalLeaseStat = await fs.promises.lstat(leasePath);
323
+ if (!sameFileIdentity(finalLeaseStat, openLeaseStat)) {
324
+ throw new ScratchLeaseIdentityError(
325
+ "Scratch lease was moved or replaced; refusing cleanup.",
326
+ );
327
+ }
328
+ await fs.promises.rmdir(leasePath);
329
+ }
330
+
331
+ async function ensureScratchContainer(
332
+ containerDir: string,
333
+ uid: number | undefined,
334
+ ): Promise<void> {
335
+ try {
336
+ await fs.promises.mkdir(containerDir, { mode: 0o700 });
337
+ await fs.promises.chmod(containerDir, 0o700);
338
+ return;
339
+ } catch (error) {
340
+ if (!(
341
+ error instanceof Error &&
342
+ "code" in error &&
343
+ error.code === "EEXIST"
344
+ )) {
345
+ throw error;
346
+ }
347
+ }
348
+
349
+ const stat = await fs.promises.lstat(containerDir);
350
+ if (!stat.isDirectory() || (uid !== undefined && stat.uid !== uid)) {
351
+ throw new ScratchSetupError(
352
+ `Scratch container directory '${containerDir}' is not a directory owned by the current user.`,
353
+ );
354
+ }
355
+ if ((stat.mode & 0o7777) !== 0o700) {
356
+ await fs.promises.chmod(containerDir, 0o700);
357
+ }
358
+ }
359
+
360
+ interface SweepOptions {
361
+ prefix?: string;
362
+ onLeaseOpened?: (leaseName: string, leaseFd: number) => Promise<void> | void;
363
+ onLeaseValidated?: (leaseName: string) => Promise<void> | void;
364
+ }
365
+
198
366
  /** Remove leases left behind by a process that is no longer running.
199
367
  *
200
368
  * The owner marker distinguishes our leases from unrelated prefix-matching
201
- * directories. Live owners are never touched. The final removal still goes
202
- * through opened descriptors and a non-recursive rmdir, so a replacement or
203
- * active workspace fails closed.
369
+ * directories. Live owners are never touched. Descriptors make the scan
370
+ * independent of a renamed parent, while pathname identity checks ensure that
371
+ * a lease renamed or replaced after it was opened is left alone. These checks
372
+ * are snapshots rather than an atomic cross-process locking primitive.
204
373
  */
205
- async function sweepStaleScratchLeases(parent: string): Promise<void> {
374
+ async function sweepStaleScratchLeases(
375
+ container: string,
376
+ options: SweepOptions = {},
377
+ ): Promise<void> {
206
378
  const uid = process.getuid?.();
207
379
  if (uid === undefined) return;
208
380
 
209
- let entries: fs.Dirent[];
381
+ let parentHandle: fs.promises.FileHandle;
210
382
  try {
211
- entries = await fs.promises.readdir(parent, { withFileTypes: true });
212
- } catch (error) {
213
- console.error("[delegate] scratch lease sweep failed", error);
383
+ parentHandle = await fs.promises.open(
384
+ container,
385
+ fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
386
+ );
387
+ } catch {
214
388
  return;
215
389
  }
216
390
 
217
- for (const entry of entries) {
218
- if (!entry.name.startsWith(SCRATCH_PREFIX) || !entry.isDirectory()) {
219
- continue;
220
- }
221
- const leaseRoot = path.join(parent, entry.name);
391
+ try {
392
+ const parentStat = await parentHandle.stat();
393
+ if (!parentStat.isDirectory() || parentStat.uid !== uid) return;
394
+
395
+ let entries: fs.Dirent[];
222
396
  try {
223
- const leaseStat = await fs.promises.lstat(leaseRoot);
224
- if (!leaseStat.isDirectory() || leaseStat.uid !== uid) continue;
225
- const contents = await fs.promises.readdir(leaseRoot);
226
- if (!contents.includes(SCRATCH_OWNER_NAME)) {
227
- // Empty leases from versions without an owner marker are still safe
228
- // to reclaim; anything else may be an unrelated directory.
229
- if (contents.length === 0) await fs.promises.rmdir(leaseRoot);
230
- continue;
231
- }
232
- const ownerPath = path.join(leaseRoot, SCRATCH_OWNER_NAME);
233
- const ownerStat = await fs.promises.lstat(ownerPath);
234
- if (
235
- !ownerStat.isFile() ||
236
- ownerStat.uid !== uid ||
237
- ownerStat.mode & 0o077
238
- ) {
239
- continue;
240
- }
241
- const pid = Number.parseInt(
242
- (await fs.promises.readFile(ownerPath, "utf8")).trim(),
243
- 10,
244
- );
245
- if (!Number.isSafeInteger(pid) || isProcessAlive(pid)) {
246
- continue;
247
- }
397
+ entries = await fs.promises.readdir(`/proc/self/fd/${parentHandle.fd}`, {
398
+ withFileTypes: true,
399
+ });
400
+ } catch (error) {
401
+ console.error("[delegate] scratch lease sweep failed", error);
402
+ return;
403
+ }
248
404
 
249
- const projectRoot = path.join(leaseRoot, SCRATCH_TREE_NAME);
250
- if (
251
- contents.some(
252
- (name) => name !== SCRATCH_OWNER_NAME && name !== SCRATCH_TREE_NAME,
253
- )
254
- ) {
255
- continue;
256
- }
257
- let projectStat: fs.Stats | undefined;
258
- try {
259
- projectStat = await fs.promises.lstat(projectRoot);
260
- if (!projectStat.isDirectory()) continue;
261
- } catch (error) {
262
- if (!(
263
- error instanceof Error &&
264
- "code" in error &&
265
- error.code === "ENOENT"
266
- )) {
267
- throw error;
268
- }
269
- }
405
+ for (const entry of entries) {
406
+ if (!entry.isDirectory()) continue;
407
+ if (options.prefix && !entry.name.startsWith(options.prefix)) continue;
270
408
 
271
- // Open the parent and lease before removing anything. This repeats the
272
- // same identity checks as normal cleanup against the directory found by
273
- // the initial scan, rather than trusting a pathname that may be replaced.
274
- const parentHandle = await fs.promises.open(
275
- parent,
276
- fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
277
- );
278
- let leaseHandle: Awaited<ReturnType<typeof fs.promises.open>> | undefined;
279
- let projectHandle:
280
- Awaited<ReturnType<typeof fs.promises.open>> | undefined;
409
+ let leaseHandle: fs.promises.FileHandle | undefined;
281
410
  try {
411
+ const leasePath = path.join(
412
+ `/proc/self/fd/${parentHandle.fd}`,
413
+ entry.name,
414
+ );
282
415
  leaseHandle = await fs.promises.open(
283
- path.join(`/proc/self/fd/${parentHandle.fd}`, entry.name),
416
+ leasePath,
284
417
  fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
285
418
  );
286
- const currentLeaseStat = await fs.promises.lstat(leaseRoot);
287
- const openLeaseStat = await leaseHandle.stat();
419
+ const openedLeaseStat = await leaseHandle.stat();
420
+ const scannedLeaseStat = await fs.promises.lstat(leasePath);
288
421
  if (
289
- currentLeaseStat.dev !== leaseStat.dev ||
290
- currentLeaseStat.ino !== leaseStat.ino ||
291
- openLeaseStat.dev !== leaseStat.dev ||
292
- openLeaseStat.ino !== leaseStat.ino
422
+ !openedLeaseStat.isDirectory() ||
423
+ openedLeaseStat.uid !== uid ||
424
+ !sameFileIdentity(scannedLeaseStat, openedLeaseStat)
293
425
  ) {
294
426
  continue;
295
427
  }
296
- const currentOwnerPath = path.join(
428
+
429
+ // Snapshot the identities before the test hook / concurrent work. If
430
+ // either pathname changes, the opened descriptor is not used for
431
+ // deletion. In particular, a rename must not turn this into cleanup of
432
+ // a lease that merely moved elsewhere.
433
+ const ownerPath = path.join(
297
434
  `/proc/self/fd/${leaseHandle.fd}`,
298
435
  SCRATCH_OWNER_NAME,
299
436
  );
300
- const currentOwnerStat = await fs.promises.lstat(currentOwnerPath);
301
- const currentPid = Number.parseInt(
302
- (await fs.promises.readFile(currentOwnerPath, "utf8")).trim(),
303
- 10,
304
- );
437
+ let scannedOwnerStat: fs.Stats;
438
+ try {
439
+ scannedOwnerStat = await fs.promises.lstat(ownerPath);
440
+ } catch (error) {
441
+ if (
442
+ error instanceof Error &&
443
+ "code" in error &&
444
+ error.code === "ENOENT"
445
+ ) {
446
+ // Leases from versions without an owner marker, or partial leases
447
+ // from a crash between mkdtemp and the marker write: reclaim only
448
+ // when empty. Anything else may be an unrelated directory. The
449
+ // identity re-check keeps the rmdir anchored to the scanned lease.
450
+ const contents = await fs.promises.readdir(
451
+ `/proc/self/fd/${leaseHandle.fd}`,
452
+ );
453
+ if (contents.length === 0) {
454
+ const currentLeaseStat = await fs.promises.lstat(leasePath);
455
+ if (sameFileIdentity(currentLeaseStat, scannedLeaseStat)) {
456
+ await fs.promises.rmdir(leasePath);
457
+ }
458
+ }
459
+ continue;
460
+ }
461
+ throw error;
462
+ }
463
+ let scannedProjectStat: fs.Stats | undefined;
464
+ try {
465
+ scannedProjectStat = await fs.promises.lstat(
466
+ path.join(`/proc/self/fd/${leaseHandle.fd}`, SCRATCH_TREE_NAME),
467
+ );
468
+ } catch (error) {
469
+ if (!(
470
+ error instanceof Error &&
471
+ "code" in error &&
472
+ error.code === "ENOENT"
473
+ )) {
474
+ throw error;
475
+ }
476
+ }
477
+
478
+ if (options.onLeaseOpened) {
479
+ await options.onLeaseOpened(entry.name, leaseHandle.fd);
480
+ }
481
+
482
+ const currentLeaseStat = await fs.promises.lstat(leasePath);
483
+ if (!sameFileIdentity(currentLeaseStat, scannedLeaseStat)) continue;
484
+ const currentOwnerStat = await fs.promises.lstat(ownerPath);
485
+ if (!sameFileIdentity(currentOwnerStat, scannedOwnerStat)) continue;
486
+ if (scannedProjectStat) {
487
+ const currentProjectStat = await fs.promises.lstat(
488
+ path.join(`/proc/self/fd/${leaseHandle.fd}`, SCRATCH_TREE_NAME),
489
+ );
490
+ if (!sameFileIdentity(currentProjectStat, scannedProjectStat)) {
491
+ continue;
492
+ }
493
+ }
494
+
305
495
  if (
306
496
  !currentOwnerStat.isFile() ||
307
497
  currentOwnerStat.uid !== uid ||
308
- currentOwnerStat.dev !== ownerStat.dev ||
309
- currentOwnerStat.ino !== ownerStat.ino ||
310
- !Number.isSafeInteger(currentPid) ||
311
- isProcessAlive(currentPid)
498
+ (currentOwnerStat.mode & 0o077) !== 0
312
499
  ) {
313
500
  continue;
314
501
  }
315
- if (projectStat) {
316
- projectHandle = await fs.promises.open(
317
- path.join(`/proc/self/fd/${leaseHandle.fd}`, SCRATCH_TREE_NAME),
318
- fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
319
- );
320
- const openProjectStat = await projectHandle.stat();
321
- if (
322
- openProjectStat.dev !== projectStat.dev ||
323
- openProjectStat.ino !== projectStat.ino
324
- ) {
325
- continue;
326
- }
502
+
503
+ const ownerContent = await fs.promises.readFile(ownerPath, "utf8");
504
+ const pid = parseOwnerPid(ownerContent);
505
+ if (pid === undefined || isProcessAlive(pid)) continue;
506
+
507
+ const contents = await fs.promises.readdir(
508
+ `/proc/self/fd/${leaseHandle.fd}`,
509
+ );
510
+ if (
511
+ contents.some(
512
+ (name) => name !== SCRATCH_OWNER_NAME && name !== SCRATCH_TREE_NAME,
513
+ )
514
+ ) {
515
+ continue;
327
516
  }
328
- await leaseHandle.chmod(0o700);
329
- if (projectStat) {
330
- await fs.promises.rm(
331
- path.join(`/proc/self/fd/${leaseHandle.fd}`, SCRATCH_TREE_NAME),
332
- { recursive: true, force: false },
517
+
518
+ const hasProject = contents.includes(SCRATCH_TREE_NAME);
519
+ if (
520
+ hasProject &&
521
+ (!scannedProjectStat ||
522
+ !scannedProjectStat.isDirectory() ||
523
+ scannedProjectStat.isSymbolicLink())
524
+ ) {
525
+ continue;
526
+ }
527
+
528
+ if (options.onLeaseValidated) {
529
+ await options.onLeaseValidated(entry.name);
530
+ }
531
+ if (hasProject) {
532
+ if (!scannedProjectStat) continue;
533
+ await deleteLeaseContentsAndRmdir(
534
+ parentHandle,
535
+ entry.name,
536
+ leaseHandle,
537
+ {
538
+ hasProject: true,
539
+ lease: scannedLeaseStat,
540
+ project: scannedProjectStat,
541
+ owner: scannedOwnerStat,
542
+ },
333
543
  );
544
+ } else {
545
+ await deleteLeaseContentsAndRmdir(
546
+ parentHandle,
547
+ entry.name,
548
+ leaseHandle,
549
+ {
550
+ hasProject: false,
551
+ lease: scannedLeaseStat,
552
+ owner: scannedOwnerStat,
553
+ },
554
+ );
555
+ }
556
+ } catch (error) {
557
+ if (error instanceof ScratchLeaseIdentityError) continue;
558
+ if (
559
+ error instanceof Error &&
560
+ "code" in error &&
561
+ (error.code === "ENOENT" ||
562
+ error.code === "ENOTDIR" ||
563
+ error.code === "ENOTEMPTY")
564
+ ) {
565
+ continue;
334
566
  }
335
- await fs.promises.rm(currentOwnerPath, { force: false });
336
- await fs.promises.rmdir(
337
- path.join(`/proc/self/fd/${parentHandle.fd}`, entry.name),
567
+ console.error(
568
+ `[delegate] failed to sweep stale scratch lease '${entry.name}'`,
569
+ error,
338
570
  );
339
571
  } finally {
340
- await projectHandle?.close();
341
572
  await leaseHandle?.close();
342
- await parentHandle.close();
343
- }
344
- } catch (error) {
345
- if (
346
- error instanceof Error &&
347
- "code" in error &&
348
- (error.code === "ENOENT" || error.code === "ENOTDIR")
349
- ) {
350
- continue;
351
573
  }
352
- // A concurrent creator/remover can legitimately win this race. Other
353
- // failures are still reported, but must not block a new scratch task.
354
- console.error(
355
- `[delegate] failed to sweep stale scratch lease '${leaseRoot}'`,
356
- error,
357
- );
358
574
  }
575
+ } finally {
576
+ await parentHandle.close();
359
577
  }
360
578
  }
361
579
 
@@ -392,10 +610,12 @@ export async function createScratchWorkspace(
392
610
 
393
611
  let sourceCwd: string;
394
612
  let sourceRoot: string;
613
+ let containerDir: string;
395
614
  let leaseRoot: string | undefined;
396
615
  let scratchRoot: string | undefined;
397
616
  let copiedLeaseStat: fs.Stats | undefined;
398
617
  let copiedRootStat: fs.Stats | undefined;
618
+ let copiedOwnerStat: fs.Stats | undefined;
399
619
  try {
400
620
  if (signal?.aborted) controller.abort(signal.reason);
401
621
  throwIfSetupCancelled(controller.signal, signal);
@@ -409,9 +629,18 @@ export async function createScratchWorkspace(
409
629
  );
410
630
  }
411
631
 
412
- await sweepStaleScratchLeases(path.dirname(sourceRoot));
632
+ containerDir = path.join(path.dirname(sourceRoot), SCRATCH_CONTAINER_NAME);
633
+ const uid = process.getuid?.();
634
+ await ensureScratchContainer(containerDir, uid);
635
+ if (uid !== undefined) {
636
+ await sweepStaleScratchLeases(containerDir);
637
+ await sweepStaleScratchLeases(path.dirname(sourceRoot), {
638
+ prefix: SCRATCH_LEGACY_PREFIX,
639
+ });
640
+ }
641
+
413
642
  leaseRoot = await fs.promises.mkdtemp(
414
- path.join(path.dirname(sourceRoot), SCRATCH_PREFIX),
643
+ path.join(containerDir, SCRATCH_LEASE_PREFIX),
415
644
  );
416
645
  await fs.promises.chmod(leaseRoot, 0o700);
417
646
  await fs.promises.writeFile(
@@ -494,6 +723,9 @@ export async function createScratchWorkspace(
494
723
  // fails, the catch below restores permissions and removes the partial copy.
495
724
  copiedLeaseStat = await fs.promises.lstat(leaseRoot);
496
725
  copiedRootStat = await fs.promises.lstat(scratchRoot);
726
+ copiedOwnerStat = await fs.promises.lstat(
727
+ path.join(leaseRoot, SCRATCH_OWNER_NAME),
728
+ );
497
729
  } catch (error) {
498
730
  if (leaseRoot) {
499
731
  try {
@@ -532,6 +764,7 @@ export async function createScratchWorkspace(
532
764
  const completedRoot = scratchRoot!;
533
765
  const completedLeaseStat = copiedLeaseStat!;
534
766
  const completedRootStat = copiedRootStat!;
767
+ const completedOwnerStat = copiedOwnerStat!;
535
768
  const relativeCwd = path.relative(sourceRoot!, sourceCwd!);
536
769
  let cleaned = false;
537
770
  const resolveReportedPath = async (candidate: string): Promise<string> => {
@@ -580,8 +813,8 @@ export async function createScratchWorkspace(
580
813
  if (cleaned) return;
581
814
  try {
582
815
  if (
583
- path.dirname(completedLeaseRoot) !== path.dirname(sourceRoot!) ||
584
- !path.basename(completedLeaseRoot).startsWith(SCRATCH_PREFIX) ||
816
+ path.dirname(completedLeaseRoot) !== containerDir ||
817
+ !path.basename(completedLeaseRoot).startsWith(SCRATCH_LEASE_PREFIX) ||
585
818
  path.dirname(completedRoot) !== completedLeaseRoot ||
586
819
  path.basename(completedRoot) !== SCRATCH_TREE_NAME
587
820
  ) {
@@ -593,7 +826,7 @@ export async function createScratchWorkspace(
593
826
  // descriptor identifies the checked directory even if its pathname is
594
827
  // renamed or replaced while cleanup is running.
595
828
  const parentHandle = await fs.promises.open(
596
- path.dirname(completedLeaseRoot),
829
+ containerDir,
597
830
  fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
598
831
  );
599
832
  let leaseHandle:
@@ -601,59 +834,51 @@ export async function createScratchWorkspace(
601
834
  let rootHandle:
602
835
  Awaited<ReturnType<typeof fs.promises.open>> | undefined;
603
836
  try {
837
+ const leaseName = path.basename(completedLeaseRoot);
604
838
  leaseHandle = await fs.promises.open(
605
- path.join(
606
- `/proc/self/fd/${parentHandle.fd}`,
607
- path.basename(completedLeaseRoot),
608
- ),
839
+ path.join(`/proc/self/fd/${parentHandle.fd}`, leaseName),
609
840
  fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
610
841
  );
611
842
  rootHandle = await fs.promises.open(
612
843
  path.join(`/proc/self/fd/${leaseHandle.fd}`, SCRATCH_TREE_NAME),
613
844
  fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
614
845
  );
615
- const currentLeaseStat = await fs.promises.lstat(completedLeaseRoot);
616
- const currentRootStat = await fs.promises.lstat(completedRoot);
617
846
  const openLeaseStat = await leaseHandle.stat();
618
847
  const openRootStat = await rootHandle.stat();
848
+ const currentLeaseStat = await fs.promises.lstat(
849
+ path.join(`/proc/self/fd/${parentHandle.fd}`, leaseName),
850
+ );
851
+ const currentRootStat = await fs.promises.lstat(
852
+ path.join(`/proc/self/fd/${leaseHandle.fd}`, SCRATCH_TREE_NAME),
853
+ );
619
854
  if (
620
- !currentLeaseStat.isDirectory() ||
621
- currentLeaseStat.dev !== completedLeaseStat.dev ||
622
- currentLeaseStat.ino !== completedLeaseStat.ino ||
623
- !currentRootStat.isDirectory() ||
624
- currentRootStat.dev !== completedRootStat.dev ||
625
- currentRootStat.ino !== completedRootStat.ino ||
626
855
  !openLeaseStat.isDirectory() ||
627
- openLeaseStat.dev !== completedLeaseStat.dev ||
628
- openLeaseStat.ino !== completedLeaseStat.ino ||
856
+ !sameFileIdentity(openLeaseStat, completedLeaseStat) ||
857
+ !sameFileIdentity(currentLeaseStat, completedLeaseStat) ||
629
858
  !openRootStat.isDirectory() ||
630
- openRootStat.dev !== completedRootStat.dev ||
631
- openRootStat.ino !== completedRootStat.ino
859
+ !sameFileIdentity(openRootStat, completedRootStat) ||
860
+ !sameFileIdentity(currentRootStat, completedRootStat)
632
861
  ) {
633
862
  throw new Error(
634
863
  "Scratch workspace root was moved or replaced; refusing to report cleanup success.",
635
864
  );
636
865
  }
637
- await leaseHandle.chmod(0o700);
638
- // Remove the project through the opened lease descriptor. The
639
- // recursive operation never resolves the disposable root pathname.
640
- await fs.promises.rm(
641
- path.join(`/proc/self/fd/${leaseHandle.fd}`, SCRATCH_TREE_NAME),
642
- { recursive: true, force: false },
643
- );
644
- await fs.promises.rm(
645
- path.join(`/proc/self/fd/${leaseHandle.fd}`, SCRATCH_OWNER_NAME),
646
- { force: false },
647
- );
648
- // The lease is empty now. Remove only its directory entry through the
649
- // opened parent. This is deliberately non-recursive: if a cooperating
650
- // process replaced the lease with a populated directory, rmdir fails
651
- // instead of deleting the replacement's contents.
652
- await fs.promises.rmdir(
653
- path.join(
654
- `/proc/self/fd/${parentHandle.fd}`,
655
- path.basename(completedLeaseRoot),
656
- ),
866
+
867
+ // The identity checks are snapshots. The primitive repeats them and
868
+ // removes project children through its opened descriptor, so a
869
+ // replacement observed before removal is preserved. This is not an
870
+ // atomic guarantee against a cooperating process changing the path
871
+ // after the final check.
872
+ await deleteLeaseContentsAndRmdir(
873
+ parentHandle,
874
+ leaseName,
875
+ leaseHandle,
876
+ {
877
+ hasProject: true,
878
+ lease: completedLeaseStat,
879
+ project: completedRootStat,
880
+ owner: completedOwnerStat,
881
+ },
657
882
  );
658
883
  cleaned = true;
659
884
  } finally {
@@ -670,3 +895,14 @@ export async function createScratchWorkspace(
670
895
  },
671
896
  };
672
897
  }
898
+
899
+ export const _testHooks = {
900
+ sweepStaleScratchLeases,
901
+ ensureScratchContainer,
902
+ deleteLeaseContentsAndRmdir,
903
+ SCRATCH_CONTAINER_NAME,
904
+ SCRATCH_LEASE_PREFIX,
905
+ SCRATCH_LEGACY_PREFIX,
906
+ SCRATCH_TREE_NAME,
907
+ SCRATCH_OWNER_NAME,
908
+ };