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.
Files changed (193) hide show
  1. package/README.md +248 -808
  2. package/dist/agents.d.ts +12 -0
  3. package/dist/agents.d.ts.map +1 -1
  4. package/dist/agents.js +29 -1
  5. package/dist/agents.js.map +1 -1
  6. package/dist/cwd-bridge.d.ts +61 -0
  7. package/dist/cwd-bridge.d.ts.map +1 -0
  8. package/dist/cwd-bridge.js +136 -0
  9. package/dist/cwd-bridge.js.map +1 -0
  10. package/dist/detached-runner.js +31 -5
  11. package/dist/detached-runner.js.map +1 -1
  12. package/dist/deterministic.d.ts +8 -0
  13. package/dist/deterministic.d.ts.map +1 -1
  14. package/dist/deterministic.js +23 -1
  15. package/dist/deterministic.js.map +1 -1
  16. package/dist/exec/driver.d.ts +48 -0
  17. package/dist/exec/driver.d.ts.map +1 -0
  18. package/dist/exec/driver.js +410 -0
  19. package/dist/exec/driver.js.map +1 -0
  20. package/dist/exec/events.d.ts +76 -0
  21. package/dist/exec/events.d.ts.map +1 -0
  22. package/dist/exec/events.js +108 -0
  23. package/dist/exec/events.js.map +1 -0
  24. package/dist/exec/fold.d.ts +45 -0
  25. package/dist/exec/fold.d.ts.map +1 -0
  26. package/dist/exec/fold.js +122 -0
  27. package/dist/exec/fold.js.map +1 -0
  28. package/dist/exec/index.d.ts +17 -0
  29. package/dist/exec/index.d.ts.map +1 -0
  30. package/dist/exec/index.js +17 -0
  31. package/dist/exec/index.js.map +1 -0
  32. package/dist/exec/kernel-policy.d.ts +35 -0
  33. package/dist/exec/kernel-policy.d.ts.map +1 -0
  34. package/dist/exec/kernel-policy.js +184 -0
  35. package/dist/exec/kernel-policy.js.map +1 -0
  36. package/dist/exec/step-kinds.d.ts +41 -0
  37. package/dist/exec/step-kinds.d.ts.map +1 -0
  38. package/dist/exec/step-kinds.js +599 -0
  39. package/dist/exec/step-kinds.js.map +1 -0
  40. package/dist/exec/step.d.ts +94 -0
  41. package/dist/exec/step.d.ts.map +1 -0
  42. package/dist/exec/step.js +454 -0
  43. package/dist/exec/step.js.map +1 -0
  44. package/dist/flowir/canonical-hash.d.ts +101 -0
  45. package/dist/flowir/canonical-hash.d.ts.map +1 -0
  46. package/dist/flowir/canonical-hash.js +219 -0
  47. package/dist/flowir/canonical-hash.js.map +1 -0
  48. package/dist/flowir/compile.d.ts +49 -0
  49. package/dist/flowir/compile.d.ts.map +1 -0
  50. package/dist/flowir/compile.js +232 -0
  51. package/dist/flowir/compile.js.map +1 -0
  52. package/dist/flowir/cond.d.ts +76 -0
  53. package/dist/flowir/cond.d.ts.map +1 -0
  54. package/dist/flowir/cond.js +214 -0
  55. package/dist/flowir/cond.js.map +1 -0
  56. package/dist/flowir/index.d.ts +20 -23
  57. package/dist/flowir/index.d.ts.map +1 -1
  58. package/dist/flowir/index.js +47 -35
  59. package/dist/flowir/index.js.map +1 -1
  60. package/dist/flowir/meta.d.ts +4 -4
  61. package/dist/flowir/meta.d.ts.map +1 -1
  62. package/dist/flowir/phasefp.d.ts.map +1 -1
  63. package/dist/flowir/phasefp.js +13 -1
  64. package/dist/flowir/phasefp.js.map +1 -1
  65. package/dist/flowir/schema.d.ts +260 -0
  66. package/dist/flowir/schema.d.ts.map +1 -0
  67. package/dist/flowir/schema.js +234 -0
  68. package/dist/flowir/schema.js.map +1 -0
  69. package/dist/flowir/translate.d.ts.map +1 -1
  70. package/dist/flowir/translate.js +3 -0
  71. package/dist/flowir/translate.js.map +1 -1
  72. package/dist/host/runner-types.d.ts +19 -0
  73. package/dist/host/runner-types.d.ts.map +1 -1
  74. package/dist/index.d.ts +4 -0
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +8 -0
  77. package/dist/index.js.map +1 -1
  78. package/dist/interpolate.d.ts +4 -0
  79. package/dist/interpolate.d.ts.map +1 -1
  80. package/dist/interpolate.js +14 -0
  81. package/dist/interpolate.js.map +1 -1
  82. package/dist/rates.d.ts +121 -0
  83. package/dist/rates.d.ts.map +1 -0
  84. package/dist/rates.js +187 -0
  85. package/dist/rates.js.map +1 -0
  86. package/dist/replay.d.ts +43 -50
  87. package/dist/replay.d.ts.map +1 -1
  88. package/dist/replay.js +629 -19
  89. package/dist/replay.js.map +1 -1
  90. package/dist/resources/authority.d.ts +35 -0
  91. package/dist/resources/authority.d.ts.map +1 -0
  92. package/dist/resources/authority.js +67 -0
  93. package/dist/resources/authority.js.map +1 -0
  94. package/dist/resources/backend.d.ts +302 -0
  95. package/dist/resources/backend.d.ts.map +1 -0
  96. package/dist/resources/backend.js +16 -0
  97. package/dist/resources/backend.js.map +1 -0
  98. package/dist/resources/baseline.d.ts +116 -0
  99. package/dist/resources/baseline.d.ts.map +1 -0
  100. package/dist/resources/baseline.js +447 -0
  101. package/dist/resources/baseline.js.map +1 -0
  102. package/dist/resources/canonical-json.d.ts +7 -0
  103. package/dist/resources/canonical-json.d.ts.map +1 -0
  104. package/dist/resources/canonical-json.js +75 -0
  105. package/dist/resources/canonical-json.js.map +1 -0
  106. package/dist/resources/errors.d.ts +23 -0
  107. package/dist/resources/errors.d.ts.map +1 -0
  108. package/dist/resources/errors.js +89 -0
  109. package/dist/resources/errors.js.map +1 -0
  110. package/dist/resources/execution.d.ts +90 -0
  111. package/dist/resources/execution.d.ts.map +1 -0
  112. package/dist/resources/execution.js +581 -0
  113. package/dist/resources/execution.js.map +1 -0
  114. package/dist/resources/index.d.ts +15 -0
  115. package/dist/resources/index.d.ts.map +1 -0
  116. package/dist/resources/index.js +15 -0
  117. package/dist/resources/index.js.map +1 -0
  118. package/dist/resources/journal.d.ts +138 -0
  119. package/dist/resources/journal.d.ts.map +1 -0
  120. package/dist/resources/journal.js +438 -0
  121. package/dist/resources/journal.js.map +1 -0
  122. package/dist/resources/leases.d.ts +51 -0
  123. package/dist/resources/leases.d.ts.map +1 -0
  124. package/dist/resources/leases.js +354 -0
  125. package/dist/resources/leases.js.map +1 -0
  126. package/dist/resources/permits.d.ts +52 -0
  127. package/dist/resources/permits.d.ts.map +1 -0
  128. package/dist/resources/permits.js +240 -0
  129. package/dist/resources/permits.js.map +1 -0
  130. package/dist/resources/persistence.d.ts +63 -0
  131. package/dist/resources/persistence.d.ts.map +1 -0
  132. package/dist/resources/persistence.js +522 -0
  133. package/dist/resources/persistence.js.map +1 -0
  134. package/dist/resources/registry.d.ts +56 -0
  135. package/dist/resources/registry.d.ts.map +1 -0
  136. package/dist/resources/registry.js +139 -0
  137. package/dist/resources/registry.js.map +1 -0
  138. package/dist/resources/resolve.d.ts +49 -0
  139. package/dist/resources/resolve.d.ts.map +1 -0
  140. package/dist/resources/resolve.js +309 -0
  141. package/dist/resources/resolve.js.map +1 -0
  142. package/dist/resources/sandbox.d.ts +72 -0
  143. package/dist/resources/sandbox.d.ts.map +1 -0
  144. package/dist/resources/sandbox.js +952 -0
  145. package/dist/resources/sandbox.js.map +1 -0
  146. package/dist/resources/schema.d.ts +195 -0
  147. package/dist/resources/schema.d.ts.map +1 -0
  148. package/dist/resources/schema.js +231 -0
  149. package/dist/resources/schema.js.map +1 -0
  150. package/dist/resources/types.d.ts +36 -0
  151. package/dist/resources/types.d.ts.map +1 -0
  152. package/dist/resources/types.js +75 -0
  153. package/dist/resources/types.js.map +1 -0
  154. package/dist/runner-core.d.ts +53 -0
  155. package/dist/runner-core.d.ts.map +1 -1
  156. package/dist/runner-core.js +521 -53
  157. package/dist/runner-core.js.map +1 -1
  158. package/dist/runtime/phases/approval.d.ts +23 -0
  159. package/dist/runtime/phases/approval.d.ts.map +1 -0
  160. package/dist/runtime/phases/approval.js +38 -0
  161. package/dist/runtime/phases/approval.js.map +1 -0
  162. package/dist/runtime/phases/expand.d.ts +29 -0
  163. package/dist/runtime/phases/expand.d.ts.map +1 -0
  164. package/dist/runtime/phases/expand.js +126 -0
  165. package/dist/runtime/phases/expand.js.map +1 -0
  166. package/dist/runtime/phases/parallel.d.ts +26 -0
  167. package/dist/runtime/phases/parallel.d.ts.map +1 -0
  168. package/dist/runtime/phases/parallel.js +15 -0
  169. package/dist/runtime/phases/parallel.js.map +1 -0
  170. package/dist/runtime/phases/race.d.ts +31 -0
  171. package/dist/runtime/phases/race.d.ts.map +1 -0
  172. package/dist/runtime/phases/race.js +220 -0
  173. package/dist/runtime/phases/race.js.map +1 -0
  174. package/dist/runtime/phases/script.d.ts +38 -0
  175. package/dist/runtime/phases/script.d.ts.map +1 -0
  176. package/dist/runtime/phases/script.js +177 -0
  177. package/dist/runtime/phases/script.js.map +1 -0
  178. package/dist/runtime.d.ts +68 -16
  179. package/dist/runtime.d.ts.map +1 -1
  180. package/dist/runtime.js +1091 -337
  181. package/dist/runtime.js.map +1 -1
  182. package/dist/schema.d.ts +104 -9
  183. package/dist/schema.d.ts.map +1 -1
  184. package/dist/schema.js +249 -32
  185. package/dist/schema.js.map +1 -1
  186. package/dist/store.d.ts +13 -0
  187. package/dist/store.d.ts.map +1 -1
  188. package/dist/store.js.map +1 -1
  189. package/dist/trace.d.ts +7 -1
  190. package/dist/trace.d.ts.map +1 -1
  191. package/dist/trace.js +33 -26
  192. package/dist/trace.js.map +1 -1
  193. package/package.json +7 -5
