@sensigo/realm 0.31.2 → 0.33.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/engine/abandon-run.d.ts.map +1 -1
- package/dist/engine/abandon-run.js +17 -9
- package/dist/engine/abandon-run.js.map +1 -1
- package/dist/engine/apply-resume.d.ts +43 -0
- package/dist/engine/apply-resume.d.ts.map +1 -0
- package/dist/engine/apply-resume.js +80 -0
- package/dist/engine/apply-resume.js.map +1 -0
- package/dist/engine/defaulted-steps.d.ts +17 -0
- package/dist/engine/defaulted-steps.d.ts.map +1 -0
- package/dist/engine/defaulted-steps.js +26 -0
- package/dist/engine/defaulted-steps.js.map +1 -0
- package/dist/engine/eligibility.d.ts +20 -2
- package/dist/engine/eligibility.d.ts.map +1 -1
- package/dist/engine/eligibility.js +46 -24
- package/dist/engine/eligibility.js.map +1 -1
- package/dist/engine/execution-loop.d.ts +38 -3
- package/dist/engine/execution-loop.d.ts.map +1 -1
- package/dist/engine/execution-loop.js +1500 -112
- package/dist/engine/execution-loop.js.map +1 -1
- package/dist/engine/lifecycle.d.ts +14 -0
- package/dist/engine/lifecycle.d.ts.map +1 -1
- package/dist/engine/lifecycle.js +14 -0
- package/dist/engine/lifecycle.js.map +1 -1
- package/dist/engine/run-health.d.ts +1 -1
- package/dist/engine/run-health.d.ts.map +1 -1
- package/dist/engine/run-health.js +88 -4
- package/dist/engine/run-health.js.map +1 -1
- package/dist/engine/settlement.d.ts +38 -0
- package/dist/engine/settlement.d.ts.map +1 -0
- package/dist/engine/settlement.js +825 -0
- package/dist/engine/settlement.js.map +1 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/dist/store/json-file-store.d.ts +56 -3
- package/dist/store/json-file-store.d.ts.map +1 -1
- package/dist/store/json-file-store.js +178 -22
- package/dist/store/json-file-store.js.map +1 -1
- package/dist/store/store-interface.d.ts +50 -1
- package/dist/store/store-interface.d.ts.map +1 -1
- package/dist/types/run-record.d.ts +90 -0
- package/dist/types/run-record.d.ts.map +1 -1
- package/dist/types/settlement.d.ts +248 -0
- package/dist/types/settlement.d.ts.map +1 -0
- package/dist/types/settlement.js +2 -0
- package/dist/types/settlement.js.map +1 -0
- 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/package.json +1 -1
|
@@ -3,10 +3,12 @@ import { WorkflowError } from '../types/workflow-error.js';
|
|
|
3
3
|
import { persistsField } from '../store/store-fidelity.js';
|
|
4
4
|
import { storeDeclaresSeal, storeDeclaresNonceCarriage } from '../store/trace-buffer-store.js';
|
|
5
5
|
import { partitionBufferedEntries } from './trace-adoption.js';
|
|
6
|
+
import { deriveDefaultedSteps } from './defaulted-steps.js';
|
|
7
|
+
import { selectFinalizers } from './settlement.js';
|
|
6
8
|
import { captureEvidence } from '../evidence/snapshot.js';
|
|
7
9
|
import { validateInputSchema, validateOutputSchema, validateTraceSchema, } from '../validation/input-schema.js';
|
|
8
10
|
import { normalizeTrace } from './trace-normalizer.js';
|
|
9
|
-
import { TERMINAL_PHASES,
|
|
11
|
+
import { TERMINAL_PHASES, DRAIN_CEILING_SECONDS } from './lifecycle.js';
|
|
10
12
|
import { omitClaim, shouldEnforceTimeout, DEFAULT_EXECUTION_TIMEOUT_SECONDS, resolveCapMs, sleepWouldExceedCap, } from './claim-liveness.js';
|
|
11
13
|
import { computeBackoff } from './backoff.js';
|
|
12
14
|
import { checkPreconditions, evaluateAllPreconditions, evaluateGuardConditions, } from './precondition.js';
|
|
@@ -376,11 +378,11 @@ function mergeWarnings(traceWarnings, ...extraWarnings) {
|
|
|
376
378
|
return [...traceWarnings, ...defined];
|
|
377
379
|
}
|
|
378
380
|
/**
|
|
379
|
-
* Pure helper (issue #220 PR-2, D6):
|
|
380
|
-
*
|
|
381
|
-
* `defaulted_steps` set to their distinct step names
|
|
382
|
-
*
|
|
383
|
-
*
|
|
381
|
+
* Pure helper (issue #220 PR-2, D6): if `sealDraft`'s evidence has any entries default-settled
|
|
382
|
+
* (per {@link deriveDefaultedSteps}, issue #232 — the SHARED derivation the read surfaces also
|
|
383
|
+
* use), returns the record with `defaulted_steps` set to their distinct step names — else returns
|
|
384
|
+
* the SAME record reference unchanged, so a run with no default-settle anywhere is byte-identical
|
|
385
|
+
* to pre-PR-2 behavior (the damage-rail this preserves). Pure, no I/O.
|
|
384
386
|
*
|
|
385
387
|
* Applied by WRAPPING the `buildFinalizedSeal` call inside the TERMINAL branch of each seal
|
|
386
388
|
* ternary — `buildFinalizedSeal` itself stays byte-untouched (a chokepoint insertion was
|
|
@@ -388,21 +390,36 @@ function mergeWarnings(traceWarnings, ...extraWarnings) {
|
|
|
388
390
|
* fragile two-touch above the damage-rail fast-path). Callers must pass ONLY a sealed record from
|
|
389
391
|
* the `'complete'` branch — never a non-terminal draft, and never a fail/abort seal — so
|
|
390
392
|
* `defaulted_steps` never leaks onto a persisted non-terminal record that a later FAIL seal
|
|
391
|
-
* inherits (the FM-5 residual this guards).
|
|
393
|
+
* inherits (the FM-5 residual this guards). issue #232 note: this complete-only stamping is
|
|
394
|
+
* UNCHANGED — a run that fails/aborts still carries no persisted `defaulted_steps`; the failure-
|
|
395
|
+
* path disclosure gap is closed by the READ surfaces calling `deriveDefaultedSteps` directly, not
|
|
396
|
+
* by widening what gets persisted here.
|
|
392
397
|
*/
|
|
393
398
|
function stampDefaultedSteps(sealDraft) {
|
|
394
|
-
const steps =
|
|
395
|
-
const seen = new Set();
|
|
396
|
-
for (const snap of sealDraft.evidence) {
|
|
397
|
-
if (snap.diagnostics?.settled_by_default === true && !seen.has(snap.step_id)) {
|
|
398
|
-
seen.add(snap.step_id);
|
|
399
|
-
steps.push(snap.step_id);
|
|
400
|
-
}
|
|
401
|
-
}
|
|
399
|
+
const steps = deriveDefaultedSteps(sealDraft.evidence);
|
|
402
400
|
if (steps.length === 0)
|
|
403
401
|
return sealDraft;
|
|
404
402
|
return { ...sealDraft, defaulted_steps: steps };
|
|
405
403
|
}
|
|
404
|
+
/**
|
|
405
|
+
* The compensating un-claim's own audit-evidence entry (issue #207 PR-2, D3 §5; extracted issue
|
|
406
|
+
* #279, increment 2, PR-D, Deliverable 1e — the `:679` semantics both the legacy
|
|
407
|
+
* `buildCompensatingUnclaim` below AND the migrated `release_step` delta's `evidence` field share
|
|
408
|
+
* verbatim).
|
|
409
|
+
*/
|
|
410
|
+
function buildCompensatingUnclaimEvidence(stepName, now) {
|
|
411
|
+
return captureEvidence({
|
|
412
|
+
stepId: stepName,
|
|
413
|
+
startedAt: now,
|
|
414
|
+
completedAt: now,
|
|
415
|
+
input: {},
|
|
416
|
+
output: {
|
|
417
|
+
compensating_unclaim: true,
|
|
418
|
+
reason: 'adoption-read failure after claim',
|
|
419
|
+
unclaimed_at: now.toISOString(),
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
}
|
|
406
423
|
/**
|
|
407
424
|
* Compensating un-claim (issue #207 PR-2, D3 §5): built from `pendingRun` — the record OUR OWN
|
|
408
425
|
* `claimStep` call returned, never a fresh get — removing the step from `in_progress_steps` AND
|
|
@@ -416,17 +433,7 @@ function stampDefaultedSteps(sealDraft) {
|
|
|
416
433
|
* naturally idempotent) is simply a no-op mutation, not a special case.
|
|
417
434
|
*/
|
|
418
435
|
function buildCompensatingUnclaim(pendingRun, stepName, now) {
|
|
419
|
-
const auditEvidence =
|
|
420
|
-
stepId: stepName,
|
|
421
|
-
startedAt: now,
|
|
422
|
-
completedAt: now,
|
|
423
|
-
input: {},
|
|
424
|
-
output: {
|
|
425
|
-
compensating_unclaim: true,
|
|
426
|
-
reason: 'adoption-read failure after claim',
|
|
427
|
-
unclaimed_at: now.toISOString(),
|
|
428
|
-
},
|
|
429
|
-
});
|
|
436
|
+
const auditEvidence = buildCompensatingUnclaimEvidence(stepName, now);
|
|
430
437
|
return {
|
|
431
438
|
...pendingRun,
|
|
432
439
|
in_progress_steps: pendingRun.in_progress_steps.filter((s) => s !== stepName),
|
|
@@ -595,6 +602,174 @@ function makeErrorEnvelope(options, run, err, definition, extraWarnings) {
|
|
|
595
602
|
}
|
|
596
603
|
return baseWithWarnings;
|
|
597
604
|
}
|
|
605
|
+
/**
|
|
606
|
+
* Design record §6: a claim-time refusal envelope's advisory line when the fresh run carries
|
|
607
|
+
* finalizer_ledger pendings — points the caller at the recovery verb. `undefined` when there is
|
|
608
|
+
* nothing pending (the common case — never emits an empty/placeholder advisory).
|
|
609
|
+
*/
|
|
610
|
+
function finalizerDrainAdvisory(run) {
|
|
611
|
+
const pendingCount = Object.values(run.finalizer_ledger ?? {}).filter((e) => e.status === 'pending').length;
|
|
612
|
+
if (pendingCount === 0)
|
|
613
|
+
return undefined;
|
|
614
|
+
return `${pendingCount} finalizer(s) not yet delivered — realm run drain ${run.id}`;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Re-arm disclosure (final-gate F10b, design record §6) — the settling caller's own comparison,
|
|
618
|
+
* NEVER inside the frozen `applySettlement` transform: any finalizer that was `'voided'` (an
|
|
619
|
+
* operator `--void`) in `before.finalizer_ledger` and is `'pending'` again in
|
|
620
|
+
* `after.finalizer_ledger` was just RE-ARMED by mintFresh on THIS terminal edge (a later
|
|
621
|
+
* fail-then-complete-differently — or any outcome that newly selects it — re-mints a clean pending
|
|
622
|
+
* entry per §4's mint rule; mintFresh has no memory of a prior void, by design). Called only when
|
|
623
|
+
* `result.transitioned === true` (mintFresh only ever runs on the terminal false→true edge).
|
|
624
|
+
*/
|
|
625
|
+
function computeReArmWarnings(before, after) {
|
|
626
|
+
const warnings = [];
|
|
627
|
+
for (const [name, afterEntry] of Object.entries(after ?? {})) {
|
|
628
|
+
const beforeEntry = before?.[name];
|
|
629
|
+
if (beforeEntry?.status === 'voided' && afterEntry.status === 'pending') {
|
|
630
|
+
warnings.push(`finalizer '${name}' was operator-voided; re-armed by this terminal edge`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return warnings;
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Builds the ResponseEnvelope for a `settle_step`/`open_gate` REFUSAL (design record §7's
|
|
637
|
+
* result/code table) — shared by the three migrated `settle_step` seal sites (issue #279,
|
|
638
|
+
* increment 1, PR-B) AND the migrated gate-open site (issue #279, increment 2, PR-D; `kind:
|
|
639
|
+
* 'open_gate'`). `allEvidence` is attached ONLY for `claim_lost`: the dispatch DID run and produce
|
|
640
|
+
* evidence; it just was not recorded, so the caller should still see what happened. The reasons
|
|
641
|
+
* both callers can actually return are enumerated explicitly; every OTHER `SettlementRefusalReason`
|
|
642
|
+
* member is lease/mark/settle_gate/settle_guard/release_step-only and structurally unreachable
|
|
643
|
+
* here (a `default` throws rather than silently mis-rendering one) — `choice_not_eligible` +
|
|
644
|
+
* `gate_choice_conflict` + the settle_gate `gate_mismatch`/`run_terminal` variants are consumed at
|
|
645
|
+
* 1b's own `errorEnvelope` (submitHumanResponse), never here; `gate_open_wait` is chain-consumed
|
|
646
|
+
* (executeChainInternal's guard loop); `already_released` is site-handled at 1d/1e (never routed
|
|
647
|
+
* through this shared builder).
|
|
648
|
+
*/
|
|
649
|
+
function buildSettlementRefusalEnvelope(options, definition, result, allEvidence, traceWarnings, kind = 'settle_step') {
|
|
650
|
+
const extraWarnings = traceWarnings.length > 0 ? traceWarnings : undefined;
|
|
651
|
+
switch (result.reason) {
|
|
652
|
+
case 'already_settled_by_other':
|
|
653
|
+
case 'settled_outcome_divergence': {
|
|
654
|
+
const persisted = result.run.settled?.[options.command]?.outcome;
|
|
655
|
+
// N1 (design record §2/§11): neutral wording — never amplify a "by_other" white lie. When
|
|
656
|
+
// the persisted entry's outcome is 'gate', the step was settled by a COMPLETED GATE (a
|
|
657
|
+
// human decision resolved elsewhere), not literally "a different attempt".
|
|
658
|
+
const settledByText = persisted === 'gate' ? 'by a completed gate' : 'by a different attempt';
|
|
659
|
+
const err = new WorkflowError(`Step '${options.command}' was already settled` +
|
|
660
|
+
(persisted !== undefined ? ` with outcome '${persisted}'` : '') +
|
|
661
|
+
` ${settledByText}.`, {
|
|
662
|
+
code: 'STATE_STEP_ALREADY_SETTLED',
|
|
663
|
+
category: 'STATE',
|
|
664
|
+
agentAction: 'resolve_precondition',
|
|
665
|
+
retryable: false,
|
|
666
|
+
details: {
|
|
667
|
+
runId: options.runId,
|
|
668
|
+
step: options.command,
|
|
669
|
+
reason: result.reason,
|
|
670
|
+
...(persisted !== undefined ? { persisted_outcome: persisted } : {}),
|
|
671
|
+
},
|
|
672
|
+
});
|
|
673
|
+
return makeErrorEnvelope(options, result.run, err, definition, extraWarnings);
|
|
674
|
+
}
|
|
675
|
+
case 'claim_lost': {
|
|
676
|
+
const err = new WorkflowError(`Step '${options.command}': this attempt's outcome was NOT recorded — the claim was lost ` +
|
|
677
|
+
`(settled by another writer, or the run advanced).`, {
|
|
678
|
+
code: 'STATE_CLAIM_LOST',
|
|
679
|
+
category: 'STATE',
|
|
680
|
+
agentAction: 'resolve_precondition',
|
|
681
|
+
retryable: false,
|
|
682
|
+
details: { runId: options.runId, step: options.command },
|
|
683
|
+
});
|
|
684
|
+
return {
|
|
685
|
+
...makeErrorEnvelope(options, result.run, err, definition, extraWarnings),
|
|
686
|
+
evidence: allEvidence,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
case 'run_terminal': {
|
|
690
|
+
const err = new WorkflowError(`Run '${options.runId}' is terminal; cannot settle step '${options.command}'.`, {
|
|
691
|
+
code: 'STATE_RUN_TERMINAL',
|
|
692
|
+
category: 'STATE',
|
|
693
|
+
agentAction: 'report_to_user',
|
|
694
|
+
retryable: false,
|
|
695
|
+
details: { runId: options.runId, run_phase: result.run.run_phase },
|
|
696
|
+
});
|
|
697
|
+
return makeErrorEnvelope(options, result.run, err, definition, extraWarnings);
|
|
698
|
+
}
|
|
699
|
+
case 'gate_mismatch': {
|
|
700
|
+
// kind-discriminated (Deliverable 2): open_gate's gate_mismatch means a DIFFERENT step's
|
|
701
|
+
// gate is open (this step stays claimed — L13); settle_step's means THIS step IS the open
|
|
702
|
+
// gate and must be resolved via submit_human_response instead.
|
|
703
|
+
const message = kind === 'open_gate'
|
|
704
|
+
? `Step '${options.command}': a gate is open on another step — wait for its resolution; ` +
|
|
705
|
+
`this step stays claimed.`
|
|
706
|
+
: `Step '${options.command}' is the currently open gate; resolve it via ` +
|
|
707
|
+
`submit_human_response instead of settling it directly.`;
|
|
708
|
+
const err = new WorkflowError(message, {
|
|
709
|
+
code: 'STATE_BLOCKED',
|
|
710
|
+
category: 'STATE',
|
|
711
|
+
agentAction: 'resolve_precondition',
|
|
712
|
+
retryable: false,
|
|
713
|
+
details: { runId: options.runId, step: options.command },
|
|
714
|
+
});
|
|
715
|
+
return makeErrorEnvelope(options, result.run, err, definition, extraWarnings);
|
|
716
|
+
}
|
|
717
|
+
default:
|
|
718
|
+
// run_not_terminal / ledger_not_pending / lease_held / lease_lost / rank_blocked /
|
|
719
|
+
// not_eligible / already_leased / already_marked / already_open / already_released /
|
|
720
|
+
// gate_choice_conflict / choice_not_eligible / gate_open_wait are all consumed elsewhere
|
|
721
|
+
// (lease_finalizer/mark_finalizer's own drain loop; open_gate's own NOOP arms at the 1a call
|
|
722
|
+
// site; 1b's own errorEnvelope; 1d/1e's own site-handling; the guard chain) — settle_step and
|
|
723
|
+
// open_gate never return them here (design record §7).
|
|
724
|
+
throw new Error(`buildSettlementRefusalEnvelope: unreachable '${kind}' refusal reason '${result.reason}'`);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Ok-shaped envelope for a `settle_step` NOOP (`already_settled`) — the idempotent-retry case
|
|
729
|
+
* (design record §7: ok-shaped, calm context_hint, never `report_to_user`). Drain-aware: when the
|
|
730
|
+
* fresh run still carries pending finalizers, this retry attempts the SAME post-commit drain a
|
|
731
|
+
* fresh apply would have run — recovering an ambiguous-retry crash window (§6). A drain failure
|
|
732
|
+
* degrades to a warning (never an error status) — the settle itself is not in question here.
|
|
733
|
+
*/
|
|
734
|
+
async function buildAlreadySettledEnvelope(store, definition, options, result, traceWarnings) {
|
|
735
|
+
let run = result.run;
|
|
736
|
+
let drainWarnings = [];
|
|
737
|
+
const hasPending = Object.values(run.finalizer_ledger ?? {}).some((e) => e.status === 'pending');
|
|
738
|
+
if (hasPending) {
|
|
739
|
+
try {
|
|
740
|
+
const drainOutcome = await drainFinalizers(store, definition, options.registry, options.runId);
|
|
741
|
+
run = drainOutcome.run;
|
|
742
|
+
drainWarnings = drainOutcome.warnings;
|
|
743
|
+
}
|
|
744
|
+
catch (err) {
|
|
745
|
+
drainWarnings = [
|
|
746
|
+
`post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
747
|
+
];
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
const nextActions = run.terminal_state ? [] : buildNextActions(definition, run);
|
|
751
|
+
return {
|
|
752
|
+
command: options.command,
|
|
753
|
+
run_id: options.runId,
|
|
754
|
+
run_version: run.version,
|
|
755
|
+
status: 'ok',
|
|
756
|
+
data: {},
|
|
757
|
+
evidence: [],
|
|
758
|
+
warnings: mergeWarnings(traceWarnings, ...drainWarnings),
|
|
759
|
+
errors: [],
|
|
760
|
+
context_hint: `Step '${options.command}' was already settled (a duplicate/retried attempt) — no action was taken.`,
|
|
761
|
+
run_phase: run.run_phase,
|
|
762
|
+
next_actions: nextActions,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Design record §10 (I16, fail-closed dormancy): the ONE advisory warning every legacy
|
|
767
|
+
* (dormancy-fallback) seal-site envelope carries when `store.settleStep` is undeclared — never a
|
|
768
|
+
* hard requirement; the legacy read-then-update path remains fully functional. This dual branch
|
|
769
|
+
* persists until a major version (final-gate F4/R13).
|
|
770
|
+
*/
|
|
771
|
+
const DORMANCY_ADVISORY = 'settled via the legacy compatibility path — this store does not declare atomic settlement ' +
|
|
772
|
+
'(RunStore.settleStep); upgrade the store to close the fan-out seal race (issue #279)';
|
|
598
773
|
/**
|
|
599
774
|
* Validates eligibility, claims the step, executes it through the dispatcher with retry
|
|
600
775
|
* and timeout support, captures evidence, persists the updated run record, and returns
|
|
@@ -958,6 +1133,8 @@ export async function executeStep(store, definition, options) {
|
|
|
958
1133
|
if (err instanceof WorkflowError) {
|
|
959
1134
|
if (err.code === 'STATE_STEP_ALREADY_CLAIMED') {
|
|
960
1135
|
const freshRun = await store.get(options.runId).catch(() => run);
|
|
1136
|
+
// Design record §6: append the drain advisory when the fresh run carries pendings.
|
|
1137
|
+
const claimedAdvisory = finalizerDrainAdvisory(freshRun);
|
|
961
1138
|
return {
|
|
962
1139
|
command: options.command,
|
|
963
1140
|
run_id: options.runId,
|
|
@@ -965,7 +1142,7 @@ export async function executeStep(store, definition, options) {
|
|
|
965
1142
|
status: 'blocked',
|
|
966
1143
|
data: {},
|
|
967
1144
|
evidence: [],
|
|
968
|
-
warnings: [],
|
|
1145
|
+
warnings: claimedAdvisory !== undefined ? [claimedAdvisory] : [],
|
|
969
1146
|
errors: [],
|
|
970
1147
|
agent_action: 'resolve_precondition',
|
|
971
1148
|
context_hint: `Step '${options.command}' was already claimed by another process.`,
|
|
@@ -977,6 +1154,19 @@ export async function executeStep(store, definition, options) {
|
|
|
977
1154
|
},
|
|
978
1155
|
};
|
|
979
1156
|
}
|
|
1157
|
+
// Design record §6: STATE_STEP_NOT_ELIGIBLE (a claim-time eligibility re-check race) also
|
|
1158
|
+
// gets the drain advisory when the fresh run carries pendings — a fresh re-read since `run`
|
|
1159
|
+
// (Step 1's load) may be stale by the time claimStep's own re-check inside its lock raced.
|
|
1160
|
+
if (err.code === 'STATE_STEP_NOT_ELIGIBLE') {
|
|
1161
|
+
const freshRun = await store.get(options.runId).catch(() => run);
|
|
1162
|
+
const notEligibleAdvisory = finalizerDrainAdvisory(freshRun);
|
|
1163
|
+
const extraWarnings = notEligibleAdvisory !== undefined
|
|
1164
|
+
? [...traceWarnings, notEligibleAdvisory]
|
|
1165
|
+
: traceWarnings.length > 0
|
|
1166
|
+
? traceWarnings
|
|
1167
|
+
: undefined;
|
|
1168
|
+
return makeErrorEnvelope(options, freshRun, err, definition, extraWarnings);
|
|
1169
|
+
}
|
|
980
1170
|
return makeErrorEnvelope(options, run, err, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
|
|
981
1171
|
}
|
|
982
1172
|
return makeErrorEnvelope(options, run, new WorkflowError('Failed to claim step', {
|
|
@@ -1013,13 +1203,43 @@ export async function executeStep(store, definition, options) {
|
|
|
1013
1203
|
: [];
|
|
1014
1204
|
}
|
|
1015
1205
|
catch (err) {
|
|
1016
|
-
|
|
1017
|
-
|
|
1206
|
+
// issue #279 (increment 2, PR-D, Deliverable 1e): the migrated path — settles this release
|
|
1207
|
+
// atomically against FRESH state via the store's own settleStep, evidence = the SAME
|
|
1208
|
+
// compensating_unclaim audit line (:679 semantics). LOG-ONLY for ALL results (applied / NOOP
|
|
1209
|
+
// / any refusal / a thrown infra error) — no envelope change on any settle outcome; the
|
|
1210
|
+
// ENGINE_STORE_FAILED envelope below is the disclosure regardless. Dormancy: an undeclaring
|
|
1211
|
+
// store falls through to the byte-identical legacy path (I16/#169 fail-closed dormancy).
|
|
1212
|
+
let unclaimDormancyWarning;
|
|
1213
|
+
if (store.settleStep !== undefined) {
|
|
1214
|
+
const unclaimToken = pendingRun.claims?.[options.command]?.token;
|
|
1215
|
+
const delta = {
|
|
1216
|
+
kind: 'release_step',
|
|
1217
|
+
step: options.command,
|
|
1218
|
+
...(unclaimToken !== undefined ? { claimToken: unclaimToken } : {}),
|
|
1219
|
+
evidence: [buildCompensatingUnclaimEvidence(options.command, new Date())],
|
|
1220
|
+
};
|
|
1221
|
+
try {
|
|
1222
|
+
await store.settleStep(options.runId, delta, definition);
|
|
1223
|
+
}
|
|
1224
|
+
catch {
|
|
1225
|
+
// Log-only — see the comment above; never surfaces as its own error.
|
|
1226
|
+
}
|
|
1018
1227
|
}
|
|
1019
|
-
|
|
1020
|
-
//
|
|
1021
|
-
|
|
1228
|
+
else {
|
|
1229
|
+
// --- Legacy path (dormancy fallback — byte-identical to pre-#279 behavior) ---
|
|
1230
|
+
try {
|
|
1231
|
+
await store.update(buildCompensatingUnclaim(pendingRun, options.command, new Date()));
|
|
1232
|
+
}
|
|
1233
|
+
catch {
|
|
1234
|
+
// CAS mismatch (someone else already resolved the claim) or any other failure to even
|
|
1235
|
+
// un-claim: stop immediately, leave the claim exactly as it is — never retry here.
|
|
1236
|
+
}
|
|
1237
|
+
// issue #279 (increment 2, PR-D): + the ONE dormancy advisory (I16) — this IS the legacy
|
|
1238
|
+
// path (store.settleStep undeclared); the ENGINE_STORE_FAILED envelope below is its only
|
|
1239
|
+
// carrier since this release is log-only.
|
|
1240
|
+
unclaimDormancyWarning = DORMANCY_ADVISORY;
|
|
1022
1241
|
}
|
|
1242
|
+
const unclaimEnvelopeWarnings = mergeWarnings(traceWarnings, unclaimDormancyWarning);
|
|
1023
1243
|
return makeErrorEnvelope(options, pendingRun, new WorkflowError('Failed to read trace buffer after claiming step', {
|
|
1024
1244
|
code: 'ENGINE_STORE_FAILED',
|
|
1025
1245
|
category: 'ENGINE',
|
|
@@ -1029,7 +1249,7 @@ export async function executeStep(store, definition, options) {
|
|
|
1029
1249
|
step_id: options.command,
|
|
1030
1250
|
cause: err instanceof Error ? err.message : String(err),
|
|
1031
1251
|
},
|
|
1032
|
-
}), definition,
|
|
1252
|
+
}), definition, unclaimEnvelopeWarnings.length > 0 ? unclaimEnvelopeWarnings : undefined);
|
|
1033
1253
|
}
|
|
1034
1254
|
// issue #197 PR-2 (design §2): the SAME predicate as the pre-claim pass, now over the
|
|
1035
1255
|
// complete post-claim set. Lifted to `adoptionPartition` (outer scope) — read again at the
|
|
@@ -1335,6 +1555,75 @@ export async function executeStep(store, definition, options) {
|
|
|
1335
1555
|
}),
|
|
1336
1556
|
status: 'skipped',
|
|
1337
1557
|
};
|
|
1558
|
+
// issue #279 (increment 1, PR-B): the migrated path — a store declaring settleStep
|
|
1559
|
+
// settles this abort atomically against FRESH state (not `pendingRun`, which may be
|
|
1560
|
+
// stale relative to a concurrent sibling settle). Dormancy: an undeclaring store falls
|
|
1561
|
+
// through to the byte-identical legacy path below (I16/#169 fail-closed dormancy).
|
|
1562
|
+
if (store.settleStep !== undefined) {
|
|
1563
|
+
const abortClaimToken = pendingRun.claims?.[options.command]?.token;
|
|
1564
|
+
const delta = {
|
|
1565
|
+
kind: 'settle_step',
|
|
1566
|
+
step: options.command,
|
|
1567
|
+
outcome: 'abort',
|
|
1568
|
+
...(abortClaimToken !== undefined ? { claimToken: abortClaimToken } : {}),
|
|
1569
|
+
evidence: [abortEvidence],
|
|
1570
|
+
abort: { stepId: options.command, abortMessage },
|
|
1571
|
+
};
|
|
1572
|
+
let result;
|
|
1573
|
+
try {
|
|
1574
|
+
result = await store.settleStep(options.runId, delta, definition);
|
|
1575
|
+
}
|
|
1576
|
+
catch (err) {
|
|
1577
|
+
// THROWN infra errors (lock exhaustion, run-not-found, I/O) — the complete-site's
|
|
1578
|
+
// existing catch shape, replicated at all three migrated sites.
|
|
1579
|
+
if (err instanceof WorkflowError) {
|
|
1580
|
+
return makeErrorEnvelope(options, pendingRun, err, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
|
|
1581
|
+
}
|
|
1582
|
+
const internal = new WorkflowError('Failed to persist run update', {
|
|
1583
|
+
code: 'ENGINE_STORE_FAILED',
|
|
1584
|
+
category: 'ENGINE',
|
|
1585
|
+
agentAction: 'stop',
|
|
1586
|
+
retryable: false,
|
|
1587
|
+
});
|
|
1588
|
+
return makeErrorEnvelope(options, pendingRun, internal, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
|
|
1589
|
+
}
|
|
1590
|
+
if (!result.applied) {
|
|
1591
|
+
if (result.reason === 'already_settled') {
|
|
1592
|
+
return buildAlreadySettledEnvelope(store, definition, options, { ...result, reason: 'already_settled' }, traceWarnings);
|
|
1593
|
+
}
|
|
1594
|
+
return buildSettlementRefusalEnvelope(options, definition, result, [abortEvidence], traceWarnings);
|
|
1595
|
+
}
|
|
1596
|
+
// applied: true — abort is UNCONDITIONALLY terminal (transitioned is always true here;
|
|
1597
|
+
// isTerminal(fresh) was already refused above inside applySettlement).
|
|
1598
|
+
let finalRun = result.run;
|
|
1599
|
+
let drainWarnings = [];
|
|
1600
|
+
const reArmWarnings = computeReArmWarnings(pendingRun.finalizer_ledger, result.run.finalizer_ledger);
|
|
1601
|
+
try {
|
|
1602
|
+
const drainOutcome = await drainFinalizers(store, definition, options.registry, options.runId);
|
|
1603
|
+
finalRun = drainOutcome.run;
|
|
1604
|
+
drainWarnings = drainOutcome.warnings;
|
|
1605
|
+
}
|
|
1606
|
+
catch (err) {
|
|
1607
|
+
// A drain failure is NEVER the step's own failure — the abort already committed.
|
|
1608
|
+
drainWarnings = [
|
|
1609
|
+
`post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1610
|
+
];
|
|
1611
|
+
}
|
|
1612
|
+
return {
|
|
1613
|
+
command: options.command,
|
|
1614
|
+
run_id: options.runId,
|
|
1615
|
+
run_version: finalRun.version,
|
|
1616
|
+
status: 'ok',
|
|
1617
|
+
data: {},
|
|
1618
|
+
evidence: [abortEvidence],
|
|
1619
|
+
warnings: mergeWarnings(traceWarnings, ...reArmWarnings, ...drainWarnings),
|
|
1620
|
+
errors: [],
|
|
1621
|
+
context_hint: `Handler step '${options.command}' aborted the run: ${abortMessage}`,
|
|
1622
|
+
run_phase: finalRun.run_phase,
|
|
1623
|
+
next_actions: [],
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
// --- Legacy path (dormancy fallback — byte-identical to pre-#279 behavior) ---
|
|
1338
1627
|
const withHandlerSkipped = {
|
|
1339
1628
|
...pendingRun,
|
|
1340
1629
|
in_progress_steps: pendingRun.in_progress_steps.filter((s) => s !== options.command),
|
|
@@ -1382,7 +1671,9 @@ export async function executeStep(store, definition, options) {
|
|
|
1382
1671
|
evidence: [abortEvidence],
|
|
1383
1672
|
// Issue #140: was hardcoded `[]` — now threads traceWarnings (e.g. the programmatic
|
|
1384
1673
|
// on_timeout/idempotent gate advisory above) so it survives this settle path too.
|
|
1385
|
-
|
|
1674
|
+
// Issue #279 (increment 1, PR-B): + the ONE dormancy advisory (I16) — this IS the
|
|
1675
|
+
// legacy path (store.settleStep undeclared).
|
|
1676
|
+
warnings: mergeWarnings(traceWarnings, DORMANCY_ADVISORY),
|
|
1386
1677
|
errors: [],
|
|
1387
1678
|
context_hint: `Handler step '${options.command}' aborted the run: ${abortMessage}`,
|
|
1388
1679
|
run_phase: (persistedAbortRun ?? abortedRun).run_phase,
|
|
@@ -1694,13 +1985,61 @@ export async function executeStep(store, definition, options) {
|
|
|
1694
1985
|
// Non-terminal: recompute the phase so the store-fail fallback below is correct too
|
|
1695
1986
|
// (on the happy path store.update recomputes it identically via deriveRunPhase).
|
|
1696
1987
|
const blockedRun = { ...blockedDraft, run_phase: deriveRunPhase(blockedDraft) };
|
|
1988
|
+
// issue #279 (increment 2, PR-D, Deliverable 1d): the migrated path — settles this release
|
|
1989
|
+
// atomically against FRESH state via the store's own settleStep. This site NEVER calls the
|
|
1990
|
+
// shared buildSettlementRefusalEnvelope: regardless of write outcome (applied / NOOP
|
|
1991
|
+
// already_released / any OTHER refusal / a thrown infra error), the RETURNED envelope is
|
|
1992
|
+
// ALWAYS this SAME capability-block report — only whether the internal capability_blocks
|
|
1993
|
+
// marker got durably persisted varies, disclosed via blockStoreWarning. Dormancy: an
|
|
1994
|
+
// undeclaring store falls through to the byte-identical legacy path below (I16/#169
|
|
1995
|
+
// fail-closed dormancy).
|
|
1697
1996
|
let persistedBlockedRun;
|
|
1698
1997
|
let blockStoreWarning;
|
|
1699
|
-
|
|
1700
|
-
|
|
1998
|
+
let dormancyWarning;
|
|
1999
|
+
if (store.settleStep !== undefined) {
|
|
2000
|
+
const releaseClaimToken = pendingRun.claims?.[options.command]?.token;
|
|
2001
|
+
const delta = {
|
|
2002
|
+
kind: 'release_step',
|
|
2003
|
+
step: options.command,
|
|
2004
|
+
...(releaseClaimToken !== undefined ? { claimToken: releaseClaimToken } : {}),
|
|
2005
|
+
capabilityBlock: {
|
|
2006
|
+
requirement: requirement !== undefined
|
|
2007
|
+
? { kind: requirement.kind, name: requirement.name }
|
|
2008
|
+
: {
|
|
2009
|
+
kind: recoverableCode === 'ENGINE_HANDLER_NOT_REGISTERED' ? 'handler' : 'adapter',
|
|
2010
|
+
name: 'unknown',
|
|
2011
|
+
},
|
|
2012
|
+
code: recoverableCode,
|
|
2013
|
+
},
|
|
2014
|
+
// The current :2490 append — legacy parity; the compensating un-claim's own :679
|
|
2015
|
+
// audit-line channel belongs to Deliverable 1e ONLY.
|
|
2016
|
+
evidence: allEvidence,
|
|
2017
|
+
};
|
|
2018
|
+
try {
|
|
2019
|
+
const releaseResult = await store.settleStep(options.runId, delta, definition);
|
|
2020
|
+
persistedBlockedRun = releaseResult.run;
|
|
2021
|
+
// applied / NOOP already_released ⇒ the block envelope exactly as today (NOOP merges
|
|
2022
|
+
// silently — no extra warning). ANY OTHER refusal ⇒ the same block envelope + a typed
|
|
2023
|
+
// warning (never STATE_CLAIM_LOST framing) — the claim survives for reclaim either way.
|
|
2024
|
+
if (!releaseResult.applied && releaseResult.reason !== 'already_released') {
|
|
2025
|
+
blockStoreWarning = `capability block not persisted: ${releaseResult.reason}`;
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
catch (storeErr) {
|
|
2029
|
+
blockStoreWarning = `Failed to persist capability block: ${storeErr instanceof Error ? storeErr.message : String(storeErr)}`;
|
|
2030
|
+
}
|
|
1701
2031
|
}
|
|
1702
|
-
|
|
1703
|
-
|
|
2032
|
+
else {
|
|
2033
|
+
// --- Legacy path (dormancy fallback — byte-identical to pre-#279 behavior) ---
|
|
2034
|
+
try {
|
|
2035
|
+
persistedBlockedRun = await store.update(blockedRun);
|
|
2036
|
+
}
|
|
2037
|
+
catch (storeErr) {
|
|
2038
|
+
blockStoreWarning = `Failed to persist capability block: ${storeErr instanceof Error ? storeErr.message : String(storeErr)}`;
|
|
2039
|
+
}
|
|
2040
|
+
// issue #279 (increment 2, PR-D): + the ONE dormancy advisory (I16) — this IS the legacy
|
|
2041
|
+
// path (store.settleStep undeclared).
|
|
2042
|
+
dormancyWarning = DORMANCY_ADVISORY;
|
|
1704
2043
|
}
|
|
1705
2044
|
// issue #207 PR-2 (D3 §5): NO WAL delete belongs on this capability-block settle path — the
|
|
1706
2045
|
// prior try/catch here was removed, not just gated. Contract-consistency hygiene, not a
|
|
@@ -1735,7 +2074,7 @@ export async function executeStep(store, definition, options) {
|
|
|
1735
2074
|
status: 'error',
|
|
1736
2075
|
data: {},
|
|
1737
2076
|
evidence: allEvidence,
|
|
1738
|
-
warnings: mergeWarnings(traceWarnings, blockStoreWarning),
|
|
2077
|
+
warnings: mergeWarnings(traceWarnings, blockStoreWarning, dormancyWarning),
|
|
1739
2078
|
errors: [dispatchError.message],
|
|
1740
2079
|
agent_action: blockedAction,
|
|
1741
2080
|
error_code: recoverableCode,
|
|
@@ -1744,6 +2083,141 @@ export async function executeStep(store, definition, options) {
|
|
|
1744
2083
|
next_actions: blockedNextActions,
|
|
1745
2084
|
};
|
|
1746
2085
|
}
|
|
2086
|
+
// issue #279 (increment 1, PR-B): the migrated path — settles this failure atomically against
|
|
2087
|
+
// FRESH state via the store's own settleStep. Dormancy: an undeclaring store falls through to
|
|
2088
|
+
// the byte-identical legacy path below (I16/#169 fail-closed dormancy).
|
|
2089
|
+
if (store.settleStep !== undefined) {
|
|
2090
|
+
const failClaimToken = pendingRun.claims?.[options.command]?.token;
|
|
2091
|
+
const delta = {
|
|
2092
|
+
kind: 'settle_step',
|
|
2093
|
+
step: options.command,
|
|
2094
|
+
outcome: 'fail',
|
|
2095
|
+
...(failClaimToken !== undefined ? { claimToken: failClaimToken } : {}),
|
|
2096
|
+
evidence: allEvidence,
|
|
2097
|
+
failureMessage: dispatchError.message,
|
|
2098
|
+
};
|
|
2099
|
+
let result;
|
|
2100
|
+
try {
|
|
2101
|
+
result = await store.settleStep(options.runId, delta, definition);
|
|
2102
|
+
}
|
|
2103
|
+
catch (err) {
|
|
2104
|
+
// THROWN infra errors — the same catch shape replicated at all three migrated sites.
|
|
2105
|
+
if (err instanceof WorkflowError) {
|
|
2106
|
+
return makeErrorEnvelope(options, pendingRun, err, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
|
|
2107
|
+
}
|
|
2108
|
+
const internal = new WorkflowError('Failed to persist run update', {
|
|
2109
|
+
code: 'ENGINE_STORE_FAILED',
|
|
2110
|
+
category: 'ENGINE',
|
|
2111
|
+
agentAction: 'stop',
|
|
2112
|
+
retryable: false,
|
|
2113
|
+
});
|
|
2114
|
+
return makeErrorEnvelope(options, pendingRun, internal, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
|
|
2115
|
+
}
|
|
2116
|
+
if (!result.applied) {
|
|
2117
|
+
if (result.reason === 'already_settled') {
|
|
2118
|
+
return buildAlreadySettledEnvelope(store, definition, options, { ...result, reason: 'already_settled' }, traceWarnings);
|
|
2119
|
+
}
|
|
2120
|
+
// WAL/sealFenced gates become result.applied (BU-12): claim_lost ⇒ the WAL SURVIVES
|
|
2121
|
+
// (reclaim's drain owns it) — no WAL cleanup attempted on ANY refusal.
|
|
2122
|
+
return buildSettlementRefusalEnvelope(options, definition, result, allEvidence, traceWarnings);
|
|
2123
|
+
}
|
|
2124
|
+
let finalRun = result.run;
|
|
2125
|
+
let drainWarnings = [];
|
|
2126
|
+
const reArmWarnings = result.transitioned
|
|
2127
|
+
? computeReArmWarnings(pendingRun.finalizer_ledger, result.run.finalizer_ledger)
|
|
2128
|
+
: [];
|
|
2129
|
+
if (result.transitioned) {
|
|
2130
|
+
try {
|
|
2131
|
+
const drainOutcome = await drainFinalizers(store, definition, options.registry, options.runId);
|
|
2132
|
+
finalRun = drainOutcome.run;
|
|
2133
|
+
drainWarnings = drainOutcome.warnings;
|
|
2134
|
+
}
|
|
2135
|
+
catch (err) {
|
|
2136
|
+
drainWarnings = [
|
|
2137
|
+
`post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
2138
|
+
];
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
// WAL cleanup — placement stays post-commit (unchanged), now gated on result.applied
|
|
2142
|
+
// (BU-12) rather than a separate persistedRun-defined check (the settle already committed
|
|
2143
|
+
// by the time we reach here, so there is no "did the persist succeed" ambiguity to gate on).
|
|
2144
|
+
let migratedWalCleanupWarning;
|
|
2145
|
+
{
|
|
2146
|
+
let performPlainDelete = true;
|
|
2147
|
+
if (adoptionPartition !== undefined &&
|
|
2148
|
+
adoptionPartition.preserved_foreign > 0 &&
|
|
2149
|
+
options.traceBufferStore !== undefined &&
|
|
2150
|
+
storeDeclaresSeal(options.traceBufferStore)) {
|
|
2151
|
+
try {
|
|
2152
|
+
const sealResult = await options.traceBufferStore.sealFenced(options.runId, options.command, buildSettleSealGuard(store, options.runId, options.command));
|
|
2153
|
+
if (sealResult.sealed) {
|
|
2154
|
+
performPlainDelete = false;
|
|
2155
|
+
migratedWalCleanupWarning =
|
|
2156
|
+
`${adoptionPartition.preserved_foreign} foreign line(s) preserved (sealed) — ` +
|
|
2157
|
+
'retrieve via `realm run export`';
|
|
2158
|
+
}
|
|
2159
|
+
else if (sealResult.reason === 'capped') {
|
|
2160
|
+
migratedWalCleanupWarning =
|
|
2161
|
+
'preservation cap reached — foreign lines destroyed, not preserved';
|
|
2162
|
+
}
|
|
2163
|
+
else {
|
|
2164
|
+
performPlainDelete = false;
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
catch (err) {
|
|
2168
|
+
performPlainDelete = false;
|
|
2169
|
+
migratedWalCleanupWarning = `Failed to seal trace buffer after step failure: ${err instanceof Error ? err.message : String(err)}`;
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
if (performPlainDelete) {
|
|
2173
|
+
try {
|
|
2174
|
+
await options.traceBufferStore?.delete(options.runId, options.command);
|
|
2175
|
+
}
|
|
2176
|
+
catch (walErr) {
|
|
2177
|
+
migratedWalCleanupWarning = `Failed to clean up trace buffer after step failure: ${walErr instanceof Error ? walErr.message : String(walErr)}`;
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
const migratedEffectiveAction = resolvePostDispatchAgentAction(dispatchError, finalRun.terminal_state);
|
|
2182
|
+
let migratedNextActions = [];
|
|
2183
|
+
if (migratedEffectiveAction !== 'stop') {
|
|
2184
|
+
try {
|
|
2185
|
+
migratedNextActions = buildNextActions(definition, finalRun);
|
|
2186
|
+
}
|
|
2187
|
+
catch {
|
|
2188
|
+
// buildNextActions can throw for unresolvable template references; fall back to [].
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
const migratedContextHint = migratedEffectiveAction === 'stop'
|
|
2192
|
+
? `Step '${options.command}' failed. Run is terminated.`
|
|
2193
|
+
: migratedEffectiveAction === 'wait_and_proceed'
|
|
2194
|
+
? `Step '${options.command}' was rate-limited. Wait ${dispatchError.retry_after !== undefined ? `${dispatchError.retry_after} second(s)` : 'a moment'} then follow next_actions — no human intervention required.`
|
|
2195
|
+
: migratedEffectiveAction === 'wait_for_human'
|
|
2196
|
+
? `Step '${options.command}' failed due to external service unavailability. Wait for service recovery, then proceed with the steps in next_actions.`
|
|
2197
|
+
: `Step '${options.command}' failed. ${result.transitioned ? 'Run is terminated.' : 'Recovery steps are available in next_actions.'}`;
|
|
2198
|
+
return {
|
|
2199
|
+
command: options.command,
|
|
2200
|
+
run_id: options.runId,
|
|
2201
|
+
run_version: finalRun.version,
|
|
2202
|
+
status: 'error',
|
|
2203
|
+
data: {},
|
|
2204
|
+
evidence: allEvidence,
|
|
2205
|
+
warnings: mergeWarnings(traceWarnings, migratedWalCleanupWarning, ...reArmWarnings, ...drainWarnings),
|
|
2206
|
+
errors: [dispatchError.message],
|
|
2207
|
+
agent_action: migratedEffectiveAction,
|
|
2208
|
+
error_code: dispatchError.code,
|
|
2209
|
+
...(Object.keys(dispatchError.details).length > 0
|
|
2210
|
+
? { error_details: dispatchError.details }
|
|
2211
|
+
: {}),
|
|
2212
|
+
...(dispatchError.retry_after !== undefined
|
|
2213
|
+
? { retry_after: dispatchError.retry_after }
|
|
2214
|
+
: {}),
|
|
2215
|
+
context_hint: migratedContextHint,
|
|
2216
|
+
run_phase: finalRun.run_phase,
|
|
2217
|
+
next_actions: migratedNextActions,
|
|
2218
|
+
};
|
|
2219
|
+
}
|
|
2220
|
+
// --- Legacy path (dormancy fallback — byte-identical to pre-#279 behavior) ---
|
|
1747
2221
|
// Pure in-memory derivations — no I/O, no try required.
|
|
1748
2222
|
const afterFail = {
|
|
1749
2223
|
...pendingRun,
|
|
@@ -1879,7 +2353,9 @@ export async function executeStep(store, definition, options) {
|
|
|
1879
2353
|
status: 'error',
|
|
1880
2354
|
data: {},
|
|
1881
2355
|
evidence: allEvidence,
|
|
1882
|
-
|
|
2356
|
+
// Issue #279 (increment 1, PR-B): + the ONE dormancy advisory (I16) — this IS the legacy
|
|
2357
|
+
// path (store.settleStep undeclared).
|
|
2358
|
+
warnings: mergeWarnings(traceWarnings, storeCleanupWarning ?? walCleanupWarning, DORMANCY_ADVISORY),
|
|
1883
2359
|
errors: [dispatchError.message],
|
|
1884
2360
|
agent_action: effectiveAction,
|
|
1885
2361
|
// issue #140 (D3 §2, discriminator OBSERVABLE): additive-optional — lets a caller
|
|
@@ -1952,37 +2428,21 @@ export async function executeStep(store, definition, options) {
|
|
|
1952
2428
|
resolvedGateMessage = raw;
|
|
1953
2429
|
}
|
|
1954
2430
|
const gateConfig = stepDef.gate;
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
? { resolution_messages: gateConfig.resolution_messages }
|
|
1971
|
-
: {}),
|
|
1972
|
-
},
|
|
1973
|
-
});
|
|
1974
|
-
}
|
|
1975
|
-
catch (err) {
|
|
1976
|
-
if (err instanceof WorkflowError) {
|
|
1977
|
-
return makeErrorEnvelope(options, pendingRun, err, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
|
|
1978
|
-
}
|
|
1979
|
-
return makeErrorEnvelope(options, pendingRun, new WorkflowError('Failed to open gate', {
|
|
1980
|
-
code: 'ENGINE_STORE_FAILED',
|
|
1981
|
-
category: 'ENGINE',
|
|
1982
|
-
agentAction: 'stop',
|
|
1983
|
-
retryable: false,
|
|
1984
|
-
}), definition, traceWarnings.length > 0 ? traceWarnings : undefined);
|
|
1985
|
-
}
|
|
2431
|
+
// The PendingGate object is built EXACTLY as before, regardless of which path commits it below
|
|
2432
|
+
// (issue #279, increment 2, PR-D, Deliverable 1a — the migrated `open_gate` delta carries this
|
|
2433
|
+
// SAME object verbatim; the legacy fallback writes it via `store.update` unchanged).
|
|
2434
|
+
const pendingGate = {
|
|
2435
|
+
gate_id,
|
|
2436
|
+
step_name,
|
|
2437
|
+
preview: output,
|
|
2438
|
+
choices,
|
|
2439
|
+
opened_at: new Date().toISOString(),
|
|
2440
|
+
...(gateConfig?.owner !== undefined ? { owner: gateConfig.owner } : {}),
|
|
2441
|
+
...(resolvedGateMessage !== undefined ? { resolved_message: resolvedGateMessage } : {}),
|
|
2442
|
+
...(gateConfig?.resolution_messages !== undefined
|
|
2443
|
+
? { resolution_messages: gateConfig.resolution_messages }
|
|
2444
|
+
: {}),
|
|
2445
|
+
};
|
|
1986
2446
|
// gate.display fallback chain: gate.message resolved → step.prompt resolved → absent
|
|
1987
2447
|
const resolvedGateDisplay = resolvedGateMessage !== undefined
|
|
1988
2448
|
? resolvedGateMessage
|
|
@@ -2000,19 +2460,155 @@ export async function executeStep(store, definition, options) {
|
|
|
2000
2460
|
...wfCtxSpreadEarly,
|
|
2001
2461
|
})
|
|
2002
2462
|
: undefined;
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2463
|
+
function buildGateNextAction(id, gateChoices, forStep) {
|
|
2464
|
+
return {
|
|
2465
|
+
instruction: {
|
|
2466
|
+
tool: 'submit_human_response',
|
|
2467
|
+
params: { run_id: options.runId, gate_id: id },
|
|
2468
|
+
call_with: {
|
|
2469
|
+
run_id: options.runId,
|
|
2470
|
+
gate_id: id,
|
|
2471
|
+
choice: `<${gateChoices.join('|')}>`,
|
|
2472
|
+
},
|
|
2473
|
+
},
|
|
2474
|
+
human_readable: `Human review required for step '${forStep}'. Present gate.display to the user, wait for their choice from gate.response_spec.choices, then call submit_human_response.`,
|
|
2475
|
+
orientation: `Run is paused at gate '${id}'. Available choices: ${gateChoices.join(', ')}.`,
|
|
2476
|
+
};
|
|
2477
|
+
}
|
|
2478
|
+
// issue #279 (increment 2, PR-D, Deliverable 1a): the migrated path — opens this gate
|
|
2479
|
+
// atomically against FRESH state via the store's own settleStep. Dormancy: an undeclaring
|
|
2480
|
+
// store falls through to the byte-identical legacy path below (I16/#169 fail-closed dormancy).
|
|
2481
|
+
if (store.settleStep !== undefined) {
|
|
2482
|
+
const openClaimToken = pendingRun.claims?.[options.command]?.token;
|
|
2483
|
+
const delta = {
|
|
2484
|
+
kind: 'open_gate',
|
|
2485
|
+
step: options.command,
|
|
2486
|
+
...(openClaimToken !== undefined ? { claimToken: openClaimToken } : {}),
|
|
2487
|
+
pendingGate,
|
|
2488
|
+
evidence: allEvidence,
|
|
2489
|
+
};
|
|
2490
|
+
let result;
|
|
2491
|
+
try {
|
|
2492
|
+
result = await store.settleStep(options.runId, delta, definition);
|
|
2493
|
+
}
|
|
2494
|
+
catch (err) {
|
|
2495
|
+
// THROWN infra errors — the same catch shape replicated at every migrated site.
|
|
2496
|
+
if (err instanceof WorkflowError) {
|
|
2497
|
+
return makeErrorEnvelope(options, pendingRun, err, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
|
|
2498
|
+
}
|
|
2499
|
+
const internal = new WorkflowError('Failed to open gate', {
|
|
2500
|
+
code: 'ENGINE_STORE_FAILED',
|
|
2501
|
+
category: 'ENGINE',
|
|
2502
|
+
agentAction: 'stop',
|
|
2503
|
+
retryable: false,
|
|
2504
|
+
});
|
|
2505
|
+
return makeErrorEnvelope(options, pendingRun, internal, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
|
|
2506
|
+
}
|
|
2507
|
+
if (!result.applied) {
|
|
2508
|
+
if (result.reason === 'already_settled') {
|
|
2509
|
+
// Ok-shaped NOOP — buildAlreadySettledEnvelope's SHAPE but WITHOUT its drain clause:
|
|
2510
|
+
// gate-open NEVER drains (design record §6 row 1 — the crashed-drain recovery paths are
|
|
2511
|
+
// the resolution site's own NOOP drain and the drain verb).
|
|
2512
|
+
const noopRun = result.run;
|
|
2513
|
+
const noopNextActions = noopRun.terminal_state
|
|
2514
|
+
? []
|
|
2515
|
+
: buildNextActions(definition, noopRun);
|
|
2516
|
+
return {
|
|
2517
|
+
command: options.command,
|
|
2518
|
+
run_id: options.runId,
|
|
2519
|
+
run_version: noopRun.version,
|
|
2520
|
+
status: 'ok',
|
|
2521
|
+
data: {},
|
|
2522
|
+
evidence: [],
|
|
2523
|
+
warnings: [...traceWarnings],
|
|
2524
|
+
errors: [],
|
|
2525
|
+
context_hint: `Step '${options.command}' was already settled (a duplicate/retried attempt) — no action was taken.`,
|
|
2526
|
+
run_phase: noopRun.run_phase,
|
|
2527
|
+
next_actions: noopNextActions,
|
|
2528
|
+
};
|
|
2529
|
+
}
|
|
2530
|
+
if (result.reason === 'already_open') {
|
|
2531
|
+
// D-1: the LIVE gate wins — rendered VERBATIM (this delta's own rebuilt gate is
|
|
2532
|
+
// discarded). Calm, confirm_required (the gate genuinely IS still open) — never
|
|
2533
|
+
// report_to_user (no `agent_action` set, matching the fresh-open confirm_required shape).
|
|
2534
|
+
const liveGate = result.gate;
|
|
2535
|
+
return {
|
|
2536
|
+
command: options.command,
|
|
2537
|
+
run_id: options.runId,
|
|
2538
|
+
run_version: result.run.version,
|
|
2539
|
+
status: 'confirm_required',
|
|
2540
|
+
data: liveGate.preview,
|
|
2541
|
+
evidence: [],
|
|
2542
|
+
warnings: [...traceWarnings],
|
|
2543
|
+
errors: [],
|
|
2544
|
+
context_hint: `Run is already paused at gate '${liveGate.gate_id}'. Available choices: ${liveGate.choices.join(', ')}.`,
|
|
2545
|
+
run_phase: result.run.run_phase,
|
|
2546
|
+
next_actions: [
|
|
2547
|
+
buildGateNextAction(liveGate.gate_id, liveGate.choices, liveGate.step_name),
|
|
2548
|
+
],
|
|
2549
|
+
gate: {
|
|
2550
|
+
gate_id: liveGate.gate_id,
|
|
2551
|
+
step_name: liveGate.step_name,
|
|
2552
|
+
preview: liveGate.preview,
|
|
2553
|
+
choices: liveGate.choices,
|
|
2554
|
+
...(liveGate.resolved_message !== undefined
|
|
2555
|
+
? { display: liveGate.resolved_message }
|
|
2556
|
+
: {}),
|
|
2557
|
+
response_spec: { choices: liveGate.choices },
|
|
2558
|
+
},
|
|
2559
|
+
};
|
|
2560
|
+
}
|
|
2561
|
+
return buildSettlementRefusalEnvelope(options, definition, result, allEvidence, traceWarnings, 'open_gate');
|
|
2562
|
+
}
|
|
2563
|
+
// applied: true — never terminalizes (design record §4.1); build confirm_required off
|
|
2564
|
+
// result.run.
|
|
2565
|
+
const gateRun = result.run;
|
|
2566
|
+
return {
|
|
2567
|
+
command: options.command,
|
|
2568
|
+
run_id: options.runId,
|
|
2569
|
+
run_version: gateRun.version,
|
|
2570
|
+
status: 'confirm_required',
|
|
2571
|
+
data: output,
|
|
2572
|
+
evidence: allEvidence,
|
|
2573
|
+
warnings: [...traceWarnings],
|
|
2574
|
+
errors: [],
|
|
2575
|
+
context_hint: `Run is paused at gate '${gate_id}'. Available choices: ${choices.join(', ')}.`,
|
|
2576
|
+
run_phase: gateRun.run_phase,
|
|
2577
|
+
next_actions: [buildGateNextAction(gate_id, choices, step_name)],
|
|
2578
|
+
gate: {
|
|
2009
2579
|
gate_id,
|
|
2010
|
-
|
|
2580
|
+
step_name,
|
|
2581
|
+
preview: output,
|
|
2582
|
+
choices,
|
|
2583
|
+
...(resolvedGateDisplay !== undefined ? { display: resolvedGateDisplay } : {}),
|
|
2584
|
+
...(resolvedGateInstructions !== undefined
|
|
2585
|
+
? { agent_hint: resolvedGateInstructions }
|
|
2586
|
+
: {}),
|
|
2587
|
+
response_spec: { choices },
|
|
2011
2588
|
},
|
|
2012
|
-
}
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2589
|
+
};
|
|
2590
|
+
}
|
|
2591
|
+
// --- Legacy path (dormancy fallback — byte-identical to pre-#279 behavior) ---
|
|
2592
|
+
let gateRun;
|
|
2593
|
+
try {
|
|
2594
|
+
gateRun = await store.update({
|
|
2595
|
+
...pendingRun,
|
|
2596
|
+
// Step stays in in_progress_steps while gate is open — moved to completed on submit.
|
|
2597
|
+
evidence: [...pendingRun.evidence, ...allEvidence],
|
|
2598
|
+
pending_gate: pendingGate,
|
|
2599
|
+
});
|
|
2600
|
+
}
|
|
2601
|
+
catch (err) {
|
|
2602
|
+
if (err instanceof WorkflowError) {
|
|
2603
|
+
return makeErrorEnvelope(options, pendingRun, err, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
|
|
2604
|
+
}
|
|
2605
|
+
return makeErrorEnvelope(options, pendingRun, new WorkflowError('Failed to open gate', {
|
|
2606
|
+
code: 'ENGINE_STORE_FAILED',
|
|
2607
|
+
category: 'ENGINE',
|
|
2608
|
+
agentAction: 'stop',
|
|
2609
|
+
retryable: false,
|
|
2610
|
+
}), definition, traceWarnings.length > 0 ? traceWarnings : undefined);
|
|
2611
|
+
}
|
|
2016
2612
|
return {
|
|
2017
2613
|
command: options.command,
|
|
2018
2614
|
run_id: options.runId,
|
|
@@ -2020,11 +2616,11 @@ export async function executeStep(store, definition, options) {
|
|
|
2020
2616
|
status: 'confirm_required',
|
|
2021
2617
|
data: output,
|
|
2022
2618
|
evidence: allEvidence,
|
|
2023
|
-
warnings:
|
|
2619
|
+
warnings: mergeWarnings(traceWarnings, DORMANCY_ADVISORY),
|
|
2024
2620
|
errors: [],
|
|
2025
2621
|
context_hint: `Run is paused at gate '${gate_id}'. Available choices: ${choices.join(', ')}.`,
|
|
2026
2622
|
run_phase: gateRun.run_phase,
|
|
2027
|
-
next_actions: [
|
|
2623
|
+
next_actions: [buildGateNextAction(gate_id, choices, step_name)],
|
|
2028
2624
|
gate: {
|
|
2029
2625
|
gate_id,
|
|
2030
2626
|
step_name,
|
|
@@ -2037,6 +2633,134 @@ export async function executeStep(store, definition, options) {
|
|
|
2037
2633
|
};
|
|
2038
2634
|
}
|
|
2039
2635
|
// Step 6: Move step from in_progress to completed, compute terminal state.
|
|
2636
|
+
// issue #279 (increment 1, PR-B): the migrated path — settles this completion atomically
|
|
2637
|
+
// against FRESH state via the store's own settleStep. Dormancy: an undeclaring store falls
|
|
2638
|
+
// through to the byte-identical legacy path below (I16/#169 fail-closed dormancy).
|
|
2639
|
+
if (store.settleStep !== undefined) {
|
|
2640
|
+
const completeClaimToken = pendingRun.claims?.[options.command]?.token;
|
|
2641
|
+
const delta = {
|
|
2642
|
+
kind: 'settle_step',
|
|
2643
|
+
step: options.command,
|
|
2644
|
+
outcome: 'complete',
|
|
2645
|
+
...(completeClaimToken !== undefined ? { claimToken: completeClaimToken } : {}),
|
|
2646
|
+
evidence: allEvidence,
|
|
2647
|
+
};
|
|
2648
|
+
let result;
|
|
2649
|
+
try {
|
|
2650
|
+
result = await store.settleStep(options.runId, delta, definition);
|
|
2651
|
+
}
|
|
2652
|
+
catch (err) {
|
|
2653
|
+
// THROWN infra errors — the same catch shape replicated at all three migrated sites.
|
|
2654
|
+
if (err instanceof WorkflowError) {
|
|
2655
|
+
return makeErrorEnvelope(options, pendingRun, err, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
|
|
2656
|
+
}
|
|
2657
|
+
const internal = new WorkflowError('Failed to persist run update', {
|
|
2658
|
+
code: 'ENGINE_STORE_FAILED',
|
|
2659
|
+
category: 'ENGINE',
|
|
2660
|
+
agentAction: 'stop',
|
|
2661
|
+
retryable: false,
|
|
2662
|
+
});
|
|
2663
|
+
return makeErrorEnvelope(options, pendingRun, internal, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
|
|
2664
|
+
}
|
|
2665
|
+
if (!result.applied) {
|
|
2666
|
+
if (result.reason === 'already_settled') {
|
|
2667
|
+
return buildAlreadySettledEnvelope(store, definition, options, { ...result, reason: 'already_settled' }, traceWarnings);
|
|
2668
|
+
}
|
|
2669
|
+
// WAL/sealFenced gates become result.applied (BU-12): claim_lost ⇒ the WAL SURVIVES.
|
|
2670
|
+
return buildSettlementRefusalEnvelope(options, definition, result, allEvidence, traceWarnings);
|
|
2671
|
+
}
|
|
2672
|
+
let finalRun = result.run;
|
|
2673
|
+
let drainWarnings = [];
|
|
2674
|
+
const reArmWarnings = result.transitioned
|
|
2675
|
+
? computeReArmWarnings(pendingRun.finalizer_ledger, result.run.finalizer_ledger)
|
|
2676
|
+
: [];
|
|
2677
|
+
if (result.transitioned) {
|
|
2678
|
+
try {
|
|
2679
|
+
const drainOutcome = await drainFinalizers(store, definition, options.registry, options.runId);
|
|
2680
|
+
finalRun = drainOutcome.run;
|
|
2681
|
+
drainWarnings = drainOutcome.warnings;
|
|
2682
|
+
}
|
|
2683
|
+
catch (err) {
|
|
2684
|
+
drainWarnings = [
|
|
2685
|
+
`post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
2686
|
+
];
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
// WAL cleanup — placement stays post-commit (unchanged), gated on result.applied (BU-12).
|
|
2690
|
+
let migratedSuccessWalCleanupWarning;
|
|
2691
|
+
{
|
|
2692
|
+
let performPlainDelete = true;
|
|
2693
|
+
if (adoptionPartition !== undefined &&
|
|
2694
|
+
adoptionPartition.preserved_foreign > 0 &&
|
|
2695
|
+
options.traceBufferStore !== undefined &&
|
|
2696
|
+
storeDeclaresSeal(options.traceBufferStore)) {
|
|
2697
|
+
try {
|
|
2698
|
+
const sealResult = await options.traceBufferStore.sealFenced(options.runId, options.command, buildSettleSealGuard(store, options.runId, options.command));
|
|
2699
|
+
if (sealResult.sealed) {
|
|
2700
|
+
performPlainDelete = false;
|
|
2701
|
+
migratedSuccessWalCleanupWarning =
|
|
2702
|
+
`${adoptionPartition.preserved_foreign} foreign line(s) preserved (sealed) — ` +
|
|
2703
|
+
'retrieve via `realm run export`';
|
|
2704
|
+
}
|
|
2705
|
+
else if (sealResult.reason === 'capped') {
|
|
2706
|
+
migratedSuccessWalCleanupWarning =
|
|
2707
|
+
'preservation cap reached — foreign lines destroyed, not preserved';
|
|
2708
|
+
}
|
|
2709
|
+
else {
|
|
2710
|
+
performPlainDelete = false;
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
catch (err) {
|
|
2714
|
+
performPlainDelete = false;
|
|
2715
|
+
migratedSuccessWalCleanupWarning = `Failed to seal trace buffer after step completion: ${err instanceof Error ? err.message : String(err)}`;
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
if (performPlainDelete) {
|
|
2719
|
+
try {
|
|
2720
|
+
await options.traceBufferStore?.delete(options.runId, options.command);
|
|
2721
|
+
}
|
|
2722
|
+
catch (err) {
|
|
2723
|
+
migratedSuccessWalCleanupWarning = `Failed to clean up trace buffer after step completion: ${err instanceof Error ? err.message : String(err)}`;
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
const migratedDefaultedStepsDurabilityWarning = finalRun.defaulted_steps !== undefined &&
|
|
2728
|
+
finalRun.defaulted_steps.length > 0 &&
|
|
2729
|
+
!persistsField(store, 'defaulted_steps')
|
|
2730
|
+
? 'run-level defaultedness marker (defaulted_steps) not durable on this store'
|
|
2731
|
+
: undefined;
|
|
2732
|
+
const migratedNextActions = finalRun.terminal_state
|
|
2733
|
+
? []
|
|
2734
|
+
: buildNextActions(definition, finalRun);
|
|
2735
|
+
const migratedOrientation = finalRun.terminal_state
|
|
2736
|
+
? `Run completed (phase: '${finalRun.run_phase}'). Call get_run_state with run_id '${options.runId}' to retrieve the full evidence record.`
|
|
2737
|
+
: migratedNextActions.length > 0
|
|
2738
|
+
? `Step '${options.command}' completed. ${migratedNextActions.length} step(s) now available.`
|
|
2739
|
+
: `Step '${options.command}' completed. Waiting for other steps to complete.`;
|
|
2740
|
+
return {
|
|
2741
|
+
command: options.command,
|
|
2742
|
+
run_id: options.runId,
|
|
2743
|
+
run_version: finalRun.version,
|
|
2744
|
+
status: 'ok',
|
|
2745
|
+
data: output,
|
|
2746
|
+
evidence: allEvidence,
|
|
2747
|
+
warnings: mergeWarnings(traceWarnings, currentWarn, migratedSuccessWalCleanupWarning, migratedDefaultedStepsDurabilityWarning, ...reArmWarnings, ...drainWarnings),
|
|
2748
|
+
errors: [],
|
|
2749
|
+
context_hint: migratedOrientation,
|
|
2750
|
+
run_phase: finalRun.run_phase,
|
|
2751
|
+
next_actions: migratedNextActions,
|
|
2752
|
+
...(carriageActive && adoptionPartition !== undefined
|
|
2753
|
+
? {
|
|
2754
|
+
adopted_own: adoptionPartition.adopted_own,
|
|
2755
|
+
adopted_anonymous: adoptionPartition.adopted_anonymous,
|
|
2756
|
+
preserved_foreign: adoptionPartition.preserved_foreign,
|
|
2757
|
+
}
|
|
2758
|
+
: {}),
|
|
2759
|
+
...(settledByDefault ? { settled_by_default: true } : {}),
|
|
2760
|
+
...(finalRun.defaulted_steps?.length ? { defaulted_steps: finalRun.defaulted_steps } : {}),
|
|
2761
|
+
};
|
|
2762
|
+
}
|
|
2763
|
+
// --- Legacy path (dormancy fallback — byte-identical to pre-#279 behavior) ---
|
|
2040
2764
|
const afterComplete = {
|
|
2041
2765
|
...pendingRun,
|
|
2042
2766
|
in_progress_steps: pendingRun.in_progress_steps.filter((s) => s !== options.command),
|
|
@@ -2158,7 +2882,9 @@ export async function executeStep(store, definition, options) {
|
|
|
2158
2882
|
status: 'ok',
|
|
2159
2883
|
data: output,
|
|
2160
2884
|
evidence: allEvidence,
|
|
2161
|
-
|
|
2885
|
+
// Issue #279 (increment 1, PR-B): + the ONE dormancy advisory (I16) — this IS the legacy
|
|
2886
|
+
// path (store.settleStep undeclared).
|
|
2887
|
+
warnings: mergeWarnings(traceWarnings, currentWarn, successWalCleanupWarning, defaultedStepsDurabilityWarning, DORMANCY_ADVISORY),
|
|
2162
2888
|
errors: [],
|
|
2163
2889
|
context_hint: orientation,
|
|
2164
2890
|
run_phase: savedRun.run_phase,
|
|
@@ -2187,6 +2913,39 @@ export async function executeStep(store, definition, options) {
|
|
|
2187
2913
|
* Submits a human response for a gate-waiting run.
|
|
2188
2914
|
* Validates the gate_id and choice, then moves the step to completed_steps.
|
|
2189
2915
|
*/
|
|
2916
|
+
/** Finds the settled step name for a resolved gate matching `gateId` (issue #279, increment 2,
|
|
2917
|
+
* PR-D) — a LOCAL mirror of settlement.ts's own `findSettledGateEntry` (not imported: this file
|
|
2918
|
+
* touches settlement.ts ONLY for Deliverable 3's cancel-trail `gate_id` addition). Used to recover
|
|
2919
|
+
* a reliable step name off `result.run.settled` for the `already_settled`/`gate_choice_conflict`
|
|
2920
|
+
* envelopes, since the caller's own pre-read may already be stale by the time either of those
|
|
2921
|
+
* fires (the gate could have resolved before this call's own Step-1 read). */
|
|
2922
|
+
function findGateStepName(run, gateId) {
|
|
2923
|
+
for (const [step, entry] of Object.entries(run.settled ?? {})) {
|
|
2924
|
+
if (entry.outcome === 'gate' && entry.token === gateId)
|
|
2925
|
+
return step;
|
|
2926
|
+
}
|
|
2927
|
+
return undefined;
|
|
2928
|
+
}
|
|
2929
|
+
/** Builds the gate_response evidence snapshot (issue #279, increment 2, PR-D, Deliverable 1b) —
|
|
2930
|
+
* the SAME shape submitHumanResponse's legacy path has always built (mirrors execution-loop.ts's
|
|
2931
|
+
* own pre-PR-D Step 5), extracted so both the migrated and legacy paths construct it identically.
|
|
2932
|
+
* `respondedBy`, when supplied, populates the snapshot's own `responded_by` field (design record
|
|
2933
|
+
* D-5) in addition to the delta's own field. */
|
|
2934
|
+
function buildGateResponseSnapshot(gate, choice, respondedAt, respondedBy) {
|
|
2935
|
+
const gateEvidence = captureEvidence({
|
|
2936
|
+
stepId: gate.step_name,
|
|
2937
|
+
startedAt: new Date(gate.opened_at),
|
|
2938
|
+
completedAt: respondedAt,
|
|
2939
|
+
input: { choice },
|
|
2940
|
+
output: { ...gate.preview, choice },
|
|
2941
|
+
});
|
|
2942
|
+
return {
|
|
2943
|
+
...gateEvidence,
|
|
2944
|
+
kind: 'gate_response',
|
|
2945
|
+
...(gate.resolved_message !== undefined ? { gate_message: gate.resolved_message } : {}),
|
|
2946
|
+
...(respondedBy !== undefined ? { responded_by: respondedBy } : {}),
|
|
2947
|
+
};
|
|
2948
|
+
}
|
|
2190
2949
|
export async function submitHumanResponse(store, definition, options) {
|
|
2191
2950
|
// 1. Load run.
|
|
2192
2951
|
let run;
|
|
@@ -2204,6 +2963,227 @@ export async function submitHumanResponse(store, definition, options) {
|
|
|
2204
2963
|
});
|
|
2205
2964
|
return errorEnvelope('submit_gate', options.runId, 0, e);
|
|
2206
2965
|
}
|
|
2966
|
+
// issue #279 (increment 2, PR-D, Deliverable 1b): the migrated path — the four legacy
|
|
2967
|
+
// verify-arms below (1a-4) become settle_gate's OWN predicate arms; this branch skips them
|
|
2968
|
+
// entirely and settles atomically against FRESH state via the store's own settleStep. Dormancy:
|
|
2969
|
+
// an undeclaring store falls through to the byte-identical legacy path below (I16/#169
|
|
2970
|
+
// fail-closed dormancy).
|
|
2971
|
+
if (store.settleStep !== undefined) {
|
|
2972
|
+
const respondedAt = new Date();
|
|
2973
|
+
// Evidence rule (design record §6 lens-3 S2): built from the PRE-READ `pending_gate` IFF it
|
|
2974
|
+
// matches options.gateId — else `[]` (the arm can never APPLY against a non-matching fresh
|
|
2975
|
+
// read either, so an empty evidence array is inert there; a matching pre-read is guaranteed
|
|
2976
|
+
// fresh enough to be correct on `applied: true`, since gate_id is a per-attempt-minted UUID
|
|
2977
|
+
// that can never "come back" once resolved/absent).
|
|
2978
|
+
const gateResponseEvidence = run.pending_gate !== undefined && run.pending_gate.gate_id === options.gateId
|
|
2979
|
+
? [
|
|
2980
|
+
buildGateResponseSnapshot(run.pending_gate, options.choice, respondedAt, options.respondedBy),
|
|
2981
|
+
]
|
|
2982
|
+
: [];
|
|
2983
|
+
const delta = {
|
|
2984
|
+
kind: 'settle_gate',
|
|
2985
|
+
gateId: options.gateId,
|
|
2986
|
+
choice: options.choice,
|
|
2987
|
+
...(options.respondedBy !== undefined ? { respondedBy: options.respondedBy } : {}),
|
|
2988
|
+
evidence: gateResponseEvidence,
|
|
2989
|
+
};
|
|
2990
|
+
let result;
|
|
2991
|
+
try {
|
|
2992
|
+
result = await store.settleStep(options.runId, delta, definition);
|
|
2993
|
+
}
|
|
2994
|
+
catch (err) {
|
|
2995
|
+
// Thrown infra errors keep the SAME shape as the legacy path's own final-persist catch,
|
|
2996
|
+
// below (design record §6: "thrown infra errors ALSO keep the :3563-3581 shape").
|
|
2997
|
+
const e = err instanceof WorkflowError
|
|
2998
|
+
? err
|
|
2999
|
+
: new WorkflowError('Failed to persist gate response', {
|
|
3000
|
+
code: 'ENGINE_STORE_FAILED',
|
|
3001
|
+
category: 'ENGINE',
|
|
3002
|
+
agentAction: 'stop',
|
|
3003
|
+
retryable: false,
|
|
3004
|
+
});
|
|
3005
|
+
return errorEnvelope(run.pending_gate?.step_name ?? 'submit_gate', options.runId, run.version, e, `Failed to persist gate response.`, run.run_phase);
|
|
3006
|
+
}
|
|
3007
|
+
if (!result.applied) {
|
|
3008
|
+
switch (result.reason) {
|
|
3009
|
+
case 'already_settled': {
|
|
3010
|
+
// Calm ok envelope stating the resolution already committed (same choice) — never
|
|
3011
|
+
// report_to_user. Drain: on already_settled ∧ pending ledger entries non-empty — hand-
|
|
3012
|
+
// rolled here (buildAlreadySettledEnvelope is ExecuteStepOptions-shaped, not reusable at
|
|
3013
|
+
// this call site) — recovers a crashed-drain RESOLVE on a duplicate submit (design record
|
|
3014
|
+
// §6 row 2, the buildAlreadySettledEnvelope drain-on-NOOP pattern reused verbatim).
|
|
3015
|
+
const stepName = findGateStepName(result.run, options.gateId) ?? 'submit_gate';
|
|
3016
|
+
let noopRun = result.run;
|
|
3017
|
+
let noopDrainWarnings = [];
|
|
3018
|
+
const hasPending = Object.values(noopRun.finalizer_ledger ?? {}).some((e) => e.status === 'pending');
|
|
3019
|
+
if (hasPending) {
|
|
3020
|
+
try {
|
|
3021
|
+
const drainOutcome = await drainFinalizers(store, definition, options.registry, options.runId);
|
|
3022
|
+
noopRun = drainOutcome.run;
|
|
3023
|
+
noopDrainWarnings = drainOutcome.warnings;
|
|
3024
|
+
}
|
|
3025
|
+
catch (err) {
|
|
3026
|
+
noopDrainWarnings = [
|
|
3027
|
+
`post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
3028
|
+
];
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
return {
|
|
3032
|
+
command: stepName,
|
|
3033
|
+
run_id: options.runId,
|
|
3034
|
+
run_version: noopRun.version,
|
|
3035
|
+
status: 'ok',
|
|
3036
|
+
data: {},
|
|
3037
|
+
evidence: [],
|
|
3038
|
+
warnings: mergeWarnings([], ...noopDrainWarnings),
|
|
3039
|
+
errors: [],
|
|
3040
|
+
context_hint: `Gate '${options.gateId}' was already resolved with choice '${options.choice}' — no action was taken.`,
|
|
3041
|
+
run_phase: noopRun.run_phase,
|
|
3042
|
+
next_actions: noopRun.terminal_state ? [] : buildNextActions(definition, noopRun),
|
|
3043
|
+
};
|
|
3044
|
+
}
|
|
3045
|
+
case 'gate_choice_conflict': {
|
|
3046
|
+
const stepName = findGateStepName(result.run, options.gateId);
|
|
3047
|
+
const err = new WorkflowError(`Gate '${options.gateId}' was already resolved with choice '${result.winningChoice}' ` +
|
|
3048
|
+
`— your choice '${options.choice}' was not recorded.`, {
|
|
3049
|
+
code: 'STATE_BLOCKED',
|
|
3050
|
+
category: 'STATE',
|
|
3051
|
+
agentAction: 'report_to_user',
|
|
3052
|
+
retryable: false,
|
|
3053
|
+
details: {
|
|
3054
|
+
runId: options.runId,
|
|
3055
|
+
gateId: options.gateId,
|
|
3056
|
+
winning_choice: result.winningChoice,
|
|
3057
|
+
},
|
|
3058
|
+
});
|
|
3059
|
+
return errorEnvelope(stepName ?? 'submit_gate', options.runId, result.run.version, err, err.message, result.run.run_phase);
|
|
3060
|
+
}
|
|
3061
|
+
case 'choice_not_eligible': {
|
|
3062
|
+
// VALIDATION_INPUT_SCHEMA envelope — parity with the legacy path's own step 4 (below).
|
|
3063
|
+
const stepName = result.run.pending_gate.step_name; // the arm only reaches this check
|
|
3064
|
+
// when fresh.pending_gate.gate_id === gateId, so this is reliably the live gate's step.
|
|
3065
|
+
const expected = (result.choices ?? []).join(', ');
|
|
3066
|
+
const err = new WorkflowError(`Choice '${options.choice}' is not valid. Expected one of: ${expected}`, {
|
|
3067
|
+
code: 'VALIDATION_INPUT_SCHEMA',
|
|
3068
|
+
category: 'VALIDATION',
|
|
3069
|
+
agentAction: 'report_to_user',
|
|
3070
|
+
retryable: false,
|
|
3071
|
+
});
|
|
3072
|
+
return errorEnvelope(stepName, options.runId, result.run.version, err, `Invalid choice '${options.choice}' for gate '${stepName}'.`, result.run.run_phase);
|
|
3073
|
+
}
|
|
3074
|
+
case 'gate_mismatch': {
|
|
3075
|
+
const err = new WorkflowError(`Gate '${options.gateId}' is not the open gate and matches no committed resolution.`, {
|
|
3076
|
+
code: 'STATE_BLOCKED',
|
|
3077
|
+
category: 'STATE',
|
|
3078
|
+
agentAction: 'report_to_user',
|
|
3079
|
+
retryable: false,
|
|
3080
|
+
details: { runId: options.runId, gateId: options.gateId },
|
|
3081
|
+
});
|
|
3082
|
+
return errorEnvelope('submit_gate', options.runId, result.run.version, err, err.message, result.run.run_phase);
|
|
3083
|
+
}
|
|
3084
|
+
case 'run_terminal': {
|
|
3085
|
+
// Composed cancelled-predicate (design record §5 D-4/§11 N10): any gate_cancelled_by_abort
|
|
3086
|
+
// skip detail ⇒ the cancelled variant ("your choice was NOT recorded" + cause) — bound by
|
|
3087
|
+
// gate_id equality once that field is populated (PR-D+), else by presence alone (pre-PR-D
|
|
3088
|
+
// records, N10). No match ⇒ the zombie/grandfathered variant + the resume-clears/purge
|
|
3089
|
+
// pointer.
|
|
3090
|
+
const cancelEntry = Object.entries(result.run.skip_details ?? {}).find(([, d]) => d.kind === 'gate_cancelled_by_abort');
|
|
3091
|
+
const cancelDetail = cancelEntry?.[1];
|
|
3092
|
+
const isCancelledMatch = cancelEntry !== undefined &&
|
|
3093
|
+
(cancelDetail.gate_id === undefined || cancelDetail.gate_id === options.gateId);
|
|
3094
|
+
if (isCancelledMatch) {
|
|
3095
|
+
const [cancelledStep] = cancelEntry;
|
|
3096
|
+
const abortedBy = result.run.aborted_at?.step_id;
|
|
3097
|
+
const err = new WorkflowError(`Gate '${options.gateId}' on '${cancelledStep}' was cancelled when ` +
|
|
3098
|
+
`'${abortedBy ?? 'another step'}' aborted the run — your choice was NOT recorded.`, {
|
|
3099
|
+
code: 'STATE_RUN_TERMINAL',
|
|
3100
|
+
category: 'STATE',
|
|
3101
|
+
agentAction: 'report_to_user',
|
|
3102
|
+
retryable: false,
|
|
3103
|
+
details: {
|
|
3104
|
+
runId: options.runId,
|
|
3105
|
+
run_phase: result.run.run_phase,
|
|
3106
|
+
gate_id: options.gateId,
|
|
3107
|
+
step_name: cancelledStep,
|
|
3108
|
+
...(abortedBy !== undefined ? { aborted_by: abortedBy } : {}),
|
|
3109
|
+
},
|
|
3110
|
+
});
|
|
3111
|
+
return errorEnvelope(cancelledStep, options.runId, result.run.version, err, err.message, result.run.run_phase);
|
|
3112
|
+
}
|
|
3113
|
+
// Zombie/grandfathered variant — the #282 class: a terminal record may still carry a
|
|
3114
|
+
// stale pending_gate (never cleared), which is the best-effort step label here.
|
|
3115
|
+
const zombieStep = result.run.pending_gate?.step_name ?? 'submit_gate';
|
|
3116
|
+
const err = new WorkflowError(`Run '${options.runId}' is terminal; cannot submit a gate response — 'realm resume' ` +
|
|
3117
|
+
`clears a stale pending gate on a resumable run, or 'realm run purge' removes the ` +
|
|
3118
|
+
`record entirely.`, {
|
|
3119
|
+
code: 'STATE_RUN_TERMINAL',
|
|
3120
|
+
category: 'STATE',
|
|
3121
|
+
agentAction: 'report_to_user',
|
|
3122
|
+
retryable: false,
|
|
3123
|
+
details: { runId: options.runId, run_phase: result.run.run_phase },
|
|
3124
|
+
});
|
|
3125
|
+
return errorEnvelope(zombieStep, options.runId, result.run.version, err, err.message, result.run.run_phase);
|
|
3126
|
+
}
|
|
3127
|
+
default:
|
|
3128
|
+
// gate_open_wait/already_open/already_released/choice_not_eligible's siblings and every
|
|
3129
|
+
// other kind's own reason are unreachable here — settle_gate never returns them (design
|
|
3130
|
+
// record §7).
|
|
3131
|
+
throw new Error(`submitHumanResponse: unreachable settle_gate refusal reason '${result.reason}'`);
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
// applied: true. Reliable step name: the pre-read's pending_gate.step_name (guaranteed
|
|
3135
|
+
// correct whenever `applied` is true — see the evidence-rule comment above).
|
|
3136
|
+
const resolvedGateStepName = run.pending_gate.step_name;
|
|
3137
|
+
// Drain: on transitioned OR (already_settled ∧ pending ledger entries non-empty) — hand-rolled
|
|
3138
|
+
// (buildAlreadySettledEnvelope is ExecuteStepOptions-shaped, not reusable here). `applied:
|
|
3139
|
+
// true` never reaches the already_settled leg, so only the `transitioned` disjunct applies at
|
|
3140
|
+
// THIS call site (design record §6 row 2).
|
|
3141
|
+
let finalRun = result.run;
|
|
3142
|
+
let drainWarnings = [];
|
|
3143
|
+
if (result.transitioned) {
|
|
3144
|
+
try {
|
|
3145
|
+
const drainOutcome = await drainFinalizers(store, definition, options.registry, options.runId);
|
|
3146
|
+
finalRun = drainOutcome.run;
|
|
3147
|
+
drainWarnings = drainOutcome.warnings;
|
|
3148
|
+
}
|
|
3149
|
+
catch (err) {
|
|
3150
|
+
drainWarnings = [
|
|
3151
|
+
`post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
3152
|
+
];
|
|
3153
|
+
}
|
|
3154
|
+
}
|
|
3155
|
+
// Convergence hint (design record D-2 N8 narrowing, pedestal steal — must not drop): after a
|
|
3156
|
+
// committed RESOLVE, when a guard is thereby eligible, append one line per eligible guard.
|
|
3157
|
+
// findEligibleGuardSteps self-filters terminal runs (returns [] there), so this is inert on a
|
|
3158
|
+
// gate-completion terminal transition.
|
|
3159
|
+
const convergenceHints = findEligibleGuardSteps(definition, finalRun).map((name) => `guard '${name}' now eligible — converges at the next drive`);
|
|
3160
|
+
const defaultedStepsDurabilityWarning = finalRun.defaulted_steps !== undefined &&
|
|
3161
|
+
finalRun.defaulted_steps.length > 0 &&
|
|
3162
|
+
!persistsField(store, 'defaulted_steps')
|
|
3163
|
+
? 'run-level defaultedness marker (defaulted_steps) not durable on this store'
|
|
3164
|
+
: undefined;
|
|
3165
|
+
const migratedNextActions = finalRun.terminal_state
|
|
3166
|
+
? []
|
|
3167
|
+
: buildNextActions(definition, finalRun);
|
|
3168
|
+
const migratedOrientation = finalRun.terminal_state
|
|
3169
|
+
? `Run completed (phase: '${finalRun.run_phase}'). Call get_run_state with run_id '${options.runId}' to retrieve the full evidence record.`
|
|
3170
|
+
: `Gate '${resolvedGateStepName}' resolved with choice '${options.choice}'. ${migratedNextActions.length} step(s) now available.`;
|
|
3171
|
+
return {
|
|
3172
|
+
command: resolvedGateStepName,
|
|
3173
|
+
run_id: options.runId,
|
|
3174
|
+
run_version: finalRun.version,
|
|
3175
|
+
status: 'ok',
|
|
3176
|
+
data: { ...run.pending_gate.preview, choice: options.choice },
|
|
3177
|
+
evidence: [],
|
|
3178
|
+
warnings: mergeWarnings(convergenceHints, ...drainWarnings, defaultedStepsDurabilityWarning),
|
|
3179
|
+
errors: [],
|
|
3180
|
+
context_hint: migratedOrientation,
|
|
3181
|
+
run_phase: finalRun.run_phase,
|
|
3182
|
+
next_actions: migratedNextActions,
|
|
3183
|
+
...(finalRun.defaulted_steps?.length ? { defaulted_steps: finalRun.defaulted_steps } : {}),
|
|
3184
|
+
};
|
|
3185
|
+
}
|
|
3186
|
+
// --- Legacy path (dormancy fallback — byte-identical to pre-#279 behavior) ---
|
|
2207
3187
|
// 1a. Defensive terminal guard (mirrors #91/#95): a late gate response must never re-drive a
|
|
2208
3188
|
// run that has already reached a terminal phase.
|
|
2209
3189
|
if (run.terminal_state) {
|
|
@@ -2334,7 +3314,9 @@ export async function submitHumanResponse(store, definition, options) {
|
|
|
2334
3314
|
// envelope flag (do NOT add an evidence scan to recompute it). Its disclosure surface is the
|
|
2335
3315
|
// run-level `defaulted_steps` marker below, plus whatever the gate-open envelope already
|
|
2336
3316
|
// warned the human with.
|
|
2337
|
-
|
|
3317
|
+
// issue #279 (increment 2, PR-D): + the ONE dormancy advisory (I16) — this IS the legacy path
|
|
3318
|
+
// (store.settleStep undeclared).
|
|
3319
|
+
warnings: mergeWarnings([], defaultedStepsDurabilityWarning, DORMANCY_ADVISORY),
|
|
2338
3320
|
errors: [],
|
|
2339
3321
|
context_hint: orientation,
|
|
2340
3322
|
run_phase: savedRun.run_phase,
|
|
@@ -2462,13 +3444,6 @@ async function executeGuardStep(stepName, stepDef, definition, run) {
|
|
|
2462
3444
|
},
|
|
2463
3445
|
};
|
|
2464
3446
|
}
|
|
2465
|
-
/** Normalizes a finalizer's `on_outcome` to a set of triggers. */
|
|
2466
|
-
function finalizerTriggers(stepDef) {
|
|
2467
|
-
const raw = stepDef.on_outcome;
|
|
2468
|
-
if (raw === undefined)
|
|
2469
|
-
return new Set();
|
|
2470
|
-
return new Set(Array.isArray(raw) ? raw : [raw]);
|
|
2471
|
-
}
|
|
2472
3447
|
/**
|
|
2473
3448
|
* Drains the finalizers matching a run's terminal `outcome` and returns the FINALIZED
|
|
2474
3449
|
* RunRecord — the workflow-level try/catch/finally at the seal. Modeled on
|
|
@@ -2496,22 +3471,15 @@ async function buildFinalizedSeal(definition, sealDraft, outcome, registry) {
|
|
|
2496
3471
|
const hasFinalizers = Object.values(definition.steps).some((s) => s.execution === 'finalizer');
|
|
2497
3472
|
if (!hasFinalizers)
|
|
2498
3473
|
return sealDraft;
|
|
3474
|
+
// issue #279 (increment 1, PR-A extraction): the grouping/ordering logic itself now lives in
|
|
3475
|
+
// settlement.ts's selectFinalizers, shared with `mintFresh` — this call passes the SAME inputs
|
|
3476
|
+
// the inline loop used to compute over, so the returned name list is byte-identical to what
|
|
3477
|
+
// `[...groupA, ...groupB]` produced before the extraction.
|
|
2499
3478
|
const settled = new Set([...sealDraft.completed_steps, ...sealDraft.failed_steps]);
|
|
2500
|
-
const
|
|
2501
|
-
const groupB = [];
|
|
2502
|
-
for (const [name, step] of Object.entries(definition.steps)) {
|
|
2503
|
-
if (step.execution !== 'finalizer')
|
|
2504
|
-
continue;
|
|
2505
|
-
if (settled.has(name))
|
|
2506
|
-
continue; // at-most-once per run (resume / re-drive safety)
|
|
2507
|
-
const triggers = finalizerTriggers(step);
|
|
2508
|
-
if (triggers.has(outcome))
|
|
2509
|
-
groupA.push([name, step]);
|
|
2510
|
-
else if (triggers.has('always'))
|
|
2511
|
-
groupB.push([name, step]);
|
|
2512
|
-
}
|
|
3479
|
+
const selected = selectFinalizers(definition, settled, outcome);
|
|
2513
3480
|
let record = sealDraft;
|
|
2514
|
-
for (const
|
|
3481
|
+
for (const name of selected) {
|
|
3482
|
+
const step = definition.steps[name];
|
|
2515
3483
|
const now = new Date();
|
|
2516
3484
|
const evidenceByStep = buildEvidenceByStep(record);
|
|
2517
3485
|
const timeoutMs = (step.timeout_seconds ?? DRAIN_CEILING_SECONDS) * 1000;
|
|
@@ -2585,6 +3553,185 @@ async function buildFinalizedSeal(definition, sealDraft, outcome, registry) {
|
|
|
2585
3553
|
}
|
|
2586
3554
|
return { ...record, terminal_state: true };
|
|
2587
3555
|
}
|
|
3556
|
+
/** Every currently-`'pending'` finalizer in `run`'s ledger, ascending by rank — the order the
|
|
3557
|
+
* drain loop below consumes (design record §6). */
|
|
3558
|
+
function pendingByRank(run) {
|
|
3559
|
+
return Object.entries(run.finalizer_ledger ?? {})
|
|
3560
|
+
.filter(([, e]) => e.status === 'pending')
|
|
3561
|
+
.sort(([, a], [, b]) => a.rank - b.rank)
|
|
3562
|
+
.map(([name]) => name);
|
|
3563
|
+
}
|
|
3564
|
+
/** Bounded retry count for `mark_finalizer` on a thrown `STATE_RUN_BUSY` (design record §6: "bounded
|
|
3565
|
+
* retry on thrown STATE_RUN_BUSY"). Small and fixed — lock contention self-heals quickly; this is
|
|
3566
|
+
* not a backoff schedule, just enough attempts to ride out a transient contender. */
|
|
3567
|
+
const DRAIN_MARK_BUSY_RETRIES = 3;
|
|
3568
|
+
/**
|
|
3569
|
+
* Post-commit finalizer drain (design record §6, D2, issue #279 increment 1 PR-B). Drains the
|
|
3570
|
+
* LOWEST-ranked pending finalizer, re-reading the record returned by the LAST settle/lease/mark at
|
|
3571
|
+
* every step — NEVER a pass-start snapshot (the DRAIN_REREADS_LEDGER pin: a snapshot taken once at
|
|
3572
|
+
* the top of this function would go stale the instant the first lease/mark commits, silently
|
|
3573
|
+
* reintroducing exactly the kind of pre-commit assumption #279 exists to eliminate).
|
|
3574
|
+
*
|
|
3575
|
+
* Lives here (not in settlement.ts) so the module-private `withTimeout`/`callHandler` — already
|
|
3576
|
+
* proven by `buildFinalizedSeal` above — are reused without exporting them; `settlement.ts` stays
|
|
3577
|
+
* pure/no-I/O (design record §7 CS-purity).
|
|
3578
|
+
*
|
|
3579
|
+
* Registry pre-check: an absent handler leaves the entry pending and discloses why, rather than
|
|
3580
|
+
* burning it (leasing it, failing the call, and marking it 'failed' would destroy the "recoverable
|
|
3581
|
+
* on a capable runner" property a still-pending entry carries). Rank-monotonic: a pass HALTS at the
|
|
3582
|
+
* first entry it cannot lease/execute, so a lower-ranked absent-handler or held-lease entry
|
|
3583
|
+
* withholds every higher-ranked (later) finalizer behind it (R11) — this is deliberate: rank order
|
|
3584
|
+
* is a DELIVERY order guarantee, not just a preference.
|
|
3585
|
+
*
|
|
3586
|
+
* `not_eligible` at lease OR mark time is a contract violation (an unknown finalizer id — mintFresh
|
|
3587
|
+
* only ever mints real workflow finalizer names) and aborts the pass LOUD (throws), distinct from
|
|
3588
|
+
* `ledger_not_pending` at lease time, which ADVANCES (someone else already resolved it). A
|
|
3589
|
+
* non-BUSY infra throw from `mark_finalizer` also aborts loud, with the remaining-pending list in
|
|
3590
|
+
* the message. The executed-but-not-recorded warning (three reasons: `lease_lost`,
|
|
3591
|
+
* `ledger_not_pending`-AFTER-execution, `run_not_terminal`) halts the pass rather than continuing —
|
|
3592
|
+
* each of those three reasons refuses the mark WITHOUT mutating the ledger entry's `'pending'`
|
|
3593
|
+
* status, so continuing would re-select the SAME entry next iteration (an infinite loop); halting
|
|
3594
|
+
* is the only forward-progress-safe response until an operator or a later terminal edge resolves it.
|
|
3595
|
+
*/
|
|
3596
|
+
export async function drainFinalizers(store, definition, registry, runId) {
|
|
3597
|
+
// .bind(store): a bare `store.settleStep` reference loses its `this` binding — the store's own
|
|
3598
|
+
// method body (e.g. JsonFileStore's `this.ensureDir()`/`this.filePath()`) would throw on
|
|
3599
|
+
// `this === undefined` once called through the detached reference below.
|
|
3600
|
+
const settleStep = store.settleStep?.bind(store);
|
|
3601
|
+
if (settleStep === undefined) {
|
|
3602
|
+
// Defensive — every caller only invokes this once it has already confirmed the store
|
|
3603
|
+
// declares settleStep. A fresh read is still the correct degenerate response.
|
|
3604
|
+
return { run: await store.get(runId), warnings: [] };
|
|
3605
|
+
}
|
|
3606
|
+
const warnings = [];
|
|
3607
|
+
let run = await store.get(runId);
|
|
3608
|
+
for (;;) {
|
|
3609
|
+
const pending = pendingByRank(run);
|
|
3610
|
+
if (pending.length === 0)
|
|
3611
|
+
break;
|
|
3612
|
+
const finalizerName = pending[0];
|
|
3613
|
+
const stepDef = definition.steps[finalizerName];
|
|
3614
|
+
const handlerName = stepDef?.handler;
|
|
3615
|
+
const handler = handlerName !== undefined ? registry?.getHandler(handlerName) : undefined;
|
|
3616
|
+
// Registry pre-check — never burn an absent handler; rank-monotonic HALT (R11).
|
|
3617
|
+
if (stepDef === undefined || handlerName === undefined || handler === undefined) {
|
|
3618
|
+
warnings.push(`finalizer '${finalizerName}' left pending — handler not available on this surface`);
|
|
3619
|
+
break;
|
|
3620
|
+
}
|
|
3621
|
+
const leaseToken = crypto.randomUUID();
|
|
3622
|
+
const leaseSeconds = stepDef.timeout_seconds ?? DRAIN_CEILING_SECONDS;
|
|
3623
|
+
const leaseResult = await settleStep(runId, { kind: 'lease_finalizer', finalizer: finalizerName, leaseToken, leaseSeconds }, definition);
|
|
3624
|
+
if (!leaseResult.applied) {
|
|
3625
|
+
if (leaseResult.reason === 'lease_held' || leaseResult.reason === 'rank_blocked') {
|
|
3626
|
+
run = leaseResult.run;
|
|
3627
|
+
break; // HALT the pass — a peer holds this lease, or a lower rank is still pending.
|
|
3628
|
+
}
|
|
3629
|
+
if (leaseResult.reason === 'ledger_not_pending') {
|
|
3630
|
+
run = leaseResult.run;
|
|
3631
|
+
continue; // ADVANCE — a peer already resolved this entry.
|
|
3632
|
+
}
|
|
3633
|
+
// not_eligible — unknown finalizer id; a contract violation (mintFresh never mints one).
|
|
3634
|
+
throw new Error(`drainFinalizers: lease refused '${leaseResult.reason}' for finalizer '${finalizerName}' ` +
|
|
3635
|
+
`on run '${runId}' — remaining pending: ${pendingByRank(leaseResult.run).join(', ')}`);
|
|
3636
|
+
}
|
|
3637
|
+
run = leaseResult.run;
|
|
3638
|
+
// callHandler under withTimeout, OUTSIDE any critical section (the store's CS ends the
|
|
3639
|
+
// instant the lease-apply write above returned).
|
|
3640
|
+
const startedAt = new Date();
|
|
3641
|
+
const evidenceByStep = buildEvidenceByStep(run);
|
|
3642
|
+
const callOptions = {
|
|
3643
|
+
runId,
|
|
3644
|
+
command: finalizerName,
|
|
3645
|
+
input: {},
|
|
3646
|
+
dispatcher: async () => ({}),
|
|
3647
|
+
...(registry !== undefined ? { registry } : {}),
|
|
3648
|
+
};
|
|
3649
|
+
let markResult;
|
|
3650
|
+
let evidenceSnapshot;
|
|
3651
|
+
try {
|
|
3652
|
+
const callResult = await withTimeout((signal) => callHandler(stepDef, callOptions, run, evidenceByStep, signal), leaseSeconds * 1000, finalizerName);
|
|
3653
|
+
if (callResult.kind === 'abort') {
|
|
3654
|
+
markResult = 'failed';
|
|
3655
|
+
evidenceSnapshot = captureEvidence({
|
|
3656
|
+
stepId: finalizerName,
|
|
3657
|
+
startedAt,
|
|
3658
|
+
completedAt: new Date(),
|
|
3659
|
+
input: {},
|
|
3660
|
+
output: { aborted: true, abort_message: callResult.message },
|
|
3661
|
+
error: `Finalizer '${finalizerName}' returned abort: ${callResult.message}`,
|
|
3662
|
+
});
|
|
3663
|
+
}
|
|
3664
|
+
else {
|
|
3665
|
+
markResult = 'completed';
|
|
3666
|
+
evidenceSnapshot = captureEvidence({
|
|
3667
|
+
stepId: finalizerName,
|
|
3668
|
+
startedAt,
|
|
3669
|
+
completedAt: new Date(),
|
|
3670
|
+
input: {},
|
|
3671
|
+
output: callResult.output,
|
|
3672
|
+
...(callResult.kind === 'warn' ? { warn: callResult.message } : {}),
|
|
3673
|
+
});
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3676
|
+
catch (err) {
|
|
3677
|
+
markResult = 'failed';
|
|
3678
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3679
|
+
evidenceSnapshot = captureEvidence({
|
|
3680
|
+
stepId: finalizerName,
|
|
3681
|
+
startedAt,
|
|
3682
|
+
completedAt: new Date(),
|
|
3683
|
+
input: {},
|
|
3684
|
+
output: {},
|
|
3685
|
+
error: `Finalizer '${finalizerName}' failed: ${message}`,
|
|
3686
|
+
});
|
|
3687
|
+
}
|
|
3688
|
+
// mark_finalizer — same lease token; bounded retry on a THROWN STATE_RUN_BUSY only.
|
|
3689
|
+
let markOutcome;
|
|
3690
|
+
for (let attempt = 1; attempt <= DRAIN_MARK_BUSY_RETRIES; attempt++) {
|
|
3691
|
+
try {
|
|
3692
|
+
markOutcome = await settleStep(runId, {
|
|
3693
|
+
kind: 'mark_finalizer',
|
|
3694
|
+
finalizer: finalizerName,
|
|
3695
|
+
leaseToken,
|
|
3696
|
+
result: markResult,
|
|
3697
|
+
evidence: evidenceSnapshot,
|
|
3698
|
+
}, definition);
|
|
3699
|
+
break;
|
|
3700
|
+
}
|
|
3701
|
+
catch (err) {
|
|
3702
|
+
const isBusy = err instanceof WorkflowError && err.code === 'STATE_RUN_BUSY';
|
|
3703
|
+
if (isBusy && attempt < DRAIN_MARK_BUSY_RETRIES)
|
|
3704
|
+
continue;
|
|
3705
|
+
// Non-BUSY infra throw (or BUSY exhausted) ⇒ abort the pass loud, remaining-pending named.
|
|
3706
|
+
throw new Error(`drainFinalizers: mark_finalizer failed for '${finalizerName}' on run '${runId}' — ` +
|
|
3707
|
+
`remaining pending: ${pendingByRank(run).join(', ')}: ` +
|
|
3708
|
+
`${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
3709
|
+
}
|
|
3710
|
+
}
|
|
3711
|
+
/* istanbul ignore next -- the for-loop above always either assigns markOutcome or throws */
|
|
3712
|
+
if (markOutcome === undefined) {
|
|
3713
|
+
throw new Error(`drainFinalizers: mark_finalizer produced no outcome for '${finalizerName}' on run '${runId}'`);
|
|
3714
|
+
}
|
|
3715
|
+
if (!markOutcome.applied) {
|
|
3716
|
+
if (markOutcome.reason === 'not_eligible') {
|
|
3717
|
+
throw new Error(`drainFinalizers: mark refused 'not_eligible' for finalizer '${finalizerName}' on run ` +
|
|
3718
|
+
`'${runId}' — contract violation (mintFresh never mints an unknown id)`);
|
|
3719
|
+
}
|
|
3720
|
+
// lease_lost / ledger_not_pending(-after-execution) / run_not_terminal: the handler DID
|
|
3721
|
+
// execute, but the outcome could not be durably recorded — the three-reason
|
|
3722
|
+
// executed-but-not-recorded warning (design record §6). Halt: none of these three mutate
|
|
3723
|
+
// the entry's 'pending' status, so continuing would re-select the SAME entry forever.
|
|
3724
|
+
run = markOutcome.run;
|
|
3725
|
+
warnings.push(`finalizer '${finalizerName}' executed but its outcome may not have been recorded ` +
|
|
3726
|
+
`(${markOutcome.reason}) — it may re-execute at the next terminal edge`);
|
|
3727
|
+
break;
|
|
3728
|
+
}
|
|
3729
|
+
run = markOutcome.run;
|
|
3730
|
+
// result:'failed' is settled too (a recorded terminal outcome for this finalizer) — the loop
|
|
3731
|
+
// continues to the next rank regardless of markResult.
|
|
3732
|
+
}
|
|
3733
|
+
return { run, warnings };
|
|
3734
|
+
}
|
|
2588
3735
|
async function executeChainInternal(store, definition, options, depth, chainedSteps,
|
|
2589
3736
|
/**
|
|
2590
3737
|
* issue #197 PR-2 (chain-replacement disposition, accepted): a settled DEPTH-0 step (typically
|
|
@@ -2642,15 +3789,242 @@ depth0Warnings) {
|
|
|
2642
3789
|
// Execute any eligible guard steps inline before looking for the next auto step.
|
|
2643
3790
|
// Guard steps are synchronous engine decisions — not returned to the agent.
|
|
2644
3791
|
// Loop to handle cascading guards (guard A passes → guard B becomes eligible).
|
|
3792
|
+
// issue #279 (increment 2, PR-D, Deliverable 1c): non-abort settled_outcome_divergence warnings
|
|
3793
|
+
// ADVANCE the chain but must still surface somewhere — carried here and merged into whichever
|
|
3794
|
+
// envelope eventually returns (the guardsRan rebuild below, or a migrated terminal return).
|
|
3795
|
+
const guardWarnings = [];
|
|
2645
3796
|
let guardEligible = findEligibleGuardSteps(definition, run);
|
|
2646
3797
|
while (guardEligible.length > 0) {
|
|
2647
3798
|
const guardName = guardEligible[0];
|
|
2648
3799
|
const guardStepDef = definition.steps[guardName];
|
|
2649
|
-
// Execute inline (pure in-memory; returns updated RunRecord).
|
|
3800
|
+
// Execute inline (pure in-memory; returns updated RunRecord). executeGuardStep stays PURE and
|
|
3801
|
+
// UNTOUCHED (design record §1) — both the migrated and legacy paths below call it identically.
|
|
2650
3802
|
const guardResult = await executeGuardStep(guardName, guardStepDef, definition, run);
|
|
2651
3803
|
// Capture the guard's OWN evidence (its last entry) BEFORE the finalizer drain appends
|
|
2652
3804
|
// finalizer evidence — the terminal return below surfaces only the guard's evidence.
|
|
2653
3805
|
const guardOwnEvidence = guardResult.evidence.slice(-1);
|
|
3806
|
+
// issue #279 (increment 2, PR-D, Deliverable 1c): the migrated path — settles this guard's
|
|
3807
|
+
// evaluated outcome atomically against FRESH state via the store's own settleStep. Dormancy:
|
|
3808
|
+
// an undeclaring store falls through to the byte-identical legacy path below (I16/#169
|
|
3809
|
+
// fail-closed dormancy).
|
|
3810
|
+
if (store.settleStep !== undefined) {
|
|
3811
|
+
// Extraction rule (design record §2, normative): reverse-classify guardResult's SEALED
|
|
3812
|
+
// output by MEMBERSHIP — NEVER the terminal_state ternary below (wrong for a non-terminal
|
|
3813
|
+
// pass, which never sets terminal_state at all).
|
|
3814
|
+
const guardSettleOutcome = guardResult.aborted_at !== undefined
|
|
3815
|
+
? 'abort'
|
|
3816
|
+
: guardResult.failed_steps.includes(guardName)
|
|
3817
|
+
? 'resolution_error'
|
|
3818
|
+
: 'pass'; // the only remaining membership — completed_steps.includes(guardName)
|
|
3819
|
+
let resolutionError;
|
|
3820
|
+
if (guardSettleOutcome === 'resolution_error') {
|
|
3821
|
+
// Rails-compliant re-derivation (normative): normalize abort_unless to string[] (the
|
|
3822
|
+
// executeGuardStep :3632-3635 shape) and re-run evaluateGuardConditions against the SAME
|
|
3823
|
+
// pre-seal `run` passed to executeGuardStep — pure + deterministic, so this reproduces the
|
|
3824
|
+
// discarded internal result byte-for-byte.
|
|
3825
|
+
const conditions = Array.isArray(guardStepDef.abort_unless)
|
|
3826
|
+
? guardStepDef.abort_unless
|
|
3827
|
+
: [guardStepDef.abort_unless];
|
|
3828
|
+
const reEvaluated = evaluateGuardConditions(conditions, buildEvidenceByStep(run));
|
|
3829
|
+
if (reEvaluated.kind === 'resolution_error') {
|
|
3830
|
+
resolutionError = {
|
|
3831
|
+
condition: reEvaluated.condition,
|
|
3832
|
+
unresolvable_path: reEvaluated.unresolvable_path,
|
|
3833
|
+
};
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3836
|
+
const delta = {
|
|
3837
|
+
kind: 'settle_guard',
|
|
3838
|
+
step: guardName,
|
|
3839
|
+
outcome: guardSettleOutcome,
|
|
3840
|
+
evidence: guardOwnEvidence[0],
|
|
3841
|
+
...(resolutionError !== undefined ? { resolutionError } : {}),
|
|
3842
|
+
...(guardSettleOutcome === 'abort'
|
|
3843
|
+
? {
|
|
3844
|
+
abort: {
|
|
3845
|
+
conditions: guardResult.aborted_at.conditions ?? [],
|
|
3846
|
+
...(guardResult.aborted_at.abort_message !== undefined
|
|
3847
|
+
? { abort_message: guardResult.aborted_at.abort_message }
|
|
3848
|
+
: {}),
|
|
3849
|
+
},
|
|
3850
|
+
}
|
|
3851
|
+
: {}),
|
|
3852
|
+
// evaluatedAtVersion (design record §2, lane-B steal 2): the chain's OWN evaluation
|
|
3853
|
+
// snapshot — this iteration's pre-settle `run.version`.
|
|
3854
|
+
evaluatedAtVersion: run.version,
|
|
3855
|
+
};
|
|
3856
|
+
let guardSettleResult;
|
|
3857
|
+
try {
|
|
3858
|
+
guardSettleResult = await store.settleStep(options.runId, delta, definition);
|
|
3859
|
+
}
|
|
3860
|
+
catch (storeErr) {
|
|
3861
|
+
// Thrown infra errors — the same persist-failure envelope shape the legacy path's own
|
|
3862
|
+
// store.update catch (below) has always returned.
|
|
3863
|
+
const msg = storeErr instanceof Error ? storeErr.message : String(storeErr);
|
|
3864
|
+
return {
|
|
3865
|
+
command: options.command,
|
|
3866
|
+
run_id: options.runId,
|
|
3867
|
+
run_version: run.version,
|
|
3868
|
+
status: 'error',
|
|
3869
|
+
data: {},
|
|
3870
|
+
evidence: [],
|
|
3871
|
+
warnings: [],
|
|
3872
|
+
errors: [`Failed to persist guard step '${guardName}': ${msg}`],
|
|
3873
|
+
agent_action: 'stop',
|
|
3874
|
+
context_hint: `Guard step '${guardName}' could not be persisted. Run state may be inconsistent.`,
|
|
3875
|
+
run_phase: run.run_phase,
|
|
3876
|
+
next_actions: [],
|
|
3877
|
+
};
|
|
3878
|
+
}
|
|
3879
|
+
if (!guardSettleResult.applied) {
|
|
3880
|
+
// Chain-consumption table (design record §6, adjudicated):
|
|
3881
|
+
if (guardSettleResult.reason === 'already_settled' ||
|
|
3882
|
+
guardSettleResult.reason === 'gate_open_wait' ||
|
|
3883
|
+
(guardSettleResult.reason === 'settled_outcome_divergence' &&
|
|
3884
|
+
guardSettleOutcome !== 'abort')) {
|
|
3885
|
+
// already_settled / gate_open_wait ⇒ ADVANCE, threading result.run (findEligibleGuardSteps
|
|
3886
|
+
// self-filters both a now-settled guard and an open gate, so the loop naturally converges).
|
|
3887
|
+
// settled_outcome_divergence on a NON-abort attempt ⇒ ADVANCE + a warning line.
|
|
3888
|
+
if (guardSettleResult.reason === 'settled_outcome_divergence') {
|
|
3889
|
+
guardWarnings.push(`guard '${guardName}' outcome diverged from a concurrent settle` +
|
|
3890
|
+
(guardSettleResult.persisted !== undefined
|
|
3891
|
+
? ` (persisted: '${guardSettleResult.persisted}')`
|
|
3892
|
+
: '') +
|
|
3893
|
+
' — chain advanced on the persisted outcome.');
|
|
3894
|
+
}
|
|
3895
|
+
if (guardSettleResult.reason !== 'gate_open_wait') {
|
|
3896
|
+
// "quiet" end-of-pass for gate_open_wait only — nothing was decided, so nothing is
|
|
3897
|
+
// recorded; already_settled/divergence DID decide something (elsewhere), so it is.
|
|
3898
|
+
chainedSteps.push({ step: guardName, run_phase: guardSettleResult.run.run_phase });
|
|
3899
|
+
}
|
|
3900
|
+
run = guardSettleResult.run;
|
|
3901
|
+
// Drain: on already_settled ∧ pending ledger entries non-empty (design record §6, "same
|
|
3902
|
+
// clause" as the transitioned leg below) — recovers a crashed-drain RESOLVE that a
|
|
3903
|
+
// sibling's own settle committed but never drained.
|
|
3904
|
+
if (guardSettleResult.reason === 'already_settled') {
|
|
3905
|
+
const hasPending = Object.values(run.finalizer_ledger ?? {}).some((e) => e.status === 'pending');
|
|
3906
|
+
if (hasPending) {
|
|
3907
|
+
try {
|
|
3908
|
+
const drainOutcome = await drainFinalizers(store, definition, options.registry, options.runId);
|
|
3909
|
+
run = drainOutcome.run;
|
|
3910
|
+
if (drainOutcome.warnings.length > 0)
|
|
3911
|
+
guardWarnings.push(...drainOutcome.warnings);
|
|
3912
|
+
}
|
|
3913
|
+
catch (err) {
|
|
3914
|
+
guardWarnings.push(`post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
3915
|
+
}
|
|
3916
|
+
}
|
|
3917
|
+
}
|
|
3918
|
+
guardEligible = findEligibleGuardSteps(definition, run);
|
|
3919
|
+
continue;
|
|
3920
|
+
}
|
|
3921
|
+
if (guardSettleResult.reason === 'settled_outcome_divergence') {
|
|
3922
|
+
// ABORT leg only ⇒ report_to_user + chain-RETURN (design record §6/§7) — this attempt's
|
|
3923
|
+
// abort was never recorded.
|
|
3924
|
+
const err = new WorkflowError(`Guard step '${guardName}' was already settled` +
|
|
3925
|
+
(guardSettleResult.persisted !== undefined
|
|
3926
|
+
? ` (persisted: '${guardSettleResult.persisted}')`
|
|
3927
|
+
: '') +
|
|
3928
|
+
` by a different attempt — your abort was NOT recorded.`, {
|
|
3929
|
+
code: 'STATE_STEP_ALREADY_SETTLED',
|
|
3930
|
+
category: 'STATE',
|
|
3931
|
+
agentAction: 'report_to_user',
|
|
3932
|
+
retryable: false,
|
|
3933
|
+
details: {
|
|
3934
|
+
runId: options.runId,
|
|
3935
|
+
step: guardName,
|
|
3936
|
+
reason: guardSettleResult.reason,
|
|
3937
|
+
...(guardSettleResult.persisted !== undefined
|
|
3938
|
+
? { persisted: guardSettleResult.persisted }
|
|
3939
|
+
: {}),
|
|
3940
|
+
},
|
|
3941
|
+
});
|
|
3942
|
+
return {
|
|
3943
|
+
command: options.command,
|
|
3944
|
+
run_id: options.runId,
|
|
3945
|
+
run_version: guardSettleResult.run.version,
|
|
3946
|
+
status: 'error',
|
|
3947
|
+
data: {},
|
|
3948
|
+
evidence: [],
|
|
3949
|
+
warnings: [],
|
|
3950
|
+
errors: [err.message],
|
|
3951
|
+
error_code: err.code,
|
|
3952
|
+
...(Object.keys(err.details).length > 0 ? { error_details: err.details } : {}),
|
|
3953
|
+
agent_action: 'report_to_user',
|
|
3954
|
+
context_hint: err.message,
|
|
3955
|
+
run_phase: guardSettleResult.run.run_phase,
|
|
3956
|
+
next_actions: [],
|
|
3957
|
+
};
|
|
3958
|
+
}
|
|
3959
|
+
if (guardSettleResult.reason === 'run_terminal') {
|
|
3960
|
+
// Terminal by OTHER (a sibling settle raced this guard's own evaluation) — INLINE
|
|
3961
|
+
// construction, parity with the entry-terminal envelope (executeChain's own early
|
|
3962
|
+
// return).
|
|
3963
|
+
return {
|
|
3964
|
+
command: options.command,
|
|
3965
|
+
run_id: options.runId,
|
|
3966
|
+
run_version: guardSettleResult.run.version,
|
|
3967
|
+
status: 'ok',
|
|
3968
|
+
data: {},
|
|
3969
|
+
evidence: [],
|
|
3970
|
+
warnings: [],
|
|
3971
|
+
errors: [],
|
|
3972
|
+
agent_action: 'stop',
|
|
3973
|
+
context_hint: `Run '${options.runId}' is already terminal (${guardSettleResult.run.run_phase}); guard '${guardName}' was not evaluated.`,
|
|
3974
|
+
run_phase: guardSettleResult.run.run_phase,
|
|
3975
|
+
next_actions: [],
|
|
3976
|
+
};
|
|
3977
|
+
}
|
|
3978
|
+
// gate_mismatch/choice_not_eligible/already_open/already_released and every other kind's
|
|
3979
|
+
// own reason are unreachable here — settle_guard never returns them (design record §7).
|
|
3980
|
+
throw new Error(`executeChainInternal: unreachable settle_guard refusal reason '${guardSettleResult.reason}'`);
|
|
3981
|
+
}
|
|
3982
|
+
// applied: true.
|
|
3983
|
+
chainedSteps.push({ step: guardName, run_phase: guardSettleResult.run.run_phase });
|
|
3984
|
+
if (guardSettleResult.transitioned) {
|
|
3985
|
+
// Drain IMMEDIATELY after a transitioned settle result, BEFORE building the in-loop
|
|
3986
|
+
// terminal envelope (design record §6/R5 — a post-loop drain would be dead code on this
|
|
3987
|
+
// leg: this function RETURNS before ever reaching a post-loop point).
|
|
3988
|
+
let finalGuardRun = guardSettleResult.run;
|
|
3989
|
+
let guardDrainWarnings;
|
|
3990
|
+
try {
|
|
3991
|
+
const drainOutcome = await drainFinalizers(store, definition, options.registry, options.runId);
|
|
3992
|
+
finalGuardRun = drainOutcome.run;
|
|
3993
|
+
guardDrainWarnings = drainOutcome.warnings;
|
|
3994
|
+
}
|
|
3995
|
+
catch (err) {
|
|
3996
|
+
guardDrainWarnings = [
|
|
3997
|
+
`post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
3998
|
+
];
|
|
3999
|
+
}
|
|
4000
|
+
const migratedContextHint = guardSettleOutcome === 'abort'
|
|
4001
|
+
? `Guard step '${guardName}' aborted the run.`
|
|
4002
|
+
: guardSettleOutcome === 'resolution_error'
|
|
4003
|
+
? `Guard step '${guardName}' failed with a resolution error. Run is terminated.`
|
|
4004
|
+
: `Guard step '${guardName}' passed and completed the run.`;
|
|
4005
|
+
return {
|
|
4006
|
+
command: options.command,
|
|
4007
|
+
run_id: options.runId,
|
|
4008
|
+
run_version: finalGuardRun.version,
|
|
4009
|
+
status: 'ok',
|
|
4010
|
+
data: {},
|
|
4011
|
+
evidence: guardOwnEvidence,
|
|
4012
|
+
warnings: mergeWarnings(guardWarnings, ...guardDrainWarnings),
|
|
4013
|
+
errors: [],
|
|
4014
|
+
context_hint: migratedContextHint,
|
|
4015
|
+
run_phase: finalGuardRun.run_phase,
|
|
4016
|
+
next_actions: [],
|
|
4017
|
+
...(finalGuardRun.defaulted_steps?.length
|
|
4018
|
+
? { defaulted_steps: finalGuardRun.defaulted_steps }
|
|
4019
|
+
: {}),
|
|
4020
|
+
};
|
|
4021
|
+
}
|
|
4022
|
+
// Non-terminal pass — continue the chain.
|
|
4023
|
+
run = guardSettleResult.run;
|
|
4024
|
+
guardEligible = findEligibleGuardSteps(definition, run);
|
|
4025
|
+
continue;
|
|
4026
|
+
}
|
|
4027
|
+
// --- Legacy path (dormancy fallback — byte-identical to pre-#279 behavior) ---
|
|
2654
4028
|
// Blocking fix #1: classify the terminal outcome by the SEALED record, not aborted_at
|
|
2655
4029
|
// alone. executeGuardStep sets terminal_state in THREE cases — abort (aborted_at set),
|
|
2656
4030
|
// resolution-error (failed, no aborted_at), and a PASS that completes the run
|
|
@@ -2719,7 +4093,9 @@ depth0Warnings) {
|
|
|
2719
4093
|
data: {},
|
|
2720
4094
|
// The guard's own evidence entry, captured before the finalizer drain appended any.
|
|
2721
4095
|
evidence: guardOwnEvidence,
|
|
2722
|
-
|
|
4096
|
+
// issue #279 (increment 2, PR-D): + the ONE dormancy advisory (I16) — this IS the legacy
|
|
4097
|
+
// path (store.settleStep undeclared).
|
|
4098
|
+
warnings: mergeWarnings([], guardDefaultedStepsDurabilityWarning, DORMANCY_ADVISORY),
|
|
2723
4099
|
errors: [],
|
|
2724
4100
|
context_hint: contextHint,
|
|
2725
4101
|
run_phase: persistedGuardRun.run_phase,
|
|
@@ -2743,6 +4119,12 @@ depth0Warnings) {
|
|
|
2743
4119
|
...result,
|
|
2744
4120
|
run_version: run.version,
|
|
2745
4121
|
next_actions: freshNextActions,
|
|
4122
|
+
// issue #279 (increment 2, PR-D): non-abort settled_outcome_divergence warnings accumulated
|
|
4123
|
+
// during the guard loop above (the ADVANCE leg) must reach whichever envelope returns —
|
|
4124
|
+
// this rebuild is the first point after the loop `result` is touched again.
|
|
4125
|
+
...(guardWarnings.length > 0
|
|
4126
|
+
? { warnings: mergeWarnings(result.warnings, ...guardWarnings) }
|
|
4127
|
+
: {}),
|
|
2746
4128
|
};
|
|
2747
4129
|
}
|
|
2748
4130
|
if (run.terminal_state || run.pending_gate !== undefined) {
|
|
@@ -2799,7 +4181,13 @@ export async function executeChain(store, definition, options) {
|
|
|
2799
4181
|
throw err;
|
|
2800
4182
|
}
|
|
2801
4183
|
}
|
|
2802
|
-
|
|
4184
|
+
// issue #279 (increment 2, PR-C — D-3 leg v): keyed on terminal_state, never the persisted
|
|
4185
|
+
// run_phase — a grandfathered terminal-with-stale-gate record (the #282 class) must still be
|
|
4186
|
+
// recognized as terminal here.
|
|
4187
|
+
if (entryRun !== undefined && entryRun.terminal_state === true) {
|
|
4188
|
+
// Derive-for-message (D-3 leg v): render the TRUE (derived) phase, never the possibly-stale
|
|
4189
|
+
// persisted one.
|
|
4190
|
+
const derivedPhase = deriveRunPhase(entryRun);
|
|
2803
4191
|
return {
|
|
2804
4192
|
command: options.command,
|
|
2805
4193
|
run_id: options.runId,
|
|
@@ -2810,8 +4198,8 @@ export async function executeChain(store, definition, options) {
|
|
|
2810
4198
|
warnings: [],
|
|
2811
4199
|
errors: [],
|
|
2812
4200
|
agent_action: 'stop',
|
|
2813
|
-
context_hint: `Run '${options.runId}' is already terminal (${
|
|
2814
|
-
run_phase:
|
|
4201
|
+
context_hint: `Run '${options.runId}' is already terminal (${derivedPhase}); no steps executed.`,
|
|
4202
|
+
run_phase: derivedPhase,
|
|
2815
4203
|
next_actions: [],
|
|
2816
4204
|
};
|
|
2817
4205
|
}
|