@sensigo/realm 0.26.0 → 0.27.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 (38) hide show
  1. package/dist/engine/eligibility.d.ts +13 -0
  2. package/dist/engine/eligibility.d.ts.map +1 -1
  3. package/dist/engine/eligibility.js +20 -8
  4. package/dist/engine/eligibility.js.map +1 -1
  5. package/dist/engine/execution-loop.d.ts +13 -0
  6. package/dist/engine/execution-loop.d.ts.map +1 -1
  7. package/dist/engine/execution-loop.js +355 -45
  8. package/dist/engine/execution-loop.js.map +1 -1
  9. package/dist/engine/reclaim-step.d.ts.map +1 -1
  10. package/dist/engine/reclaim-step.js +187 -12
  11. package/dist/engine/reclaim-step.js.map +1 -1
  12. package/dist/engine/trace-adoption.d.ts +53 -0
  13. package/dist/engine/trace-adoption.d.ts.map +1 -0
  14. package/dist/engine/trace-adoption.js +49 -0
  15. package/dist/engine/trace-adoption.js.map +1 -0
  16. package/dist/index.d.ts +7 -5
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +7 -4
  19. package/dist/index.js.map +1 -1
  20. package/dist/store/fs-io.d.ts +26 -0
  21. package/dist/store/fs-io.d.ts.map +1 -1
  22. package/dist/store/fs-io.js +37 -2
  23. package/dist/store/fs-io.js.map +1 -1
  24. package/dist/store/json-file-store.d.ts.map +1 -1
  25. package/dist/store/json-file-store.js +2 -5
  26. package/dist/store/json-file-store.js.map +1 -1
  27. package/dist/store/trace-buffer-store.d.ts +419 -7
  28. package/dist/store/trace-buffer-store.d.ts.map +1 -1
  29. package/dist/store/trace-buffer-store.js +401 -30
  30. package/dist/store/trace-buffer-store.js.map +1 -1
  31. package/dist/types/response-envelope.d.ts +20 -0
  32. package/dist/types/response-envelope.d.ts.map +1 -1
  33. package/dist/types/run-record.d.ts +33 -10
  34. package/dist/types/run-record.d.ts.map +1 -1
  35. package/dist/types/workflow-error.d.ts +1 -1
  36. package/dist/types/workflow-error.d.ts.map +1 -1
  37. package/dist/types/workflow-error.js.map +1 -1
  38. package/package.json +1 -1
@@ -1,6 +1,8 @@
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';
@@ -348,13 +350,75 @@ export function buildNextActions(definition, run) {
348
350
  .map((name) => stepToNextAction(name, definition.steps[name], context));
349
351
  }
350
352
  /**
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).
353
+ * Merges call-scoped trace-schema warnings with any number of optional extra warnings into a
354
+ * single warnings array. Trace warnings are listed first (deterministic order); `extraWarnings`
355
+ * entries are appended in call order, `undefined` entries skipped. Variadic since issue #207
356
+ * PR-2 (the success-settle path now has TWO independent optional warnings to merge — a
357
+ * handler-level warning and a WAL-cleanup warning — where every earlier call site had at most
358
+ * one).
353
359
  */
