@sensigo/realm 0.32.0 → 0.34.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 +11 -2
- package/dist/engine/apply-resume.d.ts.map +1 -1
- package/dist/engine/apply-resume.js +15 -4
- package/dist/engine/apply-resume.js.map +1 -1
- 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 +29 -5
- package/dist/engine/execution-loop.d.ts.map +1 -1
- package/dist/engine/execution-loop.js +1152 -90
- package/dist/engine/execution-loop.js.map +1 -1
- package/dist/engine/gate-timing.d.ts +17 -0
- package/dist/engine/gate-timing.d.ts.map +1 -0
- package/dist/engine/gate-timing.js +19 -0
- package/dist/engine/gate-timing.js.map +1 -0
- package/dist/engine/reclaim-step.d.ts +8 -0
- package/dist/engine/reclaim-step.d.ts.map +1 -1
- package/dist/engine/reclaim-step.js +48 -3
- package/dist/engine/reclaim-step.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 +100 -2
- package/dist/engine/run-health.js.map +1 -1
- package/dist/engine/settlement.d.ts +56 -7
- package/dist/engine/settlement.d.ts.map +1 -1
- package/dist/engine/settlement.js +690 -37
- package/dist/engine/settlement.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/store/json-file-store.d.ts +6 -4
- package/dist/store/json-file-store.d.ts.map +1 -1
- package/dist/store/json-file-store.js +26 -9
- package/dist/store/json-file-store.js.map +1 -1
- package/dist/store/store-interface.d.ts +11 -8
- package/dist/store/store-interface.d.ts.map +1 -1
- package/dist/types/response-envelope.d.ts +16 -0
- package/dist/types/response-envelope.d.ts.map +1 -1
- package/dist/types/run-record.d.ts +132 -3
- package/dist/types/run-record.d.ts.map +1 -1
- package/dist/types/settlement.d.ts +164 -11
- package/dist/types/settlement.d.ts.map +1 -1
- package/dist/types/workflow-definition.d.ts +113 -32
- package/dist/types/workflow-definition.d.ts.map +1 -1
- package/dist/types/workflow-definition.js +20 -0
- package/dist/types/workflow-definition.js.map +1 -1
- package/dist/workflow/diagnostics.d.ts +1 -1
- package/dist/workflow/diagnostics.d.ts.map +1 -1
- package/dist/workflow/diagnostics.js +3 -0
- package/dist/workflow/diagnostics.js.map +1 -1
- package/dist/workflow/structured-output-eligibility.d.ts +81 -0
- package/dist/workflow/structured-output-eligibility.d.ts.map +1 -0
- package/dist/workflow/structured-output-eligibility.js +359 -0
- package/dist/workflow/structured-output-eligibility.js.map +1 -0
- package/dist/workflow/yaml-loader.d.ts.map +1 -1
- package/dist/workflow/yaml-loader.js +145 -2
- package/dist/workflow/yaml-loader.js.map +1 -1
- package/package.json +1 -1
|
@@ -4,11 +4,12 @@ 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
6
|
import { deriveDefaultedSteps } from './defaulted-steps.js';
|
|
7
|
-
import {
|
|
7
|
+
import { computeGateDueState } from './gate-timing.js';
|
|
8
|
+
import { selectFinalizers, deriveEffectiveTriggers, applySettlement } from './settlement.js';
|
|
8
9
|
import { captureEvidence } from '../evidence/snapshot.js';
|
|
9
10
|
import { validateInputSchema, validateOutputSchema, validateTraceSchema, } from '../validation/input-schema.js';
|
|
10
11
|
import { normalizeTrace } from './trace-normalizer.js';
|
|
11
|
-
import { TERMINAL_PHASES,
|
|
12
|
+
import { TERMINAL_PHASES, DRAIN_CEILING_SECONDS } from './lifecycle.js';
|
|
12
13
|
import { omitClaim, shouldEnforceTimeout, DEFAULT_EXECUTION_TIMEOUT_SECONDS, resolveCapMs, sleepWouldExceedCap, } from './claim-liveness.js';
|
|
13
14
|
import { computeBackoff } from './backoff.js';
|
|
14
15
|
import { checkPreconditions, evaluateAllPreconditions, evaluateGuardConditions, } from './precondition.js';
|
|
@@ -401,6 +402,25 @@ function stampDefaultedSteps(sealDraft) {
|
|
|
401
402
|
return sealDraft;
|
|
402
403
|
return { ...sealDraft, defaulted_steps: steps };
|
|
403
404
|
}
|
|
405
|
+
/**
|
|
406
|
+
* The compensating un-claim's own audit-evidence entry (issue #207 PR-2, D3 §5; extracted issue
|
|
407
|
+
* #279, increment 2, PR-D, Deliverable 1e — the `:679` semantics both the legacy
|
|
408
|
+
* `buildCompensatingUnclaim` below AND the migrated `release_step` delta's `evidence` field share
|
|
409
|
+
* verbatim).
|
|
410
|
+
*/
|
|
411
|
+
function buildCompensatingUnclaimEvidence(stepName, now) {
|
|
412
|
+
return captureEvidence({
|
|
413
|
+
stepId: stepName,
|
|
414
|
+
startedAt: now,
|
|
415
|
+
completedAt: now,
|
|
416
|
+
input: {},
|
|
417
|
+
output: {
|
|
418
|
+
compensating_unclaim: true,
|
|
419
|
+
reason: 'adoption-read failure after claim',
|
|
420
|
+
unclaimed_at: now.toISOString(),
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
}
|
|
404
424
|
/**
|
|
405
425
|
* Compensating un-claim (issue #207 PR-2, D3 §5): built from `pendingRun` — the record OUR OWN
|
|
406
426
|
* `claimStep` call returned, never a fresh get — removing the step from `in_progress_steps` AND
|
|
@@ -414,17 +434,7 @@ function stampDefaultedSteps(sealDraft) {
|
|
|
414
434
|
* naturally idempotent) is simply a no-op mutation, not a special case.
|
|
415
435
|
*/
|
|
416
436
|
function buildCompensatingUnclaim(pendingRun, stepName, now) {
|
|
417
|
-
const auditEvidence =
|
|
418
|
-
stepId: stepName,
|
|
419
|
-
startedAt: now,
|
|
420
|
-
completedAt: now,
|
|
421
|
-
input: {},
|
|
422
|
-
output: {
|
|
423
|
-
compensating_unclaim: true,
|
|
424
|
-
reason: 'adoption-read failure after claim',
|
|
425
|
-
unclaimed_at: now.toISOString(),
|
|
426
|
-
},
|
|
427
|
-
});
|
|
437
|
+
const auditEvidence = buildCompensatingUnclaimEvidence(stepName, now);
|
|
428
438
|
return {
|
|
429
439
|
...pendingRun,
|
|
430
440
|
in_progress_steps: pendingRun.in_progress_steps.filter((s) => s !== stepName),
|
|
@@ -624,23 +634,32 @@ function computeReArmWarnings(before, after) {
|
|
|
624
634
|
return warnings;
|
|
625
635
|
}
|
|
626
636
|
/**
|
|
627
|
-
* Builds the ResponseEnvelope for a `settle_step` REFUSAL (design record §7's
|
|
628
|
-
* shared by
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
633
|
-
*
|
|
637
|
+
* Builds the ResponseEnvelope for a `settle_step`/`open_gate` REFUSAL (design record §7's
|
|
638
|
+
* result/code table) — shared by the three migrated `settle_step` seal sites (issue #279,
|
|
639
|
+
* increment 1, PR-B) AND the migrated gate-open site (issue #279, increment 2, PR-D; `kind:
|
|
640
|
+
* 'open_gate'`). `allEvidence` is attached ONLY for `claim_lost`: the dispatch DID run and produce
|
|
641
|
+
* evidence; it just was not recorded, so the caller should still see what happened. The reasons
|
|
642
|
+
* both callers can actually return are enumerated explicitly; every OTHER `SettlementRefusalReason`
|
|
643
|
+
* member is lease/mark/settle_gate/settle_guard/release_step-only and structurally unreachable
|
|
644
|
+
* here (a `default` throws rather than silently mis-rendering one) — `choice_not_eligible` +
|
|
645
|
+
* `gate_choice_conflict` + the settle_gate `gate_mismatch`/`run_terminal` variants are consumed at
|
|
646
|
+
* 1b's own `errorEnvelope` (submitHumanResponse), never here; `gate_open_wait` is chain-consumed
|
|
647
|
+
* (executeChainInternal's guard loop); `already_released` is site-handled at 1d/1e (never routed
|
|
648
|
+
* through this shared builder).
|
|
634
649
|
*/
|
|
635
|
-
function buildSettlementRefusalEnvelope(options, definition, result, allEvidence, traceWarnings) {
|
|
650
|
+
function buildSettlementRefusalEnvelope(options, definition, result, allEvidence, traceWarnings, kind = 'settle_step') {
|
|
636
651
|
const extraWarnings = traceWarnings.length > 0 ? traceWarnings : undefined;
|
|
637
652
|
switch (result.reason) {
|
|
638
653
|
case 'already_settled_by_other':
|
|
639
654
|
case 'settled_outcome_divergence': {
|
|
640
655
|
const persisted = result.run.settled?.[options.command]?.outcome;
|
|
656
|
+
// N1 (design record §2/§11): neutral wording — never amplify a "by_other" white lie. When
|
|
657
|
+
// the persisted entry's outcome is 'gate', the step was settled by a COMPLETED GATE (a
|
|
658
|
+
// human decision resolved elsewhere), not literally "a different attempt".
|
|
659
|
+
const settledByText = persisted === 'gate' ? 'by a completed gate' : 'by a different attempt';
|
|
641
660
|
const err = new WorkflowError(`Step '${options.command}' was already settled` +
|
|
642
661
|
(persisted !== undefined ? ` with outcome '${persisted}'` : '') +
|
|
643
|
-
`
|
|
662
|
+
` ${settledByText}.`, {
|
|
644
663
|
code: 'STATE_STEP_ALREADY_SETTLED',
|
|
645
664
|
category: 'STATE',
|
|
646
665
|
agentAction: 'resolve_precondition',
|
|
@@ -679,8 +698,15 @@ function buildSettlementRefusalEnvelope(options, definition, result, allEvidence
|
|
|
679
698
|
return makeErrorEnvelope(options, result.run, err, definition, extraWarnings);
|
|
680
699
|
}
|
|
681
700
|
case 'gate_mismatch': {
|
|
682
|
-
|
|
683
|
-
|
|
701
|
+
// kind-discriminated (Deliverable 2): open_gate's gate_mismatch means a DIFFERENT step's
|
|
702
|
+
// gate is open (this step stays claimed — L13); settle_step's means THIS step IS the open
|
|
703
|
+
// gate and must be resolved via submit_human_response instead.
|
|
704
|
+
const message = kind === 'open_gate'
|
|
705
|
+
? `Step '${options.command}': a gate is open on another step — wait for its resolution; ` +
|
|
706
|
+
`this step stays claimed.`
|
|
707
|
+
: `Step '${options.command}' is the currently open gate; resolve it via ` +
|
|
708
|
+
`submit_human_response instead of settling it directly.`;
|
|
709
|
+
const err = new WorkflowError(message, {
|
|
684
710
|
code: 'STATE_BLOCKED',
|
|
685
711
|
category: 'STATE',
|
|
686
712
|
agentAction: 'resolve_precondition',
|
|
@@ -691,9 +717,12 @@ function buildSettlementRefusalEnvelope(options, definition, result, allEvidence
|
|
|
691
717
|
}
|
|
692
718
|
default:
|
|
693
719
|
// run_not_terminal / ledger_not_pending / lease_held / lease_lost / rank_blocked /
|
|
694
|
-
// not_eligible / already_leased / already_marked
|
|
695
|
-
//
|
|
696
|
-
|
|
720
|
+
// not_eligible / already_leased / already_marked / already_open / already_released /
|
|
721
|
+
// gate_choice_conflict / choice_not_eligible / gate_open_wait are all consumed elsewhere
|
|
722
|
+
// (lease_finalizer/mark_finalizer's own drain loop; open_gate's own NOOP arms at the 1a call
|
|
723
|
+
// site; 1b's own errorEnvelope; 1d/1e's own site-handling; the guard chain) — settle_step and
|
|
724
|
+
// open_gate never return them here (design record §7).
|
|
725
|
+
throw new Error(`buildSettlementRefusalEnvelope: unreachable '${kind}' refusal reason '${result.reason}'`);
|
|
697
726
|
}
|
|
698
727
|
}
|
|
699
728
|
/**
|
|
@@ -742,6 +771,72 @@ async function buildAlreadySettledEnvelope(store, definition, options, result, t
|
|
|
742
771
|
*/
|
|
743
772
|
const DORMANCY_ADVISORY = 'settled via the legacy compatibility path — this store does not declare atomic settlement ' +
|
|
744
773
|
'(RunStore.settleStep); upgrade the store to close the fan-out seal race (issue #279)';
|
|
774
|
+
/**
|
|
775
|
+
* issue #291 (D1 "execute_step pre-refusal" enactment point — enact-then-proceed): if `run`
|
|
776
|
+
* carries an expired, enactable gate (`expires_at` past, `on_expiry` frozen), enacts it via the
|
|
777
|
+
* SAME dormancy-discriminated pattern `submitHumanResponse` uses (settleStep when declared, else
|
|
778
|
+
* the pure `applySettlement` transform + `store.update`'s CAS write) and returns the resulting
|
|
779
|
+
* (possibly unchanged) run plus any disclosure line for the caller's `warnings` array. A
|
|
780
|
+
* finding-only gate (no `on_expiry`) is a fast no-op — nothing to enact, never touched. Any
|
|
781
|
+
* refusal from the enactment attempt (a benign race: `already_settled`/`not_expired`/
|
|
782
|
+
* `gate_mismatch`/`run_terminal`) is absorbed silently — the caller's own subsequent eligibility
|
|
783
|
+
* re-check against the returned `run` is what actually matters, and a NOOP correctly leaves `run`
|
|
784
|
+
* as the fresh state the refusal matched against. [F4] advisory-not-crash: an external store's
|
|
785
|
+
* OWN `settleStep` throwing on the `expire_gate` kind (a pre-#291 re-implementing store
|
|
786
|
+
* honoring the union-openness contract's refuse-loud mandate) degrades to a console advisory and
|
|
787
|
+
* proceeds with `run` UNCHANGED — never crashes the caller's verb.
|
|
788
|
+
*/
|
|
789
|
+
async function enactExpiredGateIfDue(store, definition, run, registry, now) {
|
|
790
|
+
const gate = run.pending_gate;
|
|
791
|
+
if (gate === undefined ||
|
|
792
|
+
gate.expires_at === undefined ||
|
|
793
|
+
gate.on_expiry === undefined ||
|
|
794
|
+
now.getTime() < new Date(gate.expires_at).getTime()) {
|
|
795
|
+
return { run };
|
|
796
|
+
}
|
|
797
|
+
const delta = { kind: 'expire_gate', gateId: gate.gate_id };
|
|
798
|
+
let expireOutcome;
|
|
799
|
+
try {
|
|
800
|
+
if (store.settleStep !== undefined) {
|
|
801
|
+
expireOutcome = await store.settleStep(run.id, delta, definition, { now });
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
const pure = applySettlement(run, delta, definition, { now });
|
|
805
|
+
if (!pure.applied) {
|
|
806
|
+
return { run: pure.run };
|
|
807
|
+
}
|
|
808
|
+
const persisted = await store.update(pure.run);
|
|
809
|
+
expireOutcome = { ...pure, run: persisted };
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
catch (err) {
|
|
813
|
+
console.warn(`⚠ realm: could not enact run '${run.id}''s expired gate '${gate.gate_id}' (${err instanceof Error ? err.message : String(err)}) — proceeding with the pre-enactment state.`);
|
|
814
|
+
return { run };
|
|
815
|
+
}
|
|
816
|
+
if (!expireOutcome.applied) {
|
|
817
|
+
return { run: expireOutcome.run };
|
|
818
|
+
}
|
|
819
|
+
let finalRun = expireOutcome.run;
|
|
820
|
+
const disclosureParts = [];
|
|
821
|
+
const disposition = finalRun.settled?.[gate.step_name]?.resolved_by === 'timeout' ? 'settle_default' : 'abort';
|
|
822
|
+
disclosureParts.push(`gate '${gate.gate_id}' on '${gate.step_name}' had expired — enacted declared ${disposition} before this execute_step call (enacted_via: execute_step).`);
|
|
823
|
+
if (expireOutcome.transitioned) {
|
|
824
|
+
try {
|
|
825
|
+
const drainOutcome = await drainFinalizers(store, definition, registry, run.id);
|
|
826
|
+
finalRun = drainOutcome.run;
|
|
827
|
+
disclosureParts.push(...drainOutcome.warnings);
|
|
828
|
+
}
|
|
829
|
+
catch (err) {
|
|
830
|
+
disclosureParts.push(`post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
const disclosure = disclosureParts.join(' ');
|
|
834
|
+
// Printed unconditionally (never silently dropped, regardless of which downstream envelope
|
|
835
|
+
// path the caller's own request takes) — the caller ALSO threads this into whichever
|
|
836
|
+
// response-envelope warnings array is in scope at its own return point.
|
|
837
|
+
console.warn(`⚠ ${disclosure}`);
|
|
838
|
+
return { run: finalRun, disclosure };
|
|
839
|
+
}
|
|
745
840
|
/**
|
|
746
841
|
* Validates eligibility, claims the step, executes it through the dispatcher with retry
|
|
747
842
|
* and timeout support, captures evidence, persists the updated run record, and returns
|
|
@@ -765,6 +860,18 @@ export async function executeStep(store, definition, options) {
|
|
|
765
860
|
});
|
|
766
861
|
return makeErrorEnvelope(options, null, internal);
|
|
767
862
|
}
|
|
863
|
+
// Step 1.5 (issue #291, D1 "execute_step pre-refusal" enactment point): if this run's gate has
|
|
864
|
+
// expired with an enactable disposition, enact it BEFORE the eligibility check below — a
|
|
865
|
+
// finding-only or non-expired gate is an immediate no-op (same `run` reference back). Level-
|
|
866
|
+
// triggering: the requested step may become newly eligible right here (settle_default/abort
|
|
867
|
+
// both clear `pending_gate`, un-blocking `findEligibleSteps`'s gate-serialization exclusion).
|
|
868
|
+
const gateExpiryCheckNow = options.now ?? new Date();
|
|
869
|
+
let gateExpiryDisclosure;
|
|
870
|
+
if (run.pending_gate !== undefined) {
|
|
871
|
+
const enacted = await enactExpiredGateIfDue(store, definition, run, options.registry, gateExpiryCheckNow);
|
|
872
|
+
run = enacted.run;
|
|
873
|
+
gateExpiryDisclosure = enacted.disclosure;
|
|
874
|
+
}
|
|
768
875
|
// Step 2: Check eligibility.
|
|
769
876
|
const eligible = findEligibleSteps(definition, run);
|
|
770
877
|
if (!eligible.includes(options.command)) {
|
|
@@ -776,7 +883,7 @@ export async function executeStep(store, definition, options) {
|
|
|
776
883
|
status: 'blocked',
|
|
777
884
|
data: {},
|
|
778
885
|
evidence: [],
|
|
779
|
-
warnings: [],
|
|
886
|
+
warnings: gateExpiryDisclosure !== undefined ? [gateExpiryDisclosure] : [],
|
|
780
887
|
errors: [],
|
|
781
888
|
agent_action: 'resolve_precondition',
|
|
782
889
|
context_hint: `Step '${options.command}' is not eligible in the current run state.`,
|
|
@@ -994,6 +1101,10 @@ export async function executeStep(store, definition, options) {
|
|
|
994
1101
|
// walEntries is declared at this outer scope because it is REASSIGNED to the post-claim read
|
|
995
1102
|
// below and referenced at the captureEvidence call site further down this function.
|
|
996
1103
|
const traceWarnings = [];
|
|
1104
|
+
// issue #291: the Step-1.5 gate-expiry disclosure (if any) now rides every downstream envelope
|
|
1105
|
+
// this function's own `traceWarnings` threading already reaches.
|
|
1106
|
+
if (gateExpiryDisclosure !== undefined)
|
|
1107
|
+
traceWarnings.push(gateExpiryDisclosure);
|
|
997
1108
|
let preNormalizedTrace;
|
|
998
1109
|
let walEntries = [];
|
|
999
1110
|
let preClaimSchemaResult;
|
|
@@ -1175,13 +1286,43 @@ export async function executeStep(store, definition, options) {
|
|
|
1175
1286
|
: [];
|
|
1176
1287
|
}
|
|
1177
1288
|
catch (err) {
|
|
1178
|
-
|
|
1179
|
-
|
|
1289
|
+
// issue #279 (increment 2, PR-D, Deliverable 1e): the migrated path — settles this release
|
|
1290
|
+
// atomically against FRESH state via the store's own settleStep, evidence = the SAME
|
|
1291
|
+
// compensating_unclaim audit line (:679 semantics). LOG-ONLY for ALL results (applied / NOOP
|
|
1292
|
+
// / any refusal / a thrown infra error) — no envelope change on any settle outcome; the
|
|
1293
|
+
// ENGINE_STORE_FAILED envelope below is the disclosure regardless. Dormancy: an undeclaring
|
|
1294
|
+
// store falls through to the byte-identical legacy path (I16/#169 fail-closed dormancy).
|
|
1295
|
+
let unclaimDormancyWarning;
|
|
1296
|
+
if (store.settleStep !== undefined) {
|
|
1297
|
+
const unclaimToken = pendingRun.claims?.[options.command]?.token;
|
|
1298
|
+
const delta = {
|
|
1299
|
+
kind: 'release_step',
|
|
1300
|
+
step: options.command,
|
|
1301
|
+
...(unclaimToken !== undefined ? { claimToken: unclaimToken } : {}),
|
|
1302
|
+
evidence: [buildCompensatingUnclaimEvidence(options.command, new Date())],
|
|
1303
|
+
};
|
|
1304
|
+
try {
|
|
1305
|
+
await store.settleStep(options.runId, delta, definition);
|
|
1306
|
+
}
|
|
1307
|
+
catch {
|
|
1308
|
+
// Log-only — see the comment above; never surfaces as its own error.
|
|
1309
|
+
}
|
|
1180
1310
|
}
|
|
1181
|
-
|
|
1182
|
-
//
|
|
1183
|
-
|
|
1311
|
+
else {
|
|
1312
|
+
// --- Legacy path (dormancy fallback — byte-identical to pre-#279 behavior) ---
|
|
1313
|
+
try {
|
|
1314
|
+
await store.update(buildCompensatingUnclaim(pendingRun, options.command, new Date()));
|
|
1315
|
+
}
|
|
1316
|
+
catch {
|
|
1317
|
+
// CAS mismatch (someone else already resolved the claim) or any other failure to even
|
|
1318
|
+
// un-claim: stop immediately, leave the claim exactly as it is — never retry here.
|
|
1319
|
+
}
|
|
1320
|
+
// issue #279 (increment 2, PR-D): + the ONE dormancy advisory (I16) — this IS the legacy
|
|
1321
|
+
// path (store.settleStep undeclared); the ENGINE_STORE_FAILED envelope below is its only
|
|
1322
|
+
// carrier since this release is log-only.
|
|
1323
|
+
unclaimDormancyWarning = DORMANCY_ADVISORY;
|
|
1184
1324
|
}
|
|
1325
|
+
const unclaimEnvelopeWarnings = mergeWarnings(traceWarnings, unclaimDormancyWarning);
|
|
1185
1326
|
return makeErrorEnvelope(options, pendingRun, new WorkflowError('Failed to read trace buffer after claiming step', {
|
|
1186
1327
|
code: 'ENGINE_STORE_FAILED',
|
|
1187
1328
|
category: 'ENGINE',
|
|
@@ -1191,7 +1332,7 @@ export async function executeStep(store, definition, options) {
|
|
|
1191
1332
|
step_id: options.command,
|
|
1192
1333
|
cause: err instanceof Error ? err.message : String(err),
|
|
1193
1334
|
},
|
|
1194
|
-
}), definition,
|
|
1335
|
+
}), definition, unclaimEnvelopeWarnings.length > 0 ? unclaimEnvelopeWarnings : undefined);
|
|
1195
1336
|
}
|
|
1196
1337
|
// issue #197 PR-2 (design §2): the SAME predicate as the pre-claim pass, now over the
|
|
1197
1338
|
// complete post-claim set. Lifted to `adoptionPartition` (outer scope) — read again at the
|
|
@@ -1666,6 +1807,20 @@ export async function executeStep(store, definition, options) {
|
|
|
1666
1807
|
...(attemptError === null && (run.validation_rejections?.[options.command] ?? 0) > 0
|
|
1667
1808
|
? { validation_rejections: run.validation_rejections[options.command] }
|
|
1668
1809
|
: {}),
|
|
1810
|
+
// issue #236: the attempt's structured_output disclosure. External-agent stamp [Rv6 +
|
|
1811
|
+
// R2-3]: a step that DECLARED structured_output but arrives with no
|
|
1812
|
+
// options.stepMeta.structuredOutput at all was driven by something other than
|
|
1813
|
+
// run-agent (e.g. an external agent calling execute_step over MCP directly) — realm
|
|
1814
|
+
// cannot know whether strict was honored, so it says so rather than staying silent.
|
|
1815
|
+
...(stepDef?.structured_output !== undefined
|
|
1816
|
+
? {
|
|
1817
|
+
structured_output: options.stepMeta?.structuredOutput ?? {
|
|
1818
|
+
requested: true,
|
|
1819
|
+
sent: false,
|
|
1820
|
+
downgrade_reason: 'external_agent',
|
|
1821
|
+
},
|
|
1822
|
+
}
|
|
1823
|
+
: {}),
|
|
1669
1824
|
},
|
|
1670
1825
|
...(profileData !== undefined
|
|
1671
1826
|
? { agentProfile: profile, agentProfileHash: profileData.content_hash }
|
|
@@ -1772,6 +1927,17 @@ export async function executeStep(store, definition, options) {
|
|
|
1772
1927
|
precondition_trace: preconditionTrace,
|
|
1773
1928
|
settled_by_default: true,
|
|
1774
1929
|
validation_rejections: exhaustion.details['rejections'],
|
|
1930
|
+
// issue #236: same disclosure/external-agent-stamp rule as the real dispatch-loop
|
|
1931
|
+
// capture above.
|
|
1932
|
+
...(stepDef?.structured_output !== undefined
|
|
1933
|
+
? {
|
|
1934
|
+
structured_output: options.stepMeta?.structuredOutput ?? {
|
|
1935
|
+
requested: true,
|
|
1936
|
+
sent: false,
|
|
1937
|
+
downgrade_reason: 'external_agent',
|
|
1938
|
+
},
|
|
1939
|
+
}
|
|
1940
|
+
: {}),
|
|
1775
1941
|
},
|
|
1776
1942
|
...(defaultProfileData !== undefined
|
|
1777
1943
|
? { agentProfile: defaultProfile, agentProfileHash: defaultProfileData.content_hash }
|
|
@@ -1829,6 +1995,17 @@ export async function executeStep(store, definition, options) {
|
|
|
1829
1995
|
input_token_estimate: inputTokenEstimate,
|
|
1830
1996
|
precondition_trace: preconditionTrace,
|
|
1831
1997
|
validation_rejections: exhaustion.details['rejections'],
|
|
1998
|
+
// issue #236: same disclosure/external-agent-stamp rule as the real dispatch-loop
|
|
1999
|
+
// capture above.
|
|
2000
|
+
...(stepDef?.structured_output !== undefined
|
|
2001
|
+
? {
|
|
2002
|
+
structured_output: options.stepMeta?.structuredOutput ?? {
|
|
2003
|
+
requested: true,
|
|
2004
|
+
sent: false,
|
|
2005
|
+
downgrade_reason: 'external_agent',
|
|
2006
|
+
},
|
|
2007
|
+
}
|
|
2008
|
+
: {}),
|
|
1832
2009
|
},
|
|
1833
2010
|
...(exhaustedProfileData !== undefined
|
|
1834
2011
|
? { agentProfile: exhaustedProfile, agentProfileHash: exhaustedProfileData.content_hash }
|
|
@@ -1927,13 +2104,61 @@ export async function executeStep(store, definition, options) {
|
|
|
1927
2104
|
// Non-terminal: recompute the phase so the store-fail fallback below is correct too
|
|
1928
2105
|
// (on the happy path store.update recomputes it identically via deriveRunPhase).
|
|
1929
2106
|
const blockedRun = { ...blockedDraft, run_phase: deriveRunPhase(blockedDraft) };
|
|
2107
|
+
// issue #279 (increment 2, PR-D, Deliverable 1d): the migrated path — settles this release
|
|
2108
|
+
// atomically against FRESH state via the store's own settleStep. This site NEVER calls the
|
|
2109
|
+
// shared buildSettlementRefusalEnvelope: regardless of write outcome (applied / NOOP
|
|
2110
|
+
// already_released / any OTHER refusal / a thrown infra error), the RETURNED envelope is
|
|
2111
|
+
// ALWAYS this SAME capability-block report — only whether the internal capability_blocks
|
|
2112
|
+
// marker got durably persisted varies, disclosed via blockStoreWarning. Dormancy: an
|
|
2113
|
+
// undeclaring store falls through to the byte-identical legacy path below (I16/#169
|
|
2114
|
+
// fail-closed dormancy).
|
|
1930
2115
|
let persistedBlockedRun;
|
|
1931
2116
|
let blockStoreWarning;
|
|
1932
|
-
|
|
1933
|
-
|
|
2117
|
+
let dormancyWarning;
|
|
2118
|
+
if (store.settleStep !== undefined) {
|
|
2119
|
+
const releaseClaimToken = pendingRun.claims?.[options.command]?.token;
|
|
2120
|
+
const delta = {
|
|
2121
|
+
kind: 'release_step',
|
|
2122
|
+
step: options.command,
|
|
2123
|
+
...(releaseClaimToken !== undefined ? { claimToken: releaseClaimToken } : {}),
|
|
2124
|
+
capabilityBlock: {
|
|
2125
|
+
requirement: requirement !== undefined
|
|
2126
|
+
? { kind: requirement.kind, name: requirement.name }
|
|
2127
|
+
: {
|
|
2128
|
+
kind: recoverableCode === 'ENGINE_HANDLER_NOT_REGISTERED' ? 'handler' : 'adapter',
|
|
2129
|
+
name: 'unknown',
|
|
2130
|
+
},
|
|
2131
|
+
code: recoverableCode,
|
|
2132
|
+
},
|
|
2133
|
+
// The current :2490 append — legacy parity; the compensating un-claim's own :679
|
|
2134
|
+
// audit-line channel belongs to Deliverable 1e ONLY.
|
|
2135
|
+
evidence: allEvidence,
|
|
2136
|
+
};
|
|
2137
|
+
try {
|
|
2138
|
+
const releaseResult = await store.settleStep(options.runId, delta, definition);
|
|
2139
|
+
persistedBlockedRun = releaseResult.run;
|
|
2140
|
+
// applied / NOOP already_released ⇒ the block envelope exactly as today (NOOP merges
|
|
2141
|
+
// silently — no extra warning). ANY OTHER refusal ⇒ the same block envelope + a typed
|
|
2142
|
+
// warning (never STATE_CLAIM_LOST framing) — the claim survives for reclaim either way.
|
|
2143
|
+
if (!releaseResult.applied && releaseResult.reason !== 'already_released') {
|
|
2144
|
+
blockStoreWarning = `capability block not persisted: ${releaseResult.reason}`;
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
catch (storeErr) {
|
|
2148
|
+
blockStoreWarning = `Failed to persist capability block: ${storeErr instanceof Error ? storeErr.message : String(storeErr)}`;
|
|
2149
|
+
}
|
|
1934
2150
|
}
|
|
1935
|
-
|
|
1936
|
-
|
|
2151
|
+
else {
|
|
2152
|
+
// --- Legacy path (dormancy fallback — byte-identical to pre-#279 behavior) ---
|
|
2153
|
+
try {
|
|
2154
|
+
persistedBlockedRun = await store.update(blockedRun);
|
|
2155
|
+
}
|
|
2156
|
+
catch (storeErr) {
|
|
2157
|
+
blockStoreWarning = `Failed to persist capability block: ${storeErr instanceof Error ? storeErr.message : String(storeErr)}`;
|
|
2158
|
+
}
|
|
2159
|
+
// issue #279 (increment 2, PR-D): + the ONE dormancy advisory (I16) — this IS the legacy
|
|
2160
|
+
// path (store.settleStep undeclared).
|
|
2161
|
+
dormancyWarning = DORMANCY_ADVISORY;
|
|
1937
2162
|
}
|
|
1938
2163
|
// issue #207 PR-2 (D3 §5): NO WAL delete belongs on this capability-block settle path — the
|
|
1939
2164
|
// prior try/catch here was removed, not just gated. Contract-consistency hygiene, not a
|
|
@@ -1968,7 +2193,7 @@ export async function executeStep(store, definition, options) {
|
|
|
1968
2193
|
status: 'error',
|
|
1969
2194
|
data: {},
|
|
1970
2195
|
evidence: allEvidence,
|
|
1971
|
-
warnings: mergeWarnings(traceWarnings, blockStoreWarning),
|
|
2196
|
+
warnings: mergeWarnings(traceWarnings, blockStoreWarning, dormancyWarning),
|
|
1972
2197
|
errors: [dispatchError.message],
|
|
1973
2198
|
agent_action: blockedAction,
|
|
1974
2199
|
error_code: recoverableCode,
|
|
@@ -2322,37 +2547,49 @@ export async function executeStep(store, definition, options) {
|
|
|
2322
2547
|
resolvedGateMessage = raw;
|
|
2323
2548
|
}
|
|
2324
2549
|
const gateConfig = stepDef.gate;
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2550
|
+
const openedAt = new Date();
|
|
2551
|
+
// issue #291 (mint-time freeze, [F2]): the gate's OWN enforce/notify clock fields, frozen
|
|
2552
|
+
// into the record HERE — never re-read from the definition by any later enactment/
|
|
2553
|
+
// notification/read-side surface (the definition-drift-livelock cure). `expires_at` derives
|
|
2554
|
+
// from THIS `openedAt` instant, never a second `new Date()` call. `reminder_max` defaults to
|
|
2555
|
+
// 3 at mint when `reminder_seconds` is declared and the author gave no explicit value — so
|
|
2556
|
+
// every later reader can treat the frozen field as authoritative without re-applying a
|
|
2557
|
+
// default itself.
|
|
2558
|
+
const expiresAt = gateConfig?.timeout_seconds !== undefined
|
|
2559
|
+
? new Date(openedAt.getTime() + gateConfig.timeout_seconds * 1000).toISOString()
|
|
2560
|
+
: undefined;
|
|
2561
|
+
// The PendingGate object is built EXACTLY as before, regardless of which path commits it below
|
|
2562
|
+
// (issue #279, increment 2, PR-D, Deliverable 1a — the migrated `open_gate` delta carries this
|
|
2563
|
+
// SAME object verbatim; the legacy fallback writes it via `store.update` unchanged).
|
|
2564
|
+
const pendingGate = {
|
|
2565
|
+
gate_id,
|
|
2566
|
+
step_name,
|
|
2567
|
+
preview: output,
|
|
2568
|
+
choices,
|
|
2569
|
+
opened_at: openedAt.toISOString(),
|
|
2570
|
+
...(gateConfig?.owner !== undefined ? { owner: gateConfig.owner } : {}),
|
|
2571
|
+
...(resolvedGateMessage !== undefined ? { resolved_message: resolvedGateMessage } : {}),
|
|
2572
|
+
...(gateConfig?.resolution_messages !== undefined
|
|
2573
|
+
? { resolution_messages: gateConfig.resolution_messages }
|
|
2574
|
+
: {}),
|
|
2575
|
+
// issue #291: the mint-frozen enforce clock.
|
|
2576
|
+
...(expiresAt !== undefined ? { expires_at: expiresAt } : {}),
|
|
2577
|
+
...(gateConfig?.on_expiry !== undefined ? { on_expiry: gateConfig.on_expiry } : {}),
|
|
2578
|
+
...(gateConfig?.default_choice !== undefined
|
|
2579
|
+
? { default_choice: gateConfig.default_choice }
|
|
2580
|
+
: {}),
|
|
2581
|
+
// issue #291: the mint-frozen notify clock (standalone-legal — independent of expiresAt).
|
|
2582
|
+
...(gateConfig?.reminder_seconds !== undefined
|
|
2583
|
+
? {
|
|
2584
|
+
reminder_seconds: gateConfig.reminder_seconds,
|
|
2585
|
+
reminder_max: gateConfig.reminder_max ?? 3,
|
|
2586
|
+
}
|
|
2587
|
+
: {}),
|
|
2588
|
+
};
|
|
2589
|
+
// issue #291 ([F-A2-6]): computed ONCE for the whole gate-open envelope (migrated + legacy
|
|
2590
|
+
// both read it) — the absolute first-due notify-clock timestamp, when reminder_seconds was
|
|
2591
|
+
// declared.
|
|
2592
|
+
const gateOpenDueState = computeGateDueState(pendingGate, openedAt);
|
|
2356
2593
|
// gate.display fallback chain: gate.message resolved → step.prompt resolved → absent
|
|
2357
2594
|
const resolvedGateDisplay = resolvedGateMessage !== undefined
|
|
2358
2595
|
? resolvedGateMessage
|
|
@@ -2370,19 +2607,164 @@ export async function executeStep(store, definition, options) {
|
|
|
2370
2607
|
...wfCtxSpreadEarly,
|
|
2371
2608
|
})
|
|
2372
2609
|
: undefined;
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2610
|
+
function buildGateNextAction(id, gateChoices, forStep) {
|
|
2611
|
+
return {
|
|
2612
|
+
instruction: {
|
|
2613
|
+
tool: 'submit_human_response',
|
|
2614
|
+
params: { run_id: options.runId, gate_id: id },
|
|
2615
|
+
call_with: {
|
|
2616
|
+
run_id: options.runId,
|
|
2617
|
+
gate_id: id,
|
|
2618
|
+
choice: `<${gateChoices.join('|')}>`,
|
|
2619
|
+
},
|
|
2620
|
+
},
|
|
2621
|
+
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.`,
|
|
2622
|
+
orientation: `Run is paused at gate '${id}'. Available choices: ${gateChoices.join(', ')}.`,
|
|
2623
|
+
};
|
|
2624
|
+
}
|
|
2625
|
+
// issue #279 (increment 2, PR-D, Deliverable 1a): the migrated path — opens this gate
|
|
2626
|
+
// atomically against FRESH state via the store's own settleStep. Dormancy: an undeclaring
|
|
2627
|
+
// store falls through to the byte-identical legacy path below (I16/#169 fail-closed dormancy).
|
|
2628
|
+
if (store.settleStep !== undefined) {
|
|
2629
|
+
const openClaimToken = pendingRun.claims?.[options.command]?.token;
|
|
2630
|
+
const delta = {
|
|
2631
|
+
kind: 'open_gate',
|
|
2632
|
+
step: options.command,
|
|
2633
|
+
...(openClaimToken !== undefined ? { claimToken: openClaimToken } : {}),
|
|
2634
|
+
pendingGate,
|
|
2635
|
+
evidence: allEvidence,
|
|
2636
|
+
};
|
|
2637
|
+
let result;
|
|
2638
|
+
try {
|
|
2639
|
+
result = await store.settleStep(options.runId, delta, definition);
|
|
2640
|
+
}
|
|
2641
|
+
catch (err) {
|
|
2642
|
+
// THROWN infra errors — the same catch shape replicated at every migrated site.
|
|
2643
|
+
if (err instanceof WorkflowError) {
|
|
2644
|
+
return makeErrorEnvelope(options, pendingRun, err, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
|
|
2645
|
+
}
|
|
2646
|
+
const internal = new WorkflowError('Failed to open gate', {
|
|
2647
|
+
code: 'ENGINE_STORE_FAILED',
|
|
2648
|
+
category: 'ENGINE',
|
|
2649
|
+
agentAction: 'stop',
|
|
2650
|
+
retryable: false,
|
|
2651
|
+
});
|
|
2652
|
+
return makeErrorEnvelope(options, pendingRun, internal, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
|
|
2653
|
+
}
|
|
2654
|
+
if (!result.applied) {
|
|
2655
|
+
if (result.reason === 'already_settled') {
|
|
2656
|
+
// Ok-shaped NOOP — buildAlreadySettledEnvelope's SHAPE but WITHOUT its drain clause:
|
|
2657
|
+
// gate-open NEVER drains (design record §6 row 1 — the crashed-drain recovery paths are
|
|
2658
|
+
// the resolution site's own NOOP drain and the drain verb).
|
|
2659
|
+
const noopRun = result.run;
|
|
2660
|
+
const noopNextActions = noopRun.terminal_state
|
|
2661
|
+
? []
|
|
2662
|
+
: buildNextActions(definition, noopRun);
|
|
2663
|
+
return {
|
|
2664
|
+
command: options.command,
|
|
2665
|
+
run_id: options.runId,
|
|
2666
|
+
run_version: noopRun.version,
|
|
2667
|
+
status: 'ok',
|
|
2668
|
+
data: {},
|
|
2669
|
+
evidence: [],
|
|
2670
|
+
warnings: [...traceWarnings],
|
|
2671
|
+
errors: [],
|
|
2672
|
+
context_hint: `Step '${options.command}' was already settled (a duplicate/retried attempt) — no action was taken.`,
|
|
2673
|
+
run_phase: noopRun.run_phase,
|
|
2674
|
+
next_actions: noopNextActions,
|
|
2675
|
+
};
|
|
2676
|
+
}
|
|
2677
|
+
if (result.reason === 'already_open') {
|
|
2678
|
+
// D-1: the LIVE gate wins — rendered VERBATIM (this delta's own rebuilt gate is
|
|
2679
|
+
// discarded). Calm, confirm_required (the gate genuinely IS still open) — never
|
|
2680
|
+
// report_to_user (no `agent_action` set, matching the fresh-open confirm_required shape).
|
|
2681
|
+
const liveGate = result.gate;
|
|
2682
|
+
const liveGateDueState = computeGateDueState(liveGate, new Date());
|
|
2683
|
+
return {
|
|
2684
|
+
command: options.command,
|
|
2685
|
+
run_id: options.runId,
|
|
2686
|
+
run_version: result.run.version,
|
|
2687
|
+
status: 'confirm_required',
|
|
2688
|
+
data: liveGate.preview,
|
|
2689
|
+
evidence: [],
|
|
2690
|
+
warnings: [...traceWarnings],
|
|
2691
|
+
errors: [],
|
|
2692
|
+
context_hint: `Run is already paused at gate '${liveGate.gate_id}'. Available choices: ${liveGate.choices.join(', ')}.`,
|
|
2693
|
+
run_phase: result.run.run_phase,
|
|
2694
|
+
next_actions: [
|
|
2695
|
+
buildGateNextAction(liveGate.gate_id, liveGate.choices, liveGate.step_name),
|
|
2696
|
+
],
|
|
2697
|
+
gate: {
|
|
2698
|
+
gate_id: liveGate.gate_id,
|
|
2699
|
+
step_name: liveGate.step_name,
|
|
2700
|
+
preview: liveGate.preview,
|
|
2701
|
+
choices: liveGate.choices,
|
|
2702
|
+
...(liveGate.resolved_message !== undefined
|
|
2703
|
+
? { display: liveGate.resolved_message }
|
|
2704
|
+
: {}),
|
|
2705
|
+
response_spec: { choices: liveGate.choices },
|
|
2706
|
+
...(liveGate.expires_at !== undefined ? { expires_at: liveGate.expires_at } : {}),
|
|
2707
|
+
...(liveGateDueState.next_reminder_due_at !== undefined
|
|
2708
|
+
? { first_reminder_due_at: liveGateDueState.next_reminder_due_at }
|
|
2709
|
+
: {}),
|
|
2710
|
+
},
|
|
2711
|
+
};
|
|
2712
|
+
}
|
|
2713
|
+
return buildSettlementRefusalEnvelope(options, definition, result, allEvidence, traceWarnings, 'open_gate');
|
|
2714
|
+
}
|
|
2715
|
+
// applied: true — never terminalizes (design record §4.1); build confirm_required off
|
|
2716
|
+
// result.run.
|
|
2717
|
+
const gateRun = result.run;
|
|
2718
|
+
return {
|
|
2719
|
+
command: options.command,
|
|
2720
|
+
run_id: options.runId,
|
|
2721
|
+
run_version: gateRun.version,
|
|
2722
|
+
status: 'confirm_required',
|
|
2723
|
+
data: output,
|
|
2724
|
+
evidence: allEvidence,
|
|
2725
|
+
warnings: [...traceWarnings],
|
|
2726
|
+
errors: [],
|
|
2727
|
+
context_hint: `Run is paused at gate '${gate_id}'. Available choices: ${choices.join(', ')}.`,
|
|
2728
|
+
run_phase: gateRun.run_phase,
|
|
2729
|
+
next_actions: [buildGateNextAction(gate_id, choices, step_name)],
|
|
2730
|
+
gate: {
|
|
2379
2731
|
gate_id,
|
|
2380
|
-
|
|
2732
|
+
step_name,
|
|
2733
|
+
preview: output,
|
|
2734
|
+
choices,
|
|
2735
|
+
...(resolvedGateDisplay !== undefined ? { display: resolvedGateDisplay } : {}),
|
|
2736
|
+
...(resolvedGateInstructions !== undefined
|
|
2737
|
+
? { agent_hint: resolvedGateInstructions }
|
|
2738
|
+
: {}),
|
|
2739
|
+
response_spec: { choices },
|
|
2740
|
+
...(pendingGate.expires_at !== undefined ? { expires_at: pendingGate.expires_at } : {}),
|
|
2741
|
+
...(gateOpenDueState.next_reminder_due_at !== undefined
|
|
2742
|
+
? { first_reminder_due_at: gateOpenDueState.next_reminder_due_at }
|
|
2743
|
+
: {}),
|
|
2381
2744
|
},
|
|
2382
|
-
}
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2745
|
+
};
|
|
2746
|
+
}
|
|
2747
|
+
// --- Legacy path (dormancy fallback — byte-identical to pre-#279 behavior) ---
|
|
2748
|
+
let gateRun;
|
|
2749
|
+
try {
|
|
2750
|
+
gateRun = await store.update({
|
|
2751
|
+
...pendingRun,
|
|
2752
|
+
// Step stays in in_progress_steps while gate is open — moved to completed on submit.
|
|
2753
|
+
evidence: [...pendingRun.evidence, ...allEvidence],
|
|
2754
|
+
pending_gate: pendingGate,
|
|
2755
|
+
});
|
|
2756
|
+
}
|
|
2757
|
+
catch (err) {
|
|
2758
|
+
if (err instanceof WorkflowError) {
|
|
2759
|
+
return makeErrorEnvelope(options, pendingRun, err, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
|
|
2760
|
+
}
|
|
2761
|
+
return makeErrorEnvelope(options, pendingRun, new WorkflowError('Failed to open gate', {
|
|
2762
|
+
code: 'ENGINE_STORE_FAILED',
|
|
2763
|
+
category: 'ENGINE',
|
|
2764
|
+
agentAction: 'stop',
|
|
2765
|
+
retryable: false,
|
|
2766
|
+
}), definition, traceWarnings.length > 0 ? traceWarnings : undefined);
|
|
2767
|
+
}
|
|
2386
2768
|
return {
|
|
2387
2769
|
command: options.command,
|
|
2388
2770
|
run_id: options.runId,
|
|
@@ -2390,11 +2772,11 @@ export async function executeStep(store, definition, options) {
|
|
|
2390
2772
|
status: 'confirm_required',
|
|
2391
2773
|
data: output,
|
|
2392
2774
|
evidence: allEvidence,
|
|
2393
|
-
warnings:
|
|
2775
|
+
warnings: mergeWarnings(traceWarnings, DORMANCY_ADVISORY),
|
|
2394
2776
|
errors: [],
|
|
2395
2777
|
context_hint: `Run is paused at gate '${gate_id}'. Available choices: ${choices.join(', ')}.`,
|
|
2396
2778
|
run_phase: gateRun.run_phase,
|
|
2397
|
-
next_actions: [
|
|
2779
|
+
next_actions: [buildGateNextAction(gate_id, choices, step_name)],
|
|
2398
2780
|
gate: {
|
|
2399
2781
|
gate_id,
|
|
2400
2782
|
step_name,
|
|
@@ -2403,6 +2785,10 @@ export async function executeStep(store, definition, options) {
|
|
|
2403
2785
|
...(resolvedGateDisplay !== undefined ? { display: resolvedGateDisplay } : {}),
|
|
2404
2786
|
...(resolvedGateInstructions !== undefined ? { agent_hint: resolvedGateInstructions } : {}),
|
|
2405
2787
|
response_spec: { choices },
|
|
2788
|
+
...(pendingGate.expires_at !== undefined ? { expires_at: pendingGate.expires_at } : {}),
|
|
2789
|
+
...(gateOpenDueState.next_reminder_due_at !== undefined
|
|
2790
|
+
? { first_reminder_due_at: gateOpenDueState.next_reminder_due_at }
|
|
2791
|
+
: {}),
|
|
2406
2792
|
},
|
|
2407
2793
|
};
|
|
2408
2794
|
}
|
|
@@ -2687,6 +3073,145 @@ export async function executeStep(store, definition, options) {
|
|
|
2687
3073
|
* Submits a human response for a gate-waiting run.
|
|
2688
3074
|
* Validates the gate_id and choice, then moves the step to completed_steps.
|
|
2689
3075
|
*/
|
|
3076
|
+
/** Finds the settled step name for a resolved gate matching `gateId` (issue #279, increment 2,
|
|
3077
|
+
* PR-D) — a LOCAL mirror of settlement.ts's own `findSettledGateEntry` (not imported: this file
|
|
3078
|
+
* touches settlement.ts ONLY for Deliverable 3's cancel-trail `gate_id` addition). Used to recover
|
|
3079
|
+
* a reliable step name off `result.run.settled` for the `already_settled`/`gate_choice_conflict`
|
|
3080
|
+
* envelopes, since the caller's own pre-read may already be stale by the time either of those
|
|
3081
|
+
* fires (the gate could have resolved before this call's own Step-1 read). */
|
|
3082
|
+
function findGateStepName(run, gateId) {
|
|
3083
|
+
for (const [step, entry] of Object.entries(run.settled ?? {})) {
|
|
3084
|
+
if (entry.outcome === 'gate' && entry.token === gateId)
|
|
3085
|
+
return step;
|
|
3086
|
+
}
|
|
3087
|
+
return undefined;
|
|
3088
|
+
}
|
|
3089
|
+
/** Builds the gate_response evidence snapshot (issue #279, increment 2, PR-D, Deliverable 1b) —
|
|
3090
|
+
* the SAME shape submitHumanResponse's legacy path has always built (mirrors execution-loop.ts's
|
|
3091
|
+
* own pre-PR-D Step 5), extracted so both the migrated and legacy paths construct it identically.
|
|
3092
|
+
* `respondedBy`, when supplied, populates the snapshot's own `responded_by` field (design record
|
|
3093
|
+
* D-5) in addition to the delta's own field. */
|
|
3094
|
+
function buildGateResponseSnapshot(gate, choice, respondedAt, respondedBy) {
|
|
3095
|
+
const gateEvidence = captureEvidence({
|
|
3096
|
+
stepId: gate.step_name,
|
|
3097
|
+
startedAt: new Date(gate.opened_at),
|
|
3098
|
+
completedAt: respondedAt,
|
|
3099
|
+
input: { choice },
|
|
3100
|
+
output: { ...gate.preview, choice },
|
|
3101
|
+
});
|
|
3102
|
+
return {
|
|
3103
|
+
...gateEvidence,
|
|
3104
|
+
kind: 'gate_response',
|
|
3105
|
+
...(gate.resolved_message !== undefined ? { gate_message: gate.resolved_message } : {}),
|
|
3106
|
+
...(respondedBy !== undefined ? { responded_by: respondedBy } : {}),
|
|
3107
|
+
};
|
|
3108
|
+
}
|
|
3109
|
+
/** Renders a millisecond duration as a compact human-readable string ("3m", "2h 15m", "1d 4h") —
|
|
3110
|
+
* issue #291 [F8] overdue-delta disclosure. Local to this file (core has no CLI dependency);
|
|
3111
|
+
* mirrors the CLI's own `formatGateAge` shape but is independently maintained — no cross-package
|
|
3112
|
+
* import for a two-branch formatter. */
|
|
3113
|
+
function formatOverdueDuration(ms) {
|
|
3114
|
+
const totalMinutes = Math.floor(ms / 60_000);
|
|
3115
|
+
const totalHours = Math.floor(totalMinutes / 60);
|
|
3116
|
+
const totalDays = Math.floor(totalHours / 24);
|
|
3117
|
+
if (totalMinutes < 60)
|
|
3118
|
+
return `${totalMinutes}m`;
|
|
3119
|
+
if (totalHours < 24)
|
|
3120
|
+
return `${totalHours}h ${totalMinutes % 60}m`;
|
|
3121
|
+
return `${totalDays}d ${totalHours % 24}h`;
|
|
3122
|
+
}
|
|
3123
|
+
/**
|
|
3124
|
+
* issue #291 ([F3] shape c / [F8] / [F12]): composes the honest envelope for a late gate response
|
|
3125
|
+
* that lost the race to the enforce clock — called AFTER an `expire_gate` settleStep attempt,
|
|
3126
|
+
* regardless of whether THIS call enacted it (`applied: true`) or a racing enactment point already
|
|
3127
|
+
* had (`applied: false, reason: 'already_settled'` — F1's arms make both paths land on the SAME
|
|
3128
|
+
* committed disposition, read from `finalRun`). `finalRun` must be the state that actually reflects
|
|
3129
|
+
* the enactment (either `expireResult.run` directly). Drains post-commit finalizers when the
|
|
3130
|
+
* enactment itself transitioned the run (F7 — submit's existing transitioned-drain plumbing,
|
|
3131
|
+
* extended to the expire result); a store lacking `settleStep`'s companion `drainFinalizers`
|
|
3132
|
+
* capability is not a concern here — `drainFinalizers` works off any `RunStore`.
|
|
3133
|
+
*/
|
|
3134
|
+
async function composeExpiredGateEnvelope(store, definition, registry, originalGateId, originalChoice, overdueMs, expireResult) {
|
|
3135
|
+
let finalRun = expireResult.run;
|
|
3136
|
+
let drainWarnings = [];
|
|
3137
|
+
if (expireResult.applied && expireResult.transitioned) {
|
|
3138
|
+
try {
|
|
3139
|
+
const drainOutcome = await drainFinalizers(store, definition, registry, finalRun.id);
|
|
3140
|
+
finalRun = drainOutcome.run;
|
|
3141
|
+
drainWarnings = drainOutcome.warnings;
|
|
3142
|
+
}
|
|
3143
|
+
catch (err) {
|
|
3144
|
+
drainWarnings = [
|
|
3145
|
+
`post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
3146
|
+
];
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
const overdueLabel = formatOverdueDuration(Math.max(0, overdueMs));
|
|
3150
|
+
// settle_default disposition: a 'gate' settled entry bearing this gateId, resolved_by:'timeout'.
|
|
3151
|
+
const settledEntry = Object.entries(finalRun.settled ?? {}).find(([, e]) => e.outcome === 'gate' && e.token === originalGateId && e.resolved_by === 'timeout');
|
|
3152
|
+
if (settledEntry !== undefined) {
|
|
3153
|
+
const [stepName, entry] = settledEntry;
|
|
3154
|
+
const enactedDisclosure = `gate '${originalGateId}' expired ${overdueLabel} ago and was enacted (settle_default: '${entry.choice}') before this response arrived — enacted_via: submit.`;
|
|
3155
|
+
if (entry.choice === originalChoice) {
|
|
3156
|
+
// [F12]'s own pinned string — same choice, still honestly not "your" recorded response.
|
|
3157
|
+
return {
|
|
3158
|
+
command: stepName,
|
|
3159
|
+
run_id: finalRun.id,
|
|
3160
|
+
run_version: finalRun.version,
|
|
3161
|
+
status: 'ok',
|
|
3162
|
+
data: {},
|
|
3163
|
+
evidence: [],
|
|
3164
|
+
warnings: mergeWarnings([], enactedDisclosure, ...drainWarnings),
|
|
3165
|
+
errors: [],
|
|
3166
|
+
context_hint: 'the outcome matches your choice, but it was settled by timeout; your response was not recorded.',
|
|
3167
|
+
run_phase: finalRun.run_phase,
|
|
3168
|
+
next_actions: finalRun.terminal_state ? [] : buildNextActions(definition, finalRun),
|
|
3169
|
+
};
|
|
3170
|
+
}
|
|
3171
|
+
const err = new WorkflowError(`Gate '${originalGateId}' was settled by timeout with choice '${entry.choice}' — your choice '${originalChoice}' was not recorded.`, {
|
|
3172
|
+
code: 'STATE_BLOCKED',
|
|
3173
|
+
category: 'STATE',
|
|
3174
|
+
agentAction: 'report_to_user',
|
|
3175
|
+
retryable: false,
|
|
3176
|
+
details: {
|
|
3177
|
+
runId: finalRun.id,
|
|
3178
|
+
gateId: originalGateId,
|
|
3179
|
+
winning_choice: entry.choice,
|
|
3180
|
+
resolved_by: 'timeout',
|
|
3181
|
+
},
|
|
3182
|
+
});
|
|
3183
|
+
const envelope = errorEnvelope(stepName, finalRun.id, finalRun.version, err, err.message, finalRun.run_phase);
|
|
3184
|
+
return { ...envelope, warnings: mergeWarnings([], enactedDisclosure, ...drainWarnings) };
|
|
3185
|
+
}
|
|
3186
|
+
// abort disposition: a skip_details entry kind 'gate_expired' bearing this gateId.
|
|
3187
|
+
const abortEntry = Object.entries(finalRun.skip_details ?? {}).find(([, d]) => d.kind === 'gate_expired' && d.gate_id === originalGateId);
|
|
3188
|
+
if (abortEntry !== undefined) {
|
|
3189
|
+
const [stepName] = abortEntry;
|
|
3190
|
+
const enactedDisclosure = `gate '${originalGateId}' expired ${overdueLabel} ago and was enacted (abort) before this response arrived — enacted_via: submit.`;
|
|
3191
|
+
const err = new WorkflowError(`Gate '${originalGateId}' on '${stepName}' expired and the run aborted per the ` +
|
|
3192
|
+
`workflow's declared on_expiry — your choice was NOT recorded.`, {
|
|
3193
|
+
code: 'STATE_RUN_TERMINAL',
|
|
3194
|
+
category: 'STATE',
|
|
3195
|
+
agentAction: 'report_to_user',
|
|
3196
|
+
retryable: false,
|
|
3197
|
+
details: { runId: finalRun.id, gateId: originalGateId, step_name: stepName },
|
|
3198
|
+
});
|
|
3199
|
+
const envelope = errorEnvelope(stepName, finalRun.id, finalRun.version, err, err.message, finalRun.run_phase);
|
|
3200
|
+
return { ...envelope, warnings: mergeWarnings([], enactedDisclosure, ...drainWarnings) };
|
|
3201
|
+
}
|
|
3202
|
+
// Unreachable in-contract (the expire delta either enacted it here or a racing enactment point
|
|
3203
|
+
// already had — one of the two branches above always matches). Defensive fallback: never throw
|
|
3204
|
+
// out of a response-envelope-returning function; report the ambiguity honestly instead.
|
|
3205
|
+
const err = new WorkflowError(`Gate '${originalGateId}' expired, but its enacted disposition could not be determined from ` +
|
|
3206
|
+
`the resulting record — this indicates a store or engine defect, not a normal refusal.`, {
|
|
3207
|
+
code: 'ENGINE_INTERNAL',
|
|
3208
|
+
category: 'ENGINE',
|
|
3209
|
+
agentAction: 'stop',
|
|
3210
|
+
retryable: false,
|
|
3211
|
+
details: { runId: finalRun.id, gateId: originalGateId },
|
|
3212
|
+
});
|
|
3213
|
+
return errorEnvelope('submit_gate', finalRun.id, finalRun.version, err, err.message, finalRun.run_phase);
|
|
3214
|
+
}
|
|
2690
3215
|
export async function submitHumanResponse(store, definition, options) {
|
|
2691
3216
|
// 1. Load run.
|
|
2692
3217
|
let run;
|
|
@@ -2704,6 +3229,260 @@ export async function submitHumanResponse(store, definition, options) {
|
|
|
2704
3229
|
});
|
|
2705
3230
|
return errorEnvelope('submit_gate', options.runId, 0, e);
|
|
2706
3231
|
}
|
|
3232
|
+
// issue #291: injectable clock, hoisted here so BOTH the migrated and legacy paths below use
|
|
3233
|
+
// the SAME instant for their expiry checks.
|
|
3234
|
+
const now = options.now ?? new Date();
|
|
3235
|
+
// issue #279 (increment 2, PR-D, Deliverable 1b): the migrated path — the four legacy
|
|
3236
|
+
// verify-arms below (1a-4) become settle_gate's OWN predicate arms; this branch skips them
|
|
3237
|
+
// entirely and settles atomically against FRESH state via the store's own settleStep. Dormancy:
|
|
3238
|
+
// an undeclaring store falls through to the byte-identical legacy path below (I16/#169
|
|
3239
|
+
// fail-closed dormancy).
|
|
3240
|
+
if (store.settleStep !== undefined) {
|
|
3241
|
+
const respondedAt = now;
|
|
3242
|
+
// Evidence rule (design record §6 lens-3 S2): built from the PRE-READ `pending_gate` IFF it
|
|
3243
|
+
// matches options.gateId — else `[]` (the arm can never APPLY against a non-matching fresh
|
|
3244
|
+
// read either, so an empty evidence array is inert there; a matching pre-read is guaranteed
|
|
3245
|
+
// fresh enough to be correct on `applied: true`, since gate_id is a per-attempt-minted UUID
|
|
3246
|
+
// that can never "come back" once resolved/absent).
|
|
3247
|
+
const gateResponseEvidence = run.pending_gate !== undefined && run.pending_gate.gate_id === options.gateId
|
|
3248
|
+
? [
|
|
3249
|
+
buildGateResponseSnapshot(run.pending_gate, options.choice, respondedAt, options.respondedBy),
|
|
3250
|
+
]
|
|
3251
|
+
: [];
|
|
3252
|
+
const delta = {
|
|
3253
|
+
kind: 'settle_gate',
|
|
3254
|
+
gateId: options.gateId,
|
|
3255
|
+
choice: options.choice,
|
|
3256
|
+
...(options.respondedBy !== undefined ? { respondedBy: options.respondedBy } : {}),
|
|
3257
|
+
evidence: gateResponseEvidence,
|
|
3258
|
+
};
|
|
3259
|
+
let result;
|
|
3260
|
+
try {
|
|
3261
|
+
// issue #291 ([F3]): the injectable `now` reaches applySettleGate's write-free
|
|
3262
|
+
// gate_expired_pending arm through this SAME options.now plumbing.
|
|
3263
|
+
result = await store.settleStep(options.runId, delta, definition, { now });
|
|
3264
|
+
}
|
|
3265
|
+
catch (err) {
|
|
3266
|
+
// Thrown infra errors keep the SAME shape as the legacy path's own final-persist catch,
|
|
3267
|
+
// below (design record §6: "thrown infra errors ALSO keep the :3563-3581 shape").
|
|
3268
|
+
const e = err instanceof WorkflowError
|
|
3269
|
+
? err
|
|
3270
|
+
: new WorkflowError('Failed to persist gate response', {
|
|
3271
|
+
code: 'ENGINE_STORE_FAILED',
|
|
3272
|
+
category: 'ENGINE',
|
|
3273
|
+
agentAction: 'stop',
|
|
3274
|
+
retryable: false,
|
|
3275
|
+
});
|
|
3276
|
+
return errorEnvelope(run.pending_gate?.step_name ?? 'submit_gate', options.runId, run.version, e, `Failed to persist gate response.`, run.run_phase);
|
|
3277
|
+
}
|
|
3278
|
+
if (!result.applied) {
|
|
3279
|
+
switch (result.reason) {
|
|
3280
|
+
case 'gate_expired_pending': {
|
|
3281
|
+
// issue #291 ([F3] shape c): the live gate has already expired unresolved — issue the
|
|
3282
|
+
// caller-composed expire_gate settleStep ([F1]'s arms make this idempotent even under a
|
|
3283
|
+
// race with another enactment point) and compose the honest late-response envelope from
|
|
3284
|
+
// whatever the enactment result actually committed.
|
|
3285
|
+
const overdueMs = run.pending_gate?.expires_at !== undefined
|
|
3286
|
+
? now.getTime() - new Date(run.pending_gate.expires_at).getTime()
|
|
3287
|
+
: 0;
|
|
3288
|
+
const expireDelta = { kind: 'expire_gate', gateId: options.gateId };
|
|
3289
|
+
let expireResult;
|
|
3290
|
+
try {
|
|
3291
|
+
expireResult = await store.settleStep(options.runId, expireDelta, definition, {
|
|
3292
|
+
now,
|
|
3293
|
+
});
|
|
3294
|
+
}
|
|
3295
|
+
catch (err) {
|
|
3296
|
+
const e = err instanceof WorkflowError
|
|
3297
|
+
? err
|
|
3298
|
+
: new WorkflowError("Failed to enact the gate's expiry", {
|
|
3299
|
+
code: 'ENGINE_STORE_FAILED',
|
|
3300
|
+
category: 'ENGINE',
|
|
3301
|
+
agentAction: 'stop',
|
|
3302
|
+
retryable: false,
|
|
3303
|
+
});
|
|
3304
|
+
return errorEnvelope(run.pending_gate?.step_name ?? 'submit_gate', options.runId, result.run.version, e, "Failed to enact the gate's expiry.", result.run.run_phase);
|
|
3305
|
+
}
|
|
3306
|
+
return composeExpiredGateEnvelope(store, definition, options.registry, options.gateId, options.choice, overdueMs, expireResult);
|
|
3307
|
+
}
|
|
3308
|
+
case 'already_settled': {
|
|
3309
|
+
// Calm ok envelope stating the resolution already committed (same choice) — never
|
|
3310
|
+
// report_to_user. Drain: on already_settled ∧ pending ledger entries non-empty — hand-
|
|
3311
|
+
// rolled here (buildAlreadySettledEnvelope is ExecuteStepOptions-shaped, not reusable at
|
|
3312
|
+
// this call site) — recovers a crashed-drain RESOLVE on a duplicate submit (design record
|
|
3313
|
+
// §6 row 2, the buildAlreadySettledEnvelope drain-on-NOOP pattern reused verbatim).
|
|
3314
|
+
const stepName = findGateStepName(result.run, options.gateId) ?? 'submit_gate';
|
|
3315
|
+
let noopRun = result.run;
|
|
3316
|
+
let noopDrainWarnings = [];
|
|
3317
|
+
const hasPending = Object.values(noopRun.finalizer_ledger ?? {}).some((e) => e.status === 'pending');
|
|
3318
|
+
if (hasPending) {
|
|
3319
|
+
try {
|
|
3320
|
+
const drainOutcome = await drainFinalizers(store, definition, options.registry, options.runId);
|
|
3321
|
+
noopRun = drainOutcome.run;
|
|
3322
|
+
noopDrainWarnings = drainOutcome.warnings;
|
|
3323
|
+
}
|
|
3324
|
+
catch (err) {
|
|
3325
|
+
noopDrainWarnings = [
|
|
3326
|
+
`post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
3327
|
+
];
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
return {
|
|
3331
|
+
command: stepName,
|
|
3332
|
+
run_id: options.runId,
|
|
3333
|
+
run_version: noopRun.version,
|
|
3334
|
+
status: 'ok',
|
|
3335
|
+
data: {},
|
|
3336
|
+
evidence: [],
|
|
3337
|
+
warnings: mergeWarnings([], ...noopDrainWarnings),
|
|
3338
|
+
errors: [],
|
|
3339
|
+
context_hint: `Gate '${options.gateId}' was already resolved with choice '${options.choice}' — no action was taken.`,
|
|
3340
|
+
run_phase: noopRun.run_phase,
|
|
3341
|
+
next_actions: noopRun.terminal_state ? [] : buildNextActions(definition, noopRun),
|
|
3342
|
+
};
|
|
3343
|
+
}
|
|
3344
|
+
case 'gate_choice_conflict': {
|
|
3345
|
+
const stepName = findGateStepName(result.run, options.gateId);
|
|
3346
|
+
const err = new WorkflowError(`Gate '${options.gateId}' was already resolved with choice '${result.winningChoice}' ` +
|
|
3347
|
+
`— your choice '${options.choice}' was not recorded.`, {
|
|
3348
|
+
code: 'STATE_BLOCKED',
|
|
3349
|
+
category: 'STATE',
|
|
3350
|
+
agentAction: 'report_to_user',
|
|
3351
|
+
retryable: false,
|
|
3352
|
+
details: {
|
|
3353
|
+
runId: options.runId,
|
|
3354
|
+
gateId: options.gateId,
|
|
3355
|
+
winning_choice: result.winningChoice,
|
|
3356
|
+
},
|
|
3357
|
+
});
|
|
3358
|
+
return errorEnvelope(stepName ?? 'submit_gate', options.runId, result.run.version, err, err.message, result.run.run_phase);
|
|
3359
|
+
}
|
|
3360
|
+
case 'choice_not_eligible': {
|
|
3361
|
+
// VALIDATION_INPUT_SCHEMA envelope — parity with the legacy path's own step 4 (below).
|
|
3362
|
+
const stepName = result.run.pending_gate.step_name; // the arm only reaches this check
|
|
3363
|
+
// when fresh.pending_gate.gate_id === gateId, so this is reliably the live gate's step.
|
|
3364
|
+
const expected = (result.choices ?? []).join(', ');
|
|
3365
|
+
const err = new WorkflowError(`Choice '${options.choice}' is not valid. Expected one of: ${expected}`, {
|
|
3366
|
+
code: 'VALIDATION_INPUT_SCHEMA',
|
|
3367
|
+
category: 'VALIDATION',
|
|
3368
|
+
agentAction: 'report_to_user',
|
|
3369
|
+
retryable: false,
|
|
3370
|
+
});
|
|
3371
|
+
return errorEnvelope(stepName, options.runId, result.run.version, err, `Invalid choice '${options.choice}' for gate '${stepName}'.`, result.run.run_phase);
|
|
3372
|
+
}
|
|
3373
|
+
case 'gate_mismatch': {
|
|
3374
|
+
const err = new WorkflowError(`Gate '${options.gateId}' is not the open gate and matches no committed resolution.`, {
|
|
3375
|
+
code: 'STATE_BLOCKED',
|
|
3376
|
+
category: 'STATE',
|
|
3377
|
+
agentAction: 'report_to_user',
|
|
3378
|
+
retryable: false,
|
|
3379
|
+
details: { runId: options.runId, gateId: options.gateId },
|
|
3380
|
+
});
|
|
3381
|
+
return errorEnvelope('submit_gate', options.runId, result.run.version, err, err.message, result.run.run_phase);
|
|
3382
|
+
}
|
|
3383
|
+
case 'run_terminal': {
|
|
3384
|
+
// Composed cancelled-predicate (design record §5 D-4/§11 N10): any gate_cancelled_by_abort
|
|
3385
|
+
// skip detail ⇒ the cancelled variant ("your choice was NOT recorded" + cause) — bound by
|
|
3386
|
+
// gate_id equality once that field is populated (PR-D+), else by presence alone (pre-PR-D
|
|
3387
|
+
// records, N10). No match ⇒ the zombie/grandfathered variant + the resume-clears/purge
|
|
3388
|
+
// pointer.
|
|
3389
|
+
const cancelEntry = Object.entries(result.run.skip_details ?? {}).find(([, d]) => d.kind === 'gate_cancelled_by_abort');
|
|
3390
|
+
const cancelDetail = cancelEntry?.[1];
|
|
3391
|
+
const isCancelledMatch = cancelEntry !== undefined &&
|
|
3392
|
+
(cancelDetail.gate_id === undefined || cancelDetail.gate_id === options.gateId);
|
|
3393
|
+
if (isCancelledMatch) {
|
|
3394
|
+
const [cancelledStep] = cancelEntry;
|
|
3395
|
+
const abortedBy = result.run.aborted_at?.step_id;
|
|
3396
|
+
const err = new WorkflowError(`Gate '${options.gateId}' on '${cancelledStep}' was cancelled when ` +
|
|
3397
|
+
`'${abortedBy ?? 'another step'}' aborted the run — your choice was NOT recorded.`, {
|
|
3398
|
+
code: 'STATE_RUN_TERMINAL',
|
|
3399
|
+
category: 'STATE',
|
|
3400
|
+
agentAction: 'report_to_user',
|
|
3401
|
+
retryable: false,
|
|
3402
|
+
details: {
|
|
3403
|
+
runId: options.runId,
|
|
3404
|
+
run_phase: result.run.run_phase,
|
|
3405
|
+
gate_id: options.gateId,
|
|
3406
|
+
step_name: cancelledStep,
|
|
3407
|
+
...(abortedBy !== undefined ? { aborted_by: abortedBy } : {}),
|
|
3408
|
+
},
|
|
3409
|
+
});
|
|
3410
|
+
return errorEnvelope(cancelledStep, options.runId, result.run.version, err, err.message, result.run.run_phase);
|
|
3411
|
+
}
|
|
3412
|
+
// Zombie/grandfathered variant — the #282 class: a terminal record may still carry a
|
|
3413
|
+
// stale pending_gate (never cleared), which is the best-effort step label here.
|
|
3414
|
+
const zombieStep = result.run.pending_gate?.step_name ?? 'submit_gate';
|
|
3415
|
+
const err = new WorkflowError(`Run '${options.runId}' is terminal; cannot submit a gate response — 'realm resume' ` +
|
|
3416
|
+
`clears a stale pending gate on a resumable run, or 'realm run purge' removes the ` +
|
|
3417
|
+
`record entirely.`, {
|
|
3418
|
+
code: 'STATE_RUN_TERMINAL',
|
|
3419
|
+
category: 'STATE',
|
|
3420
|
+
agentAction: 'report_to_user',
|
|
3421
|
+
retryable: false,
|
|
3422
|
+
details: { runId: options.runId, run_phase: result.run.run_phase },
|
|
3423
|
+
});
|
|
3424
|
+
return errorEnvelope(zombieStep, options.runId, result.run.version, err, err.message, result.run.run_phase);
|
|
3425
|
+
}
|
|
3426
|
+
default:
|
|
3427
|
+
// gate_open_wait/already_open/already_released/choice_not_eligible's siblings and every
|
|
3428
|
+
// other kind's own reason are unreachable here — settle_gate never returns them (design
|
|
3429
|
+
// record §7).
|
|
3430
|
+
throw new Error(`submitHumanResponse: unreachable settle_gate refusal reason '${result.reason}'`);
|
|
3431
|
+
}
|
|
3432
|
+
}
|
|
3433
|
+
// applied: true. Reliable step name: the pre-read's pending_gate.step_name (guaranteed
|
|
3434
|
+
// correct whenever `applied` is true — see the evidence-rule comment above).
|
|
3435
|
+
const resolvedGateStepName = run.pending_gate.step_name;
|
|
3436
|
+
// Drain: on transitioned OR (already_settled ∧ pending ledger entries non-empty) — hand-rolled
|
|
3437
|
+
// (buildAlreadySettledEnvelope is ExecuteStepOptions-shaped, not reusable here). `applied:
|
|
3438
|
+
// true` never reaches the already_settled leg, so only the `transitioned` disjunct applies at
|
|
3439
|
+
// THIS call site (design record §6 row 2).
|
|
3440
|
+
let finalRun = result.run;
|
|
3441
|
+
let drainWarnings = [];
|
|
3442
|
+
if (result.transitioned) {
|
|
3443
|
+
try {
|
|
3444
|
+
const drainOutcome = await drainFinalizers(store, definition, options.registry, options.runId);
|
|
3445
|
+
finalRun = drainOutcome.run;
|
|
3446
|
+
drainWarnings = drainOutcome.warnings;
|
|
3447
|
+
}
|
|
3448
|
+
catch (err) {
|
|
3449
|
+
drainWarnings = [
|
|
3450
|
+
`post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
3451
|
+
];
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
// Convergence hint (design record D-2 N8 narrowing, pedestal steal — must not drop): after a
|
|
3455
|
+
// committed RESOLVE, when a guard is thereby eligible, append one line per eligible guard.
|
|
3456
|
+
// findEligibleGuardSteps self-filters terminal runs (returns [] there), so this is inert on a
|
|
3457
|
+
// gate-completion terminal transition.
|
|
3458
|
+
const convergenceHints = findEligibleGuardSteps(definition, finalRun).map((name) => `guard '${name}' now eligible — converges at the next drive`);
|
|
3459
|
+
const defaultedStepsDurabilityWarning = finalRun.defaulted_steps !== undefined &&
|
|
3460
|
+
finalRun.defaulted_steps.length > 0 &&
|
|
3461
|
+
!persistsField(store, 'defaulted_steps')
|
|
3462
|
+
? 'run-level defaultedness marker (defaulted_steps) not durable on this store'
|
|
3463
|
+
: undefined;
|
|
3464
|
+
const migratedNextActions = finalRun.terminal_state
|
|
3465
|
+
? []
|
|
3466
|
+
: buildNextActions(definition, finalRun);
|
|
3467
|
+
const migratedOrientation = finalRun.terminal_state
|
|
3468
|
+
? `Run completed (phase: '${finalRun.run_phase}'). Call get_run_state with run_id '${options.runId}' to retrieve the full evidence record.`
|
|
3469
|
+
: `Gate '${resolvedGateStepName}' resolved with choice '${options.choice}'. ${migratedNextActions.length} step(s) now available.`;
|
|
3470
|
+
return {
|
|
3471
|
+
command: resolvedGateStepName,
|
|
3472
|
+
run_id: options.runId,
|
|
3473
|
+
run_version: finalRun.version,
|
|
3474
|
+
status: 'ok',
|
|
3475
|
+
data: { ...run.pending_gate.preview, choice: options.choice },
|
|
3476
|
+
evidence: [],
|
|
3477
|
+
warnings: mergeWarnings(convergenceHints, ...drainWarnings, defaultedStepsDurabilityWarning),
|
|
3478
|
+
errors: [],
|
|
3479
|
+
context_hint: migratedOrientation,
|
|
3480
|
+
run_phase: finalRun.run_phase,
|
|
3481
|
+
next_actions: migratedNextActions,
|
|
3482
|
+
...(finalRun.defaulted_steps?.length ? { defaulted_steps: finalRun.defaulted_steps } : {}),
|
|
3483
|
+
};
|
|
3484
|
+
}
|
|
3485
|
+
// --- Legacy path (dormancy fallback — byte-identical to pre-#279 behavior) ---
|
|
2707
3486
|
// 1a. Defensive terminal guard (mirrors #91/#95): a late gate response must never re-drive a
|
|
2708
3487
|
// run that has already reached a terminal phase.
|
|
2709
3488
|
if (run.terminal_state) {
|
|
@@ -2733,6 +3512,44 @@ export async function submitHumanResponse(store, definition, options) {
|
|
|
2733
3512
|
retryable: false,
|
|
2734
3513
|
}), `Gate ID mismatch on run '${options.runId}'.`);
|
|
2735
3514
|
}
|
|
3515
|
+
// 3.5. issue #291 ([F4] legacy-store expiry — the ONE enactment point F4 explicitly gives a
|
|
3516
|
+
// legacy-CAS fallback, since it already owns one): the gate has expired AND has an enactable
|
|
3517
|
+
// disposition (on_expiry frozen — a finding-only gate, expires_at with no on_expiry, is
|
|
3518
|
+
// excluded exactly like applySettleGate's own F3 gating, so a finding-only gate's human
|
|
3519
|
+
// response resolves normally below, however overdue). Enacted via the SAME pure
|
|
3520
|
+
// `applySettlement` transform this store's declaring siblings use through `settleStep` — this
|
|
3521
|
+
// store has no `settleStep` of its own, so the result is persisted through the version-CAS
|
|
3522
|
+
// `store.update()` this legacy path already owns. A CAS-mismatch (a genuine race) surfaces as
|
|
3523
|
+
// an honest error, matching this path's existing no-retry risk profile everywhere else.
|
|
3524
|
+
if (run.pending_gate.expires_at !== undefined &&
|
|
3525
|
+
run.pending_gate.on_expiry !== undefined &&
|
|
3526
|
+
now.getTime() >= new Date(run.pending_gate.expires_at).getTime()) {
|
|
3527
|
+
const overdueMs = now.getTime() - new Date(run.pending_gate.expires_at).getTime();
|
|
3528
|
+
const expireOutcome = applySettlement(run, { kind: 'expire_gate', gateId: options.gateId }, definition, { now });
|
|
3529
|
+
if (!expireOutcome.applied) {
|
|
3530
|
+
// In-contract for this snapshot-based path: the local `run` might already reflect a prior
|
|
3531
|
+
// enactment (e.g. a same-process retry) — already_settled composes the honest envelope the
|
|
3532
|
+
// same way the migrated path's race leg does, reading disposition off `expireOutcome.run`
|
|
3533
|
+
// (the very snapshot the refusal matched against).
|
|
3534
|
+
return composeExpiredGateEnvelope(store, definition, options.registry, options.gateId, options.choice, overdueMs, expireOutcome);
|
|
3535
|
+
}
|
|
3536
|
+
let persistedExpiry;
|
|
3537
|
+
try {
|
|
3538
|
+
persistedExpiry = await store.update(expireOutcome.run);
|
|
3539
|
+
}
|
|
3540
|
+
catch (err) {
|
|
3541
|
+
const e = err instanceof WorkflowError
|
|
3542
|
+
? err
|
|
3543
|
+
: new WorkflowError("Failed to persist the gate's expiry", {
|
|
3544
|
+
code: 'ENGINE_STORE_FAILED',
|
|
3545
|
+
category: 'ENGINE',
|
|
3546
|
+
agentAction: 'stop',
|
|
3547
|
+
retryable: false,
|
|
3548
|
+
});
|
|
3549
|
+
return errorEnvelope(run.pending_gate.step_name, options.runId, run.version, e, "Failed to enact the gate's expiry.", run.run_phase);
|
|
3550
|
+
}
|
|
3551
|
+
return composeExpiredGateEnvelope(store, definition, options.registry, options.gateId, options.choice, overdueMs, { ...expireOutcome, run: persistedExpiry });
|
|
3552
|
+
}
|
|
2736
3553
|
// 4. Validate choice.
|
|
2737
3554
|
if (!run.pending_gate.choices.includes(options.choice)) {
|
|
2738
3555
|
const expected = run.pending_gate.choices.join(', ');
|
|
@@ -2834,7 +3651,9 @@ export async function submitHumanResponse(store, definition, options) {
|
|
|
2834
3651
|
// envelope flag (do NOT add an evidence scan to recompute it). Its disclosure surface is the
|
|
2835
3652
|
// run-level `defaulted_steps` marker below, plus whatever the gate-open envelope already
|
|
2836
3653
|
// warned the human with.
|
|
2837
|
-
|
|
3654
|
+
// issue #279 (increment 2, PR-D): + the ONE dormancy advisory (I16) — this IS the legacy path
|
|
3655
|
+
// (store.settleStep undeclared).
|
|
3656
|
+
warnings: mergeWarnings([], defaultedStepsDurabilityWarning, DORMANCY_ADVISORY),
|
|
2838
3657
|
errors: [],
|
|
2839
3658
|
context_hint: orientation,
|
|
2840
3659
|
run_phase: savedRun.run_phase,
|
|
@@ -2993,8 +3812,10 @@ async function buildFinalizedSeal(definition, sealDraft, outcome, registry) {
|
|
|
2993
3812
|
// settlement.ts's selectFinalizers, shared with `mintFresh` — this call passes the SAME inputs
|
|
2994
3813
|
// the inline loop used to compute over, so the returned name list is byte-identical to what
|
|
2995
3814
|
// `[...groupA, ...groupB]` produced before the extraction.
|
|
3815
|
+
// issue #302 (chokepoint 2 of 2): derive the full effective trigger set from sealDraft — the
|
|
3816
|
+
// legacy seal path for a non-declaring external store (the dormancy fallback).
|
|
2996
3817
|
const settled = new Set([...sealDraft.completed_steps, ...sealDraft.failed_steps]);
|
|
2997
|
-
const selected = selectFinalizers(definition, settled, outcome);
|
|
3818
|
+
const selected = selectFinalizers(definition, settled, deriveEffectiveTriggers(outcome, sealDraft));
|
|
2998
3819
|
let record = sealDraft;
|
|
2999
3820
|
for (const name of selected) {
|
|
3000
3821
|
const step = definition.steps[name];
|
|
@@ -3307,15 +4128,242 @@ depth0Warnings) {
|
|
|
3307
4128
|
// Execute any eligible guard steps inline before looking for the next auto step.
|
|
3308
4129
|
// Guard steps are synchronous engine decisions — not returned to the agent.
|
|
3309
4130
|
// Loop to handle cascading guards (guard A passes → guard B becomes eligible).
|
|
4131
|
+
// issue #279 (increment 2, PR-D, Deliverable 1c): non-abort settled_outcome_divergence warnings
|
|
4132
|
+
// ADVANCE the chain but must still surface somewhere — carried here and merged into whichever
|
|
4133
|
+
// envelope eventually returns (the guardsRan rebuild below, or a migrated terminal return).
|
|
4134
|
+
const guardWarnings = [];
|
|
3310
4135
|
let guardEligible = findEligibleGuardSteps(definition, run);
|
|
3311
4136
|
while (guardEligible.length > 0) {
|
|
3312
4137
|
const guardName = guardEligible[0];
|
|
3313
4138
|
const guardStepDef = definition.steps[guardName];
|
|
3314
|
-
// Execute inline (pure in-memory; returns updated RunRecord).
|
|
4139
|
+
// Execute inline (pure in-memory; returns updated RunRecord). executeGuardStep stays PURE and
|
|
4140
|
+
// UNTOUCHED (design record §1) — both the migrated and legacy paths below call it identically.
|
|
3315
4141
|
const guardResult = await executeGuardStep(guardName, guardStepDef, definition, run);
|
|
3316
4142
|
// Capture the guard's OWN evidence (its last entry) BEFORE the finalizer drain appends
|
|
3317
4143
|
// finalizer evidence — the terminal return below surfaces only the guard's evidence.
|
|
3318
4144
|
const guardOwnEvidence = guardResult.evidence.slice(-1);
|
|
4145
|
+
// issue #279 (increment 2, PR-D, Deliverable 1c): the migrated path — settles this guard's
|
|
4146
|
+
// evaluated outcome atomically against FRESH state via the store's own settleStep. Dormancy:
|
|
4147
|
+
// an undeclaring store falls through to the byte-identical legacy path below (I16/#169
|
|
4148
|
+
// fail-closed dormancy).
|
|
4149
|
+
if (store.settleStep !== undefined) {
|
|
4150
|
+
// Extraction rule (design record §2, normative): reverse-classify guardResult's SEALED
|
|
4151
|
+
// output by MEMBERSHIP — NEVER the terminal_state ternary below (wrong for a non-terminal
|
|
4152
|
+
// pass, which never sets terminal_state at all).
|
|
4153
|
+
const guardSettleOutcome = guardResult.aborted_at !== undefined
|
|
4154
|
+
? 'abort'
|
|
4155
|
+
: guardResult.failed_steps.includes(guardName)
|
|
4156
|
+
? 'resolution_error'
|
|
4157
|
+
: 'pass'; // the only remaining membership — completed_steps.includes(guardName)
|
|
4158
|
+
let resolutionError;
|
|
4159
|
+
if (guardSettleOutcome === 'resolution_error') {
|
|
4160
|
+
// Rails-compliant re-derivation (normative): normalize abort_unless to string[] (the
|
|
4161
|
+
// executeGuardStep :3632-3635 shape) and re-run evaluateGuardConditions against the SAME
|
|
4162
|
+
// pre-seal `run` passed to executeGuardStep — pure + deterministic, so this reproduces the
|
|
4163
|
+
// discarded internal result byte-for-byte.
|
|
4164
|
+
const conditions = Array.isArray(guardStepDef.abort_unless)
|
|
4165
|
+
? guardStepDef.abort_unless
|
|
4166
|
+
: [guardStepDef.abort_unless];
|
|
4167
|
+
const reEvaluated = evaluateGuardConditions(conditions, buildEvidenceByStep(run));
|
|
4168
|
+
if (reEvaluated.kind === 'resolution_error') {
|
|
4169
|
+
resolutionError = {
|
|
4170
|
+
condition: reEvaluated.condition,
|
|
4171
|
+
unresolvable_path: reEvaluated.unresolvable_path,
|
|
4172
|
+
};
|
|
4173
|
+
}
|
|
4174
|
+
}
|
|
4175
|
+
const delta = {
|
|
4176
|
+
kind: 'settle_guard',
|
|
4177
|
+
step: guardName,
|
|
4178
|
+
outcome: guardSettleOutcome,
|
|
4179
|
+
evidence: guardOwnEvidence[0],
|
|
4180
|
+
...(resolutionError !== undefined ? { resolutionError } : {}),
|
|
4181
|
+
...(guardSettleOutcome === 'abort'
|
|
4182
|
+
? {
|
|
4183
|
+
abort: {
|
|
4184
|
+
conditions: guardResult.aborted_at.conditions ?? [],
|
|
4185
|
+
...(guardResult.aborted_at.abort_message !== undefined
|
|
4186
|
+
? { abort_message: guardResult.aborted_at.abort_message }
|
|
4187
|
+
: {}),
|
|
4188
|
+
},
|
|
4189
|
+
}
|
|
4190
|
+
: {}),
|
|
4191
|
+
// evaluatedAtVersion (design record §2, lane-B steal 2): the chain's OWN evaluation
|
|
4192
|
+
// snapshot — this iteration's pre-settle `run.version`.
|
|
4193
|
+
evaluatedAtVersion: run.version,
|
|
4194
|
+
};
|
|
4195
|
+
let guardSettleResult;
|
|
4196
|
+
try {
|
|
4197
|
+
guardSettleResult = await store.settleStep(options.runId, delta, definition);
|
|
4198
|
+
}
|
|
4199
|
+
catch (storeErr) {
|
|
4200
|
+
// Thrown infra errors — the same persist-failure envelope shape the legacy path's own
|
|
4201
|
+
// store.update catch (below) has always returned.
|
|
4202
|
+
const msg = storeErr instanceof Error ? storeErr.message : String(storeErr);
|
|
4203
|
+
return {
|
|
4204
|
+
command: options.command,
|
|
4205
|
+
run_id: options.runId,
|
|
4206
|
+
run_version: run.version,
|
|
4207
|
+
status: 'error',
|
|
4208
|
+
data: {},
|
|
4209
|
+
evidence: [],
|
|
4210
|
+
warnings: [],
|
|
4211
|
+
errors: [`Failed to persist guard step '${guardName}': ${msg}`],
|
|
4212
|
+
agent_action: 'stop',
|
|
4213
|
+
context_hint: `Guard step '${guardName}' could not be persisted. Run state may be inconsistent.`,
|
|
4214
|
+
run_phase: run.run_phase,
|
|
4215
|
+
next_actions: [],
|
|
4216
|
+
};
|
|
4217
|
+
}
|
|
4218
|
+
if (!guardSettleResult.applied) {
|
|
4219
|
+
// Chain-consumption table (design record §6, adjudicated):
|
|
4220
|
+
if (guardSettleResult.reason === 'already_settled' ||
|
|
4221
|
+
guardSettleResult.reason === 'gate_open_wait' ||
|
|
4222
|
+
(guardSettleResult.reason === 'settled_outcome_divergence' &&
|
|
4223
|
+
guardSettleOutcome !== 'abort')) {
|
|
4224
|
+
// already_settled / gate_open_wait ⇒ ADVANCE, threading result.run (findEligibleGuardSteps
|
|
4225
|
+
// self-filters both a now-settled guard and an open gate, so the loop naturally converges).
|
|
4226
|
+
// settled_outcome_divergence on a NON-abort attempt ⇒ ADVANCE + a warning line.
|
|
4227
|
+
if (guardSettleResult.reason === 'settled_outcome_divergence') {
|
|
4228
|
+
guardWarnings.push(`guard '${guardName}' outcome diverged from a concurrent settle` +
|
|
4229
|
+
(guardSettleResult.persisted !== undefined
|
|
4230
|
+
? ` (persisted: '${guardSettleResult.persisted}')`
|
|
4231
|
+
: '') +
|
|
4232
|
+
' — chain advanced on the persisted outcome.');
|
|
4233
|
+
}
|
|
4234
|
+
if (guardSettleResult.reason !== 'gate_open_wait') {
|
|
4235
|
+
// "quiet" end-of-pass for gate_open_wait only — nothing was decided, so nothing is
|
|
4236
|
+
// recorded; already_settled/divergence DID decide something (elsewhere), so it is.
|
|
4237
|
+
chainedSteps.push({ step: guardName, run_phase: guardSettleResult.run.run_phase });
|
|
4238
|
+
}
|
|
4239
|
+
run = guardSettleResult.run;
|
|
4240
|
+
// Drain: on already_settled ∧ pending ledger entries non-empty (design record §6, "same
|
|
4241
|
+
// clause" as the transitioned leg below) — recovers a crashed-drain RESOLVE that a
|
|
4242
|
+
// sibling's own settle committed but never drained.
|
|
4243
|
+
if (guardSettleResult.reason === 'already_settled') {
|
|
4244
|
+
const hasPending = Object.values(run.finalizer_ledger ?? {}).some((e) => e.status === 'pending');
|
|
4245
|
+
if (hasPending) {
|
|
4246
|
+
try {
|
|
4247
|
+
const drainOutcome = await drainFinalizers(store, definition, options.registry, options.runId);
|
|
4248
|
+
run = drainOutcome.run;
|
|
4249
|
+
if (drainOutcome.warnings.length > 0)
|
|
4250
|
+
guardWarnings.push(...drainOutcome.warnings);
|
|
4251
|
+
}
|
|
4252
|
+
catch (err) {
|
|
4253
|
+
guardWarnings.push(`post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
4254
|
+
}
|
|
4255
|
+
}
|
|
4256
|
+
}
|
|
4257
|
+
guardEligible = findEligibleGuardSteps(definition, run);
|
|
4258
|
+
continue;
|
|
4259
|
+
}
|
|
4260
|
+
if (guardSettleResult.reason === 'settled_outcome_divergence') {
|
|
4261
|
+
// ABORT leg only ⇒ report_to_user + chain-RETURN (design record §6/§7) — this attempt's
|
|
4262
|
+
// abort was never recorded.
|
|
4263
|
+
const err = new WorkflowError(`Guard step '${guardName}' was already settled` +
|
|
4264
|
+
(guardSettleResult.persisted !== undefined
|
|
4265
|
+
? ` (persisted: '${guardSettleResult.persisted}')`
|
|
4266
|
+
: '') +
|
|
4267
|
+
` by a different attempt — your abort was NOT recorded.`, {
|
|
4268
|
+
code: 'STATE_STEP_ALREADY_SETTLED',
|
|
4269
|
+
category: 'STATE',
|
|
4270
|
+
agentAction: 'report_to_user',
|
|
4271
|
+
retryable: false,
|
|
4272
|
+
details: {
|
|
4273
|
+
runId: options.runId,
|
|
4274
|
+
step: guardName,
|
|
4275
|
+
reason: guardSettleResult.reason,
|
|
4276
|
+
...(guardSettleResult.persisted !== undefined
|
|
4277
|
+
? { persisted: guardSettleResult.persisted }
|
|
4278
|
+
: {}),
|
|
4279
|
+
},
|
|
4280
|
+
});
|
|
4281
|
+
return {
|
|
4282
|
+
command: options.command,
|
|
4283
|
+
run_id: options.runId,
|
|
4284
|
+
run_version: guardSettleResult.run.version,
|
|
4285
|
+
status: 'error',
|
|
4286
|
+
data: {},
|
|
4287
|
+
evidence: [],
|
|
4288
|
+
warnings: [],
|
|
4289
|
+
errors: [err.message],
|
|
4290
|
+
error_code: err.code,
|
|
4291
|
+
...(Object.keys(err.details).length > 0 ? { error_details: err.details } : {}),
|
|
4292
|
+
agent_action: 'report_to_user',
|
|
4293
|
+
context_hint: err.message,
|
|
4294
|
+
run_phase: guardSettleResult.run.run_phase,
|
|
4295
|
+
next_actions: [],
|
|
4296
|
+
};
|
|
4297
|
+
}
|
|
4298
|
+
if (guardSettleResult.reason === 'run_terminal') {
|
|
4299
|
+
// Terminal by OTHER (a sibling settle raced this guard's own evaluation) — INLINE
|
|
4300
|
+
// construction, parity with the entry-terminal envelope (executeChain's own early
|
|
4301
|
+
// return).
|
|
4302
|
+
return {
|
|
4303
|
+
command: options.command,
|
|
4304
|
+
run_id: options.runId,
|
|
4305
|
+
run_version: guardSettleResult.run.version,
|
|
4306
|
+
status: 'ok',
|
|
4307
|
+
data: {},
|
|
4308
|
+
evidence: [],
|
|
4309
|
+
warnings: [],
|
|
4310
|
+
errors: [],
|
|
4311
|
+
agent_action: 'stop',
|
|
4312
|
+
context_hint: `Run '${options.runId}' is already terminal (${guardSettleResult.run.run_phase}); guard '${guardName}' was not evaluated.`,
|
|
4313
|
+
run_phase: guardSettleResult.run.run_phase,
|
|
4314
|
+
next_actions: [],
|
|
4315
|
+
};
|
|
4316
|
+
}
|
|
4317
|
+
// gate_mismatch/choice_not_eligible/already_open/already_released and every other kind's
|
|
4318
|
+
// own reason are unreachable here — settle_guard never returns them (design record §7).
|
|
4319
|
+
throw new Error(`executeChainInternal: unreachable settle_guard refusal reason '${guardSettleResult.reason}'`);
|
|
4320
|
+
}
|
|
4321
|
+
// applied: true.
|
|
4322
|
+
chainedSteps.push({ step: guardName, run_phase: guardSettleResult.run.run_phase });
|
|
4323
|
+
if (guardSettleResult.transitioned) {
|
|
4324
|
+
// Drain IMMEDIATELY after a transitioned settle result, BEFORE building the in-loop
|
|
4325
|
+
// terminal envelope (design record §6/R5 — a post-loop drain would be dead code on this
|
|
4326
|
+
// leg: this function RETURNS before ever reaching a post-loop point).
|
|
4327
|
+
let finalGuardRun = guardSettleResult.run;
|
|
4328
|
+
let guardDrainWarnings;
|
|
4329
|
+
try {
|
|
4330
|
+
const drainOutcome = await drainFinalizers(store, definition, options.registry, options.runId);
|
|
4331
|
+
finalGuardRun = drainOutcome.run;
|
|
4332
|
+
guardDrainWarnings = drainOutcome.warnings;
|
|
4333
|
+
}
|
|
4334
|
+
catch (err) {
|
|
4335
|
+
guardDrainWarnings = [
|
|
4336
|
+
`post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
4337
|
+
];
|
|
4338
|
+
}
|
|
4339
|
+
const migratedContextHint = guardSettleOutcome === 'abort'
|
|
4340
|
+
? `Guard step '${guardName}' aborted the run.`
|
|
4341
|
+
: guardSettleOutcome === 'resolution_error'
|
|
4342
|
+
? `Guard step '${guardName}' failed with a resolution error. Run is terminated.`
|
|
4343
|
+
: `Guard step '${guardName}' passed and completed the run.`;
|
|
4344
|
+
return {
|
|
4345
|
+
command: options.command,
|
|
4346
|
+
run_id: options.runId,
|
|
4347
|
+
run_version: finalGuardRun.version,
|
|
4348
|
+
status: 'ok',
|
|
4349
|
+
data: {},
|
|
4350
|
+
evidence: guardOwnEvidence,
|
|
4351
|
+
warnings: mergeWarnings(guardWarnings, ...guardDrainWarnings),
|
|
4352
|
+
errors: [],
|
|
4353
|
+
context_hint: migratedContextHint,
|
|
4354
|
+
run_phase: finalGuardRun.run_phase,
|
|
4355
|
+
next_actions: [],
|
|
4356
|
+
...(finalGuardRun.defaulted_steps?.length
|
|
4357
|
+
? { defaulted_steps: finalGuardRun.defaulted_steps }
|
|
4358
|
+
: {}),
|
|
4359
|
+
};
|
|
4360
|
+
}
|
|
4361
|
+
// Non-terminal pass — continue the chain.
|
|
4362
|
+
run = guardSettleResult.run;
|
|
4363
|
+
guardEligible = findEligibleGuardSteps(definition, run);
|
|
4364
|
+
continue;
|
|
4365
|
+
}
|
|
4366
|
+
// --- Legacy path (dormancy fallback — byte-identical to pre-#279 behavior) ---
|
|
3319
4367
|
// Blocking fix #1: classify the terminal outcome by the SEALED record, not aborted_at
|
|
3320
4368
|
// alone. executeGuardStep sets terminal_state in THREE cases — abort (aborted_at set),
|
|
3321
4369
|
// resolution-error (failed, no aborted_at), and a PASS that completes the run
|
|
@@ -3384,7 +4432,9 @@ depth0Warnings) {
|
|
|
3384
4432
|
data: {},
|
|
3385
4433
|
// The guard's own evidence entry, captured before the finalizer drain appended any.
|
|
3386
4434
|
evidence: guardOwnEvidence,
|
|
3387
|
-
|
|
4435
|
+
// issue #279 (increment 2, PR-D): + the ONE dormancy advisory (I16) — this IS the legacy
|
|
4436
|
+
// path (store.settleStep undeclared).
|
|
4437
|
+
warnings: mergeWarnings([], guardDefaultedStepsDurabilityWarning, DORMANCY_ADVISORY),
|
|
3388
4438
|
errors: [],
|
|
3389
4439
|
context_hint: contextHint,
|
|
3390
4440
|
run_phase: persistedGuardRun.run_phase,
|
|
@@ -3408,6 +4458,12 @@ depth0Warnings) {
|
|
|
3408
4458
|
...result,
|
|
3409
4459
|
run_version: run.version,
|
|
3410
4460
|
next_actions: freshNextActions,
|
|
4461
|
+
// issue #279 (increment 2, PR-D): non-abort settled_outcome_divergence warnings accumulated
|
|
4462
|
+
// during the guard loop above (the ADVANCE leg) must reach whichever envelope returns —
|
|
4463
|
+
// this rebuild is the first point after the loop `result` is touched again.
|
|
4464
|
+
...(guardWarnings.length > 0
|
|
4465
|
+
? { warnings: mergeWarnings(result.warnings, ...guardWarnings) }
|
|
4466
|
+
: {}),
|
|
3411
4467
|
};
|
|
3412
4468
|
}
|
|
3413
4469
|
if (run.terminal_state || run.pending_gate !== undefined) {
|
|
@@ -3464,7 +4520,13 @@ export async function executeChain(store, definition, options) {
|
|
|
3464
4520
|
throw err;
|
|
3465
4521
|
}
|
|
3466
4522
|
}
|
|
3467
|
-
|
|
4523
|
+
// issue #279 (increment 2, PR-C — D-3 leg v): keyed on terminal_state, never the persisted
|
|
4524
|
+
// run_phase — a grandfathered terminal-with-stale-gate record (the #282 class) must still be
|
|
4525
|
+
// recognized as terminal here.
|
|
4526
|
+
if (entryRun !== undefined && entryRun.terminal_state === true) {
|
|
4527
|
+
// Derive-for-message (D-3 leg v): render the TRUE (derived) phase, never the possibly-stale
|
|
4528
|
+
// persisted one.
|
|
4529
|
+
const derivedPhase = deriveRunPhase(entryRun);
|
|
3468
4530
|
return {
|
|
3469
4531
|
command: options.command,
|
|
3470
4532
|
run_id: options.runId,
|
|
@@ -3475,8 +4537,8 @@ export async function executeChain(store, definition, options) {
|
|
|
3475
4537
|
warnings: [],
|
|
3476
4538
|
errors: [],
|
|
3477
4539
|
agent_action: 'stop',
|
|
3478
|
-
context_hint: `Run '${options.runId}' is already terminal (${
|
|
3479
|
-
run_phase:
|
|
4540
|
+
context_hint: `Run '${options.runId}' is already terminal (${derivedPhase}); no steps executed.`,
|
|
4541
|
+
run_phase: derivedPhase,
|
|
3480
4542
|
next_actions: [],
|
|
3481
4543
|
};
|
|
3482
4544
|
}
|