@sensigo/realm 0.14.1 → 0.16.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 (63) hide show
  1. package/dist/adapters/adapter-utils.d.ts +6 -0
  2. package/dist/adapters/adapter-utils.d.ts.map +1 -1
  3. package/dist/adapters/adapter-utils.js +21 -0
  4. package/dist/adapters/adapter-utils.js.map +1 -1
  5. package/dist/adapters/file-adapter.d.ts +1 -1
  6. package/dist/adapters/file-adapter.d.ts.map +1 -1
  7. package/dist/adapters/file-adapter.js +10 -2
  8. package/dist/adapters/file-adapter.js.map +1 -1
  9. package/dist/adapters/gorgias-adapter.d.ts +1 -1
  10. package/dist/adapters/gorgias-adapter.d.ts.map +1 -1
  11. package/dist/adapters/gorgias-adapter.js +28 -13
  12. package/dist/adapters/gorgias-adapter.js.map +1 -1
  13. package/dist/adapters/slack-adapter.d.ts.map +1 -1
  14. package/dist/adapters/slack-adapter.js +11 -0
  15. package/dist/adapters/slack-adapter.js.map +1 -1
  16. package/dist/engine/backoff.d.ts +4 -0
  17. package/dist/engine/backoff.d.ts.map +1 -0
  18. package/dist/engine/backoff.js +18 -0
  19. package/dist/engine/backoff.js.map +1 -0
  20. package/dist/engine/capability.d.ts +52 -0
  21. package/dist/engine/capability.d.ts.map +1 -0
  22. package/dist/engine/capability.js +76 -0
  23. package/dist/engine/capability.js.map +1 -0
  24. package/dist/engine/claim-liveness.d.ts +86 -0
  25. package/dist/engine/claim-liveness.d.ts.map +1 -0
  26. package/dist/engine/claim-liveness.js +108 -0
  27. package/dist/engine/claim-liveness.js.map +1 -0
  28. package/dist/engine/eligibility.d.ts.map +1 -1
  29. package/dist/engine/eligibility.js +15 -4
  30. package/dist/engine/eligibility.js.map +1 -1
  31. package/dist/engine/execution-loop.d.ts +8 -0
  32. package/dist/engine/execution-loop.d.ts.map +1 -1
  33. package/dist/engine/execution-loop.js +310 -43
  34. package/dist/engine/execution-loop.js.map +1 -1
  35. package/dist/engine/reclaim-step.d.ts +37 -0
  36. package/dist/engine/reclaim-step.d.ts.map +1 -0
  37. package/dist/engine/reclaim-step.js +122 -0
  38. package/dist/engine/reclaim-step.js.map +1 -0
  39. package/dist/evidence/snapshot.d.ts +5 -0
  40. package/dist/evidence/snapshot.d.ts.map +1 -1
  41. package/dist/evidence/snapshot.js +3 -0
  42. package/dist/evidence/snapshot.js.map +1 -1
  43. package/dist/index.d.ts +7 -1
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +4 -1
  46. package/dist/index.js.map +1 -1
  47. package/dist/store/json-file-store.d.ts +2 -0
  48. package/dist/store/json-file-store.d.ts.map +1 -1
  49. package/dist/store/json-file-store.js +64 -6
  50. package/dist/store/json-file-store.js.map +1 -1
  51. package/dist/store/store-interface.d.ts +9 -0
  52. package/dist/store/store-interface.d.ts.map +1 -1
  53. package/dist/types/run-record.d.ts +60 -0
  54. package/dist/types/run-record.d.ts.map +1 -1
  55. package/dist/types/workflow-definition.d.ts +24 -1
  56. package/dist/types/workflow-definition.d.ts.map +1 -1
  57. package/dist/types/workflow-error.d.ts +1 -1
  58. package/dist/types/workflow-error.d.ts.map +1 -1
  59. package/dist/types/workflow-error.js.map +1 -1
  60. package/dist/workflow/yaml-loader.d.ts.map +1 -1
  61. package/dist/workflow/yaml-loader.js +113 -2
  62. package/dist/workflow/yaml-loader.js.map +1 -1
  63. package/package.json +1 -1
@@ -3,14 +3,17 @@ import { WorkflowError } from '../types/workflow-error.js';
3
3
  import { captureEvidence } from '../evidence/snapshot.js';
4
4
  import { validateInputSchema, validateOutputSchema, validateTraceSchema, } from '../validation/input-schema.js';
