@bermudi/pi-delegate 0.1.13 → 0.1.14

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/tickets.ts CHANGED
@@ -94,8 +94,13 @@ export const ticketRegistry = new TicketRegistry();
94
94
 
95
95
  /** Generate a short human-copyable identifier for an async ticket. */
96
96
  export function generateTicketId(): string {
97
- // 8-char alphanumeric, no lookalikes
98
- return Math.random().toString(36).slice(2, 10);
97
+ // Retry on the extremely unlikely collision rather than allowing Map.set()
98
+ // in dispatch to replace a still-retained ticket.
99
+ let id: string;
100
+ do {
101
+ id = Math.random().toString(36).slice(2, 10);
102
+ } while (!id || ticketRegistry.has(id));
103
+ return id;
99
104
  }
100
105
 
101
106
  /** Remove completed tickets after their retention TTL. Running tickets have no
@@ -220,13 +225,19 @@ export function resolveFinalTicketStatus(
220
225
  export function formatCompletedTicket(
221
226
  ticket: AsyncTicket,
222
227
  ): AgentToolResult<DelegateDetails> {
228
+ // Shutdown can make a ticket terminal while its workers are still unwinding.
229
+ // Do not freeze that partial projection: late TaskResults must appear in a
230
+ // later poll once the worker-settled barrier has resolved. Tickets created
231
+ // before this marker existed (including simple fixtures) are already safe to
232
+ // memoize because only live async dispatches explicitly set it false.
233
+ const canMemoize = ticket.workersSettled !== false;
234
+ if (canMemoize && ticket.formattedResult) return ticket.formattedResult;
235
+
223
236
  const parts: string[] = [];
224
237
  const succeeded = ticket.results.filter(
225
238
  (r) => r && !("error" in r && r.error),
226
239
  ).length;
227
- const elapsedTotal = ticket.completedAt
228
- ? ticket.completedAt - ticket.created
229
- : 0;
240
+ const elapsedTotal = (ticket.completedAt ?? Date.now()) - ticket.created;
230
241
  // Surface the overall ticket status so a failed/cancelled batch is not
231
242
  // mistaken for success. "done" tickets keep the original header; others
232
243
  // get an explicit status tag up front.
@@ -280,7 +291,7 @@ export function formatCompletedTicket(
280
291
  );
281
292
  }
282
293
 
283
- return {
294
+ const formatted: AgentToolResult<DelegateDetails> = {
284
295
  content: [{ type: "text", text: parts.join("\n\n") }],
285
296
  details: {
286
297
  tasks: ticket.tasks,
@@ -297,10 +308,13 @@ export function formatCompletedTicket(
297
308
  // the human sees which ticket they polled, even in the rich tree path.
298
309
  ticketId: ticket.id,
299
310
  status: ticket.status,
311
+ elapsedMs: elapsedTotal,
300
312
  overlapWarning: overlapWarning || undefined,
301
313
  dispatchWarning: ticket.dispatchWarning,
302
314
  },
303
315
  };
316
+ if (canMemoize) ticket.formattedResult = formatted;
317
+ return formatted;
304
318
  }
305
319
 
306
320
  // ── Waiter helpers ─────────────────────────────────────────────────────────
@@ -340,6 +354,7 @@ function buildWaitDetails(ticket: AsyncTicket): DelegateDetails {
340
354
  parentModel: ticket.parentModelId,
341
355
  ticketId: ticket.id,
342
356
  status: ticket.status,
357
+ elapsedMs: (ticket.completedAt ?? Date.now()) - ticket.created,
343
358
  overlapWarning: overlapWarning || undefined,
344
359
  dispatchWarning: ticket.dispatchWarning,
345
360
  };
@@ -427,6 +442,9 @@ function settleWaiter(
427
442
  if (w.settled) return;
428
443
  w.settled = true;
429
444
  w.clearDeadline?.();
445
+ w.clearDeadline = undefined;
446
+ w.removeAbortListener?.();
447
+ w.removeAbortListener = undefined;
430
448
  w.resolve(result);
431
449
  }
432
450
 
@@ -573,6 +591,7 @@ export function deliverTicketResults(
573
591
  ...formatted.details,
574
592
  ticketId: ticket.id,
575
593
  status: ticket.status,
594
+ crossLeafDelivery: crossLeaf,
576
595
  },
577
596
  },
578
597
  crossLeaf
@@ -626,6 +645,7 @@ export function handlePoll(
626
645
  // (friction #2). The LLM-facing content still names the ticket id too.
627
646
  ticketId: ticket.id,
628
647
  status: ticket.status,
648
+ elapsedMs: Date.now() - ticket.created,
629
649
  overlapWarning: snapshot.overlapWarning || undefined,
630
650
  dispatchWarning: ticket.dispatchWarning,
631
651
  },
@@ -747,13 +767,13 @@ export function handleWait(
747
767
  };
748
768
 
749
769
  if (signal) {
750
- signal.addEventListener(
751
- "abort",
752
- () => {
753
- abortWaiter(waiter, ticket);
754
- },
755
- { once: true },
756
- );
770
+ const onAbort = () => {
771
+ abortWaiter(waiter, ticket);
772
+ };
773
+ signal.addEventListener("abort", onAbort, { once: true });
774
+ waiter.removeAbortListener = () => {
775
+ signal.removeEventListener("abort", onAbort);
776
+ };
757
777
  }
758
778
 
759
779
  if (
package/tools.ts CHANGED
@@ -35,10 +35,11 @@ const PROVIDER_TOOLS: Readonly<Record<string, readonly string[]>> = {
35
35
  };
36
36
 
37
37
  export function availableToolNames(modelProvider?: string): string[] {
38
- return [
39
- ...Object.keys(TOOL_FACTORIES),
40
- ...(modelProvider ? (PROVIDER_TOOLS[modelProvider] ?? []) : []),
41
- ];
38
+ const providerTools =
39
+ modelProvider && Object.hasOwn(PROVIDER_TOOLS, modelProvider)
40
+ ? PROVIDER_TOOLS[modelProvider]
41
+ : undefined;
42
+ return [...Object.keys(TOOL_FACTORIES), ...(providerTools ?? [])];
42
43
  }
43
44
 
44
45
  /** Expand tool-group shorthands (`*`, `ro`) into concrete tool lists.
package/types.ts CHANGED
@@ -70,6 +70,7 @@ export interface TicketWaiter {
70
70
  resolve: (result: AgentToolResult<DelegateDetails>) => void;
71
71
  reject: (reason: unknown) => void;
72
72
  clearDeadline?: () => void;
73
+ removeAbortListener?: () => void;
73
74
  settled: boolean;
74
75
  }
75
76
 
@@ -112,6 +113,10 @@ export interface AsyncTicket {
112
113
  /** Immutable dispatch-scoped delegate.json snapshot used by async workers and
113
114
  * later result formatting. */
