@inflexa-ai/harness 0.37.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +1 -2
  2. package/dist/auth/local-run-authorizer.js +5 -4
  3. package/dist/auth/types.d.ts +28 -9
  4. package/dist/auth/types.js +5 -3
  5. package/dist/billing/cached-resolver.d.ts +11 -7
  6. package/dist/billing/cached-resolver.js +17 -11
  7. package/dist/billing/noop-run-charge.js +3 -2
  8. package/dist/billing/noop-usage-recorder.js +2 -1
  9. package/dist/billing/run-charge.d.ts +18 -5
  10. package/dist/billing/run-charge.js +5 -1
  11. package/dist/billing/usage-recorder.d.ts +11 -9
  12. package/dist/billing/usage-recorder.js +2 -3
  13. package/dist/contracts/chat-events.d.ts +4 -1
  14. package/dist/contracts/chat-parts.d.ts +1 -1
  15. package/dist/contracts/schemas/chat-parts.d.ts +4 -4
  16. package/dist/execution/artifact-registration.d.ts +6 -1
  17. package/dist/execution/artifact-registration.js +12 -4
  18. package/dist/execution/artifact-registry.d.ts +10 -3
  19. package/dist/execution/noop-artifact-registry.js +3 -4
  20. package/dist/execution/post-step-pipeline.d.ts +14 -3
  21. package/dist/execution/post-step-pipeline.js +21 -8
  22. package/dist/execution/run-authorizer.d.ts +5 -3
  23. package/dist/execution/run-canceler.js +4 -20
  24. package/dist/index.d.ts +14 -8
  25. package/dist/index.js +12 -6
  26. package/dist/lib/hooks.d.ts +49 -0
  27. package/dist/lib/hooks.js +39 -0
  28. package/dist/lib/result.d.ts +22 -11
  29. package/dist/lib/result.js +24 -14
  30. package/dist/loop/run-agent.d.ts +2 -2
  31. package/dist/loop/run-agent.js +5 -4
  32. package/dist/provenance/seam.d.ts +1 -1
  33. package/dist/providers/ai-sdk.d.ts +43 -5
  34. package/dist/providers/ai-sdk.js +55 -52
  35. package/dist/providers/anthropic.d.ts +2 -3
  36. package/dist/providers/anthropic.js +2 -2
  37. package/dist/providers/embedding.d.ts +13 -7
  38. package/dist/providers/embedding.js +26 -6
  39. package/dist/providers/errors.d.ts +67 -37
  40. package/dist/providers/errors.js +57 -58
  41. package/dist/providers/request-headers.d.ts +18 -0
  42. package/dist/providers/request-headers.js +22 -0
  43. package/dist/report-model/production-resolver.d.ts +5 -2
  44. package/dist/report-model/production-resolver.js +12 -2
  45. package/dist/runtime/assemble.d.ts +3 -2
  46. package/dist/runtime/dbos.d.ts +4 -3
  47. package/dist/runtime/dbos.js +18 -3
  48. package/dist/runtime/result-serialization.d.ts +29 -0
  49. package/dist/runtime/result-serialization.js +45 -0
  50. package/dist/sandbox/client.d.ts +11 -3
  51. package/dist/sandbox/create-sandbox.d.ts +5 -2
  52. package/dist/sandbox/create-sandbox.js +51 -25
  53. package/dist/sandbox/docker-client.d.ts +4 -3
  54. package/dist/sandbox/docker-client.js +23 -31
  55. package/dist/sandbox/k8s-client.d.ts +4 -13
  56. package/dist/sandbox/k8s-client.js +28 -61
  57. package/dist/sandbox/labels.d.ts +34 -0
  58. package/dist/sandbox/labels.js +47 -0
  59. package/dist/sandbox/mount-plan.d.ts +3 -1
  60. package/dist/sandbox/mount-plan.js +9 -0
  61. package/dist/sandbox/sandbox-error.d.ts +36 -13
  62. package/dist/sandbox/sandbox-error.js +32 -17
  63. package/dist/sandbox/types.d.ts +18 -10
  64. package/dist/schemas/validate-plan.js +13 -0
  65. package/dist/schemas/workflow-state.d.ts +4 -4
  66. package/dist/state/analyses.d.ts +4 -4
  67. package/dist/state/analyses.js +4 -4
  68. package/dist/state/schema.d.ts +5 -5
  69. package/dist/tasks/data-profile.d.ts +5 -8
  70. package/dist/tasks/data-profile.js +88 -43
  71. package/dist/tasks/derive-table-exec.d.ts +10 -6
  72. package/dist/tasks/derive-table-exec.js +17 -12
  73. package/dist/tasks/extract-values.d.ts +16 -10
  74. package/dist/tasks/extract-values.js +34 -18
  75. package/dist/tools/execute-analysis.js +29 -24
  76. package/dist/tools/report-session/derive-table.d.ts +9 -1
  77. package/dist/tools/report-session/derive-table.js +25 -22
  78. package/dist/tools/report-session/examine-page.d.ts +6 -3
  79. package/dist/tools/report-session/examine-page.js +16 -8
  80. package/dist/tools/report-session/index.d.ts +1 -1
  81. package/dist/tools/report-session/preview-report.js +11 -9
  82. package/dist/tools/report-session/session-page-publisher.d.ts +17 -21
  83. package/dist/tools/report-session/session-page-publisher.js +5 -7
  84. package/dist/tools/research/inspect-run.d.ts +1 -1
  85. package/dist/tools/sandbox/link-packages.d.ts +8 -0
  86. package/dist/tools/sandbox/link-packages.js +19 -9
  87. package/dist/workflows/execute-analysis.d.ts +25 -16
  88. package/dist/workflows/execute-analysis.js +223 -216
  89. package/dist/workflows/metrics.d.ts +4 -3
  90. package/dist/workflows/metrics.js +4 -3
  91. package/dist/workflows/sandbox-step.d.ts +20 -29
  92. package/dist/workflows/sandbox-step.js +71 -127
  93. package/dist/workflows/suspension.d.ts +32 -0
  94. package/dist/workflows/suspension.js +38 -0
  95. package/package.json +1 -1
  96. package/dist/billing/noop-resolver.d.ts +0 -6
  97. package/dist/billing/noop-resolver.js +0 -7
  98. package/dist/billing/resolver.d.ts +0 -64
  99. package/dist/billing/resolver.js +0 -24
  100. package/dist/loop/budget-exceeded.d.ts +0 -38
  101. package/dist/loop/budget-exceeded.js +0 -88