5
5
  import { normalizeTrace } from './trace-normalizer.js';
6
- import { TERMINAL_PHASES, isTerminalPhase } from './lifecycle.js';
6
+ import { TERMINAL_PHASES, isTerminalPhase, DRAIN_CEILING_SECONDS } from './lifecycle.js';
7
+ import { omitClaim, shouldEnforceTimeout, DEFAULT_EXECUTION_TIMEOUT_SECONDS, } from './claim-liveness.js';
8
+ import { computeBackoff } from './backoff.js';
7
9
  import { checkPreconditions, evaluateAllPreconditions, evaluateGuardConditions, } from './precondition.js';
8
10
  import { ExtensionRegistry } from '../extensions/registry.js';
9
11
  import { createDefaultRegistry } from '../extensions/default-registry.js';
10
12
  import { renderTemplate, resolvePath, UnknownFilterError } from './render-template.js';
11
13
  import { generateSchemaSkeleton } from '../utils/schema-skeleton.js';
12
14
  import { loadWorkflowContext } from './workflow-context-loader.js';
13
- import { findEligibleSteps, findEligibleGuardSteps, isWorkflowComplete, buildEvidenceByStep, propagateSkips, } from './eligibility.js';
15
+ import { findEligibleSteps, findEligibleGuardSteps, isWorkflowComplete, buildEvidenceByStep, propagateSkips, deriveRunPhase, } from './eligibility.js';
16
+ import { requirementForStep } from './capability.js';
14
17
  import { resolvePreExecutionAgentAction, resolvePostDispatchAgentAction, } from './error-resolution.js';
15
18
  function delayMs(ms) {
16
19
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -83,23 +86,6 @@ function resolveInputMapNode(node, root, keyChain, depth) {
83
86
  }
84
87
  return result;
85
88
  }
