@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.
- package/dist/adapters/adapter-utils.d.ts +6 -0
- package/dist/adapters/adapter-utils.d.ts.map +1 -1
- package/dist/adapters/adapter-utils.js +21 -0
- package/dist/adapters/adapter-utils.js.map +1 -1
- package/dist/adapters/file-adapter.d.ts +1 -1
- package/dist/adapters/file-adapter.d.ts.map +1 -1
- package/dist/adapters/file-adapter.js +10 -2
- package/dist/adapters/file-adapter.js.map +1 -1
- package/dist/adapters/gorgias-adapter.d.ts +1 -1
- package/dist/adapters/gorgias-adapter.d.ts.map +1 -1
- package/dist/adapters/gorgias-adapter.js +28 -13
- package/dist/adapters/gorgias-adapter.js.map +1 -1
- package/dist/adapters/slack-adapter.d.ts.map +1 -1
- package/dist/adapters/slack-adapter.js +11 -0
- package/dist/adapters/slack-adapter.js.map +1 -1
- package/dist/engine/backoff.d.ts +4 -0
- package/dist/engine/backoff.d.ts.map +1 -0
- package/dist/engine/backoff.js +18 -0
- package/dist/engine/backoff.js.map +1 -0
- package/dist/engine/capability.d.ts +52 -0
- package/dist/engine/capability.d.ts.map +1 -0
- package/dist/engine/capability.js +76 -0
- package/dist/engine/capability.js.map +1 -0
- package/dist/engine/claim-liveness.d.ts +86 -0
- package/dist/engine/claim-liveness.d.ts.map +1 -0
- package/dist/engine/claim-liveness.js +108 -0
- package/dist/engine/claim-liveness.js.map +1 -0
- package/dist/engine/eligibility.d.ts.map +1 -1
- package/dist/engine/eligibility.js +15 -4
- package/dist/engine/eligibility.js.map +1 -1
- package/dist/engine/execution-loop.d.ts +8 -0
- package/dist/engine/execution-loop.d.ts.map +1 -1
- package/dist/engine/execution-loop.js +310 -43
- package/dist/engine/execution-loop.js.map +1 -1
- package/dist/engine/reclaim-step.d.ts +37 -0
- package/dist/engine/reclaim-step.d.ts.map +1 -0
- package/dist/engine/reclaim-step.js +122 -0
- package/dist/engine/reclaim-step.js.map +1 -0
- package/dist/evidence/snapshot.d.ts +5 -0
- package/dist/evidence/snapshot.d.ts.map +1 -1
- package/dist/evidence/snapshot.js +3 -0
- package/dist/evidence/snapshot.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/store/json-file-store.d.ts +2 -0
- package/dist/store/json-file-store.d.ts.map +1 -1
- package/dist/store/json-file-store.js +64 -6
- package/dist/store/json-file-store.js.map +1 -1
- package/dist/store/store-interface.d.ts +9 -0
- package/dist/store/store-interface.d.ts.map +1 -1
- package/dist/types/run-record.d.ts +60 -0
- package/dist/types/run-record.d.ts.map +1 -1
- package/dist/types/workflow-definition.d.ts +24 -1
- package/dist/types/workflow-definition.d.ts.map +1 -1
- package/dist/types/workflow-error.d.ts +1 -1
- package/dist/types/workflow-error.d.ts.map +1 -1
- package/dist/types/workflow-error.js.map +1 -1
- package/dist/workflow/yaml-loader.d.ts.map +1 -1
- package/dist/workflow/yaml-loader.js +113 -2
- package/dist/workflow/yaml-loader.js.map +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
//
|
|
1498
|
-
|
|
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
|
-
:
|
|
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
|
|
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,
|