@@ -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
- const killAllChildren = () => {
244
- for (const pid of activeChildren) {
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
- process.kill(pid, "SIGKILL");
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, { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"], env });
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
- activeChildren.add(proc.pid);
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
- proc.kill("SIGTERM");
316
- forceTimer = setTimeout(() => proc.kill("SIGKILL"), 5000);
317
- forceTimer.unref();
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
- // A throwing foldLine (malformed/hostile stream line) or a throwing onLive
330
- // callback must NEVER crash the run — this is the fail-open backstop that
331
- // turns a bad line into a skipped line. It also honours the AGENTS.md
332
- // invariant that user callbacks are wrapped in try/catch.
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
- const live = foldLine(acc, line);
335
- if (live && opts.onLive)
336
- opts.onLive(live);
634
+ event = JSON.parse(line);
337
635
  }
338
636
  catch {
339
- /* malformed stream line / throwing callback — skip, never crash */
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
- armIdle();
344
- buffer += data.toString();
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 STDERR_MAX_LEN = 64 * 1024;
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
- result.stderr += data.toString();
355
- if (result.stderr.length >= STDERR_MAX_LEN) {
356
- result.stderr = result.stderr.slice(0, STDERR_MAX_LEN) + "\n[...stderr truncated at 64KB]";
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
- proc.on("close", (code, signal) => {
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
- activeChildren.delete(proc.pid);
364
- clearTimers();
365
- if (buffer.trim())
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
- if (code === null && signal)
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
- resolve(code ?? 0);
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
- resolve(1);
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
- proc.kill("SIGTERM");
390
- const forceKill = setTimeout(() => proc.kill("SIGKILL"), 5000);
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 (acc.fatalError) {
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 (exitCode === 0 && killedBySignal && !idleTimedOut && !wasAborted) {
859
+ if (!isFailed(result) && opts.requireTerminalEvent && !acc.terminalSeen) {
412
860
  result.exitCode = 1;
413
861
  result.stopReason = "error";
414
- result.errorMessage = `Subagent killed by signal ${killedBySignal}`;
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.idleTimeout = true;
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
- else if (wasAborted) {
422
- result.stopReason = "aborted";
423
- result.errorMessage = "Subagent was aborted";
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) {