@bermudi/pi-delegate 0.1.18 → 0.1.20
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 +64 -15
- package/agents.ts +1 -1
- package/assistant-preview.ts +31 -0
- package/browser-state.ts +250 -0
- package/browser.ts +334 -0
- package/concurrency.ts +7 -0
- package/delegate.ts +8 -0
- package/dispatch.ts +512 -163
- package/extension.ts +65 -28
- package/format.ts +27 -9
- package/host.ts +1 -1
- package/isolated-workspace.ts +154 -8
- package/lifecycle.ts +34 -20
- package/manual.ts +21 -8
- package/package.json +2 -1
- package/parent-context.ts +1 -1
- package/pause.ts +81 -0
- package/pool.ts +492 -428
- package/render-branches.ts +19 -5
- package/render-result.ts +7 -0
- package/runner.ts +55 -1
- package/runtime.ts +36 -0
- package/schema.ts +29 -18
- package/status.ts +38 -11
- package/task-resolution.ts +12 -9
- package/test-harness.ts +81 -0
- package/ticket-format.ts +29 -7
- package/tickets.ts +724 -576
- package/types.ts +33 -0
- package/workspace.ts +58 -27
package/extension.ts
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
handlePoll,
|
|
5
|
-
handleWait,
|
|
6
|
-
cancelTicketForShutdown,
|
|
7
|
-
ticketRegistry,
|
|
8
|
-
} from "./tickets.ts";
|
|
2
|
+
import { registerSubagentBrowser } from "./browser.ts";
|
|
3
|
+
import { getDefaultDelegateRuntime, type DelegateRuntime } from "./runtime.ts";
|
|
9
4
|
import { discoverAgents } from "./agents.ts";
|
|
10
5
|
import { getSubagentManualMarkdown } from "./manual.ts";
|
|
11
6
|
import {
|
|
@@ -25,7 +20,6 @@ import { hostCompatError } from "./host-compat.ts";
|
|
|
25
20
|
import { invalidateHostDepsCache } from "./host.ts";
|
|
26
21
|
import { registerProviderExtensionNotifier } from "./provider-extensions.ts";
|
|
27
22
|
import { recordTreeNavigation, resetLeafTracking } from "./leaf.ts";
|
|
28
|
-
import { closeAllPooledAgents } from "./pool.ts";
|
|
29
23
|
import { reconfigureGlobalConcurrency } from "./concurrency.ts";
|
|
30
24
|
import { reloadDelegateConfig, getMaxConcurrent } from "./config.ts";
|
|
31
25
|
import {
|
|
@@ -130,12 +124,20 @@ export function _setShutdownDrainTimeoutForTesting(
|
|
|
130
124
|
shutdownDrainTimeoutMs = timeoutMs ?? DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS;
|
|
131
125
|
}
|
|
132
126
|
|
|
133
|
-
/** Register the delegate tool and clean up its parent-session resources.
|
|
134
|
-
|
|
127
|
+
/** Register the delegate tool and clean up its parent-session resources.
|
|
128
|
+
*
|
|
129
|
+
* Production uses the module default runtime. Tests/embedders may pass a
|
|
130
|
+
* fresh runtime as the second argument so all tool execution, status, and
|
|
131
|
+
* shutdown operate in an isolated pool/ticket environment. */
|
|
132
|
+
export default function delegateExtension(
|
|
133
|
+
pi: ExtensionAPI,
|
|
134
|
+
runtime: DelegateRuntime = getDefaultDelegateRuntime(),
|
|
135
|
+
): void {
|
|
135
136
|
// A /reload can reuse this module instance after the previous runtime closed
|
|
136
137
|
// its SQLite handle. Permit the new runtime to open a fresh backend; stale
|
|
137
138
|
// workers from the old runtime remain blocked from reopening it.
|
|
138
139
|
prepareTelemetryForSession();
|
|
140
|
+
const browserHistory = registerSubagentBrowser(pi, runtime);
|
|
139
141
|
|
|
140
142
|
// Async completion arrives as a custom message after the original tool call
|
|
141
143
|
// has returned. Give it the same compact/expanded UI as sync results while
|
|
@@ -156,6 +158,8 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
156
158
|
prepareArguments: normalizeDelegateArguments,
|
|
157
159
|
|
|
158
160
|
async execute(_id, params: DelegateArguments, signal, onUpdate, ctx) {
|
|
161
|
+
const browserGeneration = browserHistory.generation;
|
|
162
|
+
const captureBrowser = ctx.mode === "tui" && !params.async;
|
|
159
163
|
// Reload user-edited delegate.json at the start of every execution.
|
|
160
164
|
// Help, poll, cancel, wait, and invalid calls observe new settings, and
|
|
161
165
|
// the global concurrency cap is reconfigured so hot-reloaded maxConcurrent
|
|
@@ -237,17 +241,27 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
237
241
|
|
|
238
242
|
// ── Poll action ───────────────────────────────────────────────────
|
|
239
243
|
if (params.ticketAction === "poll") {
|
|
240
|
-
const result = handlePoll(params, ctx);
|
|
244
|
+
const result = runtime.tickets.handlePoll(params, ctx);
|
|
241
245
|
succeedCall();
|
|
242
246
|
return result;
|
|
243
247
|
}
|
|
244
248
|
|
|
245
249
|
// ── Cancel action ─────────────────────────────────────────────────
|
|
250
|
+
if (params.ticketAction === "pause" || params.ticketAction === "resume") {
|
|
251
|
+
const result = runtime.tickets.handlePause({
|
|
252
|
+
ticket: params.ticket,
|
|
253
|
+
ticketAction: params.ticketAction,
|
|
254
|
+
});
|
|
255
|
+
syncDelegateStatus(ctx, runtime);
|
|
256
|
+
succeedCall();
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
|
|
246
260
|
if (params.ticketAction === "cancel") {
|
|
247
|
-
const result = handleCancel(params);
|
|
261
|
+
const result = runtime.tickets.handleCancel(params);
|
|
248
262
|
// A forced cancel flips the ticket to "cancelling" — keep the
|
|
249
263
|
// footer status in step (deduped; the preview path is a no-op).
|
|
250
|
-
syncDelegateStatus(ctx);
|
|
264
|
+
syncDelegateStatus(ctx, runtime);
|
|
251
265
|
succeedCall();
|
|
252
266
|
return result;
|
|
253
267
|
}
|
|
@@ -255,7 +269,12 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
255
269
|
// ── Wait action ────────────────────────────────────────────────────
|
|
256
270
|
if (params.ticketAction === "wait") {
|
|
257
271
|
try {
|
|
258
|
-
const result = await handleWait(
|
|
272
|
+
const result = await runtime.tickets.handleWait(
|
|
273
|
+
params,
|
|
274
|
+
signal,
|
|
275
|
+
onUpdate,
|
|
276
|
+
ctx,
|
|
277
|
+
);
|
|
259
278
|
succeedCall();
|
|
260
279
|
return result;
|
|
261
280
|
} catch (err) {
|
|
@@ -283,7 +302,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
283
302
|
invalidateHostDepsCache();
|
|
284
303
|
// Keep the footer-status pipeline in step exactly as a normal
|
|
285
304
|
// dispatch would (deduped no-op when nothing is running).
|
|
286
|
-
syncDelegateStatus(ctx);
|
|
305
|
+
syncDelegateStatus(ctx, runtime);
|
|
287
306
|
return await dispatchDelegate({
|
|
288
307
|
pi,
|
|
289
308
|
params: { ...params, tasks: [bridgeSessionControlTask(params)] },
|
|
@@ -297,6 +316,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
297
316
|
signal,
|
|
298
317
|
onUpdate,
|
|
299
318
|
callSpan,
|
|
319
|
+
runtime,
|
|
300
320
|
});
|
|
301
321
|
}
|
|
302
322
|
|
|
@@ -318,14 +338,14 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
318
338
|
// dispatch narrows it to DelegateToolCtx (which has no `ui`). The
|
|
319
339
|
// status push itself is a deduped no-op here; dispatchAsync re-syncs
|
|
320
340
|
// after registering its ticket.
|
|
321
|
-
syncDelegateStatus(ctx);
|
|
341
|
+
syncDelegateStatus(ctx, runtime);
|
|
322
342
|
|
|
323
343
|
// Keep expensive host deps shared within this dispatch, not indefinitely
|
|
324
344
|
// across dispatches: edits to auth/models/settings/context files must be
|
|
325
345
|
// visible without restarting Pi.
|
|
326
346
|
invalidateHostDepsCache();
|
|
327
347
|
try {
|
|
328
|
-
|
|
348
|
+
const result = await dispatchDelegate({
|
|
329
349
|
pi,
|
|
330
350
|
params,
|
|
331
351
|
ctx,
|
|
@@ -336,10 +356,25 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
336
356
|
tools: pi.getActiveTools(),
|
|
337
357
|
},
|
|
338
358
|
signal,
|
|
339
|
-
onUpdate
|
|
359
|
+
onUpdate: captureBrowser
|
|
360
|
+
? (update) => {
|
|
361
|
+
browserHistory.update(
|
|
362
|
+
_id,
|
|
363
|
+
update.details,
|
|
364
|
+
false,
|
|
365
|
+
browserGeneration,
|
|
366
|
+
);
|
|
367
|
+
onUpdate?.(update);
|
|
368
|
+
}
|
|
369
|
+
: onUpdate,
|
|
340
370
|
callSpan,
|
|
371
|
+
runtime,
|
|
341
372
|
});
|
|
373
|
+
if (captureBrowser)
|
|
374
|
+
browserHistory.update(_id, result.details, true, browserGeneration);
|
|
375
|
+
return result;
|
|
342
376
|
} catch (err) {
|
|
377
|
+
if (captureBrowser) browserHistory.fail(_id, err, browserGeneration);
|
|
343
378
|
failCall();
|
|
344
379
|
throw err;
|
|
345
380
|
}
|
|
@@ -360,15 +395,15 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
360
395
|
// The turn settling with live tickets is the "looks idle but isn't" moment:
|
|
361
396
|
// warn once per ticket. The footer status carries it from there.
|
|
362
397
|
pi.on("agent_settled", (_event, ctx) => {
|
|
363
|
-
notifyActiveTicketsOnSettled(ctx);
|
|
398
|
+
notifyActiveTicketsOnSettled(ctx, runtime);
|
|
364
399
|
});
|
|
365
400
|
|
|
366
401
|
// Session replacements are cancellable — confirm before killing live work.
|
|
367
402
|
pi.on("session_before_switch", (_event, ctx) =>
|
|
368
|
-
guardSessionReplacement(ctx, "switch"),
|
|
403
|
+
guardSessionReplacement(ctx, "switch", runtime),
|
|
369
404
|
);
|
|
370
405
|
pi.on("session_before_fork", (_event, ctx) =>
|
|
371
|
-
guardSessionReplacement(ctx, "fork"),
|
|
406
|
+
guardSessionReplacement(ctx, "fork", runtime),
|
|
372
407
|
);
|
|
373
408
|
|
|
374
409
|
// /tree navigation stays inside the same session: nothing is torn down and
|
|
@@ -376,10 +411,12 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
376
411
|
// user moves to. Ask first, and record the new leaf either way so delivery
|
|
377
412
|
// can detect the mismatch (issue #30). `session_tree` also fires for
|
|
378
413
|
// extension-driven ctx.navigateTree, which never reaches the guard.
|
|
379
|
-
pi.on("session_before_tree", (_event, ctx) =>
|
|
414
|
+
pi.on("session_before_tree", (_event, ctx) =>
|
|
415
|
+
guardTreeNavigation(ctx, runtime),
|
|
416
|
+
);
|
|
380
417
|
pi.on("session_tree", (event, ctx) => {
|
|
381
418
|
recordTreeNavigation(event.newLeafId);
|
|
382
|
-
syncDelegateStatus(ctx);
|
|
419
|
+
syncDelegateStatus(ctx, runtime);
|
|
383
420
|
});
|
|
384
421
|
|
|
385
422
|
// ── Session shutdown: abort tickets and dispose live pooled sessions ──
|
|
@@ -395,7 +432,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
395
432
|
// leave a trace. For quit the TUI is already stopped — stderr lands in
|
|
396
433
|
// the scrollback. For reload the TUI survives — warn in place. Switch
|
|
397
434
|
// and fork already passed the confirm guard above.
|
|
398
|
-
const active = activeTicketSummary();
|
|
435
|
+
const active = activeTicketSummary(runtime);
|
|
399
436
|
if (active.tickets.length) {
|
|
400
437
|
if (event.reason === "quit") {
|
|
401
438
|
console.error(
|
|
@@ -416,15 +453,15 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
416
453
|
}
|
|
417
454
|
}
|
|
418
455
|
|
|
419
|
-
for (const ticket of
|
|
456
|
+
for (const ticket of runtime.tickets.values()) {
|
|
420
457
|
if (ticket.status === "running" || ticket.status === "cancelling") {
|
|
421
|
-
cancelTicketForShutdown(ticket);
|
|
458
|
+
runtime.tickets.cancelTicketForShutdown(ticket);
|
|
422
459
|
}
|
|
423
460
|
// Include already-cancelled tickets too: a repeated shutdown event can
|
|
424
461
|
// race the first handler while its workers are still unwinding.
|
|
425
462
|
if (ticket.completion) ticketCompletions.push(ticket.completion);
|
|
426
463
|
}
|
|
427
|
-
syncDelegateStatus(ctx);
|
|
464
|
+
syncDelegateStatus(ctx, runtime);
|
|
428
465
|
// The runtime is invalidated right after this handler returns; aborted
|
|
429
466
|
// tickets keep unwinding asynchronously and must find no cached ctx (or
|
|
430
467
|
// captured pi) to touch. The cancelled completion path still writes one
|
|
@@ -443,7 +480,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
443
480
|
// SQLite still stays open until both cleanup paths finish.
|
|
444
481
|
let poolCleanup: Promise<void>;
|
|
445
482
|
try {
|
|
446
|
-
poolCleanup = closeAllPooledAgents();
|
|
483
|
+
poolCleanup = runtime.pool.closeAllPooledAgents();
|
|
447
484
|
} catch (error) {
|
|
448
485
|
console.error("[delegate] pooled-session shutdown start failed", error);
|
|
449
486
|
poolCleanup = Promise.resolve();
|
package/format.ts
CHANGED
|
@@ -530,7 +530,10 @@ export function formatCompletedTask(
|
|
|
530
530
|
if (integration.patchPath)
|
|
531
531
|
parts.push(`full patch: ${integration.patchPath}`);
|
|
532
532
|
if (integration.worktreePath)
|
|
533
|
-
parts.push(`
|
|
533
|
+
parts.push(`recovery worktree: ${integration.worktreePath}`);
|
|
534
|
+
if (integration.status === "retained") {
|
|
535
|
+
parts.push(`not applied: ${integration.reason}`);
|
|
536
|
+
}
|
|
534
537
|
for (const conflict of integration.conflicts ?? []) {
|
|
535
538
|
parts.push(`conflict: ${conflict.path}: ${conflict.reason}`);
|
|
536
539
|
}
|
|
@@ -583,6 +586,7 @@ export function latestActivity(p: TaskProgress): ToolActivity | null {
|
|
|
583
586
|
* - "last: read src/bar.ts" after a tool completes and the model is thinking
|
|
584
587
|
* - "thinking" when no activity has been recorded yet */
|
|
585
588
|
export function formatActivityLabel(p: TaskProgress): string {
|
|
589
|
+
if (p.paused) return "paused between turns";
|
|
586
590
|
const activity = inFlightActivity(p) ?? latestActivity(p);
|
|
587
591
|
if (!activity) return "thinking";
|
|
588
592
|
const call = sanitizeTerminalLine(
|
|
@@ -596,6 +600,7 @@ export function formatActivityLabel(p: TaskProgress): string {
|
|
|
596
600
|
* as {@link formatActivityLabel} but adds elapsed time for in-flight tools and
|
|
597
601
|
* a completion/error icon for finished ones. */
|
|
598
602
|
export function compactActivity(p: TaskProgress): string {
|
|
603
|
+
if (p.paused) return "paused between turns";
|
|
599
604
|
const activity = inFlightActivity(p) ?? latestActivity(p);
|
|
600
605
|
if (!activity) return "thinking…";
|
|
601
606
|
const call = sanitizeTerminalLine(
|
|
@@ -653,18 +658,31 @@ export function findTouchedOverlaps(
|
|
|
653
658
|
results: readonly {
|
|
654
659
|
attributedFiles?: string[];
|
|
655
660
|
workspace?: WorkspaceMode;
|
|
661
|
+
serializedGroup?: number;
|
|
662
|
+
incomplete?: string;
|
|
656
663
|
}[],
|
|
657
664
|
): string[] {
|
|
658
|
-
const
|
|
665
|
+
const owners = new Map<string, (typeof results)[number][]>();
|
|
666
|
+
const overlaps = new Set<string>();
|
|
659
667
|
for (const r of results) {
|
|
660
|
-
for (const f of r.attributedFiles ?? []) {
|
|
661
|
-
|
|
668
|
+
for (const f of new Set(r.attributedFiles ?? [])) {
|
|
669
|
+
const previous = owners.get(f) ?? [];
|
|
670
|
+
if (
|
|
671
|
+
previous.some(
|
|
672
|
+
(other) =>
|
|
673
|
+
r.serializedGroup === undefined ||
|
|
674
|
+
r.serializedGroup !== other.serializedGroup ||
|
|
675
|
+
r.incomplete !== undefined ||
|
|
676
|
+
other.incomplete !== undefined,
|
|
677
|
+
)
|
|
678
|
+
) {
|
|
679
|
+
overlaps.add(f);
|
|
680
|
+
}
|
|
681
|
+
previous.push(r);
|
|
682
|
+
owners.set(f, previous);
|
|
662
683
|
}
|
|
663
684
|
}
|
|
664
|
-
return [...
|
|
665
|
-
.filter(([, count]) => count > 1)
|
|
666
|
-
.map(([file]) => file)
|
|
667
|
-
.sort();
|
|
685
|
+
return [...overlaps].sort();
|
|
668
686
|
}
|
|
669
687
|
|
|
670
688
|
/**
|
|
@@ -677,5 +695,5 @@ export function findTouchedOverlaps(
|
|
|
677
695
|
*/
|
|
678
696
|
export function formatTouchedOverlapWarning(overlaps: string[]): string | null {
|
|
679
697
|
if (!overlaps.length) return null;
|
|
680
|
-
return `WARNING:
|
|
698
|
+
return `WARNING: Tasks without a verified ordering reported touching the same file(s): ${overlaps.join(", ")}. File reports do not prove simultaneous writes or a conflict; completed writes are not rolled back.`;
|
|
681
699
|
}
|
package/host.ts
CHANGED
|
@@ -85,7 +85,7 @@ export interface HostDepsOptions {
|
|
|
85
85
|
* host deps are cached per (agentDir + cwd + systemPrompt): the expensive
|
|
86
86
|
* `reload()` (skills, project AGENTS.md discovery) runs once per distinct combo, then
|
|
87
87
|
* is reused across concurrent subagents. Provider-configured or
|
|
88
|
-
* allowlisted-extension tasks always receive fresh host deps. For
|
|
88
|
+
* allowlisted-extension tasks always receive fresh host deps. For inline
|
|
89
89
|
* tasks (no named agent) pass undefined to use the discovered prompt.
|
|
90
90
|
*/
|
|
91
91
|
systemPrompt?: string;
|
package/isolated-workspace.ts
CHANGED
|
@@ -25,6 +25,7 @@ let removeWorktreeHookForTesting:
|
|
|
25
25
|
destination: string,
|
|
26
26
|
) => boolean | undefined | Promise<boolean | undefined>)
|
|
27
27
|
| undefined;
|
|
28
|
+
let beforeSourceApplyHookForTesting: (() => void | Promise<void>) | undefined;
|
|
28
29
|
|
|
29
30
|
/** @internal Keep tests out of the developer's real ~/.pi directory. */
|
|
30
31
|
export function _setIsolatedArtifactRootForTesting(
|
|
@@ -44,6 +45,12 @@ export function _setRemoveWorktreeHookForTesting(
|
|
|
44
45
|
removeWorktreeHookForTesting = hook;
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
export function _setBeforeSourceApplyHookForTesting(
|
|
49
|
+
hook: (() => void | Promise<void>) | undefined,
|
|
50
|
+
): void {
|
|
51
|
+
beforeSourceApplyHookForTesting = hook;
|
|
52
|
+
}
|
|
53
|
+
|
|
47
54
|
interface CommandResult {
|
|
48
55
|
stdout: string;
|
|
49
56
|
stderr: string;
|
|
@@ -698,9 +705,18 @@ interface IsolatedWorker {
|
|
|
698
705
|
patchPath: string;
|
|
699
706
|
}
|
|
700
707
|
|
|
708
|
+
export interface IsolatedReconcileOptions {
|
|
709
|
+
shouldApplySource?: () => boolean;
|
|
710
|
+
retainedReason?: string;
|
|
711
|
+
signal?: AbortSignal;
|
|
712
|
+
}
|
|
713
|
+
|
|
701
714
|
export interface PreparedIsolatedBatch {
|
|
702
715
|
resolved: ResolvedTask[];
|
|
703
|
-
reconcile(
|
|
716
|
+
reconcile(
|
|
717
|
+
results: TaskResult[],
|
|
718
|
+
options?: IsolatedReconcileOptions,
|
|
719
|
+
): Promise<TaskResult[]>;
|
|
704
720
|
}
|
|
705
721
|
|
|
706
722
|
async function restoreAfterFailedApply(
|
|
@@ -732,10 +748,45 @@ async function restoreAfterFailedApply(
|
|
|
732
748
|
}
|
|
733
749
|
}
|
|
734
750
|
|
|
751
|
+
async function retainAcceptedProposals(
|
|
752
|
+
group: IsolatedGroup,
|
|
753
|
+
workers: Map<number, IsolatedWorker>,
|
|
754
|
+
results: TaskResult[],
|
|
755
|
+
accepted: Map<number, string[]>,
|
|
756
|
+
pristineRoot: string,
|
|
757
|
+
reason: string,
|
|
758
|
+
): Promise<void> {
|
|
759
|
+
let cleanupIssue:
|
|
760
|
+
{ status: "failed"; reason: string; recoveryPath: string } | undefined;
|
|
761
|
+
try {
|
|
762
|
+
await requireWorktreeRemoved(group.sourceRoot, pristineRoot);
|
|
763
|
+
} catch (error) {
|
|
764
|
+
cleanupIssue = {
|
|
765
|
+
status: "failed",
|
|
766
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
767
|
+
recoveryPath: pristineRoot,
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
for (const [taskIndex, proposedFiles] of accepted) {
|
|
771
|
+
const worker = workers.get(taskIndex)!;
|
|
772
|
+
results[taskIndex]!.integration = {
|
|
773
|
+
status: "retained",
|
|
774
|
+
reason,
|
|
775
|
+
proposedFiles,
|
|
776
|
+
appliedFiles: [],
|
|
777
|
+
baselineRef: group.baselineRef,
|
|
778
|
+
proposalRef: worker.proposalRef,
|
|
779
|
+
patchPath: worker.patchPath,
|
|
780
|
+
...(cleanupIssue ? { cleanupIssue } : {}),
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
735
785
|
async function reconcileGroup(
|
|
736
786
|
group: IsolatedGroup,
|
|
737
787
|
workers: Map<number, IsolatedWorker>,
|
|
738
788
|
results: TaskResult[],
|
|
789
|
+
options: IsolatedReconcileOptions,
|
|
739
790
|
): Promise<void> {
|
|
740
791
|
let integratedCommit = group.baselineCommit;
|
|
741
792
|
const accepted = new Map<number, string[]>();
|
|
@@ -948,6 +999,17 @@ async function reconcileGroup(
|
|
|
948
999
|
await requireWorktreeRemoved(group.sourceRoot, pristineRoot);
|
|
949
1000
|
return;
|
|
950
1001
|
}
|
|
1002
|
+
if (options.shouldApplySource && !options.shouldApplySource()) {
|
|
1003
|
+
await retainAcceptedProposals(
|
|
1004
|
+
group,
|
|
1005
|
+
workers,
|
|
1006
|
+
results,
|
|
1007
|
+
accepted,
|
|
1008
|
+
pristineRoot,
|
|
1009
|
+
options.retainedReason ?? "Source application was cancelled.",
|
|
1010
|
+
);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
951
1013
|
|
|
952
1014
|
const currentTree = await snapshotTree(
|
|
953
1015
|
group.sourceRoot,
|
|
@@ -995,6 +1057,18 @@ async function reconcileGroup(
|
|
|
995
1057
|
group.baselineCommit,
|
|
996
1058
|
integratedCommit,
|
|
997
1059
|
);
|
|
1060
|
+
await beforeSourceApplyHookForTesting?.();
|
|
1061
|
+
if (options.shouldApplySource && !options.shouldApplySource()) {
|
|
1062
|
+
await retainAcceptedProposals(
|
|
1063
|
+
group,
|
|
1064
|
+
workers,
|
|
1065
|
+
results,
|
|
1066
|
+
accepted,
|
|
1067
|
+
pristineRoot,
|
|
1068
|
+
options.retainedReason ?? "Source application was cancelled.",
|
|
1069
|
+
);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
998
1072
|
const applyIndex = path.join(group.artifactRoot, "apply.index");
|
|
999
1073
|
await fs.promises.rm(applyIndex, { force: true });
|
|
1000
1074
|
const env = {
|
|
@@ -1010,12 +1084,25 @@ async function reconcileGroup(
|
|
|
1010
1084
|
cwd: group.sourceRoot,
|
|
1011
1085
|
env,
|
|
1012
1086
|
});
|
|
1087
|
+
if (options.shouldApplySource && !options.shouldApplySource()) {
|
|
1088
|
+
await retainAcceptedProposals(
|
|
1089
|
+
group,
|
|
1090
|
+
workers,
|
|
1091
|
+
results,
|
|
1092
|
+
accepted,
|
|
1093
|
+
pristineRoot,
|
|
1094
|
+
options.retainedReason ?? "Source application was cancelled.",
|
|
1095
|
+
);
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1013
1098
|
await git(["apply", "--binary", "--index", finalPatch], {
|
|
1014
1099
|
cwd: group.sourceRoot,
|
|
1015
1100
|
env,
|
|
1101
|
+
signal: options.signal,
|
|
1016
1102
|
});
|
|
1017
1103
|
} catch (error) {
|
|
1018
1104
|
const recoveryRoot = path.join(group.artifactRoot, "failed-apply-files");
|
|
1105
|
+
let rollbackSucceeded = true;
|
|
1019
1106
|
try {
|
|
1020
1107
|
await restoreAfterFailedApply(
|
|
1021
1108
|
group.sourceRoot,
|
|
@@ -1024,11 +1111,23 @@ async function reconcileGroup(
|
|
|
1024
1111
|
recoveryRoot,
|
|
1025
1112
|
);
|
|
1026
1113
|
} catch (rollbackError) {
|
|
1114
|
+
rollbackSucceeded = false;
|
|
1027
1115
|
console.error(
|
|
1028
1116
|
"[delegate] isolated apply rollback failed; recovery artifacts retained",
|
|
1029
1117
|
rollbackError,
|
|
1030
1118
|
);
|
|
1031
1119
|
}
|
|
1120
|
+
if (options.signal?.aborted && rollbackSucceeded) {
|
|
1121
|
+
await retainAcceptedProposals(
|
|
1122
|
+
group,
|
|
1123
|
+
workers,
|
|
1124
|
+
results,
|
|
1125
|
+
accepted,
|
|
1126
|
+
pristineRoot,
|
|
1127
|
+
options.retainedReason ?? "Source application was cancelled.",
|
|
1128
|
+
);
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1032
1131
|
for (const [taskIndex] of accepted) {
|
|
1033
1132
|
const integration = results[taskIndex]!.integration!;
|
|
1034
1133
|
const worker = workers.get(taskIndex)!;
|
|
@@ -1145,24 +1244,35 @@ async function cleanupCompletedGroupRefs(
|
|
|
1145
1244
|
group: IsolatedGroup,
|
|
1146
1245
|
workers: Map<number, IsolatedWorker>,
|
|
1147
1246
|
results: readonly TaskResult[],
|
|
1148
|
-
): Promise<
|
|
1247
|
+
): Promise<boolean> {
|
|
1149
1248
|
const disposableProposalRefs: string[] = [];
|
|
1150
1249
|
let retainsRecoveryArtifacts = false;
|
|
1250
|
+
let retainsPrivateRefs = false;
|
|
1151
1251
|
|
|
1152
1252
|
for (const taskIndex of group.taskIndexes) {
|
|
1153
|
-
const
|
|
1154
|
-
|
|
1253
|
+
const integration = results[taskIndex]?.integration;
|
|
1254
|
+
const status = integration?.status;
|
|
1255
|
+
if (!integration) {
|
|
1256
|
+
retainsRecoveryArtifacts = true;
|
|
1257
|
+
retainsPrivateRefs = true;
|
|
1258
|
+
} else if (
|
|
1259
|
+
status === "retained" ||
|
|
1260
|
+
status === "conflict" ||
|
|
1261
|
+
status === "apply_failed"
|
|
1262
|
+
) {
|
|
1155
1263
|
retainsRecoveryArtifacts = true;
|
|
1264
|
+
retainsPrivateRefs = true;
|
|
1156
1265
|
} else if (
|
|
1157
1266
|
status === "applied_unverified" ||
|
|
1158
1267
|
status === "no_changes" ||
|
|
1159
1268
|
status === "discarded"
|
|
1160
1269
|
) {
|
|
1270
|
+
if (integration.cleanupIssue) retainsRecoveryArtifacts = true;
|
|
1161
1271
|
disposableProposalRefs.push(workers.get(taskIndex)!.proposalRef);
|
|
1162
1272
|
}
|
|
1163
1273
|
}
|
|
1164
1274
|
|
|
1165
|
-
const refs =
|
|
1275
|
+
const refs = retainsPrivateRefs
|
|
1166
1276
|
? disposableProposalRefs
|
|
1167
1277
|
: [...disposableProposalRefs, group.baselineRef];
|
|
1168
1278
|
try {
|
|
@@ -1172,6 +1282,7 @@ async function cleanupCompletedGroupRefs(
|
|
|
1172
1282
|
// turn a successfully applied source change into a reported failure.
|
|
1173
1283
|
console.error("[delegate] failed to clean completed isolated refs", error);
|
|
1174
1284
|
}
|
|
1285
|
+
return retainsRecoveryArtifacts;
|
|
1175
1286
|
}
|
|
1176
1287
|
|
|
1177
1288
|
/** Prepare detached worktrees from one synthetic commit per Git root. The
|
|
@@ -1315,11 +1426,14 @@ export async function prepareIsolatedBatch(
|
|
|
1315
1426
|
|
|
1316
1427
|
return {
|
|
1317
1428
|
resolved: translated,
|
|
1318
|
-
async reconcile(
|
|
1429
|
+
async reconcile(
|
|
1430
|
+
results: TaskResult[],
|
|
1431
|
+
options: IsolatedReconcileOptions = {},
|
|
1432
|
+
): Promise<TaskResult[]> {
|
|
1319
1433
|
for (const group of groupsByRoot.values()) {
|
|
1320
1434
|
try {
|
|
1321
1435
|
try {
|
|
1322
|
-
await reconcileGroup(group, workers, results);
|
|
1436
|
+
await reconcileGroup(group, workers, results, options);
|
|
1323
1437
|
} catch (error) {
|
|
1324
1438
|
console.error(
|
|
1325
1439
|
"[delegate] isolated group reconciliation failed",
|
|
@@ -1332,13 +1446,45 @@ export async function prepareIsolatedBatch(
|
|
|
1332
1446
|
error,
|
|
1333
1447
|
);
|
|
1334
1448
|
}
|
|
1335
|
-
await cleanupCompletedGroupRefs(
|
|
1449
|
+
const retainsRecoveryArtifacts = await cleanupCompletedGroupRefs(
|
|
1450
|
+
group,
|
|
1451
|
+
workers,
|
|
1452
|
+
results,
|
|
1453
|
+
);
|
|
1454
|
+
if (!retainsRecoveryArtifacts) {
|
|
1455
|
+
try {
|
|
1456
|
+
await fs.promises.rm(group.artifactRoot, {
|
|
1457
|
+
recursive: true,
|
|
1458
|
+
force: true,
|
|
1459
|
+
});
|
|
1460
|
+
} catch (error) {
|
|
1461
|
+
console.error(
|
|
1462
|
+
`[delegate] failed to remove completed isolated artifacts '${group.artifactRoot}'`,
|
|
1463
|
+
error,
|
|
1464
|
+
);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1336
1467
|
} finally {
|
|
1337
1468
|
// Unblocks and serializes any safety-confirmed quarantine cleanups
|
|
1338
1469
|
// only after all candidate/source/ref work for this group is done.
|
|
1339
1470
|
group.finishReconciliation();
|
|
1340
1471
|
}
|
|
1341
1472
|
}
|
|
1473
|
+
try {
|
|
1474
|
+
await fs.promises.rmdir(batchArtifactRoot);
|
|
1475
|
+
} catch (error) {
|
|
1476
|
+
if (!(
|
|
1477
|
+
error instanceof Error &&
|
|
1478
|
+
"code" in error &&
|
|
1479
|
+
((error as NodeJS.ErrnoException).code === "ENOENT" ||
|
|
1480
|
+
(error as NodeJS.ErrnoException).code === "ENOTEMPTY")
|
|
1481
|
+
)) {
|
|
1482
|
+
console.error(
|
|
1483
|
+
`[delegate] failed to remove isolated batch directory '${batchArtifactRoot}'`,
|
|
1484
|
+
error,
|
|
1485
|
+
);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1342
1488
|
return results;
|
|
1343
1489
|
},
|
|
1344
1490
|
};
|