@ryan_nookpi/pi-extension-subagent 0.5.4 → 0.5.6

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/tool-execute.ts CHANGED
@@ -32,7 +32,13 @@ import {
32
32
  summarizeSubagentDisplayTask,
33
33
  } from "./display-task.js";
34
34
  import { ESCALATION_EXIT_CODE, readAndConsumeEscalation } from "./escalation.js";
35
- import { classifySubagentFailure, type SubagentErrorClass } from "./failure-telemetry.js";
35
+ import {
36
+ classifySubagentFailure,
37
+ getSubagentRunStatusLabel,
38
+ getSubagentTerminalLabel,
39
+ isSubagentAbort,
40
+ type SubagentErrorClass,
41
+ } from "./failure-telemetry.js";
36
42
  import {
37
43
  formatContextUsageBar,
38
44
  formatUsageStats,
@@ -43,6 +49,7 @@ import {
43
49
  import { clearPendingGroupCompletion, upsertPendingGroupCompletion } from "./group-pending.js";
44
50
  import { enqueueSubagentInvocation } from "./invocation-queue.js";
45
51
  import { appendDisplayTaskUpdate, getSessionFileSize } from "./persisted-session.js";
52
+ import { invokeWithAutoRetry, MAX_SUBAGENT_AUTO_RETRIES } from "./retry.js";
46
53
  import {
47
54
  clearFinishedRuns,
48
55
  evictStaleFinishedGroups,
@@ -299,6 +306,9 @@ export function diagnoseResultFailure(result: SingleResult): ResultFailureDiagno
299
306
  output: finalOutput,
300
307
  });
301
308
 
309
+ if (result.stopReason === "aborted") {
310
+ return { failed: true, errorClass: "aborted", reason: "Subagent execution was aborted." };
311
+ }
302
312
  if (result.exitCode !== 0 || result.stopReason === "error") {
303
313
  const overflowText = result.errorMessage || result.stderr || finalOutput;
304
314
  if (isContextOverflowText(overflowText)) {
@@ -324,8 +334,6 @@ export function diagnoseResultFailure(result: SingleResult): ResultFailureDiagno
324
334
  }
325
335
  if (result.stopReason === "error")
326
336
  return { failed: true, errorClass, reason: result.errorMessage || "Subagent reported stopReason=error." };
327
- if (result.stopReason === "aborted")
328
- return { failed: true, errorClass: errorClass ?? "aborted", reason: "Subagent execution was aborted." };
329
337
 
330
338
  const hasAssistantText = finalOutput.length > 0;
331
339
  if (hasAssistantText) return { failed: false };
@@ -469,13 +477,42 @@ function buildRunStartMessage(runState: CommandRunState, status: "started" | "re
469
477
  };
470
478
  }
471
479
 
480
+ function getFinalizedRunLabel(finalized: FinalizedRun): "completed" | "aborted" | "failed" {
481
+ return getSubagentTerminalLabel(finalized.isError, {
482
+ stopReason: finalized.result?.stopReason,
483
+ errorClass: finalized.runState.errorClass,
484
+ autoAbortReason: finalized.runState.autoAbortReason,
485
+ });
486
+ }
487
+
488
+ function getFinalizedRunStatus(finalized: FinalizedRun): PipelineStepResult["status"] {
489
+ const label = getFinalizedRunLabel(finalized);
490
+ return label === "completed" ? "done" : label === "aborted" ? "aborted" : "error";
491
+ }
492
+
493
+ function resolveGroupTerminalStatus(failed: number, aborted: number): "completed" | "error" | "aborted" {
494
+ if (failed > 0) return "error";
495
+ if (aborted > 0) return "aborted";
496
+ return "completed";
497
+ }
498
+
499
+ function recordBatchResult(batch: BatchGroupState, finalized: FinalizedRun): void {
500
+ const runId = finalized.runState.id;
501
+ const status = finalized.runState.removed ? "aborted" : getFinalizedRunStatus(finalized);
502
+ batch.completedRunIds.add(runId);
503
+ if (status === "aborted") batch.abortedRunIds.add(runId);
504
+ else if (status === "error") batch.failedRunIds.add(runId);
505
+ batch.pendingResults.set(runId, finalized.rawOutput);
506
+ }
507
+
472
508
  function buildRunCompletionMessage(finalized: FinalizedRun, options?: { display?: boolean }) {
473
- const { runState, result, isError, rawOutput } = finalized;
509
+ const { runState, result, rawOutput } = finalized;
474
510
  const usage = result ? formatUsageStats(result.usage, result.model) : "";
511
+ const terminalLabel = getFinalizedRunLabel(finalized);
475
512
  return {
476
513
  customType: "subagent-tool" as const,
477
514
  content:
478
- `[subagent:${runState.agent}#${runState.id}] ${isError ? "failed" : "completed"}` +
515
+ `[subagent:${runState.agent}#${runState.id}] ${terminalLabel}` +
479
516
  `\nPrompt: ${truncateLines(runState.task, 2)}` +
480
517
  (usage ? `\nUsage: ${usage}` : "") +
481
518
  (runState.thoughtText ? `\nThought: ${runState.thoughtText}` : "") +
@@ -495,6 +532,7 @@ function buildRunCompletionMessage(finalized: FinalizedRun, options?: { display?
495
532
  elapsedMs: runState.elapsedMs,
496
533
  lastActivityAt: runState.lastActivityAt,
497
534
  exitCode: result?.exitCode,
535
+ stopReason: result?.stopReason ?? (runState.errorClass === "aborted" ? "aborted" : undefined),
498
536
  usage: result?.usage,
499
537
  model: result?.model,
500
538
  source: result?.agentSource,
@@ -560,6 +598,7 @@ function finalizeRunState(runState: CommandRunState, result: SingleResult): Fina
560
598
 
561
599
  if (runState.autoAbortReason) {
562
600
  runState.status = "error";
601
+ runState.errorClass = "aborted";
563
602
  runState.elapsedMs = Date.now() - runState.startedAt;
564
603
  runState.lastOutput = runState.autoAbortReason;
565
604
  runState.lastLine = runState.autoAbortReason;
@@ -606,29 +645,46 @@ function finalizeRunState(runState: CommandRunState, result: SingleResult): Fina
606
645
  return { runState, result, isError, rawOutput };
607
646
  }
608
647
 
609
- function formatBatchSummary(batchId: string, runs: CommandRunState[], terminalStatus: "completed" | "error"): string {
610
- const headerStatus = runs.map((run) => `#${run.id} ${run.status === "done" ? "done" : "error"}`).join(", ");
648
+ function formatOutcomeCounts(failed: number, aborted: number): string {
649
+ return [failed > 0 ? `${failed} failed` : "", aborted > 0 ? `${aborted} aborted` : ""].filter(Boolean).join(", ");
650
+ }
651
+
652
+ function formatBatchSummary(
653
+ batchId: string,
654
+ runs: CommandRunState[],
655
+ terminalStatus: "completed" | "error" | "aborted",
656
+ ): string {
657
+ const statuses = runs.map((run) => getSubagentRunStatusLabel(run.status, run.errorClass));
658
+ const headerStatus = runs.map((run, index) => `#${run.id} ${statuses[index]}`).join(", ");
659
+ const outcomeCounts = formatOutcomeCounts(
660
+ statuses.filter((status) => status === "error").length,
661
+ statuses.filter((status) => status === "aborted").length,
662
+ );
611
663
  const body = runs
612
664
  .map((run) => {
613
665
  const summary = run.lastOutput?.trim() || run.lastLine?.trim() || "(no output)";
614
666
  return `#${run.id} ${run.agent}\n- ${summary}`;
615
667
  })
616
668
  .join("\n\n");
617
- return `[subagent-batch#${batchId}] ${terminalStatus}\nRuns: ${headerStatus}\n\n${body}`;
669
+ return `[subagent-batch#${batchId}] ${terminalStatus}\nRuns: ${headerStatus}${outcomeCounts ? `\nOutcomes: ${outcomeCounts}` : ""}\n\n${body}`;
618
670
  }
619
671
 
620
672
  function formatPipelineSummary(
621
673
  pipelineId: string,
622
674
  stepResults: PipelineStepResult[],
623
- terminalStatus: "completed" | "stopped" | "error",
675
+ terminalStatus: "completed" | "stopped" | "error" | "aborted",
624
676
  ): string {
677
+ const outcomeCounts = formatOutcomeCounts(
678
+ stepResults.filter((step) => step.status === "error").length,
679
+ stepResults.filter((step) => step.status === "aborted").length,
680
+ );
625
681
  const steps = stepResults
626
682
  .map(
627
683
  (step, index) =>
628
684
  `Step ${index + 1} · #${step.runId} ${step.agent} · ${step.status}\nTask: ${step.task}\n${step.output}`,
629
685
  )
630
686
  .join("\n\n");
631
- return `[subagent-chain#${pipelineId}] ${terminalStatus}\n\n${steps}`;
687
+ return `[subagent-chain#${pipelineId}] ${terminalStatus}${outcomeCounts ? `\nOutcomes: ${outcomeCounts}` : ""}\n\n${steps}`;
632
688
  }
633
689
 
634
690
  function previewOutput(output: string): string {
@@ -642,9 +698,10 @@ function formatBatchGroupStatus(store: SubagentStore, batch: BatchGroupState, de
642
698
  const total = batch.runIds.length;
643
699
  const done = batch.completedRunIds.size;
644
700
  const failed = batch.failedRunIds.size;
701
+ const aborted = batch.abortedRunIds.size;
645
702
  const header = `[subagent-batch#${batch.batchId}] running · ${done}/${total} finished${
646
703
  failed > 0 ? `, ${failed} failed` : ""
647
- }`;
704
+ }${aborted > 0 ? `, ${aborted} aborted` : ""}`;
648
705
  const body = batch.runIds
649
706
  .map((runId) => {
650
707
  const run = store.commandRuns.get(runId);
@@ -735,7 +792,13 @@ function isInteractiveTuiContext(ctx: SubagentToolExecuteContext): boolean {
735
792
 
736
793
  function finalizeRunError(runState: CommandRunState, error: unknown): FinalizedRun {
737
794
  runState.status = "error";
738
- runState.errorClass = "process_error";
795
+ runState.errorClass = isSubagentAbort({
796
+ signal: runState.abortController?.signal,
797
+ autoAbortReason: runState.autoAbortReason,
798
+ error,
799
+ })
800
+ ? "aborted"
801
+ : "process_error";
739
802
  runState.elapsedMs = Date.now() - runState.startedAt;
740
803
  runState.lastLine =
741
804
  runState.autoAbortReason ?? (error instanceof Error ? error.message : "Subagent execution failed");
@@ -1266,6 +1329,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1266
1329
  function clearRunAbortState(runState: CommandRunState): void {
1267
1330
  clearParentAbortListener(runState.id);
1268
1331
  runState.abortController = undefined;
1332
+ runState.abortPending = false;
1269
1333
  }
1270
1334
 
1271
1335
  function registerRunLaunch(config: RunLaunchConfig): CommandRunState {
@@ -1289,6 +1353,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1289
1353
  runState.lastRetryReason = undefined;
1290
1354
  runState.errorClass = undefined;
1291
1355
  runState.autoAbortReason = undefined;
1356
+ runState.abortPending = false;
1292
1357
  runState.removed = false;
1293
1358
  runState.turnCount = Math.max(DEFAULT_TURN_COUNT, runState.turnCount || DEFAULT_TURN_COUNT) + 1;
1294
1359
  runState.contextMode = runState.contextMode ?? (config.inheritMainContext ? "main" : "sub");
@@ -1350,40 +1415,62 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1350
1415
 
1351
1416
  function launchRunInBackground(runState: CommandRunState, taskForAgent: string): Promise<FinalizedRun> {
1352
1417
  let claudeCheckpointSent = !!runState.claudeSessionId;
1418
+ const abortSignal = runState.abortController?.signal;
1353
1419
  const recordActivity = createRunActivityRecorder(pi, runState, (model) => resolveContextWindow(ctx, model));
1354
- return enqueueSubagentInvocation(() =>
1355
- runSingleAgent(
1356
- ctx.cwd,
1357
- agents,
1358
- runState.agent,
1359
- taskForAgent,
1360
- runState.pipelineStepIndex,
1361
- runState.abortController?.signal,
1362
- (partial) => {
1363
- if (runState.removed || store.disposed) return;
1364
- const current = partial.details?.results?.[0];
1365
- if (!current) return;
1366
- updateRunFromResult(runState, current);
1367
- recordActivity();
1368
- if (shouldRunAsync && !claudeCheckpointSent && runState.claudeSessionId) {
1369
- claudeCheckpointSent = true;
1370
- pi.sendMessage(buildRunStartMessage(runState, "started"), {
1371
- deliverAs: "followUp",
1372
- triggerTurn: false,
1373
- });
1374
- }
1375
- updateCommandRunsWidget(store);
1376
- },
1377
- (results) => makeDetails(mode, results),
1378
- {
1379
- sessionFile: runState.sessionFile,
1380
- resumeSessionId: runState.claudeSessionId,
1381
- sidecarSessionFile: runState.sessionFile,
1382
- persistedSessionBaseOffset: runState.persistedSessionBaseOffset,
1383
- onDiagnostic: createRunDiagnosticSink(pi, runState),
1384
- },
1385
- ),
1386
- ).then((result) => finalizeRunState(runState, result));
1420
+ return invokeWithAutoRetry({
1421
+ maxRetries: MAX_SUBAGENT_AUTO_RETRIES,
1422
+ signal: abortSignal,
1423
+ onRetryScheduled: ({ retryIndex, maxRetries, delayMs, reason }) => {
1424
+ runState.retryCount = retryIndex;
1425
+ runState.lastRetryReason = reason;
1426
+ runState.lastActivityAt = Date.now();
1427
+ runState.lastLine = `Auto-retrying ${retryIndex}/${maxRetries} in ${Math.ceil(delayMs / 1000)}s: ${reason}`;
1428
+ runState.lastOutput = runState.lastLine;
1429
+ updateCommandRunsWidget(store);
1430
+ },
1431
+ invoke: () => {
1432
+ runState.persistedSessionBaseOffset = getSessionFileSize(runState.sessionFile);
1433
+ return enqueueSubagentInvocation(async () => {
1434
+ if (runState.removed || abortSignal?.aborted) throw new Error("Subagent was aborted");
1435
+ return runSingleAgent(
1436
+ ctx.cwd,
1437
+ agents,
1438
+ runState.agent,
1439
+ taskForAgent,
1440
+ runState.pipelineStepIndex,
1441
+ abortSignal,
1442
+ (partial) => {
1443
+ if (runState.removed || store.disposed) return;
1444
+ const current = partial.details?.results?.[0];
1445
+ if (!current) return;
1446
+ updateRunFromResult(runState, current);
1447
+ recordActivity();
1448
+ if (shouldRunAsync && !claudeCheckpointSent && runState.claudeSessionId) {
1449
+ claudeCheckpointSent = true;
1450
+ pi.sendMessage(buildRunStartMessage(runState, "started"), {
1451
+ deliverAs: "followUp",
1452
+ triggerTurn: false,
1453
+ });
1454
+ }
1455
+ updateCommandRunsWidget(store);
1456
+ },
1457
+ (results) => makeDetails(mode, results),
1458
+ {
1459
+ sessionFile: runState.sessionFile,
1460
+ resumeSessionId: runState.claudeSessionId,
1461
+ sidecarSessionFile: runState.sessionFile,
1462
+ persistedSessionBaseOffset: runState.persistedSessionBaseOffset,
1463
+ onDiagnostic: createRunDiagnosticSink(pi, runState),
1464
+ },
1465
+ );
1466
+ });
1467
+ },
1468
+ }).then(({ result, retryCount }) => {
1469
+ runState.retryCount = retryCount;
1470
+ return runState.removed || abortSignal?.aborted
1471
+ ? finalizeRunError(runState, new Error("Subagent was aborted"))
1472
+ : finalizeRunState(runState, result);
1473
+ });
1387
1474
  }
1388
1475
 
1389
1476
  if (hasSingle) {
@@ -1405,6 +1492,20 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1405
1492
  isError: true,
1406
1493
  };
1407
1494
  }
1495
+ if (continueFromRun.abortPending) {
1496
+ return {
1497
+ content: [
1498
+ {
1499
+ type: "text",
1500
+ text: withIdleRunWarning(
1501
+ `Subagent #${continuationRunId} is still aborting. Wait for cancellation to settle before continuing it.`,
1502
+ ),
1503
+ },
1504
+ ],
1505
+ details: makeDetails("single"),
1506
+ isError: true,
1507
+ };
1508
+ }
1408
1509
  if (continueFromRun.status === "running") {
1409
1510
  return {
1410
1511
  content: [
@@ -1505,6 +1606,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1505
1606
  };
1506
1607
  }
1507
1608
 
1609
+ // No run-start sendMessage here: mid-turn triggerTurn:false messages land between tool call and result (pi #8537).
1508
1610
  ctx.ui?.notify?.(
1509
1611
  `${continueFromRun ? `Resumed subagent #${runState.id}` : `Started subagent #${runState.id}`}: ${resolvedAgent}`,
1510
1612
  "info",
@@ -1529,6 +1631,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1529
1631
  return;
1530
1632
  }
1531
1633
 
1634
+ const terminalLabel = getFinalizedRunLabel(finalized);
1532
1635
  const completionMessage = buildRunCompletionMessage(finalized);
1533
1636
  if (isInOriginSession(ctx, originSessionFile)) {
1534
1637
  pi.sendMessage(completionMessage, { deliverAs: "followUp", triggerTurn: true });
@@ -1539,14 +1642,13 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1539
1642
  }
1540
1643
 
1541
1644
  ctx.ui?.notify?.(
1542
- finalized.isError
1543
- ? `subagent tool run #${runState.id} (${resolvedAgent}) failed`
1544
- : `subagent tool run #${runState.id} (${resolvedAgent}) completed`,
1545
- finalized.isError ? "error" : "info",
1645
+ `subagent tool run #${runState.id} (${resolvedAgent}) ${terminalLabel}`,
1646
+ terminalLabel === "completed" ? "info" : terminalLabel === "aborted" ? "warning" : "error",
1546
1647
  );
1547
1648
  } catch (error: unknown) {
1548
1649
  if (runState.removed || store.disposed) return;
1549
1650
  const finalized = finalizeRunError(runState, error);
1651
+ const terminalLabel = getFinalizedRunLabel(finalized);
1550
1652
  const errorMessage = buildRunCompletionMessage(finalized);
1551
1653
  if (isInOriginSession(ctx, originSessionFile)) {
1552
1654
  pi.sendMessage(errorMessage, { deliverAs: "followUp", triggerTurn: true });
@@ -1555,7 +1657,10 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1555
1657
  const entry = store.globalLiveRuns.get(runState.id);
1556
1658
  if (entry) entry.pendingCompletion = makePendingCompletion(errorMessage, true);
1557
1659
  }
1558
- ctx.ui?.notify?.(`subagent tool run #${runState.id} failed: ${runState.lastLine}`, "error");
1660
+ ctx.ui?.notify?.(
1661
+ `subagent tool run #${runState.id} ${terminalLabel}: ${runState.lastLine}`,
1662
+ terminalLabel === "aborted" ? "warning" : "error",
1663
+ );
1559
1664
  updateCommandRunsWidget(store);
1560
1665
  } finally {
1561
1666
  clearRunAbortState(runState);
@@ -1626,6 +1731,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1626
1731
  runIds: runStates.map(({ runState }) => runState.id),
1627
1732
  completedRunIds: new Set(),
1628
1733
  failedRunIds: new Set(),
1734
+ abortedRunIds: new Set(),
1629
1735
  originSessionFile,
1630
1736
  createdAt: Date.now(),
1631
1737
  pendingResults: new Map(),
@@ -1645,11 +1751,18 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1645
1751
  }),
1646
1752
  );