@@ -31,23 +31,30 @@
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
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`).
34
+ * - a child settling `failed`/`blocked` (or throwing for a cause that is
35
+ * not a suspension) dooms only its transitive dependents: they can never
36
+ * become dependency-satisfied, are marked `skipped` on the dag-state
37
+ * part, and are never dispatched. In-flight siblings and independent
38
+ * ready steps continue; the loop drains and the run finalises (typically
39
+ * `partial`).
39
40
  * - the halt cascade (cancel in-flight via
40
41
  * `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.
43
- * 4. Pause cascade (402)
44
- * - on a child cancelling itself with `budget_exceeded`: cancel siblings
45
- * then self-cancel the parent to `CANCELLED` (NOT `ERROR` — ERROR
46
- * isn't resumable; NOTES #3)
42
+ * stop scheduling) survives only for a suspension of a child and the
43
+ * `neverFits` plan-validation guard.
44
+ * 4. Suspension cascade (workflow-suspension spec)
45
+ * - a child that suspends sends a typed message on `CHILD_SUSPENDED_TOPIC`
46
+ * and cancels itself. The parent reads the kind of the message, never a
47
+ * reason string: it cancels the siblings, then self-cancels to
48
+ * `CANCELLED` (NOT `ERROR` — ERROR isn't resumable; NOTES #3)
49
+ * - a refusal of the `RunCharge.open` gate with the suspend flag suspends
50
+ * the run before any step starts
51
+ * - a `suspend` error of a synthesis model request suspends the run in
52
+ * place of the synthesis failure
47
53
  * - the analysis flips to `suspended_insufficient_funds` in
48
54
  * `collectAndComplete`. The paused parent lands in `CANCELLED`, a
49
- * durably reschedulable state; re-driving it after a top-up is a
50
- * future enhancement, not yet wired here.
55
+ * durably reschedulable state; re-driving it is a future enhancement,
56
+ * not yet wired here. The reason of the host rides to the run row, the
57
+ * charge close, the metric, and the terminal part, unread.
51
58
  * 5. `synthesizeFindings` (single sequential block, no sandbox)
52
59
  * 6. `collectAndComplete` (terminal — runs on ALL paths)
53
60
  * - determine final status from completed/cancelled/failed counts
@@ -62,15 +69,17 @@
62
69
  * via `inspectRun`)
63
70
  */
