@bermudi/pi-delegate 0.1.18 → 0.1.19
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 -13
- package/agents.ts +1 -1
- package/concurrency.ts +7 -0
- package/delegate.ts +6 -0
- package/dispatch.ts +460 -163
- package/extension.ts +35 -26
- package/format.ts +4 -1
- package/host.ts +1 -1
- package/isolated-workspace.ts +154 -8
- package/lifecycle.ts +31 -20
- package/manual.ts +11 -7
- package/package.json +2 -1
- package/parent-context.ts +1 -1
- package/pool.ts +492 -428
- package/render-branches.ts +2 -1
- package/render-result.ts +6 -0
- package/runtime.ts +36 -0
- package/schema.ts +22 -13
- package/status.ts +24 -11
- package/task-resolution.ts +12 -9
- package/test-harness.ts +81 -0
- package/ticket-format.ts +4 -3
- package/tickets.ts +672 -576
- package/types.ts +19 -0
- package/workspace.ts +58 -27
package/extension.ts
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
handleCancel,
|
|
4
|
-
handlePoll,
|
|
5
|
-
handleWait,
|
|
6
|
-
cancelTicketForShutdown,
|
|
7
|
-
ticketRegistry,
|
|
8
|
-
} from "./tickets.ts";
|
|
2
|
+
import { getDefaultDelegateRuntime, type DelegateRuntime } from "./runtime.ts";
|
|
9
3
|
import { discoverAgents } from "./agents.ts";
|
|
10
4
|
import { getSubagentManualMarkdown } from "./manual.ts";
|
|
11
5
|
import {
|
|
@@ -25,7 +19,6 @@ import { hostCompatError } from "./host-compat.ts";
|
|
|
25
19
|
import { invalidateHostDepsCache } from "./host.ts";
|
|
26
20
|
import { registerProviderExtensionNotifier } from "./provider-extensions.ts";
|
|
27
21
|
import { recordTreeNavigation, resetLeafTracking } from "./leaf.ts";
|
|
28
|
-
import { closeAllPooledAgents } from "./pool.ts";
|
|
29
22
|
import { reconfigureGlobalConcurrency } from "./concurrency.ts";
|
|
30
23
|
import { reloadDelegateConfig, getMaxConcurrent } from "./config.ts";
|
|
31
24
|
import {
|
|
@@ -130,8 +123,15 @@ export function _setShutdownDrainTimeoutForTesting(
|
|
|
130
123
|
shutdownDrainTimeoutMs = timeoutMs ?? DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS;
|
|
131
124
|
}
|
|
132
125
|
|
|
133
|
-
/** Register the delegate tool and clean up its parent-session resources.
|
|
134
|
-
|
|
126
|
+
/** Register the delegate tool and clean up its parent-session resources.
|
|
127
|
+
*
|
|
128
|
+
* Production uses the module default runtime. Tests/embedders may pass a
|
|
129
|
+
* fresh runtime as the second argument so all tool execution, status, and
|
|
130
|
+
* shutdown operate in an isolated pool/ticket environment. */
|
|
131
|
+
export default function delegateExtension(
|
|
132
|
+
pi: ExtensionAPI,
|
|
133
|
+
runtime: DelegateRuntime = getDefaultDelegateRuntime(),
|
|
134
|
+
): void {
|
|
135
135
|
// A /reload can reuse this module instance after the previous runtime closed
|
|
136
136
|
// its SQLite handle. Permit the new runtime to open a fresh backend; stale
|
|
137
137
|
// workers from the old runtime remain blocked from reopening it.
|
|
@@ -237,17 +237,17 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
237
237
|
|
|
238
238
|
// ── Poll action ───────────────────────────────────────────────────
|
|
239
239
|
if (params.ticketAction === "poll") {
|
|
240
|
-
const result = handlePoll(params, ctx);
|
|
240
|
+
const result = runtime.tickets.handlePoll(params, ctx);
|
|
241
241
|
succeedCall();
|
|
242
242
|
return result;
|
|
243
243
|
}
|
|
244
244
|
|
|
245
245
|
// ── Cancel action ─────────────────────────────────────────────────
|
|
246
246
|
if (params.ticketAction === "cancel") {
|
|
247
|
-
const result = handleCancel(params);
|
|
247
|
+
const result = runtime.tickets.handleCancel(params);
|
|
248
248
|
// A forced cancel flips the ticket to "cancelling" — keep the
|
|
249
249
|
// footer status in step (deduped; the preview path is a no-op).
|
|
250
|
-
syncDelegateStatus(ctx);
|
|
250
|
+
syncDelegateStatus(ctx, runtime);
|
|
251
251
|
succeedCall();
|
|
252
252
|
return result;
|
|
253
253
|
}
|
|
@@ -255,7 +255,12 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
255
255
|
// ── Wait action ────────────────────────────────────────────────────
|
|
256
256
|
if (params.ticketAction === "wait") {
|
|
257
257
|
try {
|
|
258
|
-
const result = await handleWait(
|
|
258
|
+
const result = await runtime.tickets.handleWait(
|
|
259
|
+
params,
|
|
260
|
+
signal,
|
|
261
|
+
onUpdate,
|
|
262
|
+
ctx,
|
|
263
|
+
);
|
|
259
264
|
succeedCall();
|
|
260
265
|
return result;
|
|
261
266
|
} catch (err) {
|
|
@@ -283,7 +288,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
283
288
|
invalidateHostDepsCache();
|
|
284
289
|
// Keep the footer-status pipeline in step exactly as a normal
|
|
285
290
|
// dispatch would (deduped no-op when nothing is running).
|
|
286
|
-
syncDelegateStatus(ctx);
|
|
291
|
+
syncDelegateStatus(ctx, runtime);
|
|
287
292
|
return await dispatchDelegate({
|
|
288
293
|
pi,
|
|
289
294
|
params: { ...params, tasks: [bridgeSessionControlTask(params)] },
|
|
@@ -297,6 +302,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
297
302
|
signal,
|
|
298
303
|
onUpdate,
|
|
299
304
|
callSpan,
|
|
305
|
+
runtime,
|
|
300
306
|
});
|
|
301
307
|
}
|
|
302
308
|
|
|
@@ -318,7 +324,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
318
324
|
// dispatch narrows it to DelegateToolCtx (which has no `ui`). The
|
|
319
325
|
// status push itself is a deduped no-op here; dispatchAsync re-syncs
|
|
320
326
|
// after registering its ticket.
|
|
321
|
-
syncDelegateStatus(ctx);
|
|
327
|
+
syncDelegateStatus(ctx, runtime);
|
|
322
328
|
|
|
323
329
|
// Keep expensive host deps shared within this dispatch, not indefinitely
|
|
324
330
|
// across dispatches: edits to auth/models/settings/context files must be
|
|
@@ -338,6 +344,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
338
344
|
signal,
|
|
339
345
|
onUpdate,
|
|
340
346
|
callSpan,
|
|
347
|
+
runtime,
|
|
341
348
|
});
|
|
342
349
|
} catch (err) {
|
|
343
350
|
failCall();
|
|
@@ -360,15 +367,15 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
360
367
|
// The turn settling with live tickets is the "looks idle but isn't" moment:
|
|
361
368
|
// warn once per ticket. The footer status carries it from there.
|
|
362
369
|
pi.on("agent_settled", (_event, ctx) => {
|
|
363
|
-
notifyActiveTicketsOnSettled(ctx);
|
|
370
|
+
notifyActiveTicketsOnSettled(ctx, runtime);
|
|
364
371
|
});
|
|
365
372
|
|
|
366
373
|
// Session replacements are cancellable — confirm before killing live work.
|
|
367
374
|
pi.on("session_before_switch", (_event, ctx) =>
|
|
368
|
-
guardSessionReplacement(ctx, "switch"),
|
|
375
|
+
guardSessionReplacement(ctx, "switch", runtime),
|
|
369
376
|
);
|
|
370
377
|
pi.on("session_before_fork", (_event, ctx) =>
|
|
371
|
-
guardSessionReplacement(ctx, "fork"),
|
|
378
|
+
guardSessionReplacement(ctx, "fork", runtime),
|
|
372
379
|
);
|
|
373
380
|
|
|
374
381
|
// /tree navigation stays inside the same session: nothing is torn down and
|
|
@@ -376,10 +383,12 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
376
383
|
// user moves to. Ask first, and record the new leaf either way so delivery
|
|
377
384
|
// can detect the mismatch (issue #30). `session_tree` also fires for
|
|
378
385
|
// extension-driven ctx.navigateTree, which never reaches the guard.
|
|
379
|
-
pi.on("session_before_tree", (_event, ctx) =>
|
|
386
|
+
pi.on("session_before_tree", (_event, ctx) =>
|
|
387
|
+
guardTreeNavigation(ctx, runtime),
|
|
388
|
+
);
|
|
380
389
|
pi.on("session_tree", (event, ctx) => {
|
|
381
390
|
recordTreeNavigation(event.newLeafId);
|
|
382
|
-
syncDelegateStatus(ctx);
|
|
391
|
+
syncDelegateStatus(ctx, runtime);
|
|
383
392
|
});
|
|
384
393
|
|
|
385
394
|
// ── Session shutdown: abort tickets and dispose live pooled sessions ──
|
|
@@ -395,7 +404,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
395
404
|
// leave a trace. For quit the TUI is already stopped — stderr lands in
|
|
396
405
|
// the scrollback. For reload the TUI survives — warn in place. Switch
|
|
397
406
|
// and fork already passed the confirm guard above.
|
|
398
|
-
const active = activeTicketSummary();
|
|
407
|
+
const active = activeTicketSummary(runtime);
|
|
399
408
|
if (active.tickets.length) {
|
|
400
409
|
if (event.reason === "quit") {
|
|
401
410
|
console.error(
|
|
@@ -416,15 +425,15 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
416
425
|
}
|
|
417
426
|
}
|
|
418
427
|
|
|
419
|
-
for (const ticket of
|
|
428
|
+
for (const ticket of runtime.tickets.values()) {
|
|
420
429
|
if (ticket.status === "running" || ticket.status === "cancelling") {
|
|
421
|
-
cancelTicketForShutdown(ticket);
|
|
430
|
+
runtime.tickets.cancelTicketForShutdown(ticket);
|
|
422
431
|
}
|
|
423
432
|
// Include already-cancelled tickets too: a repeated shutdown event can
|
|
424
433
|
// race the first handler while its workers are still unwinding.
|
|
425
434
|
if (ticket.completion) ticketCompletions.push(ticket.completion);
|
|
426
435
|
}
|
|
427
|
-
syncDelegateStatus(ctx);
|
|
436
|
+
syncDelegateStatus(ctx, runtime);
|
|
428
437
|
// The runtime is invalidated right after this handler returns; aborted
|
|
429
438
|
// tickets keep unwinding asynchronously and must find no cached ctx (or
|
|
430
439
|
// captured pi) to touch. The cancelled completion path still writes one
|
|
@@ -443,7 +452,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
443
452
|
// SQLite still stays open until both cleanup paths finish.
|
|
444
453
|
let poolCleanup: Promise<void>;
|
|
445
454
|
try {
|
|
446
|
-
poolCleanup = closeAllPooledAgents();
|
|
455
|
+
poolCleanup = runtime.pool.closeAllPooledAgents();
|
|
447
456
|
} catch (error) {
|
|
448
457
|
console.error("[delegate] pooled-session shutdown start failed", error);
|
|
449
458
|
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
|
}
|
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
|
};
|
package/lifecycle.ts
CHANGED
|
@@ -14,8 +14,7 @@ import type {
|
|
|
14
14
|
TaskRunEnv,
|
|
15
15
|
ToolActivity,
|
|
16
16
|
} from "./types.ts";
|
|
17
|
-
import
|
|
18
|
-
import { isSessionBusy } from "./tickets.ts";
|
|
17
|
+
import { getDefaultDelegateRuntime, type DelegateRuntime } from "./runtime.ts";
|
|
19
18
|
import {
|
|
20
19
|
createSubagentSessionManager,
|
|
21
20
|
persistSessionHeader,
|
|
@@ -50,10 +49,6 @@ let runAgentSessionForTesting: RunAgentSession = runAgentSession;
|
|
|
50
49
|
type CreateScratchWorkspace = typeof createScratchWorkspace;
|
|
51
50
|
let createScratchWorkspaceForTesting: CreateScratchWorkspace =
|
|
52
51
|
createScratchWorkspace;
|
|
53
|
-
type DetachQuarantinedPooledSession =
|
|
54
|
-
typeof pool._quarantinePooledAgentWithoutDisposal;
|
|
55
|
-
let detachQuarantinedPooledSessionForTesting: DetachQuarantinedPooledSession =
|
|
56
|
-
pool._quarantinePooledAgentWithoutDisposal;
|
|
57
52
|
|
|
58
53
|
export function _setRunAgentSessionForTesting(
|
|
59
54
|
override: RunAgentSession | undefined,
|
|
@@ -70,10 +65,11 @@ export function _setCreateScratchWorkspaceForTesting(
|
|
|
70
65
|
|
|
71
66
|
/** @internal Simulate a pooled-detachment invariant failure in lifecycle tests. */
|
|
72
67
|
export function _setQuarantinePooledSessionDetachForTesting(
|
|
73
|
-
override:
|
|
68
|
+
override:
|
|
69
|
+
((sessionId: string, expectedSession: AgentSession) => boolean) | undefined,
|
|
70
|
+
runtime: DelegateRuntime = getDefaultDelegateRuntime(),
|
|
74
71
|
): void {
|
|
75
|
-
|
|
76
|
-
override ?? pool._quarantinePooledAgentWithoutDisposal;
|
|
72
|
+
runtime.pool._setQuarantinePooledAgentWithoutDisposalForTesting(override);
|
|
77
73
|
}
|
|
78
74
|
|
|
79
75
|
/**
|
|
@@ -242,14 +238,16 @@ function disposeSession(session: AgentSession, description: string): void {
|
|
|
242
238
|
/** Detach an abandoned session from every owner immediately, then dispose it
|
|
243
239
|
* only after runner's background termination monitor proves quiescence. */
|
|
244
240
|
function quarantineAcquiredSession(
|
|
241
|
+
env: TaskRunEnv,
|
|
245
242
|
task: ResolvedTask,
|
|
246
243
|
acquired: AcquiredSession,
|
|
247
244
|
quarantine: SessionQuarantine,
|
|
248
245
|
): void {
|
|
249
246
|
let mayDisposeAfterSafety = acquired.lifecycleOwnsSession;
|
|
250
247
|
if (!acquired.lifecycleOwnsSession) {
|
|
248
|
+
const runtime = env.runtime!;
|
|
251
249
|
const detached = task.sessionId
|
|
252
|
-
?
|
|
250
|
+
? runtime.pool.quarantinePooledAgentWithoutDisposal(
|
|
253
251
|
task.sessionId,
|
|
254
252
|
acquired.session,
|
|
255
253
|
)
|
|
@@ -617,11 +615,12 @@ type AcquireResult = AcquiredSession | { error: TaskResult };
|
|
|
617
615
|
* checkout is pure (no lastUsed bump); lastUsed is bumped by commit().
|
|
618
616
|
*/
|
|
619
617
|
function checkoutPooledSession(
|
|
618
|
+
env: TaskRunEnv,
|
|
620
619
|
task: ResolvedTask,
|
|
621
620
|
p: TaskProgress,
|
|
622
621
|
): AcquireResult | undefined {
|
|
623
622
|
if (!task.sessionId) return undefined;
|
|
624
|
-
const co = pool.checkout(task.sessionId, {
|
|
623
|
+
const co = env.runtime!.pool.checkout(task.sessionId, {
|
|
625
624
|
cwd: task.cwd,
|
|
626
625
|
thinking: task.thinking,
|
|
627
626
|
tools: task.tools,
|
|
@@ -762,7 +761,7 @@ async function acquireAgentSession(
|
|
|
762
761
|
p: TaskProgress,
|
|
763
762
|
): Promise<AcquireResult> {
|
|
764
763
|
if (task.sessionId) {
|
|
765
|
-
const pooled = checkoutPooledSession(task, p);
|
|
764
|
+
const pooled = checkoutPooledSession(env, task, p);
|
|
766
765
|
if (pooled) return pooled;
|
|
767
766
|
}
|
|
768
767
|
if (task.resumeFrom) return resumeFromSessionFile(env, task, task.resumeFrom);
|
|
@@ -813,6 +812,10 @@ export async function runResolvedTask(
|
|
|
813
812
|
p: TaskProgress,
|
|
814
813
|
taskIndex: number,
|
|
815
814
|
): Promise<TaskResult> {
|
|
815
|
+
// Ensure every lifecycle call operates on an explicit runtime. Older callers
|
|
816
|
+
// (and some test fixtures) do not inject one, so the default runtime is the
|
|
817
|
+
// backward-compatible fallback.
|
|
818
|
+
env.runtime ??= getDefaultDelegateRuntime();
|
|
816
819
|
return withResumeTranscriptLock(task.resumeFrom, async (transcript) => {
|
|
817
820
|
let executionTask = task;
|
|
818
821
|
if (task.resumeFrom) {
|
|
@@ -879,8 +882,9 @@ export async function runResolvedTask(
|
|
|
879
882
|
return runResolvedTaskUnlocked(env, executionTask, p, taskIndex);
|
|
880
883
|
};
|
|
881
884
|
|
|
885
|
+
const runtime = env.runtime!;
|
|
882
886
|
return executionTask.sessionId
|
|
883
|
-
? pool.withSessionLock(executionTask.sessionId, runLocked)
|
|
887
|
+
? runtime.pool.withSessionLock(executionTask.sessionId, runLocked)
|
|
884
888
|
: runLocked();
|
|
885
889
|
});
|
|
886
890
|
}
|
|
@@ -1198,7 +1202,10 @@ async function applySessionAction(
|
|
|
1198
1202
|
// The per-session lock for action-based operations is already held by the
|
|
1199
1203
|
// outer runResolvedTask() wrapper. Use the internal close helper to avoid a
|
|
1200
1204
|
// reentrant deadlock on the same key.
|
|
1201
|
-
const
|
|
1205
|
+
const runtime = env.runtime!;
|
|
1206
|
+
const closed = await runtime.pool.closePooledAgentWithoutLock(
|
|
1207
|
+
task.sessionId,
|
|
1208
|
+
);
|
|
1202
1209
|
return finishTask(
|
|
1203
1210
|
env,
|
|
1204
1211
|
p,
|
|
@@ -1213,12 +1220,13 @@ async function applySessionAction(
|
|
|
1213
1220
|
}
|
|
1214
1221
|
|
|
1215
1222
|
if (task.sessionAction === "list") {
|
|
1223
|
+
const runtime = env.runtime!;
|
|
1216
1224
|
return finishTask(
|
|
1217
1225
|
env,
|
|
1218
1226
|
p,
|
|
1219
1227
|
completeSessionAction(
|
|
1220
1228
|
task,
|
|
1221
|
-
`Active sessions:\n${pool.listPooledAgents().join("\n")}`,
|
|
1229
|
+
`Active sessions:\n${runtime.pool.listPooledAgents().join("\n")}`,
|
|
1222
1230
|
Date.now() - env.delegateStartedAt,
|
|
1223
1231
|
),
|
|
1224
1232
|
);
|
|
@@ -1231,7 +1239,7 @@ function busySessionConflict(
|
|
|
1231
1239
|
task: ResolvedTask,
|
|
1232
1240
|
): TaskResult | undefined {
|
|
1233
1241
|
if (!task.sessionId) return undefined;
|
|
1234
|
-
const busyTicketId = isSessionBusy(task.sessionId);
|
|
1242
|
+
const busyTicketId = env.runtime!.tickets.isSessionBusy(task.sessionId);
|
|
1235
1243
|
if (busyTicketId && busyTicketId !== env.ticketId) {
|
|
1236
1244
|
return failTask(
|
|
1237
1245
|
task,
|
|
@@ -1303,6 +1311,7 @@ function noteAttemptProgress(
|
|
|
1303
1311
|
|
|
1304
1312
|
/** Commit, record, or evict a pooled session after one prompt attempt. */
|
|
1305
1313
|
async function settlePooledAttempt(
|
|
1314
|
+
env: TaskRunEnv,
|
|
1306
1315
|
task: ResolvedTask,
|
|
1307
1316
|
acquired: AcquiredSession,
|
|
1308
1317
|
r: {
|
|
@@ -1313,6 +1322,7 @@ async function settlePooledAttempt(
|
|
|
1313
1322
|
},
|
|
1314
1323
|
sessionReleased: boolean,
|
|
1315
1324
|
): Promise<boolean> {
|
|
1325
|
+
const runtime = env.runtime!;
|
|
1316
1326
|
if (!task.sessionId) return sessionReleased;
|
|
1317
1327
|
if (acquired.lifecycleOwnsSession) {
|
|
1318
1328
|
// Pool misses (including resumeFrom) transfer ownership only on
|
|
@@ -1323,7 +1333,7 @@ async function settlePooledAttempt(
|
|
|
1323
1333
|
r.failureKind !== "stalled" &&
|
|
1324
1334
|
r.failureKind !== "deadline_exceeded"
|
|
1325
1335
|
) {
|
|
1326
|
-
const committed = pool.commit(task.sessionId, {
|
|
1336
|
+
const committed = runtime.pool.commit(task.sessionId, {
|
|
1327
1337
|
session: acquired.session,
|
|
1328
1338
|
sessionManager: acquired.sessionManager,
|
|
1329
1339
|
sessionFile: acquired.sessionFile,
|
|
@@ -1353,7 +1363,7 @@ async function settlePooledAttempt(
|
|
|
1353
1363
|
) {
|
|
1354
1364
|
try {
|
|
1355
1365
|
return (
|
|
1356
|
-
(await pool.
|
|
1366
|
+
(await runtime.pool.closePooledAgentWithoutLock(task.sessionId)) ||
|
|
1357
1367
|
sessionReleased
|
|
1358
1368
|
);
|
|
1359
1369
|
} catch (error) {
|
|
@@ -1371,7 +1381,7 @@ async function settlePooledAttempt(
|
|
|
1371
1381
|
if (r.failureKind !== "deadline_exceeded") {
|
|
1372
1382
|
// Pool hits stay owned by the pool, and non-stalled, non-aborted
|
|
1373
1383
|
// completions (including failed attempts) must still count usage.
|
|
1374
|
-
pool.recordUse(task.sessionId, r.tokens);
|
|
1384
|
+
runtime.pool.recordUse(task.sessionId, r.tokens);
|
|
1375
1385
|
}
|
|
1376
1386
|
// Pre-prompt deadline (prompted === false): the pooled session was
|
|
1377
1387
|
// checked out but never used. Leave it in the pool with no usage
|
|
@@ -1558,12 +1568,13 @@ async function runTaskAttempt(
|
|
|
1558
1568
|
);
|
|
1559
1569
|
|
|
1560
1570
|
if (quarantine) {
|
|
1561
|
-
quarantineAcquiredSession(task, acquired, quarantine);
|
|
1571
|
+
quarantineAcquiredSession(env, task, acquired, quarantine);
|
|
1562
1572
|
// Neither lifecycle nor pool owns it now. The deferred safety callback is
|
|
1563
1573
|
// the sole owner and finally below must not dispose it early.
|
|
1564
1574
|
sessionReleased = true;
|
|
1565
1575
|
} else {
|
|
1566
1576
|
sessionReleased = await settlePooledAttempt(
|
|
1577
|
+
env,
|
|
1567
1578
|
task,
|
|
1568
1579
|
acquired,
|
|
1569
1580
|
r,
|