1647
1753
  const orderedRuns = runStates.map(({ runState }) => runState);
1648
- const hasError = finalizedRuns.some((finalized) => finalized.isError);
1649
- const content = formatBatchSummary(batchId, orderedRuns, hasError ? "error" : "completed");
1650
1754
  const batchForSnapshot = store.batchGroups.get(batchId);
1651
- if (batchForSnapshot)
1652
- retireFinishedGroup(store, snapshotBatchGroup(store, batchForSnapshot, hasError ? "error" : "completed"));
1755
+ if (batchForSnapshot) {
1756
+ for (const finalized of finalizedRuns) recordBatchResult(batchForSnapshot, finalized);
1757
+ }
1758
+ const terminalStatus = resolveGroupTerminalStatus(
1759
+ batchForSnapshot?.failedRunIds.size ?? 0,
1760
+ batchForSnapshot?.abortedRunIds.size ?? 0,
1761
+ );
1762
+ const content = formatBatchSummary(batchId, orderedRuns, terminalStatus);
1763
+ if (batchForSnapshot) {
1764
+ retireFinishedGroup(store, snapshotBatchGroup(store, batchForSnapshot, terminalStatus));
1765
+ }
1653
1766
  for (const { runState } of runStates) cleanupRunAfterFinalDelivery(runState.id);
