@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/README.md +14 -17
- package/delegate.ts +0 -8
- package/dispatch.ts +24 -2
- package/extension.ts +14 -6
- package/file-tracking.ts +286 -15
- package/format.ts +79 -20
- package/isolated-workspace.ts +550 -62
- package/key-hints.ts +38 -0
- package/lifecycle.ts +555 -80
- package/manual.ts +1 -1
- package/model.ts +6 -1
- package/package.json +13 -9
- package/pool.ts +37 -6
- package/quiescence.ts +19 -6
- package/render-branches.ts +232 -85
- package/render-result.ts +116 -16
- package/runner.ts +361 -82
- package/session-quarantine.ts +266 -0
- package/spill.ts +93 -22
- package/task-resolution.ts +47 -48
- package/telemetry.ts +1 -1
- package/ticket-format.ts +8 -3
- package/tickets.ts +33 -13
- package/tools.ts +5 -4
- package/types.ts +77 -8
- package/utils.ts +123 -2
- package/workspace.ts +133 -0
- package/settings.ts +0 -418
package/isolated-workspace.ts
CHANGED
|
@@ -3,11 +3,28 @@ import * as crypto from "node:crypto";
|
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import * as os from "node:os";
|
|
5
5
|
import * as path from "node:path";
|
|
6
|
-
import
|
|
6
|
+
import { mapConcurrent } from "./concurrency.ts";
|
|
7
|
+
import { revalidateFileAttribution } from "./file-tracking.ts";
|
|
8
|
+
import {
|
|
9
|
+
observeQuarantineSafety,
|
|
10
|
+
sessionQuarantineOf,
|
|
11
|
+
} from "./session-quarantine.ts";
|
|
12
|
+
import type {
|
|
13
|
+
FileAttribution,
|
|
14
|
+
ResolvedTask,
|
|
15
|
+
TaskIntegration,
|
|
16
|
+
TaskResult,
|
|
17
|
+
} from "./types.ts";
|
|
7
18
|
|
|
8
19
|
const GIT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
20
|
+
const PATH_CLASSIFICATION_CONCURRENCY = 16;
|
|
9
21
|
const PROCESS_GRACE_MS = 500;
|
|
10
22
|
let artifactBaseOverrideForTesting: string | undefined;
|
|
23
|
+
let removeWorktreeHookForTesting:
|
|
24
|
+
| ((
|
|
25
|
+
destination: string,
|
|
26
|
+
) => boolean | undefined | Promise<boolean | undefined>)
|
|
27
|
+
| undefined;
|
|
11
28
|
|
|
12
29
|
/** @internal Keep tests out of the developer's real ~/.pi directory. */
|
|
13
30
|
export function _setIsolatedArtifactRootForTesting(
|
|
@@ -16,6 +33,17 @@ export function _setIsolatedArtifactRootForTesting(
|
|
|
16
33
|
artifactBaseOverrideForTesting = root;
|
|
17
34
|
}
|
|
18
35
|
|
|
36
|
+
/** @internal Deterministic failure/order seam for worktree cleanup tests. */
|
|
37
|
+
export function _setRemoveWorktreeHookForTesting(
|
|
38
|
+
hook:
|
|
39
|
+
| ((
|
|
40
|
+
destination: string,
|
|
41
|
+
) => boolean | undefined | Promise<boolean | undefined>)
|
|
42
|
+
| undefined,
|
|
43
|
+
): void {
|
|
44
|
+
removeWorktreeHookForTesting = hook;
|
|
45
|
+
}
|
|
46
|
+
|
|
19
47
|
interface CommandResult {
|
|
20
48
|
stdout: string;
|
|
21
49
|
stderr: string;
|
|
@@ -97,6 +125,344 @@ function isWithin(root: string, candidate: string): boolean {
|
|
|
97
125
|
);
|
|
98
126
|
}
|
|
99
127
|
|
|
128
|
+
function errnoOf(error: unknown): string {
|
|
129
|
+
return error instanceof Error && "code" in error
|
|
130
|
+
? String((error as NodeJS.ErrnoException).code ?? "UNKNOWN")
|
|
131
|
+
: "UNKNOWN";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function pathEntryExists(candidate: string): boolean {
|
|
135
|
+
try {
|
|
136
|
+
fs.lstatSync(candidate);
|
|
137
|
+
return true;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
const code = errnoOf(error);
|
|
140
|
+
if (code === "ENOENT" || code === "ENOTDIR") return false;
|
|
141
|
+
console.error(
|
|
142
|
+
`[delegate] could not verify isolated recovery path ${JSON.stringify(candidate)} (errno=${code}); treating it as retained`,
|
|
143
|
+
);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function logPathCanonicalizationFailure(
|
|
149
|
+
candidate: string,
|
|
150
|
+
error: unknown,
|
|
151
|
+
): void {
|
|
152
|
+
const safeCandidate = JSON.stringify(
|
|
153
|
+
candidate.length > 1_024
|
|
154
|
+
? `${candidate.slice(0, 1_024)}…[truncated ${candidate.length - 1_024} chars]`
|
|
155
|
+
: candidate,
|
|
156
|
+
)
|
|
157
|
+
.replace(/\u2028/g, "\\u2028")
|
|
158
|
+
.replace(/\u2029/g, "\\u2029");
|
|
159
|
+
console.error(
|
|
160
|
+
`[delegate] could not canonicalize reported path ${safeCandidate} (errno=${errnoOf(error)}); retaining uncertain attribution`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
interface PhysicalReportedPath {
|
|
165
|
+
path: string;
|
|
166
|
+
/** The path crossed a symlink that could not be fully resolved, or path
|
|
167
|
+
* resolution itself failed. Such attribution must be retained rather than
|
|
168
|
+
* assumed to be an ordinary path inside the disposable worktree. */
|
|
169
|
+
uncertain: boolean;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Resolve symlinked ancestors even when the reported leaf no longer exists.
|
|
173
|
+
* The final component is followed only for explicit edit/write attribution:
|
|
174
|
+
* Git may instead be reporting a symlink node that was itself added or changed.
|
|
175
|
+
* Resolution failures are data, not reconciliation failures; callers retain
|
|
176
|
+
* uncertain attribution conservatively. */
|
|
177
|
+
async function physicalReportedPath(
|
|
178
|
+
candidate: string,
|
|
179
|
+
followFinalSymlink: boolean,
|
|
180
|
+
): Promise<PhysicalReportedPath> {
|
|
181
|
+
let cursor = followFinalSymlink ? candidate : path.dirname(candidate);
|
|
182
|
+
const suffix = followFinalSymlink ? [] : [path.basename(candidate)];
|
|
183
|
+
|
|
184
|
+
for (;;) {
|
|
185
|
+
try {
|
|
186
|
+
const physical = await fs.promises.realpath(cursor);
|
|
187
|
+
return { path: path.resolve(physical, ...suffix), uncertain: false };
|
|
188
|
+
} catch (error) {
|
|
189
|
+
const code =
|
|
190
|
+
error instanceof Error && "code" in error
|
|
191
|
+
? (error as NodeJS.ErrnoException).code
|
|
192
|
+
: undefined;
|
|
193
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
|
194
|
+
logPathCanonicalizationFailure(candidate, error);
|
|
195
|
+
return { path: candidate, uncertain: true };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// realpath reports ENOENT for a dangling symlink. Before walking up to
|
|
199
|
+
// an existing parent (which would incorrectly make it look internal),
|
|
200
|
+
// preserve the symlink's intended target and mark it uncertain.
|
|
201
|
+
let stat: fs.Stats | undefined;
|
|
202
|
+
try {
|
|
203
|
+
stat = await fs.promises.lstat(cursor);
|
|
204
|
+
} catch (lstatError) {
|
|
205
|
+
const lstatCode =
|
|
206
|
+
lstatError instanceof Error && "code" in lstatError
|
|
207
|
+
? (lstatError as NodeJS.ErrnoException).code
|
|
208
|
+
: undefined;
|
|
209
|
+
if (lstatCode !== "ENOENT" && lstatCode !== "ENOTDIR") {
|
|
210
|
+
logPathCanonicalizationFailure(candidate, lstatError);
|
|
211
|
+
return { path: candidate, uncertain: true };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (stat?.isSymbolicLink()) {
|
|
215
|
+
try {
|
|
216
|
+
const target = await fs.promises.readlink(cursor);
|
|
217
|
+
const intended = path.resolve(
|
|
218
|
+
path.dirname(cursor),
|
|
219
|
+
target,
|
|
220
|
+
...suffix,
|
|
221
|
+
);
|
|
222
|
+
const resolved = await physicalReportedPath(intended, true);
|
|
223
|
+
return { path: resolved.path, uncertain: true };
|
|
224
|
+
} catch (error) {
|
|
225
|
+
const code = errnoOf(error);
|
|
226
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
|
227
|
+
logPathCanonicalizationFailure(candidate, error);
|
|
228
|
+
}
|
|
229
|
+
return { path: candidate, uncertain: true };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const parent = path.dirname(cursor);
|
|
234
|
+
if (parent === cursor) {
|
|
235
|
+
return { path: candidate, uncertain: true };
|
|
236
|
+
}
|
|
237
|
+
suffix.unshift(path.basename(cursor));
|
|
238
|
+
cursor = parent;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function mapReportedPathToSource(
|
|
244
|
+
group: IsolatedGroup,
|
|
245
|
+
physicalWorkerRoot: string,
|
|
246
|
+
candidate: string,
|
|
247
|
+
): string {
|
|
248
|
+
return isWithin(physicalWorkerRoot, candidate)
|
|
249
|
+
? path.join(group.sourceRoot, path.relative(physicalWorkerRoot, candidate))
|
|
250
|
+
: candidate;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function changedWorkerPaths(
|
|
254
|
+
workerRoot: string,
|
|
255
|
+
baselineCommit: string,
|
|
256
|
+
): Promise<Set<string> | undefined> {
|
|
257
|
+
try {
|
|
258
|
+
const [tracked, untracked] = await Promise.all([
|
|
259
|
+
git(["diff", "--name-only", "-z", "--no-renames", baselineCommit, "--"], {
|
|
260
|
+
cwd: workerRoot,
|
|
261
|
+
}),
|
|
262
|
+
git(["ls-files", "--others", "--exclude-standard", "-z"], {
|
|
263
|
+
cwd: workerRoot,
|
|
264
|
+
}),
|
|
265
|
+
]);
|
|
266
|
+
return new Set(
|
|
267
|
+
`${tracked.stdout}${untracked.stdout}`
|
|
268
|
+
.split("\0")
|
|
269
|
+
.filter(Boolean)
|
|
270
|
+
.map((relative) => path.resolve(workerRoot, relative)),
|
|
271
|
+
);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
// Reporting is conservative when Git evidence cannot be reconstructed, but
|
|
274
|
+
// the missing evidence must be visible and attributable to its worker.
|
|
275
|
+
console.error(
|
|
276
|
+
`[delegate] failed to collect isolated Git change evidence for worker '${workerRoot}'`,
|
|
277
|
+
error,
|
|
278
|
+
);
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function classifyReportedFiles(
|
|
284
|
+
group: IsolatedGroup,
|
|
285
|
+
worker: IsolatedWorker,
|
|
286
|
+
result: TaskResult,
|
|
287
|
+
): Promise<void> {
|
|
288
|
+
const physicalWorkerRoot = await fs.promises.realpath(worker.workerRoot);
|
|
289
|
+
const gitChanged = await changedWorkerPaths(
|
|
290
|
+
worker.workerRoot,
|
|
291
|
+
group.baselineCommit,
|
|
292
|
+
);
|
|
293
|
+
const structured = (result.fileAttributions ?? []).map(
|
|
294
|
+
revalidateFileAttribution,
|
|
295
|
+
);
|
|
296
|
+
const legacyCandidates = structured.length
|
|
297
|
+
? []
|
|
298
|
+
: [
|
|
299
|
+
...new Set(
|
|
300
|
+
(result.attributedFiles ?? []).map((candidate) =>
|
|
301
|
+
path.resolve(worker.cwd, candidate),
|
|
302
|
+
),
|
|
303
|
+
),
|
|
304
|
+
];
|
|
305
|
+
const attributionByLexical = new Map(
|
|
306
|
+
structured.map((entry) => [path.resolve(entry.lexicalPath), entry]),
|
|
307
|
+
);
|
|
308
|
+
const legacySet = new Set(legacyCandidates);
|
|
309
|
+
const distinctPhysicalSnapshots = new Set(
|
|
310
|
+
structured
|
|
311
|
+
.filter(
|
|
312
|
+
(entry) =>
|
|
313
|
+
entry.preExecutionPhysicalPath !== undefined &&
|
|
314
|
+
path.resolve(entry.preExecutionPhysicalPath) !==
|
|
315
|
+
path.resolve(entry.lexicalPath),
|
|
316
|
+
)
|
|
317
|
+
.map((entry) => path.resolve(entry.preExecutionPhysicalPath!)),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
// Trusted physical snapshots are immutable evidence: never resolve them
|
|
321
|
+
// against the final worker tree. The associated lexical path is used only to
|
|
322
|
+
// decide whether a symlink node itself changed. Legacy string-only evidence
|
|
323
|
+
// retains the older conservative final-view classification.
|
|
324
|
+
const candidates = [
|
|
325
|
+
...result.touchedFiles.map((candidate) => ({
|
|
326
|
+
kind: "touched" as const,
|
|
327
|
+
candidate,
|
|
328
|
+
})),
|
|
329
|
+
...structured.map((attribution) => ({
|
|
330
|
+
kind: "structured" as const,
|
|
331
|
+
attribution,
|
|
332
|
+
})),
|
|
333
|
+
...legacyCandidates.map((candidate) => ({
|
|
334
|
+
kind: "legacy" as const,
|
|
335
|
+
candidate,
|
|
336
|
+
})),
|
|
337
|
+
];
|
|
338
|
+
const classified = await mapConcurrent(
|
|
339
|
+
candidates,
|
|
340
|
+
PATH_CLASSIFICATION_CONCURRENCY,
|
|
341
|
+
async (entry) => {
|
|
342
|
+
if (entry.kind === "touched") {
|
|
343
|
+
const absolute = path.resolve(worker.cwd, entry.candidate);
|
|
344
|
+
if (distinctPhysicalSnapshots.has(absolute)) {
|
|
345
|
+
return {
|
|
346
|
+
kind: "touched" as const,
|
|
347
|
+
path: mapReportedPathToSource(group, physicalWorkerRoot, absolute),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
const associated =
|
|
351
|
+
attributionByLexical.get(absolute) ??
|
|
352
|
+
(legacySet.has(absolute) ? true : undefined);
|
|
353
|
+
const finalStat = await fs.promises.lstat(absolute).catch(() => null);
|
|
354
|
+
if (
|
|
355
|
+
associated &&
|
|
356
|
+
finalStat?.isSymbolicLink() &&
|
|
357
|
+
gitChanged !== undefined &&
|
|
358
|
+
!gitChanged.has(absolute)
|
|
359
|
+
) {
|
|
360
|
+
return { kind: "touched" as const, path: undefined };
|
|
361
|
+
}
|
|
362
|
+
const resolved = await physicalReportedPath(absolute, false);
|
|
363
|
+
return {
|
|
364
|
+
kind: "touched" as const,
|
|
365
|
+
path: mapReportedPathToSource(
|
|
366
|
+
group,
|
|
367
|
+
physicalWorkerRoot,
|
|
368
|
+
resolved.path,
|
|
369
|
+
),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (entry.kind === "structured") {
|
|
374
|
+
const evidence = entry.attribution;
|
|
375
|
+
const physical = evidence.preExecutionPhysicalPath;
|
|
376
|
+
const resolved = {
|
|
377
|
+
path: physical
|
|
378
|
+
? path.resolve(physical)
|
|
379
|
+
: path.resolve(evidence.lexicalPath),
|
|
380
|
+
uncertain: evidence.uncertain || physical === undefined,
|
|
381
|
+
};
|
|
382
|
+
return {
|
|
383
|
+
kind: "attributed" as const,
|
|
384
|
+
value: {
|
|
385
|
+
...resolved,
|
|
386
|
+
evidence,
|
|
387
|
+
reportedPath: mapReportedPathToSource(
|
|
388
|
+
group,
|
|
389
|
+
physicalWorkerRoot,
|
|
390
|
+
resolved.path,
|
|
391
|
+
),
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const resolved = await physicalReportedPath(entry.candidate, true);
|
|
397
|
+
return {
|
|
398
|
+
kind: "attributed" as const,
|
|
399
|
+
value: {
|
|
400
|
+
...resolved,
|
|
401
|
+
evidence: undefined,
|
|
402
|
+
reportedPath: mapReportedPathToSource(
|
|
403
|
+
group,
|
|
404
|
+
physicalWorkerRoot,
|
|
405
|
+
resolved.path,
|
|
406
|
+
),
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
},
|
|
410
|
+
);
|
|
411
|
+
const touched = classified
|
|
412
|
+
.filter(
|
|
413
|
+
(
|
|
414
|
+
entry,
|
|
415
|
+
): entry is Extract<(typeof classified)[number], { kind: "touched" }> =>
|
|
416
|
+
entry.kind === "touched",
|
|
417
|
+
)
|
|
418
|
+
.map((entry) => entry.path)
|
|
419
|
+
.filter((candidate): candidate is string => candidate !== undefined);
|
|
420
|
+
const attributed = classified
|
|
421
|
+
.filter(
|
|
422
|
+
(
|
|
423
|
+
entry,
|
|
424
|
+
): entry is Extract<
|
|
425
|
+
(typeof classified)[number],
|
|
426
|
+
{ kind: "attributed" }
|
|
427
|
+
> => entry.kind === "attributed",
|
|
428
|
+
)
|
|
429
|
+
.map((entry) => entry.value);
|
|
430
|
+
const escapedOrUncertain = attributed.filter(
|
|
431
|
+
(candidate) =>
|
|
432
|
+
candidate.uncertain || !isWithin(physicalWorkerRoot, candidate.path),
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
result.fileAttributions = escapedOrUncertain
|
|
436
|
+
.filter(
|
|
437
|
+
(
|
|
438
|
+
candidate,
|
|
439
|
+
): candidate is typeof candidate & { evidence: FileAttribution } =>
|
|
440
|
+
candidate.evidence !== undefined,
|
|
441
|
+
)
|
|
442
|
+
.map((candidate) => ({
|
|
443
|
+
...candidate.evidence,
|
|
444
|
+
lexicalPath: mapReportedPathToSource(
|
|
445
|
+
group,
|
|
446
|
+
physicalWorkerRoot,
|
|
447
|
+
path.resolve(candidate.evidence.lexicalPath),
|
|
448
|
+
),
|
|
449
|
+
preExecutionPhysicalPath:
|
|
450
|
+
candidate.evidence.preExecutionPhysicalPath === undefined
|
|
451
|
+
? undefined
|
|
452
|
+
: candidate.reportedPath,
|
|
453
|
+
uncertain: candidate.uncertain,
|
|
454
|
+
}));
|
|
455
|
+
result.attributedFiles = [
|
|
456
|
+
...new Set(escapedOrUncertain.map((candidate) => candidate.reportedPath)),
|
|
457
|
+
];
|
|
458
|
+
result.touchedFiles = [
|
|
459
|
+
...new Set([
|
|
460
|
+
...touched,
|
|
461
|
+
...escapedOrUncertain.map((candidate) => candidate.reportedPath),
|
|
462
|
+
]),
|
|
463
|
+
];
|
|
464
|
+
}
|
|
465
|
+
|
|
100
466
|
async function repositoryRoot(cwd: string): Promise<string> {
|
|
101
467
|
const physicalCwd = await fs.promises.realpath(cwd);
|
|
102
468
|
let root: string;
|
|
@@ -180,27 +546,21 @@ async function removeWorktree(
|
|
|
180
546
|
root: string,
|
|
181
547
|
destination: string,
|
|
182
548
|
): Promise<boolean> {
|
|
549
|
+
const override = await removeWorktreeHookForTesting?.(destination);
|
|
550
|
+
if (override !== undefined) return override;
|
|
183
551
|
try {
|
|
184
552
|
await git(["worktree", "remove", "--force", destination], { cwd: root });
|
|
185
|
-
return true;
|
|
553
|
+
if (!pathEntryExists(destination)) return true;
|
|
554
|
+
console.error(
|
|
555
|
+
`[delegate] Git reported isolated worktree removal success but the recovery path remains at ${JSON.stringify(destination)}`,
|
|
556
|
+
);
|
|
557
|
+
return false;
|
|
186
558
|
} catch (error) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
stillRegistered = listed.stdout
|
|
193
|
-
.split("\0")
|
|
194
|
-
.some(
|
|
195
|
-
(entry) =>
|
|
196
|
-
entry.startsWith("worktree ") &&
|
|
197
|
-
path.resolve(entry.slice("worktree ".length)) ===
|
|
198
|
-
path.resolve(destination),
|
|
199
|
-
);
|
|
200
|
-
} catch {
|
|
201
|
-
// Fail closed: if registration cannot be checked, preserve the files.
|
|
202
|
-
}
|
|
203
|
-
if (!stillRegistered) return true;
|
|
559
|
+
// The command can report failure after completing removal. Only treat that
|
|
560
|
+
// as success when the recovery path itself is definitely gone.
|
|
561
|
+
if (!pathEntryExists(destination)) return true;
|
|
562
|
+
// An unregistered directory is still recovery evidence. Do not silently
|
|
563
|
+
// call it removed or recursively delete it after Git declined the cleanup.
|
|
204
564
|
console.error(
|
|
205
565
|
`[delegate] failed to remove isolated worktree '${destination}'`,
|
|
206
566
|
error,
|
|
@@ -209,6 +569,16 @@ async function removeWorktree(
|
|
|
209
569
|
}
|
|
210
570
|
}
|
|
211
571
|
|
|
572
|
+
async function requireWorktreeRemoved(
|
|
573
|
+
root: string,
|
|
574
|
+
destination: string,
|
|
575
|
+
): Promise<void> {
|
|
576
|
+
if (await removeWorktree(root, destination)) return;
|
|
577
|
+
throw new Error(
|
|
578
|
+
`Could not remove isolated worktree; recovery workspace retained at ${JSON.stringify(destination)}.`,
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
212
582
|
async function changedFiles(
|
|
213
583
|
root: string,
|
|
214
584
|
from: string,
|
|
@@ -299,6 +669,25 @@ interface IsolatedGroup {
|
|
|
299
669
|
baselineCommit: string;
|
|
300
670
|
baselineRef: string;
|
|
301
671
|
taskIndexes: number[];
|
|
672
|
+
/** Deferred quarantine cleanup must not run Git worktree operations while the
|
|
673
|
+
* group is still reconciling candidates/source state. */
|
|
674
|
+
reconciliationDone: Promise<void>;
|
|
675
|
+
finishReconciliation: () => void;
|
|
676
|
+
deferredCleanupTail: Promise<void>;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function enqueueDeferredGroupCleanup(
|
|
680
|
+
group: IsolatedGroup,
|
|
681
|
+
cleanup: () => Promise<void>,
|
|
682
|
+
): Promise<void> {
|
|
683
|
+
const run = async () => {
|
|
684
|
+
await group.reconciliationDone;
|
|
685
|
+
await cleanup();
|
|
686
|
+
};
|
|
687
|
+
const queued = group.deferredCleanupTail.then(run, run);
|
|
688
|
+
// Keep the serialization chain usable after a surfaced callback failure.
|
|
689
|
+
group.deferredCleanupTail = queued.catch(() => {});
|
|
690
|
+
return queued;
|
|
302
691
|
}
|
|
303
692
|
|
|
304
693
|
interface IsolatedWorker {
|
|
@@ -357,33 +746,100 @@ async function reconcileGroup(
|
|
|
357
746
|
const worker = workers.get(taskIndex)!;
|
|
358
747
|
const result = results[taskIndex]!;
|
|
359
748
|
result.workspace = "isolated";
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
path.relative(worker.workerRoot, absolute),
|
|
366
|
-
)
|
|
367
|
-
: absolute;
|
|
368
|
-
});
|
|
369
|
-
// Writes inside the worktree did not touch the source concurrently.
|
|
370
|
-
// Preserve only explicitly attributed paths that escaped the worktree.
|
|
371
|
-
result.attributedFiles = (result.attributedFiles ?? [])
|
|
372
|
-
.map((candidate) => path.resolve(worker.cwd, candidate))
|
|
373
|
-
.filter((candidate) => !isWithin(worker.workerRoot, candidate));
|
|
374
|
-
if (result.error) {
|
|
749
|
+
|
|
750
|
+
const quarantine = sessionQuarantineOf(result);
|
|
751
|
+
if (quarantine) {
|
|
752
|
+
result.error ??=
|
|
753
|
+
"AgentSession quiescence was abandoned; the isolated proposal was not applied.";
|
|
375
754
|
result.integration = {
|
|
376
755
|
status: "discarded",
|
|
377
756
|
proposedFiles: [],
|
|
378
757
|
appliedFiles: [],
|
|
758
|
+
cleanupIssue: {
|
|
759
|
+
status: "deferred",
|
|
760
|
+
reason:
|
|
761
|
+
"Worker cleanup is deferred until background AgentSession safety is confirmed; the recovery path may be removed after safe cleanup.",
|
|
762
|
+
recoveryPath: worker.workerRoot,
|
|
763
|
+
},
|
|
379
764
|
};
|
|
380
|
-
|
|
765
|
+
console.error(
|
|
766
|
+
`[delegate] retaining abandoned isolated worker '${worker.workerRoot}' until its AgentSession is confirmed quiescent; proposal will not be snapshotted or applied`,
|
|
767
|
+
);
|
|
768
|
+
observeQuarantineSafety(
|
|
769
|
+
quarantine,
|
|
770
|
+
"deferred isolated worker cleanup",
|
|
771
|
+
async () =>
|
|
772
|
+
enqueueDeferredGroupCleanup(group, async () => {
|
|
773
|
+
try {
|
|
774
|
+
await stopWorkspaceProcesses(worker.workerRoot);
|
|
775
|
+
const removed = await removeWorktree(
|
|
776
|
+
group.sourceRoot,
|
|
777
|
+
worker.workerRoot,
|
|
778
|
+
);
|
|
779
|
+
if (removed) {
|
|
780
|
+
console.error(
|
|
781
|
+
`[delegate] safely cleaned deferred isolated worker '${worker.workerRoot}'`,
|
|
782
|
+
);
|
|
783
|
+
} else {
|
|
784
|
+
console.error(
|
|
785
|
+
`[delegate] deferred isolated worker cleanup could not remove '${worker.workerRoot}'; retaining it as recovery evidence`,
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
} catch (error) {
|
|
789
|
+
console.error(
|
|
790
|
+
`[delegate] deferred isolated worker cleanup failed for '${worker.workerRoot}'; retaining it`,
|
|
791
|
+
error,
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
}),
|
|
795
|
+
(error) => {
|
|
796
|
+
console.error(
|
|
797
|
+
`[delegate] isolated AgentSession safety monitor failed; retaining worker '${worker.workerRoot}' indefinitely`,
|
|
798
|
+
error,
|
|
799
|
+
);
|
|
800
|
+
},
|
|
801
|
+
);
|
|
381
802
|
continue;
|
|
382
803
|
}
|
|
383
804
|
|
|
384
805
|
let proposedFiles: string[] = [];
|
|
385
806
|
try {
|
|
807
|
+
// A child that outlived prompt quiescence can still race symlink
|
|
808
|
+
// inspection. Stop it before reporting paths even when its task failed.
|
|
386
809
|
await stopWorkspaceProcesses(worker.workerRoot);
|
|
810
|
+
|
|
811
|
+
if (result.error) {
|
|
812
|
+
let classificationIssues:
|
|
813
|
+
Array<{ path: string; reason: string }> | undefined;
|
|
814
|
+
try {
|
|
815
|
+
await classifyReportedFiles(group, worker, result);
|
|
816
|
+
} catch (error) {
|
|
817
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
818
|
+
classificationIssues = [{ path: "(attribution)", reason }];
|
|
819
|
+
console.error(
|
|
820
|
+
`[delegate] failed to classify paths for already-failed isolated worker '${worker.workerRoot}'; discarding and removing it without changing the task failure`,
|
|
821
|
+
error,
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
if (classificationIssues) {
|
|
825
|
+
// The original lists may contain worker-relative paths that become
|
|
826
|
+
// dangling as soon as the disposable worktree is removed. Never
|
|
827
|
+
// publish those stale paths after classification failed.
|
|
828
|
+
result.touchedFiles = [];
|
|
829
|
+
result.attributedFiles = [];
|
|
830
|
+
result.fileAttributions = [];
|
|
831
|
+
}
|
|
832
|
+
result.integration = {
|
|
833
|
+
status: "discarded",
|
|
834
|
+
proposedFiles: [],
|
|
835
|
+
appliedFiles: [],
|
|
836
|
+
...(classificationIssues ? { classificationIssues } : {}),
|
|
837
|
+
};
|
|
838
|
+
await requireWorktreeRemoved(group.sourceRoot, worker.workerRoot);
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
await classifyReportedFiles(group, worker, result);
|
|
387
843
|
const proposalTree = await snapshotTree(
|
|
388
844
|
worker.workerRoot,
|
|
389
845
|
group.baselineCommit,
|
|
@@ -409,7 +865,7 @@ async function reconcileGroup(
|
|
|
409
865
|
group.baselineCommit,
|
|
410
866
|
proposalCommit,
|
|
411
867
|
);
|
|
412
|
-
await
|
|
868
|
+
await requireWorktreeRemoved(group.sourceRoot, worker.workerRoot);
|
|
413
869
|
|
|
414
870
|
if (!proposedFiles.length) {
|
|
415
871
|
result.integration = {
|
|
@@ -444,7 +900,7 @@ async function reconcileGroup(
|
|
|
444
900
|
proposedFiles,
|
|
445
901
|
appliedFiles: proposedFiles,
|
|
446
902
|
};
|
|
447
|
-
await
|
|
903
|
+
await requireWorktreeRemoved(group.sourceRoot, candidateRoot);
|
|
448
904
|
} catch (error) {
|
|
449
905
|
const reason =
|
|
450
906
|
error instanceof GitCommandError
|
|
@@ -469,7 +925,7 @@ async function reconcileGroup(
|
|
|
469
925
|
worker.proposalRef,
|
|
470
926
|
);
|
|
471
927
|
const patchExists = fs.existsSync(worker.patchPath);
|
|
472
|
-
const worktreeExists =
|
|
928
|
+
const worktreeExists = pathEntryExists(worker.workerRoot);
|
|
473
929
|
result.integration = {
|
|
474
930
|
status: "apply_failed",
|
|
475
931
|
proposedFiles,
|
|
@@ -489,7 +945,7 @@ async function reconcileGroup(
|
|
|
489
945
|
}
|
|
490
946
|
|
|
491
947
|
if (integratedCommit === group.baselineCommit) {
|
|
492
|
-
await
|
|
948
|
+
await requireWorktreeRemoved(group.sourceRoot, pristineRoot);
|
|
493
949
|
return;
|
|
494
950
|
}
|
|
495
951
|
|
|
@@ -523,7 +979,7 @@ async function reconcileGroup(
|
|
|
523
979
|
patchPath: worker.patchPath,
|
|
524
980
|
};
|
|
525
981
|
}
|
|
526
|
-
await
|
|
982
|
+
await requireWorktreeRemoved(group.sourceRoot, pristineRoot);
|
|
527
983
|
return;
|
|
528
984
|
}
|
|
529
985
|
|
|
@@ -595,7 +1051,27 @@ async function reconcileGroup(
|
|
|
595
1051
|
return;
|
|
596
1052
|
}
|
|
597
1053
|
|
|
598
|
-
|
|
1054
|
+
try {
|
|
1055
|
+
await requireWorktreeRemoved(group.sourceRoot, pristineRoot);
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1058
|
+
// Source application has already succeeded. Cleanup failure must not
|
|
1059
|
+
// rewrite that durable outcome as apply_failed; publish recovery metadata
|
|
1060
|
+
// separately before the result is delivered.
|
|
1061
|
+
for (const [taskIndex] of accepted) {
|
|
1062
|
+
const integration = results[taskIndex]!.integration;
|
|
1063
|
+
if (integration?.status !== "applied_unverified") continue;
|
|
1064
|
+
integration.cleanupIssue = {
|
|
1065
|
+
status: "failed",
|
|
1066
|
+
reason,
|
|
1067
|
+
recoveryPath: pristineRoot,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
console.error(
|
|
1071
|
+
`[delegate] applied isolated changes, but failed to remove recovery worktree '${pristineRoot}'; applied_unverified remains authoritative`,
|
|
1072
|
+
error,
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
599
1075
|
}
|
|
600
1076
|
|
|
601
1077
|
async function deletePrivateRefs(
|
|
@@ -637,14 +1113,7 @@ async function markGroupReconciliationFailure(
|
|
|
637
1113
|
for (const taskIndex of group.taskIndexes) {
|
|
638
1114
|
const result = results[taskIndex]!;
|
|
639
1115
|
const current = result.integration;
|
|
640
|
-
if (
|
|
641
|
-
current?.status === "conflict" ||
|
|
642
|
-
current?.status === "apply_failed" ||
|
|
643
|
-
current?.status === "no_changes" ||
|
|
644
|
-
current?.status === "discarded"
|
|
645
|
-
) {
|
|
646
|
-
continue;
|
|
647
|
-
}
|
|
1116
|
+
if (current?.status === "conflict") continue;
|
|
648
1117
|
|
|
649
1118
|
const worker = workers.get(taskIndex)!;
|
|
650
1119
|
const proposalExists = await privateRefExists(
|
|
@@ -652,11 +1121,12 @@ async function markGroupReconciliationFailure(
|
|
|
652
1121
|
worker.proposalRef,
|
|
653
1122
|
);
|
|
654
1123
|
const patchExists = fs.existsSync(worker.patchPath);
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
1124
|
+
let recoveryWorktree: string | undefined;
|
|
1125
|
+
if (pathEntryExists(worker.workerRoot)) {
|
|
1126
|
+
recoveryWorktree = worker.workerRoot;
|
|
1127
|
+
} else if (pathEntryExists(pristineRoot)) {
|
|
1128
|
+
recoveryWorktree = pristineRoot;
|
|
1129
|
+
}
|
|
660
1130
|
result.workspace = "isolated";
|
|
661
1131
|
result.integration = {
|
|
662
1132
|
status: "apply_failed",
|
|
@@ -767,6 +1237,10 @@ export async function prepareIsolatedBatch(
|
|
|
767
1237
|
cwd: sourceRoot,
|
|
768
1238
|
signal,
|
|
769
1239
|
});
|
|
1240
|
+
let finishReconciliation!: () => void;
|
|
1241
|
+
const reconciliationDone = new Promise<void>((resolve) => {
|
|
1242
|
+
finishReconciliation = resolve;
|
|
1243
|
+
});
|
|
770
1244
|
group = {
|
|
771
1245
|
sourceRoot,
|
|
772
1246
|
sourceHead,
|
|
@@ -774,6 +1248,9 @@ export async function prepareIsolatedBatch(
|
|
|
774
1248
|
baselineCommit,
|
|
775
1249
|
baselineRef,
|
|
776
1250
|
taskIndexes: [],
|
|
1251
|
+
reconciliationDone,
|
|
1252
|
+
finishReconciliation,
|
|
1253
|
+
deferredCleanupTail: Promise.resolve(),
|
|
777
1254
|
};
|
|
778
1255
|
groupsByRoot.set(sourceRoot, group);
|
|
779
1256
|
}
|
|
@@ -841,15 +1318,26 @@ export async function prepareIsolatedBatch(
|
|
|
841
1318
|
async reconcile(results: TaskResult[]): Promise<TaskResult[]> {
|
|
842
1319
|
for (const group of groupsByRoot.values()) {
|
|
843
1320
|
try {
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
1321
|
+
try {
|
|
1322
|
+
await reconcileGroup(group, workers, results);
|
|
1323
|
+
} catch (error) {
|
|
1324
|
+
console.error(
|
|
1325
|
+
"[delegate] isolated group reconciliation failed",
|
|
1326
|
+
error,
|
|
1327
|
+
);
|
|
1328
|
+
await markGroupReconciliationFailure(
|
|
1329
|
+
group,
|
|
1330
|
+
workers,
|
|
1331
|
+
results,
|
|
1332
|
+
error,
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
await cleanupCompletedGroupRefs(group, workers, results);
|
|
1336
|
+
} finally {
|
|
1337
|
+
// Unblocks and serializes any safety-confirmed quarantine cleanups
|
|
1338
|
+
// only after all candidate/source/ref work for this group is done.
|
|
1339
|
+
group.finishReconciliation();
|
|
851
1340
|
}
|
|
852
|
-
await cleanupCompletedGroupRefs(group, workers, results);
|
|
853
1341
|
}
|
|
854
1342
|
return results;
|
|
855
1343
|
},
|