taskflow-core 0.1.8 → 0.2.1
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 +248 -808
- package/dist/agents.d.ts +12 -0
- package/dist/agents.d.ts.map +1 -1
- package/dist/agents.js +29 -1
- package/dist/agents.js.map +1 -1
- package/dist/cwd-bridge.d.ts +61 -0
- package/dist/cwd-bridge.d.ts.map +1 -0
- package/dist/cwd-bridge.js +136 -0
- package/dist/cwd-bridge.js.map +1 -0
- package/dist/detached-runner.js +31 -5
- package/dist/detached-runner.js.map +1 -1
- package/dist/deterministic.d.ts +8 -0
- package/dist/deterministic.d.ts.map +1 -1
- package/dist/deterministic.js +23 -1
- package/dist/deterministic.js.map +1 -1
- package/dist/exec/driver.d.ts +48 -0
- package/dist/exec/driver.d.ts.map +1 -0
- package/dist/exec/driver.js +410 -0
- package/dist/exec/driver.js.map +1 -0
- package/dist/exec/events.d.ts +76 -0
- package/dist/exec/events.d.ts.map +1 -0
- package/dist/exec/events.js +108 -0
- package/dist/exec/events.js.map +1 -0
- package/dist/exec/fold.d.ts +45 -0
- package/dist/exec/fold.d.ts.map +1 -0
- package/dist/exec/fold.js +122 -0
- package/dist/exec/fold.js.map +1 -0
- package/dist/exec/index.d.ts +17 -0
- package/dist/exec/index.d.ts.map +1 -0
- package/dist/exec/index.js +17 -0
- package/dist/exec/index.js.map +1 -0
- package/dist/exec/kernel-policy.d.ts +35 -0
- package/dist/exec/kernel-policy.d.ts.map +1 -0
- package/dist/exec/kernel-policy.js +184 -0
- package/dist/exec/kernel-policy.js.map +1 -0
- package/dist/exec/step-kinds.d.ts +41 -0
- package/dist/exec/step-kinds.d.ts.map +1 -0
- package/dist/exec/step-kinds.js +599 -0
- package/dist/exec/step-kinds.js.map +1 -0
- package/dist/exec/step.d.ts +94 -0
- package/dist/exec/step.d.ts.map +1 -0
- package/dist/exec/step.js +454 -0
- package/dist/exec/step.js.map +1 -0
- package/dist/flowir/canonical-hash.d.ts +101 -0
- package/dist/flowir/canonical-hash.d.ts.map +1 -0
- package/dist/flowir/canonical-hash.js +219 -0
- package/dist/flowir/canonical-hash.js.map +1 -0
- package/dist/flowir/compile.d.ts +49 -0
- package/dist/flowir/compile.d.ts.map +1 -0
- package/dist/flowir/compile.js +232 -0
- package/dist/flowir/compile.js.map +1 -0
- package/dist/flowir/cond.d.ts +76 -0
- package/dist/flowir/cond.d.ts.map +1 -0
- package/dist/flowir/cond.js +214 -0
- package/dist/flowir/cond.js.map +1 -0
- package/dist/flowir/index.d.ts +20 -23
- package/dist/flowir/index.d.ts.map +1 -1
- package/dist/flowir/index.js +47 -35
- package/dist/flowir/index.js.map +1 -1
- package/dist/flowir/meta.d.ts +4 -4
- package/dist/flowir/meta.d.ts.map +1 -1
- package/dist/flowir/phasefp.d.ts.map +1 -1
- package/dist/flowir/phasefp.js +13 -1
- package/dist/flowir/phasefp.js.map +1 -1
- package/dist/flowir/schema.d.ts +260 -0
- package/dist/flowir/schema.d.ts.map +1 -0
- package/dist/flowir/schema.js +234 -0
- package/dist/flowir/schema.js.map +1 -0
- package/dist/flowir/translate.d.ts.map +1 -1
- package/dist/flowir/translate.js +3 -0
- package/dist/flowir/translate.js.map +1 -1
- package/dist/host/runner-types.d.ts +19 -0
- package/dist/host/runner-types.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/interpolate.d.ts +4 -0
- package/dist/interpolate.d.ts.map +1 -1
- package/dist/interpolate.js +14 -0
- package/dist/interpolate.js.map +1 -1
- package/dist/rates.d.ts +121 -0
- package/dist/rates.d.ts.map +1 -0
- package/dist/rates.js +187 -0
- package/dist/rates.js.map +1 -0
- package/dist/replay.d.ts +43 -50
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +629 -19
- package/dist/replay.js.map +1 -1
- package/dist/resources/authority.d.ts +35 -0
- package/dist/resources/authority.d.ts.map +1 -0
- package/dist/resources/authority.js +67 -0
- package/dist/resources/authority.js.map +1 -0
- package/dist/resources/backend.d.ts +302 -0
- package/dist/resources/backend.d.ts.map +1 -0
- package/dist/resources/backend.js +16 -0
- package/dist/resources/backend.js.map +1 -0
- package/dist/resources/baseline.d.ts +116 -0
- package/dist/resources/baseline.d.ts.map +1 -0
- package/dist/resources/baseline.js +447 -0
- package/dist/resources/baseline.js.map +1 -0
- package/dist/resources/canonical-json.d.ts +7 -0
- package/dist/resources/canonical-json.d.ts.map +1 -0
- package/dist/resources/canonical-json.js +75 -0
- package/dist/resources/canonical-json.js.map +1 -0
- package/dist/resources/errors.d.ts +23 -0
- package/dist/resources/errors.d.ts.map +1 -0
- package/dist/resources/errors.js +89 -0
- package/dist/resources/errors.js.map +1 -0
- package/dist/resources/execution.d.ts +90 -0
- package/dist/resources/execution.d.ts.map +1 -0
- package/dist/resources/execution.js +581 -0
- package/dist/resources/execution.js.map +1 -0
- package/dist/resources/index.d.ts +15 -0
- package/dist/resources/index.d.ts.map +1 -0
- package/dist/resources/index.js +15 -0
- package/dist/resources/index.js.map +1 -0
- package/dist/resources/journal.d.ts +138 -0
- package/dist/resources/journal.d.ts.map +1 -0
- package/dist/resources/journal.js +438 -0
- package/dist/resources/journal.js.map +1 -0
- package/dist/resources/leases.d.ts +51 -0
- package/dist/resources/leases.d.ts.map +1 -0
- package/dist/resources/leases.js +354 -0
- package/dist/resources/leases.js.map +1 -0
- package/dist/resources/permits.d.ts +52 -0
- package/dist/resources/permits.d.ts.map +1 -0
- package/dist/resources/permits.js +240 -0
- package/dist/resources/permits.js.map +1 -0
- package/dist/resources/persistence.d.ts +63 -0
- package/dist/resources/persistence.d.ts.map +1 -0
- package/dist/resources/persistence.js +522 -0
- package/dist/resources/persistence.js.map +1 -0
- package/dist/resources/registry.d.ts +56 -0
- package/dist/resources/registry.d.ts.map +1 -0
- package/dist/resources/registry.js +139 -0
- package/dist/resources/registry.js.map +1 -0
- package/dist/resources/resolve.d.ts +49 -0
- package/dist/resources/resolve.d.ts.map +1 -0
- package/dist/resources/resolve.js +309 -0
- package/dist/resources/resolve.js.map +1 -0
- package/dist/resources/sandbox.d.ts +72 -0
- package/dist/resources/sandbox.d.ts.map +1 -0
- package/dist/resources/sandbox.js +952 -0
- package/dist/resources/sandbox.js.map +1 -0
- package/dist/resources/schema.d.ts +195 -0
- package/dist/resources/schema.d.ts.map +1 -0
- package/dist/resources/schema.js +231 -0
- package/dist/resources/schema.js.map +1 -0
- package/dist/resources/types.d.ts +36 -0
- package/dist/resources/types.d.ts.map +1 -0
- package/dist/resources/types.js +75 -0
- package/dist/resources/types.js.map +1 -0
- package/dist/runner-core.d.ts +53 -0
- package/dist/runner-core.d.ts.map +1 -1
- package/dist/runner-core.js +521 -53
- package/dist/runner-core.js.map +1 -1
- package/dist/runtime/phases/approval.d.ts +23 -0
- package/dist/runtime/phases/approval.d.ts.map +1 -0
- package/dist/runtime/phases/approval.js +38 -0
- package/dist/runtime/phases/approval.js.map +1 -0
- package/dist/runtime/phases/expand.d.ts +29 -0
- package/dist/runtime/phases/expand.d.ts.map +1 -0
- package/dist/runtime/phases/expand.js +126 -0
- package/dist/runtime/phases/expand.js.map +1 -0
- package/dist/runtime/phases/parallel.d.ts +26 -0
- package/dist/runtime/phases/parallel.d.ts.map +1 -0
- package/dist/runtime/phases/parallel.js +15 -0
- package/dist/runtime/phases/parallel.js.map +1 -0
- package/dist/runtime/phases/race.d.ts +31 -0
- package/dist/runtime/phases/race.d.ts.map +1 -0
- package/dist/runtime/phases/race.js +220 -0
- package/dist/runtime/phases/race.js.map +1 -0
- package/dist/runtime/phases/script.d.ts +38 -0
- package/dist/runtime/phases/script.d.ts.map +1 -0
- package/dist/runtime/phases/script.js +177 -0
- package/dist/runtime/phases/script.js.map +1 -0
- package/dist/runtime.d.ts +68 -16
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +1091 -337
- package/dist/runtime.js.map +1 -1
- package/dist/schema.d.ts +104 -9
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +249 -32
- package/dist/schema.js.map +1 -1
- package/dist/store.d.ts +13 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js.map +1 -1
- package/dist/trace.d.ts +7 -1
- package/dist/trace.d.ts.map +1 -1
- package/dist/trace.js +33 -26
- package/dist/trace.js.map +1 -1
- package/package.json +7 -5
package/dist/runner-core.js
CHANGED
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
* failure semantics, retry heuristics, and usage folding are identical across
|
|
12
12
|
* hosts.
|
|
13
13
|
*/
|
|
14
|
-
import { spawn } from "node:child_process";
|
|
14
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
15
|
+
import { StringDecoder } from "node:string_decoder";
|
|
15
16
|
import { emptyUsage } from "./usage.js";
|
|
16
17
|
export function isFailed(r) {
|
|
17
|
-
return r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted";
|
|
18
|
+
return r.exitCode !== 0 || Boolean(r.errorMessage) || r.stopReason === "error" || r.stopReason === "aborted";
|
|
18
19
|
}
|
|
19
20
|
/**
|
|
20
21
|
* Heuristic: did this failure look like a transient/retryable provider error
|
|
@@ -35,6 +36,26 @@ export function isTransientError(r) {
|
|
|
35
36
|
const hay = `${r.errorMessage ?? ""} ${r.stderr ?? ""} ${r.output ?? ""}`;
|
|
36
37
|
return TRANSIENT_ERROR_RE.test(hay);
|
|
37
38
|
}
|
|
39
|
+
/** Wait for a retry backoff, but release immediately when the run is aborted.
|
|
40
|
+
* Resolves (rather than rejects) on abort so callers can leave their retry loop
|
|
41
|
+
* through the normal `signal.aborted` branch and preserve paused semantics. */
|
|
42
|
+
export function abortableDelay(ms, signal) {
|
|
43
|
+
if (ms <= 0 || signal?.aborted)
|
|
44
|
+
return Promise.resolve();
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
let settled = false;
|
|
47
|
+
const finish = () => {
|
|
48
|
+
if (settled)
|
|
49
|
+
return;
|
|
50
|
+
settled = true;
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
signal?.removeEventListener("abort", finish);
|
|
53
|
+
resolve();
|
|
54
|
+
};
|
|
55
|
+
const timer = setTimeout(finish, ms);
|
|
56
|
+
signal?.addEventListener("abort", finish, { once: true });
|
|
57
|
+
});
|
|
58
|
+
}
|
|
38
59
|
/** Placeholder written to a failed phase's `output` so downstream interpolation
|
|
39
60
|
* can detect "upstream failed" without being polluted by raw HTML/JSON. */
|
|
40
61
|
export const TRANSPORT_ERROR_PLACEHOLDER = "(upstream error: subagent failed; see error)";
|
|
@@ -102,7 +123,7 @@ export function getFinalOutput(messages) {
|
|
|
102
123
|
return "";
|
|
103
124
|
}
|
|
104
125
|
export function newAccumulator(model) {
|
|
105
|
-
return { messages: [], usage: emptyUsage(), model, lastActivity: "" };
|
|
126
|
+
return { messages: [], usage: emptyUsage(), model, finalText: "", lastActivity: "" };
|
|
106
127
|
}
|
|
107
128
|
/**
|
|
108
129
|
* Fold one NDJSON line into the accumulator. Returns a LiveUpdate when an
|
|
@@ -149,8 +170,13 @@ export function foldEventLine(acc, line) {
|
|
|
149
170
|
acc.model = msg.model;
|
|
150
171
|
if (msg.stopReason)
|
|
151
172
|
acc.stopReason = msg.stopReason;
|
|
152
|
-
if (msg.errorMessage)
|
|
173
|
+
if (msg.errorMessage) {
|
|
153
174
|
acc.errorMessage = msg.errorMessage;
|
|
175
|
+
acc.fatalError = msg.errorMessage;
|
|
176
|
+
}
|
|
177
|
+
const finalText = getFinalOutput([msg]);
|
|
178
|
+
if (finalText.trim())
|
|
179
|
+
acc.finalText = finalText;
|
|
154
180
|
const activity = describeActivity(msg);
|
|
155
181
|
if (activity)
|
|
156
182
|
acc.lastActivity = activity;
|
|
@@ -240,17 +266,123 @@ export function num(v) {
|
|
|
240
266
|
* Module-global (not per-host): a single exit handler must reach every host's
|
|
241
267
|
* children, so all host runners register here. */
|
|
242
268
|
const activeChildren = new Set();
|
|
243
|
-
|
|
244
|
-
|
|
269
|
+
/** Register a detached process group with the host-wide supervisor. Script
|
|
270
|
+
* phases and agent runners share this registry so external host termination
|
|
271
|
+
* cannot leave either kind of child mutating the workspace. */
|
|
272
|
+
export function registerProcessTree(pid) {
|
|
273
|
+
activeChildren.add(pid);
|
|
274
|
+
}
|
|
275
|
+
/** Remove a process group after its stdio has closed and all descendants have
|
|
276
|
+
* been synchronously reaped. */
|
|
277
|
+
export function unregisterProcessTree(pid) {
|
|
278
|
+
activeChildren.delete(pid);
|
|
279
|
+
}
|
|
280
|
+
/** Signal the whole process tree rooted at a spawned subagent. POSIX children
|
|
281
|
+
* are process-group leaders (`detached:true` at spawn), so a negative pid
|
|
282
|
+
* reaches every descendant in the group. Windows has no equivalent signal;
|
|
283
|
+
* taskkill /T /F is the platform-supported tree termination primitive. */
|
|
284
|
+
export function killProcessTree(pid, signal, direct) {
|
|
285
|
+
if (process.platform === "win32") {
|
|
286
|
+
try {
|
|
287
|
+
// Wait for taskkill: returning while /T is still enumerating descendants
|
|
288
|
+
// violates the phase boundary and is especially racy after root exit.
|
|
289
|
+
const killed = spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
|
290
|
+
shell: false,
|
|
291
|
+
stdio: "ignore",
|
|
292
|
+
windowsHide: true,
|
|
293
|
+
});
|
|
294
|
+
if (killed.error || killed.status !== 0) {
|
|
295
|
+
try {
|
|
296
|
+
direct?.kill();
|
|
297
|
+
}
|
|
298
|
+
catch { /* already dead */ }
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
try {
|
|
303
|
+
direct?.kill();
|
|
304
|
+
}
|
|
305
|
+
catch { /* already dead */ }
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
process.kill(-pid, signal);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
// A process can exit between the liveness check and group signal. Falling
|
|
314
|
+
// back to the direct handle also covers platforms that reject group kills.
|
|
245
315
|
try {
|
|
246
|
-
|
|
316
|
+
direct?.kill(signal);
|
|
247
317
|
}
|
|
248
318
|
catch { /* already dead */ }
|
|
249
319
|
}
|
|
320
|
+
}
|
|
321
|
+
/** Synchronous variant for the host's `exit` event, where asynchronous taskkill
|
|
322
|
+
* cannot be awaited and would never get a chance to run. */
|
|
323
|
+
function killProcessTreeSync(pid) {
|
|
324
|
+
if (process.platform === "win32") {
|
|
325
|
+
try {
|
|
326
|
+
spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
|
327
|
+
shell: false,
|
|
328
|
+
stdio: "ignore",
|
|
329
|
+
windowsHide: true,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
catch { /* already dead / taskkill unavailable */ }
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
process.kill(-pid, "SIGKILL");
|
|
337
|
+
}
|
|
338
|
+
catch { /* already dead */ }
|
|
339
|
+
}
|
|
340
|
+
const killAllChildren = () => {
|
|
341
|
+
for (const pid of activeChildren) {
|
|
342
|
+
killProcessTreeSync(pid);
|
|
343
|
+
}
|
|
250
344
|
};
|
|
251
345
|
process.on("exit", killAllChildren);
|
|
346
|
+
// Installing a signal listener disables Node's default signal termination.
|
|
347
|
+
// Reap every registered process group synchronously, then restore the default
|
|
348
|
+
// disposition and re-deliver the same signal so callers still observe a real
|
|
349
|
+
// signal exit (rather than an arbitrary numeric process.exit code).
|
|
350
|
+
const EXTERNAL_SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];
|
|
351
|
+
const SIGNAL_EXIT_CODES = {
|
|
352
|
+
SIGTERM: 143,
|
|
353
|
+
SIGINT: 130,
|
|
354
|
+
SIGHUP: 129,
|
|
355
|
+
};
|
|
356
|
+
let handlingExternalSignal = false;
|
|
357
|
+
for (const signal of EXTERNAL_SIGNALS) {
|
|
358
|
+
process.on(signal, () => {
|
|
359
|
+
if (handlingExternalSignal)
|
|
360
|
+
return;
|
|
361
|
+
handlingExternalSignal = true;
|
|
362
|
+
killAllChildren();
|
|
363
|
+
// Existing listeners have already been snapshotted for this EventEmitter
|
|
364
|
+
// dispatch. Removing them here only ensures the re-delivered signal takes
|
|
365
|
+
// the OS default path instead of recursively entering user handlers.
|
|
366
|
+
process.removeAllListeners(signal);
|
|
367
|
+
try {
|
|
368
|
+
process.kill(process.pid, signal);
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
process.exit(SIGNAL_EXIT_CODES[signal]);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
}
|
|
252
375
|
/** Same idle window every host runner uses: a child silent this long is wedged. */
|
|
253
376
|
export const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60_000;
|
|
377
|
+
/** After a phase timeout aborts its runner, allow process-backed hosts enough
|
|
378
|
+
* time to execute their SIGTERM to SIGKILL escalation. A custom runner that
|
|
379
|
+
* ignores AbortSignal entirely is still bounded by this grace window. */
|
|
380
|
+
export const PHASE_TIMEOUT_ABORT_GRACE_MS = 5500;
|
|
381
|
+
/** Maximum NDJSON line size retained while waiting for a newline. A hostile or
|
|
382
|
+
* broken host can otherwise stream an unterminated line until the orchestrator
|
|
383
|
+
* runs out of memory. Host events are expected to be compact; 1 MiB still
|
|
384
|
+
* leaves ample room for a large final answer while providing a hard bound. */
|
|
385
|
+
export const MAX_STDOUT_LINE_BYTES = 1024 * 1024;
|
|
254
386
|
/** Standard "unknown agent" RunResult — identical across every host runner. */
|
|
255
387
|
export function unknownAgentResult(agentName, task, agents) {
|
|
256
388
|
const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
|
|
@@ -284,7 +416,21 @@ export async function runSubagentProcess(opts) {
|
|
|
284
416
|
let wasAborted = false;
|
|
285
417
|
let idleTimedOut = false;
|
|
286
418
|
let killedBySignal;
|
|
419
|
+
let protocolError;
|
|
420
|
+
let policyFatal;
|
|
421
|
+
let terminalCommitted = false;
|
|
422
|
+
let reapedAfterTerminal = false;
|
|
423
|
+
let completionNotified = false;
|
|
424
|
+
let killReason;
|
|
287
425
|
const idleMs = opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
426
|
+
const terminationGraceMs = opts.terminationGraceMs ?? 5000;
|
|
427
|
+
const completionPolicy = opts.completionPolicy;
|
|
428
|
+
if (completionPolicy && (!Number.isFinite(completionPolicy.terminalGraceMs) || completionPolicy.terminalGraceMs < 0)) {
|
|
429
|
+
throw new Error("terminalGraceMs must be a non-negative finite number");
|
|
430
|
+
}
|
|
431
|
+
if (!Number.isFinite(terminationGraceMs) || terminationGraceMs < 0) {
|
|
432
|
+
throw new Error("terminationGraceMs must be a non-negative finite number");
|
|
433
|
+
}
|
|
288
434
|
// Structured run-log header, opt-in via PI_TASKFLOW_RUN_LOG. Written to the
|
|
289
435
|
// HOST process's stderr (the MCP stdio log channel; pi's subagent diagnostic
|
|
290
436
|
// stream) — never to stdout (stdout is JSON-RPC for MCP). One line per spawn
|
|
@@ -298,25 +444,89 @@ export async function runSubagentProcess(opts) {
|
|
|
298
444
|
}
|
|
299
445
|
}
|
|
300
446
|
const exitCode = await new Promise((resolve) => {
|
|
301
|
-
const proc = spawn(bin, args, {
|
|
447
|
+
const proc = spawn(bin, args, {
|
|
448
|
+
cwd,
|
|
449
|
+
shell: false,
|
|
450
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
451
|
+
env,
|
|
452
|
+
// On POSIX this makes the subagent a process-group leader while retaining
|
|
453
|
+
// piped stdout/stderr. Cancellation can then signal `-pid` and cannot
|
|
454
|
+
// strand grandchildren that outlive the direct CLI process.
|
|
455
|
+
detached: process.platform !== "win32",
|
|
456
|
+
windowsHide: true,
|
|
457
|
+
});
|
|
302
458
|
if (proc.pid)
|
|
303
|
-
|
|
459
|
+
registerProcessTree(proc.pid);
|
|
304
460
|
let buffer = "";
|
|
461
|
+
const stdoutDecoder = new StringDecoder("utf8");
|
|
462
|
+
const stderrDecoder = new StringDecoder("utf8");
|
|
305
463
|
let idleTimer;
|
|
464
|
+
let terminalTimer;
|
|
306
465
|
let forceTimer;
|
|
466
|
+
let candidateEpoch = 0;
|
|
467
|
+
let terminalCandidate = false;
|
|
468
|
+
let settled = false;
|
|
469
|
+
let removeAbortListener = () => { };
|
|
307
470
|
const clearTimers = () => {
|
|
308
|
-
if (idleTimer)
|
|
471
|
+
if (idleTimer) {
|
|
309
472
|
clearTimeout(idleTimer);
|
|
473
|
+
idleTimer = undefined;
|
|
474
|
+
}
|
|
475
|
+
if (terminalTimer) {
|
|
476
|
+
clearTimeout(terminalTimer);
|
|
477
|
+
terminalTimer = undefined;
|
|
478
|
+
}
|
|
479
|
+
if (forceTimer) {
|
|
480
|
+
clearTimeout(forceTimer);
|
|
481
|
+
forceTimer = undefined;
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
const signalTree = (signal) => {
|
|
485
|
+
if (proc.pid)
|
|
486
|
+
killProcessTree(proc.pid, signal, proc);
|
|
487
|
+
else {
|
|
488
|
+
try {
|
|
489
|
+
proc.kill(signal);
|
|
490
|
+
}
|
|
491
|
+
catch { /* spawn failed / already dead */ }
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
const setKillReason = (reason) => {
|
|
495
|
+
if (killReason)
|
|
496
|
+
return killReason === reason;
|
|
497
|
+
killReason = reason;
|
|
498
|
+
return true;
|
|
499
|
+
};
|
|
500
|
+
const scheduleForceKill = () => {
|
|
501
|
+
if (settled)
|
|
502
|
+
return;
|
|
310
503
|
if (forceTimer)
|
|
311
504
|
clearTimeout(forceTimer);
|
|
505
|
+
forceTimer = setTimeout(() => {
|
|
506
|
+
if (!settled)
|
|
507
|
+
signalTree("SIGKILL");
|
|
508
|
+
}, terminationGraceMs);
|
|
509
|
+
forceTimer.unref();
|
|
510
|
+
};
|
|
511
|
+
const clearTerminalCandidate = () => {
|
|
512
|
+
terminalCandidate = false;
|
|
513
|
+
candidateEpoch++;
|
|
514
|
+
if (terminalTimer) {
|
|
515
|
+
clearTimeout(terminalTimer);
|
|
516
|
+
terminalTimer = undefined;
|
|
517
|
+
}
|
|
312
518
|
};
|
|
313
519
|
const hardKill = () => {
|
|
520
|
+
if (settled || terminalCommitted || !setKillReason("idle-timeout"))
|
|
521
|
+
return;
|
|
314
522
|
idleTimedOut = true;
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
523
|
+
clearTerminalCandidate();
|
|
524
|
+
signalTree("SIGTERM");
|
|
525
|
+
scheduleForceKill();
|
|
318
526
|
};
|
|
319
527
|
const armIdle = () => {
|
|
528
|
+
if (terminalCandidate || terminalCommitted || settled)
|
|
529
|
+
return;
|
|
320
530
|
if (idleTimer)
|
|
321
531
|
clearTimeout(idleTimer);
|
|
322
532
|
if (idleMs <= 0)
|
|
@@ -325,102 +535,360 @@ export async function runSubagentProcess(opts) {
|
|
|
325
535
|
idleTimer.unref();
|
|
326
536
|
};
|
|
327
537
|
armIdle();
|
|
538
|
+
const failProtocol = (message) => {
|
|
539
|
+
if (protocolError)
|
|
540
|
+
return;
|
|
541
|
+
// An earlier abort/idle/fatal stop can itself truncate the last record.
|
|
542
|
+
// First-writer wins; only a terminal reap remains revocable by a bad tail.
|
|
543
|
+
if (killReason && killReason !== "terminal-reap" && killReason !== "protocol-error")
|
|
544
|
+
return;
|
|
545
|
+
protocolError = message;
|
|
546
|
+
if (!killReason && !setKillReason("protocol-error"))
|
|
547
|
+
return;
|
|
548
|
+
clearTerminalCandidate();
|
|
549
|
+
if (idleTimer) {
|
|
550
|
+
clearTimeout(idleTimer);
|
|
551
|
+
idleTimer = undefined;
|
|
552
|
+
}
|
|
553
|
+
signalTree("SIGTERM");
|
|
554
|
+
scheduleForceKill();
|
|
555
|
+
};
|
|
556
|
+
const notifyCompletionCommit = () => {
|
|
557
|
+
if (completionNotified)
|
|
558
|
+
return;
|
|
559
|
+
completionNotified = true;
|
|
560
|
+
try {
|
|
561
|
+
opts.onTerminalCommit?.();
|
|
562
|
+
}
|
|
563
|
+
catch { /* internal observer is fail-open */ }
|
|
564
|
+
};
|
|
565
|
+
const canCommitTerminal = () => {
|
|
566
|
+
if (!completionPolicy)
|
|
567
|
+
return false;
|
|
568
|
+
try {
|
|
569
|
+
return completionPolicy.canCommitTerminal(acc);
|
|
570
|
+
}
|
|
571
|
+
catch (error) {
|
|
572
|
+
failProtocol(`Completion policy failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
573
|
+
return false;
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
const commitTerminal = (epoch) => {
|
|
577
|
+
// A cleared timer may already be queued. The epoch is the CAS token that
|
|
578
|
+
// prevents it from committing a revoked candidate.
|
|
579
|
+
if (settled || terminalCommitted || !terminalCandidate || epoch !== candidateEpoch ||
|
|
580
|
+
wasAborted || protocolError || policyFatal || killReason)
|
|
581
|
+
return;
|
|
582
|
+
if (buffer.trim()) {
|
|
583
|
+
failProtocol("Subagent emitted malformed or truncated JSON after its terminal event");
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (!canCommitTerminal()) {
|
|
587
|
+
clearTerminalCandidate();
|
|
588
|
+
armIdle();
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
terminalCommitted = true;
|
|
592
|
+
terminalCandidate = false;
|
|
593
|
+
setKillReason("terminal-reap");
|
|
594
|
+
reapedAfterTerminal = true;
|
|
595
|
+
notifyCompletionCommit();
|
|
596
|
+
const diagnostic = `[taskflow] Child produced terminal output but did not exit after ${completionPolicy?.terminalGraceMs ?? 0}ms; ` +
|
|
597
|
+
"reaped process tree and accepted the completed result.";
|
|
598
|
+
result.stderr += `${result.stderr && !result.stderr.endsWith("\n") ? "\n" : ""}${diagnostic}\n`;
|
|
599
|
+
signalTree("SIGTERM");
|
|
600
|
+
scheduleForceKill();
|
|
601
|
+
};
|
|
602
|
+
const beginTerminalCandidate = () => {
|
|
603
|
+
if (!completionPolicy || terminalCommitted || settled || killReason)
|
|
604
|
+
return;
|
|
605
|
+
if (!canCommitTerminal()) {
|
|
606
|
+
clearTerminalCandidate();
|
|
607
|
+
armIdle();
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
terminalCandidate = true;
|
|
611
|
+
const epoch = ++candidateEpoch;
|
|
612
|
+
if (idleTimer) {
|
|
613
|
+
clearTimeout(idleTimer);
|
|
614
|
+
idleTimer = undefined;
|
|
615
|
+
}
|
|
616
|
+
if (terminalTimer)
|
|
617
|
+
clearTimeout(terminalTimer);
|
|
618
|
+
terminalTimer = setTimeout(() => {
|
|
619
|
+
terminalTimer = undefined;
|
|
620
|
+
// Let already-readable stdout callbacks run before the linearization
|
|
621
|
+
// point so lifecycle activity cannot lose to a same-tick timer.
|
|
622
|
+
setImmediate(() => commitTerminal(epoch));
|
|
623
|
+
}, completionPolicy.terminalGraceMs);
|
|
624
|
+
terminalTimer.unref();
|
|
625
|
+
};
|
|
328
626
|
const processLine = (line) => {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
//
|
|
332
|
-
//
|
|
627
|
+
if (!line.trim() || protocolError)
|
|
628
|
+
return;
|
|
629
|
+
// Every supported host advertises a JSON/NDJSON stream. Treat malformed
|
|
630
|
+
// records as a protocol failure: silently dropping them can turn a
|
|
631
|
+
// truncated provider error into a successful phase with empty output.
|
|
632
|
+
let event;
|
|
333
633
|
try {
|
|
334
|
-
|
|
335
|
-
if (live && opts.onLive)
|
|
336
|
-
opts.onLive(live);
|
|
634
|
+
event = JSON.parse(line);
|
|
337
635
|
}
|
|
338
636
|
catch {
|
|
339
|
-
|
|
637
|
+
failProtocol("Subagent emitted malformed or truncated JSON output");
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
let live;
|
|
641
|
+
try {
|
|
642
|
+
live = foldLine(acc, line);
|
|
643
|
+
}
|
|
644
|
+
catch (error) {
|
|
645
|
+
failProtocol(`Subagent output parser failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
// onLive is a user callback and remains fail-open; parser failures above
|
|
649
|
+
// are part of the transport contract and therefore fail closed.
|
|
650
|
+
if (live && opts.onLive) {
|
|
651
|
+
try {
|
|
652
|
+
opts.onLive(live);
|
|
653
|
+
}
|
|
654
|
+
catch { /* user callback must not sink run */ }
|
|
655
|
+
}
|
|
656
|
+
if (!completionPolicy || terminalCommitted) {
|
|
657
|
+
armIdle();
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
let classification;
|
|
661
|
+
try {
|
|
662
|
+
classification = completionPolicy.classifyEvent(acc, event);
|
|
663
|
+
}
|
|
664
|
+
catch (error) {
|
|
665
|
+
failProtocol(`Completion policy failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
switch (classification) {
|
|
669
|
+
case "terminal-candidate":
|
|
670
|
+
beginTerminalCandidate();
|
|
671
|
+
break;
|
|
672
|
+
case "fatal":
|
|
673
|
+
if (setKillReason("fatal-error")) {
|
|
674
|
+
policyFatal = acc.fatalError ?? "Subagent emitted a fatal terminal event";
|
|
675
|
+
if (idleTimer) {
|
|
676
|
+
clearTimeout(idleTimer);
|
|
677
|
+
idleTimer = undefined;
|
|
678
|
+
}
|
|
679
|
+
signalTree("SIGTERM");
|
|
680
|
+
scheduleForceKill();
|
|
681
|
+
}
|
|
682
|
+
clearTerminalCandidate();
|
|
683
|
+
break;
|
|
684
|
+
case "activity":
|
|
685
|
+
clearTerminalCandidate();
|
|
686
|
+
armIdle();
|
|
687
|
+
break;
|
|
688
|
+
case "ignore":
|
|
689
|
+
break;
|
|
340
690
|
}
|
|
341
691
|
};
|
|
342
692
|
proc.stdout.on("data", (data) => {
|
|
343
|
-
|
|
344
|
-
|
|
693
|
+
// StringDecoder preserves multi-byte UTF-8 characters split across
|
|
694
|
+
// arbitrary pipe chunks. Candidate revocation is event-semantic below:
|
|
695
|
+
// metadata/heartbeat records classified as `ignore` must not disable both
|
|
696
|
+
// terminal grace and the ordinary idle watchdog.
|
|
697
|
+
buffer += stdoutDecoder.write(data);
|
|
698
|
+
if (Buffer.byteLength(buffer) > MAX_STDOUT_LINE_BYTES && !buffer.includes("\n")) {
|
|
699
|
+
// Drop the retained bytes before terminating so the bound remains true
|
|
700
|
+
// even while a non-cooperative child takes time to die.
|
|
701
|
+
buffer = "";
|
|
702
|
+
failProtocol(`Subagent emitted an unterminated stdout record larger than ${MAX_STDOUT_LINE_BYTES} bytes`);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
345
705
|
const lines = buffer.split("\n");
|
|
346
706
|
buffer = lines.pop() || "";
|
|
347
|
-
for (const line of lines)
|
|
707
|
+
for (const line of lines) {
|
|
708
|
+
if (Buffer.byteLength(line) > MAX_STDOUT_LINE_BYTES) {
|
|
709
|
+
failProtocol(`Subagent emitted a stdout record larger than ${MAX_STDOUT_LINE_BYTES} bytes`);
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
348
712
|
processLine(line);
|
|
713
|
+
}
|
|
714
|
+
if (!completionPolicy)
|
|
715
|
+
armIdle();
|
|
349
716
|
});
|
|
350
|
-
const
|
|
717
|
+
const STDERR_MAX_BYTES = 64 * 1024;
|
|
718
|
+
let stderrBytes = 0;
|
|
351
719
|
let stderrCapped = false;
|
|
352
720
|
proc.stderr.on("data", (data) => {
|
|
721
|
+
// Diagnostics are real child activity too. A CLI that is actively
|
|
722
|
+
// reporting provider retries on stderr must not be killed as idle.
|
|
723
|
+
// stderr is activity while running, but after a terminal candidate it
|
|
724
|
+
// neither revokes nor prolongs the bounded grace window.
|
|
725
|
+
if (!terminalCandidate && !terminalCommitted)
|
|
726
|
+
armIdle();
|
|
353
727
|
if (!stderrCapped) {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
728
|
+
const remaining = STDERR_MAX_BYTES - stderrBytes;
|
|
729
|
+
const retained = data.subarray(0, Math.max(0, remaining));
|
|
730
|
+
if (retained.length > 0) {
|
|
731
|
+
result.stderr += stderrDecoder.write(retained);
|
|
732
|
+
stderrBytes += retained.length;
|
|
733
|
+
}
|
|
734
|
+
if (data.length > retained.length) {
|
|
735
|
+
result.stderr += "\n[...stderr truncated at 64KB]";
|
|
357
736
|
stderrCapped = true;
|
|
358
737
|
}
|
|
359
738
|
}
|
|
360
739
|
});
|
|
361
|
-
|
|
740
|
+
// `close` waits for inherited stdio handles. A direct CLI can exit while a
|
|
741
|
+
// background descendant still owns stdout/stderr, so reap the group at the
|
|
742
|
+
// earlier `exit` boundary; `close` remains the point where buffered output
|
|
743
|
+
// is folded and the result is settled.
|
|
744
|
+
proc.once("exit", () => {
|
|
362
745
|
if (proc.pid)
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
746
|
+
killProcessTree(proc.pid, "SIGKILL", proc);
|
|
747
|
+
});
|
|
748
|
+
const finish = (code, signal) => {
|
|
749
|
+
if (settled)
|
|
750
|
+
return;
|
|
751
|
+
// Flush decoders and classify the final unterminated record before
|
|
752
|
+
// settling. Any force-kill timer created by a malformed tail is cleared
|
|
753
|
+
// below, so no signal can fire after this Promise resolves.
|
|
754
|
+
buffer += stdoutDecoder.end();
|
|
755
|
+
if (!stderrCapped)
|
|
756
|
+
result.stderr += stderrDecoder.end();
|
|
757
|
+
if (buffer.trim() && killReason !== "abort" && killReason !== "idle-timeout" && killReason !== "fatal-error") {
|
|
366
758
|
processLine(buffer);
|
|
367
|
-
|
|
759
|
+
}
|
|
760
|
+
settled = true;
|
|
761
|
+
// The direct CLI may intentionally or accidentally leave background
|
|
762
|
+
// descendants behind. A phase boundary is also a process-tree boundary:
|
|
763
|
+
// always reap the group before returning, not only on abort/idle timeout.
|
|
764
|
+
// Otherwise detached grandchildren can keep mutating the workspace after
|
|
765
|
+
// the phase has been persisted as complete.
|
|
766
|
+
if (proc.pid)
|
|
767
|
+
killProcessTree(proc.pid, "SIGKILL", proc);
|
|
768
|
+
if (proc.pid)
|
|
769
|
+
unregisterProcessTree(proc.pid);
|
|
770
|
+
clearTimers();
|
|
771
|
+
removeAbortListener();
|
|
772
|
+
if (signal)
|
|
368
773
|
killedBySignal = signal;
|
|
369
|
-
|
|
774
|
+
let terminalValid = !completionPolicy;
|
|
775
|
+
if (completionPolicy) {
|
|
776
|
+
try {
|
|
777
|
+
terminalValid = completionPolicy.canCommitTerminal(acc);
|
|
778
|
+
}
|
|
779
|
+
catch (error) {
|
|
780
|
+
protocolError ??= `Completion policy failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
if (code === 0 && !signal && !protocolError && !policyFatal && !acc.fatalError &&
|
|
784
|
+
!wasAborted && !idleTimedOut &&
|
|
785
|
+
terminalValid)
|
|
786
|
+
notifyCompletionCommit();
|
|
787
|
+
resolve(code);
|
|
788
|
+
};
|
|
789
|
+
proc.on("close", (code, signal) => {
|
|
790
|
+
finish(code ?? 0, code === null ? signal : undefined);
|
|
370
791
|
});
|
|
371
792
|
proc.on("error", (err) => {
|
|
372
|
-
clearTimers();
|
|
373
|
-
if (proc.pid)
|
|
374
|
-
activeChildren.delete(proc.pid); // defensive: close usually fires after error, but don't rely on it
|
|
375
793
|
if (!result.stderr)
|
|
376
794
|
result.stderr = err.message;
|
|
377
795
|
if (!result.errorMessage)
|
|
378
796
|
result.errorMessage = err.message;
|
|
379
|
-
|
|
797
|
+
finish(1);
|
|
380
798
|
});
|
|
381
799
|
if (opts.signal) {
|
|
382
800
|
const kill = () => {
|
|
801
|
+
// Terminal commit is the completion linearization point. A later outer
|
|
802
|
+
// timeout/user abort cannot retroactively change a completed task.
|
|
803
|
+
if (terminalCommitted || settled)
|
|
804
|
+
return;
|
|
805
|
+
if (!setKillReason("abort"))
|
|
806
|
+
return;
|
|
383
807
|
wasAborted = true;
|
|
384
808
|
// Disarm the idle watchdog first: otherwise, if the idle timer fires
|
|
385
809
|
// between this SIGTERM and the close event, `idleTimedOut` would be
|
|
386
810
|
// set and the post-exit classify chain (which checks idle BEFORE
|
|
387
811
|
// abort) would misreport a user abort as an idle stall.
|
|
388
812
|
clearTimers();
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
forceKill.unref();
|
|
813
|
+
signalTree("SIGTERM");
|
|
814
|
+
scheduleForceKill();
|
|
392
815
|
};
|
|
393
816
|
if (opts.signal.aborted)
|
|
394
817
|
kill();
|
|
395
|
-
else
|
|
818
|
+
else {
|
|
396
819
|
opts.signal.addEventListener("abort", kill, { once: true });
|
|
820
|
+
removeAbortListener = () => {
|
|
821
|
+
opts.signal?.removeEventListener("abort", kill);
|
|
822
|
+
removeAbortListener = () => { };
|
|
823
|
+
};
|
|
824
|
+
}
|
|
397
825
|
}
|
|
398
826
|
});
|
|
399
827
|
result.exitCode = exitCode;
|
|
400
828
|
result.usage = acc.usage;
|
|
401
829
|
result.model = acc.model;
|
|
402
830
|
result.output = acc.finalText;
|
|
403
|
-
if (
|
|
831
|
+
if (protocolError) {
|
|
832
|
+
result.exitCode = result.exitCode || 1;
|
|
833
|
+
result.stopReason = "error";
|
|
834
|
+
result.errorMessage = protocolError;
|
|
835
|
+
}
|
|
836
|
+
else if (wasAborted) {
|
|
837
|
+
result.stopReason = "aborted";
|
|
838
|
+
result.errorMessage = "Subagent was aborted";
|
|
839
|
+
}
|
|
840
|
+
else if (idleTimedOut) {
|
|
841
|
+
result.stopReason = "error";
|
|
842
|
+
result.idleTimeout = true;
|
|
843
|
+
result.errorMessage = `Subagent stalled: no output for ${Math.round(idleMs / 1000)}s (idle timeout) — killed`;
|
|
844
|
+
}
|
|
845
|
+
else if (acc.fatalError || policyFatal) {
|
|
404
846
|
result.exitCode = result.exitCode || 1;
|
|
405
847
|
result.stopReason = "error";
|
|
406
|
-
result.errorMessage = acc.fatalError;
|
|
848
|
+
result.errorMessage = acc.fatalError ?? policyFatal;
|
|
849
|
+
}
|
|
850
|
+
else if (terminalCommitted) {
|
|
851
|
+
// A signal caused by our own terminal reap is a successful controlled
|
|
852
|
+
// shutdown, regardless of the OS exit code/signal encoding.
|
|
853
|
+
result.exitCode = 0;
|
|
854
|
+
result.stopReason = "end";
|
|
407
855
|
}
|
|
408
856
|
else {
|
|
409
|
-
result.stopReason = exitCode === 0 ? "end" : "error";
|
|
857
|
+
result.stopReason = acc.stopReason ?? (exitCode === 0 ? "end" : "error");
|
|
410
858
|
}
|
|
411
|
-
if (
|
|
859
|
+
if (!isFailed(result) && opts.requireTerminalEvent && !acc.terminalSeen) {
|
|
412
860
|
result.exitCode = 1;
|
|
413
861
|
result.stopReason = "error";
|
|
414
|
-
result.errorMessage = `Subagent
|
|
862
|
+
result.errorMessage = `Subagent stream ended before ${opts.terminalEventLabel ?? "the terminal event"}`;
|
|
415
863
|
}
|
|
416
|
-
if (idleTimedOut
|
|
864
|
+
if (exitCode === 0 && killedBySignal && !idleTimedOut && !wasAborted && !protocolError &&
|
|
865
|
+
!terminalCommitted && killReason !== "fatal-error") {
|
|
866
|
+
result.exitCode = 1;
|
|
417
867
|
result.stopReason = "error";
|
|
418
|
-
result.
|
|
419
|
-
result.errorMessage = `Subagent stalled: no output for ${Math.round(idleMs / 1000)}s (idle timeout) — killed`;
|
|
868
|
+
result.errorMessage = `Subagent killed by signal ${killedBySignal}`;
|
|
420
869
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
870
|
+
result.completionSource = protocolError
|
|
871
|
+
? "protocol-error"
|
|
872
|
+
: idleTimedOut
|
|
873
|
+
? "idle-timeout"
|
|
874
|
+
: wasAborted
|
|
875
|
+
? "abort"
|
|
876
|
+
: terminalCommitted
|
|
877
|
+
? "terminal-reap"
|
|
878
|
+
: killedBySignal && killReason !== "fatal-error"
|
|
879
|
+
? "external-signal"
|
|
880
|
+
: "process-exit";
|
|
881
|
+
if (terminalCommitted) {
|
|
882
|
+
result.reapedAfterTerminal = reapedAfterTerminal;
|
|
883
|
+
result.terminalGraceMs = completionPolicy?.terminalGraceMs;
|
|
884
|
+
}
|
|
885
|
+
// A zero exit with no answer is not evidence of successful agent work. It is
|
|
886
|
+
// the characteristic outcome of a truncated/unknown host stream whose lines
|
|
887
|
+
// were syntactically valid but never contained a terminal answer.
|
|
888
|
+
if (!isFailed(result) && !result.output.trim()) {
|
|
889
|
+
result.exitCode = 1;
|
|
890
|
+
result.stopReason = "error";
|
|
891
|
+
result.errorMessage = "Subagent exited successfully without a final output";
|
|
424
892
|
}
|
|
425
893
|
if (isFailed(result)) {
|
|
426
894
|
if (!result.output) {
|