1654
1767
  clearPendingGroupCompletion("batch", batchId);
1655
1768
  store.batchGroups.delete(batchId);
@@ -1671,7 +1784,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1671
1784
  .filter((result): result is SingleResult => Boolean(result)),
1672
1785
  runStates.map(({ runState }) => toLaunchSummary(runState, "batch")),
1673
1786
  ),
1674
- isError: hasError,
1787
+ isError: terminalStatus !== "completed",
1675
1788
  };
1676
1789
  }
1677
1790
 
@@ -1682,16 +1795,14 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1682
1795
 
1683
1796
  const batch = store.batchGroups.get(batchId);
1684
1797
  if (!batch) return;
1685
- batch.completedRunIds.add(runState.id);
1686
- if (finalized.isError) batch.failedRunIds.add(runState.id);
1687
- batch.pendingResults.set(runState.id, finalized.rawOutput);
1798
+ recordBatchResult(batch, finalized);
1688
1799
  updateCommandRunsWidget(store);
1689
1800
 
1690
1801
  if (batch.completedRunIds.size === batch.runIds.length) {
1691
1802
  const orderedRuns = batch.runIds
1692
1803
  .map((runId) => store.commandRuns.get(runId))
1693
1804
  .filter((run): run is CommandRunState => Boolean(run));
1694
- const batchTerminalStatus = batch.failedRunIds.size > 0 ? "error" : "completed";
1805
+ const batchTerminalStatus = resolveGroupTerminalStatus(batch.failedRunIds.size, batch.abortedRunIds.size);
1695
1806
  const content = formatBatchSummary(batchId, orderedRuns, batchTerminalStatus);
1696
1807
  const message = {
1697
1808
  customType: "subagent-tool" as const,
@@ -1700,7 +1811,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1700
1811
  details: {
1701
1812
  batchId,
1702
1813
  runIds: batch.runIds,
1703
- status: batch.failedRunIds.size > 0 ? "error" : "done",
1814
+ status: batchTerminalStatus === "completed" ? "done" : batchTerminalStatus,
1704
1815
  runSummaries: orderedRuns.map((run) => buildRunAnalyticsSummary(run)),
1705
1816
  },
1706
1817
  };
@@ -1728,38 +1839,36 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1728
1839
  removalReason: "trim",
1729
1840
  });
