@bermudi/pi-delegate 0.1.15 → 0.1.17
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/agents.ts +2 -2
- package/constants.ts +10 -0
- package/delegate.ts +1 -0
- package/dispatch.ts +21 -7
- package/extension.ts +55 -1
- package/lifecycle.ts +17 -6
- package/manual.ts +5 -3
- package/package.json +1 -1
- package/render-branches.ts +2 -1
- package/schema.ts +163 -79
- package/task-resolution.ts +39 -19
- package/telemetry.ts +86 -11
- package/test-preload.ts +26 -0
- package/ticket-format.ts +6 -2
- package/types.ts +22 -4
- package/workspace.ts +183 -58
package/telemetry.ts
CHANGED
|
@@ -7,7 +7,12 @@ import * as crypto from "node:crypto";
|
|
|
7
7
|
import * as piCodingAgent from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import type { DatabaseSync, StatementSync } from "node:sqlite";
|
|
9
9
|
import { getTelemetryConfig } from "./config.ts";
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
ResolvedTask,
|
|
12
|
+
TaskProgress,
|
|
13
|
+
TaskResult,
|
|
14
|
+
WorkspaceMode,
|
|
15
|
+
} from "./types.ts";
|
|
11
16
|
|
|
12
17
|
export interface CallRecord {
|
|
13
18
|
id: string;
|
|
@@ -22,6 +27,7 @@ export interface CallRecord {
|
|
|
22
27
|
total_tokens: number;
|
|
23
28
|
total_cost: number;
|
|
24
29
|
parent_session_file: string | undefined;
|
|
30
|
+
parent_cwd: string | undefined;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
export interface TaskRecord {
|
|
@@ -35,6 +41,7 @@ export interface TaskRecord {
|
|
|
35
41
|
model: string | undefined;
|
|
36
42
|
thinking: string | undefined;
|
|
37
43
|
tools: string;
|
|
44
|
+
workspace: WorkspaceMode | undefined;
|
|
38
45
|
outcome: string;
|
|
39
46
|
failure_kind: string | undefined;
|
|
40
47
|
duration_ms: number;
|
|
@@ -46,6 +53,7 @@ export interface TaskRecord {
|
|
|
46
53
|
output_chars: number;
|
|
47
54
|
session_file: string | undefined;
|
|
48
55
|
async: number;
|
|
56
|
+
error_snippet: string | undefined;
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
export interface TelemetryRecorder {
|
|
@@ -87,13 +95,32 @@ let telemetryGeneration = 0;
|
|
|
87
95
|
let telemetryClosed = false;
|
|
88
96
|
let testingRecorder: TelemetryRecorder | undefined;
|
|
89
97
|
|
|
90
|
-
const TELEMETRY_SCHEMA_VERSION =
|
|
98
|
+
const TELEMETRY_SCHEMA_VERSION = 3;
|
|
91
99
|
const SQLITE_BUSY_TIMEOUT_MS = 5_000;
|
|
92
100
|
|
|
93
101
|
function defaultDbPath(): string {
|
|
94
102
|
return path.join(os.homedir(), ".pi", "agent", "delegate-usage.db");
|
|
95
103
|
}
|
|
96
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Telemetry database path resolution: explicit config wins, then the
|
|
107
|
+
* DELEGATE_TELEMETRY_DB environment variable, then the default user path.
|
|
108
|
+
* The env var exists so test runs can redirect the default destination away
|
|
109
|
+
* from the production database without touching user config — the pi test
|
|
110
|
+
* harness builds real sessions in-process, so process.env is the extension's
|
|
111
|
+
* environment (see test-preload.ts). Config beats env on purpose: telemetry
|
|
112
|
+
* tests drive backends through explicit config dbPath values, including
|
|
113
|
+
* spawned Node children that inherit this variable.
|
|
114
|
+
*/
|
|
115
|
+
function resolveTelemetryDbPath(
|
|
116
|
+
config: import("./config.ts").TelemetryConfig,
|
|
117
|
+
): string {
|
|
118
|
+
if (config.dbPath) return config.dbPath;
|
|
119
|
+
const fromEnv = process.env.DELEGATE_TELEMETRY_DB;
|
|
120
|
+
if (fromEnv) return fromEnv;
|
|
121
|
+
return defaultDbPath();
|
|
122
|
+
}
|
|
123
|
+
|
|
97
124
|
function findPackageJson(startFile: string): string | undefined {
|
|
98
125
|
const candidates = [
|
|
99
126
|
path.join(path.dirname(startFile), "package.json"),
|
|
@@ -175,6 +202,7 @@ const TELEMETRY_TABLES: readonly TelemetryTable[] = [
|
|
|
175
202
|
["total_tokens", "INTEGER"],
|
|
176
203
|
["total_cost", "REAL"],
|
|
177
204
|
["parent_session_file", "TEXT"],
|
|
205
|
+
["parent_cwd", "TEXT"],
|
|
178
206
|
],
|
|
179
207
|
createSql: `
|
|
180
208
|
CREATE TABLE IF NOT EXISTS calls(
|
|
@@ -189,7 +217,8 @@ const TELEMETRY_TABLES: readonly TelemetryTable[] = [
|
|
|
189
217
|
status TEXT,
|
|
190
218
|
total_tokens INTEGER,
|
|
191
219
|
total_cost REAL,
|
|
192
|
-
parent_session_file TEXT
|
|
220
|
+
parent_session_file TEXT,
|
|
221
|
+
parent_cwd TEXT
|
|
193
222
|
);
|
|
194
223
|
`,
|
|
195
224
|
},
|
|
@@ -206,6 +235,7 @@ const TELEMETRY_TABLES: readonly TelemetryTable[] = [
|
|
|
206
235
|
["model", "TEXT"],
|
|
207
236
|
["thinking", "TEXT"],
|
|
208
237
|
["tools", "TEXT"],
|
|
238
|
+
["workspace", "TEXT"],
|
|
209
239
|
["outcome", "TEXT"],
|
|
210
240
|
["failure_kind", "TEXT"],
|
|
211
241
|
["duration_ms", "INTEGER"],
|
|
@@ -217,6 +247,7 @@ const TELEMETRY_TABLES: readonly TelemetryTable[] = [
|
|
|
217
247
|
["output_chars", "INTEGER"],
|
|
218
248
|
["session_file", "TEXT"],
|
|
219
249
|
["async", "INTEGER"],
|
|
250
|
+
["error_snippet", "TEXT"],
|
|
220
251
|
],
|
|
221
252
|
createSql: `
|
|
222
253
|
CREATE TABLE IF NOT EXISTS tasks(
|
|
@@ -230,6 +261,7 @@ const TELEMETRY_TABLES: readonly TelemetryTable[] = [
|
|
|
230
261
|
model TEXT,
|
|
231
262
|
thinking TEXT,
|
|
232
263
|
tools TEXT,
|
|
264
|
+
workspace TEXT,
|
|
233
265
|
outcome TEXT,
|
|
234
266
|
failure_kind TEXT,
|
|
235
267
|
duration_ms INTEGER,
|
|
@@ -240,7 +272,8 @@ const TELEMETRY_TABLES: readonly TelemetryTable[] = [
|
|
|
240
272
|
prompt_chars INTEGER,
|
|
241
273
|
output_chars INTEGER,
|
|
242
274
|
session_file TEXT,
|
|
243
|
-
async INTEGER
|
|
275
|
+
async INTEGER,
|
|
276
|
+
error_snippet TEXT
|
|
244
277
|
);
|
|
245
278
|
`,
|
|
246
279
|
},
|
|
@@ -312,6 +345,26 @@ function initSchema(db: DatabaseSync): void {
|
|
|
312
345
|
}
|
|
313
346
|
}
|
|
314
347
|
|
|
348
|
+
// Backfill legacy rows that predate the workspace column. New rows store
|
|
349
|
+
// 'shared' explicitly, but ALTER TABLE leaves existing rows as NULL. Without
|
|
350
|
+
// this, GROUP BY workspace splits NULL vs 'shared' for the same semantics.
|
|
351
|
+
// Reviewer defaulted to scratch when these rows were recorded, so preserve
|
|
352
|
+
// that heuristic for historical rows; everything else was shared by
|
|
353
|
+
// default. This is idempotent and runs inside the same transaction as the schema changes so a crash before COMMIT
|
|
354
|
+
// retries cleanly.
|
|
355
|
+
try {
|
|
356
|
+
db.exec(
|
|
357
|
+
"UPDATE tasks SET workspace='scratch' WHERE workspace IS NULL AND agent='reviewer'",
|
|
358
|
+
);
|
|
359
|
+
db.exec("UPDATE tasks SET workspace='shared' WHERE workspace IS NULL");
|
|
360
|
+
} catch {
|
|
361
|
+
// tasks may not exist on first run (fresh DB) or workspace column may
|
|
362
|
+
// have just been created via CREATE TABLE — UPDATE affecting 0 rows is fine.
|
|
363
|
+
// Any real error will surface on the next write and disable telemetry
|
|
364
|
+
// via the existing fail-open path, so swallowing here preserves the
|
|
365
|
+
// repair-loop's best-effort nature.
|
|
366
|
+
}
|
|
367
|
+
|
|
315
368
|
// Set the marker only after every table/column operation succeeded.
|
|
316
369
|
db.exec(`PRAGMA user_version = ${TELEMETRY_SCHEMA_VERSION}`);
|
|
317
370
|
db.exec("COMMIT");
|
|
@@ -346,15 +399,16 @@ class SqliteTelemetryBackend implements TelemetryBackend {
|
|
|
346
399
|
this.insertCall = db.prepare(
|
|
347
400
|
`INSERT OR REPLACE INTO calls(
|
|
348
401
|
id, ts, version, pi_version, mode, parent_model, task_count,
|
|
349
|
-
wall_ms, status, total_tokens, total_cost, parent_session_file
|
|
350
|
-
|
|
402
|
+
wall_ms, status, total_tokens, total_cost, parent_session_file,
|
|
403
|
+
parent_cwd
|
|
404
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
351
405
|
);
|
|
352
406
|
this.insertTask = db.prepare(
|
|
353
407
|
`INSERT OR REPLACE INTO tasks(
|
|
354
408
|
id, call_id, ts, version, pi_version, idx, agent, model, thinking,
|
|
355
|
-
tools, outcome, failure_kind, duration_ms, tokens, cost, tool_uses,
|
|
356
|
-
retries, prompt_chars, output_chars, session_file, async
|
|
357
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
409
|
+
tools, workspace, outcome, failure_kind, duration_ms, tokens, cost, tool_uses,
|
|
410
|
+
retries, prompt_chars, output_chars, session_file, async, error_snippet
|
|
411
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
358
412
|
);
|
|
359
413
|
}
|
|
360
414
|
|
|
@@ -373,6 +427,7 @@ class SqliteTelemetryBackend implements TelemetryBackend {
|
|
|
373
427
|
record.total_tokens,
|
|
374
428
|
record.total_cost,
|
|
375
429
|
record.parent_session_file ?? null,
|
|
430
|
+
record.parent_cwd ?? null,
|
|
376
431
|
);
|
|
377
432
|
} catch (error) {
|
|
378
433
|
this.onFailure("recordCall", error);
|
|
@@ -392,6 +447,7 @@ class SqliteTelemetryBackend implements TelemetryBackend {
|
|
|
392
447
|
record.model ?? null,
|
|
393
448
|
record.thinking ?? null,
|
|
394
449
|
record.tools,
|
|
450
|
+
record.workspace ?? null,
|
|
395
451
|
record.outcome,
|
|
396
452
|
record.failure_kind ?? null,
|
|
397
453
|
record.duration_ms,
|
|
@@ -403,6 +459,7 @@ class SqliteTelemetryBackend implements TelemetryBackend {
|
|
|
403
459
|
record.output_chars,
|
|
404
460
|
record.session_file ?? null,
|
|
405
461
|
record.async,
|
|
462
|
+
record.error_snippet ?? null,
|
|
406
463
|
);
|
|
407
464
|
} catch (error) {
|
|
408
465
|
this.onFailure("recordTask", error);
|
|
@@ -453,7 +510,7 @@ function backendIdentity(
|
|
|
453
510
|
config: import("./config.ts").TelemetryConfig,
|
|
454
511
|
): string | undefined {
|
|
455
512
|
if (config.enabled === false) return undefined;
|
|
456
|
-
return config
|
|
513
|
+
return resolveTelemetryDbPath(config);
|
|
457
514
|
}
|
|
458
515
|
|
|
459
516
|
function disableActiveBackend(
|
|
@@ -495,7 +552,7 @@ function openSqliteBackend(
|
|
|
495
552
|
if (config.enabled === false || telemetryClosed) return undefined;
|
|
496
553
|
if (!DatabaseSyncCtor) return undefined;
|
|
497
554
|
|
|
498
|
-
const dbPath = config
|
|
555
|
+
const dbPath = resolveTelemetryDbPath(config);
|
|
499
556
|
let db: DatabaseSync | undefined;
|
|
500
557
|
try {
|
|
501
558
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
@@ -587,6 +644,8 @@ export interface CallSpanInput {
|
|
|
587
644
|
mode: string;
|
|
588
645
|
taskCount: number;
|
|
589
646
|
parentSessionFile?: string;
|
|
647
|
+
/** Parent working directory at dispatch — one dispatch, one cwd. */
|
|
648
|
+
parentCwd?: string;
|
|
590
649
|
}
|
|
591
650
|
|
|
592
651
|
export interface CallSpanFinish {
|
|
@@ -637,6 +696,7 @@ class CallSpanImpl implements CallSpan {
|
|
|
637
696
|
total_tokens: 0,
|
|
638
697
|
total_cost: 0,
|
|
639
698
|
parent_session_file: this.input.parentSessionFile,
|
|
699
|
+
parent_cwd: this.input.parentCwd,
|
|
640
700
|
};
|
|
641
701
|
}
|
|
642
702
|
|
|
@@ -700,6 +760,19 @@ function outcomeFromResult(result: TaskResult): string {
|
|
|
700
760
|
return "success";
|
|
701
761
|
}
|
|
702
762
|
|
|
763
|
+
/** Error text only — never prompt or output content — whitespace-collapsed
|
|
764
|
+
* and capped, so failure classification becomes a query instead of
|
|
765
|
+
* duration-based guessing. */
|
|
766
|
+
const ERROR_SNIPPET_MAX_CHARS = 200;
|
|
767
|
+
|
|
768
|
+
function errorSnippetOf(result: TaskResult): string | undefined {
|
|
769
|
+
if (!result.error) return undefined;
|
|
770
|
+
const collapsed = result.error.replace(/\s+/g, " ").trim();
|
|
771
|
+
if (collapsed.length === 0) return undefined;
|
|
772
|
+
if (collapsed.length <= ERROR_SNIPPET_MAX_CHARS) return collapsed;
|
|
773
|
+
return `${collapsed.slice(0, ERROR_SNIPPET_MAX_CHARS - 1)}…`;
|
|
774
|
+
}
|
|
775
|
+
|
|
703
776
|
export function recordTask(input: TaskSpanInput): string | undefined {
|
|
704
777
|
const b = input.telemetryConfig
|
|
705
778
|
? getBackendForConfig(
|
|
@@ -723,6 +796,7 @@ export function recordTask(input: TaskSpanInput): string | undefined {
|
|
|
723
796
|
model: task.model?.id,
|
|
724
797
|
thinking: task.thinking,
|
|
725
798
|
tools: JSON.stringify(task.tools),
|
|
799
|
+
workspace: task.workspace ?? "shared",
|
|
726
800
|
outcome: outcomeFromResult(result),
|
|
727
801
|
failure_kind: result.failureKind,
|
|
728
802
|
duration_ms: result.durationMs,
|
|
@@ -734,6 +808,7 @@ export function recordTask(input: TaskSpanInput): string | undefined {
|
|
|
734
808
|
output_chars: result.output?.length ?? 0,
|
|
735
809
|
session_file: result.sessionFile,
|
|
736
810
|
async: async ? 1 : 0,
|
|
811
|
+
error_snippet: errorSnippetOf(result),
|
|
737
812
|
};
|
|
738
813
|
b.recordTask(record);
|
|
739
814
|
return record.id;
|
package/test-preload.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-run telemetry isolation.
|
|
3
|
+
*
|
|
4
|
+
* The pi test harness builds real sessions in-process, so this process's
|
|
5
|
+
* environment IS the extension's environment. telemetry.ts resolves the
|
|
6
|
+
* database path as config.dbPath > DELEGATE_TELEMETRY_DB > default; setting
|
|
7
|
+
* the env var here redirects every test that runs on the default path to a
|
|
8
|
+
* throwaway directory instead of the production ~/.pi/agent/delegate-usage.db.
|
|
9
|
+
* Tests that set an explicit config dbPath are unaffected (config wins).
|
|
10
|
+
*
|
|
11
|
+
* Loaded once per `bun test` invocation via bunfig.toml [test] preload.
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as os from "node:os";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
|
|
17
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "delegate-telemetry-test-"));
|
|
18
|
+
process.env.DELEGATE_TELEMETRY_DB = path.join(dir, "usage.db");
|
|
19
|
+
|
|
20
|
+
process.on("exit", () => {
|
|
21
|
+
try {
|
|
22
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
23
|
+
} catch {
|
|
24
|
+
// Best-effort cleanup; a leftover temp dir is harmless.
|
|
25
|
+
}
|
|
26
|
+
});
|
package/ticket-format.ts
CHANGED
|
@@ -274,9 +274,13 @@ export function formatCancelPreview(ticket: AsyncTicket): string {
|
|
|
274
274
|
|
|
275
275
|
for (const p of ticket.progress) {
|
|
276
276
|
if (p.status === "done") {
|
|
277
|
-
lines.push(
|
|
277
|
+
lines.push(
|
|
278
|
+
`✓ ${p.agent}${resumeMarker(p)}${formatTaskId(p.id)} · completed`,
|
|
279
|
+
);
|
|
278
280
|
} else if (p.status === "failed") {
|
|
279
|
-
lines.push(
|
|
281
|
+
lines.push(
|
|
282
|
+
`✗ ${p.agent}${resumeMarker(p)}${formatTaskId(p.id)} · ${p.error ?? "failed"}`,
|
|
283
|
+
);
|
|
280
284
|
} else if (p.status === "running") {
|
|
281
285
|
lines.push(formatInFlightTaskLine(p));
|
|
282
286
|
} else {
|
package/types.ts
CHANGED
|
@@ -53,13 +53,22 @@ type CanonicalTaskDef = NonNullable<
|
|
|
53
53
|
export type TicketAction = NonNullable<
|
|
54
54
|
CanonicalDelegateArguments["ticketAction"]
|
|
55
55
|
>;
|
|
56
|
-
/**
|
|
57
|
-
export type SessionAction = NonNullable<
|
|
56
|
+
/** Top-level session RPC action: "close" | "list" (#32 promotion). */
|
|
57
|
+
export type SessionAction = NonNullable<
|
|
58
|
+
CanonicalDelegateArguments["sessionAction"]
|
|
59
|
+
>;
|
|
58
60
|
/** Filesystem mode: shared source tree or an ephemeral CoW scratch copy. */
|
|
59
61
|
export type WorkspaceMode = NonNullable<CanonicalTaskDef["workspace"]>;
|
|
60
62
|
|
|
61
63
|
export type TaskDef = CanonicalTaskDef;
|
|
62
64
|
|
|
65
|
+
/** Pipeline-wide task type: caller-provided TaskDefs plus the internal
|
|
66
|
+
* session-RPC bridge task built by extension.ts from the promoted top-level
|
|
67
|
+
* `sessionAction`/`sessionId` fields. `sessionAction` left the public task
|
|
68
|
+
* schema in #32 (it became top-level); the runner still executes the bridged
|
|
69
|
+
* operation through ResolvedTask, so the dispatch pipeline accepts both. */
|
|
70
|
+
export type DispatchableTask = TaskDef & { sessionAction?: SessionAction };
|
|
71
|
+
|
|
63
72
|
export type DelegateArguments = CanonicalDelegateArguments;
|
|
64
73
|
|
|
65
74
|
// ── Async Ticket Types ─────────────────────────────────────────────────────
|
|
@@ -169,6 +178,14 @@ export interface ResolvedTask {
|
|
|
169
178
|
providerExtensionSources?: string;
|
|
170
179
|
}
|
|
171
180
|
|
|
181
|
+
/** Result of `resolveTasks`: either every task resolved, or a batch-wide
|
|
182
|
+
* rejection (e.g. an unknown tool name) with no tasks to dispatch. The
|
|
183
|
+
* optional-`error` discriminant keeps `.tasks` accessible on the whole union
|
|
184
|
+
* so success-path callers can narrow with a single `error !== undefined` check. */
|
|
185
|
+
export type ResolveTasksResult =
|
|
186
|
+
| { tasks: ResolvedTask[]; error?: undefined }
|
|
187
|
+
| { tasks?: undefined; error: string };
|
|
188
|
+
|
|
172
189
|
export interface FileAttributionPathSignature {
|
|
173
190
|
/** Absolute component inspected while resolving the pre-execution target. */
|
|
174
191
|
path: string;
|
|
@@ -251,8 +268,9 @@ export interface TaskProgress {
|
|
|
251
268
|
model?: string;
|
|
252
269
|
lastActivityAt?: number;
|
|
253
270
|
activities: ToolActivity[];
|
|
254
|
-
/** Human-facing notices (e.g.
|
|
255
|
-
*
|
|
271
|
+
/** Human-facing notices (e.g. scratch/isolated workspace notices, an
|
|
272
|
+
* ignored model `:level` suffix). Surfaced in the TUI under the task; the
|
|
273
|
+
* LLM gets the same text in `content` already. */
|
|
256
274
|
warnings?: string[];
|
|
257
275
|
}
|
|
258
276
|
|
package/workspace.ts
CHANGED
|
@@ -57,6 +57,7 @@ class CommandError extends Error {
|
|
|
57
57
|
constructor(
|
|
58
58
|
message: string,
|
|
59
59
|
readonly stderr: string,
|
|
60
|
+
readonly file: string,
|
|
60
61
|
options: ErrorOptions,
|
|
61
62
|
) {
|
|
62
63
|
super(message, options);
|
|
@@ -85,6 +86,7 @@ function runFile(
|
|
|
85
86
|
new CommandError(
|
|
86
87
|
detail ? `${file}: ${detail}` : `${file}: ${error.message}`,
|
|
87
88
|
detail,
|
|
89
|
+
file,
|
|
88
90
|
{ cause: error },
|
|
89
91
|
),
|
|
90
92
|
);
|
|
@@ -142,9 +144,67 @@ function throwIfSetupCancelled(
|
|
|
142
144
|
);
|
|
143
145
|
}
|
|
144
146
|
|
|
145
|
-
/**
|
|
146
|
-
|
|
147
|
+
/** Project an absolute source-tree symlink target onto its counterpart inside
|
|
148
|
+
* the copy, as a relative target so the copy stays self-contained. Returns
|
|
149
|
+
* undefined when the target is not lexically inside the source root, which is
|
|
150
|
+
* the only case that can be rewritten without following it. */
|
|
151
|
+
function relinkTargetIntoCopy(
|
|
152
|
+
linkPath: string,
|
|
153
|
+
target: string,
|
|
147
154
|
root: string,
|
|
155
|
+
sourceRoot: string,
|
|
156
|
+
): string | undefined {
|
|
157
|
+
if (!path.isAbsolute(target) || !isWithin(sourceRoot, target)) {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
const projected = path.join(root, path.relative(sourceRoot, target));
|
|
161
|
+
if (!isWithin(root, projected)) return undefined;
|
|
162
|
+
const relative = path.relative(path.dirname(linkPath), projected);
|
|
163
|
+
return relative === "" ? "." : relative;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Restore owner traversal/write bits so a partial copy can be removed.
|
|
167
|
+
* `cp --archive` reproduces read-only source directories, and their copies
|
|
168
|
+
* would otherwise leak a lease directory behind a failed setup. */
|
|
169
|
+
async function makeTreeRemovable(root: string): Promise<void> {
|
|
170
|
+
let entries: fs.Dirent[];
|
|
171
|
+
try {
|
|
172
|
+
await fs.promises.chmod(root, 0o700);
|
|
173
|
+
entries = await fs.promises.readdir(root, { withFileTypes: true });
|
|
174
|
+
} catch {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
for (const entry of entries) {
|
|
178
|
+
if (entry.isDirectory()) {
|
|
179
|
+
await makeTreeRemovable(path.join(root, entry.name));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function replaceSymlink(linkPath: string, target: string): Promise<void> {
|
|
185
|
+
const staging = path.join(
|
|
186
|
+
path.dirname(linkPath),
|
|
187
|
+
`.pi-delegate-relink-${process.pid.toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
|
|
188
|
+
);
|
|
189
|
+
await fs.promises.symlink(target, staging);
|
|
190
|
+
try {
|
|
191
|
+
await fs.promises.rename(staging, linkPath);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
await fs.promises.rm(staging, { force: true });
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Validate a scratch candidate tree — the source before copying (read-only
|
|
199
|
+
* fast-fail) or the completed copy before any subagent receives its path
|
|
200
|
+
* (authority, with the symlink-retarget side effect). One rule, two timings:
|
|
201
|
+
* the blockers are identical, so the pre-check cannot drift from the copy
|
|
202
|
+
* check, and the copy check remains authoritative for trees that change
|
|
203
|
+
* mid-copy. */
|
|
204
|
+
async function validateScratchTree(
|
|
205
|
+
root: string,
|
|
206
|
+
sourceRoot: string,
|
|
207
|
+
retargetLinks: boolean,
|
|
148
208
|
signal: AbortSignal,
|
|
149
209
|
parentSignal: AbortSignal | undefined,
|
|
150
210
|
): Promise<void> {
|
|
@@ -179,16 +239,91 @@ async function validateCopiedTree(
|
|
|
179
239
|
}
|
|
180
240
|
if (!entry.isSymbolicLink()) continue;
|
|
181
241
|
const target = await fs.promises.readlink(candidate);
|
|
242
|
+
// `cp --archive` copies link text verbatim, so an absolute in-project
|
|
243
|
+
// link (bun/pnpm-style local package installs) still resolves to the
|
|
244
|
+
// real tree from inside the copy. Retarget it at its copied counterpart
|
|
245
|
+
// instead of failing: the link keeps working and stays disposable. The
|
|
246
|
+
// pre-check runs the same rule read-only against the source.
|
|
247
|
+
const relinked = relinkTargetIntoCopy(
|
|
248
|
+
candidate,
|
|
249
|
+
target,
|
|
250
|
+
root,
|
|
251
|
+
sourceRoot,
|
|
252
|
+
);
|
|
253
|
+
if (relinked !== undefined) {
|
|
254
|
+
if (!retargetLinks) continue;
|
|
255
|
+
try {
|
|
256
|
+
await replaceSymlink(candidate, relinked);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
throw new ScratchSetupError(
|
|
259
|
+
`Scratch workspace could not retarget in-project symlink '${path.relative(root, candidate)}' at its copied counterpart.`,
|
|
260
|
+
{ cause: error },
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
182
265
|
const resolvedTarget = path.resolve(path.dirname(candidate), target);
|
|
183
266
|
if (path.isAbsolute(target) || !isWithin(root, resolvedTarget)) {
|
|
184
267
|
throw new ScratchSetupError(
|
|
185
|
-
`Scratch workspace cannot safely copy symlink '${path.relative(root, candidate)}'
|
|
268
|
+
`Scratch workspace cannot safely copy symlink '${path.relative(root, candidate)}' -> '${target}': it resolves outside the disposable copy, so relative writes through it would reach the host. Remove the link, or run this task with workspace "shared" (read-only tools) or "isolated".`,
|
|
186
269
|
);
|
|
187
270
|
}
|
|
188
271
|
}
|
|
189
272
|
}
|
|
190
273
|
}
|
|
191
274
|
|
|
275
|
+
/** Reject Git metadata that redirects the worktree or git dirs outside `root`
|
|
276
|
+
* (core.worktree, commondir pointers, gitdir redirects). Runs against the
|
|
277
|
+
* source before copying and against the copy afterwards — a source that is
|
|
278
|
+
* self-consistent can still copy into a copy that points back at the source. */
|
|
279
|
+
async function assertGitMetadataContained(
|
|
280
|
+
root: string,
|
|
281
|
+
signal: AbortSignal,
|
|
282
|
+
): Promise<void> {
|
|
283
|
+
const hasGitDir = await fs.promises
|
|
284
|
+
.stat(path.join(root, ".git"))
|
|
285
|
+
.then((stat) => stat.isDirectory(), () => false);
|
|
286
|
+
if (!hasGitDir) return;
|
|
287
|
+
const effectiveWorktree = path.resolve(
|
|
288
|
+
(
|
|
289
|
+
await runFile("git", ["rev-parse", "--show-toplevel"], {
|
|
290
|
+
cwd: root,
|
|
291
|
+
signal,
|
|
292
|
+
timeout: 5000,
|
|
293
|
+
})
|
|
294
|
+
).trim(),
|
|
295
|
+
);
|
|
296
|
+
const effectiveGitDir = path.resolve(
|
|
297
|
+
root,
|
|
298
|
+
(
|
|
299
|
+
await runFile("git", ["rev-parse", "--absolute-git-dir"], {
|
|
300
|
+
cwd: root,
|
|
301
|
+
signal,
|
|
302
|
+
timeout: 5000,
|
|
303
|
+
})
|
|
304
|
+
).trim(),
|
|
305
|
+
);
|
|
306
|
+
const effectiveCommonDir = path.resolve(
|
|
307
|
+
effectiveGitDir,
|
|
308
|
+
(
|
|
309
|
+
await runFile("git", ["rev-parse", "--git-common-dir"], {
|
|
310
|
+
cwd: root,
|
|
311
|
+
signal,
|
|
312
|
+
timeout: 5000,
|
|
313
|
+
})
|
|
314
|
+
).trim(),
|
|
315
|
+
);
|
|
316
|
+
if (
|
|
317
|
+
effectiveWorktree !== root ||
|
|
318
|
+
!isWithin(root, effectiveGitDir) ||
|
|
319
|
+
!isWithin(root, effectiveCommonDir)
|
|
320
|
+
) {
|
|
321
|
+
throw new ScratchSetupError(
|
|
322
|
+
"Scratch workspace Git configuration redirects its worktree or metadata outside the copied project.",
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
192
327
|
function isWithin(root: string, candidate: string): boolean {
|
|
193
328
|
const relative = path.relative(root, candidate);
|
|
194
329
|
return (
|
|
@@ -666,6 +801,20 @@ export async function createScratchWorkspace(
|
|
|
666
801
|
);
|
|
667
802
|
}
|
|
668
803
|
|
|
804
|
+
// Fast-fail on blockers that are knowable before anything is created:
|
|
805
|
+
// the same rule the copy validation enforces, run read-only against the
|
|
806
|
+
// source. A rejection here costs milliseconds instead of a doomed
|
|
807
|
+
// reflink copy, and no lease/container is left behind. The post-copy
|
|
808
|
+
// validation below stays the authority — the tree can change mid-copy.
|
|
809
|
+
await validateScratchTree(
|
|
810
|
+
sourceRoot,
|
|
811
|
+
sourceRoot,
|
|
812
|
+
false,
|
|
813
|
+
controller.signal,
|
|
814
|
+
signal,
|
|
815
|
+
);
|
|
816
|
+
await assertGitMetadataContained(sourceRoot, controller.signal);
|
|
817
|
+
|
|
669
818
|
containerDir = path.join(path.dirname(sourceRoot), SCRATCH_CONTAINER_NAME);
|
|
670
819
|
const uid = process.getuid?.();
|
|
671
820
|
await ensureScratchContainer(containerDir, uid);
|
|
@@ -704,52 +853,14 @@ export async function createScratchWorkspace(
|
|
|
704
853
|
// GNU cp --archive applies the source root's mode to the destination.
|
|
705
854
|
// Restore the private boundary after it has finished copying metadata.
|
|
706
855
|
await fs.promises.chmod(scratchRoot, 0o700);
|
|
707
|
-
await
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
)
|
|
714
|
-
|
|
715
|
-
(
|
|
716
|
-
await runFile("git", ["rev-parse", "--show-toplevel"], {
|
|
717
|
-
cwd: scratchRoot,
|
|
718
|
-
signal: controller.signal,
|
|
719
|
-
timeout: 5000,
|
|
720
|
-
})
|
|
721
|
-
).trim(),
|
|
722
|
-
);
|
|
723
|
-
const effectiveGitDir = path.resolve(
|
|
724
|
-
scratchRoot,
|
|
725
|
-
(
|
|
726
|
-
await runFile("git", ["rev-parse", "--absolute-git-dir"], {
|
|
727
|
-
cwd: scratchRoot,
|
|
728
|
-
signal: controller.signal,
|
|
729
|
-
timeout: 5000,
|
|
730
|
-
})
|
|
731
|
-
).trim(),
|
|
732
|
-
);
|
|
733
|
-
const effectiveCommonDir = path.resolve(
|
|
734
|
-
effectiveGitDir,
|
|
735
|
-
(
|
|
736
|
-
await runFile("git", ["rev-parse", "--git-common-dir"], {
|
|
737
|
-
cwd: scratchRoot,
|
|
738
|
-
signal: controller.signal,
|
|
739
|
-
timeout: 5000,
|
|
740
|
-
})
|
|
741
|
-
).trim(),
|
|
742
|
-
);
|
|
743
|
-
if (
|
|
744
|
-
effectiveWorktree !== scratchRoot ||
|
|
745
|
-
!isWithin(scratchRoot, effectiveGitDir) ||
|
|
746
|
-
!isWithin(scratchRoot, effectiveCommonDir)
|
|
747
|
-
) {
|
|
748
|
-
throw new ScratchSetupError(
|
|
749
|
-
"Scratch workspace Git configuration redirects its worktree or metadata outside the copied project.",
|
|
750
|
-
);
|
|
751
|
-
}
|
|
752
|
-
}
|
|
856
|
+
await validateScratchTree(
|
|
857
|
+
scratchRoot,
|
|
858
|
+
sourceRoot,
|
|
859
|
+
true,
|
|
860
|
+
controller.signal,
|
|
861
|
+
signal,
|
|
862
|
+
);
|
|
863
|
+
await assertGitMetadataContained(scratchRoot, controller.signal);
|
|
753
864
|
throwIfSetupCancelled(controller.signal, signal);
|
|
754
865
|
// Keep the copied project writable, but make its private parent immutable
|
|
755
866
|
// to ordinary task commands. `mv "$PWD" …` then cannot unlink the project
|
|
@@ -766,7 +877,7 @@ export async function createScratchWorkspace(
|
|
|
766
877
|
} catch (error) {
|
|
767
878
|
if (leaseRoot) {
|
|
768
879
|
try {
|
|
769
|
-
await
|
|
880
|
+
await makeTreeRemovable(leaseRoot);
|
|
770
881
|
await fs.promises.rm(leaseRoot, { recursive: true, force: true });
|
|
771
882
|
} catch (cleanupError) {
|
|
772
883
|
console.error(
|
|
@@ -787,8 +898,17 @@ export async function createScratchWorkspace(
|
|
|
787
898
|
);
|
|
788
899
|
}
|
|
789
900
|
if (error instanceof ScratchSetupError) throw error;
|
|
901
|
+
// Surface the failed command's own stderr: the generic message's
|
|
902
|
+
// reflink guidance is a guess, and the real cp failure (or a btrfs
|
|
903
|
+
// project failing for an unrelated reason) is only visible in stderr.
|
|
904
|
+
let commandDetail = "";
|
|
905
|
+
if (error instanceof CommandError) {
|
|
906
|
+
const line = error.stderr.split("\n", 1)[0]?.trim();
|
|
907
|
+
if (line) commandDetail = ` ${error.file} failed: ${line}`;
|
|
908
|
+
}
|
|
790
909
|
throw new Error(
|
|
791
|
-
"Could not create a CoW scratch workspace. The project and scratch directory must be on a reflink-capable filesystem (for example Btrfs)."
|
|
910
|
+
"Could not create a CoW scratch workspace. The project and scratch directory must be on a reflink-capable filesystem (for example Btrfs)." +
|
|
911
|
+
commandDetail,
|
|
792
912
|
{ cause: error },
|
|
793
913
|
);
|
|
794
914
|
} finally {
|
|
@@ -913,13 +1033,18 @@ export async function createScratchWorkspace(
|
|
|
913
1033
|
readStableLink(source, sourceStat),
|
|
914
1034
|
]);
|
|
915
1035
|
// A readlink or identity race is not evidence that the nodes matched.
|
|
916
|
-
// Keep the lexical source path rather than dropping it.
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
1036
|
+
// Keep the lexical source path rather than dropping it. An in-project
|
|
1037
|
+
// absolute source link was deliberately retargeted at setup, so its
|
|
1038
|
+
// copied form matches that projection rather than the source text.
|
|
1039
|
+
if (scratchTarget !== undefined && sourceTarget !== undefined) {
|
|
1040
|
+
const expected =
|
|
1041
|
+
relinkTargetIntoCopy(
|
|
1042
|
+
lexical,
|
|
1043
|
+
sourceTarget,
|
|
1044
|
+
completedRoot,
|
|
1045
|
+
sourceRoot!,
|
|
1046
|
+
) ?? sourceTarget;
|
|
1047
|
+
if (scratchTarget === expected) return undefined;
|
|
923
1048
|
}
|
|
924
1049
|
}
|
|
925
1050
|
// This is evidence about the lexical node, not its current target.
|