@inflexa-ai/harness 0.37.0 → 0.38.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.
- package/README.md +1 -2
- package/dist/auth/local-run-authorizer.js +5 -4
- package/dist/auth/types.d.ts +28 -9
- package/dist/auth/types.js +5 -3
- package/dist/billing/noop-run-charge.js +3 -2
- package/dist/billing/noop-usage-recorder.js +2 -1
- package/dist/billing/run-charge.d.ts +18 -5
- package/dist/billing/run-charge.js +5 -1
- package/dist/billing/usage-recorder.d.ts +11 -9
- package/dist/billing/usage-recorder.js +2 -3
- package/dist/contracts/chat-events.d.ts +4 -1
- package/dist/contracts/chat-parts.d.ts +1 -1
- package/dist/contracts/schemas/chat-parts.d.ts +4 -4
- package/dist/execution/artifact-registration.d.ts +6 -1
- package/dist/execution/artifact-registration.js +12 -4
- package/dist/execution/artifact-registry.d.ts +10 -3
- package/dist/execution/noop-artifact-registry.js +3 -4
- package/dist/execution/post-step-pipeline.d.ts +14 -3
- package/dist/execution/post-step-pipeline.js +21 -8
- package/dist/execution/run-authorizer.d.ts +5 -3
- package/dist/execution/run-canceler.js +4 -20
- package/dist/index.d.ts +13 -7
- package/dist/index.js +12 -6
- package/dist/lib/hooks.d.ts +49 -0
- package/dist/lib/hooks.js +39 -0
- package/dist/lib/result.d.ts +22 -11
- package/dist/lib/result.js +24 -14
- package/dist/loop/run-agent.d.ts +2 -2
- package/dist/loop/run-agent.js +5 -4
- package/dist/provenance/seam.d.ts +1 -1
- package/dist/providers/ai-sdk.d.ts +43 -5
- package/dist/providers/ai-sdk.js +55 -52
- package/dist/providers/anthropic.d.ts +2 -3
- package/dist/providers/anthropic.js +2 -2
- package/dist/providers/embedding.d.ts +13 -7
- package/dist/providers/embedding.js +26 -6
- package/dist/providers/errors.d.ts +67 -37
- package/dist/providers/errors.js +57 -58
- package/dist/providers/request-headers.d.ts +18 -0
- package/dist/providers/request-headers.js +22 -0
- package/dist/report-model/production-resolver.d.ts +5 -2
- package/dist/report-model/production-resolver.js +12 -2
- package/dist/runtime/assemble.d.ts +3 -2
- package/dist/runtime/dbos.d.ts +4 -3
- package/dist/runtime/dbos.js +18 -3
- package/dist/runtime/result-serialization.d.ts +29 -0
- package/dist/runtime/result-serialization.js +45 -0
- package/dist/sandbox/client.d.ts +11 -3
- package/dist/sandbox/create-sandbox.d.ts +5 -2
- package/dist/sandbox/create-sandbox.js +51 -25
- package/dist/sandbox/docker-client.d.ts +4 -3
- package/dist/sandbox/docker-client.js +23 -31
- package/dist/sandbox/k8s-client.d.ts +4 -13
- package/dist/sandbox/k8s-client.js +28 -61
- package/dist/sandbox/labels.d.ts +34 -0
- package/dist/sandbox/labels.js +47 -0
- package/dist/sandbox/mount-plan.d.ts +3 -1
- package/dist/sandbox/mount-plan.js +9 -0
- package/dist/sandbox/sandbox-error.d.ts +36 -13
- package/dist/sandbox/sandbox-error.js +32 -17
- package/dist/sandbox/types.d.ts +13 -9
- package/dist/schemas/validate-plan.js +13 -0
- package/dist/schemas/workflow-state.d.ts +4 -4
- package/dist/state/analyses.d.ts +4 -4
- package/dist/state/analyses.js +4 -4
- package/dist/state/schema.d.ts +5 -5
- package/dist/tasks/data-profile.d.ts +5 -8
- package/dist/tasks/data-profile.js +88 -43
- package/dist/tasks/derive-table-exec.d.ts +10 -6
- package/dist/tasks/derive-table-exec.js +17 -12
- package/dist/tasks/extract-values.d.ts +16 -10
- package/dist/tasks/extract-values.js +34 -18
- package/dist/tools/execute-analysis.js +22 -15
- package/dist/tools/report-session/derive-table.d.ts +9 -1
- package/dist/tools/report-session/derive-table.js +25 -22
- package/dist/tools/research/inspect-run.d.ts +1 -1
- package/dist/workflows/execute-analysis.d.ts +25 -16
- package/dist/workflows/execute-analysis.js +223 -216
- package/dist/workflows/metrics.d.ts +4 -3
- package/dist/workflows/metrics.js +4 -3
- package/dist/workflows/sandbox-step.d.ts +20 -29
- package/dist/workflows/sandbox-step.js +71 -127
- package/dist/workflows/suspension.d.ts +32 -0
- package/dist/workflows/suspension.js +38 -0
- package/package.json +1 -1
- package/dist/billing/noop-resolver.d.ts +0 -6
- package/dist/billing/noop-resolver.js +0 -7
- package/dist/billing/resolver.d.ts +0 -64
- package/dist/billing/resolver.js +0 -24
- package/dist/loop/budget-exceeded.d.ts +0 -38
- 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
|
|
35
|
-
*
|
|
36
|
-
* dependency-satisfied, are marked `skipped` on the dag-state
|
|
37
|
-
* are never dispatched. In-flight siblings and independent
|
|
38
|
-
* continue; the loop drains and the run finalises (typically
|
|
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
|
|
42
|
-
* `
|
|
43
|
-
* 4.
|
|
44
|
-
* -
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
|
50
|
-
*
|
|
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 {
|
|
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 {
|
|
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.
|
|
285
|
-
*
|
|
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.
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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.
|
|
524
|
-
//
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
571
|
-
// body, so an observer placed after it would never see a
|
|
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
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
if (
|
|
577
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
|
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 (
|
|
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
|
|
676
|
-
// `
|
|
677
|
-
//
|
|
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
|
|
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
|
|
828
|
-
const
|
|
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
|
-
|
|
851
|
-
|
|
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 (
|
|
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
|
|
905
|
-
//
|
|
906
|
-
//
|
|
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
|
-
|
|
910
|
-
|
|
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
|
-
|
|
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
|
|
985
|
+
await drainChildSuspensions(childSuspensions);
|
|
951
986
|
return {
|
|
952
987
|
completed,
|
|
953
988
|
failed,
|
|
954
989
|
canceled,
|
|
955
|
-
|
|
990
|
+
suspension,
|
|
956
991
|
failureReason,
|
|
957
992
|
stepStates: stepRuntime,
|
|
958
993
|
...(hasReportedUsage(runUsage) ? { usage: { ...runUsage } } : {}),
|
|
959
994
|
};
|
|
960
995
|
}
|
|
961
|
-
async function
|
|
996
|
+
async function drainChildSuspensions(suspensions) {
|
|
962
997
|
while (true) {
|
|
963
|
-
const msg = await DBOS.recv(
|
|
998
|
+
const msg = await DBOS.recv(CHILD_SUSPENDED_TOPIC, 0);
|
|
964
999
|
if (!msg)
|
|
965
1000
|
return;
|
|
966
|
-
|
|
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,
|
|
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
|
-
|
|
1028
|
+
suspended: suspended !== null,
|
|
991
1029
|
});
|
|
992
|
-
const
|
|
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
|
-
:
|
|
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
|
-
|
|
1020
|
-
await
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 (
|
|
1071
|
-
|
|
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
|
-
|
|
1081
|
-
|
|
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
|
-
|
|
1111
|
-
|
|
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" })
|
|
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 ?? (
|
|
1145
|
-
...(
|
|
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,
|
|
1165
|
-
if (
|
|
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";
|