1730
1841
  ctx.ui?.notify?.(
1731
- batch.failedRunIds.size > 0
1842
+ batchTerminalStatus === "error"
1732
1843
  ? `subagent batch ${batchId} finished with errors`
1733
- : `subagent batch ${batchId} completed`,
1734
- batch.failedRunIds.size > 0 ? "error" : "info",
1844
+ : batchTerminalStatus === "aborted"
1845
+ ? `subagent batch ${batchId} aborted`
1846
+ : `subagent batch ${batchId} completed`,
1847
+ batchTerminalStatus === "error" ? "error" : batchTerminalStatus === "aborted" ? "warning" : "info",
1735
1848
  );
1736
1849
  }
1737
1850
  } catch (error: unknown) {
1738
- runState.status = "error";
1739
- runState.elapsedMs = Date.now() - runState.startedAt;
1740
- runState.lastLine = error instanceof Error ? error.message : "Subagent execution failed";
1741
- runState.lastOutput = runState.lastLine;
1851
+ const finalized = finalizeRunError(runState, error);
1742
1852
  const batch = store.batchGroups.get(batchId);
1743
1853
  if (!batch) return;
1744
- batch.completedRunIds.add(runState.id);
1745
- batch.failedRunIds.add(runState.id);
1746
- batch.pendingResults.set(runState.id, runState.lastLine);
1854
+ recordBatchResult(batch, finalized);
1747
1855
  if (batch.completedRunIds.size === batch.runIds.length) {
1748
1856
  const orderedRuns = batch.runIds
1749
1857
  .map((runId) => store.commandRuns.get(runId))
1750
1858
  .filter((run): run is CommandRunState => Boolean(run));
1859
+ const batchTerminalStatus = resolveGroupTerminalStatus(batch.failedRunIds.size, batch.abortedRunIds.size);
1751
1860
  const message = {
1752
1861
  customType: "subagent-tool" as const,
1753
- content: formatBatchSummary(batchId, orderedRuns, "error"),
1862
+ content: formatBatchSummary(batchId, orderedRuns, batchTerminalStatus),
1754
1863
  display: true,
1755
1864
  details: {
1756
1865
  batchId,
1757
1866
  runIds: batch.runIds,
1758
- status: "error",
1867
+ status: batchTerminalStatus,
1759
1868
  runSummaries: orderedRuns.map((run) => buildRunAnalyticsSummary(run)),
1760
1869
  },
1761
1870
  };
1762
- retireFinishedGroup(store, snapshotBatchGroup(store, batch, "error"));
1871
+ retireFinishedGroup(store, snapshotBatchGroup(store, batch, batchTerminalStatus));
1763
1872
  if (isInOriginSession(ctx, batch.originSessionFile)) {
1764
1873
  pi.sendMessage(message, { deliverAs: "followUp", triggerTurn: true });
1765
1874
  clearPendingGroupCompletion("batch", batchId);
@@ -1777,6 +1886,8 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1777
1886
  }
1778
1887
  }
1779
1888
  updateCommandRunsWidget(store);
1889
+ } finally {
1890
+ clearRunAbortState(runState);
1780
1891
  }
