@sensigo/realm 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/engine/claim-liveness.d.ts +59 -11
  2. package/dist/engine/claim-liveness.d.ts.map +1 -1
  3. package/dist/engine/claim-liveness.js +83 -19
  4. package/dist/engine/claim-liveness.js.map +1 -1
  5. package/dist/engine/eligibility.d.ts +13 -0
  6. package/dist/engine/eligibility.d.ts.map +1 -1
  7. package/dist/engine/eligibility.js +20 -8
  8. package/dist/engine/eligibility.js.map +1 -1
  9. package/dist/engine/execution-loop.d.ts +13 -0
  10. package/dist/engine/execution-loop.d.ts.map +1 -1
  11. package/dist/engine/execution-loop.js +496 -58
  12. package/dist/engine/execution-loop.js.map +1 -1
  13. package/dist/engine/reclaim-step.d.ts.map +1 -1
  14. package/dist/engine/reclaim-step.js +187 -12
  15. package/dist/engine/reclaim-step.js.map +1 -1
  16. package/dist/engine/trace-adoption.d.ts +53 -0
  17. package/dist/engine/trace-adoption.d.ts.map +1 -0
  18. package/dist/engine/trace-adoption.js +49 -0
  19. package/dist/engine/trace-adoption.js.map +1 -0
  20. package/dist/evidence/snapshot.d.ts +11 -1
  21. package/dist/evidence/snapshot.d.ts.map +1 -1
  22. package/dist/evidence/snapshot.js +1 -0
  23. package/dist/evidence/snapshot.js.map +1 -1
  24. package/dist/index.d.ts +8 -6
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +8 -5
  27. package/dist/index.js.map +1 -1
  28. package/dist/store/fs-io.d.ts +26 -0
  29. package/dist/store/fs-io.d.ts.map +1 -1
  30. package/dist/store/fs-io.js +37 -2
  31. package/dist/store/fs-io.js.map +1 -1
  32. package/dist/store/json-file-store.d.ts.map +1 -1
  33. package/dist/store/json-file-store.js +2 -5
  34. package/dist/store/json-file-store.js.map +1 -1
  35. package/dist/store/trace-buffer-store.d.ts +419 -7
  36. package/dist/store/trace-buffer-store.d.ts.map +1 -1
  37. package/dist/store/trace-buffer-store.js +401 -30
  38. package/dist/store/trace-buffer-store.js.map +1 -1
  39. package/dist/types/response-envelope.d.ts +20 -0
  40. package/dist/types/response-envelope.d.ts.map +1 -1
  41. package/dist/types/run-record.d.ts +60 -15
  42. package/dist/types/run-record.d.ts.map +1 -1
  43. package/dist/types/workflow-definition.d.ts +52 -7
  44. package/dist/types/workflow-definition.d.ts.map +1 -1
  45. package/dist/types/workflow-definition.js +15 -0
  46. package/dist/types/workflow-definition.js.map +1 -1
  47. package/dist/types/workflow-error.d.ts +1 -1
  48. package/dist/types/workflow-error.d.ts.map +1 -1
  49. package/dist/types/workflow-error.js.map +1 -1
  50. package/dist/workflow/diagnostics.d.ts +15 -6
  51. package/dist/workflow/diagnostics.d.ts.map +1 -1
  52. package/dist/workflow/diagnostics.js +16 -8
  53. package/dist/workflow/diagnostics.js.map +1 -1
  54. package/dist/workflow/yaml-loader.d.ts.map +1 -1
  55. package/dist/workflow/yaml-loader.js +108 -6
  56. package/dist/workflow/yaml-loader.js.map +1 -1
  57. package/package.json +1 -1
@@ -1,11 +1,13 @@
1
1
  import { extensionIdentityDiffers } from '../types/extension-identity.js';
2
2
  import { WorkflowError } from '../types/workflow-error.js';
3
3
  import { persistsField } from '../store/store-fidelity.js';
4
+ import { storeDeclaresSeal, storeDeclaresNonceCarriage } from '../store/trace-buffer-store.js';
5
+ import { partitionBufferedEntries } from './trace-adoption.js';
4
6
  import { captureEvidence } from '../evidence/snapshot.js';
5
7
  import { validateInputSchema, validateOutputSchema, validateTraceSchema, } from '../validation/input-schema.js';
6
8
  import { normalizeTrace } from './trace-normalizer.js';
7
9
  import { TERMINAL_PHASES, isTerminalPhase, DRAIN_CEILING_SECONDS } from './lifecycle.js';
8
- import { omitClaim, shouldEnforceTimeout, DEFAULT_EXECUTION_TIMEOUT_SECONDS, } from './claim-liveness.js';
10
+ import { omitClaim, shouldEnforceTimeout, DEFAULT_EXECUTION_TIMEOUT_SECONDS, resolveCapMs, sleepWouldExceedCap, } from './claim-liveness.js';
9
11
  import { computeBackoff } from './backoff.js';
10
12
  import { checkPreconditions, evaluateAllPreconditions, evaluateGuardConditions, } from './precondition.js';
11
13
  import { ExtensionRegistry } from '../extensions/registry.js';
@@ -34,7 +36,9 @@ function withTimeout(dispatch, ms, stepName) {
34
36
  category: 'ENGINE',
35
37
  agentAction: 'report_to_user',
36
38
  retryable: false,
37
- details: { stepName, timeout_ms: ms },
39
+ // issue #140: details gain `stepId` alongside the pre-existing `stepName` additive,
40
+ // never removes stepName (byte-identical shape for any existing consumer of that key).
41
+ details: { stepName, stepId: stepName, timeout_ms: ms },
38
42
  }));
39
43
  }, ms);
40
44
  });
@@ -348,13 +352,75 @@ export function buildNextActions(definition, run) {
348
352
  .map((name) => stepToNextAction(name, definition.steps[name], context));
349
353
  }
350
354
  /**
351
- * Merges call-scoped trace-schema warnings with an optional cleanup warning into
352
- * a single warnings array. Trace warnings are listed first (deterministic order).
355
+ * Merges call-scoped trace-schema warnings with any number of optional extra warnings into a
356
+ * single warnings array. Trace warnings are listed first (deterministic order); `extraWarnings`
357
+ * entries are appended in call order, `undefined` entries skipped. Variadic since issue #207
358
+ * PR-2 (the success-settle path now has TWO independent optional warnings to merge — a
359
+ * handler-level warning and a WAL-cleanup warning — where every earlier call site had at most
360
+ * one).
353
361
  */
