@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.
- package/dist/engine/claim-liveness.d.ts +59 -11
- package/dist/engine/claim-liveness.d.ts.map +1 -1
- package/dist/engine/claim-liveness.js +83 -19
- package/dist/engine/claim-liveness.js.map +1 -1
- package/dist/engine/eligibility.d.ts +13 -0
- package/dist/engine/eligibility.d.ts.map +1 -1
- package/dist/engine/eligibility.js +20 -8
- package/dist/engine/eligibility.js.map +1 -1
- package/dist/engine/execution-loop.d.ts +13 -0
- package/dist/engine/execution-loop.d.ts.map +1 -1
- package/dist/engine/execution-loop.js +496 -58
- package/dist/engine/execution-loop.js.map +1 -1
- package/dist/engine/reclaim-step.d.ts.map +1 -1
- package/dist/engine/reclaim-step.js +187 -12
- package/dist/engine/reclaim-step.js.map +1 -1
- package/dist/engine/trace-adoption.d.ts +53 -0
- package/dist/engine/trace-adoption.d.ts.map +1 -0
- package/dist/engine/trace-adoption.js +49 -0
- package/dist/engine/trace-adoption.js.map +1 -0
- package/dist/evidence/snapshot.d.ts +11 -1
- package/dist/evidence/snapshot.d.ts.map +1 -1
- package/dist/evidence/snapshot.js +1 -0
- package/dist/evidence/snapshot.js.map +1 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -5
- package/dist/index.js.map +1 -1
- package/dist/store/fs-io.d.ts +26 -0
- package/dist/store/fs-io.d.ts.map +1 -1
- package/dist/store/fs-io.js +37 -2
- package/dist/store/fs-io.js.map +1 -1
- package/dist/store/json-file-store.d.ts.map +1 -1
- package/dist/store/json-file-store.js +2 -5
- package/dist/store/json-file-store.js.map +1 -1
- package/dist/store/trace-buffer-store.d.ts +419 -7
- package/dist/store/trace-buffer-store.d.ts.map +1 -1
- package/dist/store/trace-buffer-store.js +401 -30
- package/dist/store/trace-buffer-store.js.map +1 -1
- package/dist/types/response-envelope.d.ts +20 -0
- package/dist/types/response-envelope.d.ts.map +1 -1
- package/dist/types/run-record.d.ts +60 -15
- package/dist/types/run-record.d.ts.map +1 -1
- package/dist/types/workflow-definition.d.ts +52 -7
- package/dist/types/workflow-definition.d.ts.map +1 -1
- package/dist/types/workflow-definition.js +15 -0
- package/dist/types/workflow-definition.js.map +1 -1
- package/dist/types/workflow-error.d.ts +1 -1
- package/dist/types/workflow-error.d.ts.map +1 -1
- package/dist/types/workflow-error.js.map +1 -1
- package/dist/workflow/diagnostics.d.ts +15 -6
- package/dist/workflow/diagnostics.d.ts.map +1 -1
- package/dist/workflow/diagnostics.js +16 -8
- package/dist/workflow/diagnostics.js.map +1 -1
- package/dist/workflow/yaml-loader.d.ts.map +1 -1
- package/dist/workflow/yaml-loader.js +108 -6
- package/dist/workflow/yaml-loader.js.map +1 -1
- 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
|
-
|
|
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
|
|
352
|
-
*
|
|
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,
|
|
355
|
-
|
|
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
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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(
|
|
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
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
|
719
|
-
|
|
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
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
//
|
|
824
|
-
//
|
|
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 =
|
|
878
|
-
? await withTimeout((signal) => makeCall(signal),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
1096
|
-
try
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
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
|
|
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
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2022
|
-
|
|
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,
|