354
- function mergeWarnings(traceWarnings, cleanupWarning) {
355
- if (traceWarnings.length === 0 && cleanupWarning === undefined)
360
+ function mergeWarnings(traceWarnings, ...extraWarnings) {
361
+ const defined = extraWarnings.filter((w) => w !== undefined);
362
+ if (traceWarnings.length === 0 && defined.length === 0)
356
363
  return [];
357
- return cleanupWarning !== undefined ? [...traceWarnings, cleanupWarning] : [...traceWarnings];
364
+ return [...traceWarnings, ...defined];
365
+ }
366
+ /**
367
+ * Compensating un-claim (issue #207 PR-2, D3 §5): built from `pendingRun` — the record OUR OWN
368
+ * `claimStep` call returned, never a fresh get — removing the step from `in_progress_steps` AND
369
+ * `claims[step]` in the SAME mutation (the settle-site invariant every other settle path in this
370
+ * file upholds), plus an audit-evidence entry. The caller CAS's this against `pendingRun.version`
371
+ * (by passing the returned record straight to `store.update`): any intervening write (a
372
+ * concurrent settle, reclaim, or second claim) bumps version, so this compensating un-claim can
373
+ * never stomp it — a CAS mismatch means some other actor already resolved the claim, and the
374
+ * caller must stop immediately rather than retry, leaving the claim exactly as that actor left
375
+ * it. A step already absent from `in_progress_steps` (should not happen here, but the filter is
376
+ * naturally idempotent) is simply a no-op mutation, not a special case.
377
+ */
378
+ function buildCompensatingUnclaim(pendingRun, stepName, now) {
379
+ const auditEvidence = captureEvidence({
380
+ stepId: stepName,
381
+ startedAt: now,
382
+ completedAt: now,
383
+ input: {},
384
+ output: {
385
+ compensating_unclaim: true,
386
+ reason: 'adoption-read failure after claim',
387
+ unclaimed_at: now.toISOString(),
388
+ },
389
+ });
390
+ return {
391
+ ...pendingRun,
392
+ in_progress_steps: pendingRun.in_progress_steps.filter((s) => s !== stepName),
393
+ claims: omitClaim(pendingRun.claims, stepName),
394
+ evidence: [...pendingRun.evidence, auditEvidence],
395
+ };
396
+ }
397
+ /**
398
+ * Guard for the settle-time seal attempt (issue #197 PR-2, deliverable 1f) — ONE lock-free
399
+ * `store.get` re-verifying the run exists and this step has actually LEFT `in_progress_steps`
400
+ * (i.e. our own settling `store.update` already landed) — the "purge-guard shape" (mirrors
401
+ * #184's terminal-re-verify-under-lock precedent). In the normal case this always passes: by the
402
+ * time either settle site calls `sealFenced`, the settling update has already committed
403
+ * synchronously just above it. A run genuinely gone (e.g. concurrently purged) surfaces as
404
+ * `store.get`'s own typed `STATE_RUN_NOT_FOUND` throw — deliberately NOT special-cased here; it
405
+ * propagates as an ordinary guard THROW, which the caller's uniform "a throw ⇒ warn + skip the
406
+ * delete" handling already covers correctly (residue-not-loss either way).
407
+ */
408
+ function buildSettleSealGuard(store, runId, stepName) {
409
+ return async () => {
410
+ const fresh = await store.get(runId);
411
+ if (fresh.in_progress_steps.includes(stepName)) {
412
+ throw new WorkflowError(`Refusing to seal trace buffer for run '${runId}' step '${stepName}': the step is still ` +
413
+ 'in_progress (the settling update has not yet landed) — residue-not-loss, the live WAL ' +
414
+ 'is left intact.', {
415
+ code: 'STATE_STEP_PENDING',
416
+ category: 'STATE',
417
+ agentAction: 'report_to_user',
418
+ retryable: true,
419
+ });
420
+ }
421
+ };
358
422
  }
359
423
  /**
360
424
  * Issue #185 Fix 1 (budget-priority): builds the merged, canonicalized trace for an agent step,
@@ -617,17 +681,57 @@ export async function executeStep(store, definition, options) {
617
681
  let preNormalizedTrace;
618
682
  let walEntries = [];
619
683
  let preClaimSchemaResult;
684
+ // issue #197 PR-2 (design §3, the activation gate): computed once — options.traceBufferStore
685
+ // is invariant for this whole call. `carriageActive` also gates whether the NEW
686
+ // adopted_own/adopted_anonymous/preserved_foreign fields are surfaced on the 'ok' envelope
687
+ // (absent entirely on a bare-floor store, never just zeroed) — see the settle-site comment.
688
+ const carriageActive = options.traceBufferStore !== undefined && storeDeclaresNonceCarriage(options.traceBufferStore);
689
+ const effectiveClaimantNonce = carriageActive ? options.writerNonce : undefined;
690
+ // Post-claim adoption partition (issue #197 PR-2) — lifted to this outer scope because it is
691
+ // read again at the settle sites (seal-vs-delete decision) and at 'ok' envelope build, both far
692
+ // below the post-claim block that computes it. `undefined` for a non-agent step (never computed
693
+ // there) — the settle sites and envelope build both treat that as "nothing to preserve/report".
694
+ let adoptionPartition;
620
695
  if (stepDef?.execution === 'agent') {
621
- walEntries =
622
- options.traceBufferStore !== undefined
623
- ? await options.traceBufferStore.read(options.runId, options.command)
624
- : [];
696
+ // issue #207 PR-2 (D3 §5): wrapped — a rejection here (e.g. lock contention under a fenced
697
+ // trio's serialized reads) happens BEFORE claimStep, so no claim exists to compensate for;
698
+ // return a typed, retryable envelope instead of letting the read throw uncaught.
699
+ try {
700
+ walEntries =
701
+ options.traceBufferStore !== undefined
702
+ ? await options.traceBufferStore.read(options.runId, options.command)
703
+ : [];
704
+ }
705
+ catch (err) {
706
+ return makeErrorEnvelope(options, run, new WorkflowError('Failed to read trace buffer before claiming step', {
707
+ code: 'ENGINE_STORE_FAILED',
708
+ category: 'ENGINE',
709
+ agentAction: 'stop',
710
+ retryable: true,
711
+ details: {
712
+ step_id: options.command,
713
+ cause: err instanceof Error ? err.message : String(err),
714
+ },
715
+ }), definition);
716
+ }
717
+ // issue #197 PR-2 (design §3, missing-leg advisory): a caller-supplied nonce this store
718
+ // cannot carry is IGNORED for adoption purposes (carriageActive is false) — loudly, once per
719
+ // call, so a minting client discovers the silent floor rather than assuming attribution.
720
+ if (options.writerNonce !== undefined && !carriageActive) {
721
+ traceWarnings.push('writer_nonce ignored: the trace-buffer store does not declare writer_nonce_carriage — ' +
722
+ 'adoption falls back to the honest floor');
723
+ }
625
724
  const hasAnyTrace = walEntries.length > 0 || (options.trace !== undefined && options.trace.length > 0);
626
725
  if (hasAnyTrace) {
726
+ // issue #197 PR-2 (design §2, ADOPTION_CONGRUENCE): the pre-claim enforce-gate validates
727
+ // ONLY the adopted subset — a foreign-nonce-only WAL must never gate a (nonced) claimant.
728
+ // Congruence with the post-claim partition below is mandatory: both call the SAME
729
+ // `partitionBufferedEntries` helper against the SAME `effectiveClaimantNonce`.
730
+ const prClaimPartition = partitionBufferedEntries(walEntries, effectiveClaimantNonce);
627
731
  // issue #185 Fix 1: budget-priority merge (see buildPriorityMergedTrace's own doc) — this
628
732
  // pre-claim pass exists only to feed validateTraceSchema below; its RESULT is discarded
629
733
  // (not stored into the outer preNormalizedTrace) once the enforce-gate decision is made.
630
- const preClaimNormalized = buildPriorityMergedTrace(walEntries, options.trace);
734
+ const preClaimNormalized = buildPriorityMergedTrace(prClaimPartition.adopted, options.trace);
631
735
  // Validate trace schema if configured (unchanged call site).
632
736
  if (stepDef.trace_schema !== undefined) {
633
737
  const mode = stepDef.trace_validation_mode ?? 'warn';
@@ -709,14 +813,51 @@ export async function executeStep(store, definition, options) {
709
813
  // complete, post-claim set, which is also what the captureEvidence call site further below
710
814
  // keys its `stepDef?.execution === 'agent' && walEntries.length > 0` check on.
711
815
  if (stepDef?.execution === 'agent') {
712
- walEntries =
713
- options.traceBufferStore !== undefined
714
- ? await options.traceBufferStore.read(options.runId, options.command)
715
- : [];
816
+ // issue #207 PR-2 (D3 §5): wrapped — a rejection here happens AFTER claimStep, so a claim IS
817
+ // outstanding. COMPENSATING UN-CLAIM: built from `pendingRun` (our own claimStep result,
818
+ // never a fresh get) and CAS'd against `pendingRun.version` — an intervening write (someone
819
+ // else already resolved this claim) makes the CAS fail with STATE_SNAPSHOT_MISMATCH, in
820
+ // which case we stop immediately and leave the claim exactly as it is. Either way (compensated
821
+ // or left in place), the caller always gets the same typed retryable envelope — see
822
+ // buildCompensatingUnclaim's own doc for the full contract.
823
+ try {
824
+ walEntries =
825
+ options.traceBufferStore !== undefined
826
+ ? await options.traceBufferStore.read(options.runId, options.command)
827
+ : [];
828
+ }
829
+ catch (err) {
830
+ try {
831
+ await store.update(buildCompensatingUnclaim(pendingRun, options.command, new Date()));
832
+ }
833
+ catch {
834
+ // CAS mismatch (someone else already resolved the claim) or any other failure to even
835
+ // un-claim: stop immediately, leave the claim exactly as it is — never retry here.
836
+ }
837
+ return makeErrorEnvelope(options, pendingRun, new WorkflowError('Failed to read trace buffer after claiming step', {
838
+ code: 'ENGINE_STORE_FAILED',
839
+ category: 'ENGINE',
840
+ agentAction: 'stop',
841
+ retryable: true,
842
+ details: {
843
+ step_id: options.command,
844
+ cause: err instanceof Error ? err.message : String(err),
845
+ },
846
+ }), definition, traceWarnings.length > 0 ? traceWarnings : undefined);
847
+ }
848
+ // issue #197 PR-2 (design §2): the SAME predicate as the pre-claim pass, now over the
849
+ // complete post-claim set. Lifted to `adoptionPartition` (outer scope) — read again at the
850
+ // settle sites (seal-vs-delete) and at 'ok' envelope build, both below this block.
851
+ adoptionPartition = partitionBufferedEntries(walEntries, effectiveClaimantNonce);
852
+ // issue #197 PR-2 (design §2, the "keying pin"): this condition stays keyed on the FULL
853
+ // post-claim WAL set, NOT the adopted subset — a foreign-only WAL (adoptionPartition.adopted
854
+ // empty) with no options.trace must still enter this block so foreign_lines_preserved below
855
+ // is captured into trace_summary; otherwise that count is silently lost.
716
856
  if (walEntries.length > 0 || (options.trace !== undefined && options.trace.length > 0)) {
717
857
  // 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);
858
+ // complete post-claim ADOPTED subset only a foreign line never reaches canonical
859
+ // evidence (issue #197 PR-2, design §2).
860
+ preNormalizedTrace = buildPriorityMergedTrace(adoptionPartition.adopted, options.trace);
720
861
  // Carry over the enforce-gate's schema-validation result (computed pre-claim against a
721
862
  // possibly-incomplete set) rather than re-validating here — Fix 2 deliberately keeps
722
863
  // validation pre-claim (see that block's comment); this just republishes its verdict onto
@@ -726,13 +867,48 @@ export async function executeStep(store, definition, options) {
726
867
  preNormalizedTrace.summary.validation_mode = preClaimSchemaResult.validation_mode;
727
868
  preNormalizedTrace.summary.validation_errors = preClaimSchemaResult.validation_errors;
728
869
  }
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;
870
+ // issue #185 Fix 3 / issue #197 PR-2 (design §2/§6): the three-way honest split.
871
+ // buffered_lines_adopted now counts ONLY the adopted-ANONYMOUS entries (bare-adopted by a
872
+ // claimant) for all-bare traffic this is numerically IDENTICAL to before #197 (every
873
+ // adopted line was, and still is, bare). attributed_lines_adopted counts own-nonce
874
+ // adoptions NO caveat (design §6 wording, verbatim below). foreign_lines_preserved counts
875
+ // lines from a different writer, preserved (sealed where supported) but never adopted —
876
+ // its accompanying pointer warning is the only way an agent learns to retrieve them.
877
+ if (adoptionPartition.adopted_anonymous > 0) {
878
+ preNormalizedTrace.summary.buffered_lines_adopted = adoptionPartition.adopted_anonymous;
879
+ }
880
+ if (adoptionPartition.adopted_own > 0) {
881
+ preNormalizedTrace.summary.attributed_lines_adopted = adoptionPartition.adopted_own;
882
+ }
883
+ if (adoptionPartition.preserved_foreign > 0) {
884
+ preNormalizedTrace.summary.foreign_lines_preserved = adoptionPartition.preserved_foreign;
885
+ traceWarnings.push(`${adoptionPartition.preserved_foreign} buffered line(s) from a different writer were ` +
886
+ 'preserved, not adopted — retrieve via `realm run export`');
887
+ }
888
+ // issue #197 PR-2 (design §6, the half-minted advisory): signature heuristics over the RAW
889
+ // walEntries/foreign set (never the adopted subset — these two cases are both about
890
+ // content this claimant did NOT adopt), neutral phrasing, values never echoed. Mutually
891
+ // exclusive by claimant type (nonced vs ⊥), so an if/else-if is exact, not a simplification.
892
+ if (effectiveClaimantNonce !== undefined &&
893
+ walEntries.length > 0 &&
894
+ walEntries.every((e) => e._nonce === undefined)) {
895
+ // A nonced claimant found nothing but bare lines — if those are this SAME attempt's own
896
+ // earlier append() calls, the client minted inconsistently (nonce on execute_step but not
897
+ // on the preceding append_trace calls, or vice versa).
898
+ traceWarnings.push(`the ${walEntries.length} buffered line(s) were bare and were preserved, not adopted — ` +
899
+ 'if they are yours from this same attempt, you minted inconsistently (mint on both ' +
900
+ 'calls, or neither)');
901
+ }
902
+ else if (effectiveClaimantNonce === undefined && adoptionPartition.foreign.length > 0) {
903
+ const distinctForeignNonces = new Set(adoptionPartition.foreign.map((e) => e._nonce));
904
+ if (distinctForeignNonces.size === 1) {
905
+ // A bare claimant found every foreign line under exactly ONE other nonce — if that
906
+ // nonce is this SAME attempt's own (minted on append_trace but the execute_step call
907
+ // stayed bare), the client minted inconsistently.
908
+ traceWarnings.push(`${adoptionPartition.foreign.length} line(s) under a different writer_nonce were ` +
909
+ 'preserved, not adopted — if these are yours from this same attempt, you minted ' +
910
+ 'inconsistently');
911
+ }
736
912
  }
737
913
  }
738
914
  }
@@ -1092,14 +1268,15 @@ export async function executeStep(store, definition, options) {
1092
1268
  catch (storeErr) {
1093
1269
  blockStoreWarning = `Failed to persist capability block: ${storeErr instanceof Error ? storeErr.message : String(storeErr)}`;
1094
1270
  }
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
- }
1271
+ // issue #207 PR-2 (D3 §5): NO WAL delete belongs on this capability-block settle path — the
1272
+ // prior try/catch here was removed, not just gated. Contract-consistency hygiene, not a
1273
+ // functional fix: capability-block is reachable ONLY for `execution: 'auto'` steps
1274
+ // (ENGINE_HANDLER_NOT_REGISTERED/ENGINE_ADAPTER_NOT_REGISTERED mint only in the
1275
+ // auto-dispatch branches), and a WAL only ever exists for `execution: 'agent'` steps
1276
+ // (`append_trace` refuses non-agent steps) — DISJOINT populations; there is no in-repo
1277
+ // blocked-path WAL to clean up. A custom embedder whose dispatcher throws NOT_REGISTERED
1278
+ // for an agent step would leave WAL residue reaped at purge (an enumerated, accepted
1279
+ // residue class — D3 §8 residual 6), never silently destroyed here.
1103
1280
  // Non-terminal 'stop' → 'report_to_user' via the existing mapping: a human must provision the
1104
1281
  // runner (or re-run on a capable one); no further progress is possible on THIS runner.
1105
1282
  const blockedAction = resolvePostDispatchAgentAction(dispatchError, false);
@@ -1124,7 +1301,7 @@ export async function executeStep(store, definition, options) {
1124
1301
  status: 'error',
1125
1302
  data: {},
1126
1303
  evidence: allEvidence,
1127
- warnings: mergeWarnings(traceWarnings, blockStoreWarning ?? blockWalWarning),
1304
+ warnings: mergeWarnings(traceWarnings, blockStoreWarning),
1128
1305
  errors: [dispatchError.message],
1129
1306
  agent_action: blockedAction,
1130
1307
  error_code: recoverableCode,
@@ -1179,12 +1356,60 @@ export async function executeStep(store, definition, options) {
1179
1356
  storeCleanupWarning = `Failed to persist step failure: ${storeErr instanceof Error ? storeErr.message : String(storeErr)}`;
1180
1357
  }
1181
1358
  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)}`;
1359
+ // issue #207 PR-2 (D3 §5): gate the WAL cleanup on the preceding store.update having actually
1360
+ // SUCCEEDED (persistedRun defined) a failed persist leaves the step in_progress with its
1361
+ // trace already read into an evidence write that never landed anywhere durable; the WAL is
1362
+ // the SOLE remaining evidence copy until reclaim recovers the wedge (#186 posture). Reclaim's
1363
+ // own drain (reclaim-step.ts) warns with the destroyed entry count when it eventually clears
1364
+ // this same buffer.
1365
+ //
1366
+ // issue #197 PR-2 (deliverable 1f): when this step's post-claim read found foreign (preserved,
1367
+ // not adopted) lines AND the store declares `seal`, retire the WAL to a sealed artifact
1368
+ // instead of destroying it — `preserved_foreign === 0` (the overwhelming common case, and the
1369
+ // ONLY case on a non-seal-declaring store, since carriage requires seal by the ladder) takes
1370
+ // the exact same plain `delete()` this always has, below, byte-identical.
1371
+ if (persistedRun !== undefined) {
1372
+ let performPlainDelete = true;
1373
+ if (adoptionPartition !== undefined &&
1374
+ adoptionPartition.preserved_foreign > 0 &&
1375
+ options.traceBufferStore !== undefined &&
1376
+ storeDeclaresSeal(options.traceBufferStore)) {
1377
+ try {
1378
+ const sealResult = await options.traceBufferStore.sealFenced(options.runId, options.command, buildSettleSealGuard(store, options.runId, options.command));
1379
+ if (sealResult.sealed) {
1380
+ performPlainDelete = false;
1381
+ walCleanupWarning =
1382
+ `${adoptionPartition.preserved_foreign} foreign line(s) preserved (sealed) — ` +
1383
+ 'retrieve via `realm run export`';
1384
+ }
1385
+ else if (sealResult.reason === 'capped') {
1386
+ // Fall back to the SAME plain delete below (loud + bounded, never a silent eviction
1387
+ // of an already-sealed artifact to make room) — the destroyed-count warning names
1388
+ // exactly what happened; performPlainDelete stays true.
1389
+ walCleanupWarning = 'preservation cap reached — foreign lines destroyed, not preserved';
1390
+ }
1391
+ else {
1392
+ // 'absent' — the live WAL vanished between the post-claim read and this seal attempt
1393
+ // (e.g. a concurrent purge/reclaim) — nothing left to delete either; residue-not-loss.
1394
+ performPlainDelete = false;
1395
+ }
1396
+ }
1397
+ catch (err) {
1398
+ // A THROW (lock contention, genuine I/O failure) ⇒ warn + SKIP the delete
1399
+ // (residue-not-loss; the fence refuses further appends; purge reaps).
1400
+ performPlainDelete = false;
1401
+ walCleanupWarning = `Failed to seal trace buffer after step failure: ${err instanceof Error ? err.message : String(err)}`;
1402
+ }
1403
+ }
1404
+ if (performPlainDelete) {
1405
+ try {
1406
+ // Delete WAL after run state is written for failure — entries are now in evidence.
1407
+ await options.traceBufferStore?.delete(options.runId, options.command);
1408
+ }
1409
+ catch (walErr) {
1410
+ walCleanupWarning = `Failed to clean up trace buffer after step failure: ${walErr instanceof Error ? walErr.message : String(walErr)}`;
1411
+ }
1412
+ }
1188
1413
  }
1189
1414
  // Derive the agent_action from the error semantics and run termination state.
1190
1415
  //
@@ -1413,8 +1638,54 @@ export async function executeStep(store, definition, options) {
1413
1638
  });
1414
1639
  return makeErrorEnvelope(options, pendingRun, internal, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
1415
1640
  }
1416
- // Delete WAL after successful run update — entries are now in evidence.
1417
- await options.traceBufferStore?.delete(options.runId, options.command);
1641
+ // Delete WAL after successful run update — entries are now in evidence. issue #207 PR-2 (D3
1642
+ // §5, clause (a) of the unified deletion contract): wrapped in try/catch-to-warning — the
1643
+ // settlement already committed and is durably visible, so a cleanup failure here (e.g. lock
1644
+ // contention) must degrade to a warning, never surface as though the STEP itself failed (this
1645
+ // was previously unwrapped and would have thrown straight out of executeStep).
1646
+ //
1647
+ // issue #197 PR-2 (deliverable 1f): same seal-vs-delete decision as the failure-settle site
1648
+ // above — see its comment for the full outcome table. preserved_foreign === 0 (the common case,
1649
+ // and the ONLY case on a non-seal-declaring store) takes the exact same plain delete() this
1650
+ // always has, byte-identical.
1651
+ let successWalCleanupWarning;
1652
+ {
1653
+ let performPlainDelete = true;
1654
+ if (adoptionPartition !== undefined &&
1655
+ adoptionPartition.preserved_foreign > 0 &&
1656
+ options.traceBufferStore !== undefined &&
1657
+ storeDeclaresSeal(options.traceBufferStore)) {
1658
+ try {
1659
+ const sealResult = await options.traceBufferStore.sealFenced(options.runId, options.command, buildSettleSealGuard(store, options.runId, options.command));
1660
+ if (sealResult.sealed) {
1661
+ performPlainDelete = false;
1662
+ successWalCleanupWarning =
1663
+ `${adoptionPartition.preserved_foreign} foreign line(s) preserved (sealed) — ` +
1664
+ 'retrieve via `realm run export`';
1665
+ }
1666
+ else if (sealResult.reason === 'capped') {
1667
+ successWalCleanupWarning =
1668
+ 'preservation cap reached — foreign lines destroyed, not preserved';
1669
+ }
1670
+ else {
1671
+ // 'absent' — residue-not-loss; nothing left to delete either.
1672
+ performPlainDelete = false;
1673
+ }
1674
+ }
1675
+ catch (err) {
1676
+ performPlainDelete = false;
1677
+ successWalCleanupWarning = `Failed to seal trace buffer after step completion: ${err instanceof Error ? err.message : String(err)}`;
1678
+ }
1679
+ }
1680
+ if (performPlainDelete) {
1681
+ try {
1682
+ await options.traceBufferStore?.delete(options.runId, options.command);
1683
+ }
1684
+ catch (err) {
1685
+ successWalCleanupWarning = `Failed to clean up trace buffer after step completion: ${err instanceof Error ? err.message : String(err)}`;
1686
+ }
1687
+ }
1688
+ }
1418
1689
  // Step 7: Build and return ResponseEnvelope.
1419
1690
  const nextActions = savedRun.terminal_state ? [] : buildNextActions(definition, savedRun);
1420
1691
  const orientation = savedRun.terminal_state
@@ -1429,11 +1700,22 @@ export async function executeStep(store, definition, options) {
1429
1700
  status: 'ok',
1430
1701
  data: output,
1431
1702
  evidence: allEvidence,
1432
- warnings: mergeWarnings(traceWarnings, currentWarn),
1703
+ warnings: mergeWarnings(traceWarnings, currentWarn, successWalCleanupWarning),
1433
1704
  errors: [],
1434
1705
  context_hint: orientation,
1435
1706
  run_phase: savedRun.run_phase,
1436
1707
  next_actions: nextActions,
1708
+ // issue #197 PR-2 (deliverable 2e): additive per-partition counts, SET BY THE ENGINE (never
1709
+ // the MCP tool layer, which strips data/evidence and never sees per-line nonces). Gated on
1710
+ // `carriageActive` (store CAPABILITY), not on whether a nonce happened to be provided this
1711
+ // call — "absent for bare-floor stores" means incapable, not merely unused-this-time.
1712
+ ...(carriageActive && adoptionPartition !== undefined
1713
+ ? {
1714
+ adopted_own: adoptionPartition.adopted_own,
1715
+ adopted_anonymous: adoptionPartition.adopted_anonymous,
1716
+ preserved_foreign: adoptionPartition.preserved_foreign,
1717
+ }
1718
+ : {}),
1437
1719
  };
1438
1720
  }
1439
1721
  /**
@@ -1824,7 +2106,19 @@ async function buildFinalizedSeal(definition, sealDraft, outcome, registry) {
1824
2106
  }
1825
2107
  return { ...record, terminal_state: true };
1826
2108
  }
1827
- async function executeChainInternal(store, definition, options, depth, chainedSteps) {
2109
+ async function executeChainInternal(store, definition, options, depth, chainedSteps,
2110
+ /**
2111
+ * issue #197 PR-2 (chain-replacement disposition, accepted): a settled DEPTH-0 step (typically
2112
+ * an agent step — never itself recorded in `chainedSteps`, which is auto-steps-only, feeding
2113
+ * the VISIBLE `chained_auto_steps` list) whose own envelope is about to be discarded in favor
2114
+ * of a deeper auto step's envelope would otherwise silently lose its OWN warnings (seal
2115
+ * outcome / half-minted / missing-carriage-leg advisories) — this separate accumulator exists
2116
+ * ONLY to carry those forward; it never touches `chainedSteps`'/`chained_auto_steps`'s shape.
2117
+ * The three NEW adoption counts on that depth-0 envelope are NOT similarly rescued — they
2118
+ * persist authoritatively in the settled step's own `trace_summary` regardless (see
2119
+ * `ResponseEnvelope.adopted_own`'s own doc) — only the warnings are a load-bearing rescue.
2120
+ */
2121
+ depth0Warnings) {
1828
2122
  if (depth > MAX_CHAIN_DEPTH) {
1829
2123
  return {
1830
2124
  command: options.command,
@@ -1966,7 +2260,20 @@ async function executeChainInternal(store, definition, options, depth, chainedSt
1966
2260
  // Only agent steps or nothing — stop chain, return with latest next_actions.
1967
2261
  return result;
1968
2262
  }
1969
- return executeChainInternal(store, definition, { ...options, command: nextAutoStep, input: {} }, depth + 1, chainedSteps);
2263
+ // issue #197 PR-2 (chain-replacement disposition): THIS step's own result is about to be
2264
+ // discarded in favor of the recursive call's — capture its warnings now, before that happens.
2265
+ // Every step past depth 0 in this recursion is guaranteed 'auto' (nextAutoStep's own filter),
2266
+ // so depth === 0 is the ONLY case where a non-auto (agent) step's warnings would otherwise be
2267
+ // lost here (an 'auto' depth-0 step's warnings are already captured above via `chainedSteps`,
2268
+ // so this would double them — the `depth === 0` guard is exact, not merely conservative:
2269
+ // `chainedSteps` only records 'auto' steps, so a depth-0 'auto' step's warnings are recorded
2270
+ // there, never here, avoiding any double-count).
2271
+ if (depth === 0 &&
2272
+ definition.steps[options.command]?.execution !== 'auto' &&
2273
+ result.warnings.length > 0) {
2274
+ depth0Warnings.push(...result.warnings);
2275
+ }
2276
+ return executeChainInternal(store, definition, { ...options, command: nextAutoStep, input: {} }, depth + 1, chainedSteps, depth0Warnings);
1970
2277
  }
1971
2278
  /**
1972
2279
  * Executes a step and automatically chains into subsequent `execution: auto` steps.
@@ -2018,8 +2325,11 @@ export async function executeChain(store, definition, options) {
2018
2325
  registry: options.registry ?? createDefaultRegistry(),
2019
2326
  };
2020
2327
  const chained = [];
2021
- const result = await executeChainInternal(store, definition, effectiveOptions, 0, chained);
2022
- const chainWarnings = chained.flatMap((s) => s.warnings ?? []);
2328
+ // issue #197 PR-2 (chain-replacement disposition) see executeChainInternal's own doc on this
2329
+ // parameter for the full contract.
2330
+ const depth0Warnings = [];
2331
+ const result = await executeChainInternal(store, definition, effectiveOptions, 0, chained, depth0Warnings);
2332
+ const chainWarnings = [...depth0Warnings, ...chained.flatMap((s) => s.warnings ?? [])];
2023
2333
  const envelope = {
2024
2334
  ...result,
2025
2335
  command: options.command,