@inflexa-ai/harness 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -83,6 +83,7 @@ export function createExecuteCommandTool(deps) {
83
83
  command,
84
84
  exitCode: result.exitCode,
85
85
  durationMs: result.durationMs,
86
+ ...(deps.logger ? { logger: deps.logger } : {}),
86
87
  ...(result.provenance ? { provenance: result.provenance } : {}),
87
88
  });
88
89
  }
@@ -30,11 +30,16 @@
30
30
  * completion
31
31
  * - await child results via `getResult` (these are themselves cached
32
32
  * in DBOS, so parent recovery does NOT re-run completed children)
33
- * 3. Fail-fast cascade
34
- * - on the first non-resumable child failure (or on external parent
35
- * cancel): stop scheduling, then
36
- * `Promise.allSettled(handles.map(h => DBOS.cancelWorkflow(h.workflowID)))`
37
- * - each cancelled child tears down its sandbox in its own terminal path
33
+ * 3. Failure isolation
34
+ * - a child settling `failed`/`blocked` (or throwing for a non-budget
35
+ * cause) dooms only its transitive dependents: they can never become
36
+ * dependency-satisfied, are marked `skipped` on the dag-state part, and
37
+ * are never dispatched. In-flight siblings and independent ready steps
38
+ * continue; the loop drains and the run finalises (typically `partial`).
39
+ * - the halt cascade (cancel in-flight via
40
+ * `Promise.allSettled(handles.map(h => DBOS.cancelWorkflow(h.workflowID)))`,
41
+ * stop scheduling) survives only for the budget paths: a
42
+ * `budget_exceeded` settlement and the `neverFits` plan-validation guard.
38
43
  * 4. Pause cascade (402)
39
44
  * - on a child cancelling itself with `budget_exceeded`: cancel siblings
