@rulvar/core 1.59.3 → 1.60.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/index.d.ts CHANGED
@@ -2652,6 +2652,7 @@ declare class Replayer {
2652
2652
  private readonly runId;
2653
2653
  private readonly store;
2654
2654
  private readonly lease?;
2655
+ private readonly leaseOf?;
2655
2656
  private readonly now;
2656
2657
  private readonly priceUsd?;
2657
2658
  private readonly onWarn?;
@@ -2683,6 +2684,14 @@ declare class Replayer {
2683
2684
  * is asserted instead of fenced (the embedded default).
2684
2685
  */
2685
2686
  lease?: Lease;
2687
+ /**
2688
+ * Late-bound lease lookup (P0.2): consulted at EVERY append,
2689
+ * winning over the static `lease` when it returns one. The engine
2690
+ * passes its segment-lease holder here, because the
2691
+ * engine-acquired genesis lease exists only after the ownership
2692
+ * boot, which runs after this constructor.
2693
+ */
2694
+ leaseOf?: () => Lease | undefined;
2686
2695
  });
2687
2696
  /**
2688
2697
  * Forward-matches one live call against the prior journal. Fresh
@@ -3867,6 +3876,24 @@ interface UsageLimits {
3867
3876
  max: number;
3868
3877
  costs?: Record<string, number>;
3869
3878
  };
3879
+ /**
3880
+ * The guaranteed finalization turn (the experiment-review P1.1): when
3881
+ * a TOOL budget limiter (maxToolCalls or toolUnits) expires, the
3882
+ * runtime closes the current batch's remaining calls with explicit
3883
+ * skipped-call error results instead of dropping them silently, then
3884
+ * grants the model exactly ONE summary turn with tools withheld
3885
+ * before the invocation settles as status 'limit' with the exact
3886
+ * limiter named in the terminal error. The summary text becomes the
3887
+ * limit result's output for schema-less calls; a ridden schema
3888
+ * validates into typed output when the summary parses (one attempt,
3889
+ * no re-prompt). `maxOutputTokens` bounds the summary turn only;
3890
+ * absent, the ordinary per-turn output policy applies. Off by
3891
+ * default: the skip results and the summary instruction enter the
3892
+ * conversation, so enabling it changes recorded model requests.
3893
+ */
3894
+ finalizationReserve?: {
3895
+ maxOutputTokens?: number;
3896
+ };
3870
3897
  }
3871
3898
  declare const DEFAULT_MAX_TURNS = 32;
3872
3899
  declare const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 12e4;
@@ -3887,6 +3914,9 @@ interface EffectiveUsageLimits {
3887
3914
  max: number;
3888
3915
  costs?: Record<string, number>;
3889
3916
  };
3917
+ finalizationReserve?: {
3918
+ maxOutputTokens?: number;
3919
+ };
3890
3920
  }
3891
3921
  /**
3892
3922
  * Limits merge per spawn: AgentOpts.limits over profile limits over engine
@@ -5682,6 +5712,25 @@ interface CreateEngineOptions {
5682
5712
  security?: {
5683
5713
  argsHashSalt?: string;
5684
5714
  };
5715
+ /**
5716
+ * The genesis ownership protocol (P0.2): over a journal store with
5717
+ * the lease capability, a run or resume segment that was NOT handed
5718
+ * a lease acquires its own before its first durable mutation, renews
5719
+ * it at ttl/3 exactly like a queue worker, and releases it at
5720
+ * settle. Fresh start, in-process resume, and worker takeover then
5721
+ * share ONE owner/lease contract: at most one live driver per run
5722
+ * across processes, a second driver's acquire rejects with the typed
5723
+ * LeaseHeldError before any write or provider dispatch, and a
5724
+ * crashed owner's lease expires after the store ttl so a worker
5725
+ * sweep recovers the run. Default 'auto'. 'none' restores the
5726
+ * pre-1.59.4 behavior (no engine-acquired leases) for hosts that
5727
+ * coordinate ownership entirely outside the engine; a lease passed
5728
+ * via RunOptions.lease or ResumeOptions.lease always wins over both
5729
+ * modes (the caller owns acquire, renew, and release). Stores
5730
+ * without the lease capability are unaffected: the embedded
5731
+ * single-process default keeps the single-writer precondition.
5732
+ */
5733
+ ownership?: "auto" | "none";
5685
5734
  }
