@bermudi/pi-delegate 0.1.1 → 0.1.2
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 +3 -2
- package/agents.ts +163 -17
- package/concurrency.ts +70 -7
- package/delegate.ts +13 -0
- package/dispatch.ts +33 -7
- package/extension.ts +20 -4
- package/file-tracking.ts +27 -5
- package/format.ts +46 -7
- package/leaf.ts +48 -0
- package/lifecycle.ts +83 -21
- package/manual.ts +37 -10
- package/package.json +4 -1
- package/patches/@marcfargas%2Fpi-test-harness@0.6.1.patch +13 -0
- package/render-branches.ts +31 -13
- package/render-result.ts +12 -0
- package/runner.ts +237 -42
- package/schema.ts +203 -46
- package/status.ts +68 -2
- package/task-resolution.ts +101 -65
- package/tickets.ts +173 -62
- package/tools.ts +16 -15
- package/types.ts +54 -13
package/render-result.ts
CHANGED
|
@@ -166,6 +166,18 @@ export function renderDelegateResult(
|
|
|
166
166
|
ticketStatus,
|
|
167
167
|
};
|
|
168
168
|
|
|
169
|
+
// Surface the touched-file overlap warning at the top of the TUI. The same
|
|
170
|
+
// text already lives in the textual content, but the progress-based renderer
|
|
171
|
+
// ignores content, so we must render it explicitly from details. Rendering it
|
|
172
|
+
// before the progress tree places it at the top of the budgeted region, so it
|
|
173
|
+
// survives truncation from the bottom when many tasks collapse the view.
|
|
174
|
+
if (details?.overlapWarning) {
|
|
175
|
+
lines.push(
|
|
176
|
+
truncLine(theme.fg("warning", `⚠ ${details.overlapWarning}`), w),
|
|
177
|
+
"",
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
169
181
|
if (options.isPartial) {
|
|
170
182
|
renderPartialBranch(branchCtx, helpers);
|
|
171
183
|
} else {
|
package/runner.ts
CHANGED
|
@@ -19,6 +19,16 @@ import type {
|
|
|
19
19
|
ToolActivity,
|
|
20
20
|
} from "./types.ts";
|
|
21
21
|
|
|
22
|
+
/** Human-facing error for an expired `deadlineMs` budget.
|
|
23
|
+
*
|
|
24
|
+
* This is exported so the lifecycle pre-check can return the same text as the
|
|
25
|
+
* runner, keeping the sync and (future) async paths consistent. */
|
|
26
|
+
export function formatDeadlineExceededError(budgetMs: number): string {
|
|
27
|
+
return `Deadline exceeded: task exceeded its ${fmtDuration(
|
|
28
|
+
Math.max(0, budgetMs),
|
|
29
|
+
)} wall-clock budget and was cooperatively aborted (not a hard kill). Completed writes and commands remain.`;
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
/**
|
|
23
33
|
* Run a single prompt against a live `AgentSession` and report progress.
|
|
24
34
|
*
|
|
@@ -34,6 +44,15 @@ import type {
|
|
|
34
44
|
* 4. snapshots usage before/after the prompt for token delta accounting, and
|
|
35
45
|
* 5. computes touched files from activity + git diff.
|
|
36
46
|
*
|
|
47
|
+
* Touched-file tracking is best-effort. Explicit edit/write tool calls are
|
|
48
|
+
* captured from the activity log. bash mutations are only captured via git
|
|
49
|
+
* status when the cwd is a git repo and git is available; in a non-git
|
|
50
|
+
* directory, bash-mutated files are not reported. Git failures on either the
|
|
51
|
+
* baseline or post-run snapshot degrade to an empty diff, and a failed baseline
|
|
52
|
+
* suppresses git-based attribution entirely so pre-existing dirty files are not
|
|
53
|
+
* blamed on this task. The resulting list is a lower bound, not a complete
|
|
54
|
+
* record.
|
|
55
|
+
*
|
|
37
56
|
* Output is captured from AgentSession events so compaction cannot erase it
|
|
38
57
|
* before collection; `session.messages` is only a guarded fallback for
|
|
39
58
|
* providers that fail before emitting message_end. AgentSession's internal
|
|
@@ -46,16 +65,24 @@ export async function runAgentSession(
|
|
|
46
65
|
config: { cwd: string },
|
|
47
66
|
signal: AbortSignal | undefined,
|
|
48
67
|
onProgress: ((update: AgentProgressUpdate) => void) | undefined,
|
|
49
|
-
gitBaseline: Set<string
|
|
68
|
+
gitBaseline: Set<string> | undefined,
|
|
50
69
|
start: number,
|
|
70
|
+
deadlineAt?: number,
|
|
51
71
|
): Promise<{
|
|
52
72
|
output: string;
|
|
53
73
|
error?: string;
|
|
54
74
|
durationMs: number;
|
|
55
75
|
tokens: number;
|
|
56
76
|
usage: Usage;
|
|
77
|
+
/** Best-effort union of activity- and git-derived touched files for display. */
|
|
57
78
|
touchedFiles: string[];
|
|
79
|
+
/** Files directly attributable to this run's edit/write tool calls. */
|
|
80
|
+
attributedFiles: string[];
|
|
58
81
|
failureKind?: TaskFailureKind;
|
|
82
|
+
/** Whether `session.prompt()` was actually invoked. Distinguishes a pre-prompt
|
|
83
|
+
* deadline (session never used) from a mid-prompt deadline (session was
|
|
84
|
+
* prompted and may have partial state mutations). */
|
|
85
|
+
prompted: boolean;
|
|
59
86
|
}> {
|
|
60
87
|
const startTime = start ?? Date.now();
|
|
61
88
|
const stallTimeoutMs = getStallTimeoutMs();
|
|
@@ -65,6 +92,9 @@ export async function runAgentSession(
|
|
|
65
92
|
let stalled = false;
|
|
66
93
|
let stalledPhase: string | undefined;
|
|
67
94
|
let clearStallDeadline: (() => void) | undefined;
|
|
95
|
+
let deadlineExceeded = false;
|
|
96
|
+
let clearDeadline: (() => void) | undefined;
|
|
97
|
+
let prompted = false;
|
|
68
98
|
const activities: ToolActivity[] = [];
|
|
69
99
|
const pendingById = new Map<string, ToolActivity>();
|
|
70
100
|
let sessionEventGeneration = 0;
|
|
@@ -110,7 +140,7 @@ export async function runAgentSession(
|
|
|
110
140
|
new Promise<void>((resolve) => setImmediate(resolve));
|
|
111
141
|
|
|
112
142
|
const requestSessionCancellation = (
|
|
113
|
-
source: "parent-aborted" | "stalled",
|
|
143
|
+
source: "parent-aborted" | "stalled" | "deadline",
|
|
114
144
|
): void => {
|
|
115
145
|
const logFailure = (operation: string, error: unknown) => {
|
|
116
146
|
console.error(`[delegate] ${source} subagent ${operation} failed`, error);
|
|
@@ -171,9 +201,14 @@ export async function runAgentSession(
|
|
|
171
201
|
*/
|
|
172
202
|
const waitForSessionQuiescence = async (): Promise<void> => {
|
|
173
203
|
const cancelledGraceMs = 50;
|
|
174
|
-
const cancellationRequested = () =>
|
|
175
|
-
|
|
176
|
-
|
|
204
|
+
const cancellationRequested = () =>
|
|
205
|
+
signal?.aborted || stalled || deadlineExceeded;
|
|
206
|
+
const cancellationSource = (): "parent-aborted" | "stalled" | "deadline" =>
|
|
207
|
+
signal?.aborted
|
|
208
|
+
? "parent-aborted"
|
|
209
|
+
: deadlineExceeded
|
|
210
|
+
? "deadline"
|
|
211
|
+
: "stalled";
|
|
177
212
|
let quietTurns = 0;
|
|
178
213
|
let graceWaited = false;
|
|
179
214
|
while (quietTurns < 2) {
|
|
@@ -235,14 +270,24 @@ export async function runAgentSession(
|
|
|
235
270
|
const fireProgress = () => {
|
|
236
271
|
if (!onProgress) return;
|
|
237
272
|
const delta = currentUsage().totalTokens;
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
273
|
+
try {
|
|
274
|
+
onProgress({
|
|
275
|
+
tokens: delta,
|
|
276
|
+
toolUses,
|
|
277
|
+
durationMs: Date.now() - startTime,
|
|
278
|
+
lastActivityAt,
|
|
279
|
+
activities: [...activities],
|
|
280
|
+
failureKind: signal?.aborted
|
|
281
|
+
? undefined
|
|
282
|
+
: deadlineExceeded
|
|
283
|
+
? "deadline_exceeded"
|
|
284
|
+
: stalled
|
|
285
|
+
? "stalled"
|
|
286
|
+
: undefined,
|
|
287
|
+
});
|
|
288
|
+
} catch (error) {
|
|
289
|
+
console.error("[delegate] progress callback threw; continuing", error);
|
|
290
|
+
}
|
|
246
291
|
};
|
|
247
292
|
|
|
248
293
|
const stallError = () =>
|
|
@@ -252,10 +297,12 @@ export async function runAgentSession(
|
|
|
252
297
|
clearStallDeadline = undefined;
|
|
253
298
|
};
|
|
254
299
|
const abortForStall = () => {
|
|
255
|
-
if (stalled || signal?.aborted) return;
|
|
300
|
+
if (stalled || signal?.aborted || deadlineExceeded) return;
|
|
256
301
|
stalled = true;
|
|
257
302
|
stalledPhase = phase;
|
|
258
303
|
clearStallWatchdog();
|
|
304
|
+
clearDeadline?.();
|
|
305
|
+
clearDeadline = undefined;
|
|
259
306
|
console.warn(
|
|
260
307
|
`[delegate] stalled subagent detected after ${fmtDuration(stallTimeoutMs)} while ${phase}; requesting cooperative cancellation`,
|
|
261
308
|
);
|
|
@@ -269,12 +316,41 @@ export async function runAgentSession(
|
|
|
269
316
|
};
|
|
270
317
|
const armStallWatchdog = (graceMs = 0) => {
|
|
271
318
|
clearStallWatchdog();
|
|
272
|
-
if (!stallTimeoutMs || stalled || signal?.aborted)
|
|
319
|
+
if (!stallTimeoutMs || stalled || signal?.aborted || deadlineExceeded)
|
|
320
|
+
return;
|
|
273
321
|
|
|
274
322
|
const grace = Number.isFinite(graceMs) && graceMs > 0 ? graceMs : 0;
|
|
275
323
|
const deadline = Date.now() + stallTimeoutMs + grace;
|
|
276
324
|
clearStallDeadline = scheduleDeadline(deadline, abortForStall);
|
|
277
325
|
};
|
|
326
|
+
|
|
327
|
+
const deadlineError = () =>
|
|
328
|
+
formatDeadlineExceededError(deadlineAt ? deadlineAt - startTime : 0);
|
|
329
|
+
const clearDeadlineWatchdog = () => {
|
|
330
|
+
clearDeadline?.();
|
|
331
|
+
clearDeadline = undefined;
|
|
332
|
+
};
|
|
333
|
+
const abortForDeadline = () => {
|
|
334
|
+
if (deadlineExceeded || signal?.aborted) return;
|
|
335
|
+
deadlineExceeded = true;
|
|
336
|
+
clearDeadlineWatchdog();
|
|
337
|
+
clearStallWatchdog();
|
|
338
|
+
console.warn(
|
|
339
|
+
`[delegate] subagent exceeded its wall-clock deadline; requesting cooperative cancellation`,
|
|
340
|
+
);
|
|
341
|
+
requestSessionCancellation("deadline");
|
|
342
|
+
fireProgress();
|
|
343
|
+
};
|
|
344
|
+
const armDeadlineWatchdog = () => {
|
|
345
|
+
clearDeadlineWatchdog();
|
|
346
|
+
if (!deadlineAt || deadlineExceeded || stalled || signal?.aborted) return;
|
|
347
|
+
if (Date.now() >= deadlineAt) {
|
|
348
|
+
abortForDeadline();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
clearDeadline = scheduleDeadline(deadlineAt, abortForDeadline);
|
|
352
|
+
};
|
|
353
|
+
|
|
278
354
|
const noteActivity = (nextPhase: string, graceMs = 0) => {
|
|
279
355
|
lastActivityAt = Date.now();
|
|
280
356
|
phase = nextPhase;
|
|
@@ -593,6 +669,7 @@ export async function runAgentSession(
|
|
|
593
669
|
if (signal) {
|
|
594
670
|
abortHandler = () => {
|
|
595
671
|
clearStallWatchdog();
|
|
672
|
+
clearDeadlineWatchdog();
|
|
596
673
|
// Fire-and-forget: prompt()/the quiescence barrier observe cancellation
|
|
597
674
|
// and then return the partial evidence that this runner reports.
|
|
598
675
|
requestSessionCancellation("parent-aborted");
|
|
@@ -621,34 +698,107 @@ export async function runAgentSession(
|
|
|
621
698
|
tokens: 0,
|
|
622
699
|
usage: emptyUsage(),
|
|
623
700
|
touchedFiles: [],
|
|
701
|
+
attributedFiles: [],
|
|
702
|
+
prompted: false,
|
|
624
703
|
};
|
|
625
704
|
}
|
|
626
705
|
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
|
|
630
|
-
|
|
706
|
+
// If the deadline is already in the past, request cooperative cancellation
|
|
707
|
+
// and wait for the session to settle before returning ownership. Starting a
|
|
708
|
+
// prompt after the deadline would let the session do work without an active
|
|
709
|
+
// deadline watchdog.
|
|
710
|
+
if (deadlineAt && Date.now() >= deadlineAt) {
|
|
711
|
+
abortForDeadline();
|
|
712
|
+
// A pre-prompt deadline still calls session.abort(), so any in-flight
|
|
713
|
+
// compaction or extension work must settle before lifecycle disposes or
|
|
714
|
+
// reuses the session. The same quiescence barrier used after prompt()
|
|
715
|
+
// handles a never-prompted session safely — it just observes idle state.
|
|
716
|
+
//
|
|
717
|
+
// Keep the parent abort listener armed during the quiescence wait. If the
|
|
718
|
+
// parent signal aborts while we are waiting, the runner should report the
|
|
719
|
+
// parent cancellation ("Aborted") rather than the pre-expired deadline.
|
|
720
|
+
await waitForSessionQuiescence();
|
|
721
|
+
clearStallWatchdog();
|
|
722
|
+
clearDeadlineWatchdog();
|
|
723
|
+
if (signal && abortHandler)
|
|
724
|
+
signal.removeEventListener("abort", abortHandler);
|
|
725
|
+
unsubscribe();
|
|
726
|
+
if (signal?.aborted) {
|
|
727
|
+
return {
|
|
728
|
+
output: "",
|
|
729
|
+
error: "Aborted",
|
|
730
|
+
durationMs: Date.now() - startTime,
|
|
731
|
+
tokens: 0,
|
|
732
|
+
usage: emptyUsage(),
|
|
733
|
+
touchedFiles: [],
|
|
734
|
+
attributedFiles: [],
|
|
735
|
+
prompted: false,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
return {
|
|
739
|
+
output: "(no output)",
|
|
740
|
+
error: deadlineError(),
|
|
741
|
+
durationMs: Date.now() - startTime,
|
|
742
|
+
tokens: 0,
|
|
743
|
+
usage: emptyUsage(),
|
|
744
|
+
touchedFiles: [],
|
|
745
|
+
attributedFiles: [],
|
|
746
|
+
failureKind: "deadline_exceeded",
|
|
747
|
+
prompted: false,
|
|
748
|
+
};
|
|
749
|
+
}
|
|
631
750
|
|
|
632
751
|
try {
|
|
752
|
+
// Start detection only once the session is ready to receive its prompt;
|
|
753
|
+
// queued delegate tasks never enter this runner and therefore never time out.
|
|
754
|
+
armStallWatchdog();
|
|
755
|
+
armDeadlineWatchdog();
|
|
756
|
+
fireProgress();
|
|
757
|
+
|
|
758
|
+
prompted = true;
|
|
633
759
|
await session.prompt(prompt);
|
|
634
760
|
await waitForSessionQuiescence();
|
|
761
|
+
|
|
762
|
+
// The model is done; inactivity is no longer the right watchdog. Git
|
|
763
|
+
// evidence collection can take several seconds (up to 5s per git call),
|
|
764
|
+
// so keep the wall-clock deadline armed through it while preventing the
|
|
765
|
+
// stall watchdog from firing on the silent git commands.
|
|
635
766
|
clearStallWatchdog();
|
|
767
|
+
phase = "collecting git evidence";
|
|
768
|
+
lastActivityAt = Date.now();
|
|
636
769
|
|
|
770
|
+
const gitAfter = await getGitChangedFiles(config.cwd);
|
|
771
|
+
if (deadlineAt && Date.now() >= deadlineAt && !deadlineExceeded) {
|
|
772
|
+
abortForDeadline();
|
|
773
|
+
}
|
|
774
|
+
clearDeadlineWatchdog();
|
|
775
|
+
// If the deadline or parent abort fired after the first quiescence barrier,
|
|
776
|
+
// including while Git was running, the fire-and-forget
|
|
777
|
+
// cancellation is still unwinding. Wait for the same quiescence barrier so
|
|
778
|
+
// lifecycle does not dispose/reuse the session while compaction or an
|
|
779
|
+
// extension callback is still active.
|
|
780
|
+
if (deadlineExceeded || signal?.aborted) await waitForSessionQuiescence();
|
|
781
|
+
|
|
782
|
+
// Recompute evidence after the final quiescence wait. Output, usage,
|
|
783
|
+
// activity-derived touched files, and the session error state can all be
|
|
784
|
+
// mutated by the unwinding work that the barrier just waited for.
|
|
637
785
|
const state = session.state as { errorMessage?: string };
|
|
638
786
|
const output = capturedOutput();
|
|
639
787
|
const usage = currentUsage();
|
|
640
|
-
|
|
641
|
-
// Compute touched files: union of activity-based (edit/write) and git diff
|
|
642
|
-
// against the pre-prompt baseline. Independent of the runner's event model.
|
|
643
788
|
const fromActivities = extractTouchedFromActivities(activities, config.cwd);
|
|
644
|
-
const
|
|
645
|
-
|
|
789
|
+
const fromGit =
|
|
790
|
+
gitBaseline && gitAfter
|
|
791
|
+
? [...gitAfter].filter((f) => !gitBaseline.has(f))
|
|
792
|
+
: [];
|
|
646
793
|
const touchedFiles = [...new Set([...fromActivities, ...fromGit])];
|
|
647
|
-
const
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
794
|
+
const attributedFiles = fromActivities;
|
|
795
|
+
const errorMessage = signal?.aborted
|
|
796
|
+
? "Aborted"
|
|
797
|
+
: deadlineExceeded
|
|
798
|
+
? deadlineError()
|
|
799
|
+
: stalled
|
|
800
|
+
? stallError()
|
|
801
|
+
: state.errorMessage;
|
|
652
802
|
|
|
653
803
|
return {
|
|
654
804
|
output: output || "(no output)",
|
|
@@ -657,26 +807,62 @@ export async function runAgentSession(
|
|
|
657
807
|
tokens: usage.totalTokens,
|
|
658
808
|
usage,
|
|
659
809
|
touchedFiles,
|
|
660
|
-
|
|
810
|
+
attributedFiles,
|
|
811
|
+
failureKind: signal?.aborted
|
|
812
|
+
? undefined
|
|
813
|
+
: deadlineExceeded
|
|
814
|
+
? "deadline_exceeded"
|
|
815
|
+
: stalled
|
|
816
|
+
? "stalled"
|
|
817
|
+
: undefined,
|
|
818
|
+
prompted,
|
|
661
819
|
};
|
|
662
820
|
} catch (err) {
|
|
663
821
|
// Preserve partial-work evidence: whatever assistant output, token spend,
|
|
664
|
-
// and touched files accumulated before the failure/abort.
|
|
822
|
+
// and touched files accumulated before the failure/abort. The touched-file
|
|
823
|
+
// union is still best-effort; any edit/write activity and git-visible
|
|
824
|
+
// changes observed so far are retained. Keep the stall watchdog armed
|
|
825
|
+
// through the quiescence barrier so a busy session that stops emitting
|
|
826
|
+
// events is still rescued; clear it only once the barrier completes.
|
|
827
|
+
await waitForSessionQuiescence();
|
|
828
|
+
clearStallWatchdog();
|
|
829
|
+
phase = "collecting git evidence";
|
|
830
|
+
lastActivityAt = Date.now();
|
|
831
|
+
|
|
832
|
+
const gitAfter = await getGitChangedFiles(config.cwd);
|
|
833
|
+
if (deadlineAt && Date.now() >= deadlineAt && !deadlineExceeded) {
|
|
834
|
+
abortForDeadline();
|
|
835
|
+
}
|
|
836
|
+
clearDeadlineWatchdog();
|
|
837
|
+
// If the deadline or parent abort fired after the first quiescence barrier,
|
|
838
|
+
// including while Git was running, the fire-and-forget
|
|
839
|
+
// cancellation is still unwinding. Wait for the same quiescence barrier so
|
|
840
|
+
// lifecycle does not dispose/reuse the session while compaction or an
|
|
841
|
+
// extension callback is still active.
|
|
842
|
+
if (deadlineExceeded || signal?.aborted) await waitForSessionQuiescence();
|
|
843
|
+
|
|
844
|
+
// Recompute evidence after the final quiescence wait. Output, usage, and
|
|
845
|
+
// activity-derived touched files can all be mutated by the unwinding work
|
|
846
|
+
// that the barrier just waited for.
|
|
665
847
|
const partialOutput = capturedOutput();
|
|
666
848
|
const usage = currentUsage();
|
|
667
|
-
|
|
668
849
|
const fromActivities = extractTouchedFromActivities(activities, config.cwd);
|
|
669
|
-
const
|
|
670
|
-
|
|
850
|
+
const fromGit =
|
|
851
|
+
gitBaseline && gitAfter
|
|
852
|
+
? [...gitAfter].filter((f) => !gitBaseline.has(f))
|
|
853
|
+
: [];
|
|
671
854
|
const touchedFiles = [...new Set([...fromActivities, ...fromGit])];
|
|
855
|
+
const attributedFiles = fromActivities;
|
|
672
856
|
|
|
673
|
-
const msg =
|
|
674
|
-
?
|
|
675
|
-
:
|
|
676
|
-
?
|
|
677
|
-
:
|
|
678
|
-
?
|
|
679
|
-
:
|
|
857
|
+
const msg = signal?.aborted
|
|
858
|
+
? "Aborted"
|
|
859
|
+
: deadlineExceeded
|
|
860
|
+
? deadlineError()
|
|
861
|
+
: stalled
|
|
862
|
+
? stallError()
|
|
863
|
+
: err instanceof Error
|
|
864
|
+
? err.message
|
|
865
|
+
: String(err);
|
|
680
866
|
return {
|
|
681
867
|
output: partialOutput || "(no output)",
|
|
682
868
|
error: msg,
|
|
@@ -684,10 +870,19 @@ export async function runAgentSession(
|
|
|
684
870
|
tokens: usage.totalTokens,
|
|
685
871
|
usage,
|
|
686
872
|
touchedFiles,
|
|
687
|
-
|
|
873
|
+
attributedFiles,
|
|
874
|
+
failureKind: signal?.aborted
|
|
875
|
+
? undefined
|
|
876
|
+
: deadlineExceeded
|
|
877
|
+
? "deadline_exceeded"
|
|
878
|
+
: stalled
|
|
879
|
+
? "stalled"
|
|
880
|
+
: undefined,
|
|
881
|
+
prompted,
|
|
688
882
|
};
|
|
689
883
|
} finally {
|
|
690
884
|
clearStallWatchdog();
|
|
885
|
+
clearDeadlineWatchdog();
|
|
691
886
|
if (signal && abortHandler)
|
|
692
887
|
signal.removeEventListener("abort", abortHandler);
|
|
693
888
|
unsubscribe();
|