@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/format.ts
CHANGED
|
@@ -185,6 +185,11 @@ export function trunc(s: string, n: number): string {
|
|
|
185
185
|
return s.length <= n ? s : s.slice(0, n - 1) + "…";
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
/** Render an optional task `id` in a compact, visually distinct form. */
|
|
189
|
+
export function formatTaskId(id: string | undefined): string {
|
|
190
|
+
return id ? ` #${id}` : "";
|
|
191
|
+
}
|
|
192
|
+
|
|
188
193
|
/**
|
|
189
194
|
* Extract a single-line preview of agent output for collapsed final display.
|
|
190
195
|
*
|
|
@@ -340,7 +345,7 @@ function isResumableSessionFile(sessionFile: string): boolean {
|
|
|
340
345
|
* path that didn't exist on disk.
|
|
341
346
|
*
|
|
342
347
|
* Emits:
|
|
343
|
-
* [FAILED|ABORTED: <error> · session: <shortpath> · touched: <files>]
|
|
348
|
+
* [FAILED|ABORTED: <error> · session: <shortpath> · touched (best-effort): <files>]
|
|
344
349
|
* <partial output, when available>
|
|
345
350
|
* → To retry: delegate({ tasks: [{ resumeFrom: "<path>", prompt: "continue" }] })
|
|
346
351
|
*
|
|
@@ -351,12 +356,12 @@ function isResumableSessionFile(sessionFile: string): boolean {
|
|
|
351
356
|
*/
|
|
352
357
|
export function formatFailedTask(r: TaskResult, cwd?: string): string[] {
|
|
353
358
|
const parts: string[] = [];
|
|
354
|
-
const isAbort =
|
|
359
|
+
const isAbort = r.error === "Aborted";
|
|
355
360
|
// Empty string is falsy but not nullish — `||` covers both undefined and "".
|
|
356
361
|
const failParts = [r.error || "unknown error"];
|
|
357
362
|
if (r.sessionFile) failParts.push(`session: ${shortenPath(r.sessionFile)}`);
|
|
358
363
|
const touched = cwd ? relativeTouchedSummary(r.touchedFiles, cwd) : null;
|
|
359
|
-
if (touched) failParts.push(`touched: ${touched}`);
|
|
364
|
+
if (touched) failParts.push(`touched (best-effort): ${touched}`);
|
|
360
365
|
parts.push(`[${isAbort ? "ABORTED" : "FAILED"}: ${failParts.join(" · ")}]`);
|
|
361
366
|
|
|
362
367
|
// Surface partial assistant output even when the task did not complete.
|
|
@@ -398,7 +403,7 @@ export function formatFailedTask(r: TaskResult, cwd?: string): string[] {
|
|
|
398
403
|
* Emits:
|
|
399
404
|
* === <agent>: <truncated prompt> ===
|
|
400
405
|
* [WARNING: <w>] (per warning, if any)
|
|
401
|
-
* [FAILED: ...] / [OK | <duration> | <tokens> tokens · <sessionFile> · touched: <files>]
|
|
406
|
+
* [FAILED: ...] / [OK | <duration> | <tokens> tokens · <sessionFile> · touched (best-effort): <files>]
|
|
402
407
|
*
|
|
403
408
|
* <output> (success body only)
|
|
404
409
|
*
|
|
@@ -410,10 +415,10 @@ export function formatCompletedTask(
|
|
|
410
415
|
result: TaskResult,
|
|
411
416
|
): string[] {
|
|
412
417
|
const parts: string[] = [];
|
|
413
|
-
// `|| task.
|
|
418
|
+
// `|| task.sessionAction` covers action-only tasks (close/list/...) where prompt is
|
|
414
419
|
// empty. Async prompt tasks always set prompt, so this is a no-op there.
|
|
415
420
|
parts.push(
|
|
416
|
-
`=== ${result.agent}: ${trunc(task.prompt || task.
|
|
421
|
+
`=== ${result.agent}${formatTaskId(result.id ?? task.id)}: ${trunc(task.prompt || task.sessionAction || "", 80)} ===`,
|
|
417
422
|
);
|
|
418
423
|
if (task.warnings?.length) {
|
|
419
424
|
for (const w of task.warnings) parts.push(`[WARNING: ${w}]`);
|
|
@@ -426,7 +431,7 @@ export function formatCompletedTask(
|
|
|
426
431
|
];
|
|
427
432
|
if (result.sessionFile) meta.push(shortenPath(result.sessionFile));
|
|
428
433
|
const touched = relativeTouchedSummary(result.touchedFiles, task.cwd);
|
|
429
|
-
if (touched) meta.push(`touched: ${touched}`);
|
|
434
|
+
if (touched) meta.push(`touched (best-effort): ${touched}`);
|
|
430
435
|
parts.push(
|
|
431
436
|
`[${meta.join(" · ")}]\n\n${renderOutputForLLM(result.output, result.agent)}`,
|
|
432
437
|
);
|
|
@@ -504,3 +509,37 @@ export function relativeTouchedSummary(
|
|
|
504
509
|
.filter((f) => f && !f.startsWith(".."));
|
|
505
510
|
return rel.length ? rel.join(", ") : null;
|
|
506
511
|
}
|
|
512
|
+
|
|
513
|
+
/** Find absolute paths directly attributed to more than one task result.
|
|
514
|
+
*
|
|
515
|
+
* Overlap is computed from {@link TaskResult.attributedFiles} (edit/write
|
|
516
|
+
* tool calls), not from {@link TaskResult.touchedFiles}, so concurrent tasks
|
|
517
|
+
* in the same repository do not fabricate false conflicts from shared
|
|
518
|
+
* repository-wide git snapshots. */
|
|
519
|
+
export function findTouchedOverlaps(
|
|
520
|
+
results: readonly { attributedFiles?: string[] }[],
|
|
521
|
+
): string[] {
|
|
522
|
+
const counts = new Map<string, number>();
|
|
523
|
+
for (const r of results) {
|
|
524
|
+
for (const f of r.attributedFiles ?? []) {
|
|
525
|
+
counts.set(f, (counts.get(f) ?? 0) + 1);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return [...counts.entries()]
|
|
529
|
+
.filter(([, count]) => count > 1)
|
|
530
|
+
.map(([file]) => file)
|
|
531
|
+
.sort();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Format a post-dispatch overlap warning, or null when there is no overlap.
|
|
536
|
+
*
|
|
537
|
+
* The warning is deliberately conservative: it only reports paths that two or
|
|
538
|
+
* more tasks claimed to touch. It does NOT claim that disjoint touchedFiles
|
|
539
|
+
* mean there was no conflict, and it does NOT claim filesystem isolation or
|
|
540
|
+
* rollback.
|
|
541
|
+
*/
|
|
542
|
+
export function formatTouchedOverlapWarning(overlaps: string[]): string | null {
|
|
543
|
+
if (!overlaps.length) return null;
|
|
544
|
+
return `WARNING: These tasks reported touching the same file(s): ${overlaps.join(", ")}. Delegate does not isolate or serialize file access and does not roll back completed writes.`;
|
|
545
|
+
}
|
package/leaf.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-tree leaf affinity for async tickets.
|
|
3
|
+
*
|
|
4
|
+
* An async ticket outlives the turn that spawned it. `/tree` navigation moves
|
|
5
|
+
* the session to a different leaf **within the same session file**: no
|
|
6
|
+
* `session_shutdown` fires, the extension runtime stays live, and the ticket
|
|
7
|
+
* keeps running. When it finishes, `deliverTicketResults` wakes the agent at
|
|
8
|
+
* whatever leaf is active *now* — which may be a branch that knows nothing
|
|
9
|
+
* about the task. See GitHub issue #30.
|
|
10
|
+
*
|
|
11
|
+
* pi exposes no "what leaf am I on?" query, so the current leaf has to be
|
|
12
|
+
* tracked from the `session_tree` event (`newLeafId`). That event also fires
|
|
13
|
+
* for extension-driven `ctx.navigateTree`, so this tracking covers navigation
|
|
14
|
+
* that never passed the `session_before_tree` confirm guard.
|
|
15
|
+
*
|
|
16
|
+
* State is runtime-scoped: `resetLeafTracking()` on session shutdown, since a
|
|
17
|
+
* replacement session starts on its own (unknown) leaf.
|
|
18
|
+
*/
|
|
19
|
+
import type { AsyncTicket } from "./types.ts";
|
|
20
|
+
|
|
21
|
+
/** Leaf the session is currently on. `undefined` means no navigation has been
|
|
22
|
+
* observed by this runtime — i.e. the leaf the session opened on. */
|
|
23
|
+
let currentLeafId: string | null | undefined;
|
|
24
|
+
|
|
25
|
+
/** Record a completed `/tree` navigation. */
|
|
26
|
+
export function recordTreeNavigation(newLeafId: string | null): void {
|
|
27
|
+
currentLeafId = newLeafId;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Leaf id to stamp on a ticket at spawn time. */
|
|
31
|
+
export function getCurrentLeafId(): string | null | undefined {
|
|
32
|
+
return currentLeafId;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resetLeafTracking(): void {
|
|
36
|
+
currentLeafId = undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** True when the session has navigated away from the leaf that spawned this
|
|
40
|
+
* ticket, so delivering its result would wake the agent on a foreign branch.
|
|
41
|
+
*
|
|
42
|
+
* Navigating away and back to the spawn leaf yields a fresh leaf id and is
|
|
43
|
+
* therefore reported as cross-leaf. That false positive is deliberate: the
|
|
44
|
+
* cross-leaf path only downgrades delivery to non-waking, and reconstructing
|
|
45
|
+
* true leaf identity across a round trip is not worth the complexity. */
|
|
46
|
+
export function isCrossLeafTicket(ticket: AsyncTicket): boolean {
|
|
47
|
+
return ticket.spawnLeafId !== currentLeafId;
|
|
48
|
+
}
|
package/lifecycle.ts
CHANGED
|
@@ -20,12 +20,13 @@ import {
|
|
|
20
20
|
persistSessionHeader,
|
|
21
21
|
setParentSession,
|
|
22
22
|
} from "./sessions.ts";
|
|
23
|
-
import { runAgentSession } from "./runner.ts";
|
|
23
|
+
import { runAgentSession, formatDeadlineExceededError } from "./runner.ts";
|
|
24
24
|
import { getGitChangedFiles } from "./file-tracking.ts";
|
|
25
25
|
import { getHostDeps } from "./host.ts";
|
|
26
26
|
import { resolveCwd, validateResumeFromPath } from "./utils.ts";
|
|
27
27
|
import { getWholeTaskMaxRetries, getWholeTaskBaseDelayMs } from "./config.ts";
|
|
28
28
|
import { addUsage, emptyUsage } from "./usage.ts";
|
|
29
|
+
import { scheduleDeadline } from "./timer.ts";
|
|
29
30
|
|
|
30
31
|
/** Internal seam for lifecycle-level tests without replacing session ownership. */
|
|
31
32
|
type RunAgentSession = typeof runAgentSession;
|
|
@@ -72,6 +73,7 @@ function failTask(
|
|
|
72
73
|
sessionFile?: string,
|
|
73
74
|
): TaskResult {
|
|
74
75
|
return {
|
|
76
|
+
id: task.id,
|
|
75
77
|
agent: task.agentName,
|
|
76
78
|
output: "",
|
|
77
79
|
error,
|
|
@@ -80,6 +82,7 @@ function failTask(
|
|
|
80
82
|
usage: emptyUsage(),
|
|
81
83
|
sessionFile,
|
|
82
84
|
touchedFiles: [],
|
|
85
|
+
attributedFiles: [],
|
|
83
86
|
};
|
|
84
87
|
}
|
|
85
88
|
|
|
@@ -91,6 +94,7 @@ function completeSessionAction(
|
|
|
91
94
|
elapsedMs?: number,
|
|
92
95
|
): TaskResult {
|
|
93
96
|
return {
|
|
97
|
+
id: task.id,
|
|
94
98
|
agent: task.agentName,
|
|
95
99
|
output,
|
|
96
100
|
durationMs: elapsedMs ?? 0,
|
|
@@ -98,6 +102,7 @@ function completeSessionAction(
|
|
|
98
102
|
usage: emptyUsage(),
|
|
99
103
|
sessionFile: undefined,
|
|
100
104
|
touchedFiles: [],
|
|
105
|
+
attributedFiles: [],
|
|
101
106
|
};
|
|
102
107
|
}
|
|
103
108
|
|
|
@@ -266,6 +271,7 @@ function canRetryWholeTask(
|
|
|
266
271
|
return (
|
|
267
272
|
result.failureKind !== "stalled" &&
|
|
268
273
|
result.failureKind !== "model_error" &&
|
|
274
|
+
result.failureKind !== "deadline_exceeded" &&
|
|
269
275
|
!task.sessionId &&
|
|
270
276
|
!task.resumeFrom &&
|
|
271
277
|
result.touchedFiles.length === 0 &&
|
|
@@ -277,16 +283,23 @@ function canRetryWholeTask(
|
|
|
277
283
|
async function sleepForWholeTaskRetry(
|
|
278
284
|
signal: AbortSignal | undefined,
|
|
279
285
|
delayMs: number,
|
|
286
|
+
deadlineAt?: number,
|
|
280
287
|
): Promise<void> {
|
|
281
288
|
if (signal?.aborted) return;
|
|
289
|
+
if (deadlineAt !== undefined && Date.now() >= deadlineAt) return;
|
|
290
|
+
|
|
291
|
+
const retryAt = Date.now() + delayMs;
|
|
292
|
+
const wakeAt =
|
|
293
|
+
deadlineAt !== undefined ? Math.min(retryAt, deadlineAt) : retryAt;
|
|
294
|
+
|
|
282
295
|
await new Promise<void>((resolve) => {
|
|
283
|
-
let
|
|
296
|
+
let clear: (() => void) | undefined;
|
|
284
297
|
const done = () => {
|
|
285
|
-
|
|
298
|
+
clear?.();
|
|
286
299
|
signal?.removeEventListener("abort", done);
|
|
287
300
|
resolve();
|
|
288
301
|
};
|
|
289
|
-
|
|
302
|
+
clear = scheduleDeadline(wakeAt, done);
|
|
290
303
|
if (!signal) return;
|
|
291
304
|
signal.addEventListener("abort", done, { once: true });
|
|
292
305
|
});
|
|
@@ -516,7 +529,7 @@ function resolveResumableSessionFile(
|
|
|
516
529
|
* Used by both sync (params.async === false) and async (params.async === true) paths.
|
|
517
530
|
* When task.sessionId is set, the entire acquire/run/close lifecycle runs under
|
|
518
531
|
* a per-session mutex so concurrent tasks with the same sessionId serialize
|
|
519
|
-
* cleanly. The lock also covers
|
|
532
|
+
* cleanly. The lock also covers sessionAction='close' and the early-busy/abort paths. */
|
|
520
533
|
export async function runResolvedTask(
|
|
521
534
|
env: TaskRunEnv,
|
|
522
535
|
task: ResolvedTask,
|
|
@@ -558,12 +571,12 @@ async function runResolvedTaskUnlocked(
|
|
|
558
571
|
p.model = task.model?.id;
|
|
559
572
|
|
|
560
573
|
// ── Session action handling ───────────────────────────────────────
|
|
561
|
-
if (task.
|
|
574
|
+
if (task.sessionAction === "close") {
|
|
562
575
|
if (!task.sessionId) {
|
|
563
576
|
return finishTask(
|
|
564
577
|
env,
|
|
565
578
|
p,
|
|
566
|
-
failTask(task, "
|
|
579
|
+
failTask(task, "sessionAction='close' requires sessionId."),
|
|
567
580
|
);
|
|
568
581
|
}
|
|
569
582
|
// The per-session lock for action-based operations is already held by the
|
|
@@ -583,7 +596,7 @@ async function runResolvedTaskUnlocked(
|
|
|
583
596
|
);
|
|
584
597
|
}
|
|
585
598
|
|
|
586
|
-
if (task.
|
|
599
|
+
if (task.sessionAction === "list") {
|
|
587
600
|
return finishTask(
|
|
588
601
|
env,
|
|
589
602
|
p,
|
|
@@ -599,6 +612,10 @@ async function runResolvedTaskUnlocked(
|
|
|
599
612
|
let cumulativeTokens = 0;
|
|
600
613
|
let cumulativeToolUses = 0;
|
|
601
614
|
const taskStartedAt = Date.now();
|
|
615
|
+
const deadlineAt =
|
|
616
|
+
task.deadlineMs && task.deadlineMs > 0
|
|
617
|
+
? taskStartedAt + task.deadlineMs
|
|
618
|
+
: undefined;
|
|
602
619
|
let accumulatedUsage = emptyUsage();
|
|
603
620
|
|
|
604
621
|
const onAttemptProgress = (u: AgentProgressUpdate): void => {
|
|
@@ -651,7 +668,10 @@ async function runResolvedTaskUnlocked(
|
|
|
651
668
|
|
|
652
669
|
// Snapshot git status before the run so touchedFiles can diff after.
|
|
653
670
|
// AgentSession owns retry/compaction internally — runAgentSession just
|
|
654
|
-
// drives the prompt and maps events to the progress model.
|
|
671
|
+
// drives the prompt and maps events to the progress model. Git failures
|
|
672
|
+
// degrade to an undefined baseline, which tells the runner to skip
|
|
673
|
+
// git-based attribution entirely; see getGitChangedFiles for the
|
|
674
|
+
// contract.
|
|
655
675
|
const gitBaseline = await getGitChangedFiles(task.cwd);
|
|
656
676
|
let r = await runAgentSessionForTesting(
|
|
657
677
|
acquired.session,
|
|
@@ -660,7 +680,8 @@ async function runResolvedTaskUnlocked(
|
|
|
660
680
|
env.signal,
|
|
661
681
|
onProgress,
|
|
662
682
|
gitBaseline,
|
|
663
|
-
|
|
683
|
+
taskStartedAt,
|
|
684
|
+
deadlineAt,
|
|
664
685
|
);
|
|
665
686
|
|
|
666
687
|
cumulativeTokens += r.tokens;
|
|
@@ -682,7 +703,11 @@ async function runResolvedTaskUnlocked(
|
|
|
682
703
|
// Pool misses (including resumeFrom) transfer ownership only on
|
|
683
704
|
// successful completion; failures are owned by lifecycle and must
|
|
684
705
|
// be disposed in this finally path.
|
|
685
|
-
if (
|
|
706
|
+
if (
|
|
707
|
+
!r.error &&
|
|
708
|
+
r.failureKind !== "stalled" &&
|
|
709
|
+
r.failureKind !== "deadline_exceeded"
|
|
710
|
+
) {
|
|
686
711
|
const committed = pool.commit(task.sessionId, {
|
|
687
712
|
session: acquired.session,
|
|
688
713
|
sessionManager: acquired.sessionManager,
|
|
@@ -699,28 +724,37 @@ async function runResolvedTaskUnlocked(
|
|
|
699
724
|
sessionReleased = sessionReleased || committed;
|
|
700
725
|
}
|
|
701
726
|
} else {
|
|
702
|
-
// A stalled
|
|
703
|
-
//
|
|
704
|
-
|
|
727
|
+
// A stalled, parent-aborted, or mid-prompt deadline-exceeded pooled
|
|
728
|
+
// attempt is not safe to keep; the session may have been mutated.
|
|
729
|
+
// A pre-prompt deadline (runner never called session.prompt()) left
|
|
730
|
+
// the session in its pre-task state, so return it to the pool intact.
|
|
731
|
+
if (
|
|
732
|
+
r.failureKind === "stalled" ||
|
|
733
|
+
r.error === "Aborted" ||
|
|
734
|
+
(r.failureKind === "deadline_exceeded" && r.prompted !== false)
|
|
735
|
+
) {
|
|
705
736
|
try {
|
|
706
737
|
sessionReleased =
|
|
707
738
|
(await pool._closePooledAgentWithoutLock(task.sessionId)) ||
|
|
708
739
|
sessionReleased;
|
|
709
740
|
} catch (error) {
|
|
710
|
-
// Preserve the primary
|
|
741
|
+
// Preserve the primary failure result while logging the cleanup
|
|
711
742
|
// failure explicitly. A pooled session may still be removed by
|
|
712
743
|
// the pool; a pool-miss remains lifecycle-owned and is handled
|
|
713
744
|
// by the finally path above.
|
|
714
745
|
console.error(
|
|
715
|
-
`[delegate] failed to dispose stalled pooled session '${task.sessionId}'`,
|
|
746
|
+
`[delegate] failed to dispose aborted, stalled, or deadline-exceeded pooled session '${task.sessionId}'`,
|
|
716
747
|
error,
|
|
717
748
|
);
|
|
718
749
|
}
|
|
719
|
-
} else {
|
|
720
|
-
// Pool hits stay owned by the pool, and non-stalled
|
|
721
|
-
// (including failed attempts) must still count usage.
|
|
750
|
+
} else if (r.failureKind !== "deadline_exceeded") {
|
|
751
|
+
// Pool hits stay owned by the pool, and non-stalled, non-aborted
|
|
752
|
+
// completions (including failed attempts) must still count usage.
|
|
722
753
|
pool.recordUse(task.sessionId, r.tokens);
|
|
723
754
|
}
|
|
755
|
+
// Pre-prompt deadline (prompted === false): the pooled session was
|
|
756
|
+
// checked out but never used. Leave it in the pool with no usage
|
|
757
|
+
// recorded.
|
|
724
758
|
}
|
|
725
759
|
}
|
|
726
760
|
|
|
@@ -728,6 +762,7 @@ async function runResolvedTaskUnlocked(
|
|
|
728
762
|
cumulativeToolUses += attemptToolUsesObserved;
|
|
729
763
|
|
|
730
764
|
return {
|
|
765
|
+
id: task.id,
|
|
731
766
|
agent: task.agentName,
|
|
732
767
|
output: r.output,
|
|
733
768
|
error: r.error,
|
|
@@ -747,6 +782,7 @@ async function runResolvedTaskUnlocked(
|
|
|
747
782
|
usage: r.usage,
|
|
748
783
|
sessionFile,
|
|
749
784
|
touchedFiles: r.touchedFiles,
|
|
785
|
+
attributedFiles: r.attributedFiles ?? [],
|
|
750
786
|
};
|
|
751
787
|
} finally {
|
|
752
788
|
// This runs for ordinary success, normal provider failure, whole-task
|
|
@@ -756,9 +792,30 @@ async function runResolvedTaskUnlocked(
|
|
|
756
792
|
}
|
|
757
793
|
};
|
|
758
794
|
|
|
795
|
+
const buildDeadlineExceededResult = (prior?: TaskResult): TaskResult => {
|
|
796
|
+
const budgetMs = Math.max(0, (deadlineAt ?? 0) - taskStartedAt);
|
|
797
|
+
return {
|
|
798
|
+
id: task.id,
|
|
799
|
+
agent: task.agentName,
|
|
800
|
+
output: prior?.output ?? "",
|
|
801
|
+
error: formatDeadlineExceededError(budgetMs),
|
|
802
|
+
failureKind: "deadline_exceeded",
|
|
803
|
+
durationMs: prior?.durationMs ?? 0,
|
|
804
|
+
tokens: prior?.tokens ?? 0,
|
|
805
|
+
usage: prior?.usage ?? emptyUsage(),
|
|
806
|
+
sessionFile: prior?.sessionFile,
|
|
807
|
+
touchedFiles: prior?.touchedFiles ?? [],
|
|
808
|
+
attributedFiles: prior?.attributedFiles ?? [],
|
|
809
|
+
};
|
|
810
|
+
};
|
|
811
|
+
|
|
759
812
|
let result: TaskResult;
|
|
760
813
|
try {
|
|
761
|
-
|
|
814
|
+
if (deadlineAt && Date.now() >= deadlineAt) {
|
|
815
|
+
result = buildDeadlineExceededResult(undefined);
|
|
816
|
+
} else {
|
|
817
|
+
result = await runAttempt();
|
|
818
|
+
}
|
|
762
819
|
} catch (err) {
|
|
763
820
|
const failure = failTask(
|
|
764
821
|
task,
|
|
@@ -781,7 +838,7 @@ async function runResolvedTaskUnlocked(
|
|
|
781
838
|
) {
|
|
782
839
|
const delayMs = baseDelayMs * 2 ** retry;
|
|
783
840
|
p.durationMs = Math.max(p.durationMs, Date.now() - taskStartedAt);
|
|
784
|
-
await sleepForWholeTaskRetry(env.signal, delayMs);
|
|
841
|
+
await sleepForWholeTaskRetry(env.signal, delayMs, deadlineAt);
|
|
785
842
|
if (env.signal?.aborted) {
|
|
786
843
|
// Preserve any partial output/session path from the last failed attempt
|
|
787
844
|
// while recording that the retry loop was aborted. The task already
|
|
@@ -798,6 +855,11 @@ async function runResolvedTaskUnlocked(
|
|
|
798
855
|
break;
|
|
799
856
|
}
|
|
800
857
|
|
|
858
|
+
if (deadlineAt && Date.now() >= deadlineAt) {
|
|
859
|
+
result = buildDeadlineExceededResult(result);
|
|
860
|
+
break;
|
|
861
|
+
}
|
|
862
|
+
|
|
801
863
|
p.status = "running";
|
|
802
864
|
p.error = undefined;
|
|
803
865
|
p.failureKind = undefined;
|
package/manual.ts
CHANGED
|
@@ -73,13 +73,33 @@ export function getSubagentManualMarkdown(
|
|
|
73
73
|
'delegate({ tasks: [{ agent: "default", prompt: "Investigate the auth module" }] })',
|
|
74
74
|
"```",
|
|
75
75
|
"",
|
|
76
|
-
"Delegate subagents to execute tasks in parallel. Each subagent gets an independent
|
|
76
|
+
"Delegate subagents to execute tasks in parallel. Each subagent gets an independent conversation but uses the real filesystem at its task `cwd`; tasks sharing a directory can observe and overwrite one another's changes. Fresh prompts must therefore be self-contained, and dependent or shared-file work should run separately.",
|
|
77
|
+
"",
|
|
78
|
+
"The three handles have different lifetimes:",
|
|
79
|
+
"",
|
|
80
|
+
"- **ticket** — controls one async batch with `poll`, `wait`, or `cancel`.",
|
|
81
|
+
"- **sessionId** — a caller-chosen key for a live multi-turn worker, retained until close or parent shutdown.",
|
|
82
|
+
"- **resumeFrom** — an absolute `.jsonl` transcript path used to recover an interrupted worker.",
|
|
83
|
+
"",
|
|
84
|
+
"Each task entry may also carry an optional `id` — a caller-provided per-dispatch correlation key. Duplicate `id` values in the same call are rejected; when omitted, tasks are identified by array index, agent, and prompt.",
|
|
85
|
+
"",
|
|
86
|
+
"Subagents cannot call `delegate` recursively. Their tool activity runs at `cwd`, while Pi stores the runtime session transcript in its own session directory outside that `cwd`.",
|
|
87
|
+
"",
|
|
88
|
+
"## Touched Files (best-effort)",
|
|
89
|
+
"",
|
|
90
|
+
"The `touched:` list in each task result is a **best-effort lower bound**, not an authoritative record.",
|
|
91
|
+
"",
|
|
92
|
+
"- `write` and `edit` tool calls are captured reliably from the activity log.",
|
|
93
|
+
"- `bash` mutations are captured only when the task `cwd` is inside a git repo and git is available, via `git status` against the pre-run baseline.",
|
|
94
|
+
"- In a non-git directory, bash-mutated files are not reported.",
|
|
95
|
+
"- Git failures degrade to an empty diff.",
|
|
96
|
+
"- A path missing from `touched:` does **not** mean the file was unchanged. Delegate does not isolate file access or roll back writes.",
|
|
77
97
|
"",
|
|
78
98
|
"## Built-in Agent",
|
|
79
99
|
"",
|
|
80
100
|
"- **default**: mirrors the live parent model, thinking level, delegatable native tools, and base system prompt.",
|
|
81
101
|
"",
|
|
82
|
-
"Parent extension/MCP tools are not copied
|
|
102
|
+
"Parent extension/MCP tools are not copied. Parent-global `AGENTS.md` instructions are also excluded. Project-local context is rebuilt safely for the task's `cwd`; per-task fields remain explicit overrides.",
|
|
83
103
|
"",
|
|
84
104
|
"## Available Custom Agents",
|
|
85
105
|
"",
|
|
@@ -118,10 +138,10 @@ export function getSubagentManualMarkdown(
|
|
|
118
138
|
'delegate({ tasks: [{ prompt: "Now check the tests for that module", sessionId: "auth-research" }] })',
|
|
119
139
|
"",
|
|
120
140
|
"// Clean up when done",
|
|
121
|
-
'delegate({ tasks: [{ sessionId: "auth-research",
|
|
141
|
+
'delegate({ tasks: [{ sessionId: "auth-research", sessionAction: "close" }] })',
|
|
122
142
|
"```",
|
|
123
143
|
"",
|
|
124
|
-
'Pooled agents remain live until `
|
|
144
|
+
'Pooled agents remain live until `sessionAction: "close"` or parent Pi session shutdown.',
|
|
125
145
|
"",
|
|
126
146
|
"## Resuming Previous Sessions",
|
|
127
147
|
"",
|
|
@@ -158,28 +178,35 @@ export function getSubagentManualMarkdown(
|
|
|
158
178
|
"",
|
|
159
179
|
"## Async Mode",
|
|
160
180
|
"",
|
|
161
|
-
"Set `async: true` to run tasks in the background. The top-level `
|
|
181
|
+
"Set `async: true` to run tasks in the background. The top-level `ticketAction` controls the ticket:",
|
|
162
182
|
"",
|
|
163
183
|
"```ts",
|
|
164
184
|
'delegate({ async: true, tasks: [{ prompt: "Investigate auth", systemPrompt: "You are a focused investigator.", tools: ["read", "grep", "find", "ls"] }] })',
|
|
165
185
|
"```",
|
|
166
186
|
"",
|
|
167
|
-
'- `delegate({
|
|
168
|
-
'- `delegate({
|
|
169
|
-
'- `delegate({
|
|
170
|
-
'- `delegate({
|
|
171
|
-
'- `delegate({
|
|
187
|
+
'- `delegate({ ticketAction: "poll" })` \u2014 list all tickets',
|
|
188
|
+
'- `delegate({ ticketAction: "poll", ticket: "abc123" })` \u2014 check one ticket',
|
|
189
|
+
'- `delegate({ ticketAction: "wait", ticket: "abc123", timeoutMs: 600000 })` \u2014 block until finished or timeout',
|
|
190
|
+
'- `delegate({ ticketAction: "cancel", ticket: "abc123" })` \u2014 preview activity and partial effects before cancelling',
|
|
191
|
+
'- `delegate({ ticketAction: "cancel", ticket: "abc123", force: true })` \u2014 abort after review',
|
|
172
192
|
"",
|
|
173
193
|
`See the field tables above for the full semantics. Max ${getMaxAsyncTickets()} concurrent async tickets.`,
|
|
194
|
+
"Async results arrive as follow-up messages, so Pi cannot fold their usage into the parent session total; displayed task usage remains informational.",
|
|
174
195
|
"",
|
|
175
196
|
"## Gotchas",
|
|
176
197
|
"",
|
|
198
|
+
"- Dispatch validation is batch-wide and runs before spawning: one invalid task rejects the call without starting its siblings.",
|
|
177
199
|
"- `*` means read/write/edit/bash, not every tool. `grep`, `find`, and `ls` are valid explicit tools and are the `ro` preset.",
|
|
178
200
|
'- `tasks` is an array. The tool recovers common stringified calls for compatibility, but canonical calls use `{ tasks: [{ prompt: "..." }] }`.',
|
|
179
201
|
'- Use `agent: "default"` for the parent\'s live model/thinking/native tools/base prompt. Omitting `agent` creates an ad-hoc task with delegate defaults.',
|
|
180
202
|
"- An ad-hoc task with no `tools` uses `*`; a named custom task uses its profile; a profile with no tools uses `*`.",
|
|
181
203
|
"- Subagents inherit all skills discovered in their `cwd` (via AgentSession's resource loader). Per-task skill filtering is not supported — curate the cwd's skill set instead.",
|
|
182
204
|
`- Sync \`delegate\` runs at most ${getMaxConcurrent()} tasks at once (the rest queue, not fail). Use \`async: true\` to move work to the background.`,
|
|
205
|
+
"- `deadlineMs` is a per-task wall-clock budget measured from when the task starts running (after queuing). It requests cooperative abort and is not a hard kill; completed writes/commands remain. Omission disables the deadline.",
|
|
206
|
+
"",
|
|
207
|
+
"## Legacy `action` compatibility",
|
|
208
|
+
"",
|
|
209
|
+
"The overloaded `action` field was split into `ticketAction` (poll/wait/cancel) and `sessionAction` (prompt/close/list). Legacy `action` values are still accepted at runtime through automatic normalization, but new calls should use the canonical fields. Programmatic TypeScript consumers should note the exported type `DelegateAction` is now `TicketAction`.",
|
|
183
210
|
"",
|
|
184
211
|
"## Config",
|
|
185
212
|
"",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bermudi/pi-delegate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Delegate tool for the Pi coding agent.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
"prettier": "^3.8.4",
|
|
33
33
|
"typescript": "^5.9.0"
|
|
34
34
|
},
|
|
35
|
+
"patchedDependencies": {
|
|
36
|
+
"@marcfargas/pi-test-harness@0.6.1": "patches/@marcfargas%2Fpi-test-harness@0.6.1.patch"
|
|
37
|
+
},
|
|
35
38
|
"private": false,
|
|
36
39
|
"scripts": {
|
|
37
40
|
"test": "bun test",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
diff --git a/dist/session.js b/dist/session.js
|
|
2
|
+
index f7dc5de9be959c541ee4bab0ad1424656993bbe9..e515073926f0fbb0e2bc566a9b17f5f15f80a1a1 100644
|
|
3
|
+
--- a/dist/session.js
|
|
4
|
+
+++ b/dist/session.js
|
|
5
|
+
@@ -12,7 +12,7 @@ import * as fs from "node:fs";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
-import { getModel } from "@earendil-works/pi-ai";
|
|
10
|
+
+import { getModel } from "@earendil-works/pi-ai/compat";
|
|
11
|
+
import { createPlaybookStreamFn } from "./playbook.js";
|
|
12
|
+
import { interceptToolExecution } from "./mock-tools.js";
|
|
13
|
+
import { createMockUIContext } from "./mock-ui.js";
|
package/render-branches.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
|
3
3
|
import {
|
|
4
4
|
fmtDuration,
|
|
5
5
|
fmtTokens,
|
|
6
|
+
formatTaskId,
|
|
6
7
|
getActivityAge,
|
|
7
8
|
indent,
|
|
8
9
|
tree,
|
|
@@ -107,7 +108,7 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
107
108
|
case "done":
|
|
108
109
|
lines.push(
|
|
109
110
|
truncLine(
|
|
110
|
-
`${tree(i, total)} ${theme.fg("success", "✓")} ${theme.bold(p.agent)}${modelLabel(p)}${statJoin([fmtDuration(p.durationMs), `${fmtTokens(p.tokens)} tokens`])}`,
|
|
111
|
+
`${tree(i, total)} ${theme.fg("success", "✓")} ${theme.bold(p.agent)}${p.id ? theme.fg("accent", formatTaskId(p.id)) : ""}${modelLabel(p)}${statJoin([fmtDuration(p.durationMs), `${fmtTokens(p.tokens)} tokens`])}`,
|
|
111
112
|
w,
|
|
112
113
|
),
|
|
113
114
|
);
|
|
@@ -126,7 +127,7 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
126
127
|
case "failed":
|
|
127
128
|
lines.push(
|
|
128
129
|
truncLine(
|
|
129
|
-
`${tree(i, total)} ${theme.fg("error", "✗")} ${theme.bold(p.agent)}${modelLabel(p)}${p.error ? theme.fg("error", ` ${p.error}`) : ""}`,
|
|
130
|
+
`${tree(i, total)} ${theme.fg("error", "✗")} ${theme.bold(p.agent)}${p.id ? theme.fg("accent", formatTaskId(p.id)) : ""}${modelLabel(p)}${p.error ? theme.fg("error", ` ${p.error}`) : ""}`,
|
|
130
131
|
w,
|
|
131
132
|
),
|
|
132
133
|
);
|
|
@@ -146,14 +147,19 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
146
147
|
{
|
|
147
148
|
const activityAge = getActivityAge(p.lastActivityAt);
|
|
148
149
|
const ageTag = activityAge ? ` · ${activityAge}` : "";
|
|
149
|
-
const
|
|
150
|
-
p.failureKind === "
|
|
151
|
-
? theme.fg("
|
|
152
|
-
: ""
|
|
150
|
+
const issueTag =
|
|
151
|
+
p.failureKind === "deadline_exceeded"
|
|
152
|
+
? theme.fg("error", " · deadline exceeded · cancellation pending")
|
|
153
|
+
: p.failureKind === "stalled"
|
|
154
|
+
? theme.fg(
|
|
155
|
+
"warning",
|
|
156
|
+
" · stall detected · cancellation pending",
|
|
157
|
+
)
|
|
158
|
+
: "";
|
|
153
159
|
const glyph = theme.fg("warning", spinnerFrame());
|
|
154
160
|
lines.push(
|
|
155
161
|
truncLine(
|
|
156
|
-
`${tree(i, total)} ${glyph} ${theme.bold(p.agent)}${modelLabel(p)}${statJoin(runParts)}${
|
|
162
|
+
`${tree(i, total)} ${glyph} ${theme.bold(p.agent)}${p.id ? theme.fg("accent", formatTaskId(p.id)) : ""}${modelLabel(p)}${statJoin(runParts)}${issueTag}${theme.fg("muted", ageTag)}`,
|
|
157
163
|
w,
|
|
158
164
|
),
|
|
159
165
|
);
|
|
@@ -229,7 +235,7 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
229
235
|
);
|
|
230
236
|
lines.push(
|
|
231
237
|
truncLine(
|
|
232
|
-
`${tree(i, total)} ${theme.fg("muted", "○")} ${theme.bold(p.agent)}${modelLabel(p)} ${queuedTag}`,
|
|
238
|
+
`${tree(i, total)} ${theme.fg("muted", "○")} ${theme.bold(p.agent)}${p.id ? theme.fg("accent", formatTaskId(p.id)) : ""}${modelLabel(p)} ${queuedTag}`,
|
|
233
239
|
w,
|
|
234
240
|
),
|
|
235
241
|
);
|
|
@@ -260,12 +266,18 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
260
266
|
const totalTokens = progress.reduce((sum, p) => sum + p.tokens, 0);
|
|
261
267
|
const ticketId = ctx.ticketId;
|
|
262
268
|
const ticketStatus = ctx.ticketStatus;
|
|
263
|
-
|
|
269
|
+
// A terminal ticket can retain a stale running/pending row while its workers
|
|
270
|
+
// unwind. Keep the row presentation terminal in that case; an absent status
|
|
271
|
+
// is the synchronous-render path, where task status remains authoritative.
|
|
272
|
+
const ticketIsLive =
|
|
273
|
+
ticketStatus === undefined ||
|
|
274
|
+
ticketStatus === "running" ||
|
|
275
|
+
ticketStatus === "cancelling";
|
|
264
276
|
const elapsed = state.startedAt
|
|
265
277
|
? fmtDuration(Date.now() - state.startedAt)
|
|
266
278
|
: fmtDuration(progress.reduce((sum, p) => sum + p.durationMs, 0));
|
|
267
279
|
|
|
268
|
-
if (ticketId &&
|
|
280
|
+
if (ticketId && ticketIsLive) {
|
|
269
281
|
// Background ticket — frame it as in-progress, not a finished result.
|
|
270
282
|
const ticketParts = [
|
|
271
283
|
`ticket ${ticketId}`,
|
|
@@ -307,9 +319,15 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
307
319
|
: p.status === "running"
|
|
308
320
|
? theme.fg("warning", "◐")
|
|
309
321
|
: theme.fg("muted", "○");
|
|
310
|
-
const
|
|
322
|
+
const taskId = p.id ? formatTaskId(p.id) : "";
|
|
323
|
+
const taskIdTag = p.id ? theme.fg("accent", taskId) : "";
|
|
324
|
+
const taskIdWidth = p.id ? taskId.length : 0;
|
|
325
|
+
const previewBudget = Math.max(1, w - 30 - taskIdWidth);
|
|
326
|
+
const taskPreview = theme.fg("muted", trunc(p.task, previewBudget));
|
|
311
327
|
const isLive =
|
|
312
|
-
|
|
328
|
+
ticketIsLive &&
|
|
329
|
+
(p.status === "running" ||
|
|
330
|
+
(p.status === "pending" && !isCancelledPending));
|
|
313
331
|
// Live tasks show an activity/waiting hint instead of final stats.
|
|
314
332
|
const liveTail =
|
|
315
333
|
p.status === "running"
|
|
@@ -322,7 +340,7 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
322
340
|
: "";
|
|
323
341
|
lines.push(
|
|
324
342
|
truncLine(
|
|
325
|
-
`${tree(i, total)} ${icon} ${theme.bold(p.agent)}${modelLabel(p)} ${taskPreview}${isLive ? liveTail : cancelledTail || statJoin([fmtDuration(p.durationMs), `${fmtTokens(p.tokens)} tokens`])}`,
|
|
343
|
+
`${tree(i, total)} ${icon} ${theme.bold(p.agent)}${modelLabel(p)}${taskIdTag} ${taskPreview}${isLive ? liveTail : cancelledTail || statJoin([fmtDuration(p.durationMs), `${fmtTokens(p.tokens)} tokens`])}`,
|
|
326
344
|
w,
|
|
327
345
|
),
|
|
328
346
|
);
|