pi-subagents 0.53.0 → 0.55.0

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.
Files changed (76) hide show
  1. package/CHANGELOG.md +66 -5
  2. package/README.md +1 -1
  3. package/agents/reviewer.md +12 -2
  4. package/docs/agents.md +4 -4
  5. package/docs/configuration.md +6 -6
  6. package/docs/extension-api.md +14 -4
  7. package/docs/models.md +41 -6
  8. package/docs/observability.md +14 -3
  9. package/docs/tool-reference.md +21 -6
  10. package/docs/workflows.md +7 -1
  11. package/index.ts +10 -1
  12. package/package.json +1 -1
  13. package/prompts/council.md +19 -7
  14. package/prompts/parallel-review.md +5 -1
  15. package/prompts/review-loop.md +9 -5
  16. package/skills/council-mode/SKILL.md +50 -26
  17. package/skills/pi-subagents/SKILL.md +5 -1
  18. package/skills/pi-subagents/references/constraints-and-recipes.md +16 -1
  19. package/skills/pi-subagents/references/execution-controls.md +13 -4
  20. package/skills/pi-subagents/references/multi-lane-orchestration.md +3 -3
  21. package/skills/pi-subagents/references/prompting-and-roles.md +14 -7
  22. package/src/agents/agent-management.ts +115 -14
  23. package/src/agents/agent-serializer.ts +2 -2
  24. package/src/agents/agents.ts +195 -47
  25. package/src/api/external-job-provider.ts +10 -1
  26. package/src/api/preflight.ts +33 -19
  27. package/src/api/project-panes.ts +2 -0
  28. package/src/extension/doctor.ts +10 -0
  29. package/src/extension/fanout-child.ts +3 -2
  30. package/src/extension/index.ts +24 -4
  31. package/src/extension/public-execution.ts +12 -9
  32. package/src/extension/rpc.ts +77 -3
  33. package/src/extension/schemas.ts +2 -1
  34. package/src/extension/tool-description.ts +9 -6
  35. package/src/extension/tool-result.ts +19 -0
  36. package/src/inspectors/herdr/client.ts +3 -3
  37. package/src/inspectors/herdr/focus.ts +55 -0
  38. package/src/inspectors/herdr/project-panes.ts +228 -44
  39. package/src/integrations/herdr-status.ts +26 -4
  40. package/src/runs/background/async-execution.ts +112 -13
  41. package/src/runs/background/async-job-tracker.ts +33 -14
  42. package/src/runs/background/async-resume.ts +20 -2
  43. package/src/runs/background/async-retention.ts +1 -1
  44. package/src/runs/background/async-status.ts +10 -0
  45. package/src/runs/background/chain-root-attachment.ts +5 -0
  46. package/src/runs/background/control-channel.ts +98 -10
  47. package/src/runs/background/notify.ts +62 -1
  48. package/src/runs/background/result-watcher.ts +4 -3
  49. package/src/runs/background/run-status.ts +15 -1
  50. package/src/runs/background/stale-run-reconciler.ts +3 -0
  51. package/src/runs/background/subagent-runner.ts +251 -38
  52. package/src/runs/background/wait-completions.ts +2 -0
  53. package/src/runs/background/wait-tool.ts +4 -3
  54. package/src/runs/foreground/async-stop-action.ts +23 -2
  55. package/src/runs/foreground/execution.ts +110 -8
  56. package/src/runs/foreground/subagent-executor.ts +361 -45
  57. package/src/runs/foreground/workflow-detach-reconcile.ts +3 -0
  58. package/src/runs/shared/acceptance.ts +1 -0
  59. package/src/runs/shared/child-identity.ts +36 -0
  60. package/src/runs/shared/completion-guard.ts +50 -1
  61. package/src/runs/shared/external-job-bridge.ts +53 -37
  62. package/src/runs/shared/external-job-runner.ts +126 -23
  63. package/src/runs/shared/model-fallback.ts +46 -15
  64. package/src/runs/shared/model-scope.ts +106 -39
  65. package/src/runs/shared/orca-progress-tabs.ts +71 -9
  66. package/src/runs/shared/parallel-utils.ts +12 -0
  67. package/src/runs/shared/pi-args.ts +31 -1
  68. package/src/runs/shared/subagent-prompt-runtime.ts +39 -10
  69. package/src/runs/shared/tool-availability.ts +1 -3
  70. package/src/shared/launch-contract.ts +6 -5
  71. package/src/shared/thinking-ceiling.ts +52 -0
  72. package/src/shared/types.ts +72 -5
  73. package/src/tui/fleet-status.ts +66 -13
  74. package/src/tui/render.ts +5 -4
  75. package/src/watchdog/permission-arbiter.ts +59 -51
  76. package/src/workflows/scripted-workflow.ts +107 -10
