@bermudi/pi-delegate 0.1.3 → 0.1.4
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/README.md +27 -1
- package/config.ts +1 -1
- package/delegate.ts +3 -0
- package/format.ts +5 -1
- package/host-compat.ts +1 -0
- package/lifecycle.ts +190 -12
- package/package.json +1 -1
- package/schema.ts +15 -1
- package/task-resolution.ts +6 -0
- package/telemetry.ts +8 -3
- package/types.ts +5 -0
- package/workspace.ts +672 -0
package/README.md
CHANGED
|
@@ -38,6 +38,31 @@ Parent extension/MCP tools are not copied, and project instructions are rebuilt
|
|
|
38
38
|
for the task's `cwd`. Omit `agent` when you want an ad-hoc task using delegate's
|
|
39
39
|
normal inline defaults instead.
|
|
40
40
|
|
|
41
|
+
### Disposable scratch workspace
|
|
42
|
+
|
|
43
|
+
For review, tests, or other commands whose project changes should be thrown
|
|
44
|
+
away, run a one-shot task in a CoW copy:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
delegate({
|
|
48
|
+
tasks: [
|
|
49
|
+
{ prompt: "Review this change and run its tests", workspace: "scratch" },
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Delegate reflink-copies the containing Git repository beside the original, runs
|
|
55
|
+
the subagent in the corresponding copied directory, then deletes the copy. It
|
|
56
|
+
requires Linux with `/proc/self/fd`, GNU `cp`, and a reflink-capable filesystem
|
|
57
|
+
such as Btrfs; it never falls back to an expensive full copy. Scratch mode
|
|
58
|
+
cannot use `sessionId`,
|
|
59
|
+
`resumeFrom`, session actions, linked Git worktrees, or project symlinks that
|
|
60
|
+
point outside the copied tree.
|
|
61
|
+
|
|
62
|
+
This protects the real project from ordinary relative writes. It is not a
|
|
63
|
+
security sandbox: unrestricted commands and absolute paths can still reach the
|
|
64
|
+
host filesystem.
|
|
65
|
+
|
|
41
66
|
### Token accounting
|
|
42
67
|
|
|
43
68
|
Sync delegate calls report aggregate subagent `Usage` on the tool result, so Pi
|
|
@@ -98,7 +123,8 @@ over an installed extension.
|
|
|
98
123
|
|
|
99
124
|
- **Delegate task** — One item in `delegate({ tasks: [...] })`. This is the
|
|
100
125
|
core unit of work: a prompt plus optional overrides such as `agent`, `tools`,
|
|
101
|
-
`systemPrompt`, `thinking`, `cwd`, `context`, `sessionId`, or
|
|
126
|
+
`systemPrompt`, `thinking`, `cwd`, `context`, `workspace`, `sessionId`, or
|
|
127
|
+
`resumeFrom`.
|
|
102
128
|
`model` is also accepted but should be rare — subagents inherit the parent
|
|
103
129
|
model by default.
|
|
104
130
|
- **Default subagent** — The reserved built-in `agent: "default"` profile. It
|
package/config.ts
CHANGED
|
@@ -131,7 +131,7 @@ function normalizeProviderExtensions(
|
|
|
131
131
|
// `providerExtensions` replaces a provider's entries (it does not append); an
|
|
132
132
|
// empty array is ignored so the default persists.
|
|
133
133
|
const DEFAULT_PROVIDER_EXTENSIONS: Record<string, readonly string[]> = {
|
|
134
|
-
"openai-codex": ["
|
|
134
|
+
"openai-codex": ["git:github.com/bermudi/manaflow-pi-codex"],
|
|
135
135
|
};
|
|
136
136
|
|
|
137
137
|
function resolveProviderExtensions(
|
package/delegate.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { default } from "./extension.ts";
|
|
|
3
3
|
export type {
|
|
4
4
|
AgentConfig,
|
|
5
5
|
SessionAction,
|
|
6
|
+
WorkspaceMode,
|
|
6
7
|
TicketAction,
|
|
7
8
|
DelegateAction,
|
|
8
9
|
DelegateArguments,
|
|
@@ -77,6 +78,8 @@ export {
|
|
|
77
78
|
isCrossLeafTicket,
|
|
78
79
|
} from "./leaf.ts";
|
|
79
80
|
export { runAgentSession } from "./runner.ts";
|
|
81
|
+
export { createScratchWorkspace } from "./workspace.ts";
|
|
82
|
+
export type { ScratchWorkspace } from "./workspace.ts";
|
|
80
83
|
export {
|
|
81
84
|
activeTicketSummary,
|
|
82
85
|
buildStatusText,
|
package/format.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
TaskProgress,
|
|
8
8
|
TaskResult,
|
|
9
9
|
ToolActivity,
|
|
10
|
+
WorkspaceMode,
|
|
10
11
|
} from "./types.ts";
|
|
11
12
|
|
|
12
13
|
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
@@ -517,7 +518,10 @@ export function relativeTouchedSummary(
|
|
|
517
518
|
* in the same repository do not fabricate false conflicts from shared
|
|
518
519
|
* repository-wide git snapshots. */
|
|
519
520
|
export function findTouchedOverlaps(
|
|
520
|
-
results: readonly {
|
|
521
|
+
results: readonly {
|
|
522
|
+
attributedFiles?: string[];
|
|
523
|
+
workspace?: WorkspaceMode;
|
|
524
|
+
}[],
|
|
521
525
|
): string[] {
|
|
522
526
|
const counts = new Map<string, number>();
|
|
523
527
|
for (const r of results) {
|
package/host-compat.ts
CHANGED
|
@@ -23,6 +23,7 @@ const REQUIRED_EXPORTS: ExportCheck[] = [
|
|
|
23
23
|
{ name: "SettingsManager", requiredMember: "create" },
|
|
24
24
|
{ name: "SessionManager", requiredMember: "create" },
|
|
25
25
|
{ name: "SessionManager", requiredMember: "open" },
|
|
26
|
+
{ name: "SessionManager", requiredMember: "inMemory" },
|
|
26
27
|
{ name: "DefaultResourceLoader" },
|
|
27
28
|
{ name: "DefaultPackageManager" },
|
|
28
29
|
{ name: "createAgentSession" },
|
package/lifecycle.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { getWholeTaskMaxRetries, getWholeTaskBaseDelayMs } from "./config.ts";
|
|
|
28
28
|
import { addUsage, emptyUsage } from "./usage.ts";
|
|
29
29
|
import { scheduleDeadline } from "./timer.ts";
|
|
30
30
|
import { recordTask } from "./telemetry.ts";
|
|
31
|
+
import { createScratchWorkspace, ScratchDeadlineError } from "./workspace.ts";
|
|
31
32
|
|
|
32
33
|
/** Internal seam for lifecycle-level tests without replacing session ownership. */
|
|
33
34
|
type RunAgentSession = typeof runAgentSession;
|
|
@@ -181,6 +182,11 @@ function updateProgressFromResult(p: TaskProgress, r: TaskResult): void {
|
|
|
181
182
|
p.failureKind = r.failureKind;
|
|
182
183
|
}
|
|
183
184
|
|
|
185
|
+
const taskRetryCounts = new WeakMap<TaskResult, number>();
|
|
186
|
+
/** The SQLite task id is part of the logical task, not the transient result
|
|
187
|
+
* object. Scratch wrapping clones results, so keep this alongside retry data. */
|
|
188
|
+
const taskTelemetryIds = new WeakMap<TaskResult, string>();
|
|
189
|
+
|
|
184
190
|
/** Apply a TaskResult to progress and notify the env (sync fires onUpdate).
|
|
185
191
|
* Used at every return point in runResolvedTask — mirrors the old fire() pattern
|
|
186
192
|
* that the duplicated sync/async bodies used after every early-return. */
|
|
@@ -192,8 +198,9 @@ function finishTask(
|
|
|
192
198
|
retries = 0,
|
|
193
199
|
): TaskResult {
|
|
194
200
|
updateProgressFromResult(p, r);
|
|
201
|
+
taskRetryCounts.set(r, retries);
|
|
195
202
|
if (env.telemetryCallId) {
|
|
196
|
-
recordTask({
|
|
203
|
+
const id = recordTask({
|
|
197
204
|
callId: env.telemetryCallId,
|
|
198
205
|
generation: env.telemetryGeneration,
|
|
199
206
|
async: env.async ?? false,
|
|
@@ -203,6 +210,7 @@ function finishTask(
|
|
|
203
210
|
result: r,
|
|
204
211
|
retries,
|
|
205
212
|
});
|
|
213
|
+
if (id) taskTelemetryIds.set(r, id);
|
|
206
214
|
}
|
|
207
215
|
env.onStatusChange?.();
|
|
208
216
|
return r;
|
|
@@ -494,15 +502,24 @@ async function acquireAgentSession(
|
|
|
494
502
|
}
|
|
495
503
|
|
|
496
504
|
// ── Fresh session (no resume) ────────────────────────────────────────────
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
505
|
+
if (task.workspace === "scratch") {
|
|
506
|
+
// A discarded filesystem must not advertise a resumable conversation: a
|
|
507
|
+
// later resume would run against the source cwd and silently lose scratch
|
|
508
|
+
// isolation. Keep scratch transcripts in memory only.
|
|
509
|
+
sessionManager = SessionManager.inMemory(task.cwd);
|
|
510
|
+
} else {
|
|
511
|
+
const fresh = createSubagentSessionManager(
|
|
512
|
+
env.parentSessionManager,
|
|
513
|
+
task.cwd,
|
|
514
|
+
);
|
|
515
|
+
if (!fresh) {
|
|
516
|
+
return {
|
|
517
|
+
error: failTask(task, "Internal: could not create session file"),
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
sessionManager = fresh.manager;
|
|
521
|
+
sessionFile = fresh.file;
|
|
503
522
|
}
|
|
504
|
-
sessionManager = fresh.manager;
|
|
505
|
-
sessionFile = fresh.file;
|
|
506
523
|
|
|
507
524
|
const session = await buildDelegateSession(
|
|
508
525
|
task,
|
|
@@ -564,6 +581,166 @@ async function runResolvedTaskUnlocked(
|
|
|
564
581
|
task: ResolvedTask,
|
|
565
582
|
p: TaskProgress,
|
|
566
583
|
taskIndex: number,
|
|
584
|
+
): Promise<TaskResult> {
|
|
585
|
+
if (task.workspace !== "scratch") {
|
|
586
|
+
return runResolvedTaskCore(env, task, p, taskIndex);
|
|
587
|
+
}
|
|
588
|
+
if (task.sessionId || task.resumeFrom || task.sessionAction) {
|
|
589
|
+
return finishTask(
|
|
590
|
+
env,
|
|
591
|
+
p,
|
|
592
|
+
failTask(
|
|
593
|
+
task,
|
|
594
|
+
"workspace 'scratch' is one-shot and cannot be combined with sessionId, resumeFrom, or sessionAction.",
|
|
595
|
+
),
|
|
596
|
+
task,
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const startedAt = Date.now();
|
|
601
|
+
const deadlineAt =
|
|
602
|
+
task.deadlineMs && task.deadlineMs > 0
|
|
603
|
+
? startedAt + task.deadlineMs
|
|
604
|
+
: undefined;
|
|
605
|
+
let workspace: Awaited<ReturnType<typeof createScratchWorkspace>>;
|
|
606
|
+
try {
|
|
607
|
+
workspace = await createScratchWorkspace(task.cwd, env.signal, deadlineAt);
|
|
608
|
+
} catch (error) {
|
|
609
|
+
const setupError = error instanceof Error ? error.message : String(error);
|
|
610
|
+
const deadlineExceeded =
|
|
611
|
+
!env.signal?.aborted && error instanceof ScratchDeadlineError;
|
|
612
|
+
return finishTask(
|
|
613
|
+
env,
|
|
614
|
+
p,
|
|
615
|
+
{
|
|
616
|
+
...failTask(
|
|
617
|
+
task,
|
|
618
|
+
env.signal?.aborted
|
|
619
|
+
? "Aborted"
|
|
620
|
+
: deadlineExceeded
|
|
621
|
+
? formatDeadlineExceededError(task.deadlineMs ?? 0)
|
|
622
|
+
: setupError,
|
|
623
|
+
),
|
|
624
|
+
failureKind: deadlineExceeded ? "deadline_exceeded" : undefined,
|
|
625
|
+
durationMs: Date.now() - startedAt,
|
|
626
|
+
},
|
|
627
|
+
task,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const executionTask: ResolvedTask = {
|
|
632
|
+
...task,
|
|
633
|
+
cwd: workspace.cwd,
|
|
634
|
+
};
|
|
635
|
+
let result: TaskResult | undefined;
|
|
636
|
+
let cleanupError: string | undefined;
|
|
637
|
+
let needsCorrection = false;
|
|
638
|
+
let telemetryTaskId: string | undefined;
|
|
639
|
+
let retries = 0;
|
|
640
|
+
try {
|
|
641
|
+
const preMappingResult = await runResolvedTaskCore(
|
|
642
|
+
env,
|
|
643
|
+
executionTask,
|
|
644
|
+
p,
|
|
645
|
+
taskIndex,
|
|
646
|
+
{
|
|
647
|
+
taskStartedAt: startedAt,
|
|
648
|
+
deadlineAt,
|
|
649
|
+
},
|
|
650
|
+
);
|
|
651
|
+
result = preMappingResult;
|
|
652
|
+
// Capture metadata before scratch wrapping creates a new result object.
|
|
653
|
+
// Both values belong to the logical task and must survive that clone.
|
|
654
|
+
telemetryTaskId = taskTelemetryIds.get(result);
|
|
655
|
+
retries = taskRetryCounts.get(result) ?? 0;
|
|
656
|
+
result = {
|
|
657
|
+
...result,
|
|
658
|
+
workspace: "scratch",
|
|
659
|
+
sessionFile: undefined,
|
|
660
|
+
touchedFiles: await Promise.all(
|
|
661
|
+
result.touchedFiles.map((file) => workspace.resolveReportedPath(file)),
|
|
662
|
+
),
|
|
663
|
+
// Writes inside scratch are discarded and cannot conflict. Explicit
|
|
664
|
+
// writes outside scratch (for example an absolute host path) persist and
|
|
665
|
+
// must remain attributable for overlap warnings. Resolve those paths
|
|
666
|
+
// physically so aliases to the same host file compare equally.
|
|
667
|
+
attributedFiles: (
|
|
668
|
+
await Promise.all(
|
|
669
|
+
(result.attributedFiles ?? []).map((file) =>
|
|
670
|
+
workspace.resolveAttributedPath(file),
|
|
671
|
+
),
|
|
672
|
+
)
|
|
673
|
+
).filter((file): file is string => file !== undefined),
|
|
674
|
+
};
|
|
675
|
+
} catch (error) {
|
|
676
|
+
result = {
|
|
677
|
+
...failTask(task, error instanceof Error ? error.message : String(error)),
|
|
678
|
+
...(result
|
|
679
|
+
? {
|
|
680
|
+
tokens: result.tokens,
|
|
681
|
+
usage: result.usage,
|
|
682
|
+
}
|
|
683
|
+
: {}),
|
|
684
|
+
workspace: "scratch",
|
|
685
|
+
durationMs: Date.now() - startedAt,
|
|
686
|
+
};
|
|
687
|
+
// runResolvedTaskCore may already have recorded/notified a successful
|
|
688
|
+
// result before path mapping failed. Correct those observable outcomes.
|
|
689
|
+
needsCorrection = true;
|
|
690
|
+
} finally {
|
|
691
|
+
try {
|
|
692
|
+
await workspace.cleanup();
|
|
693
|
+
} catch (error) {
|
|
694
|
+
cleanupError = `Scratch workspace cleanup failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
695
|
+
console.error("[delegate] scratch workspace cleanup failed", error);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (!result) {
|
|
700
|
+
result = {
|
|
701
|
+
...failTask(task, "Scratch task failed before producing a result."),
|
|
702
|
+
workspace: "scratch",
|
|
703
|
+
durationMs: Date.now() - startedAt,
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
if (cleanupError || needsCorrection) {
|
|
707
|
+
result = {
|
|
708
|
+
...result,
|
|
709
|
+
error: cleanupError
|
|
710
|
+
? result.error
|
|
711
|
+
? `${result.error}\n${cleanupError}`
|
|
712
|
+
: cleanupError
|
|
713
|
+
: result.error,
|
|
714
|
+
durationMs: Math.max(result.durationMs, Date.now() - startedAt),
|
|
715
|
+
};
|
|
716
|
+
updateProgressFromResult(p, result);
|
|
717
|
+
if (env.telemetryCallId) {
|
|
718
|
+
// runResolvedTaskCore already recorded the pre-cleanup result. Telemetry
|
|
719
|
+
// rows are upserts, so replace it with the actual returned outcome while
|
|
720
|
+
// preserving the retry count captured by finishTask.
|
|
721
|
+
recordTask({
|
|
722
|
+
id: telemetryTaskId,
|
|
723
|
+
callId: env.telemetryCallId,
|
|
724
|
+
generation: env.telemetryGeneration,
|
|
725
|
+
async: env.async ?? false,
|
|
726
|
+
taskIndex: p.index,
|
|
727
|
+
task,
|
|
728
|
+
progress: p,
|
|
729
|
+
result,
|
|
730
|
+
retries,
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
env.onStatusChange?.();
|
|
734
|
+
}
|
|
735
|
+
return result;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
async function runResolvedTaskCore(
|
|
739
|
+
env: TaskRunEnv,
|
|
740
|
+
task: ResolvedTask,
|
|
741
|
+
p: TaskProgress,
|
|
742
|
+
taskIndex: number,
|
|
743
|
+
timing?: { taskStartedAt: number; deadlineAt: number | undefined },
|
|
567
744
|
): Promise<TaskResult> {
|
|
568
745
|
try {
|
|
569
746
|
// ── Aborted before we started? ───────────────────────────────────
|
|
@@ -629,11 +806,12 @@ async function runResolvedTaskUnlocked(
|
|
|
629
806
|
let hasBashExecution = false;
|
|
630
807
|
let cumulativeTokens = 0;
|
|
631
808
|
let cumulativeToolUses = 0;
|
|
632
|
-
const taskStartedAt = Date.now();
|
|
809
|
+
const taskStartedAt = timing?.taskStartedAt ?? Date.now();
|
|
633
810
|
const deadlineAt =
|
|
634
|
-
|
|
811
|
+
timing?.deadlineAt ??
|
|
812
|
+
(task.deadlineMs && task.deadlineMs > 0
|
|
635
813
|
? taskStartedAt + task.deadlineMs
|
|
636
|
-
: undefined;
|
|
814
|
+
: undefined);
|
|
637
815
|
let accumulatedUsage = emptyUsage();
|
|
638
816
|
|
|
639
817
|
const onAttemptProgress = (u: AgentProgressUpdate): void => {
|
package/package.json
CHANGED
package/schema.ts
CHANGED
|
@@ -103,6 +103,13 @@ export const delegateTaskSchema = Type.Object({
|
|
|
103
103
|
"Wall-clock budget (ms) from run start after queueing. Cooperative abort; side effects remain. Omit disables.",
|
|
104
104
|
}),
|
|
105
105
|
),
|
|
106
|
+
workspace: Type.Optional(
|
|
107
|
+
StringEnum(["shared", "scratch"], {
|
|
108
|
+
description:
|
|
109
|
+
"scratch=disposable CoW project copy; relative edits are discarded. Not security isolation. Default=shared.",
|
|
110
|
+
default: "shared",
|
|
111
|
+
}),
|
|
112
|
+
),
|
|
106
113
|
});
|
|
107
114
|
|
|
108
115
|
// Single source of truth for registration and generated help. The exported
|
|
@@ -146,7 +153,7 @@ export const delegateArgumentsSchema = Type.Object({
|
|
|
146
153
|
Type.Array(delegateTaskSchema, {
|
|
147
154
|
minItems: 0,
|
|
148
155
|
description:
|
|
149
|
-
"Tasks
|
|
156
|
+
"Tasks run concurrently; shared workspaces share files. scratch uses a disposable CoW copy. []=full manual.",
|
|
150
157
|
}),
|
|
151
158
|
),
|
|
152
159
|
});
|
|
@@ -167,6 +174,7 @@ const TASK_FIELD_NAMES = [
|
|
|
167
174
|
"sessionAction",
|
|
168
175
|
"resumeFrom",
|
|
169
176
|
"deadlineMs",
|
|
177
|
+
"workspace",
|
|
170
178
|
] as const;
|
|
171
179
|
|
|
172
180
|
/** Every field a task entry may carry. Anything else is a model mistake —
|
|
@@ -317,6 +325,12 @@ export function validateDelegateOperation(
|
|
|
317
325
|
if (typeof rawTask.deadlineMs === "number" && !(rawTask.deadlineMs > 0)) {
|
|
318
326
|
return `task ${index + 1}: deadlineMs must be a positive number of milliseconds.`;
|
|
319
327
|
}
|
|
328
|
+
if (
|
|
329
|
+
task.workspace === "scratch" &&
|
|
330
|
+
(task.sessionId || task.resumeFrom || sessionAction !== undefined)
|
|
331
|
+
) {
|
|
332
|
+
return `task ${index + 1}: workspace 'scratch' is one-shot and cannot be combined with sessionId, resumeFrom, or sessionAction.`;
|
|
333
|
+
}
|
|
320
334
|
if (sessionAction === "close") {
|
|
321
335
|
if (!task.sessionId) {
|
|
322
336
|
return `task ${index + 1}: sessionAction 'close' requires sessionId.`;
|
package/task-resolution.ts
CHANGED
|
@@ -208,6 +208,11 @@ export function resolveTasks(
|
|
|
208
208
|
);
|
|
209
209
|
let tools: string[] = [];
|
|
210
210
|
const warnings: string[] = [];
|
|
211
|
+
if (t.workspace === "scratch") {
|
|
212
|
+
warnings.push(
|
|
213
|
+
"Scratch workspace: relative file changes run in a disposable CoW copy and are discarded.",
|
|
214
|
+
);
|
|
215
|
+
}
|
|
211
216
|
|
|
212
217
|
// Prompt is required for fresh tasks. ResumeFrom provides context already.
|
|
213
218
|
if (
|
|
@@ -418,6 +423,7 @@ export function resolveTasks(
|
|
|
418
423
|
...t,
|
|
419
424
|
id: t.id,
|
|
420
425
|
cwd,
|
|
426
|
+
workspace: t.workspace ?? "shared",
|
|
421
427
|
systemPrompt,
|
|
422
428
|
model: model!,
|
|
423
429
|
tools,
|
package/telemetry.ts
CHANGED
|
@@ -604,6 +604,8 @@ export function recordCall(record: CallRecord, generation?: number): void {
|
|
|
604
604
|
}
|
|
605
605
|
|
|
606
606
|
export interface TaskSpanInput {
|
|
607
|
+
/** Stable identity for correction writes of the same logical task row. */
|
|
608
|
+
id?: string;
|
|
607
609
|
callId: string;
|
|
608
610
|
/** Runtime generation captured by the dispatch that owns this task. */
|
|
609
611
|
generation?: number;
|
|
@@ -622,13 +624,15 @@ function outcomeFromResult(result: TaskResult): string {
|
|
|
622
624
|
return "success";
|
|
623
625
|
}
|
|
624
626
|
|
|
625
|
-
export function recordTask(input: TaskSpanInput):
|
|
627
|
+
export function recordTask(input: TaskSpanInput): string | undefined {
|
|
626
628
|
const b = getBackend(input.generation);
|
|
627
|
-
if (!b) return;
|
|
629
|
+
if (!b) return undefined;
|
|
628
630
|
|
|
629
631
|
const { callId, async, taskIndex, task, progress, result, retries } = input;
|
|
630
632
|
const record: TaskRecord = {
|
|
631
|
-
|
|
633
|
+
// Correction writes must reuse the provisional row's primary key. A fresh
|
|
634
|
+
// UUID here would make INSERT OR REPLACE append a second task row.
|
|
635
|
+
id: input.id ?? crypto.randomUUID(),
|
|
632
636
|
call_id: callId,
|
|
633
637
|
ts: Date.now(),
|
|
634
638
|
version: getDelegateVersion(),
|
|
@@ -651,6 +655,7 @@ export function recordTask(input: TaskSpanInput): void {
|
|
|
651
655
|
async: async ? 1 : 0,
|
|
652
656
|
};
|
|
653
657
|
b.recordTask(record);
|
|
658
|
+
return record.id;
|
|
654
659
|
}
|
|
655
660
|
|
|
656
661
|
/** Prevent this runtime's late workers from writing after a bounded shutdown
|
package/types.ts
CHANGED
|
@@ -43,6 +43,8 @@ export type TicketAction = NonNullable<
|
|
|
43
43
|
>;
|
|
44
44
|
/** Per-task session action: "prompt" | "close" | "list". */
|
|
45
45
|
export type SessionAction = NonNullable<CanonicalTaskDef["sessionAction"]>;
|
|
46
|
+
/** Filesystem mode: shared source tree or an ephemeral CoW scratch copy. */
|
|
47
|
+
export type WorkspaceMode = NonNullable<CanonicalTaskDef["workspace"]>;
|
|
46
48
|
|
|
47
49
|
export type TaskDef = CanonicalTaskDef & {
|
|
48
50
|
/** @deprecated Use `sessionAction` instead. Runtime normalization still accepts this alias. */
|
|
@@ -123,6 +125,7 @@ export interface ResolvedTask {
|
|
|
123
125
|
thinking: ThinkingLevel;
|
|
124
126
|
systemPrompt: string;
|
|
125
127
|
cwd: string;
|
|
128
|
+
workspace?: WorkspaceMode;
|
|
126
129
|
context?: "fresh" | "with-parent-transcript";
|
|
127
130
|
sessionId?: string;
|
|
128
131
|
sessionAction?: SessionAction;
|
|
@@ -211,6 +214,8 @@ export interface TaskResult {
|
|
|
211
214
|
* stay 0 because `getSessionStats()` exposes only the aggregate cost — and
|
|
212
215
|
* Pi sums `cost.total` for nested usage anyway. */
|
|
213
216
|
usage: Usage;
|
|
217
|
+
/** Scratch results are excluded from shared-file conflict detection and never resumable. */
|
|
218
|
+
workspace?: WorkspaceMode;
|
|
214
219
|
sessionFile?: string;
|
|
215
220
|
/** All files the subagent is known to have touched, including bash mutations
|
|
216
221
|
* captured via git diff and other tasks' concurrent git changes. This is a
|
package/workspace.ts
ADDED
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { scheduleDeadline } from "./timer.ts";
|
|
5
|
+
|
|
6
|
+
const SCRATCH_PREFIX = ".pi-delegate-scratch-";
|
|
7
|
+
const SCRATCH_TREE_NAME = "project";
|
|
8
|
+
const SCRATCH_OWNER_NAME = ".owner";
|
|
9
|
+
const COPY_TIMEOUT_MS = 5 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
export interface ScratchWorkspace {
|
|
12
|
+
/** Canonical project root copied into the scratch directory. */
|
|
13
|
+
sourceRoot: string;
|
|
14
|
+
/** Canonical cwd requested by the caller. */
|
|
15
|
+
sourceCwd: string;
|
|
16
|
+
/** Root of the disposable reflink copy. */
|
|
17
|
+
scratchRoot: string;
|
|
18
|
+
/** sourceCwd translated into scratchRoot. */
|
|
19
|
+
cwd: string;
|
|
20
|
+
mapPathToSource(candidate: string): string;
|
|
21
|
+
/** Resolve a reported path physically, mapping disposable paths back to the
|
|
22
|
+
* source tree and preserving external host paths after cleanup. */
|
|
23
|
+
resolveReportedPath(candidate: string): Promise<string>;
|
|
24
|
+
/**
|
|
25
|
+
* Resolve an explicitly attributed path. Disposable paths return undefined;
|
|
26
|
+
* external paths are returned in physical (realpath) form so symlink aliases
|
|
27
|
+
* compare with the host path they actually touched.
|
|
28
|
+
*/
|
|
29
|
+
resolveAttributedPath(candidate: string): Promise<string | undefined>;
|
|
30
|
+
/** True when the existing path resolves inside the disposable tree. */
|
|
31
|
+
isDisposablePath(candidate: string): Promise<boolean>;
|
|
32
|
+
cleanup(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class ScratchSetupError extends Error {}
|
|
36
|
+
|
|
37
|
+
export class ScratchDeadlineError extends ScratchSetupError {}
|
|
38
|
+
|
|
39
|
+
class CommandError extends Error {
|
|
40
|
+
constructor(
|
|
41
|
+
message: string,
|
|
42
|
+
readonly stderr: string,
|
|
43
|
+
options: ErrorOptions,
|
|
44
|
+
) {
|
|
45
|
+
super(message, options);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function runFile(
|
|
50
|
+
file: string,
|
|
51
|
+
args: string[],
|
|
52
|
+
options: { cwd?: string; signal?: AbortSignal; timeout?: number } = {},
|
|
53
|
+
): Promise<string> {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
execFile(
|
|
56
|
+
file,
|
|
57
|
+
args,
|
|
58
|
+
{
|
|
59
|
+
cwd: options.cwd,
|
|
60
|
+
signal: options.signal,
|
|
61
|
+
timeout: options.timeout,
|
|
62
|
+
maxBuffer: 1024 * 1024,
|
|
63
|
+
},
|
|
64
|
+
(error, stdout, stderr) => {
|
|
65
|
+
if (error) {
|
|
66
|
+
const detail = stderr.trim();
|
|
67
|
+
reject(
|
|
68
|
+
new CommandError(
|
|
69
|
+
detail ? `${file}: ${detail}` : `${file}: ${error.message}`,
|
|
70
|
+
detail,
|
|
71
|
+
{ cause: error },
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
resolve(stdout);
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function findCopyRoot(cwd: string, signal: AbortSignal): Promise<string> {
|
|
83
|
+
try {
|
|
84
|
+
const root = (
|
|
85
|
+
await runFile("git", ["rev-parse", "--show-toplevel"], {
|
|
86
|
+
cwd,
|
|
87
|
+
timeout: 5000,
|
|
88
|
+
signal,
|
|
89
|
+
})
|
|
90
|
+
).trim();
|
|
91
|
+
if (!root) {
|
|
92
|
+
throw new ScratchSetupError("Git returned an empty repository root.");
|
|
93
|
+
}
|
|
94
|
+
return await fs.promises.realpath(root);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
// Only Git's explicit "not a repository" result permits treating cwd as a
|
|
97
|
+
// plain directory. Missing Git, timeouts, dubious ownership, malformed
|
|
98
|
+
// metadata, and every other failure stop scratch creation: falling back
|
|
99
|
+
// could leave an ancestor repository or linked-worktree metadata reachable.
|
|
100
|
+
if (
|
|
101
|
+
error instanceof CommandError &&
|
|
102
|
+
/not a git repository/i.test(error.stderr)
|
|
103
|
+
) {
|
|
104
|
+
return cwd;
|
|
105
|
+
}
|
|
106
|
+
throw new ScratchSetupError(
|
|
107
|
+
"Could not safely determine the scratch project root.",
|
|
108
|
+
{
|
|
109
|
+
cause: error,
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function throwIfSetupCancelled(
|
|
116
|
+
signal: AbortSignal,
|
|
117
|
+
parentSignal: AbortSignal | undefined,
|
|
118
|
+
): void {
|
|
119
|
+
if (!signal.aborted) return;
|
|
120
|
+
if (parentSignal?.aborted) {
|
|
121
|
+
throw new Error("Scratch workspace creation was aborted.");
|
|
122
|
+
}
|
|
123
|
+
throw new ScratchDeadlineError(
|
|
124
|
+
"Scratch workspace creation exceeded the task deadline.",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Validate the completed copy before any subagent receives its path. */
|
|
129
|
+
async function validateCopiedTree(
|
|
130
|
+
root: string,
|
|
131
|
+
signal: AbortSignal,
|
|
132
|
+
parentSignal: AbortSignal | undefined,
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
const pending = [root];
|
|
135
|
+
while (pending.length) {
|
|
136
|
+
throwIfSetupCancelled(signal, parentSignal);
|
|
137
|
+
const directory = pending.pop()!;
|
|
138
|
+
for (const entry of await fs.promises.readdir(directory, {
|
|
139
|
+
withFileTypes: true,
|
|
140
|
+
})) {
|
|
141
|
+
throwIfSetupCancelled(signal, parentSignal);
|
|
142
|
+
const candidate = path.join(directory, entry.name);
|
|
143
|
+
// The root repository is validated below. A non-directory .git entry can
|
|
144
|
+
// redirect metadata outside the copy. Nested repositories are rejected as
|
|
145
|
+
// unsupported because their own config, alternates, and worktree settings
|
|
146
|
+
// would each need the same independent validation as the root repository.
|
|
147
|
+
if (entry.name === ".git") {
|
|
148
|
+
if (!entry.isDirectory()) {
|
|
149
|
+
throw new ScratchSetupError(
|
|
150
|
+
`Scratch workspace cannot safely copy linked Git metadata at '${path.relative(root, candidate)}'.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (directory !== root) {
|
|
154
|
+
throw new ScratchSetupError(
|
|
155
|
+
`Scratch workspace does not support nested Git repositories at '${path.relative(root, candidate)}'.`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (entry.isDirectory()) {
|
|
160
|
+
pending.push(candidate);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (!entry.isSymbolicLink()) continue;
|
|
164
|
+
const target = await fs.promises.readlink(candidate);
|
|
165
|
+
const resolvedTarget = path.resolve(path.dirname(candidate), target);
|
|
166
|
+
if (path.isAbsolute(target) || !isWithin(root, resolvedTarget)) {
|
|
167
|
+
throw new ScratchSetupError(
|
|
168
|
+
`Scratch workspace cannot safely copy symlink '${path.relative(root, candidate)}' because it points outside the project.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isWithin(root: string, candidate: string): boolean {
|
|
176
|
+
const relative = path.relative(root, candidate);
|
|
177
|
+
return (
|
|
178
|
+
relative === "" ||
|
|
179
|
+
(relative !== ".." &&
|
|
180
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
181
|
+
!path.isAbsolute(relative))
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function isProcessAlive(pid: number): boolean {
|
|
186
|
+
try {
|
|
187
|
+
process.kill(pid, 0);
|
|
188
|
+
return true;
|
|
189
|
+
} catch (error) {
|
|
190
|
+
return !(
|
|
191
|
+
error instanceof Error &&
|
|
192
|
+
"code" in error &&
|
|
193
|
+
error.code === "ESRCH"
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Remove leases left behind by a process that is no longer running.
|
|
199
|
+
*
|
|
200
|
+
* 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.
|
|
204
|
+
*/
|
|
205
|
+
async function sweepStaleScratchLeases(parent: string): Promise<void> {
|
|
206
|
+
const uid = process.getuid?.();
|
|
207
|
+
if (uid === undefined) return;
|
|
208
|
+
|
|
209
|
+
let entries: fs.Dirent[];
|
|
210
|
+
try {
|
|
211
|
+
entries = await fs.promises.readdir(parent, { withFileTypes: true });
|
|
212
|
+
} catch (error) {
|
|
213
|
+
console.error("[delegate] scratch lease sweep failed", error);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
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);
|
|
222
|
+
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
|
+
}
|
|
248
|
+
|
|
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
|
+
}
|
|
270
|
+
|
|
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;
|
|
281
|
+
try {
|
|
282
|
+
leaseHandle = await fs.promises.open(
|
|
283
|
+
path.join(`/proc/self/fd/${parentHandle.fd}`, entry.name),
|
|
284
|
+
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
|
|
285
|
+
);
|
|
286
|
+
const currentLeaseStat = await fs.promises.lstat(leaseRoot);
|
|
287
|
+
const openLeaseStat = await leaseHandle.stat();
|
|
288
|
+
if (
|
|
289
|
+
currentLeaseStat.dev !== leaseStat.dev ||
|
|
290
|
+
currentLeaseStat.ino !== leaseStat.ino ||
|
|
291
|
+
openLeaseStat.dev !== leaseStat.dev ||
|
|
292
|
+
openLeaseStat.ino !== leaseStat.ino
|
|
293
|
+
) {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const currentOwnerPath = path.join(
|
|
297
|
+
`/proc/self/fd/${leaseHandle.fd}`,
|
|
298
|
+
SCRATCH_OWNER_NAME,
|
|
299
|
+
);
|
|
300
|
+
const currentOwnerStat = await fs.promises.lstat(currentOwnerPath);
|
|
301
|
+
const currentPid = Number.parseInt(
|
|
302
|
+
(await fs.promises.readFile(currentOwnerPath, "utf8")).trim(),
|
|
303
|
+
10,
|
|
304
|
+
);
|
|
305
|
+
if (
|
|
306
|
+
!currentOwnerStat.isFile() ||
|
|
307
|
+
currentOwnerStat.uid !== uid ||
|
|
308
|
+
currentOwnerStat.dev !== ownerStat.dev ||
|
|
309
|
+
currentOwnerStat.ino !== ownerStat.ino ||
|
|
310
|
+
!Number.isSafeInteger(currentPid) ||
|
|
311
|
+
isProcessAlive(currentPid)
|
|
312
|
+
) {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
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
|
+
}
|
|
327
|
+
}
|
|
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 },
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
await fs.promises.rm(currentOwnerPath, { force: false });
|
|
336
|
+
await fs.promises.rmdir(
|
|
337
|
+
path.join(`/proc/self/fd/${parentHandle.fd}`, entry.name),
|
|
338
|
+
);
|
|
339
|
+
} finally {
|
|
340
|
+
await projectHandle?.close();
|
|
341
|
+
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
|
+
}
|
|
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
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Make an ephemeral, same-filesystem CoW copy of the Git repository containing
|
|
364
|
+
* cwd (or cwd itself outside Git). This is accidental-write isolation, not a
|
|
365
|
+
* security boundary: absolute paths and commands can still reach the host.
|
|
366
|
+
*/
|
|
367
|
+
export async function createScratchWorkspace(
|
|
368
|
+
cwd: string,
|
|
369
|
+
signal?: AbortSignal,
|
|
370
|
+
deadlineAt?: number,
|
|
371
|
+
): Promise<ScratchWorkspace> {
|
|
372
|
+
// Creation requires GNU cp's reflink/archive flags, and cleanup deliberately
|
|
373
|
+
// uses Linux descriptor paths to avoid deleting a renamed/replaced tree.
|
|
374
|
+
if (process.platform !== "linux" || !fs.existsSync("/proc/self/fd")) {
|
|
375
|
+
throw new Error(
|
|
376
|
+
"Scratch workspaces require Linux with GNU cp and /proc/self/fd available.",
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const controller = new AbortController();
|
|
381
|
+
const abort = () => controller.abort(signal?.reason);
|
|
382
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
383
|
+
const deadlineAbort = () =>
|
|
384
|
+
controller.abort(
|
|
385
|
+
new Error("Scratch workspace creation exceeded the task deadline."),
|
|
386
|
+
);
|
|
387
|
+
const clearDeadline =
|
|
388
|
+
deadlineAt === undefined
|
|
389
|
+
? undefined
|
|
390
|
+
: scheduleDeadline(deadlineAt, deadlineAbort);
|
|
391
|
+
if (deadlineAt !== undefined && Date.now() >= deadlineAt) deadlineAbort();
|
|
392
|
+
|
|
393
|
+
let sourceCwd: string;
|
|
394
|
+
let sourceRoot: string;
|
|
395
|
+
let leaseRoot: string | undefined;
|
|
396
|
+
let scratchRoot: string | undefined;
|
|
397
|
+
let copiedLeaseStat: fs.Stats | undefined;
|
|
398
|
+
let copiedRootStat: fs.Stats | undefined;
|
|
399
|
+
try {
|
|
400
|
+
if (signal?.aborted) controller.abort(signal.reason);
|
|
401
|
+
throwIfSetupCancelled(controller.signal, signal);
|
|
402
|
+
sourceCwd = await fs.promises.realpath(cwd);
|
|
403
|
+
throwIfSetupCancelled(controller.signal, signal);
|
|
404
|
+
sourceRoot = await findCopyRoot(sourceCwd, controller.signal);
|
|
405
|
+
throwIfSetupCancelled(controller.signal, signal);
|
|
406
|
+
if (!isWithin(sourceRoot, sourceCwd)) {
|
|
407
|
+
throw new ScratchSetupError(
|
|
408
|
+
"Scratch workspace could not map the task cwd into its project root.",
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
await sweepStaleScratchLeases(path.dirname(sourceRoot));
|
|
413
|
+
leaseRoot = await fs.promises.mkdtemp(
|
|
414
|
+
path.join(path.dirname(sourceRoot), SCRATCH_PREFIX),
|
|
415
|
+
);
|
|
416
|
+
await fs.promises.chmod(leaseRoot, 0o700);
|
|
417
|
+
await fs.promises.writeFile(
|
|
418
|
+
path.join(leaseRoot, SCRATCH_OWNER_NAME),
|
|
419
|
+
`${process.pid}\n`,
|
|
420
|
+
{ mode: 0o600 },
|
|
421
|
+
);
|
|
422
|
+
scratchRoot = path.join(leaseRoot, SCRATCH_TREE_NAME);
|
|
423
|
+
await fs.promises.mkdir(scratchRoot, { mode: 0o700 });
|
|
424
|
+
// `source/.` copies the contents into the already-created private directory.
|
|
425
|
+
// `always` deliberately refuses a full-copy fallback for unexpectedly large
|
|
426
|
+
// projects or a destination on the wrong filesystem.
|
|
427
|
+
await runFile(
|
|
428
|
+
"cp",
|
|
429
|
+
[
|
|
430
|
+
"--archive",
|
|
431
|
+
"--reflink=always",
|
|
432
|
+
"--",
|
|
433
|
+
`${sourceRoot}${path.sep}.`,
|
|
434
|
+
scratchRoot,
|
|
435
|
+
],
|
|
436
|
+
{ signal: controller.signal, timeout: COPY_TIMEOUT_MS },
|
|
437
|
+
);
|
|
438
|
+
// GNU cp --archive applies the source root's mode to the destination.
|
|
439
|
+
// Restore the private boundary after it has finished copying metadata.
|
|
440
|
+
await fs.promises.chmod(scratchRoot, 0o700);
|
|
441
|
+
await validateCopiedTree(scratchRoot, controller.signal, signal);
|
|
442
|
+
if (
|
|
443
|
+
await fs.promises.stat(path.join(scratchRoot, ".git")).then(
|
|
444
|
+
(stat) => stat.isDirectory(),
|
|
445
|
+
() => false,
|
|
446
|
+
)
|
|
447
|
+
) {
|
|
448
|
+
const effectiveWorktree = path.resolve(
|
|
449
|
+
(
|
|
450
|
+
await runFile("git", ["rev-parse", "--show-toplevel"], {
|
|
451
|
+
cwd: scratchRoot,
|
|
452
|
+
signal: controller.signal,
|
|
453
|
+
timeout: 5000,
|
|
454
|
+
})
|
|
455
|
+
).trim(),
|
|
456
|
+
);
|
|
457
|
+
const effectiveGitDir = path.resolve(
|
|
458
|
+
scratchRoot,
|
|
459
|
+
(
|
|
460
|
+
await runFile("git", ["rev-parse", "--absolute-git-dir"], {
|
|
461
|
+
cwd: scratchRoot,
|
|
462
|
+
signal: controller.signal,
|
|
463
|
+
timeout: 5000,
|
|
464
|
+
})
|
|
465
|
+
).trim(),
|
|
466
|
+
);
|
|
467
|
+
const effectiveCommonDir = path.resolve(
|
|
468
|
+
effectiveGitDir,
|
|
469
|
+
(
|
|
470
|
+
await runFile("git", ["rev-parse", "--git-common-dir"], {
|
|
471
|
+
cwd: scratchRoot,
|
|
472
|
+
signal: controller.signal,
|
|
473
|
+
timeout: 5000,
|
|
474
|
+
})
|
|
475
|
+
).trim(),
|
|
476
|
+
);
|
|
477
|
+
if (
|
|
478
|
+
effectiveWorktree !== scratchRoot ||
|
|
479
|
+
!isWithin(scratchRoot, effectiveGitDir) ||
|
|
480
|
+
!isWithin(scratchRoot, effectiveCommonDir)
|
|
481
|
+
) {
|
|
482
|
+
throw new ScratchSetupError(
|
|
483
|
+
"Scratch workspace Git configuration redirects its worktree or metadata outside the copied project.",
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
throwIfSetupCancelled(controller.signal, signal);
|
|
488
|
+
// Keep the copied project writable, but make its private parent immutable
|
|
489
|
+
// to ordinary task commands. `mv "$PWD" …` then cannot unlink the project
|
|
490
|
+
// entry. This is accidental-write protection, not a same-user security
|
|
491
|
+
// boundary: unrestricted bash can deliberately chmod the parent again.
|
|
492
|
+
await fs.promises.chmod(leaseRoot, 0o500);
|
|
493
|
+
// Capture cleanup identities inside the guarded region: if either lookup
|
|
494
|
+
// fails, the catch below restores permissions and removes the partial copy.
|
|
495
|
+
copiedLeaseStat = await fs.promises.lstat(leaseRoot);
|
|
496
|
+
copiedRootStat = await fs.promises.lstat(scratchRoot);
|
|
497
|
+
} catch (error) {
|
|
498
|
+
if (leaseRoot) {
|
|
499
|
+
try {
|
|
500
|
+
await fs.promises.chmod(leaseRoot, 0o700);
|
|
501
|
+
await fs.promises.rm(leaseRoot, { recursive: true, force: true });
|
|
502
|
+
} catch (cleanupError) {
|
|
503
|
+
console.error(
|
|
504
|
+
"[delegate] failed to clean partial scratch workspace",
|
|
505
|
+
cleanupError,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (controller.signal.aborted) {
|
|
510
|
+
if (signal?.aborted) {
|
|
511
|
+
throw new Error("Scratch workspace creation was aborted.", {
|
|
512
|
+
cause: error,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
throw new ScratchDeadlineError(
|
|
516
|
+
"Scratch workspace creation exceeded the task deadline.",
|
|
517
|
+
{ cause: error },
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
if (error instanceof ScratchSetupError) throw error;
|
|
521
|
+
throw new Error(
|
|
522
|
+
"Could not create a CoW scratch workspace. The project and scratch directory must be on a reflink-capable filesystem (for example Btrfs).",
|
|
523
|
+
{ cause: error },
|
|
524
|
+
);
|
|
525
|
+
} finally {
|
|
526
|
+
signal?.removeEventListener("abort", abort);
|
|
527
|
+
clearDeadline?.();
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Assigned before successful exit from the try block above.
|
|
531
|
+
const completedLeaseRoot = leaseRoot!;
|
|
532
|
+
const completedRoot = scratchRoot!;
|
|
533
|
+
const completedLeaseStat = copiedLeaseStat!;
|
|
534
|
+
const completedRootStat = copiedRootStat!;
|
|
535
|
+
const relativeCwd = path.relative(sourceRoot!, sourceCwd!);
|
|
536
|
+
let cleaned = false;
|
|
537
|
+
const resolveReportedPath = async (candidate: string): Promise<string> => {
|
|
538
|
+
const absolute = path.resolve(candidate);
|
|
539
|
+
try {
|
|
540
|
+
const real = await fs.promises.realpath(absolute);
|
|
541
|
+
return isWithin(completedRoot, real)
|
|
542
|
+
? path.join(sourceRoot!, path.relative(completedRoot, real))
|
|
543
|
+
: real;
|
|
544
|
+
} catch {
|
|
545
|
+
// A successful edit/write normally leaves a path behind. Keep the
|
|
546
|
+
// source mapping for a disposable path that was deleted immediately,
|
|
547
|
+
// while preserving an external path for later diagnostics.
|
|
548
|
+
return isWithin(completedRoot, absolute)
|
|
549
|
+
? path.join(sourceRoot!, path.relative(completedRoot, absolute))
|
|
550
|
+
: absolute;
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
const resolveAttributedPath = async (
|
|
554
|
+
candidate: string,
|
|
555
|
+
): Promise<string | undefined> => {
|
|
556
|
+
try {
|
|
557
|
+
const real = await fs.promises.realpath(candidate);
|
|
558
|
+
return isWithin(completedRoot, real) ? undefined : real;
|
|
559
|
+
} catch {
|
|
560
|
+
const absolute = path.resolve(candidate);
|
|
561
|
+
return isWithin(completedRoot, absolute) ? undefined : absolute;
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
return {
|
|
565
|
+
sourceRoot: sourceRoot!,
|
|
566
|
+
sourceCwd: sourceCwd!,
|
|
567
|
+
scratchRoot: completedRoot,
|
|
568
|
+
cwd: path.join(completedRoot, relativeCwd),
|
|
569
|
+
mapPathToSource(candidate: string): string {
|
|
570
|
+
const absolute = path.resolve(candidate);
|
|
571
|
+
if (!isWithin(completedRoot, absolute)) return candidate;
|
|
572
|
+
return path.join(sourceRoot!, path.relative(completedRoot, absolute));
|
|
573
|
+
},
|
|
574
|
+
resolveReportedPath,
|
|
575
|
+
resolveAttributedPath,
|
|
576
|
+
async isDisposablePath(candidate: string): Promise<boolean> {
|
|
577
|
+
return (await resolveAttributedPath(candidate)) === undefined;
|
|
578
|
+
},
|
|
579
|
+
async cleanup(): Promise<void> {
|
|
580
|
+
if (cleaned) return;
|
|
581
|
+
try {
|
|
582
|
+
if (
|
|
583
|
+
path.dirname(completedLeaseRoot) !== path.dirname(sourceRoot!) ||
|
|
584
|
+
!path.basename(completedLeaseRoot).startsWith(SCRATCH_PREFIX) ||
|
|
585
|
+
path.dirname(completedRoot) !== completedLeaseRoot ||
|
|
586
|
+
path.basename(completedRoot) !== SCRATCH_TREE_NAME
|
|
587
|
+
) {
|
|
588
|
+
throw new Error(
|
|
589
|
+
"Refusing to clean an unrecognised scratch workspace path.",
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
// Open the parent first, then resolve the lease through that handle. The
|
|
593
|
+
// descriptor identifies the checked directory even if its pathname is
|
|
594
|
+
// renamed or replaced while cleanup is running.
|
|
595
|
+
const parentHandle = await fs.promises.open(
|
|
596
|
+
path.dirname(completedLeaseRoot),
|
|
597
|
+
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
|
|
598
|
+
);
|
|
599
|
+
let leaseHandle:
|
|
600
|
+
Awaited<ReturnType<typeof fs.promises.open>> | undefined;
|
|
601
|
+
let rootHandle:
|
|
602
|
+
Awaited<ReturnType<typeof fs.promises.open>> | undefined;
|
|
603
|
+
try {
|
|
604
|
+
leaseHandle = await fs.promises.open(
|
|
605
|
+
path.join(
|
|
606
|
+
`/proc/self/fd/${parentHandle.fd}`,
|
|
607
|
+
path.basename(completedLeaseRoot),
|
|
608
|
+
),
|
|
609
|
+
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
|
|
610
|
+
);
|
|
611
|
+
rootHandle = await fs.promises.open(
|
|
612
|
+
path.join(`/proc/self/fd/${leaseHandle.fd}`, SCRATCH_TREE_NAME),
|
|
613
|
+
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY,
|
|
614
|
+
);
|
|
615
|
+
const currentLeaseStat = await fs.promises.lstat(completedLeaseRoot);
|
|
616
|
+
const currentRootStat = await fs.promises.lstat(completedRoot);
|
|
617
|
+
const openLeaseStat = await leaseHandle.stat();
|
|
618
|
+
const openRootStat = await rootHandle.stat();
|
|
619
|
+
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
|
+
!openLeaseStat.isDirectory() ||
|
|
627
|
+
openLeaseStat.dev !== completedLeaseStat.dev ||
|
|
628
|
+
openLeaseStat.ino !== completedLeaseStat.ino ||
|
|
629
|
+
!openRootStat.isDirectory() ||
|
|
630
|
+
openRootStat.dev !== completedRootStat.dev ||
|
|
631
|
+
openRootStat.ino !== completedRootStat.ino
|
|
632
|
+
) {
|
|
633
|
+
throw new Error(
|
|
634
|
+
"Scratch workspace root was moved or replaced; refusing to report cleanup success.",
|
|
635
|
+
);
|
|
636
|
+
}
|
|
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
|
+
),
|
|
657
|
+
);
|
|
658
|
+
cleaned = true;
|
|
659
|
+
} finally {
|
|
660
|
+
await rootHandle?.close();
|
|
661
|
+
await leaseHandle?.close();
|
|
662
|
+
await parentHandle.close();
|
|
663
|
+
}
|
|
664
|
+
} catch (error) {
|
|
665
|
+
throw new Error(
|
|
666
|
+
`Scratch workspace cleanup failed for lease '${completedLeaseRoot}': ${error instanceof Error ? error.message : String(error)}`,
|
|
667
|
+
{ cause: error },
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
},
|
|
671
|
+
};
|
|
672
|
+
}
|