5686
5735
  interface RunOptions {
5687
5736
  /** Explicit id; otherwise the engine mints a ULID. */
@@ -5713,6 +5762,19 @@ interface RunOptions {
5713
5762
  tags?: string[];
5714
5763
  /** Host-initiated cancellation. */
5715
5764
  signal?: AbortSignal;
5765
+ /**
5766
+ * A lease the caller already holds for this run (the genesis side of
5767
+ * the ResumeOptions.lease contract): the engine carries it on EVERY
5768
+ * durable mutation of the fresh segment (every journal append, every
5769
+ * putMeta, every transcript blob write) and never acquires, renews,
5770
+ * or releases it itself; lifecycle stays with the caller. Passing it
5771
+ * disables the engine's own ownership acquisition for this run
5772
+ * regardless of the `ownership` mode. Hosts that admit runs through
5773
+ * an external queue acquire the lease at admission time and hand it
5774
+ * here, so admission and the first dispatch are covered by ONE
5775
+ * fencing epoch.
5776
+ */
5777
+ lease?: Lease;
5716
5778
  }
5717
5779
  /** Resume-time hit/miss/orphan accounting. */
5718
5780
  interface ResumePreview extends ResumeReport {
@@ -7334,8 +7396,12 @@ interface RunInternals {
7334
7396
  * worktree patches) exactly as the Replayer threads it into every
7335
7397
  * journal append, so a store declaring fencedWrites refuses a
7336
7398
  * superseded segment's blob overwrites (fenced run state RFC, F2).
7399
+ * The engine binds this as a live getter over its segment-lease
7400
+ * holder (P0.2), so the union with undefined is explicit: before
7401
+ * the ownership boot (and on non-leasable stores) it reads
7402
+ * undefined.
7337
7403
  */
7338
- lease?: Lease;
7404
+ lease?: Lease | undefined;
7339
7405
  adapters: ReadonlyMap<string, ProviderAdapter>;
7340
7406
  defaults: {
7341
7407
  routing?: Partial<Record<InvocationRole, ModelSpec>>;
package/dist/index.js CHANGED
@@ -420,6 +420,7 @@ function wrapJournalStore(inner, hook) {
420
420
  wrapped.acquire = (runId, owner) => inner.acquire(runId, owner);
421
421
  wrapped.renew = (l) => inner.renew(l);
422
422
  wrapped.release = (l) => inner.release(l);
423
+ if (typeof leasable.leaseTtlMs === "number") wrapped.leaseTtlMs = leasable.leaseTtlMs;
423
424
  }
424
425
  if (typeof inner.getMeta === "function") wrapped.getMeta = (runId) => inner.getMeta(runId);
425
426
  return wrapped;
@@ -6779,6 +6780,7 @@ var Replayer = class {
6779
6780
  runId;
6780
6781
  store;
6781
6782
  lease;
6783
+ leaseOf;
6782
6784
  now;
6783
6785
  priceUsd;
6784
6786
  onWarn;
@@ -6796,6 +6798,7 @@ var Replayer = class {
6796
6798
  this.runId = options.runId;
6797
6799
  this.store = options.store;
6798
6800
  if (options.lease !== void 0) this.lease = options.lease;
6801
+ if (options.leaseOf !== void 0) this.leaseOf = options.leaseOf;
6799
6802
  this.now = options.now ?? realNow;
6800
6803
  if (options.priceUsd !== void 0) this.priceUsd = options.priceUsd;
6801
6804
  if (options.onWarn !== void 0) this.onWarn = options.onWarn;
@@ -7083,7 +7086,7 @@ var Replayer = class {
7083
7086
  } });
7084
7087
  const shapeIssues = validateEntryShape(entry);
7085
7088
  if (shapeIssues.length > 0) throw new ConfigError(`journal entry shape violation (kind '${entry.kind}'): ` + shapeIssues.map((i) => i.message).join("; "));
7086
- await this.store.append(this.runId, entry, this.lease);
7089
+ await this.store.append(this.runId, entry, this.leaseOf?.() ?? this.lease);
7087
7090
  this.entries.push(entry);
7088
7091
  if (entry.status === "suspended") this.foldInternal.registerSuspended(entry);
7089
7092
  else if (entry.kind !== "resolution" && entry.kind !== "abandon") this.foldInternal.registerEntry(entry);
@@ -9325,6 +9328,8 @@ function mergeUsageLimits(call, profile, engine) {
9325
9328
  if (maxCallsPerTool !== void 0) merged.maxCallsPerTool = maxCallsPerTool;
9326
9329
  const toolUnits = pick("toolUnits");
9327
9330
  if (toolUnits !== void 0) merged.toolUnits = toolUnits;
9331
+ const finalizationReserve = pick("finalizationReserve");
9332
+ if (finalizationReserve !== void 0) merged.finalizationReserve = finalizationReserve;
9328
9333
  return merged;
9329
9334
  }
9330
9335
  /**
@@ -9364,6 +9369,12 @@ function validateUsageLimits(limits, site) {
9364
9369
  for (const [name, cost] of Object.entries(costs)) requireNonNegativeInteger(cost, `${site}.toolUnits.costs['${name}']`);
9365
9370
  }
9366
9371
  }
9372
+ if (limits.finalizationReserve !== void 0) {
9373
+ const reserve = limits.finalizationReserve;
9374
+ if (typeof reserve !== "object" || reserve === null || Array.isArray(reserve)) throw new ConfigError(`${site}.finalizationReserve must be { maxOutputTokens? }`);
9375
+ const { maxOutputTokens } = reserve;
9376
+ if (maxOutputTokens !== void 0) requirePositiveInteger(maxOutputTokens, `${site}.finalizationReserve.maxOutputTokens`);
9377
+ }
9367
9378
  }
9368
9379
  //#endregion
9369
9380
  //#region src/runtime/model-retry.ts
@@ -10527,8 +10538,27 @@ async function runAgent(options) {
10527
10538
  let toolCallsUsed = 0;
10528
10539
  let escalationRequest;
10529
10540
  let abortClass;
10541
+ /**
10542
+ * Set at a tool-budget expiry when limits.finalizationReserve is
10543
+ * configured (P1.1); the reserve turn itself runs at ONE site after
10544
+ * the loop ends (the pending-turn path trips before the dispatch
10545
+ * machinery below is even defined), inside the still-open loop phase.
10546
+ */
10547
+ let reserveRequest;
10530
10548
  const noProgress = new NoProgressDetector(limits.noProgressTurns);
10531
10549
  const guard = explorationTrackingEnabled(limits) ? new ExplorationGuard(limits) : void 0;
10550
+ /**
10551
+ * The exact limiter behind a tool-budget expiry, with its counts: the
10552
+ * wording rides the finalization-reserve instruction and the 'limit'
10553
+ * terminal's errorMessage (P1.1 criterion: the terminal names the
10554
+ * limiter, never a bare status).
10555
+ */
10556
+ const toolBudgetDetail = (limiter) => {
10557
+ if (limiter === "maxToolCalls") return `maxToolCalls (${String(toolCallsUsed)}/${String(limits.maxToolCalls ?? 0)})`;
10558
+ const max = limits.toolUnits?.max ?? 0;
10559
+ const used = guard === void 0 ? max : guard.summary(toolCallsUsed).toolUnitsUsed ?? max;
10560
+ return `toolUnits (${String(used)}/${String(max)})`;
10561
+ };
10532
10562
  if (limits.toolBudgetNotices === true && limits.maxToolCalls === void 0) events?.emit({
10533
10563
  type: "log",
10534
10564
  level: "warn",
@@ -10650,15 +10680,42 @@ async function runAgent(options) {
10650
10680
  part.isError = true;
10651
10681
  return part;
10652
10682
  };
10683
+ /**
10684
+ * Closes the batch tail at a tool-budget expiry (P1.1): with the
10685
+ * finalization reserve configured every not-admitted call gets a
10686
+ * typed skipped-call error result naming the limiter, so the model
10687
+ * (and the transcript) sees exactly which calls never executed and
10688
+ * the summary turn's history stays well formed (providers reject
10689
+ * tool calls without matching results). Without the reserve the
10690
+ * tail stays unanswered, byte-identical to before.
10691
+ */
10692
+ const closeSkippedTail = (skippedCalls, limiter) => {
10693
+ if (limits.finalizationReserve === void 0) return;
10694
+ for (const call of skippedCalls) parts.push(errorPart(call, {
10695
+ error: "skipped: the tool budget is exhausted; the call was not executed",
10696
+ limiter,
10697
+ skipped: true
10698
+ }));
10699
+ };
10653
10700
  for (const [index, call] of calls.entries()) {
10654
- if (limits.maxToolCalls !== void 0 && toolCallsUsed >= limits.maxToolCalls) return {
10655
- parts,
10656
- limitHit: true
10657
- };
10658
- if (guard !== void 0 && guard.unitsExhausted()) return {
10659
- parts,
10660
- limitHit: true
10661
- };
10701
+ if (limits.maxToolCalls !== void 0 && toolCallsUsed >= limits.maxToolCalls) {
10702
+ closeSkippedTail(calls.slice(index), "maxToolCalls");
10703
+ return {
10704
+ parts,
10705
+ limitHit: true,
10706
+ limiter: "maxToolCalls",
10707
+ skipped: calls.length - index
10708
+ };
10709
+ }
10710
+ if (guard !== void 0 && guard.unitsExhausted()) {
10711
+ closeSkippedTail(calls.slice(index), "toolUnits");
10712
+ return {
10713
+ parts,
10714
+ limitHit: true,
10715
+ limiter: "toolUnits",
10716
+ skipped: calls.length - index
10717
+ };
10718
+ }
10662
10719
  const def = runtime.defs.find((candidate) => candidate.name === call.name);
10663
10720
  events?.emit({
10664
10721
  type: "tool:start",
@@ -10860,7 +10917,7 @@ async function runAgent(options) {
10860
10917
  if (record.isError === true) part.isError = true;
10861
10918
  return part;
10862
10919
  });
10863
- const { parts, limitHit, escalated, finished, guardTrip } = await runToolCalls([restored.pending.awaiting, ...restored.pending.remaining], priorParts);
10920
+ const { parts, limitHit, escalated, finished, guardTrip, limiter, skipped } = await runToolCalls([restored.pending.awaiting, ...restored.pending.remaining], priorParts);
10864
10921
  if (parts.length > 0) messages.push({
10865
10922
  role: "tool",
10866
10923
  parts
@@ -10881,6 +10938,16 @@ async function runAgent(options) {
10881
10938
  retryable: false
10882
10939
  };
10883
10940
  errorMessage = guard.describeTrip();
10941
+ } else if (limiter !== void 0 && limits.finalizationReserve !== void 0) {
10942
+ agentError = {
10943
+ kind: "terminal",
10944
+ retryable: false
10945
+ };
10946
+ errorMessage = `tool budget exhausted: ${toolBudgetDetail(limiter)}; skipped tool calls: ${String(skipped ?? 0)}`;
10947
+ reserveRequest = {
10948
+ limiter,
10949
+ skipped: skipped ?? 0
10950
+ };
10884
10951
  }
10885
10952
  } else {
10886
10953
  maybePushBudgetNotice();
@@ -11282,7 +11349,7 @@ async function runAgent(options) {
11282
11349
  }
11283
11350
  if (options.tools !== void 0 && outcome.turn.toolCalls.length > 0) {
11284
11351
  noProgress.recordTurn({ toolCalls: outcome.turn.toolCalls.length });
11285
- const { parts, limitHit, escalated, finished, guardTrip } = await runToolCalls(outcome.turn.toolCalls, []);
11352
+ const { parts, limitHit, escalated, finished, guardTrip, limiter, skipped } = await runToolCalls(outcome.turn.toolCalls, []);
11286
11353
  if (parts.length > 0) messages.push({
11287
11354
  role: "tool",
11288
11355
  parts
@@ -11307,6 +11374,16 @@ async function runAgent(options) {
11307
11374
  retryable: false
11308
11375
  };
11309
11376
  errorMessage = guard.describeTrip();
11377
+ } else if (limiter !== void 0 && limits.finalizationReserve !== void 0) {
11378
+ agentError = {
11379
+ kind: "terminal",
11380
+ retryable: false
11381
+ };
11382
+ errorMessage = `tool budget exhausted: ${toolBudgetDetail(limiter)}; skipped tool calls: ${String(skipped ?? 0)}`;
11383
+ reserveRequest = {
11384
+ limiter,
11385
+ skipped: skipped ?? 0
11386
+ };
11310
11387
  }
11311
11388
  break;
11312
11389
  }
@@ -11492,6 +11569,110 @@ async function runAgent(options) {
11492
11569
  await saveBoundary();
11493
11570
  continue loop;
11494
11571
  }
11572
+ if (status === "limit" && reserveRequest !== void 0) {
11573
+ const { limiter, skipped } = reserveRequest;
11574
+ let proceed = true;
11575
+ try {
11576
+ options.budget?.beforeTurn();
11577
+ } catch {
11578
+ events?.emit({
11579
+ type: "log",
11580
+ level: "warn",
11581
+ msg: "the finalization reserve turn was skipped: the budget blocks further turns"
11582
+ });
11583
+ proceed = false;
11584
+ }
11585
+ if (proceed) {
11586
+ turns += 1;
11587
+ const reserveMessages = [...messages, {
11588
+ role: "user",
11589
+ parts: [{
11590
+ type: "text",
11591
+ text: `The tool budget is exhausted (${toolBudgetDetail(limiter)}). Skipped tool calls: ${String(skipped)}; no further tool calls will execute. This is the final turn: produce your best final answer from the evidence already collected.`
11592
+ }]
11593
+ }];
11594
+ let reserveDispatch;
11595
+ try {
11596
+ reserveDispatch = await dispatchPhase({
11597
+ role: primaryRole,
11598
+ chain: loopChain,
11599
+ cursor: loopCursor,
11600
+ requestFor: (target) => {
11601
+ let req = buildRequest(target.resolved, projectHistory(reserveMessages, providerOf(target.adapter)), limits, options.tools?.contracts);
11602
+ if (options.schema !== void 0 && options.canonicalSchema !== void 0 && !separateExtract) req = applyStructuredOutputTier(req, rideTierFor(target), options.canonicalSchema);
11603
+ if (req.tools !== void 0) req = {
11604
+ ...req,
11605
+ toolChoice: "none"
11606
+ };
11607
+ const reserveMax = limits.finalizationReserve?.maxOutputTokens;
11608
+ if (reserveMax !== void 0) req = {
11609
+ ...req,
11610
+ maxOutputTokens: Math.min(req.maxOutputTokens ?? reserveMax, reserveMax)
11611
+ };
11612
+ return applyOutputBudget(req, target, options.budget);
11613
+ },
11614
+ streamOptionsFor: (target) => {
11615
+ const reserveStreamOptions = {
11616
+ idleTimeoutMs: limits.streamIdleTimeoutMs,
11617
+ signals: options.signal === void 0 ? [] : [options.signal],
11618
+ onUsage: (delta) => options.budget?.onUsage(delta, target.resolved.ref)
11619
+ };
11620
+ if (options.budget?.signal !== void 0) reserveStreamOptions.budgetSignal = options.budget.signal;
11621
+ if (options.stream === true) reserveStreamOptions.onDelta = (delta) => events?.emit({
11622
+ type: "agent:stream",
11623
+ delta
11624
+ });
11625
+ return reserveStreamOptions;
11626
+ }
11627
+ });
11628
+ } catch (thrown) {
11629
+ if (!(thrown instanceof BudgetExhaustedError)) throw thrown;
11630
+ events?.emit({
11631
+ type: "log",
11632
+ level: "warn",
11633
+ msg: `the finalization reserve turn was skipped: ${thrown.message}`
11634
+ });
11635
+ }
11636
+ if (reserveDispatch !== void 0) {
11637
+ const { outcome, target: reserveTarget } = reserveDispatch;
11638
+ servedBy = reserveTarget.resolved.ref;
11639
+ usageApprox = usageApprox || outcome.usageApprox;
11640
+ messages.push(assistantMsg(outcome.turn, liftRetainedParts(outcome.providerMetadata, reserveTarget.adapter)));
11641
+ if (invariantViolation !== void 0) {
11642
+ status = "error";
11643
+ agentError = {
11644
+ kind: "transport",
11645
+ retryable: false
11646
+ };
11647
+ errorMessage = invariantViolation;
11648
+ } else if (outcome.aborted === "external") status = "cancelled";
11649
+ else if (outcome.aborted === "budget") {
11650
+ status = "cancelled";
11651
+ agentError = {
11652
+ kind: "budget",
11653
+ retryable: false
11654
+ };
11655
+ } else {
11656
+ await saveBoundary();
11657
+ if (outcome.wireError !== void 0 || outcome.aborted === "idle") events?.emit({
11658
+ type: "log",
11659
+ level: "warn",
11660
+ msg: "the finalization reserve turn failed; the limit terminal stands" + (outcome.wireError === void 0 ? " (stream idle timeout)" : ` (${outcome.wireError.message})`)
11661
+ });
11662
+ else if (options.schema === void 0) {
11663
+ const summary = outcome.turn.text;
11664
+ if (summary.trim() !== "") output = summary;
11665
+ } else if (!separateExtract && options.canonicalSchema !== void 0) {
11666
+ const candidate = extractCandidate(outcome.turn, rideTierFor(reserveTarget));
11667
+ if (candidate !== void 0) {
11668
+ const validation = await validateSchemaSpec(options.schema, candidate.raw);
11669
+ if (validation.valid) output = validation.value;
11670
+ }
11671
+ }
11672
+ }
11673
+ }
11674
+ }
11675
+ }
11495
11676
  endPhase(loopPhase, phaseOutcome(), servedBy);
11496
11677
  if (status === "ok" && !finishedViaTool && options.finalize !== void 0) {
11497
11678
  const finalizeResolved = options.finalize.resolved;
@@ -14206,7 +14387,7 @@ function createCtx(internals, rootWorkflow) {
14206
14387
  transcriptRef: result.transcriptRef
14207
14388
  };
14208
14389
  if (result.status === "escalated" && result.escalation !== void 0) terminalPatch.escalation = result.escalation;
14209
- if (result.output !== null && result.status === "ok") terminalPatch.value = result.output;
14390
+ if (result.output !== null && (result.status === "ok" || result.status === "limit")) terminalPatch.value = result.output;
14210
14391
  if (result.error !== void 0) terminalPatch.error = agentErrorToWire(result.error, result.errorMessage ?? `agent terminated with status ${result.status}`);
14211
14392
  const resultUsageApprox = result.usageApprox === true;
14212
14393
  if (resultUsageApprox) terminalPatch.usageApprox = true;
@@ -17119,6 +17300,28 @@ function hashRunOutput(value) {
17119
17300
  return;
17120
17301
  }
17121
17302
  }
17303
+ /**
17304
+ * Engine ownership identity: a process-local counter, not Math.random()
17305
+ * (the queue worker's identity convention): owner strings need
17306
+ * uniqueness within the store, and the dev-mode bare-randomness guard
17307
+ * stays armed while any run is live.
17308
+ */
17309
+ let engineOrdinal = 0;
17310
+ function engineIdentity() {
17311
+ engineOrdinal += 1;
17312
+ return `rulvar-engine:${process.pid}:${engineOrdinal}`;
17313
+ }
17314
+ /** Lease capability guard, mirroring createWorker's detection. */
17315
+ function leaseCapable(store) {
17316
+ const candidate = store;
17317
+ return typeof candidate.acquire === "function" && typeof candidate.renew === "function" && typeof candidate.release === "function";
17318
+ }
17319
+ /**
17320
+ * The renew-cadence fallback when a leasable store exposes no
17321
+ * leaseTtlMs: the Appendix A interim reference ttl the shipped stores
17322
+ * default to (60000 ms).
17323
+ */
17324
+ const ENGINE_DEFAULT_LEASE_TTL_MS = 6e4;
17122
17325
  function createEngine(options) {
17123
17326
  const adapters = buildAdapterRegistry(options.adapters);
17124
17327
  const rawJournal = options.stores?.journal ?? new InMemoryStore();
@@ -17128,6 +17331,17 @@ function createEngine(options) {
17128
17331
  const maskEvents = options.redaction?.maskEvents ?? true;
17129
17332
  const eventMasker = options.redaction?.patterns === void 0 ? void 0 : compileSecretMasker(options.redaction.patterns, "createEngine redaction.patterns");
17130
17333
  const defaults = options.defaults ?? {};
17334
+ const ownership = options.ownership ?? "auto";
17335
+ if (ownership !== "auto" && ownership !== "none") throw new ConfigError(`createEngine ownership must be 'auto' or 'none'; got '${String(ownership)}'`);
17336
+ const engineOwner = engineIdentity();
17337
+ let ownershipRenewMs = Math.max(1, Math.floor(ENGINE_DEFAULT_LEASE_TTL_MS / 3));
17338
+ if (ownership === "auto" && leaseCapable(journal)) {
17339
+ const storeTtlMs = journal.leaseTtlMs;
17340
+ if (storeTtlMs !== void 0) {
17341
+ if (!Number.isInteger(storeTtlMs) || storeTtlMs < 1 || storeTtlMs > 2147483647) throw new ConfigError(`the journal store's leaseTtlMs capability must report an integer between 1 and 2147483647 ms for the engine's ownership renew cadence; got ${String(storeTtlMs)}`);
17342
+ ownershipRenewMs = Math.max(1, Math.floor(storeTtlMs / 3));
17343
+ }
17344
+ }
17131
17345
  if (defaults.retry !== void 0) validateRetryPolicy(defaults.retry, "createEngine defaults.retry");
17132
17346
  if (options.concurrency?.perRun !== void 0) requirePositiveInteger(options.concurrency.perRun, "createEngine concurrency.perRun");
17133
17347
  for (const [adapterId, cap] of Object.entries(options.concurrency?.perProvider ?? {})) requirePositiveInteger(cap, `createEngine concurrency.perProvider['${adapterId}']`);
@@ -17182,6 +17396,10 @@ function createEngine(options) {
17182
17396
  if (compiled !== void 0 && options.runners?.sandbox === void 0) throw new ConfigError("running a CompiledWorkflow requires a sandbox runner: pass createEngine({ runners: { sandbox: new WorkerSandboxRunner() } }) from @rulvar/planner ");
17183
17397
  const runId = resumeCtx?.runId ?? opts?.runId ?? mintRunId();
17184
17398
  assertSafeRunId(runId, "engine.run");
17399
+ const suppliedLease = resumeCtx?.lease ?? opts?.lease;
17400
+ if (suppliedLease !== void 0 && suppliedLease.runId !== runId) throw new ConfigError(`the supplied lease is for run '${suppliedLease.runId}', not '${runId}'; a lease fences exactly the run it was acquired for`);
17401
+ const segmentLease = {};
17402
+ if (suppliedLease !== void 0) segmentLease.current = suppliedLease;
17185
17403
  const registry = buildDeriverRegistry(options.extraDerivers);
17186
17404
  const segmentsBefore = resumeCtx?.segmentsBefore ?? 0;
17187
17405
  const telemetryBase = segmentsBefore * EVENT_SEGMENT_STRIDE;
@@ -17218,7 +17436,7 @@ function createEngine(options) {
17218
17436
  }, rootSpanId),
17219
17437
  keyRing: registryKeyRing(registry),
17220
17438
  ...resumeCtx === void 0 ? {} : { priorEntries: resumeCtx.priorEntries },
17221
- ...resumeCtx?.lease === void 0 ? {} : { lease: resumeCtx.lease },
17439
+ leaseOf: () => segmentLease.current,
17222
17440
  strict: resumeCtx?.strict ?? false
17223
17441
  });
17224
17442
  for (const seqToInvalidate of invalidated) replayer.invalidate(seqToInvalidate);
@@ -17313,7 +17531,9 @@ function createEngine(options) {
17313
17531
  external,
17314
17532
  mintTranscriptRef: () => `${runId}/t${transcriptCounter++}`,
17315
17533
  now: realNow,
17316
- ...resumeCtx?.lease === void 0 ? {} : { lease: resumeCtx.lease }
17534
+ get lease() {
17535
+ return segmentLease.current;
17536
+ }
17317
17537
  };
17318
17538
  const argsBinding = {};
17319
17539
  if (resumeCtx === void 0) {
@@ -17341,15 +17561,60 @@ function createEngine(options) {
17341
17561
  workflowName: wf.name,
17342
17562
  workflowHash: compiled === void 0 ? hashWorkflowBody(wf) : hashWorkflowSource(compiled.source),
17343
17563
  ...compiled === void 0 ? {} : { workflowSourceRef: workflowSourceRef(runId) }
17344
- }, resumeCtx?.lease);
17564
+ }, segmentLease.current);
17345
17565
  if (activeSegments.has(runId)) throw new ConfigError(`run '${runId}' already has a live execution segment in this engine; await its settled result before starting another one (exactly one segment owns a run; https://docs.rulvar.com/guide/durability#resolving-a-settled-run)`);
17346
17566
  activeSegments.add(runId);
17567
+ let ownershipTeardown = () => Promise.resolve();
17347
17568
  const result = (async () => {
17569
+ let ownedLease;
17570
+ let renewTimer;
17571
+ const settleOwnership = async () => {
17572
+ if (renewTimer !== void 0) {
17573
+ clearInterval(renewTimer);
17574
+ renewTimer = void 0;
17575
+ }
17576
+ const held = ownedLease;
17577
+ ownedLease = void 0;
17578
+ if (held !== void 0) {
17579
+ segmentLease.current = void 0;
17580
+ try {
17581
+ await journal.release(held);
17582
+ } catch {}
17583
+ }
17584
+ };
17585
+ ownershipTeardown = settleOwnership;
17586
+ if (ownership === "auto" && segmentLease.current === void 0 && resumeCtx?.strict !== true && leaseCapable(journal)) {
17587
+ let acquired;
17588
+ try {
17589
+ acquired = await journal.acquire(runId, engineOwner);
17590
+ } catch (thrown) {
17591
+ if (deadlineTimer !== void 0) deadlineTimer.cancel();
17592
+ external.close();
17593
+ bus.end();
17594
+ throw thrown;
17595
+ }
17596
+ ownedLease = acquired;
17597
+ segmentLease.current = acquired;
17598
+ renewTimer = setInterval(() => {
17599
+ journal.renew(acquired).catch(() => {
17600
+ bus.emit({
17601
+ type: "log",
17602
+ level: "warn",
17603
+ msg: `run '${runId}' ownership lost: the lease could not be renewed and its fencing epoch may be superseded`
17604
+ }, rootSpanId);
17605
+ requestCancel("run ownership lost: lease fencing epoch superseded");
17606
+ if (renewTimer !== void 0) {
17607
+ clearInterval(renewTimer);
17608
+ renewTimer = void 0;
17609
+ }
17610
+ });
17611
+ }, ownershipRenewMs);
17612
+ }
17348
17613
  let status = "ok";
17349
17614
  let value;
17350
17615
  let wireError;
17351
17616
  let pending = [];
17352
- if (compiled !== void 0 && resumeCtx?.strict !== true) await transcripts.put(workflowSourceRef(runId), new TextEncoder().encode(compiled.source), resumeCtx?.lease);
17617
+ if (compiled !== void 0 && resumeCtx?.strict !== true) await transcripts.put(workflowSourceRef(runId), new TextEncoder().encode(compiled.source), segmentLease.current);
17353
17618
  await putMeta("running");
17354
17619
  bus.emit({
17355
17620
  type: "run:start",
@@ -17476,9 +17741,11 @@ function createEngine(options) {
17476
17741
  ...replayer.resumeReport(),
17477
17742
  invalidResolutions: replayer.fold.invalidResolutions()
17478
17743
  });
17744
+ await settleOwnership();
17479
17745
  return outcome;
17480
17746
  })();
17481
17747
  result.catch(() => void 0).finally(() => {
17748
+ ownershipTeardown();
17482
17749
  activeSegments.delete(runId);
17483
17750
  });
17484
17751
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/core",
3
- "version": "1.59.3",
3
+ "version": "1.60.0",
4
4
  "description": "Rulvar core: L0 contracts, journal kernel, ctx primitives, agent runtime, model router, tool system, dynamic orchestrator, InMemory and JSONL stores, event stream.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",