40
45
  * then self-cancel the parent to `CANCELLED` (NOT `ERROR` — ERROR
@@ -30,11 +30,16 @@
30
30
  * completion
31
31
  * - await child results via `getResult` (these are themselves cached
32
32
  * in DBOS, so parent recovery does NOT re-run completed children)
33
- * 3. Fail-fast cascade
34
- * - on the first non-resumable child failure (or on external parent
35
- * cancel): stop scheduling, then
36
- * `Promise.allSettled(handles.map(h => DBOS.cancelWorkflow(h.workflowID)))`
37
- * - each cancelled child tears down its sandbox in its own terminal path
33
+ * 3. Failure isolation
34
+ * - a child settling `failed`/`blocked` (or throwing for a non-budget
35
+ * cause) dooms only its transitive dependents: they can never become
36
+ * dependency-satisfied, are marked `skipped` on the dag-state part, and
37
+ * are never dispatched. In-flight siblings and independent ready steps
38
+ * continue; the loop drains and the run finalises (typically `partial`).
39
+ * - the halt cascade (cancel in-flight via
40
+ * `Promise.allSettled(handles.map(h => DBOS.cancelWorkflow(h.workflowID)))`,
41
+ * stop scheduling) survives only for the budget paths: a
42
+ * `budget_exceeded` settlement and the `neverFits` plan-validation guard.
38
43
  * 4. Pause cascade (402)
39
44
  * - on a child cancelling itself with `budget_exceeded`: cancel siblings
40
45
  * then self-cancel the parent to `CANCELLED` (NOT `ERROR` — ERROR
@@ -105,11 +110,23 @@ export function buildChildInput(args) {
105
110
  if (!resources) {
106
111
  throw new Error(`executeAnalysis: step "${stepId}" missing from resourcesByStepId — every step must declare resources`);
107
112
  }
113
+ const planStep = input.planStepById[stepId];
114
+ if (!planStep) {
115
+ throw new Error(`executeAnalysis: step "${stepId}" missing from planStepById — every step must carry its plan data`);
116
+ }
108
117
  return {
109
118
  analysisId: input.analysisId,
110
119
  runId,
111
120
  stepId,
112
121
  agentId: input.agentByStepId[stepId] ?? "scientific-executor",
122
+ // Carried into the child because lineage classification admits a same-run
123
+ // sibling edge only when the sibling was declared here — the scheduler's
124
+ // ordering guarantee attaches to this list and nothing else. Read from the
125
+ // keyed plan snapshot rather than scanning `steps`, and thrown for rather
126
+ // than defaulted: an empty list is indistinguishable from a step that
127
+ // genuinely declared nothing, and it silently deletes every same-run edge
128
+ // the step was entitled to.
129
+ dependsOn: planStep.depends_on,
113
130
  level,
114
131
  prompt,
115
132
  parentWorkflowId: workflowId,
@@ -319,7 +336,7 @@ export async function runExecuteAnalysisBody(input, deps) {
319
336
  stepCount: input.steps.length,
320
337
  atMs: startedAtMs,
321
338
  });
322
- // (2-4) Scheduler loop + fail-fast + pause cascade.
339
+ // (2-4) Scheduler loop + failure isolation + pause cascade.
323
340
  const final = await runSchedulerLoop({
324
341
  input,
325
342
  runId,
@@ -434,7 +451,7 @@ async function runSchedulerLoop(args) {
434
451
  const canceled = new Set();
435
452
  const inFlight = new Map();
436
453
  const budgetExceededChildIds = new Set();
437
- // Child workflow ids the parent itself cancelled (fail-fast / budget cascade). Their `getResult`
454
+ // Child workflow ids the parent itself cancelled (budget / neverFits halt cascade). Their `getResult`
438
455
  // rejects with a DBOS cancellation error and lands in the settlement error branch — but the step was
439
456
  // CANCELED by us, not failed on its own merits. Keyed here (deterministic: `cancelInFlight` runs the
440
457
  // same way on a replay) so that branch records it as canceled everywhere, matching how a child that
@@ -444,7 +461,40 @@ async function runSchedulerLoop(args) {
444
461
  for (const step of input.steps) {
445
462
  stepRuntime.set(step.id, { status: "pending" });
446
463
  }
447
- let failFast = false;
464
+ // Reverse adjacency over the static plan — who depends on whom.
465
+ const dependentsByStepId = new Map();
466
+ for (const step of input.steps) {
467
+ for (const dep of step.depends_on) {
468
+ const list = dependentsByStepId.get(dep);
469
+ if (list)
470
+ list.push(step.id);
471
+ else
472
+ dependentsByStepId.set(dep, [step.id]);
473
+ }
474
+ }
475
+ /**
476
+ * Mark every transitive dependent of a failed/blocked step `skipped` in the
477
+ * dag snapshot: a failed step never enters `completed`, so its dependents
478
+ * can never become dependency-satisfied and will never be dispatched.
479
+ * Stream-only — ledger rows stay `pending` until the terminal sweep flips
480
+ * them to `skipped`. Dependents of an unfinished step are necessarily
481
+ * `pending` (never queued/running); an already-`skipped` one is not
482
+ * re-walked.
483
+ */
484
+ const markDependentsSkipped = (failedStepId) => {
485
+ const stack = [...(dependentsByStepId.get(failedStepId) ?? [])];
486
+ while (stack.length > 0) {
487
+ const id = stack.pop();
488
+ if (stepRuntime.get(id).status === "pending") {
489
+ stepRuntime.set(id, { status: "skipped" });
490
+ stack.push(...(dependentsByStepId.get(id) ?? []));
491
+ }
492
+ }
493
+ };
494
+ // Halts scheduling entirely — reserved for the budget paths (a
495
+ // `budget_exceeded` settlement, the `neverFits` guard). An ordinary step
496
+ // failure does NOT set it: it dooms only that step's transitive dependents.
497
+ let halted = false;
448
498
  let budgetExceeded = false;
449
499
  let failureReason = null;
450
500
  const stepIndexById = new Map(input.steps.map((s, i) => [s.id, i]));
@@ -473,20 +523,28 @@ async function runSchedulerLoop(args) {
473
523
  };
474
524
  const dbosParentId = DBOS.workflowID ?? workflowId;
475
525
  const dispatchReady = async () => {
476
- if (failFast)
526
+ if (halted)
477
527
  return;
478
528
  const inFlightStepIds = new Set([...inFlight.values()].map((h) => h.stepId));
529
+ // A settled-but-not-completed step (failed / blocked / canceled) is not
530
+ // in `completed` and not in flight, so `scheduleReady` would offer it
531
+ // again — filter it out of the candidate list. This also stops its
532
+ // declared resources from weighing against the budget: settled steps
533
+ // hold no capacity.
534
+ const candidates = input.steps.filter((s) => !failed.has(s.id) && !canceled.has(s.id));
479
535
  let admit;
480
536
  let queuedChanged = false;
481
537
  if (input.budget) {
482
- const scheduled = scheduleReady(input.steps, completed, inFlightStepIds, {
538
+ const scheduled = scheduleReady(candidates, completed, inFlightStepIds, {
483
539
  budget: input.budget,
484
540
  resourcesByStepId: input.resourcesByStepId,
485
541
  });
486
542
  if (scheduled.neverFits.length > 0) {
487
543
  // An over-budget step can never be admitted — plan-time validation
488
544
  // makes this unreachable for new plans; stored plans fail loudly
489
- // instead of waiting forever. Standard fail-fast follows.
545
+ // instead of waiting forever. The stored plan is invalid against
546
+ // this machine, so the run halts outright — unlike an ordinary
547
+ // step failure, which dooms only its dependents.
490
548
  for (const stepId of scheduled.neverFits) {
491
549
  const r = input.resourcesByStepId[stepId];
492
550
  const declared = r ? `${r.cpu} CPU / ${r.memoryGb} GB` : "undeclared";
@@ -496,7 +554,7 @@ async function runSchedulerLoop(args) {
496
554
  error: `step resources (${declared}) exceed the machine budget (${input.budget.cpu} CPU / ${input.budget.memoryGb} GB)`,
497
555
  });
498
556
  }
499
- failFast = true;
557
+ halted = true;
500
558
  failureReason = `step "${scheduled.neverFits[0]}" exceeds the machine resource budget`;
501
559
  await cancelInFlight(inFlight, "fail_fast", canceledByParent);
502
560
  await emitDagSnapshot();
@@ -511,7 +569,7 @@ async function runSchedulerLoop(args) {
511
569
  admit = scheduled.admit;
512
570
  }
513
571
  else {
514
- admit = scheduleReady(input.steps, completed, inFlightStepIds);
572
+ admit = scheduleReady(candidates, completed, inFlightStepIds);
515
573
  }
516
574
  if (admit.length === 0) {
517
575
  if (queuedChanged)
@@ -590,7 +648,7 @@ async function runSchedulerLoop(args) {
590
648
  const isBudgetCancel = childWasBudgetExceeded || r.error === "budget_exceeded";
591
649
  if (isBudgetCancel && !budgetExceeded) {
592
650
  budgetExceeded = true;
593
- failFast = true;
651
+ halted = true;
594
652
  failureReason = "budget_exceeded";
595
653
  await cancelInFlight(inFlight, "budget_exceeded", canceledByParent);
596
654
  }
@@ -604,31 +662,31 @@ async function runSchedulerLoop(args) {
604
662
  await emitDagSnapshot();
605
663
  }
606
664
  else {
607
- // failed or blocked — a blocker (see the harness-sandbox-agents spec) fails fast exactly like a
608
- // failure: the step declared it cannot deliver, so its dependents can
609
- // never run. Same cancel-siblings + stop-scheduling path.
665
+ // failed or blocked — a blocker (see the harness-sandbox-agents spec)
666
+ // is treated exactly like a failure: the step declared it cannot
667
+ // deliver, so only its transitive dependents can never run. In-flight
668
+ // siblings and independent ready steps continue; the dispatch round
669
+ // below also lets a budget-held step claim the freed capacity.
610
670
  failed.add(settled.stepId);
611
- if (!failFast) {
612
- failFast = true;
613
- failureReason = r.error ?? (r.status === "blocked" ? "step_blocked" : "step_failed");
614
- await cancelInFlight(inFlight, "fail_fast", canceledByParent);
615
- }
671
+ failureReason ??= r.error ?? (r.status === "blocked" ? "step_blocked" : "step_failed");
616
672
  stepRuntime.set(settled.stepId, {
617
673
  status: "failed",
618
674
  durationMs: r.durationMs ?? undefined,
619
675
  error: r.error ?? (r.status === "blocked" ? "step_blocked" : "step_failed"),
620
676
  });
677
+ markDependentsSkipped(settled.stepId);
621
678
  stepStatus = "failed";
622
679
  stepDurationMs = r.durationMs ?? undefined;
623
680
  await emitDagSnapshot();
681
+ await dispatchReady();
624
682
  }
625
683
  }
626
684
  else if (canceledByParent.has(settled.childId)) {
627
- // The child threw because the PARENT cancelled it (fail-fast / budget cascade), not because
628
- // it failed on its own — its `getResult` rejects with a DBOS cancellation error. Record it as
629
- // canceled, mirroring the graceful `{status:"canceled"}` branch above so the `canceled` set,
630
- // the terminal result, and the `step_completed` provenance all agree. No failFast / cancel
631
- // cascade here: the run is already failing fast (that is why this child was cancelled).
685
+ // The child threw because the PARENT cancelled it (budget / neverFits halt cascade), not
686
+ // because it failed on its own — its `getResult` rejects with a DBOS cancellation error.
687
+ // Record it as canceled, mirroring the graceful `{status:"canceled"}` branch above so the
688
+ // `canceled` set, the terminal result, and the `step_completed` provenance all agree. No
689
+ // halt / cancel cascade here: the run is already halted (that is why this child was cancelled).
632
690
  canceled.add(settled.stepId);
633
691
  stepRuntime.set(settled.stepId, {
634
692
  // `stepRuntime` (the DAG snapshot) has no canceled tier; the graceful-cancel branch above
@@ -641,18 +699,24 @@ async function runSchedulerLoop(args) {
641
699
  await emitDagSnapshot();
642
700
  }
643
701
  else {
702
+ // The child threw on its own (not a parent-driven cancel). A
703
+ // budget_exceeded throw still halts the run — the pause cascade is a
704
+ // money decision, not a DAG one. Any other throw is treated exactly
705
+ // like a step failure: dependents doomed, siblings continue.
644
706
  failed.add(settled.stepId);
645
- const cause = childWasBudgetExceeded || isBudgetExceeded(settled.err) ? "budget_exceeded" : "fail_fast";
646
- if (cause === "budget_exceeded" && !budgetExceeded) {
647
- budgetExceeded = true;
648
- failFast = true;
649
- failureReason = "budget_exceeded";
707
+ const isBudgetThrow = childWasBudgetExceeded || isBudgetExceeded(settled.err);
708
+ if (isBudgetThrow) {
709
+ if (!budgetExceeded) {
710
+ budgetExceeded = true;
711
+ halted = true;
712
+ failureReason = "budget_exceeded";
713
+ }
714
+ await cancelInFlight(inFlight, "budget_exceeded", canceledByParent);
650
715
  }
651
- else if (!failFast) {
652
- failFast = true;
653
- failureReason = settled.err instanceof Error ? settled.err.message : String(settled.err);
716
+ else {
717
+ failureReason ??= settled.err instanceof Error ? settled.err.message : String(settled.err);
718
+ markDependentsSkipped(settled.stepId);
654
719
  }
655
- await cancelInFlight(inFlight, cause, canceledByParent);
656
720
  stepRuntime.set(settled.stepId, {
657
721
  status: "failed",
658
722
  error: settled.err instanceof Error ? settled.err.message : String(settled.err),
@@ -661,6 +725,8 @@ async function runSchedulerLoop(args) {
661
725
  // A child that settled by throwing carries no durable duration.
662
726
  stepDurationMs = undefined;
663
727
  await emitDagSnapshot();
728
+ if (!isBudgetThrow)
729
+ await dispatchReady();
664
730
  }
665
731
  // One `step_completed` provenance emission per settled child — this
666
732
  // settlement loop is the only site that observes every executed step.
@@ -51,6 +51,18 @@ export interface SandboxStepInput {
51
51
  readonly runId: string;
52
52
  readonly stepId: string;
53
53
  readonly agentId: string;
54
+ /**
55
+ * Step ids this step declared as dependencies. Load-bearing for lineage:
56
+ * classification admits a same-run sibling edge only on declaration, so a
57
+ * collector seeded without this refuses every same-run read — legitimate
58
+ * declared dependencies included.
59
+ *
60
+ * Optional because this is durable DBOS workflow input: a workflow started
61
+ * under an input shape predating the field recovers without it. Absence
62
+ * reads as an empty declaration list, which under-captures sibling edges
63
+ * rather than admitting unstable ones.
64
+ */
65
+ readonly dependsOn?: readonly string[];
54
66
  /** Topological level — persisted on `cortex_step_executions.wave`. */
55
67
  readonly level: number;
56
68
  /** User-content prompt the agent receives as its initial message. */
@@ -240,6 +252,19 @@ export interface PostStepContext {
240
252
  /** Same step-scoped collector the agent loop fed each exec frame into. */
241
253
  readonly lineageCollector: ProvenanceCollector;
242
254
  }
255
+ /**
256
+ * Build the step's lineage collector from its durable input. The agent loop
257
+ * feeds each exec's provenance frame into it; post-step registration reads the
258
+ * resulting input/script edges. Reconstructed deterministically on replay — the
259
+ * frames it consumes are cached recv outputs and the command strings are cached
260
+ * LLM tool-use (see the harness-thread-store and harness-durable-runtime specs).
261
+ *
262
+ * Split out of the body, and taking only the fields it reads, so the seeding is
263
+ * exercisable without a live workflow: `dependsOn` is what makes a same-run edge
264
+ * admissible at all, so a collector built without it silently erases every
265
+ * legitimate declared-dependency edge while leaving each layer's own tests green.
266
+ */
267
+ export declare function createLineageCollector(input: Pick<SandboxStepInput, "stepId" | "runId" | "dependsOn">): ProvenanceCollector;
243
268
  /**
244
269
  * Register the sandbox-step child workflow with DBOS. Returns the
245
270
  * registered callable so callers can dispatch via `DBOS.startWorkflow`.
@@ -82,6 +82,25 @@ function stepPartId(kind, runId, stepId) {
82
82
  function userFacingStepFailure(errorClass) {
83
83
  return errorClass === "lineage_attestation" ? "Step results could not be finalized." : "Step failed during execution.";
84
84
  }
85
+ /**
86
+ * Build the step's lineage collector from its durable input. The agent loop
87
+ * feeds each exec's provenance frame into it; post-step registration reads the
88
+ * resulting input/script edges. Reconstructed deterministically on replay — the
89
+ * frames it consumes are cached recv outputs and the command strings are cached
90
+ * LLM tool-use (see the harness-thread-store and harness-durable-runtime specs).
91
+ *
92
+ * Split out of the body, and taking only the fields it reads, so the seeding is
93
+ * exercisable without a live workflow: `dependsOn` is what makes a same-run edge
94
+ * admissible at all, so a collector built without it silently erases every
95
+ * legitimate declared-dependency edge while leaving each layer's own tests green.
96
+ */
97
+ export function createLineageCollector(input) {
98
+ return new ProvenanceCollector({
99
+ stepId: input.stepId,
100
+ runId: input.runId,
101
+ dependsOn: [...(input.dependsOn ?? [])],
102
+ });
103
+ }
85
104
  // ── The child workflow body ───────────────────────────────────────────
86
105
  /**
87
106
  * Register the sandbox-step child workflow with DBOS. Returns the
@@ -132,15 +151,7 @@ async function runSandboxStepBody(input, deps) {
132
151
  // `session.provenance.agentId`, and billing tags ride the same provenance.
133
152
  const session = forSubAgent(input.runSession, input.agentId);
134
153
  const writePrefix = deps.resolveWritePrefix(input);
135
- // One lineage collector per step. The agent loop feeds each exec's
136
- // provenance frame into it; post-step registration reads the resulting
137
- // input/script edges. Reconstructed deterministically on replay — the
138
- // frames it consumes are cached recv outputs and the command strings are
139
- // cached LLM tool-use (see the harness-thread-store and harness-durable-runtime specs).
140
- const lineageCollector = new ProvenanceCollector({
141
- stepId: input.stepId,
142
- runId: input.runId,
143
- });
154
+ const lineageCollector = createLineageCollector(input);
144
155
  // (2a) sandbox.mint — checkpoint the machine's identity (name + HMAC secret)
145
156
  // BEFORE it is spawned (see the harness-sandbox-exec spec). Durable-before-create is what makes (2b)
146
157
  // idempotent: a crash between spawn and the spawn checkpoint re-runs (2b)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Host-agnostic bioinformatics agent harness \u2014 hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -40,12 +40,14 @@
40
40
  "prepare": "tsc -p tsconfig.json",
41
41
  "prepublishOnly": "bun run build && bun run smoke",
42
42
  "smoke": "node scripts/smoke.mjs",
43
+ "test:full": "bash scripts/test-full.sh",
43
44
  "typecheck": "tsc --noEmit"
44
45
  },
45
46
  "dependencies": {
46
47
  "@ai-sdk/anthropic": "^4.0.15",
47
48
  "@ai-sdk/openai-compatible": "^3.0.10",
48
49
  "@ai-sdk/provider": "^4.0.3",
50
+ "@ai-sdk/provider-utils": "5.0.10",
49
51
  "@anthropic-ai/sdk": "^0.107.0",
50
52
  "@dbos-inc/dbos-sdk": "^4.23.6",
51
53
  "@kubernetes/client-node": "^1.4.0",