@arhen/pi-core-subagent 1.3.63 → 1.3.65
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 +23 -8
- package/package.json +1 -1
- package/src/child.ts +61 -10
- package/src/format.ts +19 -5
- package/src/index.ts +45 -13
- package/src/manager.ts +720 -61
- package/src/notification-state.ts +30 -0
- package/src/presentation.ts +11 -11
- package/src/types.ts +10 -0
package/src/manager.ts
CHANGED
|
@@ -27,10 +27,10 @@ import {
|
|
|
27
27
|
activitySnippet,
|
|
28
28
|
describeCall,
|
|
29
29
|
getFirstText,
|
|
30
|
-
isStartupFailure,
|
|
31
30
|
isTalking,
|
|
32
31
|
makeAskNotice,
|
|
33
32
|
makeNotice,
|
|
33
|
+
makeTaskArtifactNotice,
|
|
34
34
|
makeTaskNotice,
|
|
35
35
|
SubagentsWidget,
|
|
36
36
|
truncateText,
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
import { applyUpstream, resolveNeeds, runWaveScheduler } from "./graph.ts";
|
|
39
39
|
import { createMailbox, type Mailbox } from "./mailbox.ts";
|
|
40
40
|
import { chooseModel, resolveChildModel } from "./models.ts";
|
|
41
|
+
import { artifactFingerprint, outcomeFingerprint, textFingerprint } from "./notification-state.ts";
|
|
41
42
|
import type { SubagentParamsShape, TaskInput } from "./schemas.ts";
|
|
42
43
|
import {
|
|
43
44
|
MAX_TASKS,
|
|
@@ -77,6 +78,12 @@ const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write", "cod
|
|
|
77
78
|
const WRITE_CAPABLE = ["bash", "edit", "write"];
|
|
78
79
|
const SAFE_TASK_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
79
80
|
const WIDGET_THROTTLE_MS = 150;
|
|
81
|
+
/** A submitted notice missing from the canonical context this long is treated as lost. */
|
|
82
|
+
const RECEIPT_GRACE_MS = 3_000;
|
|
83
|
+
/** Minimum wait before retrying a failed submission, so one flush cannot burn every attempt. */
|
|
84
|
+
const RETRY_BACKOFF_MS = 1_000;
|
|
85
|
+
/** Total submission attempts before a notice is declared lost. */
|
|
86
|
+
const MAX_DELIVERY_ATTEMPTS = 3;
|
|
80
87
|
|
|
81
88
|
function newId(prefix: string): string {
|
|
82
89
|
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -291,6 +298,36 @@ export interface ParkedMsg {
|
|
|
291
298
|
taskId: string;
|
|
292
299
|
agent: string;
|
|
293
300
|
text: string;
|
|
301
|
+
final?: boolean;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
interface LeaderMessageReceipt {
|
|
305
|
+
runId: string;
|
|
306
|
+
taskIds: string[];
|
|
307
|
+
kind: "update" | "final" | "terminal";
|
|
308
|
+
deliverAs: "steer" | "followUp";
|
|
309
|
+
delivered: boolean;
|
|
310
|
+
submitted: boolean;
|
|
311
|
+
lost: boolean;
|
|
312
|
+
submittedAt: number;
|
|
313
|
+
attempts: number;
|
|
314
|
+
/** Agent runs started when this receipt was submitted; a later run is needed to prove loss. */
|
|
315
|
+
submittedRunCount: number;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
interface OutboxEntry {
|
|
319
|
+
runId: string;
|
|
320
|
+
taskIds: string[];
|
|
321
|
+
kind: "update" | "final" | "terminal";
|
|
322
|
+
deliverAs: "steer" | "followUp";
|
|
323
|
+
attempts?: number;
|
|
324
|
+
attemptedAt?: number;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
interface TerminalNotice {
|
|
328
|
+
body: string;
|
|
329
|
+
fingerprint: string;
|
|
330
|
+
delivery: "pending" | "received" | "lost";
|
|
294
331
|
}
|
|
295
332
|
|
|
296
333
|
export class SubagentManager {
|
|
@@ -313,6 +350,17 @@ export class SubagentManager {
|
|
|
313
350
|
private persistChain: Promise<unknown> = Promise.resolve();
|
|
314
351
|
private readonly instanceNonce = Math.random().toString(36).slice(2, 8);
|
|
315
352
|
private cleared = false;
|
|
353
|
+
private leaderMessages = new Map<string, LeaderMessageReceipt>();
|
|
354
|
+
private outbox = new Map<string, OutboxEntry>();
|
|
355
|
+
private parkedBodies = new Map<string, Set<string>>();
|
|
356
|
+
private agentRunCount = 0;
|
|
357
|
+
private terminalNotices = new Map<string, TerminalNotice>();
|
|
358
|
+
private awaitedTaskNotices = new Map<string, string>();
|
|
359
|
+
private pendingTaskNotices = new Map<string, { run: RunSnapshot; task: TaskSnapshot }>();
|
|
360
|
+
private pendingRunNotices = new Map<string, RunSnapshot>();
|
|
361
|
+
private notificationRetries = new Set<string>();
|
|
362
|
+
private notificationFlushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
363
|
+
private notificationContext: ExtensionContext | undefined;
|
|
316
364
|
|
|
317
365
|
private autoLimit = false;
|
|
318
366
|
|
|
@@ -384,6 +432,17 @@ export class SubagentManager {
|
|
|
384
432
|
pending.resolve("(session ended — stop work immediately)");
|
|
385
433
|
}
|
|
386
434
|
this.parked.clear();
|
|
435
|
+
this.leaderMessages.clear();
|
|
436
|
+
this.outbox.clear();
|
|
437
|
+
this.parkedBodies.clear();
|
|
438
|
+
this.terminalNotices.clear();
|
|
439
|
+
this.awaitedTaskNotices.clear();
|
|
440
|
+
this.pendingTaskNotices.clear();
|
|
441
|
+
this.pendingRunNotices.clear();
|
|
442
|
+
this.notificationRetries.clear();
|
|
443
|
+
if (this.notificationFlushTimer) clearTimeout(this.notificationFlushTimer);
|
|
444
|
+
this.notificationFlushTimer = undefined;
|
|
445
|
+
this.notificationContext = undefined;
|
|
387
446
|
|
|
388
447
|
this.liveWorktrees.clear();
|
|
389
448
|
this.runs.clear();
|
|
@@ -483,27 +542,511 @@ export class SubagentManager {
|
|
|
483
542
|
this.pi.events.emit(type, { type, timestamp: Date.now(), ...payload });
|
|
484
543
|
}
|
|
485
544
|
|
|
486
|
-
private
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
545
|
+
private finalReportMatches(task: TaskSnapshot): boolean {
|
|
546
|
+
const report = task.finalParentReport;
|
|
547
|
+
if (!report || report.toolCalls !== task.toolCalls) return false;
|
|
548
|
+
const fingerprint = task.finalTextFingerprint ?? textFingerprint(task.finalText ?? "");
|
|
549
|
+
return report.fingerprint === fingerprint;
|
|
490
550
|
}
|
|
491
551
|
|
|
492
|
-
private
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
552
|
+
private hasArtifactDelta(task: TaskSnapshot): boolean {
|
|
553
|
+
return artifactFingerprint(task) !== task.finalParentReport?.artifacts;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private leaderMessageText(content: unknown): string {
|
|
557
|
+
return typeof content === "string"
|
|
558
|
+
? content
|
|
559
|
+
: Array.isArray(content)
|
|
560
|
+
? content
|
|
561
|
+
.filter((part) => part?.type === "text")
|
|
562
|
+
.map((part) => part.text ?? "")
|
|
563
|
+
.join("\n")
|
|
564
|
+
: "";
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* One pass over the finalized leader projection. Returns undefined when the pinned pi build
|
|
569
|
+
* cannot project (never treat that as loss, or every receipt would be replayed).
|
|
570
|
+
*/
|
|
571
|
+
private projectionBody(ctx: ExtensionContext): string | undefined {
|
|
572
|
+
try {
|
|
573
|
+
const manager = ctx.sessionManager as unknown as {
|
|
574
|
+
buildSessionProjection?: () => { messages: readonly { role?: string; content?: unknown }[] };
|
|
575
|
+
};
|
|
576
|
+
if (typeof manager.buildSessionProjection !== "function") return undefined;
|
|
577
|
+
const messages = manager.buildSessionProjection().messages;
|
|
578
|
+
const parts: string[] = [];
|
|
579
|
+
for (const message of messages) {
|
|
580
|
+
if (message.role !== "user" && message.role !== "custom") continue;
|
|
581
|
+
parts.push(this.leaderMessageText(message.content));
|
|
582
|
+
}
|
|
583
|
+
return parts.join("\n");
|
|
584
|
+
} catch {
|
|
585
|
+
return undefined;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** An agent run started; a receipt can only be declared lost after one full later run. */
|
|
590
|
+
noteAgentStart(): void {
|
|
591
|
+
this.agentRunCount += 1;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** True when a terminal await already rendered this task's outcome. */
|
|
595
|
+
private coveredByAwait(runId: string, taskId: string, fingerprint: string): boolean {
|
|
596
|
+
return this.awaitedTaskNotices.get(`${runId}:${taskId}`) === fingerprint;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Record what a terminal await actually rendered, so held notices are dropped instead of echoed. */
|
|
600
|
+
markAwaitCoverage(run: RunSnapshot, coveredTaskIds?: ReadonlySet<string>): void {
|
|
601
|
+
for (const task of run.tasks) {
|
|
602
|
+
if (!TERMINAL.includes(task.status)) continue;
|
|
603
|
+
if (coveredTaskIds && !coveredTaskIds.has(task.id)) continue;
|
|
604
|
+
const scope = `${run.id}:${task.id}`;
|
|
605
|
+
this.awaitedTaskNotices.set(scope, outcomeFingerprint(task));
|
|
606
|
+
this.pendingTaskNotices.delete(scope);
|
|
607
|
+
}
|
|
608
|
+
for (const [body, entry] of [...this.outbox]) {
|
|
609
|
+
if (entry.runId !== run.id) continue;
|
|
610
|
+
if (!this.outboxCovered(entry)) continue;
|
|
611
|
+
const task = run.tasks.find((t) => t.id === entry.taskIds[0]);
|
|
612
|
+
// An update is not part of the rendered summary; only terminal/aggregate entries are safe to drop.
|
|
613
|
+
if (entry.kind === "update") continue;
|
|
614
|
+
if (entry.kind === "final" && (!task || !this.finalReportMatches(task))) continue;
|
|
615
|
+
this.dropOutbox(body, entry, "awaited");
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
private outboxCovered(entry: OutboxEntry): boolean {
|
|
620
|
+
const run = this.runs.get(entry.runId);
|
|
621
|
+
if (!run) return false;
|
|
622
|
+
return entry.taskIds.every((taskId) => {
|
|
623
|
+
const task = run.tasks.find((t) => t.id === taskId);
|
|
624
|
+
if (!task || !TERMINAL.includes(task.status)) return false;
|
|
625
|
+
return this.coveredByAwait(entry.runId, taskId, outcomeFingerprint(task));
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private dropOutbox(body: string, entry: OutboxEntry, delivery: string): void {
|
|
630
|
+
this.outbox.delete(body);
|
|
631
|
+
const task = this.runs.get(entry.runId)?.tasks.find((t) => t.id === entry.taskIds[0]);
|
|
632
|
+
if (entry.kind === "final" && task?.finalParentReport?.body === body) task.finalParentReport.delivery = "awaited";
|
|
633
|
+
if (entry.kind === "terminal") {
|
|
634
|
+
for (const taskId of entry.taskIds) {
|
|
635
|
+
const notice = this.terminalNotices.get(`${entry.runId}:${taskId}`);
|
|
636
|
+
if (notice?.body === body) notice.delivery = "received";
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
this.emit("subagent:notification", {
|
|
640
|
+
runId: entry.runId,
|
|
641
|
+
taskId: entry.taskIds[0],
|
|
642
|
+
kind: "completed",
|
|
643
|
+
body: "",
|
|
644
|
+
suppressed: true,
|
|
645
|
+
delivery,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
private enqueueNotification(body: string, entry: OutboxEntry, ctx?: ExtensionContext): void {
|
|
650
|
+
this.outbox.set(body, entry);
|
|
651
|
+
const flushContext = ctx ?? this.notificationContext;
|
|
652
|
+
if (flushContext) this.scheduleNotificationFlush(flushContext);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
private noteTerminal(runId: string, taskId: string, body: string, fingerprint: string): void {
|
|
656
|
+
this.terminalNotices.set(`${runId}:${taskId}`, { body, fingerprint, delivery: "pending" });
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
private submitLeaderReport(
|
|
660
|
+
run: RunSnapshot,
|
|
661
|
+
task: TaskSnapshot,
|
|
662
|
+
message: string,
|
|
663
|
+
level: "info" | "warning" | "error",
|
|
664
|
+
final: boolean,
|
|
665
|
+
ctx: ExtensionContext,
|
|
666
|
+
): boolean {
|
|
667
|
+
if (this.cleared) return false;
|
|
668
|
+
this.notificationContext = ctx;
|
|
669
|
+
const body = `[Subagent ${task.agent} (${task.id}, ${run.id})]${final ? " Final result:" : ""}${level === "info" ? "" : ` [${level}]`} ${message}`;
|
|
670
|
+
const previous = this.leaderMessages.get(body);
|
|
671
|
+
if ((previous && !previous.lost) || this.outbox.has(body)) {
|
|
672
|
+
if (final) {
|
|
673
|
+
task.finalParentReport = {
|
|
674
|
+
body,
|
|
675
|
+
message,
|
|
676
|
+
toolCalls: task.toolCalls,
|
|
677
|
+
fingerprint: textFingerprint(message),
|
|
678
|
+
artifacts: artifactFingerprint(task),
|
|
679
|
+
delivery: previous?.delivered ? "message" : "pending",
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
return true;
|
|
683
|
+
}
|
|
684
|
+
if (previous?.lost) this.leaderMessages.delete(body);
|
|
685
|
+
const report: TaskSnapshot["finalParentReport"] = final
|
|
686
|
+
? {
|
|
687
|
+
body,
|
|
688
|
+
message,
|
|
689
|
+
toolCalls: task.toolCalls,
|
|
690
|
+
fingerprint: textFingerprint(message),
|
|
691
|
+
artifacts: artifactFingerprint(task),
|
|
692
|
+
delivery: "pending",
|
|
693
|
+
}
|
|
694
|
+
: undefined;
|
|
695
|
+
if (report) task.finalParentReport = report;
|
|
696
|
+
this.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level, message, final });
|
|
697
|
+
if (this.collectParked(run.id, { kind: "notify", taskId: task.id, agent: task.agent, text: message, final })) {
|
|
698
|
+
let parked = this.parkedBodies.get(run.id);
|
|
699
|
+
if (!parked) {
|
|
700
|
+
parked = new Set();
|
|
701
|
+
this.parkedBodies.set(run.id, parked);
|
|
702
|
+
}
|
|
703
|
+
if (parked.has(body)) return true;
|
|
704
|
+
parked.add(body);
|
|
705
|
+
task.notifiedParent = true;
|
|
706
|
+
if (report) report.delivery = "parked";
|
|
707
|
+
return true;
|
|
708
|
+
}
|
|
709
|
+
this.enqueueNotification(body, {
|
|
710
|
+
runId: run.id,
|
|
711
|
+
taskIds: [task.id],
|
|
712
|
+
kind: final ? "final" : "update",
|
|
713
|
+
deliverAs: "followUp",
|
|
714
|
+
});
|
|
715
|
+
return true;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
private hasPendingSuccessMessages(run: RunSnapshot): boolean {
|
|
719
|
+
return run.tasks.some((task) => {
|
|
720
|
+
const notice = this.terminalNotices.get(`${run.id}:${task.id}`);
|
|
721
|
+
if (notice?.delivery !== "pending") return false;
|
|
722
|
+
const receipt = this.leaderMessages.get(notice.body);
|
|
723
|
+
return receipt ? !receipt.delivered && !receipt.lost : this.outbox.has(notice.body);
|
|
724
|
+
});
|
|
725
|
+
}
|
|
498
726
|
|
|
727
|
+
confirmLeaderMessages(ctx: ExtensionContext): void {
|
|
728
|
+
if (this.cleared) return;
|
|
729
|
+
const projection = this.projectionBody(ctx);
|
|
730
|
+
let changed = false;
|
|
731
|
+
for (const [body, receipt] of [...this.leaderMessages]) {
|
|
732
|
+
if (receipt.delivered || receipt.lost || projection === undefined || !projection.includes(body)) continue;
|
|
733
|
+
receipt.delivered = true;
|
|
734
|
+
changed = true;
|
|
735
|
+
const run = this.runs.get(receipt.runId);
|
|
736
|
+
for (const taskId of receipt.taskIds) {
|
|
737
|
+
const task = run?.tasks.find((t) => t.id === taskId);
|
|
738
|
+
if (!task) continue;
|
|
739
|
+
task.notifiedParent = true;
|
|
740
|
+
if (task.finalParentReport?.body === body) task.finalParentReport.delivery = "message";
|
|
741
|
+
const key = `${receipt.runId}:${taskId}`;
|
|
742
|
+
const notice = this.terminalNotices.get(key);
|
|
743
|
+
if (notice?.body === body) notice.delivery = "received";
|
|
744
|
+
const pending = this.pendingTaskNotices.get(key);
|
|
745
|
+
if (pending && this.finalReportMatches(task)) {
|
|
746
|
+
this.pendingTaskNotices.delete(key);
|
|
747
|
+
if (this.hasArtifactDelta(task)) this.notifyTask(pending.run, task, "completed");
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
if (run && !this.hasPendingSuccessMessages(run)) this.pendingRunNotices.delete(run.id);
|
|
751
|
+
}
|
|
752
|
+
if (changed) this.persist(ctx);
|
|
753
|
+
for (const [body, receipt] of this.leaderMessages) {
|
|
754
|
+
if (!receipt.delivered && !receipt.lost) continue;
|
|
755
|
+
const run = this.runs.get(receipt.runId);
|
|
756
|
+
const tasks = run?.tasks.filter((task) => receipt.taskIds.includes(task.id)) ?? [];
|
|
757
|
+
if (
|
|
758
|
+
tasks.length > 0 &&
|
|
759
|
+
tasks.every((task) => TERMINAL.includes(task.status) && !this.liveChildren.has(`${receipt.runId}:${task.id}`))
|
|
760
|
+
)
|
|
761
|
+
this.leaderMessages.delete(body);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* A submitted receipt that never appears in the canonical context after a grace period is lost.
|
|
767
|
+
* The grace period keeps the check from racing Pi's input/auth/start hooks, which run before the
|
|
768
|
+
* message is appended to the session and can be slow while the session still reports idle.
|
|
769
|
+
*/
|
|
770
|
+
private markLostReceipts(ctx: ExtensionContext, existing: ReadonlySet<string>): void {
|
|
771
|
+
const now = Date.now();
|
|
772
|
+
const projection = this.projectionBody(ctx);
|
|
773
|
+
if (projection === undefined) return;
|
|
774
|
+
for (const [body, receipt] of [...this.leaderMessages]) {
|
|
775
|
+
if (receipt.delivered || receipt.lost || !existing.has(body)) continue;
|
|
776
|
+
if (this.outbox.has(body)) continue;
|
|
777
|
+
if (now - receipt.submittedAt < RECEIPT_GRACE_MS) continue;
|
|
778
|
+
// A later agent run proves the submitted prompt had its turn; a message that never runs
|
|
779
|
+
// at all is caught by the longer bound. While a prompt is still queued or in preflight
|
|
780
|
+
// (deferred settle, slow input/auth hooks) the session can report idle without the
|
|
781
|
+
// message being appended yet, so the short window alone must not declare loss.
|
|
782
|
+
const laterRun = this.agentRunCount > receipt.submittedRunCount;
|
|
783
|
+
const longWait = now - receipt.submittedAt >= RECEIPT_GRACE_MS * 2;
|
|
784
|
+
if (!laterRun && !longWait) continue;
|
|
785
|
+
if (projection.includes(body)) continue;
|
|
786
|
+
if (receipt.attempts < MAX_DELIVERY_ATTEMPTS) {
|
|
787
|
+
this.leaderMessages.delete(body);
|
|
788
|
+
this.outbox.set(body, {
|
|
789
|
+
runId: receipt.runId,
|
|
790
|
+
taskIds: receipt.taskIds,
|
|
791
|
+
kind: receipt.kind,
|
|
792
|
+
deliverAs: receipt.deliverAs,
|
|
793
|
+
attempts: receipt.attempts,
|
|
794
|
+
attemptedAt: now,
|
|
795
|
+
});
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
receipt.lost = true;
|
|
799
|
+
for (const taskId of receipt.taskIds) {
|
|
800
|
+
const key = `${receipt.runId}:${taskId}`;
|
|
801
|
+
const notice = this.terminalNotices.get(key);
|
|
802
|
+
if (notice?.body === body) notice.delivery = "lost";
|
|
803
|
+
const task = this.runs.get(receipt.runId)?.tasks.find((t) => t.id === taskId);
|
|
804
|
+
if (task?.finalParentReport?.body === body) task.finalParentReport.delivery = "lost";
|
|
805
|
+
}
|
|
806
|
+
this.emit("subagent:notification", {
|
|
807
|
+
runId: receipt.runId,
|
|
808
|
+
taskId: receipt.taskIds[0],
|
|
809
|
+
kind: "completed",
|
|
810
|
+
body,
|
|
811
|
+
delivery: "lost",
|
|
812
|
+
});
|
|
813
|
+
// A lost final report must still reach the leader: per-task hold or a direct full fallback.
|
|
814
|
+
if (receipt.kind === "final") {
|
|
815
|
+
const run = this.runs.get(receipt.runId);
|
|
816
|
+
for (const taskId of receipt.taskIds) {
|
|
817
|
+
if (this.pendingTaskNotices.has(`${receipt.runId}:${taskId}`)) continue;
|
|
818
|
+
const task = run?.tasks.find((t) => t.id === taskId);
|
|
819
|
+
if (run && task) this.notifyTask(run, task, "completed");
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
private flushOutbox(): void {
|
|
826
|
+
for (const [body, entry] of [...this.outbox]) {
|
|
827
|
+
if (this.outboxCovered(entry)) {
|
|
828
|
+
this.dropOutbox(body, entry, "awaited");
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
if (entry.attemptedAt && Date.now() - entry.attemptedAt < RETRY_BACKOFF_MS) continue;
|
|
832
|
+
this.outbox.delete(body);
|
|
833
|
+
try {
|
|
834
|
+
this.pi.sendUserMessage(body, { deliverAs: entry.deliverAs });
|
|
835
|
+
this.leaderMessages.set(body, {
|
|
836
|
+
runId: entry.runId,
|
|
837
|
+
taskIds: entry.taskIds,
|
|
838
|
+
kind: entry.kind,
|
|
839
|
+
deliverAs: entry.deliverAs,
|
|
840
|
+
delivered: false,
|
|
841
|
+
submitted: true,
|
|
842
|
+
lost: false,
|
|
843
|
+
submittedAt: Date.now(),
|
|
844
|
+
attempts: (entry.attempts ?? 0) + 1,
|
|
845
|
+
submittedRunCount: this.agentRunCount,
|
|
846
|
+
});
|
|
847
|
+
if (entry.kind === "terminal") {
|
|
848
|
+
for (const taskId of entry.taskIds) {
|
|
849
|
+
const notice = this.terminalNotices.get(`${entry.runId}:${taskId}`);
|
|
850
|
+
if (notice?.body === body) notice.delivery = "pending";
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
this.emit("subagent:notification", {
|
|
854
|
+
runId: entry.runId,
|
|
855
|
+
taskId: entry.taskIds[0],
|
|
856
|
+
kind: "completed",
|
|
857
|
+
body,
|
|
858
|
+
delivery: "submitted",
|
|
859
|
+
});
|
|
860
|
+
} catch {
|
|
861
|
+
const attempts = (entry.attempts ?? 0) + 1;
|
|
862
|
+
if (attempts < MAX_DELIVERY_ATTEMPTS) {
|
|
863
|
+
this.outbox.set(body, { ...entry, attempts, attemptedAt: Date.now() });
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
this.emit("subagent:notification", {
|
|
867
|
+
runId: entry.runId,
|
|
868
|
+
taskId: entry.taskIds[0],
|
|
869
|
+
kind: "completed",
|
|
870
|
+
body,
|
|
871
|
+
delivery: "failed",
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
flushPendingNotifications(ctx: ExtensionContext): void {
|
|
878
|
+
const existing = new Set([...this.leaderMessages.keys(), ...this.outbox.keys()]);
|
|
879
|
+
this.confirmLeaderMessages(ctx);
|
|
880
|
+
if (this.cleared) return;
|
|
881
|
+
let idle = false;
|
|
882
|
+
try {
|
|
883
|
+
idle = ctx.isIdle() && !ctx.hasPendingMessages();
|
|
884
|
+
} catch {
|
|
885
|
+
idle = false;
|
|
886
|
+
}
|
|
887
|
+
if (idle) this.markLostReceipts(ctx, existing);
|
|
888
|
+
const outstandingReceipts = () =>
|
|
889
|
+
[...this.leaderMessages.values()].some((receipt) => !receipt.delivered && !receipt.lost);
|
|
890
|
+
if (!this.pendingTaskNotices.size && !this.pendingRunNotices.size && !this.outbox.size && !outstandingReceipts())
|
|
891
|
+
return;
|
|
892
|
+
if (idle) {
|
|
893
|
+
this.flushOutbox();
|
|
894
|
+
for (const [key, pending] of [...this.pendingTaskNotices]) {
|
|
895
|
+
const report = pending.task.finalParentReport;
|
|
896
|
+
if (report?.delivery === "lost") {
|
|
897
|
+
this.pendingTaskNotices.delete(key);
|
|
898
|
+
pending.task.finalParentReport = undefined;
|
|
899
|
+
this.notifyTask(pending.run, pending.task, "completed");
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
if (report?.delivery === "pending") {
|
|
903
|
+
const outstanding = this.leaderMessages.has(report.body) || this.outbox.has(report.body);
|
|
904
|
+
if (outstanding) continue;
|
|
905
|
+
this.pendingTaskNotices.delete(key);
|
|
906
|
+
pending.task.finalParentReport = undefined;
|
|
907
|
+
this.notifyTask(pending.run, pending.task, "completed");
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
this.pendingTaskNotices.delete(key);
|
|
911
|
+
if (report && this.finalReportMatches(pending.task) && this.hasArtifactDelta(pending.task))
|
|
912
|
+
this.notifyTask(pending.run, pending.task, "completed");
|
|
913
|
+
}
|
|
914
|
+
for (const [key, run] of [...this.pendingRunNotices]) {
|
|
915
|
+
const notices = run.tasks
|
|
916
|
+
.map((task) => this.terminalNotices.get(`${run.id}:${task.id}`))
|
|
917
|
+
.filter((notice): notice is TerminalNotice => Boolean(notice));
|
|
918
|
+
const waiting = notices.some((notice) => {
|
|
919
|
+
const receipt = this.leaderMessages.get(notice.body);
|
|
920
|
+
return notice.delivery === "pending" && (!receipt || (!receipt.delivered && !receipt.lost));
|
|
921
|
+
});
|
|
922
|
+
if (waiting) continue;
|
|
923
|
+
this.pendingRunNotices.delete(key);
|
|
924
|
+
const lost = notices.some((notice) => notice.delivery === "lost");
|
|
925
|
+
if (!lost || this.notificationRetries.has(run.id)) continue;
|
|
926
|
+
this.notificationRetries.add(run.id);
|
|
927
|
+
this.notifyParent(run, run.status === "aborted" ? "aborted" : run.status === "failed" ? "failed" : "completed");
|
|
928
|
+
}
|
|
929
|
+
this.flushOutbox();
|
|
930
|
+
}
|
|
931
|
+
// Anything still unresolved (including a retry backoff) must be revisited without unrelated activity.
|
|
932
|
+
this.scheduleNotificationFlush(ctx, RETRY_BACKOFF_MS);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
scheduleNotificationFlush(ctx: ExtensionContext, delayMs = 100): void {
|
|
936
|
+
if (
|
|
937
|
+
this.notificationFlushTimer ||
|
|
938
|
+
this.cleared ||
|
|
939
|
+
(!this.pendingTaskNotices.size &&
|
|
940
|
+
!this.pendingRunNotices.size &&
|
|
941
|
+
!this.outbox.size &&
|
|
942
|
+
![...this.leaderMessages.values()].some((receipt) => !receipt.delivered && !receipt.lost))
|
|
943
|
+
)
|
|
944
|
+
return;
|
|
945
|
+
this.notificationFlushTimer = setTimeout(() => {
|
|
946
|
+
this.notificationFlushTimer = undefined;
|
|
947
|
+
this.flushPendingNotifications(ctx);
|
|
948
|
+
}, delayMs);
|
|
949
|
+
this.notificationFlushTimer.unref?.();
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
private notifyTask(run: RunSnapshot, task: TaskSnapshot, kind: "completed" | "failed" | "aborted"): void {
|
|
953
|
+
const key = `${run.id}:${task.id}`;
|
|
954
|
+
const outcome = outcomeFingerprint(task);
|
|
955
|
+
const final = kind === "completed" && this.finalReportMatches(task);
|
|
956
|
+
const report = task.finalParentReport;
|
|
957
|
+
if (final && report?.delivery === "pending") {
|
|
958
|
+
this.pendingTaskNotices.set(key, { run, task });
|
|
959
|
+
if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext);
|
|
960
|
+
this.emit("subagent:notification", {
|
|
961
|
+
runId: run.id,
|
|
962
|
+
taskId: task.id,
|
|
963
|
+
kind,
|
|
964
|
+
body: "",
|
|
965
|
+
suppressed: true,
|
|
966
|
+
delivery: "pending",
|
|
967
|
+
});
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
if (
|
|
971
|
+
final &&
|
|
972
|
+
report &&
|
|
973
|
+
report.delivery !== "lost" &&
|
|
974
|
+
report.delivery !== "pending" &&
|
|
975
|
+
!this.hasArtifactDelta(task)
|
|
976
|
+
) {
|
|
977
|
+
this.emit("subagent:notification", {
|
|
978
|
+
runId: run.id,
|
|
979
|
+
taskId: task.id,
|
|
980
|
+
kind,
|
|
981
|
+
body: "",
|
|
982
|
+
suppressed: true,
|
|
983
|
+
delivery: report.delivery,
|
|
984
|
+
});
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
const covered = this.terminalNotices.get(key);
|
|
988
|
+
if (covered?.fingerprint === outcome && covered.delivery !== "lost") {
|
|
989
|
+
this.emit("subagent:notification", {
|
|
990
|
+
runId: run.id,
|
|
991
|
+
taskId: task.id,
|
|
992
|
+
kind,
|
|
993
|
+
body: "",
|
|
994
|
+
suppressed: true,
|
|
995
|
+
delivery: covered.delivery,
|
|
996
|
+
});
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
if (this.coveredByAwait(run.id, task.id, outcome)) {
|
|
1000
|
+
this.emit("subagent:notification", {
|
|
1001
|
+
runId: run.id,
|
|
1002
|
+
taskId: task.id,
|
|
1003
|
+
kind,
|
|
1004
|
+
body: "",
|
|
1005
|
+
suppressed: true,
|
|
1006
|
+
delivery: "awaited",
|
|
1007
|
+
});
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
const artifactOnly = final && report?.delivery !== "lost";
|
|
1011
|
+
const body = artifactOnly ? makeTaskArtifactNotice(run, task) : makeTaskNotice(run, task, kind);
|
|
499
1012
|
if (this.collectParked(run.id, { kind: "done", taskId: task.id, agent: task.agent, text: body })) {
|
|
500
|
-
this.
|
|
1013
|
+
this.noteTerminal(run.id, task.id, body, outcome);
|
|
1014
|
+
this.terminalNotices.get(key)!.delivery = "received";
|
|
1015
|
+
this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body, delivery: "parked" });
|
|
501
1016
|
return;
|
|
502
1017
|
}
|
|
503
|
-
|
|
504
|
-
this.
|
|
505
|
-
|
|
506
|
-
|
|
1018
|
+
if (kind === "failed") {
|
|
1019
|
+
this.noteTerminal(run.id, task.id, body, outcome);
|
|
1020
|
+
try {
|
|
1021
|
+
this.pi.sendUserMessage(body, { deliverAs: "steer" });
|
|
1022
|
+
this.leaderMessages.set(body, {
|
|
1023
|
+
runId: run.id,
|
|
1024
|
+
taskIds: [task.id],
|
|
1025
|
+
kind: "terminal",
|
|
1026
|
+
deliverAs: "steer",
|
|
1027
|
+
delivered: false,
|
|
1028
|
+
submitted: true,
|
|
1029
|
+
lost: false,
|
|
1030
|
+
submittedAt: Date.now(),
|
|
1031
|
+
attempts: 0,
|
|
1032
|
+
submittedRunCount: this.agentRunCount,
|
|
1033
|
+
});
|
|
1034
|
+
this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body, delivery: "submitted" });
|
|
1035
|
+
if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext, RECEIPT_GRACE_MS);
|
|
1036
|
+
} catch {
|
|
1037
|
+
this.terminalNotices.get(key)!.delivery = "lost";
|
|
1038
|
+
this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body, delivery: "failed" });
|
|
1039
|
+
}
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
this.noteTerminal(run.id, task.id, body, outcome);
|
|
1043
|
+
this.enqueueNotification(body, {
|
|
1044
|
+
runId: run.id,
|
|
1045
|
+
taskIds: [task.id],
|
|
1046
|
+
kind: "terminal",
|
|
1047
|
+
deliverAs: "followUp",
|
|
1048
|
+
});
|
|
1049
|
+
this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body, delivery: "held" });
|
|
507
1050
|
}
|
|
508
1051
|
|
|
509
1052
|
private notifyParent(
|
|
@@ -511,19 +1054,103 @@ export class SubagentManager {
|
|
|
511
1054
|
kind: "completed" | "failed" | "aborted" | "asked",
|
|
512
1055
|
extra?: { taskId?: string; agent?: string; question?: string; urgent?: boolean },
|
|
513
1056
|
): void {
|
|
514
|
-
if (kind !== "asked" && run.
|
|
515
|
-
|
|
516
|
-
if (kind
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
1057
|
+
if (kind !== "asked" && run.completionAwaited) return;
|
|
1058
|
+
let tasks = run.tasks;
|
|
1059
|
+
if (kind !== "asked") {
|
|
1060
|
+
tasks = tasks.filter((task) => {
|
|
1061
|
+
const key = `${run.id}:${task.id}`;
|
|
1062
|
+
const outcome = outcomeFingerprint(task);
|
|
1063
|
+
const covered = this.terminalNotices.get(key);
|
|
1064
|
+
if (covered?.fingerprint === outcome && covered.delivery !== "lost") return false;
|
|
1065
|
+
if (this.pendingTaskNotices.has(key)) return false;
|
|
1066
|
+
if (this.coveredByAwait(run.id, task.id, outcome)) return false;
|
|
1067
|
+
if (
|
|
1068
|
+
kind === "completed" &&
|
|
1069
|
+
task.finalParentReport?.delivery !== "lost" &&
|
|
1070
|
+
this.finalReportMatches(task) &&
|
|
1071
|
+
!this.hasArtifactDelta(task)
|
|
1072
|
+
)
|
|
1073
|
+
return false;
|
|
1074
|
+
return true;
|
|
524
1075
|
});
|
|
525
|
-
|
|
526
|
-
|
|
1076
|
+
if (tasks.length === 0) {
|
|
1077
|
+
if (!this.notificationRetries.has(run.id) && this.hasPendingSuccessMessages(run)) {
|
|
1078
|
+
this.pendingRunNotices.set(run.id, run);
|
|
1079
|
+
if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext);
|
|
1080
|
+
}
|
|
1081
|
+
this.emit("subagent:notification", { runId: run.id, kind, body: "", suppressed: true });
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
// Aggregate-only mode has no per-task hold: keep a run-level watch so a pending report
|
|
1085
|
+
// that later goes lost still produces a fallback.
|
|
1086
|
+
if (
|
|
1087
|
+
!this.notificationRetries.has(run.id) &&
|
|
1088
|
+
!this.pendingRunNotices.has(run.id) &&
|
|
1089
|
+
run.tasks.some((task) => task.finalParentReport?.delivery === "pending")
|
|
1090
|
+
) {
|
|
1091
|
+
this.pendingRunNotices.set(run.id, run);
|
|
1092
|
+
if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
if (kind === "asked") {
|
|
1096
|
+
const body = makeAskNotice(run, extra ?? {});
|
|
1097
|
+
try {
|
|
1098
|
+
this.pi.sendUserMessage(body, { deliverAs: "steer" });
|
|
1099
|
+
this.emit("subagent:notification", { runId: run.id, taskId: extra?.taskId, kind, body, delivery: "submitted" });
|
|
1100
|
+
} catch {
|
|
1101
|
+
this.emit("subagent:notification", { runId: run.id, taskId: extra?.taskId, kind, body, delivery: "failed" });
|
|
1102
|
+
}
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
const body = makeNotice(run, kind, tasks);
|
|
1106
|
+
const deliverAs = kind === "failed" ? "steer" : "followUp";
|
|
1107
|
+
for (const task of tasks) this.noteTerminal(run.id, task.id, body, outcomeFingerprint(task));
|
|
1108
|
+
if (deliverAs === "steer") {
|
|
1109
|
+
try {
|
|
1110
|
+
this.pi.sendUserMessage(body, { deliverAs });
|
|
1111
|
+
this.leaderMessages.set(body, {
|
|
1112
|
+
runId: run.id,
|
|
1113
|
+
taskIds: tasks.map((t) => t.id),
|
|
1114
|
+
kind: "terminal",
|
|
1115
|
+
deliverAs,
|
|
1116
|
+
delivered: false,
|
|
1117
|
+
submitted: true,
|
|
1118
|
+
lost: false,
|
|
1119
|
+
submittedAt: Date.now(),
|
|
1120
|
+
attempts: 0,
|
|
1121
|
+
submittedRunCount: this.agentRunCount,
|
|
1122
|
+
});
|
|
1123
|
+
if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext, RECEIPT_GRACE_MS);
|
|
1124
|
+
} catch {
|
|
1125
|
+
for (const task of tasks) {
|
|
1126
|
+
const notice = this.terminalNotices.get(`${run.id}:${task.id}`);
|
|
1127
|
+
if (notice?.body === body) notice.delivery = "lost";
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
this.emit("subagent:notification", { runId: run.id, kind, body, delivery: "submitted" });
|
|
1131
|
+
this.holdRunFallback(run, tasks);
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
this.enqueueNotification(body, {
|
|
1135
|
+
runId: run.id,
|
|
1136
|
+
taskIds: tasks.map((t) => t.id),
|
|
1137
|
+
kind: "terminal",
|
|
1138
|
+
deliverAs,
|
|
1139
|
+
});
|
|
1140
|
+
this.emit("subagent:notification", { runId: run.id, kind, body, delivery: "held" });
|
|
1141
|
+
this.holdRunFallback(run, tasks);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/** Keep one bounded retry for aggregates whose per-task receipts never arrived. */
|
|
1145
|
+
private holdRunFallback(run: RunSnapshot, tasks: TaskSnapshot[]): void {
|
|
1146
|
+
if (this.notificationRetries.has(run.id)) return;
|
|
1147
|
+
const covered = tasks.filter((task) => {
|
|
1148
|
+
const notice = this.terminalNotices.get(`${run.id}:${task.id}`);
|
|
1149
|
+
return Boolean(notice && notice.body !== "");
|
|
1150
|
+
});
|
|
1151
|
+
if (covered.length === 0) return;
|
|
1152
|
+
this.pendingRunNotices.set(run.id, run);
|
|
1153
|
+
if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext);
|
|
527
1154
|
}
|
|
528
1155
|
|
|
529
1156
|
private widgetTui: TUI | null = null;
|
|
@@ -630,6 +1257,7 @@ export class SubagentManager {
|
|
|
630
1257
|
}
|
|
631
1258
|
|
|
632
1259
|
private makeChildHandlers(run: RunSnapshot, task: TaskSnapshot, ctx: ExtensionContext): ChildHandlers {
|
|
1260
|
+
this.notificationContext = ctx;
|
|
633
1261
|
return {
|
|
634
1262
|
onAskParent: async (_taskId, question, urgent) => {
|
|
635
1263
|
if (TERMINAL.includes(task.status)) {
|
|
@@ -649,30 +1277,10 @@ export class SubagentManager {
|
|
|
649
1277
|
this.updateTask(run, task, { status: "running" }, ctx);
|
|
650
1278
|
return reply;
|
|
651
1279
|
},
|
|
652
|
-
onNotifyParent: (_taskId, message, level) =>
|
|
653
|
-
this.
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
if (this.collectParked(run.id, { kind: "notify", taskId: task.id, agent: task.agent, text: message })) return;
|
|
657
|
-
try {
|
|
658
|
-
this.pi.sendUserMessage(`[Subagent ${task.agent}] ${message}`, { deliverAs: "followUp" });
|
|
659
|
-
} catch {}
|
|
660
|
-
},
|
|
661
|
-
onSendMessage: (_taskId, to, text) => {
|
|
662
|
-
if (to === "leader") {
|
|
663
|
-
this.emit("subagent:intercom", {
|
|
664
|
-
runId: run.id,
|
|
665
|
-
taskId: task.id,
|
|
666
|
-
kind: "notify",
|
|
667
|
-
level: "info",
|
|
668
|
-
message: text,
|
|
669
|
-
});
|
|
670
|
-
if (this.collectParked(run.id, { kind: "notify", taskId: task.id, agent: task.agent, text })) return true;
|
|
671
|
-
try {
|
|
672
|
-
this.pi.sendUserMessage(`[Subagent ${task.agent}] ${text}`, { deliverAs: "followUp" });
|
|
673
|
-
} catch {}
|
|
674
|
-
return true;
|
|
675
|
-
}
|
|
1280
|
+
onNotifyParent: (_taskId, message, level, final = false) =>
|
|
1281
|
+
this.submitLeaderReport(run, task, message, level, final, ctx),
|
|
1282
|
+
onSendMessage: (_taskId, to, text, final = false) => {
|
|
1283
|
+
if (to === "leader") return this.submitLeaderReport(run, task, text, "info", final, ctx);
|
|
676
1284
|
|
|
677
1285
|
return this.mailboxes.send(`${run.id}:${task.id}`, `${run.id}:${to}`, text);
|
|
678
1286
|
},
|
|
@@ -750,6 +1358,7 @@ export class SubagentManager {
|
|
|
750
1358
|
const text = getFirstText(message);
|
|
751
1359
|
if (text) {
|
|
752
1360
|
task.finalText = truncateText(text);
|
|
1361
|
+
task.finalTextFingerprint = textFingerprint(text);
|
|
753
1362
|
task.lastActivity = activitySnippet(text);
|
|
754
1363
|
}
|
|
755
1364
|
state.pendingFailure = classifyFailure(message.stopReason, message.errorMessage);
|
|
@@ -908,7 +1517,7 @@ export class SubagentManager {
|
|
|
908
1517
|
const worktreeNote = wt
|
|
909
1518
|
? ` You work in an isolated git worktree (branch ${wt.branch})${task.stackedOn ? `, stacked on ${task.stackedOn} (its changes are already in your tree)` : ""}. Never run git commands that switch branches, create branches, or move the worktree (git switch/checkout/branch/worktree). The extension commits your changes when you finish. git status/diff are fine for inspecting your own changes. node_modules is a SHARED symlink to the main checkout: never install, upgrade, or delete dependencies (no npm/bun/yarn/pnpm install, no \`rm -rf node_modules\`) — those writes escape your worktree and damage the user's project. If the task truly needs a dependency change, edit the manifest only and say so in your answer.`
|
|
910
1519
|
: "";
|
|
911
|
-
const subagentInstruction = `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer. You MAY use ask_parent only when truly blocked on information only the parent has (set urgent: true only when nothing else can proceed while you wait); notify_parent for one-way updates; send_agent_message/poll_agent_messages to coordinate with siblings. Your mailbox address and siblings: ${task.roster ?? "(none)"}. Use the exact task ids (e.g. task_2) as send_agent_message targets. Siblings run independently and may start late or finish early — never block indefinitely on their replies: poll at most 5 times, then proceed with your best judgment. A gated sibling (marked ↳ waits in the graph) may not be running yet; do not wait for it. An unanswered ask_parent times out after 10 minutes — proceed with your best judgment then. When your work is done, call notify_parent ONCE with
|
|
1520
|
+
const subagentInstruction = `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer. You MAY use ask_parent only when truly blocked on information only the parent has (set urgent: true only when nothing else can proceed while you wait); notify_parent for one-way updates; send_agent_message/poll_agent_messages to coordinate with siblings. Your mailbox address and siblings: ${task.roster ?? "(none)"}. Use the exact task ids (e.g. task_2) as send_agent_message targets. Siblings run independently and may start late or finish early — never block indefinitely on their replies: poll at most 5 times, then proceed with your best judgment. A gated sibling (marked ↳ waits in the graph) may not be running yet; do not wait for it. An unanswered ask_parent times out after 10 minutes — proceed with your best judgment then. When your work is done, call notify_parent ONCE with final: true and your complete concise result — key findings, verdicts, file:line evidence — as your last tool call. Repeat that exact message as your final answer; changing it or doing more tool work keeps a completion follow-up. Progress updates omit final. Identical updates to the leader are deduplicated.${worktreeNote}`;
|
|
912
1521
|
|
|
913
1522
|
const loader = new DefaultResourceLoader({
|
|
914
1523
|
cwd: childCwd,
|
|
@@ -1014,12 +1623,22 @@ export class SubagentManager {
|
|
|
1014
1623
|
const finalText =
|
|
1015
1624
|
task.finalText ||
|
|
1016
1625
|
truncateText((child.messages as AssistantMessage[]).map(getFirstText).filter(Boolean).at(-1) || "");
|
|
1626
|
+
// Prefer the fingerprint captured from the raw assistant text; never hash the 24-KiB-truncated copy.
|
|
1627
|
+
const finalFingerprint =
|
|
1628
|
+
task.finalTextFingerprint ??
|
|
1629
|
+
textFingerprint((child.messages as AssistantMessage[]).map(getFirstText).filter(Boolean).at(-1) || finalText);
|
|
1017
1630
|
|
|
1018
1631
|
if (task.status === "awaiting_parent") {
|
|
1019
1632
|
this.pendingReplies.get(key)?.resolve("(your task is being finalized — stop work and return now)");
|
|
1020
1633
|
}
|
|
1021
1634
|
if (!TERMINAL.includes(task.status)) {
|
|
1022
|
-
this.updateTask(
|
|
1635
|
+
this.updateTask(
|
|
1636
|
+
run,
|
|
1637
|
+
task,
|
|
1638
|
+
{ status: "completed", finalText, finalTextFingerprint: finalFingerprint, endedAt: Date.now() },
|
|
1639
|
+
ctx,
|
|
1640
|
+
onUpdate,
|
|
1641
|
+
);
|
|
1023
1642
|
if (wt) {
|
|
1024
1643
|
let committed: "committed" | "empty" | undefined;
|
|
1025
1644
|
try {
|
|
@@ -1075,11 +1694,9 @@ export class SubagentManager {
|
|
|
1075
1694
|
await Promise.race([child?.abort(), new Promise((r) => setTimeout(r, 5000))]);
|
|
1076
1695
|
} catch {}
|
|
1077
1696
|
|
|
1078
|
-
const
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
(child?.messages as AssistantMessage[] | undefined)?.map(getFirstText).filter(Boolean).at(-1) || "",
|
|
1082
|
-
);
|
|
1697
|
+
const salvageText =
|
|
1698
|
+
(child?.messages as AssistantMessage[] | undefined)?.map(getFirstText).filter(Boolean).at(-1) || "";
|
|
1699
|
+
const salvaged = task.finalText || truncateText(salvageText);
|
|
1083
1700
|
this.updateTask(
|
|
1084
1701
|
run,
|
|
1085
1702
|
task,
|
|
@@ -1087,6 +1704,7 @@ export class SubagentManager {
|
|
|
1087
1704
|
status: aborted ? "aborted" : ((subagentStatus as TaskStatus) ?? "failed"),
|
|
1088
1705
|
error: err instanceof Error ? err.message : String(err),
|
|
1089
1706
|
finalText: salvaged || undefined,
|
|
1707
|
+
finalTextFingerprint: task.finalTextFingerprint ?? (salvageText ? textFingerprint(salvageText) : undefined),
|
|
1090
1708
|
endedAt: Date.now(),
|
|
1091
1709
|
},
|
|
1092
1710
|
ctx,
|
|
@@ -1459,14 +2077,46 @@ export class SubagentManager {
|
|
|
1459
2077
|
error: undefined,
|
|
1460
2078
|
endedAt: undefined,
|
|
1461
2079
|
finalText: undefined,
|
|
2080
|
+
finalTextFingerprint: undefined,
|
|
1462
2081
|
notifiedParent: false,
|
|
2082
|
+
finalParentReport: undefined,
|
|
1463
2083
|
diffStat: undefined,
|
|
1464
2084
|
changedFiles: undefined,
|
|
1465
2085
|
worktreeError: undefined,
|
|
1466
2086
|
});
|
|
2087
|
+
const scope = `${run.id}:${task.id}`;
|
|
2088
|
+
this.terminalNotices.delete(scope);
|
|
2089
|
+
this.pendingTaskNotices.delete(scope);
|
|
2090
|
+
this.pendingRunNotices.delete(run.id);
|
|
2091
|
+
this.notificationRetries.delete(run.id);
|
|
2092
|
+
for (const [body, receipt] of this.leaderMessages) {
|
|
2093
|
+
if (receipt.runId !== run.id || !receipt.taskIds.includes(task.id)) continue;
|
|
2094
|
+
if (receipt.taskIds.length === 1) {
|
|
2095
|
+
this.leaderMessages.delete(body);
|
|
2096
|
+
continue;
|
|
2097
|
+
}
|
|
2098
|
+
// A shared aggregate stays for its remaining tasks; the resumed task's slice is published
|
|
2099
|
+
// again by the run's next settle.
|
|
2100
|
+
receipt.taskIds = receipt.taskIds.filter((id) => id !== task.id);
|
|
2101
|
+
}
|
|
2102
|
+
for (const [body, entry] of this.outbox) {
|
|
2103
|
+
if (entry.runId !== run.id || !entry.taskIds.includes(task.id)) continue;
|
|
2104
|
+
if (entry.taskIds.length === 1) {
|
|
2105
|
+
this.outbox.delete(body);
|
|
2106
|
+
continue;
|
|
2107
|
+
}
|
|
2108
|
+
this.outbox.delete(body);
|
|
2109
|
+
for (const otherId of entry.taskIds) {
|
|
2110
|
+
if (otherId === task.id) continue;
|
|
2111
|
+
const notice = this.terminalNotices.get(`${run.id}:${otherId}`);
|
|
2112
|
+
if (notice) notice.delivery = "lost";
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
this.awaitedTaskNotices.delete(scope);
|
|
1467
2116
|
run.status = "running";
|
|
1468
2117
|
run.endedAt = undefined;
|
|
1469
2118
|
run.awaited = false;
|
|
2119
|
+
run.completionAwaited = false;
|
|
1470
2120
|
this.settlers.set(run.id, true);
|
|
1471
2121
|
this.runControllers.set(run.id, new AbortController());
|
|
1472
2122
|
for (const t of run.tasks) this.mailboxes.open(`${run.id}:${t.id}`);
|
|
@@ -1579,7 +2229,11 @@ export class SubagentManager {
|
|
|
1579
2229
|
this.settlers.delete(runId);
|
|
1580
2230
|
const waiters = this.settleWaiters.get(runId);
|
|
1581
2231
|
this.settleWaiters.delete(runId);
|
|
1582
|
-
if (!waiters) return;
|
|
2232
|
+
if (!waiters?.size) return;
|
|
2233
|
+
if (this.parked.get(runId)?.size) {
|
|
2234
|
+
run.awaited = true;
|
|
2235
|
+
run.completionAwaited = true;
|
|
2236
|
+
}
|
|
1583
2237
|
const snapshot = cloneRun(run);
|
|
1584
2238
|
for (const waiter of waiters) waiter(snapshot);
|
|
1585
2239
|
}
|
|
@@ -1625,6 +2279,7 @@ export class SubagentManager {
|
|
|
1625
2279
|
};
|
|
1626
2280
|
if (TERMINAL.includes(run.status)) {
|
|
1627
2281
|
run.awaited = true;
|
|
2282
|
+
run.completionAwaited = true;
|
|
1628
2283
|
return Promise.resolve({ run: cloneRun(run), intercom: [] });
|
|
1629
2284
|
}
|
|
1630
2285
|
const msgs: ParkedMsg[] = [];
|
|
@@ -1654,7 +2309,10 @@ export class SubagentManager {
|
|
|
1654
2309
|
settled.then((r) => {
|
|
1655
2310
|
finish();
|
|
1656
2311
|
|
|
1657
|
-
if (!timedOut)
|
|
2312
|
+
if (!timedOut) {
|
|
2313
|
+
run.awaited = true;
|
|
2314
|
+
if (r && TERMINAL.includes(r.status)) run.completionAwaited = true;
|
|
2315
|
+
}
|
|
1658
2316
|
return { run: r, intercom: msgs };
|
|
1659
2317
|
}),
|
|
1660
2318
|
new Promise<{ run: RunSnapshot | undefined; intercom: ParkedMsg[] } | undefined>((resolve) => {
|
|
@@ -1670,6 +2328,7 @@ export class SubagentManager {
|
|
|
1670
2328
|
return settled.then((r) => {
|
|
1671
2329
|
finish();
|
|
1672
2330
|
run.awaited = true;
|
|
2331
|
+
if (r && TERMINAL.includes(r.status)) run.completionAwaited = true;
|
|
1673
2332
|
return { run: r, intercom: msgs };
|
|
1674
2333
|
});
|
|
1675
2334
|
}
|