@@ -45,7 +45,11 @@ export function createWatchdogPermissionArbiter(options: WatchdogPermissionArbit
45
45
  const createdAt = Date.now();
46
46
  const auditBase = { type: "permission.request", createdAt, toolName: request.toolName, preview, matchedRule: "ask", decisionSource: "watchdog" };
47
47
  appendPermissionAudit(request.auditPath, auditBase);
48
+ let completed = false;
48
49
  const finish = (approved: boolean, reason: string, decision: string): WatchdogPermissionResult => {
50
+ const concise = conciseReason(reason);
51
+ if (completed) return { approved, reason: concise, source: "watchdog" };
52
+ completed = true;
49
53
  appendPermissionAudit(request.auditPath, {
50
54
  type: "permission.decision",
51
55
  createdAt: Date.now(),
@@ -54,9 +58,9 @@ export function createWatchdogPermissionArbiter(options: WatchdogPermissionArbit
54
58
  decision,
55
59
  approved,
56
60
  decisionSource: "watchdog",
57
- reason: conciseReason(reason),
61
+ reason: concise,
58
62
  });
59
- return { approved, reason: conciseReason(reason), source: "watchdog" };
63
+ return { approved, reason: concise, source: "watchdog" };
60
64
  };
61
65
 
62
66
  let childConfig;
@@ -84,61 +88,65 @@ export function createWatchdogPermissionArbiter(options: WatchdogPermissionArbit
84
88
 
85
89
  let timeout: ReturnType<typeof setTimeout> | undefined;
86
90
  let agent: Agent | undefined;
91
+ let abort: (() => void) | undefined;
87
92
  try {
88
- const config = childResolvedConfig(childConfig);
89
- const selection = await resolveWatchdogReviewModel(request.ctx, config);
90
- const auth = selection.auth;
91
- const registeredProvider = (request.ctx.modelRegistry as {
92
- getRegisteredProviderConfig?: (provider: string) => { api?: string; streamSimple?: StreamFn } | undefined;
93
- }).getRegisteredProviderConfig?.(selection.model.provider);
94
- const baseStreamFn = options.streamFn ?? (registeredProvider?.streamSimple && registeredProvider.api === selection.model.api
95
- ? registeredProvider.streamSimple
96
- : streamSimple);
97
- const streamFn: StreamFn = (model, context, streamOptions) => baseStreamFn(model, context, {
98
- ...streamOptions,
99
- ...(auth.apiKey ? { apiKey: auth.apiKey } : {}),
100
- env: auth.env || streamOptions?.env ? { ...(auth.env ?? {}), ...(streamOptions?.env ?? {}) } : undefined,
101
- headers: { ...(streamOptions?.headers ?? {}), ...(auth.headers ?? {}) },
102
- });
103
- agent = new Agent({
104
- initialState: {
105
- systemPrompt: [
106
- "You are the pi-subagents watchdog permission arbiter.",
107
- "Decide only whether this exact non-bash child tool call should proceed.",
108
- "Call watchdog_permission_decision exactly once with approve or deny and a concise reason.",
109
- "Deny when uncertain. Do not produce freeform advice or ask the parent orchestrator.",
110
- ].join("\n"),
111
- model: selection.model,
112
- thinkingLevel: selection.thinkingLevel,
113
- tools: [tool],
114
- },
115
- convertToLlm,
116
- ...agentStreamOptions(streamFn),
117
- getApiKey: (providerName) => providerName === selection.model.provider ? auth.apiKey : undefined,
118
- beforeToolCall: async ({ toolCall }) => toolCall.name === tool.name ? undefined : { block: true, reason: `Permission arbiter tool '${toolCall.name}' is not allowed.` },
119
- toolExecution: "sequential",
120
- });
121
- const abort = () => agent?.abort();
122
- request.signal?.addEventListener("abort", abort, { once: true });
123
- request.ctx.signal?.addEventListener("abort", abort, { once: true });
124
- try {
125
- const prompt = `Tool: ${request.toolName}\nRedacted arguments: ${preview}`;
126
- await Promise.race([
127
- agent.prompt(prompt),
128
- new Promise<never>((_, reject) => { timeout = setTimeout(() => { agent?.abort(); reject(new Error("Watchdog permission decision timed out.")); }, childConfig.agentEndTimeoutMs); }),
129
- ]);
130
- } finally {
131
- request.signal?.removeEventListener("abort", abort);
132
- request.ctx.signal?.removeEventListener("abort", abort);
133
- }
134
- if (!decision) return finish(false, "Watchdog permission arbiter returned no decision.", "malformed");
135
- const approved = decision.decision === "approve";
136
- return finish(approved, decision.reason, decision.decision);
93
+ const run = async (): Promise<WatchdogPermissionResult> => {
94
+ const config = childResolvedConfig(childConfig);
95
+ const selection = await resolveWatchdogReviewModel(request.ctx, config);
96
+ const auth = selection.auth;
97
+ const registeredProvider = (request.ctx.modelRegistry as {
98
+ getRegisteredProviderConfig?: (provider: string) => { api?: string; streamSimple?: StreamFn } | undefined;
99
+ }).getRegisteredProviderConfig?.(selection.model.provider);
100
+ const baseStreamFn = options.streamFn ?? (registeredProvider?.streamSimple && registeredProvider.api === selection.model.api
101
+ ? registeredProvider.streamSimple
102
+ : streamSimple);
103
+ const streamFn: StreamFn = (model, context, streamOptions) => baseStreamFn(model, context, {
104
+ ...streamOptions,
105
+ ...(auth.apiKey ? { apiKey: auth.apiKey } : {}),
106
+ env: auth.env || streamOptions?.env ? { ...(auth.env ?? {}), ...(streamOptions?.env ?? {}) } : undefined,
107
+ headers: { ...(streamOptions?.headers ?? {}), ...(auth.headers ?? {}) },
108
+ });
109
+ agent = new Agent({
110
+ initialState: {
111
+ systemPrompt: [
112
+ "You are the pi-subagents watchdog permission arbiter.",
113
+ "Decide only whether this exact non-bash child tool call should proceed.",
114
+ "Call watchdog_permission_decision exactly once with approve or deny and a concise reason.",
115
+ "Deny when uncertain. Do not produce freeform advice or ask the parent orchestrator.",
116
+ ].join("\n"),
117
+ model: selection.model,
118
+ thinkingLevel: selection.thinkingLevel,
119
+ tools: [tool],
120
+ },
121
+ convertToLlm,
122
+ ...agentStreamOptions(streamFn),
123
+ getApiKey: (providerName) => providerName === selection.model.provider ? auth.apiKey : undefined,
124
+ beforeToolCall: async ({ toolCall }) => toolCall.name === tool.name ? undefined : { block: true, reason: `Permission arbiter tool '${toolCall.name}' is not allowed.` },
125
+ toolExecution: "sequential",
126
+ });
127
+ await agent.prompt(`Tool: ${request.toolName}\nRedacted arguments: ${preview}`);
128
+ if (!decision) return finish(false, "Watchdog permission arbiter returned no decision.", "malformed");
129
+ const approved = decision.decision === "approve";
130
+ return finish(approved, decision.reason, decision.decision);
131
+ };
132
+ return await Promise.race([
133
+ run(),
134
+ new Promise<WatchdogPermissionResult>((resolve) => { timeout = setTimeout(() => { agent?.abort(); resolve(finish(false, "Watchdog permission decision timed out.", "timeout")); }, childConfig.agentEndTimeoutMs); }),
135
+ new Promise<WatchdogPermissionResult>((resolve) => {
136
+ abort = () => { agent?.abort(); resolve(finish(false, "Watchdog permission decision was cancelled.", "cancelled")); };
137
+ request.signal?.addEventListener("abort", abort, { once: true });
138
+ request.ctx.signal?.addEventListener("abort", abort, { once: true });
139
+ }),
140
+ ]);
137
141
  } catch (error) {
138
142
  const reason = error instanceof Error ? error.message : String(error);
139
143
  return finish(false, `Watchdog permission arbiter failed closed: ${reason}`, reason.includes("timed out") ? "timeout" : "error");
140
144
  } finally {
141
145
  if (timeout) clearTimeout(timeout);
146
+ if (abort) {
147
+ request.signal?.removeEventListener("abort", abort);
148
+ request.ctx.signal?.removeEventListener("abort", abort);
149
+ }
142
150
  }
143
151
  };
144
152
  }
@@ -242,6 +242,33 @@ function runHostCall(key, params, collectFailure, batch) {
242
242
  return { key, callId, promise };
243
243
  }
244
244
 
245
+ function isArrayIndexProperty(prop) {
246
+ if (!/^(0|[1-9]\d*)$/.test(prop)) return false;
247
+ const index = Number(prop);
248
+ return Number.isSafeInteger(index) && index >= 0 && index < 4294967295;
249
+ }
250
+
251
+ const runsAllResultTargets = new WeakMap();
252
+
253
+ function runsAllKeyAccessError(prop) {
254
+ return new Error("Cannot read runs.all result property '" + prop + "'. runs.all resolves to an ordered array, not a key map. Use results[0], array destructuring, or results.map((result) => result.output), not results." + prop + ".");
255
+ }
256
+
257
+ function wrapRunsAllResults(results, keys) {
258
+ const keySet = new Set(keys);
259
+ const proxy = new Proxy(results, {
260
+ get(target, prop, receiver) {
261
+ if (typeof prop !== "string") return Reflect.get(target, prop, receiver);
262
+ if (prop === "then" || prop === "toJSON") return undefined;
263
+ if (prop in target || isArrayIndexProperty(prop)) return Reflect.get(target, prop, receiver);
264
+ if (keySet.has(prop)) throw runsAllKeyAccessError(prop);
265
+ throw runsAllKeyAccessError(prop);
266
+ },
267
+ });
268
+ runsAllResultTargets.set(proxy, results);
269
+ return proxy;
270
+ }
271
+
245
272
  function formatRef(result) {
246
273
  if (!result || typeof result !== "object") throw new Error("runs.ref(result) requires a run result object.");
247
274
  const parts = ["run " + (result.key || "unknown")];
@@ -302,7 +329,7 @@ const runs = Object.freeze({
302
329
  runFingerprints = fingerprints;
303
330
  const batch = { id: "batch-" + (++nextCallId), calls };
304
331
  const launched = calls.map(({ key, params }) => runHostCall(key, params, true, batch));
305
- return trackRunObservation(launched.map(({ key, callId }) => ({ key, operation: "run", callId })), Promise.all(launched.map(({ promise }) => promise)));
332
+ return trackRunObservation(launched.map(({ key, callId }) => ({ key, operation: "run", callId })), Promise.all(launched.map(({ promise }) => promise)).then((results) => wrapRunsAllResults(results, calls.map(({ key }) => key))));
306
333
  },
307
334
  steer(key, message, options = {}) {
308
335
  if (typeof key !== "string" || !runKeyPattern.test(key)) throw new Error("runs.steer has an invalid key.");
@@ -431,6 +458,31 @@ function isPlainWorkflowObject(value) {
431
458
  return prototype === null || prototype === Object.prototype || prototype === contextObjectPrototype;
432
459
  }
433
460
 
461
+ function unwrapRunsAllResults(value, seen = new Map()) {
462
+ if (value === null || typeof value !== "object") return value;
463
+ const runsAllTarget = runsAllResultTargets.get(value);
464
+ const target = runsAllTarget || value;
465
+ if (seen.has(target)) return seen.get(target);
466
+ if (Array.isArray(target)) {
467
+ const copy = [];
468
+ seen.set(target, copy);
469
+ let changed = !!runsAllTarget;
470
+ for (let index = 0; index < target.length; index++) {
471
+ copy[index] = unwrapRunsAllResults(target[index], seen);
472
+ changed ||= copy[index] !== target[index];
473
+ }
474
+ return changed ? copy : target;
475
+ }
476
+ if (!isPlainWorkflowObject(target) || Object.getOwnPropertySymbols(target).length > 0) return target;
477
+ let changed = false;
478
+ const entries = Object.entries(target).map(([key, entry]) => {
479
+ const unwrapped = unwrapRunsAllResults(entry, seen);
480
+ changed ||= unwrapped !== entry;
481
+ return [key, unwrapped];
482
+ });
483
+ return changed ? Object.fromEntries(entries) : target;
484
+ }
485
+
434
486
  function omitUndefinedWorkflowValues(value, seen = new Set()) {
435
487
  if (value === null || typeof value !== "object") return value;
436
488
  if (seen.has(value)) return value;
@@ -459,7 +511,7 @@ parentPort.on("message", async (message) => {
459
511
  }
460
512
  if (message.type !== "start") return;
461
513
  try {
462
- const sandbox = { runs, Promise: workflowPromise, emit(value) { assertJsonValue(value); parentPort.postMessage({ type: "emit", value }); }, console: capturedConsole };
514
+ const sandbox = { runs, Promise: workflowPromise, emit(value) { const emittedValue = unwrapRunsAllResults(value); assertJsonValue(emittedValue); parentPort.postMessage({ type: "emit", value: emittedValue }); }, console: capturedConsole };
463
515
  if (message.stateEnabled) sandbox.state = state;
464
516
  const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
465
517
  contextObjectPrototype = vm.runInContext("Object.prototype", context);
@@ -540,6 +592,7 @@ parentPort.on("message", async (message) => {
540
592
  export interface WorkflowScriptChildResult {
541
593
  key: string;
542
594
  ok: boolean;
595
+ stopped?: boolean;
543
596
  /** Canonical child agent name when launch resolution produced one. */
544
597
  agent?: string;
545
598
  runId?: string;
@@ -628,10 +681,27 @@ export interface RunWorkflowScriptOptions {
628
681
  get: (key: string) => unknown | Promise<unknown>;
629
682
  set: (key: string, value: unknown) => void | Promise<void>;
630
683
  };
684
+ registerStopChild?: (stop: ((key: string, message?: string) => boolean) | undefined) => void;
631
685
  onTrace?: (trace: WorkflowScriptTraceEntry[]) => void;
632
686
  onEmit?: (emits: unknown[]) => void;
633
687
  }
634
688
 
689
+ function combinedAbortSignal(signals: AbortSignal[]): AbortSignal {
690
+ const controller = new AbortController();
691
+ const abort = (signal: AbortSignal): void => {
692
+ if (controller.signal.aborted) return;
693
+ controller.abort(signal.reason);
694
+ };
695
+ for (const signal of signals) {
696
+ if (signal.aborted) {
697
+ abort(signal);
698
+ break;
699
+ }
700
+ signal.addEventListener("abort", () => abort(signal), { once: true });
701
+ }
702
+ return controller.signal;
703
+ }
704
+
635
705
  function isRecord(value: unknown): value is Record<string, unknown> {
636
706
  return !!value && typeof value === "object" && !Array.isArray(value);
637
707
  }
@@ -795,6 +865,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
795
865
  const launches = new Map<string, { fingerprint: string; promise: Promise<WorkflowScriptChildResult>; observed: boolean }>();
796
866
  const steers = new Map<number, { key: string; promise: Promise<WorkflowSteerResult>; observed: boolean }>();
797
867
  const stoppedLaunches = new Set<string>();
868
+ const childStopControllers = new Map<string, AbortController>();
798
869
  const batchAdmissions = new Map<string, Promise<void>>();
799
870
  const observedRunCalls = new Set<number>();
800
871
  const observedSteerCalls = new Set<number>();
@@ -818,6 +889,26 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
818
889
  console.error("Workflow onTrace callback failed:", error);
819
890
  }
820
891
  };
892
+ const stoppedChildResult = (key: string, message: string): WorkflowScriptChildResult => ({ key, ok: false, stopped: true, output: message, error: message, artifactPaths: [] });
893
+ const stopChild = (key: string, message = `Workflow child '${key}' stopped by user.`): boolean => {
894
+ if (!launches.has(key) || children.has(key)) return false;
895
+ stoppedLaunches.add(key);
896
+ children.set(key, stoppedChildResult(key, message));
897
+ childStopControllers.get(key)?.abort(new Error(message));
898
+ const started = trace.findLast((entry) => entry.operation === "run" && entry.key === key && entry.state === "started");
899
+ trace.push({
900
+ operation: "run",
901
+ key,
902
+ state: "stopped",
903
+ ...(started?.agent ? { agent: started.agent } : {}),
904
+ ...(started?.phase ? { phase: started.phase } : {}),
905
+ ...(started?.label ? { label: started.label } : {}),
906
+ error: message,
907
+ });
908
+ traceChanged();
909
+ return true;
910
+ };
911
+ options.registerStopChild?.(stopChild);
821
912
 
822
913
  return await new Promise<WorkflowScriptResult>((resolve, reject) => {
823
914
  const finish = (outcome: { value: unknown } | { error: Error & { workflowErrorKind?: unknown } }) => {
@@ -827,6 +918,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
827
918
  void Promise.allSettled([...steers.values()].map(({ promise }) => promise)).then(() => {
828
919
  if (settled) return;
829
920
  settled = true;
921
+ options.registerStopChild?.(undefined);
830
922
  if (timer) clearTimeout(timer);
831
923
  options.signal?.removeEventListener("abort", onAbort);
832
924
  void worker.terminate();
@@ -1061,7 +1153,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1061
1153
  const deliver = (promise: Promise<WorkflowScriptChildResult>) => collectFailure
1062
1154
  ? promise
1063
1155
  : promise.then((result) => {
1064
- if (!result.ok) {
1156
+ if (!result.ok && !result.stopped) {
1065
1157
  const childError = new Error(result.detached ? `Run '${key}' detached: ${result.error ?? result.output}` : `Run '${key}' failed: ${result.error ?? result.output}`) as Error & { workflowErrorKind?: "detached-child" };
1066
1158
  if (result.detached) childError.workflowErrorKind = "detached-child";
1067
1159
  throw childError;
@@ -1100,13 +1192,16 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1100
1192
  const promise = admission.then(async () => {
1101
1193
  if (settled || finishing || stoppedLaunches.has(key)) {
1102
1194
  const reason = childController.signal.reason;
1103
- const text = reason instanceof Error ? reason.message : typeof reason === "string" ? reason : "Workflow script aborted.";
1104
- return { key, ok: false, output: text, error: text, artifactPaths: [] };
1195
+ const text = children.get(key)?.error ?? (reason instanceof Error ? reason.message : typeof reason === "string" ? reason : "Workflow script aborted.");
1196
+ return stoppedChildResult(key, text);
1105
1197
  }
1198
+ const childStopController = new AbortController();
1199
+ childStopControllers.set(key, childStopController);
1200
+ const childSignal = combinedAbortSignal([childController.signal, childStopController.signal]);
1106
1201
  const resolvedResumeValue = resumeReference
1107
1202
  ? await Promise.resolve().then(() => {
1108
1203
  if (!options.resolveResume) throw new Error("Keyed workflow receipt resume is unavailable in this host.");
1109
- return options.resolveResume(resumeReference, childController.signal);
1204
+ return options.resolveResume(resumeReference, childSignal);
1110
1205
  })
1111
1206
  : undefined;
1112
1207
  const resolvedResume = typeof resolvedResumeValue === "string"
@@ -1124,22 +1219,24 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1124
1219
  if (resolvedResumeLineage.at(-1) !== resolvedResumeId) resolvedResumeLineage.push(resolvedResumeId!);
1125
1220
  }
1126
1221
  const launchParams = resolvedResumeId ? { ...params, resume: resolvedResumeId } : params;
1127
- return options.launch(key, { ...launchParams, async: launchParams.async ?? false }, childController.signal, { admitted: true });
1222
+ return options.launch(key, launchParams, childSignal, { admitted: true });
1128
1223
  }).then((result) => {
1129
1224
  let normalized = !result.ok && !result.error ? { ...result, error: result.output } : result;
1130
1225
  if (resolvedResumeLineage?.length && normalized.runId) {
1131
1226
  normalized = { ...normalized, continuation: { runIds: [...new Set([...resolvedResumeLineage, normalized.runId])] } };
1132
1227
  }
1133
- if (stoppedLaunches.has(key)) return normalized;
1228
+ childStopControllers.delete(key);
1229
+ if (stoppedLaunches.has(key)) return children.get(key) ?? normalized;
1134
1230
  children.set(key, normalized);
1135
- const state = normalized.ok ? "completed" : normalized.detached ? "detached" : "failed";
1231
+ const state = normalized.ok ? "completed" : normalized.stopped ? "stopped" : normalized.detached ? "detached" : "failed";
1136
1232
  trace.push({ operation: "run", key, state, durationMs: Date.now() - startedAt, ...workflowStringMetadata(params), ...(normalized.agent ? { agent: normalized.agent } : {}), ...(normalized.runId ? { runId: normalized.runId } : {}), ...(!normalized.ok ? { error: normalized.error ?? normalized.output } : {}) });
1137
1233
  traceChanged();
1138
1234
  return normalized;
1139
1235
  }, (error: unknown) => {
1140
1236
  const text = error instanceof Error ? error.message : String(error);
1141
1237
  const failure: WorkflowScriptChildResult = { key, ok: false, output: text, error: text, artifactPaths: [] };
1142
- if (stoppedLaunches.has(key)) return failure;
1238
+ childStopControllers.delete(key);
1239
+ if (stoppedLaunches.has(key)) return children.get(key) ?? { ...failure, stopped: true };
1143
1240
  children.set(key, failure);
1144
1241
  trace.push({ operation: "run", key, state: "failed", durationMs: Date.now() - startedAt, ...workflowStringMetadata(params), error: text });
1145
1242
  traceChanged();