86
- /** Computes the delay (ms) before a retry attempt based on the configured backoff strategy. */
87
- function computeBackoff(config, attemptNum) {
88
- const backoff = config.backoff ?? 'fixed';
89
- const base = config.base_delay_ms ?? 0;
90
- let delay;
91
- switch (backoff) {
92
- case 'linear':
93
- delay = base * attemptNum;
94
- break;
95
- case 'exponential':
96
- delay = base * Math.pow(2, attemptNum - 1);
97
- break;
98
- default: // 'fixed'
99
- delay = base;
100
- }
101
- return config.max_delay_ms !== undefined ? Math.min(delay, config.max_delay_ms) : delay;
102
- }
103
89
  /**
104
90
  * Resolves and calls the service adapter for an auto step with `uses_service`.
105
91
  *
@@ -130,7 +116,9 @@ async function callAdapter(stepDef, definition, options, pendingRun, rateLimiter
130
116
  ? `. Declare this adapter under 'adapters:' in realm.yaml at your deployment root.`
131
117
  : '';
132
118
  throw new WorkflowError(`Adapter '${serviceDef.adapter}' for service '${serviceName}' is not registered${extensionHint}`, {
133
- code: 'ENGINE_ADAPTER_FAILED',
119
+ // #134 discriminator: minted at the NOT-REGISTERED site ONLY (not the service-not-found or
120
+ // adapter-runtime throws), so Step 5 can settle this RECOVERABLY instead of terminal-burning.
121
+ code: 'ENGINE_ADAPTER_NOT_REGISTERED',
134
122
  category: 'ENGINE',
135
123
  agentAction: 'stop',
136
124
  retryable: false,
@@ -252,7 +240,9 @@ async function callHandler(stepDef, options, pendingRun, evidenceByStep, signal)
252
240
  const handler = (options.registry ?? createDefaultRegistry()).getHandler(handlerName);
253
241
  if (handler === undefined) {
254
242
  throw new WorkflowError(`Handler '${handlerName}' is not registered`, {
255
- code: 'ENGINE_HANDLER_FAILED',
243
+ // #134 discriminator: minted at the NOT-REGISTERED site ONLY (not the ran-and-threw throw
244
+ // below), so Step 5 can settle this RECOVERABLY instead of terminal-burning the step.
245
+ code: 'ENGINE_HANDLER_NOT_REGISTERED',
256
246
  category: 'ENGINE',
257
247
  agentAction: 'stop',
258
248
  retryable: false,
@@ -684,7 +674,17 @@ export async function executeStep(store, definition, options) {
684
674
  // Step 4: Dispatch with retry and timeout.
685
675
  const retryConfig = stepDef?.retry;
686
676
  const maxAttempts = retryConfig?.max_attempts ?? 1;
687
- const timeoutMs = stepDef?.timeout_seconds !== undefined ? stepDef.timeout_seconds * 1000 : undefined;
677
+ // A3: every `execution: 'auto'` step is bounded — authored timeout_seconds if declared, else the
678
+ // generous DEFAULT_EXECUTION_TIMEOUT_SECONDS default. Resolved ONCE here (before the retry loop
679
+ // below), not per-attempt. Agent/guard steps (shouldEnforceTimeout false) are untouched: agent
680
+ // dispatch stays the instant-return no-op it always was, never wrapped in withTimeout.
681
+ // effectiveTimeoutSeconds is the single source of truth; timeoutMs is derived from it so the
682
+ // two can never diverge. It is also surfaced onto the evidence snapshot below.
683
+ const enforceTimeout = stepDef !== undefined && shouldEnforceTimeout(stepDef);
684
+ const effectiveTimeoutSeconds = enforceTimeout
685
+ ? (stepDef.timeout_seconds ?? DEFAULT_EXECUTION_TIMEOUT_SECONDS)
686
+ : undefined;
687
+ const timeoutMs = effectiveTimeoutSeconds !== undefined ? effectiveTimeoutSeconds * 1000 : undefined;
688
688
  // Create a stable rate-limiter registry for all retry attempts of this step.
689
689
  // Shared state ensures that a pause() triggered on attempt N is still in effect
690
690
  // when the proactive acquire() runs on attempt N+1. When the caller provides an
@@ -754,6 +754,8 @@ export async function executeStep(store, definition, options) {
754
754
  const withHandlerSkipped = {
755
755
  ...pendingRun,
756
756
  in_progress_steps: pendingRun.in_progress_steps.filter((s) => s !== options.command),
757
+ // Delete the claim clock in the SAME mutation that removes the step (issue #101).
758
+ claims: omitClaim(pendingRun.claims, options.command),
757
759
  evidence: [...pendingRun.evidence, abortEvidence],
758
760
  skipped_steps: [...pendingRun.skipped_steps, options.command],
759
761
  };
@@ -761,7 +763,7 @@ export async function executeStep(store, definition, options) {
761
763
  ...withHandlerSkipped,
762
764
  skipped_steps: propagateSkips(withHandlerSkipped, definition),
763
765
  };
764
- const abortedRun = {
766
+ const abortDraft = {
765
767
  ...withAllSkipped,
766
768
  terminal_state: true,
767
769
  terminal_reason: `Handler '${options.command}' aborted the run: ${abortMessage}`,
@@ -770,6 +772,9 @@ export async function executeStep(store, definition, options) {
770
772
  abort_message: abortMessage,
771
773
  },
772
774
  };
775
+ // NEW: a handler-abort now drains the abort/always finalizers before sealing
776
+ // (previously it sealed-and-returned immediately). Single seal write below.
777
+ const abortedRun = await buildFinalizedSeal(definition, abortDraft, 'abort', options.registry);
773
778
  let persistedAbortRun;
774
779
  try {
775
780
  persistedAbortRun = await store.update(abortedRun);
@@ -836,6 +841,7 @@ export async function executeStep(store, definition, options) {
836
841
  ...(options.stepMeta?.toolCalls !== undefined
837
842
  ? { toolCalls: options.stepMeta.toolCalls }
838
843
  : {}),
844
+ ...(effectiveTimeoutSeconds !== undefined ? { effectiveTimeoutSeconds } : {}),
839
845
  // Gate trace to agent steps only — drop silently for auto/adapter/handler steps.
840
846
  // When pre-normalized (WAL merge + schema validation ran), pass the pre-normalized
841
847
  // result to avoid double normalization. Also handle WAL-only case (options.trace may
@@ -868,25 +874,122 @@ export async function executeStep(store, definition, options) {
868
874
  }
869
875
  if (dispatchError !== null && retryConfig !== undefined && attemptsUsed === maxAttempts) {
870
876
  const lastError = dispatchError;
871
- dispatchError = new WorkflowError(`Step '${options.command}' failed after ${attemptsUsed} attempts`, {
872
- code: 'STEP_RETRY_EXHAUSTED',
873
- category: 'ENGINE',
874
- agentAction: 'report_to_user',
875
- retryable: false,
876
- details: {
877
- stepName: options.command,
878
- attempts: attemptsUsed,
879
- lastError: lastError.message,
880
- ...(lastError.retry_after !== undefined ? { retry_after: lastError.retry_after } : {}),
881
- },
882
- });
877
+ // #134: do NOT wrap a recoverable-incapability error (a max_attempts:1 not-registered failure
878
+ // hits attemptsUsed === maxAttempts). The STEP_RETRY_EXHAUSTED wrap discards the inner code, which
879
+ // would rob Step 5 of the discriminator it needs to settle recoverably. Leave dispatchError as the
880
+ // original not-registered error; all other codes wrap unchanged.
881
+ const isRecoverableIncapability = lastError instanceof WorkflowError &&
882
+ (lastError.code === 'ENGINE_HANDLER_NOT_REGISTERED' ||
883
+ lastError.code === 'ENGINE_ADAPTER_NOT_REGISTERED');
884
+ if (!isRecoverableIncapability) {
885
+ dispatchError = new WorkflowError(`Step '${options.command}' failed after ${attemptsUsed} attempts`, {
886
+ code: 'STEP_RETRY_EXHAUSTED',
887
+ category: 'ENGINE',
888
+ agentAction: 'report_to_user',
889
+ retryable: false,
890
+ details: {
891
+ stepName: options.command,
892
+ attempts: attemptsUsed,
893
+ lastError: lastError.message,
894
+ ...(lastError.retry_after !== undefined ? { retry_after: lastError.retry_after } : {}),
895
+ },
896
+ });
897
+ }
883
898
  }
884
899
  // Step 5: Handle dispatch failure — move step to failed_steps.
885
900
  if (dispatchError !== null) {
901
+ // #134 recoverable-incapability settle: a NOT-REGISTERED handler/adapter means THIS runner cannot
902
+ // execute the step, but a correctly-provisioned runner can. Terminal-burning it into failed_steps
903
+ // would make it permanently un-reclaimable. Instead settle RECOVERABLY: drop it from in_progress and
904
+ // omit its claim (same mutation), do NOT add it to failed_steps, do NOT seal the run, record a
905
+ // capability_blocks marker for diagnostics, and let it fall back to eligible so a capable runner
906
+ // reclaims it. Genuine ran-and-threw / service-not-found / adapter-runtime failures keep their
907
+ // ENGINE_*_FAILED codes and fall through to the terminal path below unchanged.
908
+ const recoverableCode = dispatchError instanceof WorkflowError &&
909
+ (dispatchError.code === 'ENGINE_HANDLER_NOT_REGISTERED' ||
910
+ dispatchError.code === 'ENGINE_ADAPTER_NOT_REGISTERED')
911
+ ? dispatchError.code
912
+ : undefined;
913
+ if (recoverableCode !== undefined) {
914
+ const requirement = requirementForStep(options.command, stepDef, definition);
915
+ const blockedDraft = {
916
+ ...pendingRun,
917
+ in_progress_steps: pendingRun.in_progress_steps.filter((s) => s !== options.command),
918
+ // Delete the claim clock in the SAME mutation that removes the step (issue #101).
919
+ claims: omitClaim(pendingRun.claims, options.command),
920
+ evidence: [...pendingRun.evidence, ...allEvidence],
921
+ capability_blocks: {
922
+ ...pendingRun.capability_blocks,
923
+ [options.command]: {
924
+ requirement: requirement !== undefined
925
+ ? { kind: requirement.kind, name: requirement.name }
926
+ : {
927
+ kind: recoverableCode === 'ENGINE_HANDLER_NOT_REGISTERED' ? 'handler' : 'adapter',
928
+ name: 'unknown',
929
+ },
930
+ code: recoverableCode,
931
+ at: new Date().toISOString(),
932
+ },
933
+ },
934
+ };
935
+ // Non-terminal: recompute the phase so the store-fail fallback below is correct too
936
+ // (on the happy path store.update recomputes it identically via deriveRunPhase).
937
+ const blockedRun = { ...blockedDraft, run_phase: deriveRunPhase(blockedDraft) };
938
+ let persistedBlockedRun;
939
+ let blockStoreWarning;
940
+ try {
941
+ persistedBlockedRun = await store.update(blockedRun);
942
+ }
943
+ catch (storeErr) {
944
+ blockStoreWarning = `Failed to persist capability block: ${storeErr instanceof Error ? storeErr.message : String(storeErr)}`;
945
+ }
946
+ let blockWalWarning;
947
+ try {
948
+ // Delete WAL after run state is written — the step's entries are now in evidence.
949
+ await options.traceBufferStore?.delete(options.runId, options.command);
950
+ }
951
+ catch (walErr) {
952
+ blockWalWarning = `Failed to clean up trace buffer after capability block: ${walErr instanceof Error ? walErr.message : String(walErr)}`;
953
+ }
954
+ // Non-terminal 'stop' → 'report_to_user' via the existing mapping: a human must provision the
955
+ // runner (or re-run on a capable one); no further progress is possible on THIS runner.
956
+ const blockedAction = resolvePostDispatchAgentAction(dispatchError, false);
957
+ let blockedNextActions = [];
958
+ if (blockedAction !== 'stop' && blockStoreWarning === undefined) {
959
+ try {
960
+ blockedNextActions = buildNextActions(definition, persistedBlockedRun ?? blockedRun);
961
+ }
962
+ catch {
963
+ // buildNextActions can throw for unresolvable template references; fall back to [].
964
+ }
965
+ }
966
+ const reqLabel = requirement !== undefined
967
+ ? `${requirement.kind} '${requirement.name}'`
968
+ : recoverableCode === 'ENGINE_HANDLER_NOT_REGISTERED'
969
+ ? 'handler'
970
+ : 'adapter';
971
+ return {
972
+ command: options.command,
973
+ run_id: options.runId,
974
+ run_version: (persistedBlockedRun ?? blockedRun).version,
975
+ status: 'error',
976
+ data: {},
977
+ evidence: allEvidence,
978
+ warnings: mergeWarnings(traceWarnings, blockStoreWarning ?? blockWalWarning),
979
+ errors: [dispatchError.message],
980
+ agent_action: blockedAction,
981
+ error_code: recoverableCode,
982
+ context_hint: `Step '${options.command}' is blocked: its ${reqLabel} is not registered in this runner. The run is NOT terminated — the step remains eligible, so a runner that provides this ${requirement?.kind ?? 'capability'} can execute it. Provision this runner (or re-run on a capable one), then follow next_actions.`,
983
+ run_phase: (persistedBlockedRun ?? blockedRun).run_phase,
984
+ next_actions: blockedNextActions,
985
+ };
986
+ }
886
987
  // Pure in-memory derivations — no I/O, no try required.
887
988
  const afterFail = {
888
989
  ...pendingRun,
889
990
  in_progress_steps: pendingRun.in_progress_steps.filter((s) => s !== options.command),
991
+ // Delete the claim clock in the SAME mutation that removes the step (issue #101).
992
+ claims: omitClaim(pendingRun.claims, options.command),
890
993
  failed_steps: [...pendingRun.failed_steps, options.command],
891
994
  };
892
995
  // Propagate skips: mark steps whose trigger_rule can never be satisfied after this failure.
@@ -901,7 +1004,7 @@ export async function executeStep(store, definition, options) {
901
1004
  (withSkippedFail.in_progress_steps.length === 0 &&
902
1005
  findEligibleSteps(definition, withSkippedFail).length === 0 &&
903
1006
  findEligibleGuardSteps(definition, withSkippedFail).length === 0);
904
- const failedRun = {
1007
+ const failDraft = {
905
1008
  ...withSkippedFail,
906
1009
  evidence: [...pendingRun.evidence, ...allEvidence],
907
1010
  terminal_state: isComplete,
@@ -909,6 +1012,11 @@ export async function executeStep(store, definition, options) {
909
1012
  ? { terminal_reason: `Step '${options.command}' failed: ${dispatchError.message}` }
910
1013
  : {}),
911
1014
  };
1015
+ // On the terminal transition, drain the fail/always finalizers before the single seal.
1016
+ // Non-terminal failures (recovery steps remain) run no finalizers.
1017
+ const failedRun = isComplete
1018
+ ? await buildFinalizedSeal(definition, failDraft, 'fail', options.registry)
1019
+ : failDraft;
912
1020
  // Persist run state and WAL cleanup in separate try/catch blocks so a WAL deletion
913
1021
  // failure does not mask a successful store.update.
914
1022
  let persistedRun;
@@ -1109,6 +1217,8 @@ export async function executeStep(store, definition, options) {
1109
1217
  const afterComplete = {
1110
1218
  ...pendingRun,
1111
1219
  in_progress_steps: pendingRun.in_progress_steps.filter((s) => s !== options.command),
1220
+ // Delete the claim clock in the SAME mutation that removes the step (issue #101).
1221
+ claims: omitClaim(pendingRun.claims, options.command),
1112
1222
  completed_steps: [...pendingRun.completed_steps, options.command],
1113
1223
  evidence: [...pendingRun.evidence, ...allEvidence],
1114
1224
  };
@@ -1125,11 +1235,15 @@ export async function executeStep(store, definition, options) {
1125
1235
  (withSkippedComplete.in_progress_steps.length === 0 &&
1126
1236
  findEligibleSteps(definition, withSkippedComplete).length === 0 &&
1127
1237
  findEligibleGuardSteps(definition, withSkippedComplete).length === 0);
1128
- const finalRun = {
1238
+ const completeDraft = {
1129
1239
  ...withSkippedComplete,
1130
1240
  terminal_state: isComplete,
1131
1241
  ...(isComplete ? { terminal_reason: `Workflow completed.` } : {}),
1132
1242
  };
1243
+ // On the terminal transition, drain the complete/always finalizers before the single seal.
1244
+ const finalRun = isComplete
1245
+ ? await buildFinalizedSeal(definition, completeDraft, 'complete', options.registry)
1246
+ : completeDraft;
1133
1247
  let savedRun;
1134
1248
  try {
1135
1249
  savedRun = await store.update(finalRun);
@@ -1250,6 +1364,8 @@ export async function submitHumanResponse(store, definition, options) {
1250
1364
  const afterGate = {
1251
1365
  ...rest,
1252
1366
  in_progress_steps: rest.in_progress_steps.filter((s) => s !== gateStepName),
1367
+ // Delete the claim clock in the SAME mutation that removes the step (issue #101).
1368
+ claims: omitClaim(rest.claims, gateStepName),
1253
1369
  completed_steps: [...rest.completed_steps, gateStepName],
1254
1370
  evidence: [...rest.evidence, gateSnapshot],
1255
1371
  };
@@ -1266,11 +1382,15 @@ export async function submitHumanResponse(store, definition, options) {
1266
1382
  (withSkippedGate.in_progress_steps.length === 0 &&
1267
1383
  findEligibleSteps(definition, withSkippedGate).length === 0 &&
1268
1384
  findEligibleGuardSteps(definition, withSkippedGate).length === 0);
1269
- const finalRun = {
1385
+ const gateDraft = {
1270
1386
  ...withSkippedGate,
1271
1387
  terminal_state: isComplete,
1272
1388
  ...(isComplete ? { terminal_reason: `Workflow completed.` } : {}),
1273
1389
  };
1390
+ // On the gate-completion terminal transition, drain complete/always finalizers before seal.
1391
+ const finalRun = isComplete
1392
+ ? await buildFinalizedSeal(definition, gateDraft, 'complete', options.registry)
1393
+ : gateDraft;
1274
1394
  let savedRun;
1275
1395
  try {
1276
1396
  savedRun = await store.update(finalRun);
@@ -1418,6 +1538,129 @@ async function executeGuardStep(stepName, stepDef, definition, run) {
1418
1538
  },
1419
1539
  };
1420
1540
  }
1541
+ /** Normalizes a finalizer's `on_outcome` to a set of triggers. */
1542
+ function finalizerTriggers(stepDef) {
1543
+ const raw = stepDef.on_outcome;
1544
+ if (raw === undefined)
1545
+ return new Set();
1546
+ return new Set(Array.isArray(raw) ? raw : [raw]);
1547
+ }
1548
+ /**
1549
+ * Drains the finalizers matching a run's terminal `outcome` and returns the FINALIZED
1550
+ * RunRecord — the workflow-level try/catch/finally at the seal. Modeled on
1551
+ * executeGuardStep, but it does NOT persist: the caller performs the run's single seal
1552
+ * `store.update` with its own error/WAL/envelope handling.
1553
+ *
1554
+ * Selection & order:
1555
+ * - Group A (rank 0): `on_outcome` contains `outcome` (the specific catch/complete arm).
1556
+ * - Group B (rank 1): `on_outcome` contains `'always'` but NOT `outcome` (the `finally` arm;
1557
+ * a finalizer listing both runs once, in Group A).
1558
+ * - Each group in declaration order (`Object.entries`); Group A then Group B (`always` last).
1559
+ * - Idempotent at-most-once: any finalizer already in completed/failed_steps is skipped
1560
+ * (resume / re-drive safety).
1561
+ *
1562
+ * Each finalizer runs its handler via `callHandler` (never `claimStep`), wrapped in
1563
+ * `withTimeout` honoring `timeout_seconds` (default `DRAIN_CEILING_SECONDS`). Success →
1564
+ * evidence + completed_steps; thrown error / STEP_TIMEOUT / handler `{ abort }` → evidence
1565
+ * marked failed + failed_steps, NON-FATAL (the drain continues). A finalizer NEVER mutates
1566
+ * `aborted_at`, `terminal_reason`, `terminal_state`, `skipped_steps`, or emits next_actions —
1567
+ * the terminal marks come from `sealDraft` and `deriveRunPhase` precedence keeps the phase.
1568
+ */
1569
+ async function buildFinalizedSeal(definition, sealDraft, outcome, registry) {
1570
+ // Zero-finalizer fast path: no finalizer steps declared ⇒ return the seal draft
1571
+ // completely untouched (byte-identical to the pre-finalizer engine — the damage rail).
1572
+ const hasFinalizers = Object.values(definition.steps).some((s) => s.execution === 'finalizer');
1573
+ if (!hasFinalizers)
1574
+ return sealDraft;
1575
+ const settled = new Set([...sealDraft.completed_steps, ...sealDraft.failed_steps]);
1576
+ const groupA = [];
1577
+ const groupB = [];
1578
+ for (const [name, step] of Object.entries(definition.steps)) {
1579
+ if (step.execution !== 'finalizer')
1580
+ continue;
1581
+ if (settled.has(name))
1582
+ continue; // at-most-once per run (resume / re-drive safety)
1583
+ const triggers = finalizerTriggers(step);
1584
+ if (triggers.has(outcome))
1585
+ groupA.push([name, step]);
1586
+ else if (triggers.has('always'))
1587
+ groupB.push([name, step]);
1588
+ }
1589
+ let record = sealDraft;
1590
+ for (const [name, step] of [...groupA, ...groupB]) {
1591
+ const now = new Date();
1592
+ const evidenceByStep = buildEvidenceByStep(record);
1593
+ const timeoutMs = (step.timeout_seconds ?? DRAIN_CEILING_SECONDS) * 1000;
1594
+ // Minimal per-finalizer dispatch options: handler-only, no input (input_map prohibited),
1595
+ // no agent dispatcher path. callHandler resolves the handler from the injected registry.
1596
+ const options = {
1597
+ runId: record.id,
1598
+ command: name,
1599
+ input: {},
1600
+ dispatcher: async () => ({}),
1601
+ ...(registry !== undefined ? { registry } : {}),
1602
+ };
1603
+ try {
1604
+ const result = await withTimeout((signal) => callHandler(step, options, record, evidenceByStep, signal), timeoutMs, name);
1605
+ if (result.kind === 'abort') {
1606
+ // A finalizer handler returning { abort } is a recorded NON-FATAL failure — it must
1607
+ // never mutate aborted_at/terminal_reason (that would corrupt the sealed outcome).
1608
+ record = {
1609
+ ...record,
1610
+ evidence: [
1611
+ ...record.evidence,
1612
+ captureEvidence({
1613
+ stepId: name,
1614
+ startedAt: now,
1615
+ completedAt: new Date(),
1616
+ input: {},
1617
+ output: { aborted: true, abort_message: result.message },
1618
+ error: `Finalizer '${name}' returned abort: ${result.message}`,
1619
+ }),
1620
+ ],
1621
+ failed_steps: [...record.failed_steps, name],
1622
+ };
1623
+ }
1624
+ else {
1625
+ record = {
1626
+ ...record,
1627
+ evidence: [
1628
+ ...record.evidence,
1629
+ captureEvidence({
1630
+ stepId: name,
1631
+ startedAt: now,
1632
+ completedAt: new Date(),
1633
+ input: {},
1634
+ output: result.output,
1635
+ ...(result.kind === 'warn' ? { warn: result.message } : {}),
1636
+ }),
1637
+ ],
1638
+ completed_steps: [...record.completed_steps, name],
1639
+ };
1640
+ }
1641
+ }
1642
+ catch (err) {
1643
+ // Thrown handler error OR STEP_TIMEOUT → recorded failed, drain continues (non-fatal).
1644
+ const message = err instanceof Error ? err.message : String(err);
1645
+ record = {
1646
+ ...record,
1647
+ evidence: [
1648
+ ...record.evidence,
1649
+ captureEvidence({
1650
+ stepId: name,
1651
+ startedAt: now,
1652
+ completedAt: new Date(),
1653
+ input: {},
1654
+ output: {},
1655
+ error: `Finalizer '${name}' failed: ${message}`,
1656
+ }),
1657
+ ],
1658
+ failed_steps: [...record.failed_steps, name],
1659
+ };
1660
+ }
1661
+ }
1662
+ return { ...record, terminal_state: true };
1663
+ }
1421
1664
  async function executeChainInternal(store, definition, options, depth, chainedSteps) {
1422
1665
  if (depth > MAX_CHAIN_DEPTH) {
1423
1666
  return {
@@ -1469,10 +1712,29 @@ async function executeChainInternal(store, definition, options, depth, chainedSt
1469
1712
  const guardStepDef = definition.steps[guardName];
1470
1713
  // Execute inline (pure in-memory; returns updated RunRecord).
1471
1714
  const guardResult = await executeGuardStep(guardName, guardStepDef, definition, run);
1715
+ // Capture the guard's OWN evidence (its last entry) BEFORE the finalizer drain appends
1716
+ // finalizer evidence — the terminal return below surfaces only the guard's evidence.
1717
+ const guardOwnEvidence = guardResult.evidence.slice(-1);
1718
+ // Blocking fix #1: classify the terminal outcome by the SEALED record, not aborted_at
1719
+ // alone. executeGuardStep sets terminal_state in THREE cases — abort (aborted_at set),
1720
+ // resolution-error (failed, no aborted_at), and a PASS that completes the run
1721
+ // (terminal_reason 'Workflow completed.', no aborted_at). `aborted_at ? 'abort' : 'fail'`
1722
+ // would wrongly run the catch finalizers on that success. When terminal, drain the
1723
+ // matching finalizers before the single seal write; non-terminal guard passes persist as-is.
1724
+ const guardOutcome = guardResult.terminal_state
1725
+ ? guardResult.aborted_at !== undefined
1726
+ ? 'abort'
1727
+ : guardResult.terminal_reason === 'Workflow completed.'
1728
+ ? 'complete'
1729
+ : 'fail'
1730
+ : undefined;
1731
+ const guardSealed = guardOutcome !== undefined
1732
+ ? await buildFinalizedSeal(definition, guardResult, guardOutcome, options.registry)
1733
+ : guardResult;
1472
1734
  // Persist the guard step result.
1473
1735
  let persistedGuardRun;
1474
1736
  try {
1475
- persistedGuardRun = await store.update(guardResult);
1737
+ persistedGuardRun = await store.update(guardSealed);
1476
1738
  }
1477
1739
  catch (storeErr) {
1478
1740
  const msg = storeErr instanceof Error ? storeErr.message : String(storeErr);
@@ -1494,17 +1756,22 @@ async function executeChainInternal(store, definition, options, depth, chainedSt
1494
1756
  // Record in chained_auto_steps for visibility.
1495
1757
  chainedSteps.push({ step: guardName, run_phase: persistedGuardRun.run_phase });
1496
1758
  if (persistedGuardRun.terminal_state) {
1497
- // Guard aborted or had a resolution error run is terminal.
1498
- const contextHint = persistedGuardRun.aborted_at !== undefined
1759
+ // Run is terminal via this guard. Adjacent pre-existing bug fixed: a PASSING guard that
1760
+ // COMPLETES the run was mislabeled "failed with a resolution error" — describe each of
1761
+ // the three terminal outcomes correctly (classified by guardOutcome, not aborted_at alone).
1762
+ const contextHint = guardOutcome === 'abort'
1499
1763
  ? `Guard step '${guardName}' aborted the run.`
1500
- : `Guard step '${guardName}' failed with a resolution error. Run is terminated.`;
1764
+ : guardOutcome === 'complete'
1765
+ ? `Guard step '${guardName}' passed and completed the run.`
1766
+ : `Guard step '${guardName}' failed with a resolution error. Run is terminated.`;
1501
1767
  return {
1502
1768
  command: options.command,
1503
1769
  run_id: options.runId,
1504
1770
  run_version: persistedGuardRun.version,
1505
1771
  status: 'ok',
1506
1772
  data: {},
1507
- evidence: persistedGuardRun.evidence.slice(-1),
1773
+ // The guard's own evidence entry, captured before the finalizer drain appended any.
1774
+ evidence: guardOwnEvidence,
1508
1775
  warnings: [],
1509
1776
  errors: [],
1510
1777
  context_hint: contextHint,