114
115
  config?: import("./config.ts").DelegateConfig;
116
+ /** Memoized terminal projection, populated only after workers settle. Besides
117
+ * avoiding repeated work, this keeps repeated poll/wait calls from creating
118
+ * duplicate output spill files without freezing a shutdown-time partial result. */
119
+ formattedResult?: AgentToolResult<DelegateDetails>;
115
120
  }
116
121
 
117
122
  /** Live parent settings captured when a delegate call starts. The built-in
@@ -156,10 +161,45 @@ export interface ResolvedTask {
156
161
  providerExtensionSources?: string;
157
162
  }
158
163
 
164
+ export interface FileAttributionPathSignature {
165
+ /** Absolute component inspected while resolving the pre-execution target. */
166
+ path: string;
167
+ /** Filesystem identity. Strings avoid precision loss on platforms whose
168
+ * inode/device values exceed JavaScript's safe integer range. */
169
+ dev: string;
170
+ ino: string;
171
+ birthtimeMs: number;
172
+ kind: "directory" | "symlink" | "other";
173
+ /** Exact link text, present only for symlinks. */
174
+ symlinkTarget?: string;
175
+ }
176
+
177
+ export interface FileAttribution {
178
+ /** Absolute path spelled by the tool call. This remains associated with the
179
+ * physical snapshot so a later symlink cannot change what the call targeted. */
180
+ lexicalPath: string;
181
+ /** Physical target captured synchronously at tool_execution_start. Consumers
182
+ * must not resolve this path again against the mutable post-execution tree. */
183
+ preExecutionPhysicalPath?: string;
184
+ /** Identity/signature chain used to obtain the physical snapshot. It covers
185
+ * every existing resolved component, including the leaf, plus the exact text
186
+ * of each followed symlink. In-place mutation preserves the leaf identity;
187
+ * replacement makes attribution uncertain. */
188
+ preExecutionPathSignatures?: FileAttributionPathSignature[];
189
+ /** Which explicit native tool supplied this evidence. */
190
+ provenance: "edit" | "write";
191
+ /** Canonicalization was incomplete, ambiguous, or its signature chain changed
192
+ * after execution. Such evidence is retained conservatively even when its
193
+ * lexical path is in a disposable workspace. */
194
+ uncertain: boolean;
195
+ }
196
+
159
197
  export interface ToolActivity {
160
198
  id: string;
161
199
  name: string;
162
200
  args: Record<string, unknown>;
201
+ /** Structured edit/write attribution captured before execution. */
202
+ fileAttribution?: FileAttribution;
163
203
  result?: {
164
204
  content: Array<{ type: string; text?: string }>;
165
205
  isError: boolean;
@@ -171,6 +211,8 @@ export interface ToolActivity {
171
211
  }
172
212
 
173
213
  /** Stable machine-readable reason for a task failure.
214
+ * - `cancelled`: the caller or async-ticket controller requested cancellation.
215
+ * This must not be inferred from provider error text such as "Aborted".
174
216
  * - `stalled`: inactivity watchdog fired; the prompt was cooperatively aborted.
175
217
  * - `model_error`: the failure is attributable to the resolved model/provider
176
218
  * (account usage limit, quota exhausted, auth lost) — not transient for that
@@ -179,7 +221,8 @@ export interface ToolActivity {
179
221
  * - `deadline_exceeded`: the task's `deadlineMs` wall-clock budget expired
180
222
  * (measured from when the task left the concurrency queue). The prompt was
181
223
  * cooperatively aborted; completed side effects are not rolled back. */
182
- export type TaskFailureKind = "stalled" | "model_error" | "deadline_exceeded";
224
+ export type TaskFailureKind =
225
+ "cancelled" | "stalled" | "model_error" | "deadline_exceeded";
183
226
 
184
227
  export interface TaskProgress {
185
228
  id?: string;
@@ -192,6 +235,8 @@ export interface TaskProgress {
192
235
  toolUses: number;
193
236
  error?: string;
194
237
  failureKind?: TaskFailureKind;
238
+ /** Terminal lower-bound result returned after quiescence abandonment. */
239
+ incomplete?: "quiescence_abandoned";
195
240
  model?: string;
196
241
  lastActivityAt?: number;
197
242
  activities: ToolActivity[];
@@ -208,6 +253,10 @@ export interface DelegateDetails {
208
253
  ticketId?: string;
209
254
  /** Terminal/live ticket status when this result comes from an async ticket. */
210
255
  status?: AsyncTicket["status"];
256
+ /** Actual batch wall time for stable rendering outside the live tool context. */
257
+ elapsedMs?: number;
258
+ /** Async result arrived on a different session-tree leaf than it was spawned on. */
259
+ crossLeafDelivery?: boolean;
211
260
  /** Global overlap warning derived from result.attributedFiles, surfaced in both
212
261
  * the textual content and the custom TUI. */
213
262
  overlapWarning?: string;
@@ -222,19 +271,24 @@ export interface TaskResult {
222
271
  error?: string;
223
272
  /** Stable machine-readable failure reason; error remains human-facing. */
224
273
  failureKind?: TaskFailureKind;
274
+ /** The task returned while its quarantined AgentSession could still run.
275
+ * Output, file evidence, token usage, and cost are lower bounds rather than
276
+ * final accounting. */
277
+ incomplete?: "quiescence_abandoned";
225
278
  durationMs: number;
226
279
  /** Display token count for the task, derived from the compaction-inclusive
227
280
  * session-stat delta. This matches `usage.totalTokens`; the usage object
228
281
  * additionally preserves the provider breakdown and cost. */
229
282
  tokens: number;
230
- /** Full provider Usage consumed by this task, including compacted-away
231
- * history. Always present (`emptyUsage()` on no-op/early-failure paths) so a
232
- * sync delegate call can fold subagent spend into the parent's session
233
- * total. Aggregate `cost.total` is accurate; the per-component cost fields
234
- * stay 0 because `getSessionStats()` exposes only the aggregate cost — and
235
- * Pi sums `cost.total` for nested usage anyway. */
283
+ /** Full provider Usage observed for this task, including compacted-away
284
+ * history. Always present (`emptyUsage()` on no-op/early-failure paths).
285
+ * When `incomplete` is absent, sync delegate can fold it into the parent and
286
+ * aggregate `cost.total` is final. For an abandoned task it is only a lower
287
+ * bound and dispatch omits top-level nested usage. Per-component cost fields
288
+ * stay 0 because `getSessionStats()` exposes only aggregate cost. */
236
289
  usage: Usage;
237
- /** Scratch results are excluded from shared-file conflict detection and never resumable. */
290
+ /** Scratch is never resumable. Its certain internal writes are excluded from
291
+ * shared-file conflict detection; external or uncertain evidence is retained. */
238
292
  workspace?: WorkspaceMode;
239
293
  sessionFile?: string;
240
294
  /** All files the subagent is known to have touched, including bash mutations
@@ -245,6 +299,9 @@ export interface TaskResult {
245
299
  * for overlap detection so concurrent tasks in the same repo do not
246
300
  * fabricate false conflicts from shared git snapshots. */
247
301
  attributedFiles?: string[];
302
+ /** Provenance-bearing evidence behind attributedFiles. Optional for legacy
303
+ * callers/tests that only provide the projected string list. */
304
+ fileAttributions?: FileAttribution[];
248
305
  /** Git-native proposal/reconciliation outcome for workspace:"isolated". */
249
306
  integration?: TaskIntegration;
250
307
  }
@@ -259,6 +316,15 @@ export type TaskIntegrationStatus =
259
316
  interface TaskIntegrationFiles {
260
317
  proposedFiles: string[];
261
318
  appliedFiles: string[];
319
+ /** Cleanup is separate from the integration outcome: a proposal can remain
320
+ * successfully applied even when a disposable worktree cannot be removed. */
321
+ cleanupIssue?: {
322
+ status: "deferred" | "failed";
323
+ reason: string;
324
+ /** Stable recovery marker. It may disappear after a deferred cleanup
325
+ * succeeds, but is retained when cleanup fails. */
326
+ recoveryPath?: string;
327
+ };
262
328
  }
263
329
 
264
330
  interface TaskIntegrationWithoutRecovery extends TaskIntegrationFiles {
@@ -278,6 +344,9 @@ export type TaskIntegration =
278
344
  })
279
345
  | (TaskIntegrationWithoutRecovery & {
280
346
  status: "discarded";
347
+ /** Reporting-only issues encountered while classifying a failed task's
348
+ * paths. They do not turn the discarded proposal into an apply failure. */
349
+ classificationIssues?: Array<{ path: string; reason: string }>;
281
350
  })
282
351
  | (TaskIntegrationFiles & {
283
352
  status: "conflict";
package/utils.ts CHANGED
@@ -58,8 +58,129 @@ export function extractTextFromPartialResult(
58
58
 
59
59
  /** Strip ANSI escape sequences from text. */
60
60
  export function stripAnsi(text: string): string {
61
- // eslint-disable-next-line no-control-regex
62
- return text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
61
+ // A scanner keeps malformed, unterminated control strings linear-time.
62
+ // Regexes with lazy "anything until ST" branches become quadratic on input
63
+ // containing many unterminated OSC/DCS introducers.
64
+ let clean = "";
65
+ for (let index = 0; index < text.length;) {
66
+ const code = text.charCodeAt(index);
67
+
68
+ if (code === 0x1b) {
69
+ const next = text.charCodeAt(index + 1);
70
+ if (next === 0x5d) {
71
+ index = skipControlString(text, index + 2, true);
72
+ continue;
73
+ }
74
+ if (next === 0x50 || next === 0x58 || next === 0x5e || next === 0x5f) {
75
+ index = skipControlString(text, index + 2, false);
76
+ continue;
77
+ }
78
+ if (next === 0x5b) {
79
+ index = skipCsi(text, index + 2);
80
+ continue;
81
+ }
82
+ if (next === 0x5c) {
83
+ // Preserve a boundary for a standalone 7-bit ST just as for C1 ST,
84
+ // so removing it cannot concatenate attacker-controlled words.
85
+ clean += " ";
86
+ index += 2;
87
+ continue;
88
+ }
89
+
90
+ // Generic ESC sequence: intermediates followed by one final byte.
91
+ index++;
92
+ while (index < text.length) {
93
+ const value = text.charCodeAt(index);
94
+ if (value < 0x20 || value > 0x2f) break;
95
+ index++;
96
+ }
97
+ if (index < text.length) {
98
+ const final = text.charCodeAt(index);
99
+ if (final >= 0x30 && final <= 0x7e) index++;
100
+ }
101
+ continue;
102
+ }
103
+
104
+ if (code === 0x9d) {
105
+ index = skipControlString(text, index + 1, true);
106
+ continue;
107
+ }
108
+ if (code === 0x90 || code === 0x98 || code === 0x9e || code === 0x9f) {
109
+ index = skipControlString(text, index + 1, false);
110
+ continue;
111
+ }
112
+ if (code === 0x9b) {
113
+ index = skipCsi(text, index + 1);
114
+ continue;
115
+ }
116
+ if (code === 0x9c) {
117
+ // Preserve a boundary for a stray terminator so sanitization cannot
118
+ // concatenate attacker-controlled words around the removed control.
119
+ clean += " ";
120
+ index++;
121
+ continue;
122
+ }
123
+
124
+ clean += text[index]!;
125
+ index++;
126
+ }
127
+ return clean;
128
+ }
129
+
130
+ function skipControlString(
131
+ text: string,
132
+ index: number,
133
+ bellTerminates: boolean,
134
+ ): number {
135
+ while (index < text.length) {
136
+ const code = text.charCodeAt(index);
137
+ if ((bellTerminates && code === 0x07) || code === 0x9c) return index + 1;
138
+ if (
139
+ code === 0x1b &&
140
+ index + 1 < text.length &&
141
+ text.charCodeAt(index + 1) === 0x5c
142
+ ) {
143
+ return index + 2;
144
+ }
145
+ index++;
146
+ }
147
+ return index;
148
+ }
149
+
150
+ function skipCsi(text: string, index: number): number {
151
+ while (index < text.length) {
152
+ const code = text.charCodeAt(index);
153
+ // A malformed CSI must not consume diagnostic layout while searching for
154
+ // a final byte. Leave common layout controls for the outer sanitizer.
155
+ if (code === 0x09 || code === 0x0a || code === 0x0d) return index;
156
+ index++;
157
+ if (code >= 0x40 && code <= 0x7e) break;
158
+ }
159
+ return index;
160
+ }
161
+
162
+ /**
163
+ * Remove terminal controls from untrusted multiline text while preserving its
164
+ * line and ordinary whitespace structure for markdown/plain-text rendering.
165
+ */
166
+ export function sanitizeTerminalText(text: string): string {
167
+ return (
168
+ stripAnsi(text)
169
+ .replace(/\r\n?|\u2028|\u2029/g, "\n")
170
+ .replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]+/g, " ")
171
+ // Invisible bidi marks, embeddings/overrides, and isolates can reorder
172
+ // attacker-controlled terminal text without changing its stored spelling.
173
+ .replace(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "")
174
+ );
175
+ }
176
+
177
+ /**
178
+ * Flatten untrusted text for one terminal row. ANSI sequences and terminal
179
+ * controls are removed before whitespace is normalized, so callers can safely
180
+ * truncate and store the result without preserving a partial escape sequence.
181
+ */
182
+ export function sanitizeTerminalLine(text: string): string {
183
+ return sanitizeTerminalText(text).replace(/\s+/g, " ").trim();
63
184
  }
64
185
 
65
186
  /** Resolve carriage-return progress bars to their final line state. */
package/workspace.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
+ import { revalidateFileAttribution } from "./file-tracking.ts";
4
5
  import { scheduleDeadline } from "./timer.ts";
6
+ import type { FileAttribution } from "./types.ts";
5
7
 
6
8
  const SCRATCH_CONTAINER_NAME = ".pi-delegate-scratch";
7
9
  const SCRATCH_LEASE_PREFIX = "lease-";
@@ -29,6 +31,17 @@ export interface ScratchWorkspace {
29
31
  * compare with the host path they actually touched.
30
32
  */
31
33
  resolveAttributedPath(candidate: string): Promise<string | undefined>;
34
+ /** Project structured attribution without re-resolving its execution-time
35
+ * physical snapshot. Uncertain internal evidence is retained. */
36
+ resolveFileAttribution?(
37
+ attribution: FileAttribution,
38
+ ): Promise<FileAttribution | undefined>;
39
+ /** Project the lexical touched-path half of structured attribution. A
40
+ * certainly attributed copied symlink is suppressed only when both link
41
+ * identities remain stable while their matching targets are read. */
42
+ resolveAttributedLexicalTouch?(
43
+ attribution: FileAttribution,
44
+ ): Promise<string | undefined>;
32
45
  /** True when the existing path resolves inside the disposable tree. */
33
46
  isDisposablePath(candidate: string): Promise<boolean>;
34
47
  cleanup(): Promise<void>;
@@ -186,6 +199,30 @@ function isWithin(root: string, candidate: string): boolean {
186
199
  );
187
200
  }
188
201
 
202
+ function errnoOf(error: unknown): string {
203
+ return error instanceof Error && "code" in error
204
+ ? String((error as NodeJS.ErrnoException).code ?? "UNKNOWN")
205
+ : "UNKNOWN";
206
+ }
207
+
208
+ function logScratchProjectionFailure(
209
+ operation: "lstat" | "readlink",
210
+ candidate: string,
211
+ error: unknown,
212
+ ): void {
213
+ const safeCandidate = JSON.stringify(candidate)
214
+ .replace(/\u2028/g, "\\u2028")
215
+ .replace(/\u2029/g, "\\u2029");
216
+ console.error(
217
+ `[delegate] scratch attribution ${operation} failed for ${safeCandidate} (errno=${errnoOf(error)}); retaining lexical source evidence`,
218
+ );
219
+ }
220
+
221
+ function isExpectedPathRace(error: unknown): boolean {
222
+ const code = errnoOf(error);
223
+ return code === "ENOENT" || code === "ENOTDIR" || code === "EINVAL";
224
+ }
225
+
189
226
  function isProcessAlive(pid: number): boolean {
190
227
  try {
191
228
  process.kill(pid, 0);
@@ -794,6 +831,100 @@ export async function createScratchWorkspace(
794
831
  return isWithin(completedRoot, absolute) ? undefined : absolute;
795
832
  }
796
833
  };
834
+ const mapDisposableLexically = (candidate: string): string => {
835
+ const absolute = path.resolve(candidate);
836
+ return isWithin(completedRoot, absolute)
837
+ ? path.join(sourceRoot!, path.relative(completedRoot, absolute))
838
+ : absolute;
839
+ };
840
+ const resolveFileAttribution = async (
841
+ original: FileAttribution,
842
+ ): Promise<FileAttribution | undefined> => {
843
+ const attribution = revalidateFileAttribution(original);
844
+ const physical = attribution.preExecutionPhysicalPath;
845
+ // A certain physical snapshot inside scratch is disposable. Crucially, do
846
+ // not realpath it now: the tool may have replaced that node with a symlink.
847
+ if (
848
+ physical &&
849
+ !attribution.uncertain &&
850
+ isWithin(completedRoot, physical)
851
+ ) {
852
+ return undefined;
853
+ }
854
+ return {
855
+ ...attribution,
856
+ lexicalPath: mapDisposableLexically(attribution.lexicalPath),
857
+ preExecutionPhysicalPath: physical
858
+ ? mapDisposableLexically(physical)
859
+ : undefined,
860
+ };
861
+ };
862
+ const resolveAttributedLexicalTouch = async (
863
+ original: FileAttribution,
864
+ ): Promise<string | undefined> => {
865
+ const attribution = revalidateFileAttribution(original);
866
+ const lexical = path.resolve(attribution.lexicalPath);
867
+ if (!isWithin(completedRoot, lexical)) return lexical;
868
+ const source = path.join(
869
+ sourceRoot!,
870
+ path.relative(completedRoot, lexical),
871
+ );
872
+ // Suppression is an optimization for a copied, unchanged symlink node. An
873
+ // uncertain attribution cannot prove that the lexical node was harmless.
874
+ if (attribution.uncertain) return source;
875
+
876
+ const inspect = async (
877
+ candidate: string,
878
+ ): Promise<fs.Stats | undefined> => {
879
+ try {
880
+ return await fs.promises.lstat(candidate);
881
+ } catch (error) {
882
+ if (!isExpectedPathRace(error)) {
883
+ logScratchProjectionFailure("lstat", candidate, error);
884
+ }
885
+ return undefined;
886
+ }
887
+ };
888
+ const [scratchStat, sourceStat] = await Promise.all([
889
+ inspect(lexical),
890
+ inspect(source),
891
+ ]);
892
+ if (scratchStat?.isSymbolicLink() && sourceStat?.isSymbolicLink()) {
893
+ const readStableLink = async (
894
+ candidate: string,
895
+ before: fs.Stats,
896
+ ): Promise<string | undefined> => {
897
+ let target: string;
898
+ try {
899
+ target = await fs.promises.readlink(candidate);
900
+ } catch (error) {
901
+ if (!isExpectedPathRace(error)) {
902
+ logScratchProjectionFailure("readlink", candidate, error);
903
+ }
904
+ return undefined;
905
+ }
906
+ const after = await inspect(candidate);
907
+ return after?.isSymbolicLink() && sameFileIdentity(before, after)
908
+ ? target
909
+ : undefined;
910
+ };
911
+ const [scratchTarget, sourceTarget] = await Promise.all([
912
+ readStableLink(lexical, scratchStat),
913
+ readStableLink(source, sourceStat),
914
+ ]);
915
+ // A readlink or identity race is not evidence that the nodes matched.
916
+ // Keep the lexical source path rather than dropping it.
917
+ if (
918
+ scratchTarget !== undefined &&
919
+ sourceTarget !== undefined &&
920
+ scratchTarget === sourceTarget
921
+ ) {
922
+ return undefined;
923
+ }
924
+ }
925
+ // This is evidence about the lexical node, not its current target.
926
+ return source;
927
+ };
797
928
  return {
798
929
  sourceRoot: sourceRoot!,
799
930
  sourceCwd: sourceCwd!,
@@ -806,6 +937,8 @@ export async function createScratchWorkspace(
806
937
  },
807
938
  resolveReportedPath,
808
939
  resolveAttributedPath,
940
+ resolveFileAttribution,
941
+ resolveAttributedLexicalTouch,
809
942
  async isDisposablePath(candidate: string): Promise<boolean> {
810
943
  return (await resolveAttributedPath(candidate)) === undefined;
811
944
  },