pi-agent-squad 0.8.4 → 0.8.5
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 +42 -0
- package/index.ts +539 -101
- package/package.json +18 -1
- package/session.ts +2 -0
- package/spawn.ts +86 -39
- package/task-delivery.ts +201 -0
- package/task-recovery.ts +41 -0
- package/task-state.ts +286 -0
- package/task-status.ts +107 -0
package/index.ts
CHANGED
|
@@ -36,6 +36,27 @@ import { openSubagentSessionOverlay } from "./session-ui.ts";
|
|
|
36
36
|
import { getFinalOutput, spawnInteractiveSubagent } from "./spawn.ts";
|
|
37
37
|
import { deadlockMessage, MessageWaitGraph } from "./wait-graph.ts";
|
|
38
38
|
import { ActiveRunRegistry, type ActiveRun } from "./active-runs.ts";
|
|
39
|
+
import {
|
|
40
|
+
boundTaskText,
|
|
41
|
+
isTerminalTaskStatus,
|
|
42
|
+
isRetryableTaskStatus,
|
|
43
|
+
reconstructTaskStates,
|
|
44
|
+
TASK_STATE_ENTRY,
|
|
45
|
+
TASK_STATE_LIMITS,
|
|
46
|
+
TASK_STATE_VERSION,
|
|
47
|
+
validateLaunchText,
|
|
48
|
+
type PersistedTaskState,
|
|
49
|
+
type PersistedTaskStatus,
|
|
50
|
+
} from "./task-state.ts";
|
|
51
|
+
import { planTaskRecovery } from "./task-recovery.ts";
|
|
52
|
+
import {
|
|
53
|
+
BACKGROUND_EVENT_TYPE,
|
|
54
|
+
branchHasResultDelivery,
|
|
55
|
+
RecoveryOutbox,
|
|
56
|
+
resultDeliveryId,
|
|
57
|
+
type BackgroundEventDetails,
|
|
58
|
+
} from "./task-delivery.ts";
|
|
59
|
+
import { formatTaskStatus, type LiveTaskView } from "./task-status.ts";
|
|
39
60
|
|
|
40
61
|
const MESSAGE_ROOT_BASE = path.join(
|
|
41
62
|
os.tmpdir(),
|
|
@@ -51,7 +72,6 @@ const RUNNING_WIDGET_TICK_MS = 1000;
|
|
|
51
72
|
const RUNNING_WIDGET_NAV_DEBOUNCE_MS = 150;
|
|
52
73
|
const RUNNING_WIDGET_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
53
74
|
const INCOMING_MESSAGE_TYPE = "pi_message_request";
|
|
54
|
-
const BACKGROUND_EVENT_TYPE = "pi_subagent_background_event";
|
|
55
75
|
|
|
56
76
|
interface IncomingMessageDetails {
|
|
57
77
|
id?: string;
|
|
@@ -61,15 +81,6 @@ interface IncomingMessageDetails {
|
|
|
61
81
|
createdAt?: number;
|
|
62
82
|
}
|
|
63
83
|
|
|
64
|
-
interface BackgroundEventDetails {
|
|
65
|
-
agent?: string;
|
|
66
|
-
address?: string;
|
|
67
|
-
status?: "done" | "error";
|
|
68
|
-
body?: string;
|
|
69
|
-
elapsedMs?: number;
|
|
70
|
-
runId?: string;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
84
|
type TranscriptEventKind = "message" | "done" | "error";
|
|
74
85
|
|
|
75
86
|
function customMessageText(content: unknown): string {
|
|
@@ -351,7 +362,12 @@ interface AsyncTask {
|
|
|
351
362
|
runId: string;
|
|
352
363
|
agent: string;
|
|
353
364
|
address: string;
|
|
365
|
+
task: string;
|
|
366
|
+
cwd: string;
|
|
367
|
+
readOnly: boolean;
|
|
354
368
|
startedAt: number;
|
|
369
|
+
updatedAt: number;
|
|
370
|
+
timeoutAt: number;
|
|
355
371
|
sessionGeneration: number;
|
|
356
372
|
}
|
|
357
373
|
|
|
@@ -579,7 +595,14 @@ export class RunningSubagentWidgetController {
|
|
|
579
595
|
}
|
|
580
596
|
}
|
|
581
597
|
|
|
582
|
-
export
|
|
598
|
+
export interface AgentSquadRuntimeDependencies {
|
|
599
|
+
spawnInteractiveSubagent: typeof spawnInteractiveSubagent;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export function createAgentSquadExtension(
|
|
603
|
+
pi: ExtensionAPI,
|
|
604
|
+
dependencies: AgentSquadRuntimeDependencies = { spawnInteractiveSubagent },
|
|
605
|
+
) {
|
|
583
606
|
const isChild = process.env[ENV_ROLE] === "child";
|
|
584
607
|
|
|
585
608
|
if (isChild) {
|
|
@@ -589,6 +612,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
589
612
|
}
|
|
590
613
|
|
|
591
614
|
// ===== main-agent mode =====
|
|
615
|
+
const ownerRuntimeId = randomUUID();
|
|
592
616
|
pi.registerMessageRenderer<IncomingMessageDetails>(
|
|
593
617
|
INCOMING_MESSAGE_TYPE,
|
|
594
618
|
(message, options, theme) => {
|
|
@@ -650,13 +674,142 @@ export default function (pi: ExtensionAPI) {
|
|
|
650
674
|
string,
|
|
651
675
|
{ release: () => void; timer?: ReturnType<typeof setTimeout> }
|
|
652
676
|
>();
|
|
677
|
+
const persistedTasks = new Map<string, PersistedTaskState>();
|
|
653
678
|
let sessionContext: any;
|
|
679
|
+
let runtimeContext: any;
|
|
680
|
+
let currentSessionToken: string | undefined;
|
|
681
|
+
let acceptingTasks = false;
|
|
682
|
+
let shutdownStarted = false;
|
|
683
|
+
let router: ReturnType<typeof createMessageRouter> | undefined;
|
|
654
684
|
let terminalInputUnsubscribe: (() => void) | undefined;
|
|
655
685
|
let sessionOverlayOpen = false;
|
|
656
686
|
let closeSessionOverlay: (() => void) | undefined;
|
|
657
687
|
let pendingOpenActivityId: string | undefined;
|
|
658
688
|
let lastNavigation: { direction: -1 | 1; at: number } | undefined;
|
|
659
689
|
|
|
690
|
+
function appendTaskSnapshot(state: PersistedTaskState): PersistedTaskState {
|
|
691
|
+
pi.appendEntry(TASK_STATE_ENTRY, state);
|
|
692
|
+
persistedTasks.set(state.runId, state);
|
|
693
|
+
return state;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function transitionTask(
|
|
697
|
+
runId: string,
|
|
698
|
+
expected: readonly PersistedTaskStatus[],
|
|
699
|
+
patch: Partial<Omit<PersistedTaskState, "version" | "runId" | "updatedAt">>,
|
|
700
|
+
now = Date.now(),
|
|
701
|
+
): PersistedTaskState | undefined {
|
|
702
|
+
const current = persistedTasks.get(runId);
|
|
703
|
+
if (!current || !expected.includes(current.status)) return undefined;
|
|
704
|
+
const next: PersistedTaskState = {
|
|
705
|
+
...current,
|
|
706
|
+
...patch,
|
|
707
|
+
version: TASK_STATE_VERSION,
|
|
708
|
+
runId,
|
|
709
|
+
updatedAt: now,
|
|
710
|
+
};
|
|
711
|
+
return appendTaskSnapshot(next);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function reportPersistenceFailure(error: unknown, prefix: string): void {
|
|
715
|
+
const message = `${prefix}: ${error instanceof Error ? error.message : String(error)}`;
|
|
716
|
+
try {
|
|
717
|
+
runtimeContext?.ui?.notify?.(message, "warning");
|
|
718
|
+
} catch {
|
|
719
|
+
/* UI may already be closing */
|
|
720
|
+
}
|
|
721
|
+
console.error(`[pi-agent-squad] ${message}`);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const recoveryOutbox = new RecoveryOutbox({
|
|
725
|
+
getSessionToken: () => currentSessionToken,
|
|
726
|
+
getBranch: () => runtimeContext?.sessionManager?.getBranch?.() ?? [],
|
|
727
|
+
getTaskState: (runId) => persistedTasks.get(runId),
|
|
728
|
+
send: (message, options) => pi.sendMessage(message, options),
|
|
729
|
+
markInjected: (state) => {
|
|
730
|
+
transitionTask(
|
|
731
|
+
state.runId,
|
|
732
|
+
["completed"],
|
|
733
|
+
{ status: "completed", resultInjected: true },
|
|
734
|
+
);
|
|
735
|
+
},
|
|
736
|
+
onError: (error, state) =>
|
|
737
|
+
reportPersistenceFailure(
|
|
738
|
+
error,
|
|
739
|
+
`Could not finish result delivery bookkeeping for run ${state.runId.slice(0, 8)}`,
|
|
740
|
+
),
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
function refreshPersistedTasks(entries: readonly unknown[]): void {
|
|
744
|
+
persistedTasks.clear();
|
|
745
|
+
for (const [runId, state] of reconstructTaskStates(entries)) {
|
|
746
|
+
persistedTasks.set(runId, state);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function liveTaskRunIds(): Set<string> {
|
|
751
|
+
return new Set([
|
|
752
|
+
...tasks.keys(),
|
|
753
|
+
...activeRuns.list().map((run) => run.runId),
|
|
754
|
+
]);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function reconcileCurrentBranch(): void {
|
|
758
|
+
const branch = runtimeContext?.sessionManager?.getBranch?.() ?? [];
|
|
759
|
+
const currentLiveIds = liveTaskRunIds();
|
|
760
|
+
const liveSnapshots = [...persistedTasks.values()].filter(
|
|
761
|
+
(state) =>
|
|
762
|
+
state.ownerRuntimeId === ownerRuntimeId &&
|
|
763
|
+
currentLiveIds.has(state.runId) &&
|
|
764
|
+
(state.status === "starting" || state.status === "running"),
|
|
765
|
+
);
|
|
766
|
+
refreshPersistedTasks(branch);
|
|
767
|
+
for (const state of liveSnapshots) {
|
|
768
|
+
if (!persistedTasks.has(state.runId)) persistedTasks.set(state.runId, state);
|
|
769
|
+
}
|
|
770
|
+
const plan = planTaskRecovery(
|
|
771
|
+
persistedTasks,
|
|
772
|
+
ownerRuntimeId,
|
|
773
|
+
currentLiveIds,
|
|
774
|
+
(deliveryId) => branchHasResultDelivery(branch, deliveryId),
|
|
775
|
+
);
|
|
776
|
+
for (const stale of plan.interrupt) {
|
|
777
|
+
const now = Date.now();
|
|
778
|
+
try {
|
|
779
|
+
transitionTask(
|
|
780
|
+
stale.runId,
|
|
781
|
+
["starting", "running"],
|
|
782
|
+
{
|
|
783
|
+
status: "interrupted",
|
|
784
|
+
error: "Owning Pi runtime ended before the task reached a terminal state.",
|
|
785
|
+
endedAt: now,
|
|
786
|
+
},
|
|
787
|
+
now,
|
|
788
|
+
);
|
|
789
|
+
} catch (error) {
|
|
790
|
+
reportPersistenceFailure(
|
|
791
|
+
error,
|
|
792
|
+
`Could not mark stale run ${stale.runId.slice(0, 8)} interrupted`,
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
for (const delivered of plan.markInjected) {
|
|
797
|
+
try {
|
|
798
|
+
transitionTask(
|
|
799
|
+
delivered.runId,
|
|
800
|
+
["completed"],
|
|
801
|
+
{ status: "completed", resultInjected: true },
|
|
802
|
+
);
|
|
803
|
+
} catch (error) {
|
|
804
|
+
reportPersistenceFailure(
|
|
805
|
+
error,
|
|
806
|
+
`Could not repair delivery state for run ${delivered.runId.slice(0, 8)}`,
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
for (const pending of plan.deliver) recoveryOutbox.enqueue(pending);
|
|
811
|
+
}
|
|
812
|
+
|
|
660
813
|
const bindPoolLifecycle = (): void => {
|
|
661
814
|
pool.setProcessExitHandler((agentName, runId) => {
|
|
662
815
|
const run = activeRuns.resolveExact(runId);
|
|
@@ -715,6 +868,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
715
868
|
|
|
716
869
|
pi.on("session_start", (event, ctx) => {
|
|
717
870
|
sessionGeneration++;
|
|
871
|
+
shutdownStarted = false;
|
|
872
|
+
acceptingTasks = false;
|
|
873
|
+
recoveryOutbox.cancelAll();
|
|
718
874
|
if (poolDisposed) {
|
|
719
875
|
pool = new SubagentPool(messageRoot);
|
|
720
876
|
bindPoolLifecycle();
|
|
@@ -725,6 +881,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
725
881
|
ctx.sessionManager?.getSessionId?.() ??
|
|
726
882
|
(event as any).sessionId ??
|
|
727
883
|
"ephemeral";
|
|
884
|
+
currentSessionToken = `${sessionId}:${sessionGeneration}`;
|
|
885
|
+
runtimeContext = ctx;
|
|
728
886
|
messageRoot = sessionRoot(sessionId);
|
|
729
887
|
ensureChannelRoot(MESSAGE_ROOT_BASE);
|
|
730
888
|
sweepMessageRoots(MESSAGE_ROOT_BASE);
|
|
@@ -733,6 +891,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
733
891
|
pool.setWorkingDirectory(cwd);
|
|
734
892
|
runningWidget.attach(ctx);
|
|
735
893
|
sessionContext = ctx.mode === "tui" ? ctx : undefined;
|
|
894
|
+
reconcileCurrentBranch();
|
|
895
|
+
acceptingTasks = true;
|
|
736
896
|
lastNavigation = undefined;
|
|
737
897
|
terminalInputUnsubscribe?.();
|
|
738
898
|
terminalInputUnsubscribe =
|
|
@@ -760,7 +920,42 @@ export default function (pi: ExtensionAPI) {
|
|
|
760
920
|
: undefined;
|
|
761
921
|
});
|
|
762
922
|
|
|
763
|
-
pi.on("session_shutdown", () => {
|
|
923
|
+
pi.on("session_shutdown", (event, ctx) => {
|
|
924
|
+
if (shutdownStarted) return;
|
|
925
|
+
shutdownStarted = true;
|
|
926
|
+
acceptingTasks = false;
|
|
927
|
+
recoveryOutbox.cancelAll();
|
|
928
|
+
const now = Date.now();
|
|
929
|
+
for (const state of [...persistedTasks.values()]) {
|
|
930
|
+
if (
|
|
931
|
+
state.ownerRuntimeId !== ownerRuntimeId ||
|
|
932
|
+
(state.status !== "starting" && state.status !== "running")
|
|
933
|
+
) {
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
const reason = boundTaskText(
|
|
937
|
+
[
|
|
938
|
+
"Owning Pi runtime shut down before the task reached a terminal state.",
|
|
939
|
+
event.reason ? `Reason: ${event.reason}.` : "",
|
|
940
|
+
]
|
|
941
|
+
.filter(Boolean)
|
|
942
|
+
.join(" "),
|
|
943
|
+
TASK_STATE_LIMITS.error,
|
|
944
|
+
);
|
|
945
|
+
try {
|
|
946
|
+
transitionTask(
|
|
947
|
+
state.runId,
|
|
948
|
+
["starting", "running"],
|
|
949
|
+
{ status: "interrupted", error: reason, endedAt: now },
|
|
950
|
+
now,
|
|
951
|
+
);
|
|
952
|
+
} catch (error) {
|
|
953
|
+
reportPersistenceFailure(
|
|
954
|
+
error,
|
|
955
|
+
`Could not persist shutdown interruption for run ${state.runId.slice(0, 8)}`,
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
764
959
|
sessionGeneration++;
|
|
765
960
|
for (const controller of runControllers.values()) controller.abort();
|
|
766
961
|
runControllers.clear();
|
|
@@ -772,16 +967,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
772
967
|
sessionContext = undefined;
|
|
773
968
|
pendingOpenActivityId = undefined;
|
|
774
969
|
sessionHandles.clear();
|
|
775
|
-
activeRuns.clear();
|
|
776
970
|
for (const pending of pendingMainReplyEdges.values()) {
|
|
777
971
|
if (pending.timer) clearTimeout(pending.timer);
|
|
778
972
|
pending.release();
|
|
779
973
|
}
|
|
780
974
|
pendingMainReplyEdges.clear();
|
|
781
975
|
waitGraph.clear();
|
|
782
|
-
runningWidget.shutdown();
|
|
783
976
|
pool.dispose();
|
|
784
977
|
poolDisposed = true;
|
|
978
|
+
router?.dispose();
|
|
979
|
+
router = undefined;
|
|
980
|
+
activeRuns.clear();
|
|
981
|
+
tasks.clear();
|
|
982
|
+
runningWidget.shutdown();
|
|
983
|
+
currentSessionToken = undefined;
|
|
984
|
+
runtimeContext = undefined;
|
|
785
985
|
sweepMessageRoots(MESSAGE_ROOT_BASE);
|
|
786
986
|
});
|
|
787
987
|
|
|
@@ -809,7 +1009,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
809
1009
|
// ---- message router: subagent -> main injects into main session; child
|
|
810
1010
|
// messages resolve through the active-run registry before being delivered
|
|
811
1011
|
// to a direct/background session or the resident pool. ----
|
|
812
|
-
let router: ReturnType<typeof createMessageRouter> | undefined;
|
|
813
1012
|
const routedTimeout = (msg: MessageRequest): number =>
|
|
814
1013
|
Math.min(
|
|
815
1014
|
MAX_SUBAGENT_TIMEOUT_SECONDS * 1000,
|
|
@@ -1009,11 +1208,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1009
1208
|
router.start();
|
|
1010
1209
|
});
|
|
1011
1210
|
|
|
1012
|
-
pi.on("session_shutdown", () => {
|
|
1013
|
-
router?.dispose();
|
|
1014
|
-
router = undefined;
|
|
1015
|
-
});
|
|
1016
|
-
|
|
1017
1211
|
/** Write a reply back to the message sender */
|
|
1018
1212
|
function writeReplyTo(msg: MessageRequest, content: string, root = messageRoot): boolean {
|
|
1019
1213
|
try {
|
|
@@ -1045,20 +1239,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1045
1239
|
return `${agent.name}#${runId.slice(0, 8)}`;
|
|
1046
1240
|
}
|
|
1047
1241
|
|
|
1048
|
-
function
|
|
1242
|
+
function resolveDirectRunAddress(
|
|
1049
1243
|
agent: AgentConfig,
|
|
1050
1244
|
runId: string,
|
|
1051
|
-
mode: "background" | "task",
|
|
1052
1245
|
requestedAddress?: string,
|
|
1053
|
-
|
|
1054
|
-
runCwd = cwd,
|
|
1055
|
-
): {
|
|
1056
|
-
address: string;
|
|
1057
|
-
session: Promise<SubagentSessionHandle>;
|
|
1058
|
-
resolveSession: (session: SubagentSessionHandle) => void;
|
|
1059
|
-
rejectSession: (error: Error) => void;
|
|
1060
|
-
} {
|
|
1246
|
+
): string {
|
|
1061
1247
|
const address = requestedAddress?.trim() || directRunAddress(agent, runId);
|
|
1248
|
+
validateLaunchText("address", address);
|
|
1062
1249
|
if (
|
|
1063
1250
|
(requestedAddress &&
|
|
1064
1251
|
(!/^[A-Za-z0-9_.-]+$/.test(address) ||
|
|
@@ -1073,6 +1260,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
1073
1260
|
`Invalid or unavailable runtime address "${address}". Use a unique name matching [A-Za-z0-9_.-] that is not a logical agent name or "main".`,
|
|
1074
1261
|
);
|
|
1075
1262
|
}
|
|
1263
|
+
return address;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function createDirectRun(
|
|
1267
|
+
agent: AgentConfig,
|
|
1268
|
+
runId: string,
|
|
1269
|
+
mode: "background" | "task",
|
|
1270
|
+
address: string,
|
|
1271
|
+
readOnly = agent.readOnly === true,
|
|
1272
|
+
runCwd = cwd,
|
|
1273
|
+
): {
|
|
1274
|
+
address: string;
|
|
1275
|
+
session: Promise<SubagentSessionHandle>;
|
|
1276
|
+
resolveSession: (session: SubagentSessionHandle) => void;
|
|
1277
|
+
rejectSession: (error: Error) => void;
|
|
1278
|
+
} {
|
|
1279
|
+
if (activeRuns.hasAddress(address)) {
|
|
1280
|
+
throw new Error(`Runtime address "${address}" became unavailable before launch.`);
|
|
1281
|
+
}
|
|
1076
1282
|
let resolveSession!: (session: SubagentSessionHandle) => void;
|
|
1077
1283
|
let rejectSession!: (error: Error) => void;
|
|
1078
1284
|
const session = new Promise<SubagentSessionHandle>((resolve, reject) => {
|
|
@@ -1122,29 +1328,77 @@ export default function (pi: ExtensionAPI) {
|
|
|
1122
1328
|
timeoutMs?: number,
|
|
1123
1329
|
requestedAddress?: string,
|
|
1124
1330
|
readOnly = agent.readOnly === true,
|
|
1331
|
+
retryOf?: string,
|
|
1125
1332
|
): { runId: string; address: string } {
|
|
1333
|
+
if (!acceptingTasks) throw new Error("This Pi session is shutting down and cannot accept new subagent tasks.");
|
|
1334
|
+
validateLaunchText("agent", agent.name);
|
|
1335
|
+
validateLaunchText("task", taskText, { allowEmpty: true });
|
|
1336
|
+
const runCwd = normalizeCwd(cwdOverride ?? cwd);
|
|
1337
|
+
validateLaunchText("cwd", runCwd);
|
|
1126
1338
|
const runId = randomUUID();
|
|
1127
|
-
const
|
|
1128
|
-
const
|
|
1129
|
-
|
|
1339
|
+
const address = resolveDirectRunAddress(agent, runId, requestedAddress);
|
|
1340
|
+
const startedAt = Date.now();
|
|
1341
|
+
const effectiveTimeoutMs = Math.max(
|
|
1342
|
+
1000,
|
|
1343
|
+
timeoutMs ?? DEFAULT_SUBAGENT_TIMEOUT_SECONDS * 1000,
|
|
1344
|
+
);
|
|
1345
|
+
const timeoutAt = Math.min(Number.MAX_SAFE_INTEGER, startedAt + effectiveTimeoutMs);
|
|
1346
|
+
const starting: PersistedTaskState = {
|
|
1347
|
+
version: TASK_STATE_VERSION,
|
|
1130
1348
|
runId,
|
|
1131
|
-
|
|
1132
|
-
|
|
1349
|
+
address,
|
|
1350
|
+
agent: agent.name,
|
|
1351
|
+
task: taskText,
|
|
1352
|
+
cwd: runCwd,
|
|
1133
1353
|
readOnly,
|
|
1134
|
-
|
|
1135
|
-
|
|
1354
|
+
mode: "async",
|
|
1355
|
+
status: "starting",
|
|
1356
|
+
startedAt,
|
|
1357
|
+
updatedAt: startedAt,
|
|
1358
|
+
timeoutAt,
|
|
1359
|
+
...(retryOf ? { retryOf } : {}),
|
|
1360
|
+
ownerRuntimeId,
|
|
1361
|
+
};
|
|
1362
|
+
appendTaskSnapshot(starting);
|
|
1363
|
+
|
|
1364
|
+
const controller = new AbortController();
|
|
1365
|
+
let direct: ReturnType<typeof createDirectRun>;
|
|
1366
|
+
try {
|
|
1367
|
+
direct = createDirectRun(agent, runId, "background", address, readOnly, runCwd);
|
|
1368
|
+
} catch (error) {
|
|
1369
|
+
const now = Date.now();
|
|
1370
|
+
transitionTask(
|
|
1371
|
+
runId,
|
|
1372
|
+
["starting"],
|
|
1373
|
+
{
|
|
1374
|
+
status: "failed",
|
|
1375
|
+
error: boundTaskText(
|
|
1376
|
+
error instanceof Error ? error.message : String(error),
|
|
1377
|
+
TASK_STATE_LIMITS.error,
|
|
1378
|
+
),
|
|
1379
|
+
endedAt: now,
|
|
1380
|
+
},
|
|
1381
|
+
now,
|
|
1382
|
+
);
|
|
1383
|
+
throw error;
|
|
1384
|
+
}
|
|
1136
1385
|
runControllers.set(runId, controller);
|
|
1137
1386
|
tasks.set(runId, {
|
|
1138
1387
|
runId,
|
|
1139
1388
|
agent: agent.name,
|
|
1140
|
-
address
|
|
1141
|
-
|
|
1389
|
+
address,
|
|
1390
|
+
task: taskText,
|
|
1391
|
+
cwd: runCwd,
|
|
1392
|
+
readOnly,
|
|
1393
|
+
startedAt,
|
|
1394
|
+
updatedAt: startedAt,
|
|
1395
|
+
timeoutAt,
|
|
1142
1396
|
sessionGeneration,
|
|
1143
1397
|
});
|
|
1144
1398
|
runningWidget.start(runId, agent.name, taskText, "background");
|
|
1145
1399
|
|
|
1146
1400
|
const start = async () => {
|
|
1147
|
-
return await spawnInteractiveSubagent({
|
|
1401
|
+
return await dependencies.spawnInteractiveSubagent({
|
|
1148
1402
|
agent,
|
|
1149
1403
|
task: taskText,
|
|
1150
1404
|
address: direct.address,
|
|
@@ -1153,7 +1407,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
1153
1407
|
runId,
|
|
1154
1408
|
childIndex: 0,
|
|
1155
1409
|
signal: controller.signal,
|
|
1156
|
-
timeoutMs,
|
|
1410
|
+
timeoutMs: effectiveTimeoutMs,
|
|
1411
|
+
persistSession: true,
|
|
1412
|
+
onStarted: (childSessionFile) => {
|
|
1413
|
+
const now = Date.now();
|
|
1414
|
+
const running = transitionTask(
|
|
1415
|
+
runId,
|
|
1416
|
+
["starting"],
|
|
1417
|
+
{
|
|
1418
|
+
status: "running",
|
|
1419
|
+
...(childSessionFile
|
|
1420
|
+
? {
|
|
1421
|
+
childSessionFile: boundTaskText(
|
|
1422
|
+
childSessionFile,
|
|
1423
|
+
TASK_STATE_LIMITS.childSessionFile,
|
|
1424
|
+
),
|
|
1425
|
+
}
|
|
1426
|
+
: {}),
|
|
1427
|
+
},
|
|
1428
|
+
now,
|
|
1429
|
+
);
|
|
1430
|
+
const task = tasks.get(runId);
|
|
1431
|
+
if (task && running) task.updatedAt = running.updatedAt;
|
|
1432
|
+
},
|
|
1157
1433
|
onSession: (session) => {
|
|
1158
1434
|
direct.resolveSession(session);
|
|
1159
1435
|
registerSessionHandle(runId, session);
|
|
@@ -1168,47 +1444,82 @@ export default function (pi: ExtensionAPI) {
|
|
|
1168
1444
|
activeRuns.remove(runId);
|
|
1169
1445
|
const task = tasks.get(runId);
|
|
1170
1446
|
if (!task || task.sessionGeneration !== sessionGeneration) return;
|
|
1171
|
-
const finalStatus: "done" | "error" =
|
|
1172
|
-
result.exitCode === 0 && result.stopReason !== "error" ? "done" : "error";
|
|
1173
1447
|
const output = getFinalOutput(result.messages) || "(no text output)";
|
|
1174
1448
|
const failureReason =
|
|
1175
1449
|
result.exitCode !== 0
|
|
1176
|
-
? result.
|
|
1450
|
+
? result.errorMessage || result.stderr || `process exited with code ${result.exitCode}`
|
|
1177
1451
|
: result.stopReason === "error"
|
|
1178
1452
|
? result.errorMessage || "subagent reported an error"
|
|
1179
1453
|
: "";
|
|
1180
|
-
const
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
const
|
|
1185
|
-
|
|
1186
|
-
?
|
|
1187
|
-
:
|
|
1188
|
-
|
|
1454
|
+
const cancelled =
|
|
1455
|
+
result.exitCode === 124 ||
|
|
1456
|
+
result.stopReason === "aborted" ||
|
|
1457
|
+
/\b(?:timed out|cancelled|canceled|aborted)\b/i.test(result.errorMessage ?? "");
|
|
1458
|
+
const finalStatus: PersistedTaskStatus =
|
|
1459
|
+
result.exitCode === 0 && result.stopReason !== "error" && result.stopReason !== "aborted"
|
|
1460
|
+
? "completed"
|
|
1461
|
+
: cancelled
|
|
1462
|
+
? "cancelled"
|
|
1463
|
+
: "failed";
|
|
1464
|
+
const now = Date.now();
|
|
1465
|
+
try {
|
|
1466
|
+
if (finalStatus === "completed") {
|
|
1467
|
+
const completed = transitionTask(
|
|
1468
|
+
runId,
|
|
1469
|
+
["starting", "running"],
|
|
1470
|
+
{
|
|
1471
|
+
status: "completed",
|
|
1472
|
+
resultSummary: boundTaskText(output, TASK_STATE_LIMITS.resultSummary),
|
|
1473
|
+
deliveryId: resultDeliveryId(runId),
|
|
1474
|
+
resultInjected: false,
|
|
1475
|
+
endedAt: now,
|
|
1476
|
+
},
|
|
1477
|
+
now,
|
|
1478
|
+
);
|
|
1479
|
+
if (completed) {
|
|
1480
|
+
recoveryOutbox.enqueue(completed, { body: output, content: output });
|
|
1481
|
+
}
|
|
1482
|
+
} else {
|
|
1483
|
+
const diagnostic = boundTaskText(
|
|
1484
|
+
failureReason || result.errorMessage || `Subagent ${finalStatus}.`,
|
|
1485
|
+
TASK_STATE_LIMITS.error,
|
|
1486
|
+
);
|
|
1487
|
+
const terminal = transitionTask(
|
|
1488
|
+
runId,
|
|
1489
|
+
["starting", "running"],
|
|
1490
|
+
{ status: finalStatus, error: diagnostic, endedAt: now },
|
|
1491
|
+
now,
|
|
1492
|
+
);
|
|
1493
|
+
if (terminal) {
|
|
1494
|
+
const displayBody = [
|
|
1495
|
+
diagnostic,
|
|
1189
1496
|
output === "(no text output)" ? "" : `**Partial result**\n\n${output}`,
|
|
1190
1497
|
]
|
|
1191
1498
|
.filter(Boolean)
|
|
1192
1499
|
.join("\n\n");
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1500
|
+
pi.sendMessage(
|
|
1501
|
+
{
|
|
1502
|
+
customType: BACKGROUND_EVENT_TYPE,
|
|
1503
|
+
content: `[background subagent error] ${agent.name} (run ${runId.slice(0, 8)}): ${diagnostic}`,
|
|
1504
|
+
display: true,
|
|
1505
|
+
details: {
|
|
1506
|
+
agent: agent.name,
|
|
1507
|
+
address,
|
|
1508
|
+
status: "error",
|
|
1509
|
+
body: displayBody,
|
|
1510
|
+
elapsedMs: now - task.startedAt,
|
|
1511
|
+
runId,
|
|
1512
|
+
},
|
|
1513
|
+
},
|
|
1514
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
} catch (error) {
|
|
1519
|
+
reportPersistenceFailure(
|
|
1520
|
+
error,
|
|
1521
|
+
`Could not finalize background run ${runId.slice(0, 8)}`,
|
|
1209
1522
|
);
|
|
1210
|
-
} catch {
|
|
1211
|
-
/* session is closed */
|
|
1212
1523
|
}
|
|
1213
1524
|
})
|
|
1214
1525
|
.catch((err) => {
|
|
@@ -1216,26 +1527,47 @@ export default function (pi: ExtensionAPI) {
|
|
|
1216
1527
|
direct.rejectSession(err instanceof Error ? err : new Error(String(err)));
|
|
1217
1528
|
const task = tasks.get(runId);
|
|
1218
1529
|
const errorText = err instanceof Error ? err.message : String(err);
|
|
1530
|
+
const current = persistedTasks.get(runId);
|
|
1531
|
+
if (!current || isTerminalTaskStatus(current.status)) return;
|
|
1532
|
+
const cancelled =
|
|
1533
|
+
controller.signal.aborted ||
|
|
1534
|
+
/\b(?:timed out|cancelled|canceled|aborted)\b/i.test(errorText);
|
|
1535
|
+
const now = Date.now();
|
|
1219
1536
|
try {
|
|
1220
1537
|
if (task && task.sessionGeneration !== sessionGeneration) return;
|
|
1221
|
-
|
|
1538
|
+
const terminal = transitionTask(
|
|
1539
|
+
runId,
|
|
1540
|
+
["starting", "running"],
|
|
1222
1541
|
{
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
details: {
|
|
1227
|
-
agent: agent.name,
|
|
1228
|
-
address: direct.address,
|
|
1229
|
-
status: "error",
|
|
1230
|
-
body: errorText,
|
|
1231
|
-
elapsedMs: task ? Date.now() - task.startedAt : undefined,
|
|
1232
|
-
runId,
|
|
1233
|
-
},
|
|
1542
|
+
status: cancelled ? "cancelled" : "failed",
|
|
1543
|
+
error: boundTaskText(errorText, TASK_STATE_LIMITS.error),
|
|
1544
|
+
endedAt: now,
|
|
1234
1545
|
},
|
|
1235
|
-
|
|
1546
|
+
now,
|
|
1547
|
+
);
|
|
1548
|
+
if (terminal) {
|
|
1549
|
+
pi.sendMessage(
|
|
1550
|
+
{
|
|
1551
|
+
customType: BACKGROUND_EVENT_TYPE,
|
|
1552
|
+
content: `[background subagent error] ${agent.name} (run ${runId.slice(0, 8)}): ${errorText}`,
|
|
1553
|
+
display: true,
|
|
1554
|
+
details: {
|
|
1555
|
+
agent: agent.name,
|
|
1556
|
+
address,
|
|
1557
|
+
status: "error",
|
|
1558
|
+
body: errorText,
|
|
1559
|
+
elapsedMs: task ? now - task.startedAt : undefined,
|
|
1560
|
+
runId,
|
|
1561
|
+
},
|
|
1562
|
+
},
|
|
1563
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1566
|
+
} catch (error) {
|
|
1567
|
+
reportPersistenceFailure(
|
|
1568
|
+
error,
|
|
1569
|
+
`Could not record background failure for run ${runId.slice(0, 8)}`,
|
|
1236
1570
|
);
|
|
1237
|
-
} catch {
|
|
1238
|
-
/* ignore */
|
|
1239
1571
|
}
|
|
1240
1572
|
})
|
|
1241
1573
|
.finally(() => {
|
|
@@ -1247,7 +1579,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1247
1579
|
runningWidget.finish(runId);
|
|
1248
1580
|
});
|
|
1249
1581
|
|
|
1250
|
-
return { runId, address
|
|
1582
|
+
return { runId, address };
|
|
1251
1583
|
}
|
|
1252
1584
|
|
|
1253
1585
|
// ---- adaptive orchestration prompt; `/orchestrate on` installs the opt-in prompt ----
|
|
@@ -1277,11 +1609,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
1277
1609
|
"While running, a subagent may send_message (to=main) to reach you, or contact other subagents — reply promptly with reply_message. Synchronous tasks must use wait=false when contacting main.",
|
|
1278
1610
|
].join(" "),
|
|
1279
1611
|
parameters: Type.Object({
|
|
1280
|
-
agent: Type.String({ description: "Subagent name" }),
|
|
1281
|
-
task: Type.String({ description: "Task description for the subagent" }),
|
|
1282
|
-
cwd: Type.Optional(Type.String({ description: "Working directory for the subagent" })),
|
|
1612
|
+
agent: Type.String({ maxLength: TASK_STATE_LIMITS.agent, description: "Subagent name" }),
|
|
1613
|
+
task: Type.String({ maxLength: TASK_STATE_LIMITS.task, description: "Task description for the subagent" }),
|
|
1614
|
+
cwd: Type.Optional(Type.String({ maxLength: TASK_STATE_LIMITS.cwd, description: "Working directory for the subagent" })),
|
|
1283
1615
|
as: Type.Optional(
|
|
1284
1616
|
Type.String({
|
|
1617
|
+
maxLength: TASK_STATE_LIMITS.address,
|
|
1285
1618
|
description: "Optional unique runtime address for this run (for example actor-a); identity still comes from agent",
|
|
1286
1619
|
}),
|
|
1287
1620
|
),
|
|
@@ -1301,6 +1634,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
1301
1634
|
}),
|
|
1302
1635
|
|
|
1303
1636
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
1637
|
+
if (!acceptingTasks) {
|
|
1638
|
+
return {
|
|
1639
|
+
content: [{ type: "text", text: "This Pi session is shutting down and cannot accept new subagent tasks." }],
|
|
1640
|
+
details: undefined,
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1304
1643
|
const agents = discoverAgents(ctx.cwd);
|
|
1305
1644
|
const agent = agents.find((a) => a.name === params.agent);
|
|
1306
1645
|
if (!agent) {
|
|
@@ -1351,7 +1690,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1351
1690
|
else signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
1352
1691
|
let direct: ReturnType<typeof createDirectRun>;
|
|
1353
1692
|
try {
|
|
1354
|
-
|
|
1693
|
+
validateLaunchText("task", params.task, { allowEmpty: true });
|
|
1694
|
+
const address = resolveDirectRunAddress(agent, runId, params.as);
|
|
1695
|
+
direct = createDirectRun(agent, runId, "task", address, readOnly, params.cwd ?? cwd);
|
|
1355
1696
|
} catch (error) {
|
|
1356
1697
|
signal?.removeEventListener("abort", forwardAbort);
|
|
1357
1698
|
runControllers.delete(runId);
|
|
@@ -1373,7 +1714,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1373
1714
|
runningWidget.start(runId, agent.name, params.task, "task");
|
|
1374
1715
|
let result: Awaited<ReturnType<typeof spawnInteractiveSubagent>>;
|
|
1375
1716
|
try {
|
|
1376
|
-
result = await spawnInteractiveSubagent({
|
|
1717
|
+
result = await dependencies.spawnInteractiveSubagent({
|
|
1377
1718
|
agent,
|
|
1378
1719
|
task: params.task,
|
|
1379
1720
|
address: direct.address,
|
|
@@ -1509,25 +1850,122 @@ export default function (pi: ExtensionAPI) {
|
|
|
1509
1850
|
},
|
|
1510
1851
|
});
|
|
1511
1852
|
|
|
1853
|
+
pi.registerCommand("subagent-retry", {
|
|
1854
|
+
description: "Retry an interrupted, failed, or cancelled background task",
|
|
1855
|
+
getArgumentCompletions: (prefix) => {
|
|
1856
|
+
const items = [...persistedTasks.values()]
|
|
1857
|
+
.filter((state) => isRetryableTaskStatus(state.status))
|
|
1858
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
1859
|
+
.map((state) => ({
|
|
1860
|
+
value: state.runId,
|
|
1861
|
+
label: state.runId,
|
|
1862
|
+
description: `${state.address} · ${state.status}`,
|
|
1863
|
+
}))
|
|
1864
|
+
.filter((item) => item.value.startsWith(prefix.trim()));
|
|
1865
|
+
return items.length > 0 ? items : null;
|
|
1866
|
+
},
|
|
1867
|
+
handler: async (args, ctx) => {
|
|
1868
|
+
runtimeContext = ctx;
|
|
1869
|
+
reconcileCurrentBranch();
|
|
1870
|
+
const raw = Array.isArray(args) ? String(args[0] ?? "") : String(args ?? "");
|
|
1871
|
+
const oldRunId = raw.trim().split(/\s+/)[0] ?? "";
|
|
1872
|
+
if (!oldRunId) {
|
|
1873
|
+
ctx.ui.notify("Usage: /subagent-retry <runId>", "warning");
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
const previous = persistedTasks.get(oldRunId);
|
|
1877
|
+
if (!previous) {
|
|
1878
|
+
ctx.ui.notify(`No task "${oldRunId}" exists on the current branch.`, "error");
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1881
|
+
if (!isRetryableTaskStatus(previous.status)) {
|
|
1882
|
+
ctx.ui.notify(
|
|
1883
|
+
`Task ${oldRunId.slice(0, 8)} is ${previous.status} and cannot be retried. Only interrupted, failed, or cancelled tasks are retryable.`,
|
|
1884
|
+
"warning",
|
|
1885
|
+
);
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
const agent = discoverAgents(ctx.cwd).find((candidate) => candidate.name === previous.agent);
|
|
1889
|
+
if (!agent) {
|
|
1890
|
+
ctx.ui.notify(
|
|
1891
|
+
`Cannot retry run ${oldRunId.slice(0, 8)} because subagent "${previous.agent}" no longer exists.`,
|
|
1892
|
+
"error",
|
|
1893
|
+
);
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
const originalTimeoutMs =
|
|
1897
|
+
previous.timeoutAt !== undefined
|
|
1898
|
+
? Math.max(
|
|
1899
|
+
MIN_SUBAGENT_TIMEOUT_SECONDS * 1000,
|
|
1900
|
+
Math.min(
|
|
1901
|
+
MAX_SUBAGENT_TIMEOUT_SECONDS * 1000,
|
|
1902
|
+
previous.timeoutAt - previous.startedAt,
|
|
1903
|
+
),
|
|
1904
|
+
)
|
|
1905
|
+
: DEFAULT_SUBAGENT_TIMEOUT_SECONDS * 1000;
|
|
1906
|
+
try {
|
|
1907
|
+
const launched = launchBackground(
|
|
1908
|
+
agent,
|
|
1909
|
+
previous.task,
|
|
1910
|
+
previous.cwd,
|
|
1911
|
+
originalTimeoutMs,
|
|
1912
|
+
undefined,
|
|
1913
|
+
previous.readOnly,
|
|
1914
|
+
previous.runId,
|
|
1915
|
+
);
|
|
1916
|
+
ctx.ui.notify(
|
|
1917
|
+
`Retry started at ${launched.address} (new run ${launched.runId.slice(0, 8)}, retry of ${previous.runId.slice(0, 8)}).`,
|
|
1918
|
+
"info",
|
|
1919
|
+
);
|
|
1920
|
+
} catch (error) {
|
|
1921
|
+
ctx.ui.notify(
|
|
1922
|
+
`Failed to retry run ${oldRunId.slice(0, 8)}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1923
|
+
"error",
|
|
1924
|
+
);
|
|
1925
|
+
}
|
|
1926
|
+
},
|
|
1927
|
+
});
|
|
1928
|
+
|
|
1512
1929
|
pi.registerCommand("subagent-status", {
|
|
1513
|
-
description: "Show
|
|
1930
|
+
description: "Show live subagent tasks and current-branch task history",
|
|
1514
1931
|
handler: async (_args, ctx) => {
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1932
|
+
runtimeContext = ctx;
|
|
1933
|
+
reconcileCurrentBranch();
|
|
1934
|
+
const live: LiveTaskView[] = [...tasks.values()].map((task) => {
|
|
1935
|
+
const persisted = persistedTasks.get(task.runId);
|
|
1936
|
+
return {
|
|
1937
|
+
runId: task.runId,
|
|
1938
|
+
address: task.address,
|
|
1939
|
+
agent: task.agent,
|
|
1940
|
+
task: task.task,
|
|
1941
|
+
status:
|
|
1942
|
+
persisted?.status === "starting" || persisted?.status === "running"
|
|
1943
|
+
? persisted.status
|
|
1944
|
+
: "running",
|
|
1945
|
+
startedAt: task.startedAt,
|
|
1946
|
+
updatedAt: persisted?.updatedAt ?? task.updatedAt,
|
|
1947
|
+
};
|
|
1519
1948
|
});
|
|
1520
|
-
const
|
|
1521
|
-
const
|
|
1949
|
+
const taskRunIds = new Set(live.map((task) => task.runId));
|
|
1950
|
+
const otherActive = activeRuns
|
|
1951
|
+
.list()
|
|
1952
|
+
.filter((run) => !taskRunIds.has(run.runId))
|
|
1953
|
+
.slice(0, 10);
|
|
1954
|
+
const activeLines = otherActive.map(
|
|
1522
1955
|
(run) => `- ${run.address} -> ${run.agent} [${run.mode}] (run ${run.runId.slice(0, 8)})`,
|
|
1523
1956
|
);
|
|
1957
|
+
const status = formatTaskStatus(live, persistedTasks);
|
|
1524
1958
|
ctx.ui.notify(
|
|
1525
1959
|
[
|
|
1526
|
-
|
|
1527
|
-
activeLines.length
|
|
1960
|
+
status,
|
|
1961
|
+
activeLines.length
|
|
1962
|
+
? `Other live processes:\n${activeLines.join("\n")}${activeRuns.list().length - taskRunIds.size > 10 ? "\n- … more live processes omitted" : ""}`
|
|
1963
|
+
: "",
|
|
1528
1964
|
].join("\n"),
|
|
1529
1965
|
"info",
|
|
1530
1966
|
);
|
|
1531
1967
|
},
|
|
1532
1968
|
});
|
|
1533
1969
|
}
|
|
1970
|
+
|
|
1971
|
+
export default createAgentSquadExtension;
|