@rulvar/core 1.59.2 → 1.59.4

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
@@ -4094,8 +4103,14 @@ interface ToolRuntime {
4094
4103
  * toolset holds any non-inprocess tool; the ctx layer mints the tool
4095
4104
  * span and idempotency key and wires the provider. A throw becomes the
4096
4105
  * call's error tool result exactly like an inprocess execute throw.
4106
+ *
4107
+ * `ordinal` is the call's 1-based position in this agent invocation's
4108
+ * tool loop (checkpoint-stable across suspension and crash resume); the
4109
+ * ctx layer folds it with the agent entry's seq into the idempotency
4110
+ * key, so two separate calls with identical arguments do not collide
4111
+ * while an at-least-once retry of one call keeps its key (P0.4).
4097
4112
  */
4098
- executeExternal?: (def: ToolDef, args: Json) => Promise<unknown>;
4113
+ executeExternal?: (def: ToolDef, args: Json, ordinal: number) => Promise<unknown>;
4099
4114
  }
4100
4115
  /** One serving target of a phase: the primary or a failover fallback. */
4101
4116
  interface PhaseTarget {
@@ -5676,6 +5691,25 @@ interface CreateEngineOptions {
5676
5691
  security?: {
5677
5692
  argsHashSalt?: string;
5678
5693
  };
5694
+ /**
5695
+ * The genesis ownership protocol (P0.2): over a journal store with
5696
+ * the lease capability, a run or resume segment that was NOT handed
5697
+ * a lease acquires its own before its first durable mutation, renews
5698
+ * it at ttl/3 exactly like a queue worker, and releases it at
5699
+ * settle. Fresh start, in-process resume, and worker takeover then
5700
+ * share ONE owner/lease contract: at most one live driver per run
5701
+ * across processes, a second driver's acquire rejects with the typed
5702
+ * LeaseHeldError before any write or provider dispatch, and a
5703
+ * crashed owner's lease expires after the store ttl so a worker
5704
+ * sweep recovers the run. Default 'auto'. 'none' restores the
5705
+ * pre-1.59.4 behavior (no engine-acquired leases) for hosts that
5706
+ * coordinate ownership entirely outside the engine; a lease passed
5707
+ * via RunOptions.lease or ResumeOptions.lease always wins over both
5708
+ * modes (the caller owns acquire, renew, and release). Stores
5709
+ * without the lease capability are unaffected: the embedded
5710
+ * single-process default keeps the single-writer precondition.
5711
+ */
5712
+ ownership?: "auto" | "none";
5679
5713
  }
5680
5714
  interface RunOptions {
5681
5715
  /** Explicit id; otherwise the engine mints a ULID. */
@@ -5707,6 +5741,19 @@ interface RunOptions {
5707
5741
  tags?: string[];
5708
5742
  /** Host-initiated cancellation. */
5709
5743
  signal?: AbortSignal;
5744
+ /**
5745
+ * A lease the caller already holds for this run (the genesis side of
5746
+ * the ResumeOptions.lease contract): the engine carries it on EVERY
5747
+ * durable mutation of the fresh segment (every journal append, every
5748
+ * putMeta, every transcript blob write) and never acquires, renews,
5749
+ * or releases it itself; lifecycle stays with the caller. Passing it
5750
+ * disables the engine's own ownership acquisition for this run
5751
+ * regardless of the `ownership` mode. Hosts that admit runs through
5752
+ * an external queue acquire the lease at admission time and hand it
5753
+ * here, so admission and the first dispatch are covered by ONE
5754
+ * fencing epoch.
5755
+ */
5756
+ lease?: Lease;
5710
5757
  }
5711
5758
  /** Resume-time hit/miss/orphan accounting. */
5712
5759
  interface ResumePreview extends ResumeReport {
@@ -7328,8 +7375,12 @@ interface RunInternals {
7328
7375
  * worktree patches) exactly as the Replayer threads it into every
7329
7376
  * journal append, so a store declaring fencedWrites refuses a
7330
7377
  * superseded segment's blob overwrites (fenced run state RFC, F2).
7378
+ * The engine binds this as a live getter over its segment-lease
7379
+ * holder (P0.2), so the union with undefined is explicit: before
7380
+ * the ownership boot (and on non-leasable stores) it reads
7381
+ * undefined.
7331
7382
  */
7332
- lease?: Lease;
7383
+ lease?: Lease | undefined;
7333
7384
  adapters: ReadonlyMap<string, ProviderAdapter>;
7334
7385
  defaults: {
7335
7386
  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);
@@ -10402,7 +10405,7 @@ async function executeToolCall(options) {
10402
10405
  try {
10403
10406
  let value;
10404
10407
  if (def.executor === "inprocess") value = await def.execute(validation.value, runtime.contextFor(call.name));
10405
- else if (runtime.executeExternal !== void 0) value = await runtime.executeExternal(def, validation.value);
10408
+ else if (runtime.executeExternal !== void 0) value = await runtime.executeExternal(def, validation.value, options.ordinal);
10406
10409
  else return finish({ error: `tool '${call.name}' declares executor '${def.executor}' but no executor is registered` }, "error");
10407
10410
  const serialized = toJournalValue(value === void 0 ? null : value, `tool '${call.name}'`);
10408
10411
  options.retryCounts.delete(call.name);
@@ -10829,6 +10832,7 @@ async function runAgent(options) {
10829
10832
  runtime,
10830
10833
  retryCounts: modelRetryCounts,
10831
10834
  maxModelRetries: options.modelRetryAttempts ?? 2,
10835
+ ordinal: toolCallsUsed,
10832
10836
  ...events === void 0 ? {} : { events },
10833
10837
  ...gateAudit === void 0 ? {} : { audit: gateAudit },
10834
10838
  now
@@ -13170,15 +13174,30 @@ function setLongTimeout(onDue, dueAtMs, now = Date.now) {
13170
13174
  */
13171
13175
  /**
13172
13176
  * Derives the idempotency key for one isolated tool dispatch. The key is
13173
- * a pure function of the run, the tool name, and the JCS-canonical
13174
- * arguments, so the same logical call always yields the same key
13175
- * (byte-identical reruns dedupe) and distinct calls never collide. The
13176
- * key never enters run identity; it exists only for the provider's own
13177
- * side-effect deduplication.
13177
+ * a pure function of the run, the LOGICAL INVOCATION (the seq of the
13178
+ * containing agent's journal entry plus that call's ordinal within the
13179
+ * agent's tool loop), the tool name, and the JCS-canonical arguments.
13180
+ *
13181
+ * The logical-invocation component is what makes the key both stable and
13182
+ * distinguishing (v1.59.x review P0.4): the agent-entry seq and the
13183
+ * per-agent tool-call ordinal are journal- and checkpoint-stable, so a
13184
+ * crash-and-resume re-dispatch of the SAME logical call (the at-least-
13185
+ * once window between execution and the turn checkpoint) reuses the same
13186
+ * dispatch entry and the restored ordinal, and therefore the same key;
13187
+ * while two SEPARATE calls in one run, even with byte-identical
13188
+ * arguments, occupy different ordinals and never collide. Without it two
13189
+ * intended effects sharing arguments would fold into one under external
13190
+ * deduplication.
13191
+ *
13192
+ * The key never enters run identity (it is absent from every content key
13193
+ * and toolset hash); it exists only for the provider's own side-effect
13194
+ * deduplication.
13178
13195
  */
13179
- function deriveExecIdempotencyKey(runId, tool, args) {
13196
+ function deriveExecIdempotencyKey(runId, agentSeq, ordinal, tool, args) {
13180
13197
  const canonical = jcsSerialize({
13181
13198
  runId,
13199
+ agentSeq,
13200
+ ordinal,
13182
13201
  tool,
13183
13202
  args
13184
13203
  });
@@ -13980,7 +13999,8 @@ function createCtx(internals, rootWorkflow) {
13980
13999
  };
13981
14000
  if (internals.executors !== void 0) {
13982
14001
  const executors = internals.executors;
13983
- toolRuntime.executeExternal = async (def, args) => {
14002
+ const agentSeq = running.seq;
14003
+ toolRuntime.executeExternal = async (def, args, ordinal) => {
13984
14004
  const tag = def.executor;
13985
14005
  const provider = executors[tag];
13986
14006
  if (provider === void 0) throw new ConfigError(`no executor registered for '${def.executor}'; register one via createEngine({ executors }) (https://docs.rulvar.com/guide/isolated-executor)`);
@@ -13994,7 +14014,7 @@ function createCtx(internals, rootWorkflow) {
13994
14014
  runId: internals.runId,
13995
14015
  spanId: toolSpanId,
13996
14016
  agentType,
13997
- idempotencyKey: deriveExecIdempotencyKey(internals.runId, def.name, args),
14017
+ idempotencyKey: deriveExecIdempotencyKey(internals.runId, agentSeq, ordinal, def.name, args),
13998
14018
  signal: toolSignal,
13999
14019
  log: (level, msg, data) => internals.events.emit(data === void 0 ? {
14000
14020
  type: "log",
@@ -17102,6 +17122,28 @@ function hashRunOutput(value) {
17102
17122
  return;
17103
17123
  }
17104
17124
  }
17125
+ /**
17126
+ * Engine ownership identity: a process-local counter, not Math.random()
17127
+ * (the queue worker's identity convention): owner strings need
17128
+ * uniqueness within the store, and the dev-mode bare-randomness guard
17129
+ * stays armed while any run is live.
17130
+ */
17131
+ let engineOrdinal = 0;
17132
+ function engineIdentity() {
17133
+ engineOrdinal += 1;
17134
+ return `rulvar-engine:${process.pid}:${engineOrdinal}`;
17135
+ }
17136
+ /** Lease capability guard, mirroring createWorker's detection. */
17137
+ function leaseCapable(store) {
17138
+ const candidate = store;
17139
+ return typeof candidate.acquire === "function" && typeof candidate.renew === "function" && typeof candidate.release === "function";
17140
+ }
17141
+ /**
17142
+ * The renew-cadence fallback when a leasable store exposes no
17143
+ * leaseTtlMs: the Appendix A interim reference ttl the shipped stores
17144
+ * default to (60000 ms).
17145
+ */
17146
+ const ENGINE_DEFAULT_LEASE_TTL_MS = 6e4;
17105
17147
  function createEngine(options) {
17106
17148
  const adapters = buildAdapterRegistry(options.adapters);
17107
17149
  const rawJournal = options.stores?.journal ?? new InMemoryStore();
@@ -17111,6 +17153,17 @@ function createEngine(options) {
17111
17153
  const maskEvents = options.redaction?.maskEvents ?? true;
17112
17154
  const eventMasker = options.redaction?.patterns === void 0 ? void 0 : compileSecretMasker(options.redaction.patterns, "createEngine redaction.patterns");
17113
17155
  const defaults = options.defaults ?? {};
17156
+ const ownership = options.ownership ?? "auto";
17157
+ if (ownership !== "auto" && ownership !== "none") throw new ConfigError(`createEngine ownership must be 'auto' or 'none'; got '${String(ownership)}'`);
17158
+ const engineOwner = engineIdentity();
17159
+ let ownershipRenewMs = Math.max(1, Math.floor(ENGINE_DEFAULT_LEASE_TTL_MS / 3));
17160
+ if (ownership === "auto" && leaseCapable(journal)) {
17161
+ const storeTtlMs = journal.leaseTtlMs;
17162
+ if (storeTtlMs !== void 0) {
17163
+ 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)}`);
17164
+ ownershipRenewMs = Math.max(1, Math.floor(storeTtlMs / 3));
17165
+ }
17166
+ }
17114
17167
  if (defaults.retry !== void 0) validateRetryPolicy(defaults.retry, "createEngine defaults.retry");
17115
17168
  if (options.concurrency?.perRun !== void 0) requirePositiveInteger(options.concurrency.perRun, "createEngine concurrency.perRun");
17116
17169
  for (const [adapterId, cap] of Object.entries(options.concurrency?.perProvider ?? {})) requirePositiveInteger(cap, `createEngine concurrency.perProvider['${adapterId}']`);
@@ -17165,6 +17218,10 @@ function createEngine(options) {
17165
17218
  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 ");
17166
17219
  const runId = resumeCtx?.runId ?? opts?.runId ?? mintRunId();
17167
17220
  assertSafeRunId(runId, "engine.run");
17221
+ const suppliedLease = resumeCtx?.lease ?? opts?.lease;
17222
+ 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`);
17223
+ const segmentLease = {};
17224
+ if (suppliedLease !== void 0) segmentLease.current = suppliedLease;
17168
17225
  const registry = buildDeriverRegistry(options.extraDerivers);
17169
17226
  const segmentsBefore = resumeCtx?.segmentsBefore ?? 0;
17170
17227
  const telemetryBase = segmentsBefore * EVENT_SEGMENT_STRIDE;
@@ -17201,7 +17258,7 @@ function createEngine(options) {
17201
17258
  }, rootSpanId),
17202
17259
  keyRing: registryKeyRing(registry),
17203
17260
  ...resumeCtx === void 0 ? {} : { priorEntries: resumeCtx.priorEntries },
17204
- ...resumeCtx?.lease === void 0 ? {} : { lease: resumeCtx.lease },
17261
+ leaseOf: () => segmentLease.current,
17205
17262
  strict: resumeCtx?.strict ?? false
17206
17263
  });
17207
17264
  for (const seqToInvalidate of invalidated) replayer.invalidate(seqToInvalidate);
@@ -17296,7 +17353,9 @@ function createEngine(options) {
17296
17353
  external,
17297
17354
  mintTranscriptRef: () => `${runId}/t${transcriptCounter++}`,
17298
17355
  now: realNow,
17299
- ...resumeCtx?.lease === void 0 ? {} : { lease: resumeCtx.lease }
17356
+ get lease() {
17357
+ return segmentLease.current;
17358
+ }
17300
17359
  };
17301
17360
  const argsBinding = {};
17302
17361
  if (resumeCtx === void 0) {
@@ -17324,15 +17383,60 @@ function createEngine(options) {
17324
17383
  workflowName: wf.name,
17325
17384
  workflowHash: compiled === void 0 ? hashWorkflowBody(wf) : hashWorkflowSource(compiled.source),
17326
17385
  ...compiled === void 0 ? {} : { workflowSourceRef: workflowSourceRef(runId) }
17327
- }, resumeCtx?.lease);
17386
+ }, segmentLease.current);
17328
17387
  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)`);
17329
17388
  activeSegments.add(runId);
17389
+ let ownershipTeardown = () => Promise.resolve();
17330
17390
  const result = (async () => {
17391
+ let ownedLease;
17392
+ let renewTimer;
17393
+ const settleOwnership = async () => {
17394
+ if (renewTimer !== void 0) {
17395
+ clearInterval(renewTimer);
17396
+ renewTimer = void 0;
17397
+ }
17398
+ const held = ownedLease;
17399
+ ownedLease = void 0;
17400
+ if (held !== void 0) {
17401
+ segmentLease.current = void 0;
17402
+ try {
17403
+ await journal.release(held);
17404
+ } catch {}
17405
+ }
17406
+ };
17407
+ ownershipTeardown = settleOwnership;
17408
+ if (ownership === "auto" && segmentLease.current === void 0 && resumeCtx?.strict !== true && leaseCapable(journal)) {
17409
+ let acquired;
17410
+ try {
17411
+ acquired = await journal.acquire(runId, engineOwner);
17412
+ } catch (thrown) {
17413
+ if (deadlineTimer !== void 0) deadlineTimer.cancel();
17414
+ external.close();
17415
+ bus.end();
17416
+ throw thrown;
17417
+ }
17418
+ ownedLease = acquired;
17419
+ segmentLease.current = acquired;
17420
+ renewTimer = setInterval(() => {
17421
+ journal.renew(acquired).catch(() => {
17422
+ bus.emit({
17423
+ type: "log",
17424
+ level: "warn",
17425
+ msg: `run '${runId}' ownership lost: the lease could not be renewed and its fencing epoch may be superseded`
17426
+ }, rootSpanId);
17427
+ requestCancel("run ownership lost: lease fencing epoch superseded");
17428
+ if (renewTimer !== void 0) {
17429
+ clearInterval(renewTimer);
17430
+ renewTimer = void 0;
17431
+ }
17432
+ });
17433
+ }, ownershipRenewMs);
17434
+ }
17331
17435
  let status = "ok";
17332
17436
  let value;
17333
17437
  let wireError;
17334
17438
  let pending = [];
17335
- if (compiled !== void 0 && resumeCtx?.strict !== true) await transcripts.put(workflowSourceRef(runId), new TextEncoder().encode(compiled.source), resumeCtx?.lease);
17439
+ if (compiled !== void 0 && resumeCtx?.strict !== true) await transcripts.put(workflowSourceRef(runId), new TextEncoder().encode(compiled.source), segmentLease.current);
17336
17440
  await putMeta("running");
17337
17441
  bus.emit({
17338
17442
  type: "run:start",
@@ -17459,9 +17563,11 @@ function createEngine(options) {
17459
17563
  ...replayer.resumeReport(),
17460
17564
  invalidResolutions: replayer.fold.invalidResolutions()
17461
17565
  });
17566
+ await settleOwnership();
17462
17567
  return outcome;
17463
17568
  })();
17464
17569
  result.catch(() => void 0).finally(() => {
17570
+ ownershipTeardown();
17465
17571
  activeSegments.delete(runId);
17466
17572
  });
17467
17573
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/core",
3
- "version": "1.59.2",
3
+ "version": "1.59.4",
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",