1781
1892
  })();
1782
1893
  }
@@ -1828,7 +1939,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1828
1939
 
1829
1940
  if (!shouldRunAsync) {
1830
1941
  let previousOutput = "";
1831
- let terminalStatus: "completed" | "stopped" | "error" = "completed";
1942
+ let terminalStatus: "completed" | "stopped" | "error" | "aborted" = "completed";
1832
1943
  const finalizedRuns: FinalizedRun[] = [];
1833
1944
  const pipeline = store.pipelines.get(pipelineId);
1834
1945
  if (!pipeline) {
@@ -1895,29 +2006,30 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1895
2006
  finalizedRuns.push(finalized);
1896
2007
 
1897
2008
  if (runState.removed) {
1898
- terminalStatus = finalized.isError ? "error" : "stopped";
2009
+ terminalStatus = "aborted";
1899
2010
  pipeline.stepResults.push({
1900
2011
  runId: runState.id,
1901
2012
  agent: runState.agent,
1902
2013
  task: step.task,
1903
2014
  output: finalized.rawOutput || "Run removed before pipeline completion.",
1904
- status: "error",
2015
+ status: "aborted",
1905
2016
  });
1906
2017
  break;
1907
2018
  }
1908
2019
 
2020
+ const stepStatus = getFinalizedRunStatus(finalized);
1909
2021
  pipeline.stepResults.push({
1910
2022
  runId: runState.id,
1911
2023
  agent: runState.agent,
1912
2024
  task: step.task,
1913
2025
  output: finalized.rawOutput,
1914
- status: finalized.isError ? "error" : "done",
2026
+ status: stepStatus,
1915
2027
  });
1916
2028
  previousOutput = finalized.rawOutput;
1917
2029
  updateCommandRunsWidget(store);
1918
2030
 
1919
- if (finalized.isError) {
1920
- terminalStatus = "error";
2031
+ if (stepStatus !== "done") {
2032
+ terminalStatus = stepStatus;
1921
2033
  break;
1922
2034
  }
1923
2035
  }
@@ -1933,7 +2045,9 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1933
2045
  }
1934
2046
 
1935
2047
  const hasError = pipeline.stepResults.some((step) => step.status === "error");
1936
- if (terminalStatus === "completed" && hasError) terminalStatus = "error";
2048
+ const hasAborted = pipeline.stepResults.some((step) => step.status === "aborted");
2049
+ if (hasError) terminalStatus = "error";
2050
+ else if (terminalStatus === "completed" && hasAborted) terminalStatus = "aborted";
1937
2051
  const content = formatPipelineSummary(pipelineId, pipeline.stepResults, terminalStatus);
1938
2052
  retireFinishedGroup(store, snapshotPipeline(pipeline, terminalStatus));
1939
2053
  for (const runId of pipeline.stepRunIds) cleanupRunAfterFinalDelivery(runId);
@@ -1963,7 +2077,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1963
2077
 
1964
2078
  void (async () => {
1965
2079
  let previousOutput = "";
1966
- let terminalStatus: "completed" | "stopped" | "error" = "completed";
2080
+ let terminalStatus: "completed" | "stopped" | "error" | "aborted" = "completed";
1967
2081
  try {
1968
2082
  for (let index = 0; index < steps.length; index++) {
1969
2083
  const pipeline = store.pipelines.get(pipelineId);
@@ -2021,13 +2135,13 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
2021
2135
  clearRunAbortState(runState);
2022
2136
  }
2023
2137
  if (runState.removed) {
2024
- terminalStatus = finalized.isError ? "error" : "stopped";
2138
+ terminalStatus = "aborted";
2025
2139
  pipeline.stepResults.push({
2026
2140
  runId: runState.id,
2027
2141
  agent: runState.agent,
2028
2142
  task: step.task,
2029
2143
  output: finalized.rawOutput || "Run removed before pipeline completion.",
2030
- status: "error",
2144
+ status: "aborted",
2031
2145
  });
2032
2146
  break;
2033
2147
  }
@@ -2037,14 +2151,14 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
2037
2151
  agent: runState.agent,
2038
2152
  task: step.task,
2039
2153
  output: finalized.rawOutput,
2040
- status: finalized.isError ? "error" : "done",
2154
+ status: getFinalizedRunStatus(finalized),
2041
2155
  };
2042
2156
  pipeline.stepResults.push(stepResult);
2043
2157
  previousOutput = finalized.rawOutput;
2044
2158
  updateCommandRunsWidget(store);
2045
2159
 
2046
- if (finalized.isError) {
2047
- terminalStatus = "error";
2160
+ if (stepResult.status !== "done") {
2161
+ terminalStatus = stepResult.status;
2048
2162
  break;
2049
2163
  }
2050
2164
  }
@@ -2064,9 +2178,9 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
2064
2178
  const pipeline = store.pipelines.get(pipelineId);
2065
2179
  if (pipeline) {
2066
2180
  const hasError = pipeline.stepResults.some((step) => step.status === "error");
2067
- if (terminalStatus === "completed" && hasError) {
2068
- terminalStatus = "error";
2069
- }
2181
+ const hasAborted = pipeline.stepResults.some((step) => step.status === "aborted");
2182
+ if (hasError) terminalStatus = "error";
2183
+ else if (terminalStatus === "completed" && hasAborted) terminalStatus = "aborted";
2070
2184
  const orderedRuns = pipeline.stepRunIds
2071
2185
  .map((runId) => store.commandRuns.get(runId))
2072
2186
  .filter((run): run is CommandRunState => Boolean(run));
package/types.ts CHANGED
@@ -106,6 +106,8 @@ export interface CommandRunState {
106
106
  lastToolOutputChars?: number;
107
107
  /** Hang detector reason preserved until the normal finalizer emits the sole completion. */
108
108
  autoAbortReason?: string;
109
+ /** True while a removed invocation is still settling; blocks same-ID continuation races. */
110
+ abortPending?: boolean;
109
111
  runtime?: AgentRuntime;
110
112
  claudeSessionId?: string;
111
113
  claudeProjectDir?: string;
@@ -172,6 +174,7 @@ export interface BatchGroupState {
172
174
  runIds: number[];
173
175
  completedRunIds: Set<number>;
174
176
  failedRunIds: Set<number>;
177
+ abortedRunIds: Set<number>;
175
178
  originSessionFile: string;
176
179
  createdAt: number;
177
180
  pendingResults: Map<number, string>;
@@ -183,7 +186,7 @@ export interface PipelineStepResult {
183
186
  agent: string;
184
187
  task: string;
185
188
  output: string;
186
- status: "done" | "error";
189
+ status: "done" | "error" | "aborted";
187
190
  }
188
191
 
189
192
  export interface PipelineState {
@@ -213,10 +216,11 @@ export interface FinishedGroupMember {
213
216
  export interface FinishedGroupSnapshot {
214
217
  groupId: string;
215
218
  kind: "batch" | "chain";
216
- terminalStatus: "completed" | "error" | "stopped";
219
+ terminalStatus: "completed" | "error" | "aborted" | "stopped";
217
220
  finishedAt: number;
218
221
  total: number;
219
222
  failed: number;
223
+ aborted: number;
220
224
  members: FinishedGroupMember[];
221
225
  }
222
226
 
package/widget.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
6
6
  import { Box, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
7
7
  import { HANG_WARNING_IDLE_MS, PARENT_HINT } from "./constants.js";
8
+ import { getSubagentRunStatusLabel } from "./failure-telemetry.js";
8
9
  import {
9
10
  AGENT_NAME_PALETTE,
10
11
  agentBgIndex,
@@ -79,9 +80,12 @@ export function cleanupCommandRunsWidgetTimer(): void {
79
80
  }
80
81
 
81
82
  function getStatusVisual(run: CommandRunState): { statusColor: ThemeColor; statusIcon: string } {
82
- const statusColor: ThemeColor = run.status === "running" ? "warning" : run.status === "done" ? "success" : "error";
83
+ const statusLabel = getSubagentRunStatusLabel(run.status, run.errorClass);
84
+ const statusColor: ThemeColor =
85
+ statusLabel === "running" || statusLabel === "aborted" ? "warning" : statusLabel === "done" ? "success" : "error";
83
86
  const spinnerFrame = SPINNER_FRAMES[Math.floor(Date.now() / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length];
84
- const statusIcon = run.status === "running" ? spinnerFrame : run.status === "done" ? "✓" : "✗";
87
+ const statusIcon =
88
+ statusLabel === "running" ? spinnerFrame : statusLabel === "done" ? "✓" : statusLabel === "aborted" ? "■" : "✗";
85
89
  return { statusColor, statusIcon };
86
90
  }
87
91