354
- function mergeWarnings(traceWarnings, cleanupWarning) {
355
- if (traceWarnings.length === 0 && cleanupWarning === undefined)
362
+ function mergeWarnings(traceWarnings, ...extraWarnings) {
363
+ const defined = extraWarnings.filter((w) => w !== undefined);
364
+ if (traceWarnings.length === 0 && defined.length === 0)
356
365
  return [];
357
- return cleanupWarning !== undefined ? [...traceWarnings, cleanupWarning] : [...traceWarnings];
366
+ return [...traceWarnings, ...defined];
367
+ }
368
+ /**
369
+ * Compensating un-claim (issue #207 PR-2, D3 §5): built from `pendingRun` — the record OUR OWN
370
+ * `claimStep` call returned, never a fresh get — removing the step from `in_progress_steps` AND
371
+ * `claims[step]` in the SAME mutation (the settle-site invariant every other settle path in this
372
+ * file upholds), plus an audit-evidence entry. The caller CAS's this against `pendingRun.version`
373
+ * (by passing the returned record straight to `store.update`): any intervening write (a
374
+ * concurrent settle, reclaim, or second claim) bumps version, so this compensating un-claim can
375
+ * never stomp it — a CAS mismatch means some other actor already resolved the claim, and the
376
+ * caller must stop immediately rather than retry, leaving the claim exactly as that actor left
377
+ * it. A step already absent from `in_progress_steps` (should not happen here, but the filter is
378
+ * naturally idempotent) is simply a no-op mutation, not a special case.
379
+ */
380
+ function buildCompensatingUnclaim(pendingRun, stepName, now) {
381
+ const auditEvidence = captureEvidence({
382
+ stepId: stepName,
383
+ startedAt: now,
384
+ completedAt: now,
385
+ input: {},
386
+ output: {
387
+ compensating_unclaim: true,
388
+ reason: 'adoption-read failure after claim',
389
+ unclaimed_at: now.toISOString(),
390
+ },
391
+ });
392
+ return {
393
+ ...pendingRun,
394
+ in_progress_steps: pendingRun.in_progress_steps.filter((s) => s !== stepName),
395
+ claims: omitClaim(pendingRun.claims, stepName),
396
+ evidence: [...pendingRun.evidence, auditEvidence],
397
+ };
398
+ }
399
+ /**
400
+ * Guard for the settle-time seal attempt (issue #197 PR-2, deliverable 1f) — ONE lock-free
401
+ * `store.get` re-verifying the run exists and this step has actually LEFT `in_progress_steps`
402
+ * (i.e. our own settling `store.update` already landed) — the "purge-guard shape" (mirrors
403
+ * #184's terminal-re-verify-under-lock precedent). In the normal case this always passes: by the
404
+ * time either settle site calls `sealFenced`, the settling update has already committed
405
+ * synchronously just above it. A run genuinely gone (e.g. concurrently purged) surfaces as
406
+ * `store.get`'s own typed `STATE_RUN_NOT_FOUND` throw — deliberately NOT special-cased here; it
407
+ * propagates as an ordinary guard THROW, which the caller's uniform "a throw ⇒ warn + skip the
408
+ * delete" handling already covers correctly (residue-not-loss either way).
409
+ */
410
+ function buildSettleSealGuard(store, runId, stepName) {
411
+ return async () => {
412
+ const fresh = await store.get(runId);
413
+ if (fresh.in_progress_steps.includes(stepName)) {
414
+ throw new WorkflowError(`Refusing to seal trace buffer for run '${runId}' step '${stepName}': the step is still ` +
415
+ 'in_progress (the settling update has not yet landed) — residue-not-loss, the live WAL ' +
416
+ 'is left intact.', {
417
+ code: 'STATE_STEP_PENDING',
418
+ category: 'STATE',
419
+ agentAction: 'report_to_user',
420
+ retryable: true,
421
+ });
422
+ }
423
+ };
358
424
  }
359
425
  /**
360
426
  * Issue #185 Fix 1 (budget-priority): builds the merged, canonicalized trace for an agent step,
@@ -617,17 +683,57 @@ export async function executeStep(store, definition, options) {
617
683
  let preNormalizedTrace;
618
684
  let walEntries = [];
619
685
  let preClaimSchemaResult;
686
+ // issue #197 PR-2 (design §3, the activation gate): computed once — options.traceBufferStore
687
+ // is invariant for this whole call. `carriageActive` also gates whether the NEW
688
+ // adopted_own/adopted_anonymous/preserved_foreign fields are surfaced on the 'ok' envelope
689
+ // (absent entirely on a bare-floor store, never just zeroed) — see the settle-site comment.
690
+ const carriageActive = options.traceBufferStore !== undefined && storeDeclaresNonceCarriage(options.traceBufferStore);
691
+ const effectiveClaimantNonce = carriageActive ? options.writerNonce : undefined;
692
+ // Post-claim adoption partition (issue #197 PR-2) — lifted to this outer scope because it is
693
+ // read again at the settle sites (seal-vs-delete decision) and at 'ok' envelope build, both far
694
+ // below the post-claim block that computes it. `undefined` for a non-agent step (never computed
695
+ // there) — the settle sites and envelope build both treat that as "nothing to preserve/report".
696
+ let adoptionPartition;
620
697
  if (stepDef?.execution === 'agent') {
621
- walEntries =
622
- options.traceBufferStore !== undefined
623
- ? await options.traceBufferStore.read(options.runId, options.command)
624
- : [];
698
+ // issue #207 PR-2 (D3 §5): wrapped — a rejection here (e.g. lock contention under a fenced
699
+ // trio's serialized reads) happens BEFORE claimStep, so no claim exists to compensate for;
700
+ // return a typed, retryable envelope instead of letting the read throw uncaught.
701
+ try {
702
+ walEntries =
703
+ options.traceBufferStore !== undefined
704
+ ? await options.traceBufferStore.read(options.runId, options.command)
705
+ : [];
706
+ }
707
+ catch (err) {
708
+ return makeErrorEnvelope(options, run, new WorkflowError('Failed to read trace buffer before claiming step', {
709
+ code: 'ENGINE_STORE_FAILED',
710
+ category: 'ENGINE',
711
+ agentAction: 'stop',
712
+ retryable: true,
713
+ details: {
714
+ step_id: options.command,
715
+ cause: err instanceof Error ? err.message : String(err),
716
+ },
717
+ }), definition);
718
+ }
719
+ // issue #197 PR-2 (design §3, missing-leg advisory): a caller-supplied nonce this store
720
+ // cannot carry is IGNORED for adoption purposes (carriageActive is false) — loudly, once per
721
+ // call, so a minting client discovers the silent floor rather than assuming attribution.
722
+ if (options.writerNonce !== undefined && !carriageActive) {
723
+ traceWarnings.push('writer_nonce ignored: the trace-buffer store does not declare writer_nonce_carriage — ' +
724
+ 'adoption falls back to the honest floor');
725
+ }
625
726
  const hasAnyTrace = walEntries.length > 0 || (options.trace !== undefined && options.trace.length > 0);
626
727
  if (hasAnyTrace) {
728
+ // issue #197 PR-2 (design §2, ADOPTION_CONGRUENCE): the pre-claim enforce-gate validates
729
+ // ONLY the adopted subset — a foreign-nonce-only WAL must never gate a (nonced) claimant.
730
+ // Congruence with the post-claim partition below is mandatory: both call the SAME
731
+ // `partitionBufferedEntries` helper against the SAME `effectiveClaimantNonce`.
732
+ const prClaimPartition = partitionBufferedEntries(walEntries, effectiveClaimantNonce);
627
733
  // issue #185 Fix 1: budget-priority merge (see buildPriorityMergedTrace's own doc) — this
628
734
  // pre-claim pass exists only to feed validateTraceSchema below; its RESULT is discarded
629
735
  // (not stored into the outer preNormalizedTrace) once the enforce-gate decision is made.
630
- const preClaimNormalized = buildPriorityMergedTrace(walEntries, options.trace);
736
+ const preClaimNormalized = buildPriorityMergedTrace(prClaimPartition.adopted, options.trace);
631
737
  // Validate trace schema if configured (unchanged call site).
632
738
  if (stepDef.trace_schema !== undefined) {
633
739
  const mode = stepDef.trace_validation_mode ?? 'warn';
@@ -709,14 +815,51 @@ export async function executeStep(store, definition, options) {
709
815
  // complete, post-claim set, which is also what the captureEvidence call site further below
710
816
  // keys its `stepDef?.execution === 'agent' && walEntries.length > 0` check on.
711
817
  if (stepDef?.execution === 'agent') {
712
- walEntries =
713
- options.traceBufferStore !== undefined
714
- ? await options.traceBufferStore.read(options.runId, options.command)
715
- : [];
818
+ // issue #207 PR-2 (D3 §5): wrapped — a rejection here happens AFTER claimStep, so a claim IS
819
+ // outstanding. COMPENSATING UN-CLAIM: built from `pendingRun` (our own claimStep result,
820
+ // never a fresh get) and CAS'd against `pendingRun.version` — an intervening write (someone
821
+ // else already resolved this claim) makes the CAS fail with STATE_SNAPSHOT_MISMATCH, in
822
+ // which case we stop immediately and leave the claim exactly as it is. Either way (compensated
823
+ // or left in place), the caller always gets the same typed retryable envelope — see
824
+ // buildCompensatingUnclaim's own doc for the full contract.
825
+ try {
826
+ walEntries =
827
+ options.traceBufferStore !== undefined
828
+ ? await options.traceBufferStore.read(options.runId, options.command)
829
+ : [];
830
+ }
831
+ catch (err) {
832
+ try {
833
+ await store.update(buildCompensatingUnclaim(pendingRun, options.command, new Date()));
834
+ }
835
+ catch {
836
+ // CAS mismatch (someone else already resolved the claim) or any other failure to even
837
+ // un-claim: stop immediately, leave the claim exactly as it is — never retry here.
838
+ }
839
+ return makeErrorEnvelope(options, pendingRun, new WorkflowError('Failed to read trace buffer after claiming step', {
840
+ code: 'ENGINE_STORE_FAILED',
841
+ category: 'ENGINE',
842
+ agentAction: 'stop',
843
+ retryable: true,
844
+ details: {
845
+ step_id: options.command,
846
+ cause: err instanceof Error ? err.message : String(err),
847
+ },
848
+ }), definition, traceWarnings.length > 0 ? traceWarnings : undefined);
849
+ }
850
+ // issue #197 PR-2 (design §2): the SAME predicate as the pre-claim pass, now over the
851
+ // complete post-claim set. Lifted to `adoptionPartition` (outer scope) — read again at the
852
+ // settle sites (seal-vs-delete) and at 'ok' envelope build, both below this block.
853
+ adoptionPartition = partitionBufferedEntries(walEntries, effectiveClaimantNonce);
854
+ // issue #197 PR-2 (design §2, the "keying pin"): this condition stays keyed on the FULL
855
+ // post-claim WAL set, NOT the adopted subset — a foreign-only WAL (adoptionPartition.adopted
856
+ // empty) with no options.trace must still enter this block so foreign_lines_preserved below
857
+ // is captured into trace_summary; otherwise that count is silently lost.
716
858
  if (walEntries.length > 0 || (options.trace !== undefined && options.trace.length > 0)) {
717
859
  // issue #185 Fix 1: same budget-priority merge as the pre-claim pass, now over the
718
- // complete post-claim setthis is the value captured into evidence.
719
- preNormalizedTrace = buildPriorityMergedTrace(walEntries, options.trace);
860
+ // complete post-claim ADOPTED subset only a foreign line never reaches canonical
861
+ // evidence (issue #197 PR-2, design §2).
862
+ preNormalizedTrace = buildPriorityMergedTrace(adoptionPartition.adopted, options.trace);
720
863
  // Carry over the enforce-gate's schema-validation result (computed pre-claim against a
721
864
  // possibly-incomplete set) rather than re-validating here — Fix 2 deliberately keeps
722
865
  // validation pre-claim (see that block's comment); this just republishes its verdict onto
@@ -726,13 +869,48 @@ export async function executeStep(store, definition, options) {
726
869
  preNormalizedTrace.summary.validation_mode = preClaimSchemaResult.validation_mode;
727
870
  preNormalizedTrace.summary.validation_errors = preClaimSchemaResult.validation_errors;
728
871
  }
729
- // issue #185 Fix 3: the honest label. Any buffer/WAL line adopted here is NOT attributable
730
- // to this execution the agent trace buffer has no single owner, so an adopted line may
731
- // originate from a prior attempt (e.g. a crashed run this one resumed) or a concurrent one
732
- // racing on the same (run, step). Present only when buffer lines were actually adopted;
733
- // an options.trace-only execution carries no caveat.
734
- if (walEntries.length > 0) {
735
- preNormalizedTrace.summary.buffered_lines_adopted = walEntries.length;
872
+ // issue #185 Fix 3 / issue #197 PR-2 (design §2/§6): the three-way honest split.
873
+ // buffered_lines_adopted now counts ONLY the adopted-ANONYMOUS entries (bare-adopted by a
874
+ // claimant) for all-bare traffic this is numerically IDENTICAL to before #197 (every
875
+ // adopted line was, and still is, bare). attributed_lines_adopted counts own-nonce
876
+ // adoptions NO caveat (design §6 wording, verbatim below). foreign_lines_preserved counts
877
+ // lines from a different writer, preserved (sealed where supported) but never adopted —
878
+ // its accompanying pointer warning is the only way an agent learns to retrieve them.
879
+ if (adoptionPartition.adopted_anonymous > 0) {
880
+ preNormalizedTrace.summary.buffered_lines_adopted = adoptionPartition.adopted_anonymous;
881
+ }
882
+ if (adoptionPartition.adopted_own > 0) {
883
+ preNormalizedTrace.summary.attributed_lines_adopted = adoptionPartition.adopted_own;
884
+ }
885
+ if (adoptionPartition.preserved_foreign > 0) {
886
+ preNormalizedTrace.summary.foreign_lines_preserved = adoptionPartition.preserved_foreign;
887
+ traceWarnings.push(`${adoptionPartition.preserved_foreign} buffered line(s) from a different writer were ` +
888
+ 'preserved, not adopted — retrieve via `realm run export`');
889
+ }
890
+ // issue #197 PR-2 (design §6, the half-minted advisory): signature heuristics over the RAW
891
+ // walEntries/foreign set (never the adopted subset — these two cases are both about
892
+ // content this claimant did NOT adopt), neutral phrasing, values never echoed. Mutually
893
+ // exclusive by claimant type (nonced vs ⊥), so an if/else-if is exact, not a simplification.
894
+ if (effectiveClaimantNonce !== undefined &&
895
+ walEntries.length > 0 &&
896
+ walEntries.every((e) => e._nonce === undefined)) {
897
+ // A nonced claimant found nothing but bare lines — if those are this SAME attempt's own
898
+ // earlier append() calls, the client minted inconsistently (nonce on execute_step but not
899
+ // on the preceding append_trace calls, or vice versa).
900
+ traceWarnings.push(`the ${walEntries.length} buffered line(s) were bare and were preserved, not adopted — ` +
901
+ 'if they are yours from this same attempt, you minted inconsistently (mint on both ' +
902
+ 'calls, or neither)');
903
+ }
904
+ else if (effectiveClaimantNonce === undefined && adoptionPartition.foreign.length > 0) {
905
+ const distinctForeignNonces = new Set(adoptionPartition.foreign.map((e) => e._nonce));
906
+ if (distinctForeignNonces.size === 1) {
907
+ // A bare claimant found every foreign line under exactly ONE other nonce — if that
908
+ // nonce is this SAME attempt's own (minted on append_trace but the execute_step call
909
+ // stayed bare), the client minted inconsistently.
910
+ traceWarnings.push(`${adoptionPartition.foreign.length} line(s) under a different writer_nonce were ` +
911
+ 'preserved, not adopted — if these are yours from this same attempt, you minted ' +
912
+ 'inconsistently');
913
+ }
736
914
  }
737
915
  }
738
916
  }
@@ -819,14 +997,58 @@ export async function executeStep(store, definition, options) {
819
997
  // A3: every `execution: 'auto'` step is bounded — authored timeout_seconds if declared, else the
820
998
  // generous DEFAULT_EXECUTION_TIMEOUT_SECONDS default. Resolved ONCE here (before the retry loop
821
999
  // below), not per-attempt. Agent/guard steps (shouldEnforceTimeout false) are untouched: agent
822
- // dispatch stays the instant-return no-op it always was, never wrapped in withTimeout.
823
- // effectiveTimeoutSeconds is the single source of truth; timeoutMs is derived from it so the
824
- // two can never diverge. It is also surfaced onto the evidence snapshot below.
1000
+ // dispatch stays the instant-return no-op it always was, never wrapped in withTimeout — this
1001
+ // A3 invariant is preserved verbatim by issue #140 below (the finalizer-drain withTimeout at
1002
+ // buildFinalizedSeal is a separate, DRAIN_CEILING-bounded wrap, outside this cap's scope).
1003
+ // effectiveTimeoutSeconds is the single source of truth for the step's OWN declared/default
1004
+ // per-attempt bound; timeoutMs is derived from it so the two can never diverge.
1005
+ //
1006
+ // Issue #140 (retryable timeout + total-time cap): capMs/capStart/capExhausted resolve ONCE
1007
+ // here too (same as timeoutMs), gated on `enforceTimeout && retryConfig !== undefined` — every
1008
+ // retry-configured auto step now gets a default total-time cap (resolveCapMs), whether or not it
1009
+ // opts into `retry.on_timeout`. What is NOT resolved once anymore is the PER-ATTEMPT bound
1010
+ // actually passed to withTimeout: each attempt clips to whatever budget remains (see
1011
+ // `effectiveMs` inside the loop below) — a later attempt's evidence `effective_timeout_seconds`
1012
+ // can therefore be SMALLER than this outer `effectiveTimeoutSeconds` once the cap starts biting.
1013
+ //
1014
+ // Zombie-stacking split (A3 caveat, sharpened by #140's `on_timeout` in-place retry): a timeout
1015
+ // frees the RUNNER, not the work — `withTimeout` races the dispatch against a timer and moves on
1016
+ // the instant the timer wins, but does not stop a handler/adapter that ignores the abort signal
1017
+ // (or a remote server still processing an already-aborted request). Whether an abandoned call
1018
+ // can STACK behind a subsequent retry attempt splits on the handler's own shape: a SYNCHRONOUS
1019
+ // (blocking) handler can never stack one — it monopolizes the event loop, so nothing else
1020
+ // (including the next attempt's own dispatch) can even begin until it returns or the process is
1021
+ // killed. An ASYNCHRONOUS handler that ignores its abort signal CAN stack: each timed-out-and-
1022
+ // retried attempt leaves its own abandoned promise running, so up to `max_attempts − 1` zombies
1023
+ // can accumulate behind the one currently-live attempt (attempts 1..max_attempts−1 each
1024
+ // potentially zombie-and-retry; the final attempt is the "+1" that is never itself abandoned by
1025
+ // this loop). This was always true pre-#140 for a step whose retry consumed a NORMAL retryable
1026
+ // error mid-flight of a slow-but-not-yet-timed-out prior attempt; #140 sharpens it because
1027
+ // `on_timeout` now lets a STEP_TIMEOUT itself mint a retry, so a maximally adversarial handler
1028
+ // can produce the full max_attempts−1 zombie count from timeouts alone. The zombie count stays
1029
+ // cap-BOUNDED (the total-time cap bounds how many attempts the ENGINE'S retry loop can mint —
1030
+ // it does not, and cannot, reach into an already-abandoned zombie running on a remote server
1031
+ // outside realm's control).
825
1032
  const enforceTimeout = stepDef !== undefined && shouldEnforceTimeout(stepDef);
826
1033
  const effectiveTimeoutSeconds = enforceTimeout
827
1034
  ? (stepDef.timeout_seconds ?? DEFAULT_EXECUTION_TIMEOUT_SECONDS)
828
1035
  : undefined;
829
1036
  const timeoutMs = effectiveTimeoutSeconds !== undefined ? effectiveTimeoutSeconds * 1000 : undefined;
1037
+ const capMs = enforceTimeout && retryConfig !== undefined ? resolveCapMs(retryConfig, timeoutMs) : undefined;
1038
+ const capStart = Date.now(); // wall-clock — the SAME clock the claim horizon is measured against
1039
+ let capExhausted = false;
1040
+ const remainingMs = () => capMs - (Date.now() - capStart);
1041
+ // Programmatic-gate advisory (#119-preserving): the loader refuses `on_timeout: true` without
1042
+ // `idempotent: true` at load (E1) — but a hand-built WorkflowDefinition (a custom embedder, or a
1043
+ // test) can bypass the loader entirely. Surface the same rule here, at the engine surface, as a
1044
+ // pure advisory (never gates, never changes behavior — the willRetry conjunct below already
1045
+ // requires `idempotent === true` independently). Threaded through `traceWarnings` so it reaches
1046
+ // every settle path this function has, INCLUDING the handler-abort return path below (which
1047
+ // otherwise hardcodes `warnings: []`).
1048
+ if (stepDef !== undefined && retryConfig?.on_timeout === true && stepDef.idempotent !== true) {
1049
+ traceWarnings.push(`retry.on_timeout ignored: step '${options.command}' is not declared idempotent — declare ` +
1050
+ `both 'idempotent: true' and 'retry.on_timeout: true'; YAML workflows are refused at load.`);
1051
+ }
830
1052
  // Create a stable rate-limiter registry for all retry attempts of this step.
831
1053
  // Shared state ensures that a pause() triggered on attempt N is still in effect
832
1054
  // when the proactive acquire() runs on attempt N+1. When the caller provides an
@@ -839,11 +1061,34 @@ export async function executeStep(store, definition, options) {
839
1061
  const allEvidence = [];
840
1062
  let currentWarn;
841
1063
  for (let attemptNum = 1; attemptNum <= maxAttempts; attemptNum++) {
1064
+ // SITE (a) — issue #140, loop-top, BEFORE attemptsUsed is assigned: attempt 1 ALWAYS
1065
+ // proceeds regardless of capMs (the `attemptNum > 1` conjunct) — this guard exists solely for
1066
+ // the clock-anomaly window between `capStart` above and here (a suspend/resume or NTP forward
1067
+ // jump), never to gate the very first attempt.
1068
+ if (capMs !== undefined && attemptNum > 1 && remainingMs() <= 0) {
1069
+ capExhausted = true;
1070
+ break;
1071
+ }
842
1072
  attemptsUsed = attemptNum;
843
1073
  const startedAt = new Date();
844
1074
  let attemptOutput = {};
845
1075
  let attemptError = null;
846
1076
  let resolvedParams;
1077
+ // Per-attempt effective timeout (issue #140): uniform full-clip to whatever cap budget
1078
+ // remains. Clip floor `max(0, remainingMs())` ensures a clock anomaly (see SITE (a) above)
1079
+ // never passes a negative ms to withTimeout. capMs undefined ⇒ effectiveMs === timeoutMs,
1080
+ // byte-identical to pre-#140 behavior (every non-retry-configured, or unopted-uncapped-by-
1081
+ // total_timeout_seconds-being-absent-pre-amendment, auto step). `clippedToMs` records the
1082
+ // per-attempt evidence value ONLY when the cap actually reduced the bound below the step's
1083
+ // own declared/default timeout — never on an uncapped or not-yet-biting attempt.
1084
+ const effectiveMs = timeoutMs !== undefined
1085
+ ? capMs !== undefined
1086
+ ? Math.min(timeoutMs, Math.max(0, remainingMs()))
1087
+ : timeoutMs
1088
+ : undefined;
1089
+ const clippedToMs = capMs !== undefined && effectiveMs !== undefined && effectiveMs < timeoutMs
1090
+ ? effectiveMs
1091
+ : undefined;
847
1092
  try {
848
1093
  const makeCall = (signal) => {
849
1094
  if (stepDef?.execution === 'auto' && stepDef.uses_service !== undefined) {
@@ -874,8 +1119,8 @@ export async function executeStep(store, definition, options) {
874
1119
  .then((result) => ({ output: result, resolvedParams: undefined }));
875
1120
  }
876
1121
  };
877
- const callResult = timeoutMs !== undefined
878
- ? await withTimeout((signal) => makeCall(signal), timeoutMs, options.command)
1122
+ const callResult = effectiveMs !== undefined
1123
+ ? await withTimeout((signal) => makeCall(signal), effectiveMs, options.command)
879
1124
  : await makeCall();
880
1125
  // Handle graceful abort from a handler returning { abort: { message } }.
881
1126
  if (callResult.handlerAbort !== undefined) {
@@ -938,7 +1183,9 @@ export async function executeStep(store, definition, options) {
938
1183
  status: 'ok',
939
1184
  data: {},
940
1185
  evidence: [abortEvidence],
941
- warnings: [],
1186
+ // Issue #140: was hardcoded `[]` — now threads traceWarnings (e.g. the programmatic
1187
+ // on_timeout/idempotent gate advisory above) so it survives this settle path too.
1188
+ warnings: mergeWarnings(traceWarnings),
942
1189
  errors: [],
943
1190
  context_hint: `Handler step '${options.command}' aborted the run: ${abortMessage}`,
944
1191
  run_phase: (persistedAbortRun ?? abortedRun).run_phase,
@@ -990,7 +1237,11 @@ export async function executeStep(store, definition, options) {
990
1237
  ...(options.stepMeta?.toolCalls !== undefined
991
1238
  ? { toolCalls: options.stepMeta.toolCalls }
992
1239
  : {}),
993
- ...(effectiveTimeoutSeconds !== undefined ? { effectiveTimeoutSeconds } : {}),
1240
+ // issue #140: PER-ATTEMPT value (may be smaller than the outer effectiveTimeoutSeconds once
1241
+ // the cap starts clipping) — byte-identical to the pre-#140 outer value whenever capMs is
1242
+ // undefined or hasn't bitten yet.
1243
+ ...(effectiveMs !== undefined ? { effectiveTimeoutSeconds: effectiveMs / 1000 } : {}),
1244
+ ...(clippedToMs !== undefined ? { clippedToMs } : {}),
994
1245
  // Gate trace to agent steps only — drop silently for auto/adapter/handler steps.
995
1246
  // When pre-normalized (WAL merge + schema validation ran), pass the pre-normalized
996
1247
  // result to avoid double normalization. Also handle WAL-only case (options.trace may
@@ -1009,24 +1260,68 @@ export async function executeStep(store, definition, options) {
1009
1260
  break;
1010
1261
  }
1011
1262
  dispatchError = attemptError;
1012
- const willRetry = retryConfig !== undefined && attemptError.retryable && attemptNum < maxAttempts;
1263
+ // SITE (b) issue #140, post-attempt, AFTER dispatchError is set, BEFORE willRetry: the
1264
+ // primary capExhausted setter (site (a) above only catches the loop-top clock-anomaly window;
1265
+ // site (c) below re-checks once more right before an actual sleep). Fires on ANY dispatch
1266
+ // error once the cap is spent, not just STEP_TIMEOUT — the cap bounds the step's total
1267
+ // budget, regardless of which error exhausted it.
1268
+ if (capMs !== undefined && remainingMs() <= 0) {
1269
+ capExhausted = true;
1270
+ }
1271
+ const willRetry = (retryConfig !== undefined && attemptError.retryable && attemptNum < maxAttempts) ||
1272
+ // issue #140 (AMENDED): a STEP_TIMEOUT may ALSO retry in place when the step opted in via
1273
+ // `retry.on_timeout: true` AND attested `idempotent: true` (the concurrency-safety gate).
1274
+ // ALL SIX conjuncts are required; `capMs !== undefined` enforces opted⇒capped
1275
+ // structurally — this disjunct is inert off the enforced auto class even for a hand-built
1276
+ // definition bypassing the loader's E1 gate on a non-auto step, since capMs is undefined
1277
+ // there (shouldEnforceTimeout false ⇒ enforceTimeout false ⇒ capMs undefined) — see R11 in
1278
+ // the design record.
1279
+ (attemptError.code === 'STEP_TIMEOUT' &&
1280
+ capMs !== undefined &&
1281
+ retryConfig?.on_timeout === true &&
1282
+ stepDef.idempotent === true &&
1283
+ !capExhausted &&
1284
+ attemptNum < maxAttempts);
1013
1285
  if (willRetry) {
1014
1286
  const baseBackoff = computeBackoff(retryConfig, attemptNum);
1015
1287
  const retryAfterMs = attemptError instanceof WorkflowError && attemptError.retry_after !== undefined
1016
1288
  ? attemptError.retry_after * 1000
1017
1289
  : 0;
1018
- await delayMs(Math.max(baseBackoff, retryAfterMs));
1290
+ const waitMs = Math.max(baseBackoff, retryAfterMs);
1291
+ // SITE (c) — issue #140, sleep guard, BEFORE every backoff/retry_after sleep (`>=`: an
1292
+ // exact-fit sleep is doomed too — never sleep into a wall). dispatchError already holds the
1293
+ // ACTUAL last error (e.g. a 429 with retry_after in its details); the post-loop wrap gate
1294
+ // below decides whether/how to wrap it — this site only decides whether to sleep at all.
1295
+ if (capMs !== undefined && sleepWouldExceedCap(Date.now() - capStart, waitMs, capMs)) {
1296
+ capExhausted = true;
1297
+ break;
1298
+ }
1299
+ await delayMs(waitMs);
1019
1300
  }
1020
1301
  else {
1021
1302
  break;
1022
1303
  }
1023
1304
  }
1024
- if (dispatchError !== null && retryConfig !== undefined && attemptsUsed === maxAttempts) {
1305
+ if (dispatchError !== null &&
1306
+ retryConfig !== undefined &&
1307
+ (attemptsUsed === maxAttempts || capExhausted)) {
1025
1308
  const lastError = dispatchError;
1309
+ // issue #140: stamp the discriminator on the LAST evidence snapshot regardless of whether the
1310
+ // #134 carve-out below actually wraps dispatchError — the carve-out's recoverable settle path
1311
+ // (Step 5) never wraps, so this evidence stamp is the ONLY durable record of *why* the step
1312
+ // stopped retrying in that case. `exhausted_by: 'total_timeout'` wins the both-true tie (a
1313
+ // step whose LAST attempt both used its final slot and drained the cap is reported as
1314
+ // cap-caused — the more actionable of the two labels for an operator).
1315
+ const lastSnap = allEvidence[allEvidence.length - 1];
1316
+ if (lastSnap !== undefined) {
1317
+ lastSnap.exhausted_by = capExhausted ? 'total_timeout' : 'attempts';
1318
+ }
1026
1319
  // #134: do NOT wrap a recoverable-incapability error (a max_attempts:1 not-registered failure
1027
1320
  // hits attemptsUsed === maxAttempts). The STEP_RETRY_EXHAUSTED wrap discards the inner code, which
1028
1321
  // would rob Step 5 of the discriminator it needs to settle recoverably. Leave dispatchError as the
1029
- // original not-registered error; all other codes wrap unchanged.
1322
+ // original not-registered error; all other codes wrap unchanged. This carve-out guards BOTH
1323
+ // disjuncts above (attempts-exhaustion and cap-exhaustion alike) — a capped-but-not-registered
1324
+ // step never wraps into STEP_RETRY_EXHAUSTED either.
1030
1325
  const isRecoverableIncapability = lastError instanceof WorkflowError &&
1031
1326
  (lastError.code === 'ENGINE_HANDLER_NOT_REGISTERED' ||
1032
1327
  lastError.code === 'ENGINE_ADAPTER_NOT_REGISTERED');
@@ -1040,6 +1335,7 @@ export async function executeStep(store, definition, options) {
1040
1335
  stepName: options.command,
1041
1336
  attempts: attemptsUsed,
1042
1337
  lastError: lastError.message,
1338
+ exhausted_by: capExhausted ? 'total_timeout' : 'attempts',
1043
1339
  ...(lastError.retry_after !== undefined ? { retry_after: lastError.retry_after } : {}),
1044
1340
  },
1045
1341
  });
@@ -1092,14 +1388,15 @@ export async function executeStep(store, definition, options) {
1092
1388
  catch (storeErr) {
1093
1389
  blockStoreWarning = `Failed to persist capability block: ${storeErr instanceof Error ? storeErr.message : String(storeErr)}`;
1094
1390
  }
1095
- let blockWalWarning;
1096
- try {
1097
- // Delete WAL after run state is written the step's entries are now in evidence.
1098
- await options.traceBufferStore?.delete(options.runId, options.command);
1099
- }
1100
- catch (walErr) {
1101
- blockWalWarning = `Failed to clean up trace buffer after capability block: ${walErr instanceof Error ? walErr.message : String(walErr)}`;
1102
- }
1391
+ // issue #207 PR-2 (D3 §5): NO WAL delete belongs on this capability-block settle path — the
1392
+ // prior try/catch here was removed, not just gated. Contract-consistency hygiene, not a
1393
+ // functional fix: capability-block is reachable ONLY for `execution: 'auto'` steps
1394
+ // (ENGINE_HANDLER_NOT_REGISTERED/ENGINE_ADAPTER_NOT_REGISTERED mint only in the
1395
+ // auto-dispatch branches), and a WAL only ever exists for `execution: 'agent'` steps
1396
+ // (`append_trace` refuses non-agent steps) — DISJOINT populations; there is no in-repo
1397
+ // blocked-path WAL to clean up. A custom embedder whose dispatcher throws NOT_REGISTERED
1398
+ // for an agent step would leave WAL residue reaped at purge (an enumerated, accepted
1399
+ // residue class — D3 §8 residual 6), never silently destroyed here.
1103
1400
  // Non-terminal 'stop' → 'report_to_user' via the existing mapping: a human must provision the
1104
1401
  // runner (or re-run on a capable one); no further progress is possible on THIS runner.
1105
1402
  const blockedAction = resolvePostDispatchAgentAction(dispatchError, false);
@@ -1124,7 +1421,7 @@ export async function executeStep(store, definition, options) {
1124
1421
  status: 'error',
1125
1422
  data: {},
1126
1423
  evidence: allEvidence,
1127
- warnings: mergeWarnings(traceWarnings, blockStoreWarning ?? blockWalWarning),
1424
+ warnings: mergeWarnings(traceWarnings, blockStoreWarning),
1128
1425
  errors: [dispatchError.message],
1129
1426
  agent_action: blockedAction,
1130
1427
  error_code: recoverableCode,
@@ -1179,12 +1476,60 @@ export async function executeStep(store, definition, options) {
1179
1476
  storeCleanupWarning = `Failed to persist step failure: ${storeErr instanceof Error ? storeErr.message : String(storeErr)}`;
1180
1477
  }
1181
1478
  let walCleanupWarning;
1182
- try {
1183
- // Delete WAL after run state is written for failure entries are now in evidence.
1184
- await options.traceBufferStore?.delete(options.runId, options.command);
1185
- }
1186
- catch (walErr) {
1187
- walCleanupWarning = `Failed to clean up trace buffer after step failure: ${walErr instanceof Error ? walErr.message : String(walErr)}`;
1479
+ // issue #207 PR-2 (D3 §5): gate the WAL cleanup on the preceding store.update having actually
1480
+ // SUCCEEDED (persistedRun defined) a failed persist leaves the step in_progress with its
1481
+ // trace already read into an evidence write that never landed anywhere durable; the WAL is
1482
+ // the SOLE remaining evidence copy until reclaim recovers the wedge (#186 posture). Reclaim's
1483
+ // own drain (reclaim-step.ts) warns with the destroyed entry count when it eventually clears
1484
+ // this same buffer.
1485
+ //
1486
+ // issue #197 PR-2 (deliverable 1f): when this step's post-claim read found foreign (preserved,
1487
+ // not adopted) lines AND the store declares `seal`, retire the WAL to a sealed artifact
1488
+ // instead of destroying it — `preserved_foreign === 0` (the overwhelming common case, and the
1489
+ // ONLY case on a non-seal-declaring store, since carriage requires seal by the ladder) takes
1490
+ // the exact same plain `delete()` this always has, below, byte-identical.
1491
+ if (persistedRun !== undefined) {
1492
+ let performPlainDelete = true;
1493
+ if (adoptionPartition !== undefined &&
1494
+ adoptionPartition.preserved_foreign > 0 &&
1495
+ options.traceBufferStore !== undefined &&
1496
+ storeDeclaresSeal(options.traceBufferStore)) {
1497
+ try {
1498
+ const sealResult = await options.traceBufferStore.sealFenced(options.runId, options.command, buildSettleSealGuard(store, options.runId, options.command));
1499
+ if (sealResult.sealed) {
1500
+ performPlainDelete = false;
1501
+ walCleanupWarning =
1502
+ `${adoptionPartition.preserved_foreign} foreign line(s) preserved (sealed) — ` +
1503
+ 'retrieve via `realm run export`';
1504
+ }
1505
+ else if (sealResult.reason === 'capped') {
1506
+ // Fall back to the SAME plain delete below (loud + bounded, never a silent eviction
1507
+ // of an already-sealed artifact to make room) — the destroyed-count warning names
1508
+ // exactly what happened; performPlainDelete stays true.
1509
+ walCleanupWarning = 'preservation cap reached — foreign lines destroyed, not preserved';
1510
+ }
1511
+ else {
1512
+ // 'absent' — the live WAL vanished between the post-claim read and this seal attempt
1513
+ // (e.g. a concurrent purge/reclaim) — nothing left to delete either; residue-not-loss.
1514
+ performPlainDelete = false;
1515
+ }
1516
+ }
1517
+ catch (err) {
1518
+ // A THROW (lock contention, genuine I/O failure) ⇒ warn + SKIP the delete
1519
+ // (residue-not-loss; the fence refuses further appends; purge reaps).
1520
+ performPlainDelete = false;
1521
+ walCleanupWarning = `Failed to seal trace buffer after step failure: ${err instanceof Error ? err.message : String(err)}`;
1522
+ }
1523
+ }
1524
+ if (performPlainDelete) {
1525
+ try {
1526
+ // Delete WAL after run state is written for failure — entries are now in evidence.
1527
+ await options.traceBufferStore?.delete(options.runId, options.command);
1528
+ }
1529
+ catch (walErr) {
1530
+ walCleanupWarning = `Failed to clean up trace buffer after step failure: ${walErr instanceof Error ? walErr.message : String(walErr)}`;
1531
+ }
1532
+ }
1188
1533
  }
1189
1534
  // Derive the agent_action from the error semantics and run termination state.
1190
1535
  //
@@ -1223,6 +1568,14 @@ export async function executeStep(store, definition, options) {
1223
1568
  warnings: mergeWarnings(traceWarnings, storeCleanupWarning ?? walCleanupWarning),
1224
1569
  errors: [dispatchError.message],
1225
1570
  agent_action: effectiveAction,
1571
+ // issue #140 (D3 §2, discriminator OBSERVABLE): additive-optional — lets a caller
1572
+ // discriminate `STEP_RETRY_EXHAUSTED`'s `exhausted_by` (or any other terminal code) without
1573
+ // parsing `errors[0]`'s message text. Mirrors the errorEnvelope()/buildPreExecutionErrorEnvelope
1574
+ // pattern used elsewhere in this file.
1575
+ error_code: dispatchError.code,
1576
+ ...(Object.keys(dispatchError.details).length > 0
1577
+ ? { error_details: dispatchError.details }
1578
+ : {}),
1226
1579
  ...(dispatchError.retry_after !== undefined
1227
1580
  ? { retry_after: dispatchError.retry_after }
1228
1581
  : {}),
@@ -1413,8 +1766,54 @@ export async function executeStep(store, definition, options) {
1413
1766
  });
1414
1767
  return makeErrorEnvelope(options, pendingRun, internal, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
1415
1768
  }
1416
- // Delete WAL after successful run update — entries are now in evidence.
1417
- await options.traceBufferStore?.delete(options.runId, options.command);
1769
+ // Delete WAL after successful run update — entries are now in evidence. issue #207 PR-2 (D3
1770
+ // §5, clause (a) of the unified deletion contract): wrapped in try/catch-to-warning — the
1771
+ // settlement already committed and is durably visible, so a cleanup failure here (e.g. lock
1772
+ // contention) must degrade to a warning, never surface as though the STEP itself failed (this
1773
+ // was previously unwrapped and would have thrown straight out of executeStep).
1774
+ //
1775
+ // issue #197 PR-2 (deliverable 1f): same seal-vs-delete decision as the failure-settle site
1776
+ // above — see its comment for the full outcome table. preserved_foreign === 0 (the common case,
1777
+ // and the ONLY case on a non-seal-declaring store) takes the exact same plain delete() this
1778
+ // always has, byte-identical.
1779
+ let successWalCleanupWarning;
1780
+ {
1781
+ let performPlainDelete = true;
1782
+ if (adoptionPartition !== undefined &&
1783
+ adoptionPartition.preserved_foreign > 0 &&
1784
+ options.traceBufferStore !== undefined &&
1785
+ storeDeclaresSeal(options.traceBufferStore)) {
1786
+ try {
1787
+ const sealResult = await options.traceBufferStore.sealFenced(options.runId, options.command, buildSettleSealGuard(store, options.runId, options.command));
1788
+ if (sealResult.sealed) {
1789
+ performPlainDelete = false;
1790
+ successWalCleanupWarning =
1791
+ `${adoptionPartition.preserved_foreign} foreign line(s) preserved (sealed) — ` +
1792
+ 'retrieve via `realm run export`';
1793
+ }
1794
+ else if (sealResult.reason === 'capped') {
1795
+ successWalCleanupWarning =
1796
+ 'preservation cap reached — foreign lines destroyed, not preserved';
1797
+ }
1798
+ else {
1799
+ // 'absent' — residue-not-loss; nothing left to delete either.
1800
+ performPlainDelete = false;
1801
+ }
1802
+ }
1803
+ catch (err) {
1804
+ performPlainDelete = false;
1805
+ successWalCleanupWarning = `Failed to seal trace buffer after step completion: ${err instanceof Error ? err.message : String(err)}`;
1806
+ }
1807
+ }
1808
+ if (performPlainDelete) {
1809
+ try {
1810
+ await options.traceBufferStore?.delete(options.runId, options.command);
1811
+ }
1812
+ catch (err) {
1813
+ successWalCleanupWarning = `Failed to clean up trace buffer after step completion: ${err instanceof Error ? err.message : String(err)}`;
1814
+ }
1815
+ }
1816
+ }
1418
1817
  // Step 7: Build and return ResponseEnvelope.
1419
1818
  const nextActions = savedRun.terminal_state ? [] : buildNextActions(definition, savedRun);
1420
1819
  const orientation = savedRun.terminal_state
@@ -1429,11 +1828,22 @@ export async function executeStep(store, definition, options) {
1429
1828
  status: 'ok',
1430
1829
  data: output,
1431
1830
  evidence: allEvidence,
1432
- warnings: mergeWarnings(traceWarnings, currentWarn),
1831
+ warnings: mergeWarnings(traceWarnings, currentWarn, successWalCleanupWarning),
1433
1832
  errors: [],
1434
1833
  context_hint: orientation,
1435
1834
  run_phase: savedRun.run_phase,
1436
1835
  next_actions: nextActions,
1836
+ // issue #197 PR-2 (deliverable 2e): additive per-partition counts, SET BY THE ENGINE (never
1837
+ // the MCP tool layer, which strips data/evidence and never sees per-line nonces). Gated on
1838
+ // `carriageActive` (store CAPABILITY), not on whether a nonce happened to be provided this
1839
+ // call — "absent for bare-floor stores" means incapable, not merely unused-this-time.
1840
+ ...(carriageActive && adoptionPartition !== undefined
1841
+ ? {
1842
+ adopted_own: adoptionPartition.adopted_own,
1843
+ adopted_anonymous: adoptionPartition.adopted_anonymous,
1844
+ preserved_foreign: adoptionPartition.preserved_foreign,
1845
+ }
1846
+ : {}),
1437
1847
  };
1438
1848
  }
1439
1849
  /**
@@ -1824,7 +2234,19 @@ async function buildFinalizedSeal(definition, sealDraft, outcome, registry) {
1824
2234
  }
1825
2235
  return { ...record, terminal_state: true };
1826
2236
  }
1827
- async function executeChainInternal(store, definition, options, depth, chainedSteps) {
2237
+ async function executeChainInternal(store, definition, options, depth, chainedSteps,
2238
+ /**
2239
+ * issue #197 PR-2 (chain-replacement disposition, accepted): a settled DEPTH-0 step (typically
2240
+ * an agent step — never itself recorded in `chainedSteps`, which is auto-steps-only, feeding
2241
+ * the VISIBLE `chained_auto_steps` list) whose own envelope is about to be discarded in favor
2242
+ * of a deeper auto step's envelope would otherwise silently lose its OWN warnings (seal
2243
+ * outcome / half-minted / missing-carriage-leg advisories) — this separate accumulator exists
2244
+ * ONLY to carry those forward; it never touches `chainedSteps`'/`chained_auto_steps`'s shape.
2245
+ * The three NEW adoption counts on that depth-0 envelope are NOT similarly rescued — they
2246
+ * persist authoritatively in the settled step's own `trace_summary` regardless (see
2247
+ * `ResponseEnvelope.adopted_own`'s own doc) — only the warnings are a load-bearing rescue.
2248
+ */
2249
+ depth0Warnings) {
1828
2250
  if (depth > MAX_CHAIN_DEPTH) {
1829
2251
  return {
1830
2252
  command: options.command,
@@ -1966,7 +2388,20 @@ async function executeChainInternal(store, definition, options, depth, chainedSt
1966
2388
  // Only agent steps or nothing — stop chain, return with latest next_actions.
1967
2389
  return result;
1968
2390
  }
1969
- return executeChainInternal(store, definition, { ...options, command: nextAutoStep, input: {} }, depth + 1, chainedSteps);
2391
+ // issue #197 PR-2 (chain-replacement disposition): THIS step's own result is about to be
2392
+ // discarded in favor of the recursive call's — capture its warnings now, before that happens.
2393
+ // Every step past depth 0 in this recursion is guaranteed 'auto' (nextAutoStep's own filter),
2394
+ // so depth === 0 is the ONLY case where a non-auto (agent) step's warnings would otherwise be
2395
+ // lost here (an 'auto' depth-0 step's warnings are already captured above via `chainedSteps`,
2396
+ // so this would double them — the `depth === 0` guard is exact, not merely conservative:
2397
+ // `chainedSteps` only records 'auto' steps, so a depth-0 'auto' step's warnings are recorded
2398
+ // there, never here, avoiding any double-count).
2399
+ if (depth === 0 &&
2400
+ definition.steps[options.command]?.execution !== 'auto' &&
2401
+ result.warnings.length > 0) {
2402
+ depth0Warnings.push(...result.warnings);
2403
+ }
2404
+ return executeChainInternal(store, definition, { ...options, command: nextAutoStep, input: {} }, depth + 1, chainedSteps, depth0Warnings);
1970
2405
  }
1971
2406
  /**
1972
2407
  * Executes a step and automatically chains into subsequent `execution: auto` steps.
@@ -2018,8 +2453,11 @@ export async function executeChain(store, definition, options) {
2018
2453
  registry: options.registry ?? createDefaultRegistry(),
2019
2454
  };
2020
2455
  const chained = [];
2021
- const result = await executeChainInternal(store, definition, effectiveOptions, 0, chained);
2022
- const chainWarnings = chained.flatMap((s) => s.warnings ?? []);
2456
+ // issue #197 PR-2 (chain-replacement disposition) see executeChainInternal's own doc on this
2457
+ // parameter for the full contract.
2458
+ const depth0Warnings = [];
2459
+ const result = await executeChainInternal(store, definition, effectiveOptions, 0, chained, depth0Warnings);
2460
+ const chainWarnings = [...depth0Warnings, ...chained.flatMap((s) => s.warnings ?? [])];
2023
2461
  const envelope = {
2024
2462
  ...result,
2025
2463
  command: options.command,