64
71
  import { DBOS, Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
72
+ import { ResultAsync, err, ok } from "neverthrow";
65
73
  import { mkdir } from "node:fs/promises";
66
74
  import { join } from "node:path";
67
75
  import { forStep } from "../auth/types.js";
68
76
  import { createNoopLogger } from "../lib/console-logger.js";
77
+ import { deliverNotice, passGate } from "../lib/hooks.js";
69
78
  import { ATTR_INFLEXA_STEP_ID, stableSpan } from "../lib/otel-spans.js";
70
79
  import { recordRunCompleted, recordStepCompleted, stepOutcomeOf } from "../lib/metrics.js";
80
+ import { describeDbError } from "../lib/db-result.js";
71
81
  import { unwrapOrThrow } from "../lib/result.js";
72
- import { RunDedupCollisionError, countArtifactsForRun, insertStepExecution, loadDataProfileStatus, queryActiveRun, queryStepArtifactPaths, queryStepsByRun, seedStepExecutions, setRunSynthesisOutcome, suspendAnalysis as suspendAnalysisQuery, sweepPendingStepExecutions, updateRunStatus, updateStepExecution, } from "../state/index.js";
73
- import { isBudgetExceeded } from "../loop/budget-exceeded.js";
82
+ import { countArtifactsForRun, insertStepExecution, loadDataProfileStatus, queryActiveRun, queryStepArtifactPaths, queryStepsByRun, seedStepExecutions, setRunSynthesisOutcome, suspendAnalysis as suspendAnalysisQuery, sweepPendingStepExecutions, updateRunStatus, updateStepExecution, } from "../state/index.js";
74
83
  import { addChatUsage, hasReportedUsage } from "../loop/metrics.js";
75
84
  import { SYNTHESIS_AGENT_ID, loadStepSummariesFromDisk } from "../execution/run-synthesis.js";
76
85
  import { MAX_UPSTREAM_ARTIFACTS, composeStepBriefing } from "../prompts/briefing.js";
@@ -79,12 +88,26 @@ import { isChatDataPart } from "../sandbox/sandbox-step-translate.js";
79
88
  import { synthesizeRun } from "../app/synthesize-run.js";
80
89
  import { computeTopologicalLevels, scheduleReady, validatePlanDag } from "./execute-analysis-scheduler.js";
81
90
  import { recordCancelledChild } from "./metrics.js";
82
- import { BUDGET_EXCEEDED_TOPIC } from "./sandbox-step.js";
91
+ import { CHILD_SUSPENDED_TOPIC } from "./sandbox-step.js";
92
+ import { cancelSelf, suspensionOfFailure, suspensionOfRefusal } from "./suspension.js";
83
93
  // ── In-body orchestration helpers ────────────────────────────────────
84
94
  /** Emit a chat data part on the parent's DBOS stream. */
85
95
  function emitStreamPart(part) {
86
96
  return DBOS.writeStream("events", part);
87
97
  }
98
+ /**
99
+ * A failed write rolls back no side effect that succeeded and never fails the
100
+ * run. A DBOS exception passes through with no change.
101
+ */
102
+ async function ledgerStep(log, name, write) {
103
+ await DBOS.runStep(async () => {
104
+ const written = await write();
105
+ if (written.isOk())
106
+ return ok(undefined);
107
+ log.named(name).error("ledger write failed", { error: describeDbError(written.error), ...log.errorFields(written.error.cause) });
108
+ return err(describeDbError(written.error));
109
+ }, { name });
110
+ }
88
111
  /**
89
112
  * Fire the optional run-lifecycle provenance observer, isolating a throwing
90
113
  * host observer from the workflow. The harness is host-agnostic and cannot
@@ -281,10 +304,13 @@ async function collectUpstreamHandoffs(args) {
281
304
  * its reason in `blockedReason` — an honest agent-declared blocker, not an
282
305
  * error — and a failure carries the same reason string
283
306
  * `persist-synthesis-outcome` writes to `cortex_runs.synthesis_reason`, so the
284
- * two ledgers cannot disagree about why synthesis died. Exported for the
285
- * projection tests (the `buildChildInput` pattern).
307
+ * two ledgers cannot disagree about why synthesis died. A suspension is not an
308
+ * outcome: the row is canceled, as the row of a suspended step. Exported for
309
+ * the projection tests (the `buildChildInput` pattern).
286
310
  */
287
311
  export function synthesisRowUpdate(outcome, durationMs) {
312
+ if ("kind" in outcome)
313
+ return { status: "canceled", durationMs, error: outcome.reason };
288
314
  switch (outcome.status) {
289
315
  case "produced":
290
316
  return { status: "completed", durationMs };
@@ -394,6 +420,29 @@ export async function runExecuteAnalysisBody(input, deps) {
394
420
  canceledSteps: [],
395
421
  };
396
422
  }
423
+ if (init.kind === "charge-refused") {
424
+ const suspension = suspensionOfRefusal(init.refusal) ?? null;
425
+ const refusedAtMs = await DBOS.now();
426
+ const refused = await collectAndComplete({
427
+ input,
428
+ runId,
429
+ workflowId,
430
+ startedAtMs: refusedAtMs,
431
+ completed: new Set(),
432
+ failed: new Set(),
433
+ canceled: new Set(),
434
+ suspension,
435
+ failureReason: init.refusal.reason,
436
+ forceFailed: suspension === null,
437
+ findings: [],
438
+ synthesisOutcome: null,
439
+ deps,
440
+ });
441
+ observeRunGuarded(deps, buildRunObservation({ input, runId, status: refused.status, stepStates: new Map() }));
442
+ if (suspension !== null)
443
+ return cancelSelf("self-cancel-suspended");
444
+ return refused;
445
+ }
397
446
  // Seed the full DAG as `pending` rows so ledger readers see every step from
398
447
  // run start (honest done/total), not just the ones that began executing.
399
448
  // The seed's DO NOTHING conflict action makes this replay-safe; the agent
@@ -462,10 +511,12 @@ export async function runExecuteAnalysisBody(input, deps) {
462
511
  // still run the terminal block (close charge + revoke run authorization), and re-throw
463
512
  // after so the workflow record goes to ERROR.
464
513
  let synthesisError = null;
514
+ let synthesisSuspension = null;
465
515
  let synthesisFindings = [];
466
516
  // Null when synthesis never ran (disabled or no completed steps): the ledger
467
517
  // columns stay NULL — "unknown", not a recorded skip — and the seeded
468
- // `synthesis` row (if any) stays `pending` for the terminal sweep.
518
+ // `synthesis` row (if any) stays `pending` for the terminal sweep. Also null
519
+ // when synthesis suspended: a resume of the run gives the outcome.
469
520
  let synthesisOutcome = null;
470
521
  if (synthesisEnabled && final.completed.size > 0) {
471
522
  // Bracketing clock reads for the row's duration_ms — checkpointed, so a
@@ -474,25 +525,16 @@ export async function runExecuteAnalysisBody(input, deps) {
474
525
  // Mark the seeded `synthesis` row running. The same upsert the DAG children
475
526
  // use: it stamps started_at, and it creates the row outright if a legacy
476
527
  // persisted input did not seed it. Log-don't-fail: a progress row must never fail an
477
- // otherwise-healthy run. Cancellation still re-propagates — it is not a
478
- // write failure, and swallowing it here would only defer it one operation.
479
- try {
480
- await DBOS.runStep(async () => {
481
- unwrapOrThrow(await insertStepExecution(deps.pool, {
482
- runId,
483
- stepId: SYNTHESIS_STEP_ID,
484
- analysisId: input.analysisId,
485
- wave: synthesisWave,
486
- agentId: SYNTHESIS_AGENT_ID,
487
- childWorkflowId: null,
488
- }));
489
- }, { name: "mark-synthesis-running" });
490
- }
491
- catch (err) {
492
- if (err instanceof DBOSErrors.DBOSWorkflowCancelledError)
493
- throw err;
494
- logger.error("mark-synthesis-running failed", { runId, ...logger.errorFields(err) });
495
- }
528
+ // otherwise-healthy run.
529
+ await ledgerStep(logger.with({ runId }), "mark-synthesis-running", () => insertStepExecution(deps.pool, {
530
+ runId,
531
+ stepId: SYNTHESIS_STEP_ID,
532
+ analysisId: input.analysisId,
533
+ wave: synthesisWave,
534
+ agentId: SYNTHESIS_AGENT_ID,
535
+ childWorkflowId: null,
536
+ }));
537
+ let rowOutcome;
496
538
  try {
497
539
  const synthOut = await DBOS.runStep(() => synthesizeFindings({
498
540
  analysisId: input.analysisId,
@@ -503,6 +545,7 @@ export async function runExecuteAnalysisBody(input, deps) {
503
545
  }), { name: "synthesize-findings" });
504
546
  synthesisFindings = synthOut.findings;
505
547
  synthesisOutcome = { status: synthOut.synthesisStatus, reason: synthOut.synthesisReason };
548
+ rowOutcome = synthesisOutcome;
506
549
  }
507
550
  catch (err) {
508
551
  // Cancellation is not a synthesis outcome: re-propagate unchanged (the
@@ -514,37 +557,38 @@ export async function runExecuteAnalysisBody(input, deps) {
514
557
  // `inflexa run` already detects via its DBOS-status cross-check.
515
558
  if (err instanceof DBOSErrors.DBOSWorkflowCancelledError)
516
559
  throw err;
517
- synthesisError = err;
518
- synthesisOutcome = { status: "failed", reason: err instanceof Error ? err.message : String(err) };
519
- logger.error("synthesizeFindings failed — failing the run", { runId, ...logger.errorFields(err) });
560
+ const suspended = suspensionOfFailure(err);
561
+ if (suspended !== undefined) {
562
+ synthesisSuspension = suspended;
563
+ rowOutcome = suspended;
564
+ logger.warn("synthesizeFindings suspended — suspending the run", { runId, reason: suspended.reason });
565
+ }
566
+ else {
567
+ synthesisError = err;
568
+ synthesisOutcome = { status: "failed", reason: err instanceof Error ? err.message : String(err) };
569
+ rowOutcome = synthesisOutcome;
570
+ logger.error("synthesizeFindings failed — failing the run", { runId, ...logger.errorFields(err) });
571
+ }
520
572
  }
521
573
  // Terminal stamp for the `synthesis` row — deliberately BEFORE
522
574
  // `collectAndComplete` writes the run row's terminal status, so no reader
523
- // can observe a terminal run beside a still-`running` synthesis row. Both
524
- // branches above set `synthesisOutcome` (or threw), so it is non-null here
525
- // and the compiler narrows it — no guard needed. Log-don't-fail, like the
526
- // sibling finalisation writes.
527
- const rowOutcome = synthesisOutcome;
528
- try {
529
- const synthEndedAtMs = await DBOS.now();
530
- const rowUpdate = synthesisRowUpdate(rowOutcome, synthEndedAtMs - synthStartedAtMs);
531
- await DBOS.runStep(async () => {
532
- unwrapOrThrow(await updateStepExecution(deps.pool, runId, SYNTHESIS_STEP_ID, rowUpdate));
533
- // Recorded inside the step that writes the row, so a replayed body records nothing again.
534
- const outcome = stepOutcomeOf(rowUpdate.status);
535
- if (outcome !== undefined)
536
- recordStepCompleted({ agentId: SYNTHESIS_AGENT_ID, status: outcome, durationMs: synthEndedAtMs - synthStartedAtMs });
537
- }, { name: "persist-synthesis-step" });
538
- }
539
- catch (err) {
540
- if (err instanceof DBOSErrors.DBOSWorkflowCancelledError)
541
- throw err;
542
- logger.error("persist-synthesis-step failed", { runId, ...logger.errorFields(err) });
543
- }
575
+ // can observe a terminal run beside a still-`running` synthesis row.
576
+ // Log-don't-fail, like the sibling finalisation writes.
577
+ const synthEndedAtMs = await DBOS.now();
578
+ const rowUpdate = synthesisRowUpdate(rowOutcome, synthEndedAtMs - synthStartedAtMs);
579
+ await ledgerStep(logger.with({ runId }), "persist-synthesis-step", async () => {
580
+ const written = await updateStepExecution(deps.pool, runId, SYNTHESIS_STEP_ID, rowUpdate);
581
+ // Recorded inside the step that writes the row, so a replayed body records nothing again.
582
+ const outcome = stepOutcomeOf(rowUpdate.status);
583
+ if (written.isOk() && outcome !== undefined)
584
+ recordStepCompleted({ agentId: SYNTHESIS_AGENT_ID, status: outcome, durationMs: synthEndedAtMs - synthStartedAtMs });
585
+ return written;
586
+ });
544
587
  }
545
588
  // (6) collectAndComplete — terminal block. Runs on EVERY path. When synthesis
546
589
  // failed, force a failed terminal status so the charge/run-authorization close correctly
547
590
  // and the run row + stream report the failure before we re-throw.
591
+ const suspension = final.suspension ?? synthesisSuspension;
548
592
  const result = await collectAndComplete({
549
593
  input,
550
594
  runId,
@@ -553,13 +597,15 @@ export async function runExecuteAnalysisBody(input, deps) {
553
597
  completed: final.completed,
554
598
  failed: final.failed,
555
599
  canceled: final.canceled,
556
- budgetExceeded: final.budgetExceeded,
600
+ suspension,
557
601
  ...(final.usage ? { usage: final.usage } : {}),
558
602
  failureReason: synthesisError
559
603
  ? synthesisError instanceof Error
560
604
  ? `synthesis-failed: ${synthesisError.message}`
561
605
  : "synthesis-failed"
562
- : final.failureReason,
606
+ : final.suspension === null && synthesisSuspension !== null
607
+ ? synthesisSuspension.reason
608
+ : final.failureReason,
563
609
  forceFailed: synthesisError !== null,
564
610
  findings: synthesisFindings,
565
611
  synthesisOutcome,
@@ -567,19 +613,14 @@ export async function runExecuteAnalysisBody(input, deps) {
567
613
  });
568
614
  // Terminal snapshot, fired here rather than inside `collectAndComplete` because this is the
569
615
  // first point where BOTH the derived final status and the scheduler's final step map are in
570
- // hand. It precedes the budget self-cancel below on purpose: that branch raises out of the
571
- // body, so an observer placed after it would never see a budget-paused run's last state.
616
+ // hand. It precedes the suspension self-cancel below on purpose: that branch raises out of the
617
+ // body, so an observer placed after it would never see a suspended run's last state.
572
618
  observeRunGuarded(deps, buildRunObservation({ input, runId, status: result.status, stepStates: final.stepStates }));
573
- // Synthesis failure takes priority over the budget-exceeded cascade: a run
574
- // whose synthesis threw is definitively failed (ERROR), not a resumable
575
- // budget pause. Only self-cancel when synthesis succeeded.
576
- if (final.budgetExceeded && synthesisError === null) {
577
- await DBOS.cancelWorkflow(DBOS.workflowID);
578
- await DBOS.runStep(async () => undefined, {
579
- name: "self-cancel-budget-exceeded",
580
- });
581
- // Unreachable — the runStep above raises DBOSWorkflowCancelledError.
582
- }
619
+ // Synthesis failure takes priority over the suspension cascade: a run whose
620
+ // synthesis threw is definitively failed (ERROR), not a resumable
621
+ // suspension. Only self-cancel when synthesis did not throw a failure.
622
+ if (suspension !== null && synthesisError === null)
623
+ return cancelSelf("self-cancel-suspended");
583
624
  // Re-throw after the terminal block so the workflow record goes to ERROR.
584
625
  if (synthesisError !== null)
585
626
  throw synthesisError;
@@ -590,19 +631,9 @@ async function validateAndInit(input, runId, deps) {
590
631
  // check that no other active run for the same plan won the race — the
591
632
  // partial-unique index would have rejected the insert if so, so this is
592
633
  // defense-in-depth only.
593
- try {
594
- const existing = unwrapOrThrow(await queryActiveRun(deps.pool, input.analysisId, input.planId));
595
- if (existing && existing.runId !== runId) {
596
- return { kind: "joined-existing", runId: existing.runId };
597
- }
598
- }
599
- catch (err) {
600
- if (err instanceof RunDedupCollisionError) {
601
- const existing = unwrapOrThrow(await queryActiveRun(deps.pool, input.analysisId, input.planId));
602
- if (existing)
603
- return { kind: "joined-existing", runId: existing.runId };
604
- }
605
- throw err;
634
+ const existing = unwrapOrThrow(await queryActiveRun(deps.pool, input.analysisId, input.planId));
635
+ if (existing && existing.runId !== runId) {
636
+ return { kind: "joined-existing", runId: existing.runId };
606
637
  }
607
638
  await DBOS.runStep(async () => {
608
639
  // A resolver throw here (unknown analysis, unresolvable root) fails the
@@ -612,11 +643,13 @@ async function validateAndInit(input, runId, deps) {
612
643
  recursive: true,
613
644
  });
614
645
  }, { name: "init-run-filesystem" });
615
- await DBOS.runStep(() => deps.runCharge.open({
646
+ const opened = await DBOS.runStep(async () => await passGate("RunCharge.open", deps.runCharge.open({
616
647
  analysisId: input.analysisId,
617
648
  runId,
618
649
  session: input.runSession,
619
- }), { name: "open-running-charge" });
650
+ })), { name: "open-running-charge" });
651
+ if (opened.isErr())
652
+ return { kind: "charge-refused", refusal: opened.error };
620
653
  return { kind: "fresh" };
621
654
  }
622
655
  async function runSchedulerLoop(args) {
@@ -625,14 +658,14 @@ async function runSchedulerLoop(args) {
625
658
  const failed = new Set();
626
659
  const canceled = new Set();
627
660
  const inFlight = new Map();
628
- const budgetExceededChildIds = new Set();
661
+ const childSuspensions = new Map();
629
662
  // Run-level usage aggregate, folded from the children's own durable results.
630
663
  // `getResult` IS the replay-safe source: a recovered parent reads back the
631
664
  // identical value from each child's workflow record, so the sum reproduces
632
665
  // exactly without a second channel. A child that settled by throwing carries
633
666
  // no result and therefore contributes nothing — same rule as its duration.
634
667
  const runUsage = {};
635
- // Child workflow ids the parent itself cancelled (budget / neverFits halt cascade). Their `getResult`
668
+ // Child workflow ids the parent itself cancelled (suspension / neverFits halt cascade). Their `getResult`
636
669
  // rejects with a DBOS cancellation error and lands in the settlement error branch — but the step was
637
670
  // CANCELED by us, not failed on its own merits. Keyed here (deterministic: `cancelInFlight` runs the
638
671
  // same way on a replay) so that branch records it as canceled everywhere, matching how a child that
@@ -672,12 +705,20 @@ async function runSchedulerLoop(args) {
672
705
  }
673
706
  }
674
707
  };
675
- // Halts scheduling entirely — reserved for the budget paths (a
676
- // `budget_exceeded` settlement, the `neverFits` guard). An ordinary step
677
- // failure does NOT set it: it dooms only that step's transitive dependents.
708
+ // Halts scheduling entirely — reserved for a suspension of a child and the
709
+ // `neverFits` guard. An ordinary step failure does NOT set it: it dooms
710
+ // only that step's transitive dependents.
678
711
  let halted = false;
679
- let budgetExceeded = false;
712
+ let suspension = null;
680
713
  let failureReason = null;
714
+ const haltForSuspension = async (child) => {
715
+ if (suspension === null) {
716
+ suspension = { kind: "suspended", reason: child.reason };
717
+ halted = true;
718
+ failureReason = child.reason;
719
+ }
720
+ await cancelInFlight(inFlight, child.reason, canceledByParent);
721
+ };
681
722
  const stepIndexById = new Map(input.steps.map((s, i) => [s.id, i]));
682
723
  const emitDagSnapshot = async () => {
683
724
  await emitStreamPart({
@@ -824,8 +865,8 @@ async function runSchedulerLoop(args) {
824
865
  catch (err) {
825
866
  settled = { childId, stepId: entry.stepId, kind: "error", err };
826
867
  }
827
- await drainBudgetExceededNotifications(budgetExceededChildIds);
828
- const childWasBudgetExceeded = budgetExceededChildIds.has(settled.childId);
868
+ await drainChildSuspensions(childSuspensions);
869
+ const childSuspension = childSuspensions.get(settled.childId);
829
870
  // Terminal step status + duration for the provenance emission below. Every
830
871
  // settlement branch assigns both, so the emission fires exactly once per
831
872
  // settled child with the mapped status.
@@ -847,13 +888,8 @@ async function runSchedulerLoop(args) {
847
888
  }
848
889
  else if (r.status === "canceled") {
849
890
  canceled.add(settled.stepId);
850
- const isBudgetCancel = childWasBudgetExceeded || r.error === "budget_exceeded";
851
- if (isBudgetCancel && !budgetExceeded) {
852
- budgetExceeded = true;
853
- halted = true;
854
- failureReason = "budget_exceeded";
855
- await cancelInFlight(inFlight, "budget_exceeded", canceledByParent);
856
- }
891
+ if (childSuspension !== undefined)
892
+ await haltForSuspension(childSuspension);
857
893
  stepRuntime.set(settled.stepId, {
858
894
  status: "failed",
859
895
  durationMs: r.durationMs ?? undefined,
@@ -884,7 +920,7 @@ async function runSchedulerLoop(args) {
884
920
  }
885
921
  }
886
922
  else if (canceledByParent.has(settled.childId)) {
887
- // The child threw because the PARENT cancelled it (budget / neverFits halt cascade), not
923
+ // The child threw because the PARENT cancelled it (suspension / neverFits halt cascade), not
888
924
  // because it failed on its own — its `getResult` rejects with a DBOS cancellation error.
889
925
  // Record it as canceled, mirroring the graceful `{status:"canceled"}` branch above so the
890
926
  // `canceled` set, the terminal result, and the `step_completed` provenance all agree. No
@@ -900,25 +936,25 @@ async function runSchedulerLoop(args) {
900
936
  stepDurationMs = undefined;
901
937
  await emitDagSnapshot();
902
938
  }
939
+ else if (childSuspension !== undefined) {
940
+ // The child suspended: it sent its typed suspension, then cancelled
941
+ // itself, thus `getResult` rejects with a DBOS cancellation. The
942
+ // suspension halts the run — the cascade is a decision of the host,
943
+ // not a DAG one — and it dooms no dependent: the resumed run needs them.
944
+ canceled.add(settled.stepId);
945
+ await haltForSuspension(childSuspension);
946
+ stepRuntime.set(settled.stepId, { status: "failed", error: childSuspension.reason });
947
+ stepStatus = "canceled";
948
+ stepDurationMs = undefined;
949
+ await emitDagSnapshot();
950
+ }
903
951
  else {
904
- // The child threw on its own (not a parent-driven cancel). A
905
- // budget_exceeded throw still halts the run — the pause cascade is a
906
- // money decision, not a DAG one. Any other throw is treated exactly
907
- // like a step failure: dependents doomed, siblings continue.
952
+ // The child threw on its own (not a parent-driven cancel, not a
953
+ // suspension). A throw is treated exactly like a step failure:
954
+ // dependents doomed, siblings continue.
908
955
  failed.add(settled.stepId);
909
- const isBudgetThrow = childWasBudgetExceeded || isBudgetExceeded(settled.err);
910
- if (isBudgetThrow) {
911
- if (!budgetExceeded) {
912
- budgetExceeded = true;
913
- halted = true;
914
- failureReason = "budget_exceeded";
915
- }
916
- await cancelInFlight(inFlight, "budget_exceeded", canceledByParent);
917
- }
918
- else {
919
- failureReason ??= settled.err instanceof Error ? settled.err.message : String(settled.err);
920
- markDependentsSkipped(settled.stepId);
921
- }
956
+ failureReason ??= settled.err instanceof Error ? settled.err.message : String(settled.err);
957
+ markDependentsSkipped(settled.stepId);
922
958
  stepRuntime.set(settled.stepId, {
923
959
  status: "failed",
924
960
  error: settled.err instanceof Error ? settled.err.message : String(settled.err),
@@ -927,8 +963,7 @@ async function runSchedulerLoop(args) {
927
963
  // A child that settled by throwing carries no durable duration.
928
964
  stepDurationMs = undefined;
929
965
  await emitDagSnapshot();
930
- if (!isBudgetThrow)
931
- await dispatchReady();
966
+ await dispatchReady();
932
967
  }
933
968
  // One `step_completed` provenance emission per settled child — this
934
969
  // settlement loop is the only site that observes every executed step.
@@ -947,23 +982,24 @@ async function runSchedulerLoop(args) {
947
982
  atMs: settledAtMs,
948
983
  }, input.runSession);
949
984
  }
950
- await drainBudgetExceededNotifications(budgetExceededChildIds);
985
+ await drainChildSuspensions(childSuspensions);
951
986
  return {
952
987
  completed,
953
988
  failed,
954
989
  canceled,
955
- budgetExceeded,
990
+ suspension,
956
991
  failureReason,
957
992
  stepStates: stepRuntime,
958
993
  ...(hasReportedUsage(runUsage) ? { usage: { ...runUsage } } : {}),
959
994
  };
960
995
  }
961
- async function drainBudgetExceededNotifications(childIds) {
996
+ async function drainChildSuspensions(suspensions) {
962
997
  while (true) {
963
- const msg = await DBOS.recv(BUDGET_EXCEEDED_TOPIC, 0);
998
+ const msg = await DBOS.recv(CHILD_SUSPENDED_TOPIC, 0);
964
999
  if (!msg)
965
1000
  return;
966
- childIds.add(msg.childWorkflowId);
1001
+ if (msg.kind === "suspended" && !suspensions.has(msg.childWorkflowId))
1002
+ suspensions.set(msg.childWorkflowId, msg);
967
1003
  }
968
1004
  }
969
1005
  async function cancelInFlight(inFlight, cause, canceledByParent) {
@@ -978,7 +1014,9 @@ async function cancelInFlight(inFlight, cause, canceledByParent) {
978
1014
  }
979
1015
  }
980
1016
  async function collectAndComplete(args) {
981
- const { input, runId, workflowId, startedAtMs, completed, failed, canceled, budgetExceeded, failureReason, forceFailed, deps } = args;
1017
+ const { input, runId, workflowId, startedAtMs, completed, failed, canceled, suspension, failureReason, forceFailed, deps } = args;
1018
+ // A forced failure is terminal and never a resumable suspension.
1019
+ const suspended = suspension !== null && !forceFailed ? suspension : null;
982
1020
  const logger = (deps.logger ?? createNoopLogger()).named("executeAnalysis").with({ runId, analysisId: input.analysisId });
983
1021
  const status = forceFailed
984
1022
  ? "failed"
@@ -987,14 +1025,20 @@ async function collectAndComplete(args) {
987
1025
  completed,
988
1026
  failed,
989
1027
  canceled,
990
- budgetExceeded,
1028
+ suspended: suspended !== null,
991
1029
  });
992
- const chargeReason = status === "completed" || status === "partial" ? "ok" : status === "failed" ? "error" : budgetExceeded ? "budget_exceeded" : "canceled";
1030
+ const chargeOutcome = status === "completed" || status === "partial"
1031
+ ? { kind: "ok" }
1032
+ : status === "failed"
1033
+ ? { kind: "error" }
1034
+ : suspended !== null
1035
+ ? { kind: "suspended", reason: suspended.reason }
1036
+ : { kind: "canceled" };
993
1037
  const revokeReason = status === "completed" || status === "partial"
994
1038
  ? "workflow-completed"
995
1039
  : status === "failed"
996
1040
  ? "workflow-failed"
997
- : budgetExceeded
1041
+ : suspended !== null
998
1042
  ? "workflow-suspended"
999
1043
  : "workflow-canceled";
1000
1044
  // Terminal clock read + run-completion provenance, emitted BEFORE the `cortex_runs`
@@ -1016,36 +1060,30 @@ async function collectAndComplete(args) {
1016
1060
  atMs: terminalAtMs,
1017
1061
  durationMs: terminalAtMs - startedAtMs,
1018
1062
  }, input.runSession);
1019
- try {
1020
- await DBOS.runStep(async () => {
1021
- unwrapOrThrow(await updateRunStatus(deps.pool, runId, status, failureReason ?? (status === "canceled" && !budgetExceeded ? "external_cancel" : null)));
1022
- // The outcome metrics ride inside the step that persists the terminal status: DBOS
1023
- // caches the step, so a recovered body records nothing again. A step's own terminal
1024
- // row already carried its metric; the residue counted here is every settled step
1025
- // without one — a child the cancel cut before `mark-canceled` (the cancel check runs
1026
- // before a step body), or one that died before `failStep`. The ledger read is kept
1027
- // apart from the status write: a failed read costs the metrics, never the write.
1028
- let stepRows = [];
1029
- try {
1030
- stepRows = unwrapOrThrow(await queryStepsByRun(deps.pool, runId));
1031
- }
1032
- catch (err) {
1033
- logger.warn("run metrics: step ledger read failed, residual step outcomes skipped", logger.errorFields(err));
1034
- }
1035
- for (const row of stepRows) {
1036
- if (row.completedAt !== null)
1037
- continue;
1038
- if (canceled.has(row.stepId))
1039
- recordStepCompleted({ agentId: row.agentId, status: "canceled" });
1040
- else if (failed.has(row.stepId))
1041
- recordStepCompleted({ agentId: row.agentId, status: "failed" });
1042
- }
1043
- recordRunCompleted({ workflow: "analysis", status, durationMs: terminalAtMs - startedAtMs });
1044
- }, { name: "persist-final-status" });
1045
- }
1046
- catch (err) {
1047
- logger.error("persist-final-status failed", { status, ...logger.errorFields(err) });
1048
- }
1063
+ await ledgerStep(logger.with({ status }), "persist-final-status", async () => {
1064
+ const written = await updateRunStatus(deps.pool, runId, status, failureReason ?? (status === "canceled" && suspended === null ? "external_cancel" : null));
1065
+ if (written.isErr())
1066
+ return written;
1067
+ // The outcome metrics ride inside the step that persists the terminal status: DBOS
1068
+ // caches the step, so a recovered body records nothing again. A step's own terminal
1069
+ // row already carried its metric; the residue counted here is every settled step
1070
+ // without one — a child the cancel cut before `mark-canceled` (the cancel check runs
1071
+ // before a step body), or one that died before `failStep`. The ledger read is kept
1072
+ // apart from the status write: a failed read costs the metrics, never the write.
1073
+ const stepRows = await queryStepsByRun(deps.pool, runId);
1074
+ if (stepRows.isErr())
1075
+ logger.warn("run metrics: step ledger read failed, residual step outcomes skipped", { error: describeDbError(stepRows.error) });
1076
+ for (const row of stepRows.unwrapOr([])) {
1077
+ if (row.completedAt !== null)
1078
+ continue;
1079
+ if (canceled.has(row.stepId))
1080
+ recordStepCompleted({ agentId: row.agentId, status: "canceled" });
1081
+ else if (failed.has(row.stepId))
1082
+ recordStepCompleted({ agentId: row.agentId, status: "failed" });
1083
+ }
1084
+ recordRunCompleted({ workflow: "analysis", status, durationMs: terminalAtMs - startedAtMs });
1085
+ return written;
1086
+ });
1049
1087
  // Record the run's synthesis outcome AFTER the status write — a reader that
1050
1088
  // sees a terminal status then also sees whether (and why) synthesis ran. A
1051
1089
  // null outcome means synthesis never ran; leave the columns NULL ("unknown").
@@ -1053,53 +1091,25 @@ async function collectAndComplete(args) {
1053
1091
  // must not undo an otherwise-complete run.
1054
1092
  const outcome = args.synthesisOutcome;
1055
1093
  if (outcome !== null) {
1056
- try {
1057
- await DBOS.runStep(async () => {
1058
- unwrapOrThrow(await setRunSynthesisOutcome(deps.pool, runId, outcome.status, outcome.reason));
1059
- }, { name: "persist-synthesis-outcome" });
1060
- }
1061
- catch (err) {
1062
- logger.error("persist-synthesis-outcome failed", { synthesisStatus: outcome.status, ...logger.errorFields(err) });
1063
- }
1094
+ await ledgerStep(logger.with({ synthesisStatus: outcome.status }), "persist-synthesis-outcome", () => setRunSynthesisOutcome(deps.pool, runId, outcome.status, outcome.reason));
1064
1095
  }
1065
1096
  // Sweep never-started steps to `skipped` — but ONLY on genuinely-terminal
1066
1097
  // paths. The gate is the BRANCH, not the written run status: the resumable
1067
- // 402 pause below also writes "canceled", yet its pending rows must survive
1098
+ // suspension below also writes "canceled", yet its pending rows must survive
1068
1099
  // for the resumed workflow to execute. Log-don't-rollback, like every other
1069
1100
  // finalisation step here.
1070
- if (!(budgetExceeded && !forceFailed)) {
1071
- try {
1072
- await DBOS.runStep(async () => {
1073
- unwrapOrThrow(await sweepPendingStepExecutions(deps.pool, runId));
1074
- }, { name: "sweep-pending-steps" });
1075
- }
1076
- catch (err) {
1077
- logger.error("sweep-pending-steps failed", logger.errorFields(err));
1078
- }
1101
+ if (suspended === null) {
1102
+ await ledgerStep(logger, "sweep-pending-steps", () => sweepPendingStepExecutions(deps.pool, runId));
1079
1103
  }
1080
- // A forced failure (synthesis threw) is terminal, not a resumable budget
1081
- // pause — never suspend it, even when the budget was also exceeded.
1082
- if (budgetExceeded && !forceFailed) {
1083
- try {
1084
- await DBOS.runStep(async () => {
1085
- unwrapOrThrow(await suspendAnalysisQuery(deps.pool, input.analysisId));
1086
- }, { name: "suspend-analysis" });
1087
- }
1088
- catch (err) {
1089
- logger.error("suspend-analysis failed", logger.errorFields(err));
1090
- }
1091
- }
1092
- try {
1093
- await DBOS.runStep(() => deps.runCharge.close({
1094
- analysisId: input.analysisId,
1095
- runId,
1096
- reason: chargeReason,
1097
- session: input.runSession,
1098
- }), { name: "close-running-charge" });
1099
- }
1100
- catch (err) {
1101
- logger.error("closeRunningCharge failed", { chargeReason, ...logger.errorFields(err) });
1104
+ if (suspended !== null) {
1105
+ await ledgerStep(logger, "suspend-analysis", () => suspendAnalysisQuery(deps.pool, input.analysisId));
1102
1106
  }
1107
+ await DBOS.runStep(() => deliverNotice(logger.with({ chargeOutcome: chargeOutcome.kind }), "RunCharge.close", deps.runCharge.close({
1108
+ analysisId: input.analysisId,
1109
+ runId,
1110
+ outcome: chargeOutcome,
1111
+ session: input.runSession,
1112
+ })), { name: "close-running-charge" });
1103
1113
  // Revoke the run authorization for the terminal run state. Ownership
1104
1114
  // defaults to true for inputs persisted before the field existed (a
1105
1115
  // workflow recovered across this deploy) — those were always Cortex-owned.
@@ -1107,17 +1117,14 @@ async function collectAndComplete(args) {
1107
1117
  runSession: input.runSession,
1108
1118
  ownsMandate: input.ownsMandate ?? true, // oss-core-managed-ok
1109
1119
  };
1110
- try {
1111
- await DBOS.runStep(() => deps.runAuthorizer.revoke(authorization, revokeReason), { name: "revoke-run-auth" });
1112
- }
1113
- catch (err) {
1114
- logger.error("revokeRunAuthorization failed", { revokeReason, ...logger.errorFields(err) });
1115
- }
1120
+ await DBOS.runStep(() => deliverNotice(logger.with({ revokeReason }), "RunAuthorizer.revoke", deps.runAuthorizer.revoke(authorization, revokeReason)), {
1121
+ name: "revoke-run-auth",
1122
+ });
1116
1123
  // The `run_completed` provenance was already emitted above (before the status write, to
1117
1124
  // beat the CLI's poll-and-shutdown). These branches only fan out the UI stream part, whose
1118
1125
  // completed/failed shapes genuinely differ — the provenance record does not.
1119
1126
  if (status === "completed" || status === "partial") {
1120
- const artifactCount = await DBOS.runStep(() => countArtifactsForRun(deps.pool, input.analysisId, runId), { name: "count-run-artifacts" }).catch(() => 0);
1127
+ const artifactCount = await DBOS.runStep(async () => await ResultAsync.fromPromise(countArtifactsForRun(deps.pool, input.analysisId, runId), () => "the artifact count failed").unwrapOr(0), { name: "count-run-artifacts" });
1121
1128
  await emitStreamPart({
1122
1129
  type: "data-run-completed",
1123
1130
  runId,
@@ -1141,8 +1148,8 @@ async function collectAndComplete(args) {
1141
1148
  await emitStreamPart({
1142
1149
  type: "data-run-failed",
1143
1150
  runId,
1144
- error: failureReason ?? (budgetExceeded ? "Run paused: insufficient budget" : "Run canceled before completion"),
1145
- ...(budgetExceeded ? { reason: "budget_exceeded" } : {}),
1151
+ error: failureReason ?? (suspended !== null ? suspended.reason : "Run canceled before completion"),
1152
+ ...(suspended !== null ? { reason: suspended.reason } : {}),
1146
1153
  });
1147
1154
  }
1148
1155
  return {
@@ -1161,8 +1168,8 @@ async function collectAndComplete(args) {
1161
1168
  * status vocabulary without a cast.
1162
1169
  */
1163
1170
  function deriveFinalStatus(args) {
1164
- const { totalSteps, completed, failed, canceled, budgetExceeded } = args;
1165
- if (budgetExceeded)
1171
+ const { totalSteps, completed, failed, canceled, suspended } = args;
1172
+ if (suspended)
1166
1173
  return "canceled";
1167
1174
  if (failed.size > 0) {
1168
1175
  return completed.size > 0 ? "partial" : "failed";