@sensigo/realm 0.32.0 → 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 +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 +7 -4
- package/dist/engine/execution-loop.d.ts.map +1 -1
- package/dist/engine/execution-loop.js +810 -87
- package/dist/engine/execution-loop.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 +62 -1
- package/dist/engine/run-health.js.map +1 -1
- package/dist/engine/settlement.d.ts +11 -0
- package/dist/engine/settlement.d.ts.map +1 -1
- package/dist/engine/settlement.js +441 -30
- package/dist/engine/settlement.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -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/run-record.d.ts +26 -3
- package/dist/types/run-record.d.ts.map +1 -1
- package/dist/types/settlement.d.ts +135 -11
- package/dist/types/settlement.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -8,7 +8,7 @@ import { selectFinalizers } from './settlement.js';
|
|
|
8
8
|
import { captureEvidence } from '../evidence/snapshot.js';
|
|
9
9
|
import { validateInputSchema, validateOutputSchema, validateTraceSchema, } from '../validation/input-schema.js';
|
|
10
10
|
import { normalizeTrace } from './trace-normalizer.js';
|
|
11
|
-
import { TERMINAL_PHASES,
|
|
11
|
+
import { TERMINAL_PHASES, DRAIN_CEILING_SECONDS } from './lifecycle.js';
|
|
12
12
|
import { omitClaim, shouldEnforceTimeout, DEFAULT_EXECUTION_TIMEOUT_SECONDS, resolveCapMs, sleepWouldExceedCap, } from './claim-liveness.js';
|
|
13
13
|
import { computeBackoff } from './backoff.js';
|
|
14
14
|
import { checkPreconditions, evaluateAllPreconditions, evaluateGuardConditions, } from './precondition.js';
|
|
@@ -401,6 +401,25 @@ function stampDefaultedSteps(sealDraft) {
|
|
|
401
401
|
return sealDraft;
|
|
402
402
|
return { ...sealDraft, defaulted_steps: steps };
|
|
403
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
|
+
}
|
|
404
423
|
/**
|
|
405
424
|
* Compensating un-claim (issue #207 PR-2, D3 §5): built from `pendingRun` — the record OUR OWN
|
|
406
425
|
* `claimStep` call returned, never a fresh get — removing the step from `in_progress_steps` AND
|
|
@@ -414,17 +433,7 @@ function stampDefaultedSteps(sealDraft) {
|
|
|
414
433
|
* naturally idempotent) is simply a no-op mutation, not a special case.
|
|
415
434
|
*/
|
|
416
435
|
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
|
-
});
|
|
436
|
+
const auditEvidence = buildCompensatingUnclaimEvidence(stepName, now);
|
|
428
437
|
return {
|
|
429
438
|
...pendingRun,
|
|
430
439
|
in_progress_steps: pendingRun.in_progress_steps.filter((s) => s !== stepName),
|
|
@@ -624,23 +633,32 @@ function computeReArmWarnings(before, after) {
|
|
|
624
633
|
return warnings;
|
|
625
634
|
}
|
|
626
635
|
/**
|
|
627
|
-
* Builds the ResponseEnvelope for a `settle_step` REFUSAL (design record §7's
|
|
628
|
-
* shared by
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
633
|
-
*
|
|
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).
|
|
634
648
|
*/
|
|
635
|
-
function buildSettlementRefusalEnvelope(options, definition, result, allEvidence, traceWarnings) {
|
|
649
|
+
function buildSettlementRefusalEnvelope(options, definition, result, allEvidence, traceWarnings, kind = 'settle_step') {
|
|
636
650
|
const extraWarnings = traceWarnings.length > 0 ? traceWarnings : undefined;
|
|
637
651
|
switch (result.reason) {
|
|
638
652
|
case 'already_settled_by_other':
|
|
639
653
|
case 'settled_outcome_divergence': {
|
|
640
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';
|
|
641
659
|
const err = new WorkflowError(`Step '${options.command}' was already settled` +
|
|
642
660
|
(persisted !== undefined ? ` with outcome '${persisted}'` : '') +
|
|
643
|
-
`
|
|
661
|
+
` ${settledByText}.`, {
|
|
644
662
|
code: 'STATE_STEP_ALREADY_SETTLED',
|
|
645
663
|
category: 'STATE',
|
|
646
664
|
agentAction: 'resolve_precondition',
|
|
@@ -679,8 +697,15 @@ function buildSettlementRefusalEnvelope(options, definition, result, allEvidence
|
|
|
679
697
|
return makeErrorEnvelope(options, result.run, err, definition, extraWarnings);
|
|
680
698
|
}
|
|
681
699
|
case 'gate_mismatch': {
|
|
682
|
-
|
|
683
|
-
|
|
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, {
|
|
684
709
|
code: 'STATE_BLOCKED',
|
|
685
710
|
category: 'STATE',
|
|
686
711
|
agentAction: 'resolve_precondition',
|
|
@@ -691,9 +716,12 @@ function buildSettlementRefusalEnvelope(options, definition, result, allEvidence
|
|
|
691
716
|
}
|
|
692
717
|
default:
|
|
693
718
|
// run_not_terminal / ledger_not_pending / lease_held / lease_lost / rank_blocked /
|
|
694
|
-
// not_eligible / already_leased / already_marked
|
|
695
|
-
//
|
|
696
|
-
|
|
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}'`);
|
|
697
725
|
}
|
|
698
726
|
}
|
|
699
727
|
/**
|
|
@@ -1175,13 +1203,43 @@ export async function executeStep(store, definition, options) {
|
|
|
1175
1203
|
: [];
|
|
1176
1204
|
}
|
|
1177
1205
|
catch (err) {
|
|
1178
|
-
|
|
1179
|
-
|
|
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
|
+
}
|
|
1180
1227
|
}
|
|
1181
|
-
|
|
1182
|
-
//
|
|
1183
|
-
|
|
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;
|
|
1184
1241
|
}
|
|
1242
|
+
const unclaimEnvelopeWarnings = mergeWarnings(traceWarnings, unclaimDormancyWarning);
|
|
1185
1243
|
return makeErrorEnvelope(options, pendingRun, new WorkflowError('Failed to read trace buffer after claiming step', {
|
|
1186
1244
|
code: 'ENGINE_STORE_FAILED',
|
|
1187
1245
|
category: 'ENGINE',
|
|
@@ -1191,7 +1249,7 @@ export async function executeStep(store, definition, options) {
|
|
|
1191
1249
|
step_id: options.command,
|
|
1192
1250
|
cause: err instanceof Error ? err.message : String(err),
|
|
1193
1251
|
},
|
|
1194
|
-
}), definition,
|
|
1252
|
+
}), definition, unclaimEnvelopeWarnings.length > 0 ? unclaimEnvelopeWarnings : undefined);
|
|
1195
1253
|
}
|
|
1196
1254
|
// issue #197 PR-2 (design §2): the SAME predicate as the pre-claim pass, now over the
|
|
1197
1255
|
// complete post-claim set. Lifted to `adoptionPartition` (outer scope) — read again at the
|
|
@@ -1927,13 +1985,61 @@ export async function executeStep(store, definition, options) {
|
|
|
1927
1985
|
// Non-terminal: recompute the phase so the store-fail fallback below is correct too
|
|
1928
1986
|
// (on the happy path store.update recomputes it identically via deriveRunPhase).
|
|
1929
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).
|
|
1930
1996
|
let persistedBlockedRun;
|
|
1931
1997
|
let blockStoreWarning;
|
|
1932
|
-
|
|
1933
|
-
|
|
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
|
+
}
|
|
1934
2031
|
}
|
|
1935
|
-
|
|
1936
|
-
|
|
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;
|
|
1937
2043
|
}
|
|
1938
2044
|
// issue #207 PR-2 (D3 §5): NO WAL delete belongs on this capability-block settle path — the
|
|
1939
2045
|
// prior try/catch here was removed, not just gated. Contract-consistency hygiene, not a
|
|
@@ -1968,7 +2074,7 @@ export async function executeStep(store, definition, options) {
|
|
|
1968
2074
|
status: 'error',
|
|
1969
2075
|
data: {},
|
|
1970
2076
|
evidence: allEvidence,
|
|
1971
|
-
warnings: mergeWarnings(traceWarnings, blockStoreWarning),
|
|
2077
|
+
warnings: mergeWarnings(traceWarnings, blockStoreWarning, dormancyWarning),
|
|
1972
2078
|
errors: [dispatchError.message],
|
|
1973
2079
|
agent_action: blockedAction,
|
|
1974
2080
|
error_code: recoverableCode,
|
|
@@ -2322,37 +2428,21 @@ export async function executeStep(store, definition, options) {
|
|
|
2322
2428
|
resolvedGateMessage = raw;
|
|
2323
2429
|
}
|
|
2324
2430
|
const gateConfig = stepDef.gate;
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
? { resolution_messages: gateConfig.resolution_messages }
|
|
2341
|
-
: {}),
|
|
2342
|
-
},
|
|
2343
|
-
});
|
|
2344
|
-
}
|
|
2345
|
-
catch (err) {
|
|
2346
|
-
if (err instanceof WorkflowError) {
|
|
2347
|
-
return makeErrorEnvelope(options, pendingRun, err, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
|
|
2348
|
-
}
|
|
2349
|
-
return makeErrorEnvelope(options, pendingRun, new WorkflowError('Failed to open gate', {
|
|
2350
|
-
code: 'ENGINE_STORE_FAILED',
|
|
2351
|
-
category: 'ENGINE',
|
|
2352
|
-
agentAction: 'stop',
|
|
2353
|
-
retryable: false,
|
|
2354
|
-
}), definition, traceWarnings.length > 0 ? traceWarnings : undefined);
|
|
2355
|
-
}
|
|
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
|
+
};
|
|
2356
2446
|
// gate.display fallback chain: gate.message resolved → step.prompt resolved → absent
|
|
2357
2447
|
const resolvedGateDisplay = resolvedGateMessage !== undefined
|
|
2358
2448
|
? resolvedGateMessage
|
|
@@ -2370,19 +2460,155 @@ export async function executeStep(store, definition, options) {
|
|
|
2370
2460
|
...wfCtxSpreadEarly,
|
|
2371
2461
|
})
|
|
2372
2462
|
: undefined;
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
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: {
|
|
2379
2579
|
gate_id,
|
|
2380
|
-
|
|
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 },
|
|
2381
2588
|
},
|
|
2382
|
-
}
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
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
|
+
}
|
|
2386
2612
|
return {
|
|
2387
2613
|
command: options.command,
|
|
2388
2614
|
run_id: options.runId,
|
|
@@ -2390,11 +2616,11 @@ export async function executeStep(store, definition, options) {
|
|
|
2390
2616
|
status: 'confirm_required',
|
|
2391
2617
|
data: output,
|
|
2392
2618
|
evidence: allEvidence,
|
|
2393
|
-
warnings:
|
|
2619
|
+
warnings: mergeWarnings(traceWarnings, DORMANCY_ADVISORY),
|
|
2394
2620
|
errors: [],
|
|
2395
2621
|
context_hint: `Run is paused at gate '${gate_id}'. Available choices: ${choices.join(', ')}.`,
|
|
2396
2622
|
run_phase: gateRun.run_phase,
|
|
2397
|
-
next_actions: [
|
|
2623
|
+
next_actions: [buildGateNextAction(gate_id, choices, step_name)],
|
|
2398
2624
|
gate: {
|
|
2399
2625
|
gate_id,
|
|
2400
2626
|
step_name,
|
|
@@ -2687,6 +2913,39 @@ export async function executeStep(store, definition, options) {
|
|
|
2687
2913
|
* Submits a human response for a gate-waiting run.
|
|
2688
2914
|
* Validates the gate_id and choice, then moves the step to completed_steps.
|
|
2689
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
|
+
}
|
|
2690
2949
|
export async function submitHumanResponse(store, definition, options) {
|
|
2691
2950
|
// 1. Load run.
|
|
2692
2951
|
let run;
|
|
@@ -2704,6 +2963,227 @@ export async function submitHumanResponse(store, definition, options) {
|
|
|
2704
2963
|
});
|
|
2705
2964
|
return errorEnvelope('submit_gate', options.runId, 0, e);
|
|
2706
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) ---
|
|
2707
3187
|
// 1a. Defensive terminal guard (mirrors #91/#95): a late gate response must never re-drive a
|
|
2708
3188
|
// run that has already reached a terminal phase.
|
|
2709
3189
|
if (run.terminal_state) {
|
|
@@ -2834,7 +3314,9 @@ export async function submitHumanResponse(store, definition, options) {
|
|
|
2834
3314
|
// envelope flag (do NOT add an evidence scan to recompute it). Its disclosure surface is the
|
|
2835
3315
|
// run-level `defaulted_steps` marker below, plus whatever the gate-open envelope already
|
|
2836
3316
|
// warned the human with.
|
|
2837
|
-
|
|
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),
|
|
2838
3320
|
errors: [],
|
|
2839
3321
|
context_hint: orientation,
|
|
2840
3322
|
run_phase: savedRun.run_phase,
|
|
@@ -3307,15 +3789,242 @@ depth0Warnings) {
|
|
|
3307
3789
|
// Execute any eligible guard steps inline before looking for the next auto step.
|
|
3308
3790
|
// Guard steps are synchronous engine decisions — not returned to the agent.
|
|
3309
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 = [];
|
|
3310
3796
|
let guardEligible = findEligibleGuardSteps(definition, run);
|
|
3311
3797
|
while (guardEligible.length > 0) {
|
|
3312
3798
|
const guardName = guardEligible[0];
|
|
3313
3799
|
const guardStepDef = definition.steps[guardName];
|
|
3314
|
-
// 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.
|
|
3315
3802
|
const guardResult = await executeGuardStep(guardName, guardStepDef, definition, run);
|
|
3316
3803
|
// Capture the guard's OWN evidence (its last entry) BEFORE the finalizer drain appends
|
|
3317
3804
|
// finalizer evidence — the terminal return below surfaces only the guard's evidence.
|
|
3318
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) ---
|
|
3319
4028
|
// Blocking fix #1: classify the terminal outcome by the SEALED record, not aborted_at
|
|
3320
4029
|
// alone. executeGuardStep sets terminal_state in THREE cases — abort (aborted_at set),
|
|
3321
4030
|
// resolution-error (failed, no aborted_at), and a PASS that completes the run
|
|
@@ -3384,7 +4093,9 @@ depth0Warnings) {
|
|
|
3384
4093
|
data: {},
|
|
3385
4094
|
// The guard's own evidence entry, captured before the finalizer drain appended any.
|
|
3386
4095
|
evidence: guardOwnEvidence,
|
|
3387
|
-
|
|
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),
|
|
3388
4099
|
errors: [],
|
|
3389
4100
|
context_hint: contextHint,
|
|
3390
4101
|
run_phase: persistedGuardRun.run_phase,
|
|
@@ -3408,6 +4119,12 @@ depth0Warnings) {
|
|
|
3408
4119
|
...result,
|
|
3409
4120
|
run_version: run.version,
|
|
3410
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
|
+
: {}),
|
|
3411
4128
|
};
|
|
3412
4129
|
}
|
|
3413
4130
|
if (run.terminal_state || run.pending_gate !== undefined) {
|
|
@@ -3464,7 +4181,13 @@ export async function executeChain(store, definition, options) {
|
|
|
3464
4181
|
throw err;
|
|
3465
4182
|
}
|
|
3466
4183
|
}
|
|
3467
|
-
|
|
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);
|
|
3468
4191
|
return {
|
|
3469
4192
|
command: options.command,
|
|
3470
4193
|
run_id: options.runId,
|
|
@@ -3475,8 +4198,8 @@ export async function executeChain(store, definition, options) {
|
|
|
3475
4198
|
warnings: [],
|
|
3476
4199
|
errors: [],
|
|
3477
4200
|
agent_action: 'stop',
|
|
3478
|
-
context_hint: `Run '${options.runId}' is already terminal (${
|
|
3479
|
-
run_phase:
|
|
4201
|
+
context_hint: `Run '${options.runId}' is already terminal (${derivedPhase}); no steps executed.`,
|
|
4202
|
+
run_phase: derivedPhase,
|
|
3480
4203
|
next_actions: [],
|
|
3481
4204
|
};
|
|
3482
4205
|
}
|