@tagma/sdk 0.6.6 → 0.6.8

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/src/engine.ts CHANGED
@@ -30,7 +30,13 @@ import {
30
30
  renderInputsBlock,
31
31
  renderOutputSchemaBlock,
32
32
  } from './prompt-doc';
33
- import { extractTaskOutputs, resolveTaskInputs, substituteInputs } from './ports';
33
+ import {
34
+ extractTaskOutputs,
35
+ inferPromptPorts,
36
+ resolveTaskInputs,
37
+ substituteInputs,
38
+ } from './ports';
39
+ import type { TaskPorts } from './types';
34
40
  import {
35
41
  executeHook,
36
42
  buildPipelineStartContext,
@@ -393,6 +399,20 @@ export async function runPipeline(
393
399
  // just before a task runs, so every subsequent task_update event can
394
400
  // echo them to the UI without re-resolving.
395
401
  const resolvedInputsMap = new Map<string, Readonly<Record<string, unknown>>>();
402
+ // Reverse adjacency: for each task, list the direct-downstream task ids
403
+ // (tasks whose `depends_on` includes this one after DAG qualification).
404
+ // Computed once up front so Prompt-task port inference — which needs
405
+ // "what Commands directly consume me?" — is O(1) instead of O(tasks)
406
+ // per Prompt start. `dag.nodes` only exposes forward edges via
407
+ // `dependsOn`, so we build this locally.
408
+ const directDownstreams = new Map<string, string[]>();
409
+ for (const [id] of dag.nodes) directDownstreams.set(id, []);
410
+ for (const [id, node] of dag.nodes) {
411
+ for (const upstream of node.dependsOn) {
412
+ const list = directDownstreams.get(upstream);
413
+ if (list) list.push(id);
414
+ }
415
+ }
396
416
 
397
417
  // Pipeline timeout + abort reason tracking.
398
418
  //
@@ -753,7 +773,83 @@ export async function runPipeline(
753
773
  // Resolution runs even for tasks that declare no ports — the call
754
774
  // is cheap and returns `{kind: 'ready', inputs: {}}` in that case,
755
775
  // which downstream code handles uniformly.
756
- const inputResolution = resolveTaskInputs(task, outputValuesMap, node.dependsOn);
776
+ //
777
+ // Prompt Tasks have no declared ports — their I/O contract is
778
+ // inferred from direct-neighbor Command Tasks (see ports.ts:
779
+ // `inferPromptPorts`). We synthesize a `TaskPorts` object and
780
+ // feed it into the same resolve/substitute/render/extract
781
+ // pipeline the Command path uses. Collisions that a Prompt can't
782
+ // disambiguate (same input name on two upstreams, incompatible
783
+ // downstream output types) block the task with a clear message.
784
+ const isPromptTask = task.prompt !== undefined && task.command === undefined;
785
+ let effectivePorts: TaskPorts | undefined = task.ports;
786
+ let promptInferenceBlockReason: string | null = null;
787
+
788
+ if (isPromptTask) {
789
+ const inference = inferPromptPorts({
790
+ upstreams: node.dependsOn.map((upstreamId) => {
791
+ const upstream = dag.nodes.get(upstreamId);
792
+ const isUpstreamCommand = !!upstream?.task.command;
793
+ return {
794
+ taskId: upstreamId,
795
+ outputs: isUpstreamCommand ? upstream?.task.ports?.outputs : undefined,
796
+ };
797
+ }),
798
+ downstreams: (directDownstreams.get(taskId) ?? []).map((downstreamId) => {
799
+ const downstream = dag.nodes.get(downstreamId);
800
+ const isDownstreamCommand = !!downstream?.task.command;
801
+ return {
802
+ taskId: downstreamId,
803
+ inputs: isDownstreamCommand ? downstream?.task.ports?.inputs : undefined,
804
+ };
805
+ }),
806
+ });
807
+ effectivePorts = inference.ports;
808
+ if (inference.inputConflicts.length > 0 || inference.outputConflicts.length > 0) {
809
+ const lines: string[] = [];
810
+ for (const c of inference.inputConflicts) lines.push(c.reason);
811
+ for (const c of inference.outputConflicts) lines.push(c.reason);
812
+ promptInferenceBlockReason = lines.join('\n');
813
+ }
814
+ }
815
+
816
+ if (promptInferenceBlockReason !== null) {
817
+ log.error(
818
+ `[task:${taskId}]`,
819
+ `blocked — prompt port inference failed:\n${promptInferenceBlockReason}`,
820
+ );
821
+ state.result = {
822
+ exitCode: -1,
823
+ stdout: '',
824
+ stderr: `[engine] prompt port inference failed:\n${promptInferenceBlockReason}`,
825
+ stdoutPath: null,
826
+ stderrPath: null,
827
+ durationMs: 0,
828
+ sessionId: null,
829
+ normalizedOutput: null,
830
+ failureKind: 'spawn_error',
831
+ outputs: null,
832
+ };
833
+ state.finishedAt = nowISO();
834
+ setTaskStatus(taskId, 'blocked');
835
+ try {
836
+ await fireHook(taskId, 'task_failure');
837
+ } catch (hookErr) {
838
+ log.error(
839
+ `[task:${taskId}]`,
840
+ `hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`,
841
+ );
842
+ }
843
+ if (getOnFailure(taskId) === 'stop_all') applyStopAll(node.track.id);
844
+ return;
845
+ }
846
+
847
+ // Feed effective ports into `resolveTaskInputs` by shallow-cloning
848
+ // the task. Prompt tasks get the inferred ports; Command tasks are
849
+ // unchanged (effectivePorts === task.ports).
850
+ const taskForResolve: TaskConfig =
851
+ effectivePorts === task.ports ? task : { ...task, ports: effectivePorts };
852
+ const inputResolution = resolveTaskInputs(taskForResolve, outputValuesMap, node.dependsOn);
757
853
  if (inputResolution.kind === 'blocked') {
758
854
  log.error(
759
855
  `[task:${taskId}]`,
@@ -792,10 +888,11 @@ export async function runPipeline(
792
888
  `optional inputs unresolved (empty in placeholders): ${inputResolution.missingOptional.join(', ')}`,
793
889
  );
794
890
  }
795
- if (task.ports?.inputs && task.ports.inputs.length > 0) {
891
+ if (effectivePorts?.inputs && effectivePorts.inputs.length > 0) {
796
892
  log.debug(
797
893
  `[task:${taskId}]`,
798
- `resolved inputs: ${JSON.stringify(resolvedInputs)}`,
894
+ `resolved inputs: ${JSON.stringify(resolvedInputs)}` +
895
+ (isPromptTask ? ' (inferred from upstream Commands)' : ''),
799
896
  );
800
897
  }
801
898
 
@@ -888,11 +985,11 @@ export async function runPipeline(
888
985
  // matters: [Output Format] first (sets the deliverable), then
889
986
  // [Inputs] (the concrete data to operate on). Empty blocks are
890
987
  // filtered out — tasks without ports get no extra blocks at all.
891
- const outputFormatBlock = renderOutputSchemaBlock(task.ports?.outputs);
988
+ const outputFormatBlock = renderOutputSchemaBlock(effectivePorts?.outputs);
892
989
  if (outputFormatBlock) {
893
990
  doc = prependContext(doc, outputFormatBlock);
894
991
  }
895
- const inputsBlock = renderInputsBlock(task.ports?.inputs, resolvedInputs);
992
+ const inputsBlock = renderInputsBlock(effectivePorts?.inputs, resolvedInputs);
896
993
  if (inputsBlock) {
897
994
  doc = prependContext(doc, inputsBlock);
898
995
  }
@@ -996,6 +1093,13 @@ export async function runPipeline(
996
1093
  ...task,
997
1094
  prompt,
998
1095
  continue_from: node.resolvedContinueFrom,
1096
+ // Hand the driver the EFFECTIVE port schema rather than the
1097
+ // raw task.ports. For Prompt tasks this is the one inferred
1098
+ // from neighbor Commands; Command tasks are unchanged.
1099
+ // Drivers that introspect ports (e.g. to annotate a system
1100
+ // prompt with the I/O contract) otherwise saw `undefined`
1101
+ // for every prompt and had no way to know the contract.
1102
+ ports: effectivePorts,
999
1103
  };
1000
1104
  const driverCtx: DriverContext = {
1001
1105
  sessionMap,
@@ -1074,17 +1178,22 @@ export async function runPipeline(
1074
1178
  // through driver-specific logs.
1075
1179
  let extractedOutputs: Readonly<Record<string, unknown>> | null = null;
1076
1180
  if (terminalStatus === 'success') {
1181
+ // Prompt tasks use inferred ports (from direct-downstream Command
1182
+ // inputs); Command tasks use their declared ports. Either way,
1183
+ // `extractTaskOutputs` is a no-op when there are no declared
1184
+ // outputs to pull, so pre-ports tasks pay nothing for this call.
1077
1185
  const extraction = extractTaskOutputs(
1078
- task.ports,
1186
+ effectivePorts,
1079
1187
  result.stdout,
1080
1188
  result.normalizedOutput,
1081
1189
  );
1082
- if (task.ports?.outputs && task.ports.outputs.length > 0) {
1190
+ if (effectivePorts?.outputs && effectivePorts.outputs.length > 0) {
1083
1191
  extractedOutputs = extraction.outputs;
1084
1192
  outputValuesMap.set(taskId, extraction.outputs);
1085
1193
  log.debug(
1086
1194
  `[task:${taskId}]`,
1087
- `extracted outputs: ${JSON.stringify(extraction.outputs)}`,
1195
+ `extracted outputs: ${JSON.stringify(extraction.outputs)}` +
1196
+ (isPromptTask ? ' (inferred from downstream Commands)' : ''),
1088
1197
  );
1089
1198
  if (extraction.diagnostic) {
1090
1199
  log.error(`[task:${taskId}]`, extraction.diagnostic);
package/src/ports.test.ts CHANGED
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
2
2
  import {
3
3
  extractInputReferences,
4
4
  extractTaskOutputs,
5
+ inferPromptPorts,
5
6
  resolveTaskInputs,
6
7
  substituteInputs,
7
8
  } from './ports';
@@ -299,3 +300,172 @@ describe('extractTaskOutputs', () => {
299
300
  expect(r.diagnostic).toContain('could not find a final-line JSON object');
300
301
  });
301
302
  });
303
+
304
+ // ─── inferPromptPorts ───────────────────────────────────────────────
305
+
306
+ describe('inferPromptPorts', () => {
307
+ test('inputs are taken from direct-upstream Command outputs', () => {
308
+ const r = inferPromptPorts({
309
+ upstreams: [
310
+ {
311
+ taskId: 't.up',
312
+ outputs: [
313
+ { name: 'city', type: 'string' },
314
+ { name: 'id', type: 'number' },
315
+ ],
316
+ },
317
+ ],
318
+ downstreams: [],
319
+ });
320
+ expect(r.inputConflicts).toEqual([]);
321
+ expect(r.outputConflicts).toEqual([]);
322
+ expect(r.ports.inputs).toHaveLength(2);
323
+ expect(r.ports.inputs?.map((p) => p.name).sort()).toEqual(['city', 'id']);
324
+ // Inferred inputs default to required: the LLM wouldn't see a real
325
+ // value if the upstream failed to produce one.
326
+ expect(r.ports.inputs?.every((p) => p.required === true)).toBe(true);
327
+ expect(r.ports.outputs).toBeUndefined();
328
+ });
329
+
330
+ test('outputs are taken from direct-downstream Command inputs', () => {
331
+ const r = inferPromptPorts({
332
+ upstreams: [],
333
+ downstreams: [
334
+ {
335
+ taskId: 't.down',
336
+ inputs: [
337
+ { name: 'greeting', type: 'string', required: true },
338
+ { name: 'target', type: 'string', default: 'world' },
339
+ ],
340
+ },
341
+ ],
342
+ });
343
+ expect(r.outputConflicts).toEqual([]);
344
+ expect(r.ports.outputs?.map((p) => p.name).sort()).toEqual(['greeting', 'target']);
345
+ // Outputs drop input-only fields (required, default, from).
346
+ for (const p of r.ports.outputs ?? []) {
347
+ expect(p).not.toHaveProperty('required');
348
+ expect(p).not.toHaveProperty('default');
349
+ expect(p).not.toHaveProperty('from');
350
+ }
351
+ expect(r.ports.inputs).toBeUndefined();
352
+ });
353
+
354
+ test('Prompt neighbors (outputs undefined) contribute nothing', () => {
355
+ const r = inferPromptPorts({
356
+ upstreams: [
357
+ { taskId: 't.up', outputs: undefined }, // Prompt upstream
358
+ ],
359
+ downstreams: [
360
+ { taskId: 't.down', inputs: undefined }, // Prompt downstream
361
+ ],
362
+ });
363
+ expect(r.ports).toEqual({});
364
+ expect(r.inputConflicts).toEqual([]);
365
+ expect(r.outputConflicts).toEqual([]);
366
+ });
367
+
368
+ test('two upstreams with the same output name produce an input conflict', () => {
369
+ const r = inferPromptPorts({
370
+ upstreams: [
371
+ { taskId: 't.a', outputs: [{ name: 'city', type: 'string' }] },
372
+ { taskId: 't.b', outputs: [{ name: 'city', type: 'string' }] },
373
+ ],
374
+ downstreams: [],
375
+ });
376
+ expect(r.inputConflicts).toHaveLength(1);
377
+ expect(r.inputConflicts[0]!.portName).toBe('city');
378
+ expect(r.inputConflicts[0]!.producers.map((p) => p.taskId).sort()).toEqual(['t.a', 't.b']);
379
+ expect(r.inputConflicts[0]!.reason).toMatch(/cannot disambiguate/);
380
+ });
381
+
382
+ test('two downstreams with compatible input types merge silently', () => {
383
+ const r = inferPromptPorts({
384
+ upstreams: [],
385
+ downstreams: [
386
+ {
387
+ taskId: 't.d1',
388
+ inputs: [{ name: 'date', type: 'string', required: true }],
389
+ },
390
+ {
391
+ taskId: 't.d2',
392
+ inputs: [{ name: 'date', type: 'string', required: false }],
393
+ },
394
+ ],
395
+ });
396
+ expect(r.outputConflicts).toEqual([]);
397
+ expect(r.ports.outputs).toHaveLength(1);
398
+ expect(r.ports.outputs![0]!.name).toBe('date');
399
+ expect(r.ports.outputs![0]!.type).toBe('string');
400
+ });
401
+
402
+ test('two downstreams with incompatible input types produce an output conflict', () => {
403
+ const r = inferPromptPorts({
404
+ upstreams: [],
405
+ downstreams: [
406
+ { taskId: 't.d1', inputs: [{ name: 'date', type: 'string' }] },
407
+ { taskId: 't.d2', inputs: [{ name: 'date', type: 'number' }] },
408
+ ],
409
+ });
410
+ expect(r.outputConflicts).toHaveLength(1);
411
+ expect(r.outputConflicts[0]!.portName).toBe('date');
412
+ expect(r.outputConflicts[0]!.reason).toMatch(/conflicting type requirements/);
413
+ });
414
+
415
+ test('enum ports with differing value sets are incompatible', () => {
416
+ const r = inferPromptPorts({
417
+ upstreams: [],
418
+ downstreams: [
419
+ {
420
+ taskId: 't.d1',
421
+ inputs: [{ name: 'bucket', type: 'enum', enum: ['a', 'b'] }],
422
+ },
423
+ {
424
+ taskId: 't.d2',
425
+ inputs: [{ name: 'bucket', type: 'enum', enum: ['a', 'c'] }],
426
+ },
427
+ ],
428
+ });
429
+ expect(r.outputConflicts).toHaveLength(1);
430
+ });
431
+
432
+ test('enum ports with identical value sets merge', () => {
433
+ const r = inferPromptPorts({
434
+ upstreams: [],
435
+ downstreams: [
436
+ {
437
+ taskId: 't.d1',
438
+ inputs: [{ name: 'bucket', type: 'enum', enum: ['a', 'b'] }],
439
+ },
440
+ {
441
+ taskId: 't.d2',
442
+ inputs: [{ name: 'bucket', type: 'enum', enum: ['b', 'a'] }], // different order, same set
443
+ },
444
+ ],
445
+ });
446
+ expect(r.outputConflicts).toEqual([]);
447
+ expect(r.ports.outputs).toHaveLength(1);
448
+ });
449
+
450
+ test('description and enum propagate from the first occurrence', () => {
451
+ const r = inferPromptPorts({
452
+ upstreams: [
453
+ {
454
+ taskId: 't.up',
455
+ outputs: [
456
+ {
457
+ name: 'kind',
458
+ type: 'enum',
459
+ enum: ['hot', 'cold'],
460
+ description: 'Weather kind',
461
+ },
462
+ ],
463
+ },
464
+ ],
465
+ downstreams: [],
466
+ });
467
+ const port = r.ports.inputs![0]!;
468
+ expect(port.description).toBe('Weather kind');
469
+ expect(port.enum).toEqual(['hot', 'cold']);
470
+ });
471
+ });
package/src/ports.ts CHANGED
@@ -1,6 +1,6 @@
1
- // ═══ Task ports: substitute / resolve / extract ═══
1
+ // ═══ Task ports: substitute / resolve / extract / infer ═══
2
2
  //
3
- // One module, three concerns, all keyed on `task.ports`:
3
+ // One module, four concerns, all keyed on `task.ports`:
4
4
  //
5
5
  // 1. `substituteInputs(text, inputs)` — expand `{{inputs.<name>}}` in
6
6
  // user-authored strings (command lines, prompts). Strict syntax, no
@@ -22,10 +22,19 @@
22
22
  // it. Prefer `normalizedOutput` for AI tasks, fall back to raw
23
23
  // stdout — command tasks only ever have stdout.
24
24
  //
25
+ // 4. `inferPromptPorts({upstreams, downstreams})` — Prompt Tasks do NOT
26
+ // declare ports; their I/O contract is inferred from direct-neighbor
27
+ // Command Tasks. This helper synthesizes a `TaskPorts` object the
28
+ // engine can feed into the three concerns above, and surfaces any
29
+ // collisions that block the task (same port name on two upstreams,
30
+ // incompatible types across downstreams, …). Prompt neighbors
31
+ // contribute zero structured I/O — they pass free text via
32
+ // `continue_from` / normalizedOutput instead.
33
+ //
25
34
  // Everything here is pure / deterministic so it can be reused by the CLI,
26
35
  // the editor (for preview/simulation), and the engine without side effects.
27
36
 
28
- import type { PortDef, TaskConfig, TaskPorts } from './types';
37
+ import type { PortDef, PortType, TaskConfig, TaskPorts } from './types';
29
38
 
30
39
  // ─── Template substitution ────────────────────────────────────────────
31
40
 
@@ -440,3 +449,221 @@ function safeParseJson(candidate: string): Record<string, unknown> | null {
440
449
  }
441
450
  return null;
442
451
  }
452
+
453
+ // ─── Prompt-task port inference ───────────────────────────────────────
454
+ //
455
+ // Prompt Tasks have no declared ports. The engine calls `inferPromptPorts`
456
+ // to synthesize one from the Task's direct DAG neighbors:
457
+ //
458
+ // - **inputs** are taken from the declared `outputs` of every direct
459
+ // upstream Command Task. The union of names becomes the Prompt's
460
+ // inferred inputs. Upstream Prompt neighbors contribute nothing —
461
+ // information flows between Prompts as free text through
462
+ // `continue_from` / normalizedOutput, not through port values.
463
+ //
464
+ // - **outputs** are taken from the declared `inputs` of every direct
465
+ // downstream Command Task. The union of names becomes the Prompt's
466
+ // inferred outputs, which drives the `[Output Format]` block that
467
+ // tells the LLM what JSON to emit. Downstream Prompt neighbors
468
+ // contribute nothing (they just consume free text).
469
+ //
470
+ // Collisions:
471
+ //
472
+ // - **Input collision**: two upstream Commands both export an output
473
+ // named `city`. Command→Command would let a downstream add
474
+ // `from: taskId.city` to pick one; Prompt Tasks have no port
475
+ // declarations and therefore no escape hatch. The only fix is to
476
+ // rename on the Command side. We surface this as an `inputConflicts`
477
+ // entry; the engine blocks the task with that reason.
478
+ //
479
+ // - **Output collision with compatible types** (e.g. both downstreams
480
+ // ask for `date: string` with the same description) → merged into a
481
+ // single inferred output. The Prompt produces one `date`; both
482
+ // downstreams consume it.
483
+ //
484
+ // - **Output collision with incompatible types** (e.g. one downstream
485
+ // wants `date: string`, another `date: number`) → no single LLM
486
+ // emission can satisfy both. Surfaced as `outputConflicts`; engine
487
+ // blocks the task. User must rename on one side.
488
+
489
+ export interface PromptUpstreamNeighbor {
490
+ readonly taskId: string;
491
+ /**
492
+ * Declared outputs of the upstream task. `undefined` signals that the
493
+ * neighbor is a Prompt Task (no structured contribution) or otherwise
494
+ * has no outputs to offer. The inference logic treats `undefined` and
495
+ * an empty array the same way — neither contributes ports.
496
+ */
497
+ readonly outputs: readonly PortDef[] | undefined;
498
+ }
499
+
500
+ export interface PromptDownstreamNeighbor {
501
+ readonly taskId: string;
502
+ /**
503
+ * Declared inputs of the downstream task. `undefined` signals a
504
+ * Prompt-Task neighbor or a Command Task without declared inputs.
505
+ * Either way it contributes no ports to the inferred output contract.
506
+ */
507
+ readonly inputs: readonly PortDef[] | undefined;
508
+ }
509
+
510
+ export interface PromptPortConflict {
511
+ readonly portName: string;
512
+ readonly producers: readonly { readonly taskId: string; readonly type: PortType }[];
513
+ /** Pre-formatted human-readable reason for logs / stderr. */
514
+ readonly reason: string;
515
+ }
516
+
517
+ export interface PromptPortInference {
518
+ /**
519
+ * Synthetic `TaskPorts` the engine feeds into the resolve / substitute /
520
+ * render / extract helpers, exactly as if the Prompt had declared these
521
+ * ports itself. Empty arrays are preserved as absent so downstream code
522
+ * paths treat "no ports" uniformly (see engine.ts's existing
523
+ * `task.ports?.outputs && task.ports.outputs.length > 0` guard).
524
+ */
525
+ readonly ports: TaskPorts;
526
+ readonly inputConflicts: readonly PromptPortConflict[];
527
+ readonly outputConflicts: readonly PromptPortConflict[];
528
+ }
529
+
530
+ /**
531
+ * Derive the effective `TaskPorts` for a Prompt Task from its direct
532
+ * neighbors. See the module-level "Prompt-task port inference" comment
533
+ * for the full contract.
534
+ *
535
+ * Pure function — no side effects, safe to call from the CLI, editor
536
+ * preview, and engine hot path alike.
537
+ */
538
+ export function inferPromptPorts(input: {
539
+ readonly upstreams: readonly PromptUpstreamNeighbor[];
540
+ readonly downstreams: readonly PromptDownstreamNeighbor[];
541
+ }): PromptPortInference {
542
+ const { upstreams, downstreams } = input;
543
+
544
+ // ─── Inputs: union of upstream-Command outputs ─────────────────────
545
+ //
546
+ // Walk every upstream in DAG order. First occurrence of a name wins
547
+ // (for the synthesized port shape used to resolve values). Subsequent
548
+ // occurrences under the same name become an `inputConflicts` entry —
549
+ // the engine blocks the task because a Prompt can't disambiguate.
550
+ const inputsByName = new Map<string, { port: PortDef; firstProducer: string }>();
551
+ const inputCollisionSources = new Map<string, { taskId: string; type: PortType }[]>();
552
+
553
+ for (const upstream of upstreams) {
554
+ if (!upstream.outputs || upstream.outputs.length === 0) continue;
555
+ for (const out of upstream.outputs) {
556
+ const prior = inputsByName.get(out.name);
557
+ if (!prior) {
558
+ // Copy the shape verbatim but drop output-only fields and force
559
+ // `required: true`. Prompt-task inferred inputs are required by
560
+ // default: the LLM wouldn't be getting a real-world value
561
+ // otherwise, and substituting an empty string silently is the
562
+ // same kind of bug we already reject elsewhere.
563
+ inputsByName.set(out.name, {
564
+ port: {
565
+ name: out.name,
566
+ type: out.type,
567
+ ...(out.description ? { description: out.description } : {}),
568
+ ...(out.enum ? { enum: [...out.enum] } : {}),
569
+ required: true,
570
+ },
571
+ firstProducer: upstream.taskId,
572
+ });
573
+ continue;
574
+ }
575
+ // Collision — seed the source list with the first producer too so
576
+ // the emitted conflict lists *all* contributing producers.
577
+ const list = inputCollisionSources.get(out.name) ?? [
578
+ { taskId: prior.firstProducer, type: prior.port.type },
579
+ ];
580
+ list.push({ taskId: upstream.taskId, type: out.type });
581
+ inputCollisionSources.set(out.name, list);
582
+ }
583
+ }
584
+
585
+ const inputConflicts: PromptPortConflict[] = [];
586
+ for (const [portName, producers] of inputCollisionSources) {
587
+ const producerList = producers.map((p) => p.taskId).join(', ');
588
+ inputConflicts.push({
589
+ portName,
590
+ producers,
591
+ reason:
592
+ `input "${portName}" is produced by multiple upstream Commands (${producerList}) — ` +
593
+ `Prompt tasks cannot disambiguate (no explicit "from:" binding). ` +
594
+ `Rename the output on one of the upstream Commands.`,
595
+ });
596
+ }
597
+
598
+ // ─── Outputs: union of downstream-Command inputs ───────────────────
599
+ //
600
+ // Compatible repeats merge (preserve first-encountered shape; prefer
601
+ // required when any downstream requires it). Incompatible repeats
602
+ // (different type, different enum set) go to `outputConflicts`.
603
+ const outputsByName = new Map<string, { port: PortDef; firstConsumer: string }>();
604
+ const outputCollisionSources = new Map<string, { taskId: string; type: PortType }[]>();
605
+
606
+ for (const downstream of downstreams) {
607
+ if (!downstream.inputs || downstream.inputs.length === 0) continue;
608
+ for (const inp of downstream.inputs) {
609
+ const prior = outputsByName.get(inp.name);
610
+ if (!prior) {
611
+ // Outputs drop input-only fields (required, default, from).
612
+ outputsByName.set(inp.name, {
613
+ port: {
614
+ name: inp.name,
615
+ type: inp.type,
616
+ ...(inp.description ? { description: inp.description } : {}),
617
+ ...(inp.enum ? { enum: [...inp.enum] } : {}),
618
+ },
619
+ firstConsumer: downstream.taskId,
620
+ });
621
+ continue;
622
+ }
623
+ if (portsAreCompatible(prior.port, inp)) continue; // merge silently
624
+ const list = outputCollisionSources.get(inp.name) ?? [
625
+ { taskId: prior.firstConsumer, type: prior.port.type },
626
+ ];
627
+ list.push({ taskId: downstream.taskId, type: inp.type });
628
+ outputCollisionSources.set(inp.name, list);
629
+ }
630
+ }
631
+
632
+ const outputConflicts: PromptPortConflict[] = [];
633
+ for (const [portName, producers] of outputCollisionSources) {
634
+ const consumerList = producers.map((p) => `${p.taskId} (${p.type})`).join(', ');
635
+ outputConflicts.push({
636
+ portName,
637
+ producers,
638
+ reason:
639
+ `output "${portName}" has conflicting type requirements across downstream Commands ` +
640
+ `(${consumerList}) — a single LLM emission cannot satisfy both. ` +
641
+ `Rename the input on one of the downstream Commands.`,
642
+ });
643
+ }
644
+
645
+ const inferredInputs = [...inputsByName.values()].map((e) => e.port);
646
+ const inferredOutputs = [...outputsByName.values()].map((e) => e.port);
647
+
648
+ const ports: TaskPorts = {
649
+ ...(inferredInputs.length > 0 ? { inputs: inferredInputs } : {}),
650
+ ...(inferredOutputs.length > 0 ? { outputs: inferredOutputs } : {}),
651
+ };
652
+ return { ports, inputConflicts, outputConflicts };
653
+ }
654
+
655
+ /**
656
+ * Two ports with the same name are compatible if they agree on `type`
657
+ * and, for enum ports, on the enum value set. Descriptions and
658
+ * required/default flags are deliberately ignored — they don't affect
659
+ * whether a single value can satisfy both consumers.
660
+ */
661
+ function portsAreCompatible(a: PortDef, b: PortDef): boolean {
662
+ if (a.type !== b.type) return false;
663
+ if (a.type === 'enum') {
664
+ const aEnum = [...(a.enum ?? [])].sort().join('');
665
+ const bEnum = [...(b.enum ?? [])].sort().join('');
666
+ if (aEnum !== bEnum) return false;
667
+ }
668
+ return true;
669
+ }
@@ -55,10 +55,10 @@ test('runSpawn: oversized output — bounded tail in memory, full bytes on disk'
55
55
  expect(result.exitCode).toBe(0);
56
56
  // Total bytes reported match reality
57
57
  expect(result.stdoutBytes).toBe(totalBytes);
58
- // In-memory tail bounded (tail + truncation marker header is a couple
59
- // hundred bytes at most; give it slack)
58
+ // In-memory tail bounded above (tail + truncation marker header is a
59
+ // couple hundred bytes at most; give it slack). No lower bound — chunk
60
+ // boundaries are platform-dependent so the exact retained size varies.
60
61
  expect(result.stdout.length).toBeLessThan(cap + 1024);
61
- expect(result.stdout.length).toBeGreaterThan(cap - 1024);
62
62
  // Truncation breadcrumb present and points at the full output
63
63
  expect(result.stdout).toContain('truncated from head');
64
64
  expect(result.stdout).toContain(stdoutPath);
package/src/runner.ts CHANGED
@@ -114,12 +114,20 @@ async function collectStream(
114
114
  const chunks: Uint8Array[] = [];
115
115
  let tailBytes = 0;
116
116
  let totalBytes = 0;
117
- const reader = stream.getReader();
117
+ let streamError: Error | null = null;
118
118
 
119
119
  try {
120
- for (;;) {
121
- const { done, value } = await reader.read();
122
- if (done) break;
120
+ // Use for await...of to avoid Bun bug where getReader() returns an
121
+ // incomplete reader missing releaseLock() under concurrent spawn.
122
+ // https://github.com/oven-sh/bun/issues/28952
123
+ //
124
+ // Bun 1.3.x also has sporadic failures iterating a spawned process's
125
+ // stream under concurrent Bun.spawn — the iterator throws mid-drain even
126
+ // when the child exited 0. We record the error as a breadcrumb instead
127
+ // of propagating, so the caller still sees the real exitCode from
128
+ // proc.exited and a task that the OS considered successful doesn't get
129
+ // marked failed over a runtime stream glitch.
130
+ for await (const value of stream as AsyncIterable<Uint8Array>) {
123
131
  totalBytes += value.length;
124
132
 
125
133
  // Disk: persist every byte. Failure here degrades to tail-only mode
@@ -157,8 +165,12 @@ async function collectStream(
157
165
  tailBytes = chunks[0]!.length;
158
166
  }
159
167
  }
168
+ } catch (err) {
169
+ streamError = err instanceof Error ? err : new Error(String(err));
170
+ console.error(
171
+ `[runner] stream read failed: ${streamError.message} — returning partial output`,
172
+ );
160
173
  } finally {
161
- reader.releaseLock();
162
174
  if (fh) {
163
175
  try {
164
176
  await fh.close();
@@ -187,6 +199,10 @@ async function collectStream(
187
199
  text = `[…${dropped} bytes truncated from head — full output at: ${pathHint}]\n${text}`;
188
200
  }
189
201
 
202
+ if (streamError) {
203
+ text = text + `\n[runner] stream read aborted: ${streamError.message}`;
204
+ }
205
+
190
206
  return {
191
207
  text,
192
208
  totalBytes,