@rivus/agent-kit-collab 0.3.0 → 0.4.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/README.md CHANGED
@@ -31,11 +31,11 @@ until removed).
31
31
 
32
32
  ## Entries
33
33
 
34
- | Entry | Main exports | Kind |
35
- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
36
- | `@rivus/agent-kit-collab/process-lock` | `acquireProcessLock`, `ProcessLock`, `ProcessLockHeld` | plain TS |
37
- | `@rivus/agent-kit-collab/lease` | `createLeaseManager`, `LeaseStore`, `sqliteLeaseStore`, `fileLeaseStore`, `memoryLeaseStore`; rules `isFresh`, `canAcquire`, `nextFencingToken`, `checkFence`, `holderLiveness` | Effect |
38
- | `@rivus/agent-kit-collab/lanes` | `createLanes` | Effect |
34
+ | Entry | Main exports | Kind |
35
+ | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
36
+ | `@rivus/agent-kit-collab/process-lock` | `acquireProcessLock`, `ProcessLock`, `ProcessLockHeld` | plain TS |
37
+ | `@rivus/agent-kit-collab/lease` | `createLeaseManager`, `LeaseRepository`, `sqliteLeaseRepository`, `fileLeaseRepository`, `memoryLeaseRepository`; rules `isFresh`, `canAcquire`, `nextFencingToken`, `checkFence`, `holderLiveness` | Effect |
38
+ | `@rivus/agent-kit-collab/lanes` | `createLanes` | Effect |
39
39
 
40
40
  ### Process lock
41
41
 
@@ -62,10 +62,12 @@ instead of getting `ProcessLockHeld` at once.
62
62
 
63
63
  ```ts
64
64
  import { NodePlatformLive } from "@rivus/agent-kit/node/effect";
65
- import { createLeaseManager, sqliteLeaseStore } from "@rivus/agent-kit-collab/lease";
65
+ import { createLeaseManager, sqliteLeaseRepository } from "@rivus/agent-kit-collab/lease";
66
66
  import { Effect, Layer } from "effect";
67
67
 
68
- const LeaseLive = sqliteLeaseStore({ path: "/var/lib/my-app/leases.db" }).pipe(Layer.provideMerge(NodePlatformLive));
68
+ const LeaseLive = sqliteLeaseRepository({ path: "/var/lib/my-app/leases.db" }).pipe(
69
+ Layer.provideMerge(NodePlatformLive)
70
+ );
69
71
 
70
72
  const program = Effect.scoped(
71
73
  Effect.gen(function* () {
@@ -89,19 +91,24 @@ await Effect.runPromiseExit(program.pipe(Effect.provide(LeaseLive)));
89
91
  - A holder on the same host whose process is gone (or whose pid now belongs to another process) is taken over at
90
92
  once. A holder that is alive but stopped renewing is taken over after the TTL, measured by the observer's own
91
93
  monotonic clock from the moment it first saw the current record.
92
- - `runFenced` holds the store's per-key fence and re-reads the record before the work starts; waiting for the fence
94
+ - `runFenced` holds the repository's per-key fence and re-reads the record before the work starts; waiting for the fence
93
95
  stops when the lease is lost. The fence is released when the work's fiber ends, so a successor's fenced work starts
94
96
  after that. An interrupted fiber ends at once, but a Promise it started keeps running: put a write that cannot be
95
97
  cancelled in `Effect.uninterruptible` (the fence then waits for it to settle), or pass it the AbortSignal that
96
98
  `Effect.tryPromise` provides and let it settle only once the write has stopped.
97
99
  - Guard plus re-read is as strong as a check by the protected resource itself only when every writer goes through
98
- the same store on the same machine; a resource that can compare atomically should keep the highest token it has
100
+ the same repository on the same machine; a resource that can compare atomically should keep the highest token it has
99
101
  seen and call `checkFence`.
100
- - `sqliteLeaseStore` compares revisions inside `BEGIN IMMEDIATE` transactions. `fileLeaseStore` is the fallback
101
- without SQLite: one JSON file per key, written under a per-key lock file. `memoryLeaseStore` serves one process.
102
-
103
- Expected failures are typed values with a `_tag`: `LeaseHeld`, `LeaseLost`, `FenceRejected`, `LeaseConfigInvalid`
104
- and `LeaseStoreFailure`. The kit runs no Effect itself; run the program at your application's assembly root.
102
+ - `sqliteLeaseRepository` compares revisions inside `BEGIN IMMEDIATE` transactions. `fileLeaseRepository` is the
103
+ fallback without SQLite: one JSON file per key, written under a per-key lock file. `memoryLeaseRepository` serves
104
+ one process.
105
+ - The repository port follows the kit's repository shape: `load(key)` is the stored `LeaseSnapshot` or `undefined`,
106
+ and `save(key, snapshot, expectedRevision)` writes only over that revision and fails with a `RevisionConflict`
107
+ when another writer moved the record first.
108
+
109
+ Expected failures are typed values with a `_tag`: `LeaseHeld`, `LeaseLost`, `FenceRejected`, `LeaseConfigInvalid`,
110
+ `LeaseRepositoryFailure` and the repository's `RevisionConflict`. The kit runs no Effect itself; run the program at
111
+ your application's assembly root.
105
112
 
106
113
  ### Lanes
107
114
 
@@ -1,6 +1,6 @@
1
1
  import * as z from "zod/mini";
2
2
  import { err, ok } from "@rivus/agent-kit/catalog";
3
- //#region src/process-lock/application/holder.ts
3
+ //#region src/process-lock/application/services/holder.ts
4
4
  const stampSchema = z.object({
5
5
  host: z.string(),
6
6
  bootId: z.string(),
@@ -78,7 +78,7 @@ function holderLiveness(holder, observer, current) {
78
78
  return current.bootId === holder.bootId && current.startTime === holder.startTime ? "alive" : "dead";
79
79
  }
80
80
  //#endregion
81
- //#region src/process-lock/application/file-lock.ts
81
+ //#region src/process-lock/application/services/file-lock.ts
82
82
  /**
83
83
  * How long a lock file may stay empty or unreadable before it counts as abandoned. A holder writes its stamp right
84
84
  * after creating the file, so only a process that died in between leaves it empty. A holder stalled for longer than
@@ -165,7 +165,7 @@ async function releaseFileLock(platform, path, nonce) {
165
165
  if ((await readStamp(platform, path))?.stamp?.nonce === nonce) await platform.fs.remove(path);
166
166
  }
167
167
  //#endregion
168
- //#region src/process-lock/application/sqlite-lock.ts
168
+ //#region src/process-lock/application/services/sqlite-lock.ts
169
169
  /** SQLITE_BUSY and SQLITE_LOCKED, the primary codes of their extended codes: another connection holds the lock. */
170
170
  const BUSY_CODES = /* @__PURE__ */ new Set([5, 6]);
171
171
  /**
@@ -200,7 +200,7 @@ function isSqliteBusy(error) {
200
200
  return typeof errcode === "number" && BUSY_CODES.has(errcode & 255) || message === "database is locked";
201
201
  }
202
202
  //#endregion
203
- //#region src/process-lock/application/acquire-process-lock.ts
203
+ //#region src/process-lock/application/use-cases/acquire-process-lock.ts
204
204
  /**
205
205
  * A single-instance lock on `path`, which must be in an existing local directory (not NFS). With `platform.sqlite`,
206
206
  * `path` is a SQLite database held with `locking_mode=EXCLUSIVE`: the kernel releases it when the process exits or
@@ -296,11 +296,12 @@ function once(release) {
296
296
  }
297
297
  function delay(ms, signal) {
298
298
  return new Promise((resolve, reject) => {
299
+ let timer;
299
300
  const onAbort = () => {
300
301
  clearTimeout(timer);
301
302
  reject(signal?.reason);
302
303
  };
303
- const timer = setTimeout(() => {
304
+ timer = setTimeout(() => {
304
305
  signal?.removeEventListener("abort", onAbort);
305
306
  resolve();
306
307
  }, ms);
package/dist/lanes.d.ts CHANGED
@@ -17,6 +17,13 @@ interface LanesConfigInvalid {
17
17
  readonly message: string;
18
18
  }
19
19
  //#endregion
20
+ //#region src/lanes/domain/lane/policies/admission.d.ts
21
+ /**
22
+ * `started`: an activation started; `queued`: the lane waits for a free slot; `coalesced`: an activation that has not
23
+ * started yet serves this wake (the queued one, or the one that follows the running one).
24
+ */
25
+ type WakeResult = "started" | "queued" | "coalesced";
26
+ //#endregion
20
27
  //#region src/lanes/domain/lane/value-objects/lane-snapshot.d.ts
21
28
  /**
22
29
  * `idle`: nothing runs and nothing is owed. `queued`: woken while every slot was taken, waiting for one. `running`: one
@@ -33,7 +40,7 @@ interface LaneSnapshot {
33
40
  readonly pending: boolean;
34
41
  }
35
42
  //#endregion
36
- //#region src/lanes/application/create-lanes.d.ts
43
+ //#region src/lanes/application/use-cases/create-lanes.d.ts
37
44
  interface LanesConfig<E, R> {
38
45
  /** Activations that may run at once across all keys; a positive integer. */
39
46
  readonly maxConcurrent: number;
@@ -74,11 +81,6 @@ type ActivationExit<E> = {
74
81
  readonly key: string;
75
82
  readonly reason: ActivationInterruptReason;
76
83
  };
77
- /**
78
- * `started`: an activation started; `queued`: the lane waits for a free slot; `coalesced`: an activation that has not
79
- * started yet serves this wake (the queued one, or the one that follows the running one).
80
- */
81
- type WakeResult = "started" | "queued" | "coalesced";
82
84
  /** `close` ran, or the Scope that created the lanes closed. */
83
85
  interface LanesClosed {
84
86
  readonly _tag: "LanesClosed";
package/dist/lanes.js CHANGED
@@ -12,8 +12,21 @@ function admit(load, limits) {
12
12
  if (load.running < limits.maxConcurrent && load.queued === 0) return "start";
13
13
  return load.queued < limits.maxQueued ? "queue" : "full";
14
14
  }
15
+ /**
16
+ * Which queued lane may start now, or `undefined` when none may: the lanes are closed, the queue is empty, or every
17
+ * slot is running. The queue is FIFO: a free slot goes to the lane that has waited longest, and the slot a finishing
18
+ * activation frees goes to the head of the queue first.
19
+ */
20
+ function nextToStart(queue, load, limits, closed) {
21
+ if (closed || load.running >= limits.maxConcurrent) return;
22
+ return queue[0];
23
+ }
24
+ /** Reads how a wake ended from the events its transition produced. */
25
+ function wakeResult(events) {
26
+ return events.some(({ _tag }) => _tag === "ActivationStarted") ? "started" : events.some(({ _tag }) => _tag === "LaneQueued") ? "queued" : "coalesced";
27
+ }
15
28
  //#endregion
16
- //#region src/lanes/domain/lane/aggregate/lane.ts
29
+ //#region src/lanes/domain/lane/aggregates/lane.ts
17
30
  /**
18
31
  * One key's lane, held in memory only. At most one activation runs at a time, and every wake that arrives before an
19
32
  * activation starts is served by that activation: wakes coalesce instead of queueing one run each. Only an idle lane
@@ -168,11 +181,11 @@ function laneLimits(input) {
168
181
  });
169
182
  }
170
183
  //#endregion
171
- //#region src/lanes/application/from-result.ts
184
+ //#region src/lanes/application/services/from-result.ts
172
185
  /** Moves a plain `Result` into the typed error channel, where `catchTag` sees its error. */
173
186
  const fromResult = (result) => result.ok ? Effect.succeed(result.value) : Effect.fail(result.error);
174
187
  //#endregion
175
- //#region src/lanes/application/create-lanes.ts
188
+ //#region src/lanes/application/use-cases/create-lanes.ts
176
189
  /** An end the activation did not reach by itself: an interruption, by the lanes or its own. */
177
190
  const cutShort = (exit) => Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause);
178
191
  /**
@@ -220,10 +233,14 @@ function createLanes(config) {
220
233
  }
221
234
  return started;
222
235
  };
223
- /** Gives the free slots to the lanes that have waited longest. */
236
+ /** Hands the free slots to the lanes that `nextToStart` picks: the ones that have waited longest. */
224
237
  const pump = () => {
225
238
  const started = [];
226
- for (let key = queue[0]; !closed && key !== void 0 && activations.size < limits.maxConcurrent; key = queue[0]) started.push(...apply(laneOf(key).start()));
239
+ const load = () => ({
240
+ running: activations.size,
241
+ queued: queue.length
242
+ });
243
+ for (let key = nextToStart(queue, load(), limits, closed); key !== void 0; key = nextToStart(queue, load(), limits, closed)) started.push(...apply(laneOf(key).start()));
227
244
  return started;
228
245
  };
229
246
  const interrupted = (key, reason) => ({
@@ -276,8 +293,9 @@ function createLanes(config) {
276
293
  return fork(started);
277
294
  });
278
295
  }).pipe(Effect.provideContext(context));
279
- const fork = (started) => Effect.forEach(started, (activation) => Effect.forkDetach(run(activation), { uninterruptible: true }), { discard: true });
280
- const wakeResult = (events) => events.some(({ _tag }) => _tag === "ActivationStarted") ? "started" : events.some(({ _tag }) => _tag === "LaneQueued") ? "queued" : "coalesced";
296
+ function fork(started) {
297
+ return Effect.forEach(started, (activation) => Effect.forkDetach(run(activation), { uninterruptible: true }), { discard: true });
298
+ }
281
299
  const close = Effect.suspend(() => {
282
300
  if (!closed) {
283
301
  closed = true;
package/dist/lease.d.ts CHANGED
@@ -80,6 +80,13 @@ interface FenceRejected {
80
80
  readonly current: number | undefined;
81
81
  }
82
82
  //#endregion
83
+ //#region src/lease/domain/lease/errors/lease-config-invalid.d.ts
84
+ /** A lease duration is out of range, or the heartbeat does not fit twice into the TTL. */
85
+ interface LeaseConfigInvalid {
86
+ readonly _tag: "LeaseConfigInvalid";
87
+ readonly message: string;
88
+ }
89
+ //#endregion
83
90
  //#region src/lease/domain/lease/value-objects/fencing-token.d.ts
84
91
  /**
85
92
  * Proof of holding a lease, passed with every fenced write. A resource that can compare atomically keeps the highest
@@ -131,14 +138,26 @@ export declare function isFresh(lease: LeaseSnapshot, observation: LeaseObservat
131
138
  export declare function holderLiveness(holder: Holder, observer: Holder, current: Holder | undefined): HolderLiveness;
132
139
  //#endregion
133
140
  //#region src/lease/application/ports.d.ts
134
- /** The store could not read or write a record; the lease's state is unknown, not changed. */
135
- interface LeaseStoreFailure {
136
- readonly _tag: "LeaseStoreFailure";
141
+ /**
142
+ * Another writer wrote over the revision a `save` was conditioned on, so it wrote nothing. Declared here, next to
143
+ * the port; other contexts declare their own.
144
+ */
145
+ interface RevisionConflict {
146
+ readonly _tag: "RevisionConflict";
147
+ readonly key: string;
148
+ /** The revision the save was conditioned on; `undefined` when it required the record not to exist. */
149
+ readonly expectedRevision: number | undefined;
150
+ /** The revision the stored record had when the save compared it; `undefined` when there was none. */
151
+ readonly storedRevision: number | undefined;
152
+ }
153
+ /** The repository could not read or write a record; the lease's state is unknown, not changed. */
154
+ interface LeaseRepositoryFailure {
155
+ readonly _tag: "LeaseRepositoryFailure";
137
156
  readonly key: string;
138
157
  /**
139
- * `busy`: other writers kept the store locked; `invalid-record`: a stored record is unreadable or breaks the
140
- * invariants and is left as it is; `unsupported-schema`: the store was written by a newer version; `unavailable`:
141
- * the platform lacks what the store needs; `io`: any other failure.
158
+ * `busy`: other writers kept the repository locked; `invalid-record`: a stored record is unreadable or breaks the
159
+ * invariants and is left as it is; `unsupported-schema`: the repository was written by a newer version;
160
+ * `unavailable`: the platform lacks what the repository needs; `io`: any other failure.
142
161
  */
143
162
  readonly reason: "busy" | "io" | "invalid-record" | "unsupported-schema" | "unavailable";
144
163
  readonly message: string;
@@ -147,8 +166,10 @@ interface LeaseStoreFailure {
147
166
  /**
148
167
  * Where lease records live. Calling convention for every implementation:
149
168
  *
150
- * - `compareAndSet` is atomic across every process that uses the same store location: it writes `next` only when the
151
- * stored record's revision equals `expected` (`undefined`: no record), and reports whether it wrote.
169
+ * - `load(key)` is the stored record, or `undefined` when there is none.
170
+ * - `save(key, snapshot, expectedRevision)` writes atomically across every process that uses the same repository
171
+ * location: it writes only when the stored record's revision equals `expectedRevision` (`undefined`: no record),
172
+ * and fails with a `RevisionConflict` instead of writing when another writer moved the record first.
152
173
  * - Records are never deleted. A release writes a tombstone, so a key's generation never goes back.
153
174
  * - `fence(key)` holds one guard per key across those processes until its Scope closes; waiting for it can be
154
175
  * interrupted, and a guard whose holder process exited is freed without waiting for a TTL.
@@ -156,58 +177,59 @@ interface LeaseStoreFailure {
156
177
  *
157
178
  * The lease's `runFenced` takes the guard and re-reads the holder before the work starts. That is equivalent to the
158
179
  * protected resource checking the fencing token itself only when every writer of the resource goes through the same
159
- * store on the same machine; a resource that can compare atomically should also run `checkFence`.
180
+ * repository on the same machine; a resource that can compare atomically should also run `checkFence`.
160
181
  */
161
- interface LeaseStoreShape {
162
- read(key: string): Effect.Effect<LeaseSnapshot | undefined, LeaseStoreFailure>;
163
- compareAndSet(key: string, expected: number | undefined, next: LeaseSnapshot): Effect.Effect<boolean, LeaseStoreFailure>;
164
- fence(key: string): Effect.Effect<void, LeaseStoreFailure, Scope.Scope>;
182
+ interface LeaseRepositoryShape {
183
+ load(key: string): Effect.Effect<LeaseSnapshot | undefined, LeaseRepositoryFailure>;
184
+ save(key: string, snapshot: LeaseSnapshot, expectedRevision: number | undefined): Effect.Effect<void, LeaseRepositoryFailure | RevisionConflict>;
185
+ fence(key: string): Effect.Effect<void, LeaseRepositoryFailure, Scope.Scope>;
165
186
  }
166
- declare const KEY = "@rivus/agent-kit-collab/lease/LeaseStore/v1";
167
- declare const LeaseStoreBase: Context.ServiceClass<LeaseStore, typeof KEY, LeaseStoreShape>;
168
- /** The lease store port; `sqliteLeaseStore`, `fileLeaseStore` and `memoryLeaseStore` provide it. */
169
- export declare class LeaseStore extends LeaseStoreBase {}
187
+ declare const KEY = "@rivus/agent-kit-collab/lease/LeaseRepository/v1";
188
+ declare const LeaseRepositoryBase: Context.ServiceClass<LeaseRepository, typeof KEY, LeaseRepositoryShape>;
189
+ /** The lease repository port; `sqliteLeaseRepository`, `fileLeaseRepository` and `memoryLeaseRepository` provide it. */
190
+ export declare class LeaseRepository extends LeaseRepositoryBase {}
170
191
  //#endregion
171
- //#region src/lease/adapters/file-lease-store.d.ts
172
- interface FileLeaseStoreOptions {
192
+ //#region src/lease/infra/repository/file-lease-repository.d.ts
193
+ interface FileLeaseRepositoryOptions {
173
194
  /** An existing local directory that holds one record file and its lock files per key. */
174
195
  readonly dir: string;
175
196
  }
176
197
  /**
177
- * The fallback store for platforms without SQLite: one JSON record per key, `<key>.lease.json`, replaced with
178
- * `writeAtomic`. Every `compareAndSet` holds the key's guard (`<key>.lease.guard`) while it reads, compares and
179
- * writes, and reports `busy` when the guard stays held longer than a second; `fence` holds `<key>.lease.fence`.
180
- * Both are process locks, so without SQLite they are lock files whose dead holders are reclaimed one reclaimer at a
181
- * time, with the gaps `acquireProcessLock` documents. A record with an unknown `schemaVersion` or shape is refused
182
- * and never overwritten.
198
+ * The fallback repository for platforms without SQLite: one JSON record per key, `<key>.lease.json`, replaced with
199
+ * `writeAtomic`. Every `save` holds the key's guard (`<key>.lease.guard`) while it reads, compares and writes, and
200
+ * reports `busy` when the guard stays held longer than a second; `fence` holds `<key>.lease.fence`. Both are process
201
+ * locks, so without SQLite they are lock files whose dead holders are reclaimed one reclaimer at a time, with the
202
+ * gaps `acquireProcessLock` documents. A record with an unknown `schemaVersion` or shape is refused and never
203
+ * overwritten.
183
204
  */
184
- export declare function fileLeaseStore(options: FileLeaseStoreOptions): Layer.Layer<LeaseStore, never, PlatformService>;
205
+ export declare function fileLeaseRepository(options: FileLeaseRepositoryOptions): Layer.Layer<LeaseRepository, never, PlatformService>;
185
206
  //#endregion
186
- //#region src/lease/adapters/memory-lease-store.d.ts
207
+ //#region src/lease/infra/repository/memory-lease-repository.d.ts
187
208
  /**
188
- * A store for one process, such as tests or a host whose leases never cross a process boundary. Each Layer build
189
- * starts empty.
209
+ * A repository for one process, such as tests or a host whose leases never cross a process boundary. Each Layer
210
+ * build starts empty.
190
211
  */
191
- export declare function memoryLeaseStore(): Layer.Layer<LeaseStore>;
212
+ export declare function memoryLeaseRepository(): Layer.Layer<LeaseRepository>;
192
213
  //#endregion
193
- //#region src/lease/adapters/sqlite-lease-store.d.ts
194
- interface SqliteLeaseStoreOptions {
214
+ //#region src/lease/infra/repository/sqlite-lease-repository.d.ts
215
+ interface SqliteLeaseRepositoryOptions {
195
216
  /**
196
217
  * The database file, in an existing local directory. The per-key fence locks of `runFenced` live next to it as
197
- * `<path>.<key>.fence` (plus a `.holder` file each), so give the store a directory of its own or a distinct name.
218
+ * `<path>.<key>.fence` (plus a `.holder` file each), so give the repository a directory of its own or a distinct
219
+ * name.
198
220
  */
199
221
  readonly path: string;
200
222
  }
201
223
  /**
202
- * The lease store for one machine: one row per key in a SQLite database. `compareAndSet` runs in a
203
- * `BEGIN IMMEDIATE` transaction, which takes SQLite's write lock (an fcntl lock) before reading the revision, so
204
- * processes sharing the file compare and write atomically. The lock is released when its process exits, so neither a
205
- * crashed writer nor a reused pid can leave the store stuck. Fails to build with `unavailable` when the platform has
206
- * no SQLite, and with `unsupported-schema` for a database written by a newer version.
224
+ * The lease repository for one machine: one row per key in a SQLite database. `save` runs in a `BEGIN IMMEDIATE`
225
+ * transaction, which takes SQLite's write lock (an fcntl lock) before reading the revision, so processes sharing the
226
+ * file compare and write atomically. The lock is released when its process exits, so neither a crashed writer nor a
227
+ * reused pid can leave the repository stuck. Fails to build with `unavailable` when the platform has no SQLite, and
228
+ * with `unsupported-schema` for a database written by a newer version.
207
229
  */
208
- export declare function sqliteLeaseStore(options: SqliteLeaseStoreOptions): Layer.Layer<LeaseStore, LeaseStoreFailure, PlatformService>;
230
+ export declare function sqliteLeaseRepository(options: SqliteLeaseRepositoryOptions): Layer.Layer<LeaseRepository, LeaseRepositoryFailure, PlatformService>;
209
231
  //#endregion
210
- //#region src/lease/application/lease-manager.d.ts
232
+ //#region src/lease/application/use-cases/lease-manager.d.ts
211
233
  interface LeaseConfig {
212
234
  /** How long a lease stays valid without a renewal, as each observer's monotonic clock measures it. */
213
235
  readonly ttlMs: number;
@@ -216,15 +238,11 @@ interface LeaseConfig {
216
238
  /** How often `acquire({ wait: true })` tries again; `heartbeatMs` by default. */
217
239
  readonly retryMs?: number;
218
240
  }
219
- interface LeaseConfigInvalid {
220
- readonly _tag: "LeaseConfigInvalid";
221
- readonly message: string;
222
- }
223
241
  interface AcquireOptions {
224
242
  /**
225
- * Keep trying every `retryMs` while the lease is held or the store is busy (a guard that stays locked), instead of
226
- * failing with `LeaseHeld` or a `busy` `LeaseStoreFailure`. The wait ends when the lease is taken, on another store
227
- * failure, or when the fiber is interrupted.
243
+ * Keep trying every `retryMs` while the lease is held or the repository is busy (a guard that stays locked),
244
+ * instead of failing with `LeaseHeld` or a `busy` `LeaseRepositoryFailure`. The wait ends when the lease is taken,
245
+ * on another repository failure, or when the fiber is interrupted.
228
246
  */
229
247
  readonly wait?: boolean;
230
248
  }
@@ -238,7 +256,7 @@ interface LeaseHandle {
238
256
  */
239
257
  readonly lost: Effect.Effect<never, LeaseLost>;
240
258
  /**
241
- * Runs `work` under the store's per-key fence, after re-reading that this acquisition still holds the lease, and
259
+ * Runs `work` under the repository's per-key fence, after re-reading that this acquisition still holds the lease, and
242
260
  * passes it the fencing token for the writes it makes. Waiting for the fence stops when the lease is lost. When the
243
261
  * lease is lost while `work` runs, `work` is interrupted and the result fails with `LeaseLost`.
244
262
  *
@@ -248,7 +266,7 @@ interface LeaseHandle {
248
266
  * it the AbortSignal `tryPromise` provides and settle only after the write has stopped. A resource that can compare
249
267
  * atomically should also keep the highest token it has seen and refuse older ones with `checkFence`.
250
268
  */
251
- runFenced<A, E, R>(work: (token: FencingToken) => Effect.Effect<A, E, R>): Effect.Effect<A, E | LeaseLost | FenceRejected | LeaseStoreFailure, Exclude<R, Scope.Scope>>;
269
+ runFenced<A, E, R>(work: (token: FencingToken) => Effect.Effect<A, E, R>): Effect.Effect<A, E | LeaseLost | FenceRejected | LeaseRepositoryFailure, Exclude<R, Scope.Scope>>;
252
270
  }
253
271
  interface LeaseManager {
254
272
  /**
@@ -256,10 +274,10 @@ interface LeaseManager {
256
274
  * holder keeps it fresh; a dead holder (same host) is taken over at once, a silent one after the TTL. Throws an
257
275
  * `AgentKitError` (as a defect) for an empty key.
258
276
  */
259
- acquire(key: string, options?: AcquireOptions): Effect.Effect<LeaseHandle, LeaseHeld | LeaseStoreFailure, Scope.Scope>;
260
- read(key: string): Effect.Effect<LeaseSnapshot | undefined, LeaseStoreFailure>;
277
+ acquire(key: string, options?: AcquireOptions): Effect.Effect<LeaseHandle, LeaseHeld | LeaseRepositoryFailure, Scope.Scope>;
278
+ read(key: string): Effect.Effect<LeaseSnapshot | undefined, LeaseRepositoryFailure>;
261
279
  }
262
- /** Validates `config` and builds a manager over the `LeaseStore` and the platform in context. */
263
- export declare function createLeaseManager(config: LeaseConfig): Effect.Effect<LeaseManager, LeaseConfigInvalid, LeaseStore | PlatformService>;
280
+ /** Validates `config` and builds a manager over the `LeaseRepository` and the platform in context. */
281
+ export declare function createLeaseManager(config: LeaseConfig): Effect.Effect<LeaseManager, LeaseConfigInvalid, LeaseRepository | PlatformService>;
264
282
  //#endregion
265
- export type { AcquireOptions, AcquisitionView, FenceRejected, FencingToken, FileLeaseStoreOptions, Holder, HolderLiveness, LeaseConfig, LeaseConfigInvalid, LeaseHandle, LeaseHeld, LeaseLost, LeaseManager, LeaseObservation, LeaseSnapshot, LeaseStoreFailure, LeaseStoreShape, SqliteLeaseStoreOptions };
283
+ export type { AcquireOptions, AcquisitionView, FenceRejected, FencingToken, FileLeaseRepositoryOptions, Holder, HolderLiveness, LeaseConfig, LeaseConfigInvalid, LeaseHandle, LeaseHeld, LeaseLost, LeaseManager, LeaseObservation, LeaseRepositoryFailure, LeaseRepositoryShape, LeaseSnapshot, RevisionConflict, SqliteLeaseRepositoryOptions };
package/dist/lease.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as holderLiveness, i as isSqliteBusy, n as backoffMs, o as readText, r as tryProcessLock } from "./acquire-process-lock-A0oYDN9m.js";
1
+ import { a as holderLiveness, i as isSqliteBusy, n as backoffMs, o as readText, r as tryProcessLock } from "./acquire-process-lock-BCvK1ehZ.js";
2
2
  import { PlatformService } from "@rivus/agent-kit/platform/effect";
3
3
  import * as Effect from "effect/Effect";
4
4
  import * as Layer from "effect/Layer";
@@ -12,27 +12,35 @@ import * as Deferred from "effect/Deferred";
12
12
  import * as Exit from "effect/Exit";
13
13
  import * as Ref from "effect/Ref";
14
14
  //#region src/lease/application/ports.ts
15
- const LeaseStoreBase = Context.Service()("@rivus/agent-kit-collab/lease/LeaseStore/v1");
16
- /** The lease store port; `sqliteLeaseStore`, `fileLeaseStore` and `memoryLeaseStore` provide it. */
17
- var LeaseStore = class extends LeaseStoreBase {};
15
+ const LeaseRepositoryBase = Context.Service()("@rivus/agent-kit-collab/lease/LeaseRepository/v1");
16
+ /** The lease repository port; `sqliteLeaseRepository`, `fileLeaseRepository` and `memoryLeaseRepository` provide it. */
17
+ var LeaseRepository = class extends LeaseRepositoryBase {};
18
18
  //#endregion
19
- //#region src/lease/application/store-failure.ts
20
- function storeFailure(key, reason, message, cause) {
19
+ //#region src/lease/application/services/repository-failure.ts
20
+ function repositoryFailure(key, reason, message, cause) {
21
21
  return cause === void 0 ? {
22
- _tag: "LeaseStoreFailure",
22
+ _tag: "LeaseRepositoryFailure",
23
23
  key,
24
24
  reason,
25
25
  message
26
26
  } : {
27
- _tag: "LeaseStoreFailure",
27
+ _tag: "LeaseRepositoryFailure",
28
28
  key,
29
29
  reason,
30
30
  message,
31
31
  cause
32
32
  };
33
33
  }
34
+ function revisionConflict(key, expectedRevision, storedRevision) {
35
+ return {
36
+ _tag: "RevisionConflict",
37
+ key,
38
+ expectedRevision,
39
+ storedRevision
40
+ };
41
+ }
34
42
  //#endregion
35
- //#region src/lease/adapters/key-file-name.ts
43
+ //#region src/lease/infra/models/key-file-name.ts
36
44
  /** File names stay well under the usual 255-byte limit even with a suffix such as `.lease.json.stale`. */
37
45
  const MAX_LENGTH = 160;
38
46
  /**
@@ -48,7 +56,7 @@ function keyFileName(key) {
48
56
  }));
49
57
  }
50
58
  //#endregion
51
- //#region src/lease/adapters/lease-record-codec.ts
59
+ //#region src/lease/infra/models/lease-record-codec.ts
52
60
  const holderSchema = z.object({
53
61
  host: z.string(),
54
62
  bootId: z.string(),
@@ -121,7 +129,19 @@ function checkFence(lastSeen, token) {
121
129
  return ok(token);
122
130
  }
123
131
  //#endregion
124
- //#region src/lease/domain/lease/aggregate/lease.ts
132
+ //#region src/lease/domain/lease/policies/loss.ts
133
+ /**
134
+ * Why an acquisition that could not renew or write no longer holds, judged from the record it sees: the record is
135
+ * gone (`missing`), a tombstone says it was released, or another holder wrote it (`taken-over`). A tombstone is a
136
+ * record with no holder, so the holder field decides, whatever `holderId` says. How a read that failed counts is
137
+ * the caller's error mapping, not a property of a record.
138
+ */
139
+ function lossReason(record) {
140
+ if (record === void 0) return "missing";
141
+ return record.holder === null ? "released" : "taken-over";
142
+ }
143
+ //#endregion
144
+ //#region src/lease/domain/lease/aggregates/lease.ts
125
145
  /**
126
146
  * One key's lease. It has one holder at a time; its generation never decreases, release included, because release
127
147
  * keeps the record as a tombstone; every write increases its revision. Stores persist the snapshot and compare
@@ -183,7 +203,7 @@ var Lease = class Lease {
183
203
  }
184
204
  /** A heartbeat: only the holding acquisition renews, and the generation stays. */
185
205
  renew(holding, now) {
186
- const lost = this.lostBy(holding);
206
+ const lost = this.lossOf(holding);
187
207
  if (lost !== void 0) return err(lost);
188
208
  const state = new Lease({
189
209
  ...this.snapshot,
@@ -203,7 +223,7 @@ var Lease = class Lease {
203
223
  }
204
224
  /** Leaves a tombstone: no holder, the same generation, so the next acquisition still moves forward. */
205
225
  release(holding, now) {
206
- const lost = this.lostBy(holding);
226
+ const lost = this.lossOf(holding);
207
227
  if (lost !== void 0) return err(lost);
208
228
  const state = new Lease({
209
229
  ...this.snapshot,
@@ -226,15 +246,19 @@ var Lease = class Lease {
226
246
  toSnapshot() {
227
247
  return this.snapshot;
228
248
  }
229
- lostBy(holding) {
230
- const { key, holderId, generation } = this.snapshot;
231
- if (holderId === holding.holderId && generation === holding.generation) return;
232
- const reason = holderId === null ? "released" : "taken-over";
249
+ /** Whether this record is still the given acquisition's: the same holder id and generation. */
250
+ holds(holding) {
251
+ const { holderId, generation } = this.snapshot;
252
+ return holderId === holding.holderId && generation === holding.generation;
253
+ }
254
+ /** Why this acquisition no longer holds, or `undefined` when it still does. */
255
+ lossOf(holding) {
256
+ if (this.holds(holding)) return;
233
257
  return {
234
258
  _tag: "LeaseLost",
235
- key,
259
+ key: this.snapshot.key,
236
260
  generation: holding.generation,
237
- reason
261
+ reason: lossReason(this.snapshot)
238
262
  };
239
263
  }
240
264
  };
@@ -272,8 +296,38 @@ function observe(previous, lease, now) {
272
296
  function isFresh(lease, observation, now, ttlMs) {
273
297
  return lease.holder !== null && (observation.revision !== lease.revision || now - observation.observedAt < ttlMs);
274
298
  }
299
+ /**
300
+ * Whether the holder itself must give up: it has not confirmed a renewal for a full TTL, so an observer's clock may
301
+ * already have expired the lease and a takeover can have happened. Both times come from the holder's own monotonic
302
+ * clock; `confirmedAt` is when its last renewal write was accepted.
303
+ */
304
+ function holderExpired(confirmedAt, now, ttlMs) {
305
+ return now - confirmedAt >= ttlMs;
306
+ }
307
+ //#endregion
308
+ //#region src/lease/domain/lease/value-objects/lease-timing.ts
309
+ /** Accepts a timing where every duration is positive and one late heartbeat cannot lose the lease. */
310
+ function leaseTiming(input) {
311
+ const { ttlMs, heartbeatMs } = input;
312
+ const retryMs = input.retryMs ?? heartbeatMs;
313
+ const invalid = (message) => err({
314
+ _tag: "LeaseConfigInvalid",
315
+ message
316
+ });
317
+ for (const [name, value] of [
318
+ ["ttlMs", ttlMs],
319
+ ["heartbeatMs", heartbeatMs],
320
+ ["retryMs", retryMs]
321
+ ]) if (!Number.isFinite(value) || value <= 0) return invalid(`${name} must be a positive number of milliseconds, got ${value}`);
322
+ if (heartbeatMs * 2 > ttlMs) return invalid(`heartbeatMs × 2 must not exceed ttlMs (${heartbeatMs} × 2 > ${ttlMs})`);
323
+ return ok({
324
+ ttlMs,
325
+ heartbeatMs,
326
+ retryMs
327
+ });
328
+ }
275
329
  //#endregion
276
- //#region src/lease/adapters/process-fence.ts
330
+ //#region src/lease/infra/adapters/process-fence.ts
277
331
  /** The first retry of a waiting fence or guard; later ones back off from it. */
278
332
  const RETRY_MS = 10;
279
333
  /**
@@ -284,19 +338,19 @@ const RETRY_MS = 10;
284
338
  function holdProcessLock(platform, key, path, options = {}) {
285
339
  const attempt = (first) => Effect.acquireRelease(Effect.tryPromise({
286
340
  try: () => tryProcessLock(platform, path, { first }),
287
- catch: (cause) => storeFailure(key, "io", `cannot lock ${path}`, cause)
288
- }).pipe(Effect.flatMap((result) => result.ok ? Effect.succeed(result.value) : Effect.fail(storeFailure(key, "busy", `${path} is locked`)))), (lock) => Effect.promise(() => lock.release()));
341
+ catch: (cause) => repositoryFailure(key, "io", `cannot lock ${path}`, cause)
342
+ }).pipe(Effect.flatMap((result) => result.ok ? Effect.succeed(result.value) : Effect.fail(repositoryFailure(key, "busy", `${path} is locked`)))), (lock) => Effect.promise(() => lock.release()));
289
343
  return Effect.gen(function* () {
290
344
  const started = platform.clock.monotonic();
291
345
  for (let turn = 0;; turn += 1) {
292
346
  if (yield* attempt(turn === 0).pipe(Effect.as(true), Effect.catchIf((failure) => failure.reason === "busy", () => Effect.succeed(false)))) return;
293
- if (options.timeoutMs !== void 0 && platform.clock.monotonic() - started >= options.timeoutMs) return yield* Effect.fail(storeFailure(key, "busy", `${path} is still locked after ${options.timeoutMs} ms`));
347
+ if (options.timeoutMs !== void 0 && platform.clock.monotonic() - started >= options.timeoutMs) return yield* Effect.fail(repositoryFailure(key, "busy", `${path} is still locked after ${options.timeoutMs} ms`));
294
348
  yield* Effect.sleep(backoffMs(RETRY_MS, turn));
295
349
  }
296
350
  });
297
351
  }
298
352
  //#endregion
299
- //#region src/lease/adapters/file-lease-store.ts
353
+ //#region src/lease/infra/repository/file-lease-repository.ts
300
354
  const SCHEMA_VERSION$1 = 1;
301
355
  /**
302
356
  * A guard is held only for one read, compare and write, so waiting longer means a stalled holder: the write reports
@@ -304,34 +358,34 @@ const SCHEMA_VERSION$1 = 1;
304
358
  */
305
359
  const GUARD_TIMEOUT_MS = 1e3;
306
360
  /**
307
- * The fallback store for platforms without SQLite: one JSON record per key, `<key>.lease.json`, replaced with
308
- * `writeAtomic`. Every `compareAndSet` holds the key's guard (`<key>.lease.guard`) while it reads, compares and
309
- * writes, and reports `busy` when the guard stays held longer than a second; `fence` holds `<key>.lease.fence`.
310
- * Both are process locks, so without SQLite they are lock files whose dead holders are reclaimed one reclaimer at a
311
- * time, with the gaps `acquireProcessLock` documents. A record with an unknown `schemaVersion` or shape is refused
312
- * and never overwritten.
361
+ * The fallback repository for platforms without SQLite: one JSON record per key, `<key>.lease.json`, replaced with
362
+ * `writeAtomic`. Every `save` holds the key's guard (`<key>.lease.guard`) while it reads, compares and writes, and
363
+ * reports `busy` when the guard stays held longer than a second; `fence` holds `<key>.lease.fence`. Both are process
364
+ * locks, so without SQLite they are lock files whose dead holders are reclaimed one reclaimer at a time, with the
365
+ * gaps `acquireProcessLock` documents. A record with an unknown `schemaVersion` or shape is refused and never
366
+ * overwritten.
313
367
  */
314
- function fileLeaseStore(options) {
368
+ function fileLeaseRepository(options) {
315
369
  const { dir } = options;
316
- return Layer.effect(LeaseStore, Effect.gen(function* () {
370
+ return Layer.effect(LeaseRepository, Effect.gen(function* () {
317
371
  const platform = yield* PlatformService;
318
372
  const pathOf = (key, suffix) => keyFileName(key).pipe(Effect.map((name) => `${dir}/${name}.lease${suffix}`));
319
- const read = (key) => pathOf(key, ".json").pipe(Effect.flatMap((path) => readRecord(platform, key, path)));
373
+ const load = (key) => pathOf(key, ".json").pipe(Effect.flatMap((path) => readRecord(platform, key, path)));
320
374
  return {
321
- read,
322
- compareAndSet: (key, expected, next) => Effect.scoped(Effect.gen(function* () {
375
+ load,
376
+ save: (key, next, expectedRevision) => Effect.scoped(Effect.gen(function* () {
323
377
  yield* holdProcessLock(platform, key, yield* pathOf(key, ".guard"), { timeoutMs: GUARD_TIMEOUT_MS });
324
378
  const path = yield* pathOf(key, ".json");
325
379
  return yield* Effect.uninterruptible(Effect.gen(function* () {
326
- if ((yield* readRecord(platform, key, path))?.revision !== expected) return false;
380
+ const current = yield* readRecord(platform, key, path);
381
+ if (current?.revision !== expectedRevision) return yield* Effect.fail(revisionConflict(key, expectedRevision, current?.revision));
327
382
  yield* Effect.tryPromise({
328
383
  try: () => platform.fs.writeAtomic(path, JSON.stringify({
329
384
  schemaVersion: SCHEMA_VERSION$1,
330
385
  record: next
331
386
  })),
332
- catch: (cause) => storeFailure(key, "io", `cannot write ${path}`, cause)
387
+ catch: (cause) => repositoryFailure(key, "io", `cannot write ${path}`, cause)
333
388
  });
334
- return true;
335
389
  }));
336
390
  })),
337
391
  fence: (key) => pathOf(key, ".fence").pipe(Effect.flatMap((path) => holdProcessLock(platform, key, path)))
@@ -341,7 +395,7 @@ function fileLeaseStore(options) {
341
395
  function readRecord(platform, key, path) {
342
396
  return Effect.tryPromise({
343
397
  try: () => readText(platform, path),
344
- catch: (cause) => storeFailure(key, "io", `cannot read ${path}`, cause)
398
+ catch: (cause) => repositoryFailure(key, "io", `cannot read ${path}`, cause)
345
399
  }).pipe(Effect.flatMap((text) => text === void 0 ? Effect.succeed(void 0) : decodeFile(key, path, text)));
346
400
  }
347
401
  function decodeFile(key, path, text) {
@@ -349,21 +403,21 @@ function decodeFile(key, path, text) {
349
403
  try {
350
404
  json = JSON.parse(text);
351
405
  } catch (cause) {
352
- return Effect.fail(storeFailure(key, "invalid-record", `${path} is not JSON`, cause));
406
+ return Effect.fail(repositoryFailure(key, "invalid-record", `${path} is not JSON`, cause));
353
407
  }
354
408
  const { schemaVersion, record } = typeof json === "object" && json !== null ? json : {};
355
- if (schemaVersion !== SCHEMA_VERSION$1) return Effect.fail(storeFailure(key, "unsupported-schema", `${path} has schemaVersion ${String(schemaVersion)}; expected 1`));
409
+ if (schemaVersion !== SCHEMA_VERSION$1) return Effect.fail(repositoryFailure(key, "unsupported-schema", `${path} has schemaVersion ${String(schemaVersion)}; expected 1`));
356
410
  const snapshot = decodeSnapshot(record);
357
- return snapshot === void 0 ? Effect.fail(storeFailure(key, "invalid-record", `${path} holds a record of an unexpected shape`)) : Effect.succeed(snapshot);
411
+ return snapshot === void 0 ? Effect.fail(repositoryFailure(key, "invalid-record", `${path} holds a record of an unexpected shape`)) : Effect.succeed(snapshot);
358
412
  }
359
413
  //#endregion
360
- //#region src/lease/adapters/memory-lease-store.ts
414
+ //#region src/lease/infra/repository/memory-lease-repository.ts
361
415
  /**
362
- * A store for one process, such as tests or a host whose leases never cross a process boundary. Each Layer build
363
- * starts empty.
416
+ * A repository for one process, such as tests or a host whose leases never cross a process boundary. Each Layer
417
+ * build starts empty.
364
418
  */
365
- function memoryLeaseStore() {
366
- return Layer.sync(LeaseStore, () => {
419
+ function memoryLeaseRepository() {
420
+ return Layer.sync(LeaseRepository, () => {
367
421
  const records = /* @__PURE__ */ new Map();
368
422
  const fences = /* @__PURE__ */ new Map();
369
423
  const fenceOf = (key) => {
@@ -372,36 +426,37 @@ function memoryLeaseStore() {
372
426
  return fence;
373
427
  };
374
428
  return {
375
- read: (key) => Effect.sync(() => records.get(key)),
376
- compareAndSet: (key, expected, next) => Effect.sync(() => {
377
- if (records.get(key)?.revision !== expected) return false;
429
+ load: (key) => Effect.sync(() => records.get(key)),
430
+ save: (key, next, expectedRevision) => Effect.suspend(() => {
431
+ const stored = records.get(key);
432
+ if (stored?.revision !== expectedRevision) return Effect.fail(revisionConflict(key, expectedRevision, stored?.revision));
378
433
  records.set(key, next);
379
- return true;
434
+ return Effect.void;
380
435
  }),
381
436
  fence: (key) => Effect.acquireRelease(fenceOf(key).take(1), () => fenceOf(key).release(1), { interruptible: true }).pipe(Effect.asVoid)
382
437
  };
383
438
  });
384
439
  }
385
440
  //#endregion
386
- //#region src/lease/adapters/sqlite-lease-store.ts
441
+ //#region src/lease/infra/repository/sqlite-lease-repository.ts
387
442
  const SCHEMA_VERSION = 1;
388
443
  /** Long enough for another writer's single-row transaction; longer waits block the event loop, so retry instead. */
389
444
  const BUSY_TIMEOUT_MS = 50;
390
445
  const BUSY_RETRIES = 100;
391
446
  const BUSY_RETRY_MS = 5;
392
447
  /**
393
- * The lease store for one machine: one row per key in a SQLite database. `compareAndSet` runs in a
394
- * `BEGIN IMMEDIATE` transaction, which takes SQLite's write lock (an fcntl lock) before reading the revision, so
395
- * processes sharing the file compare and write atomically. The lock is released when its process exits, so neither a
396
- * crashed writer nor a reused pid can leave the store stuck. Fails to build with `unavailable` when the platform has
397
- * no SQLite, and with `unsupported-schema` for a database written by a newer version.
448
+ * The lease repository for one machine: one row per key in a SQLite database. `save` runs in a `BEGIN IMMEDIATE`
449
+ * transaction, which takes SQLite's write lock (an fcntl lock) before reading the revision, so processes sharing the
450
+ * file compare and write atomically. The lock is released when its process exits, so neither a crashed writer nor a
451
+ * reused pid can leave the repository stuck. Fails to build with `unavailable` when the platform has no SQLite, and
452
+ * with `unsupported-schema` for a database written by a newer version.
398
453
  */
399
- function sqliteLeaseStore(options) {
454
+ function sqliteLeaseRepository(options) {
400
455
  const { path } = options;
401
- return Layer.effect(LeaseStore, Effect.gen(function* () {
456
+ return Layer.effect(LeaseRepository, Effect.gen(function* () {
402
457
  const platform = yield* PlatformService;
403
458
  const { sqlite } = platform;
404
- if (sqlite === void 0) return yield* Effect.fail(storeFailure("", "unavailable", "the platform has no SQLite"));
459
+ if (sqlite === void 0) return yield* Effect.fail(repositoryFailure("", "unavailable", "the platform has no SQLite"));
405
460
  const db = yield* Effect.acquireRelease(sqliteTry("", `cannot open ${path}`, () => sqlite.open(path)), (opened) => Effect.sync(() => opened.close()));
406
461
  yield* retryBusy(sqliteTry("", `cannot prepare ${path}`, () => migrate(db)));
407
462
  const statements = {
@@ -412,12 +467,13 @@ function sqliteLeaseStore(options) {
412
467
  holder = excluded.holder, holder_id = excluded.holder_id, renewed_at = excluded.renewed_at`)
413
468
  };
414
469
  return {
415
- read: (key) => retryBusy(sqliteTry(key, `cannot read ${path}`, () => statements.select.get(key))).pipe(Effect.flatMap((row) => decodeRow(key, row))),
416
- compareAndSet: (key, expected, next) => retryBusy(sqliteTry(key, `cannot write ${path}`, () => compareAndSet(db, statements, expected, next))),
470
+ load: (key) => retryBusy(sqliteTry(key, `cannot read ${path}`, () => statements.select.get(key))).pipe(Effect.flatMap((row) => decodeRow(key, row))),
471
+ save: (key, next, expectedRevision) => retryBusy(sqliteTry(key, `cannot write ${path}`, () => saveInTransaction(db, statements, key, next, expectedRevision))).pipe(Effect.flatMap((outcome) => outcome === void 0 ? Effect.void : Effect.fail(outcome))),
417
472
  fence: (key) => keyFileName(key).pipe(Effect.flatMap((name) => holdProcessLock(platform, key, `${path}.${name}.fence`)))
418
473
  };
419
474
  }));
420
475
  }
476
+ var UnsupportedSchema = class extends Error {};
421
477
  function migrate(db) {
422
478
  db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
423
479
  db.exec("PRAGMA journal_mode = WAL");
@@ -436,12 +492,12 @@ function migrate(db) {
436
492
  } else if (version !== SCHEMA_VERSION) throw new UnsupportedSchema(`the lease database has schema version ${version}; this version reads ${SCHEMA_VERSION}`);
437
493
  });
438
494
  }
439
- function compareAndSet(db, statements, expected, next) {
495
+ function saveInTransaction(db, statements, key, next, expectedRevision) {
440
496
  return transaction(db, () => {
441
- const stored = statements.selectRevision.get(next.key)?.revision;
442
- if ((stored === void 0 ? void 0 : Number(stored)) !== expected) return false;
497
+ const storedRow = statements.selectRevision.get(next.key);
498
+ const stored = storedRow === void 0 ? void 0 : Number(storedRow.revision);
499
+ if (stored !== expectedRevision) return revisionConflict(key, expectedRevision, stored);
443
500
  statements.upsert.run(next.key, next.generation, next.revision, encodeHolder(next.holder), next.holderId, next.renewedAt);
444
- return true;
445
501
  });
446
502
  }
447
503
  /** Runs `body` in a `BEGIN IMMEDIATE` transaction, committing when it returns and rolling back when it throws. */
@@ -458,13 +514,12 @@ function transaction(db, body) {
458
514
  throw error;
459
515
  }
460
516
  }
461
- var UnsupportedSchema = class extends Error {};
462
517
  function sqliteTry(key, message, body) {
463
518
  return Effect.try({
464
519
  try: body,
465
520
  catch: (cause) => {
466
- if (cause instanceof UnsupportedSchema) return storeFailure(key, "unsupported-schema", cause.message);
467
- return storeFailure(key, isSqliteBusy(cause) ? "busy" : "io", message, cause);
521
+ if (cause instanceof UnsupportedSchema) return repositoryFailure(key, "unsupported-schema", cause.message);
522
+ return repositoryFailure(key, isSqliteBusy(cause) ? "busy" : "io", message, cause);
468
523
  }
469
524
  });
470
525
  }
@@ -486,36 +541,23 @@ function decodeRow(key, row) {
486
541
  holderId: row.holder_id,
487
542
  renewedAt: row.renewed_at
488
543
  });
489
- return snapshot === void 0 ? Effect.fail(storeFailure(key, "invalid-record", `the stored record for ${key} has an unexpected shape`)) : Effect.succeed(snapshot);
544
+ return snapshot === void 0 ? Effect.fail(repositoryFailure(key, "invalid-record", `the stored record for ${key} has an unexpected shape`)) : Effect.succeed(snapshot);
490
545
  }
491
546
  //#endregion
492
- //#region src/lease/application/from-result.ts
547
+ //#region src/lease/application/services/from-result.ts
493
548
  /** Moves a plain `Result` into the typed error channel, where `catchTag` sees its error. */
494
549
  const fromResult = (result) => result.ok ? Effect.succeed(result.value) : Effect.fail(result.error);
495
550
  //#endregion
496
- //#region src/lease/application/lease-manager.ts
497
- /** CAS attempts within one acquisition before the store counts as busy; each lost race re-reads the record. */
551
+ //#region src/lease/application/use-cases/lease-manager.ts
552
+ /** CAS attempts within one acquisition before the repository counts as busy; each lost race re-reads the record. */
498
553
  const MAX_CAS_TURNS = 8;
499
- /** Validates `config` and builds a manager over the `LeaseStore` and the platform in context. */
554
+ /** Validates `config` and builds a manager over the `LeaseRepository` and the platform in context. */
500
555
  function createLeaseManager(config) {
501
556
  return Effect.gen(function* () {
502
- const problem = configProblem(config);
503
- if (problem !== void 0) return yield* Effect.fail({
504
- _tag: "LeaseConfigInvalid",
505
- message: problem
506
- });
507
- return makeManager(config, yield* LeaseStore, yield* PlatformService);
557
+ return makeManager(yield* fromResult(leaseTiming(config)), yield* LeaseRepository, yield* PlatformService);
508
558
  });
509
559
  }
510
- function configProblem({ ttlMs, heartbeatMs, retryMs }) {
511
- for (const [name, value] of [
512
- ["ttlMs", ttlMs],
513
- ["heartbeatMs", heartbeatMs],
514
- ["retryMs", retryMs ?? heartbeatMs]
515
- ]) if (!Number.isFinite(value) || value <= 0) return `${name} must be a positive number of milliseconds, got ${value}`;
516
- if (heartbeatMs * 2 > ttlMs) return `heartbeatMs × 2 must not exceed ttlMs (${heartbeatMs} × 2 > ${ttlMs})`;
517
- }
518
- function makeManager(config, store, platform) {
560
+ function makeManager(timing, store, platform) {
519
561
  const observations = /* @__PURE__ */ new Map();
520
562
  const now = () => platform.clock.monotonic();
521
563
  const self = () => {
@@ -535,7 +577,7 @@ function makeManager(config, store, platform) {
535
577
  /** One acquisition attempt: reads the record, applies the Lease rules, and writes over exactly that revision. */
536
578
  const take = (key, holderId) => Effect.gen(function* () {
537
579
  for (let turn = 0; turn < MAX_CAS_TURNS; turn += 1) {
538
- const record = yield* store.read(key);
580
+ const record = yield* store.load(key);
539
581
  const claim = {
540
582
  key,
541
583
  holder: self(),
@@ -545,11 +587,11 @@ function makeManager(config, store, platform) {
545
587
  let transition;
546
588
  if (record === void 0) transition = Lease.create(claim);
547
589
  else {
548
- const lease = yield* fromResult(Lease.restore(record)).pipe(Effect.mapError((invalid) => storeFailure(key, "invalid-record", invalid.message)));
590
+ const lease = yield* fromResult(Lease.restore(record)).pipe(Effect.mapError((invalid) => repositoryFailure(key, "invalid-record", invalid.message)));
549
591
  const at = now();
550
592
  const observation = observe(observations.get(key), record, at);
551
593
  observations.set(key, observation);
552
- const fresh = isFresh(record, observation, at, config.ttlMs);
594
+ const fresh = isFresh(record, observation, at, timing.ttlMs);
553
595
  const { holder } = record;
554
596
  const liveness = fresh && holder !== null ? yield* judge(holder, claim.holder) : "unknown";
555
597
  transition = yield* fromResult(lease.acquire(claim, {
@@ -558,17 +600,16 @@ function makeManager(config, store, platform) {
558
600
  }));
559
601
  }
560
602
  const next = transition.state.toSnapshot();
561
- if (yield* store.compareAndSet(key, record?.revision, next)) {
603
+ if (yield* store.save(key, next, record?.revision).pipe(Effect.as(true), Effect.catchTag("RevisionConflict", () => Effect.succeed(false)))) {
562
604
  observations.delete(key);
563
605
  return next;
564
606
  }
565
607
  }
566
- return yield* Effect.fail(storeFailure(key, "busy", "the lease record kept changing during acquisition"));
608
+ return yield* Effect.fail(repositoryFailure(key, "busy", "the lease record kept changing during acquisition"));
567
609
  });
568
610
  const lostBy = (key, holding) => Effect.gen(function* () {
569
- const read = yield* Effect.exit(store.read(key));
570
- const record = Exit.isSuccess(read) ? read.value : void 0;
571
- const reason = !Exit.isSuccess(read) ? "taken-over" : record === void 0 ? "missing" : record.holder === null ? "released" : "taken-over";
611
+ const read = yield* Effect.exit(store.load(key));
612
+ const reason = Exit.isSuccess(read) ? lossReason(read.value) : "taken-over";
572
613
  return {
573
614
  _tag: "LeaseLost",
574
615
  key,
@@ -577,25 +618,25 @@ function makeManager(config, store, platform) {
577
618
  };
578
619
  });
579
620
  /**
580
- * Renews every `heartbeatMs`. A refused write means another writer moved the record, so the lease is lost; a store
581
- * failure is retried until no renewal has been confirmed for a TTL, after which an observer may take it over. The
582
- * write stays interruptible while it waits for the store; `release` re-reads the record, so a renewal interrupted
583
- * between its write and `Ref.set` cannot keep the tombstone from being written.
621
+ * Renews every `heartbeatMs`. A refused write means another writer moved the record, so the lease is lost; a
622
+ * repository failure is retried until no renewal has been confirmed for a TTL, after which an observer may take it
623
+ * over. The write stays interruptible while it waits for the repository; `release` re-reads the record, so a
624
+ * renewal interrupted between its write and `Ref.set` cannot keep the tombstone from being written.
584
625
  */
585
626
  const heartbeat = (key, holding, ref) => Effect.gen(function* () {
586
627
  let confirmedAt = now();
587
628
  for (;;) {
588
- yield* Effect.sleep(config.heartbeatMs);
629
+ yield* Effect.sleep(timing.heartbeatMs);
589
630
  const current = yield* Ref.get(ref);
590
631
  const restored = Lease.restore(current);
591
632
  const transition = restored.ok ? restored.value.renew(holding, platform.clock.now()) : void 0;
592
633
  if (transition?.ok !== true) return yield* Effect.fail(yield* lostBy(key, holding));
593
634
  const next = transition.value.state.toSnapshot();
594
- const written = yield* Effect.exit(store.compareAndSet(key, current.revision, next).pipe(Effect.tap((done) => done ? Ref.set(ref, next) : Effect.void)));
635
+ const written = yield* Effect.exit(store.save(key, next, current.revision).pipe(Effect.tap(() => Ref.set(ref, next)), Effect.as(true), Effect.catchTag("RevisionConflict", () => Effect.succeed(false))));
595
636
  if (Exit.isSuccess(written)) {
596
637
  if (!written.value) return yield* Effect.fail(yield* lostBy(key, holding));
597
638
  confirmedAt = now();
598
- } else if (now() - confirmedAt >= config.ttlMs) return yield* Effect.fail({
639
+ } else if (holderExpired(confirmedAt, now(), timing.ttlMs)) return yield* Effect.fail({
599
640
  _tag: "LeaseLost",
600
641
  key,
601
642
  generation: holding.generation,
@@ -608,10 +649,10 @@ function makeManager(config, store, platform) {
608
649
  * to expire after the TTL; a finalizer cannot fail.
609
650
  */
610
651
  const release = (key, holding) => Effect.gen(function* () {
611
- const current = yield* store.read(key);
652
+ const current = yield* store.load(key);
612
653
  const restored = current === void 0 ? void 0 : Lease.restore(current);
613
654
  const transition = restored?.ok === true ? restored.value.release(holding, platform.clock.now()) : void 0;
614
- if (current !== void 0 && transition?.ok === true) yield* store.compareAndSet(key, current.revision, transition.value.state.toSnapshot());
655
+ if (current !== void 0 && transition?.ok === true) yield* store.save(key, transition.value.state.toSnapshot(), current.revision);
615
656
  }).pipe(Effect.ignore);
616
657
  const handle = (key, holding, lost) => {
617
658
  const token = {
@@ -625,8 +666,8 @@ function makeManager(config, store, platform) {
625
666
  runFenced: (work) => Effect.scoped(Effect.gen(function* () {
626
667
  if (yield* Deferred.isDone(lost)) return yield* Deferred.await(lost);
627
668
  yield* store.fence(key).pipe(Effect.raceFirst(Deferred.await(lost)));
628
- const record = yield* store.read(key);
629
- if (record?.holderId !== holding.holderId || record.generation !== holding.generation) {
669
+ const record = yield* store.load(key);
670
+ if ((record === void 0 ? void 0 : yield* fromResult(Lease.restore(record)).pipe(Effect.mapError((invalid) => repositoryFailure(key, "invalid-record", invalid.message))))?.holds(holding) !== true) {
630
671
  const current = record?.generation;
631
672
  return yield* Effect.fail({
632
673
  _tag: "FenceRejected",
@@ -649,7 +690,7 @@ function makeManager(config, store, platform) {
649
690
  })));
650
691
  const acquired = options.wait === true ? attempt.pipe(Effect.retry({
651
692
  while: (error) => error._tag === "LeaseHeld" || error.reason === "busy",
652
- schedule: Schedule.spaced(config.retryMs ?? config.heartbeatMs)
693
+ schedule: Schedule.spaced(timing.retryMs)
653
694
  })) : attempt;
654
695
  return Effect.gen(function* () {
655
696
  const ref = yield* acquired;
@@ -663,8 +704,8 @@ function makeManager(config, store, platform) {
663
704
  return handle(key, holding, lost);
664
705
  });
665
706
  },
666
- read: (key) => store.read(key)
707
+ read: (key) => store.load(key)
667
708
  };
668
709
  }
669
710
  //#endregion
670
- export { LeaseStore, canAcquire, checkFence, createLeaseManager, fileLeaseStore, holderLiveness, isFresh, memoryLeaseStore, nextFencingToken, sqliteLeaseStore };
711
+ export { LeaseRepository, canAcquire, checkFence, createLeaseManager, fileLeaseRepository, holderLiveness, isFresh, memoryLeaseRepository, nextFencingToken, sqliteLeaseRepository };
@@ -8,7 +8,7 @@ import { Platform } from "@rivus/agent-kit/platform";
8
8
  */
9
9
  type ProcessLockPlatform = Pick<Platform, "fs" | "process" | "clock" | "sqlite">;
10
10
  //#endregion
11
- //#region src/process-lock/application/holder.d.ts
11
+ //#region src/process-lock/application/services/holder.d.ts
12
12
  /** Who holds or held a process lock, as recorded when it was acquired; it may be stale. */
13
13
  interface ProcessLockHolder {
14
14
  readonly host: string;
@@ -19,7 +19,7 @@ interface ProcessLockHolder {
19
19
  readonly acquiredAt: number;
20
20
  }
21
21
  //#endregion
22
- //#region src/process-lock/application/acquire-process-lock.d.ts
22
+ //#region src/process-lock/application/use-cases/acquire-process-lock.d.ts
23
23
  interface ProcessLockOptions {
24
24
  /** Keep trying while another process holds the lock, instead of returning `ProcessLockHeld` at once. */
25
25
  readonly wait?: boolean;
@@ -1,2 +1,2 @@
1
- import { t as acquireProcessLock } from "./acquire-process-lock-A0oYDN9m.js";
1
+ import { t as acquireProcessLock } from "./acquire-process-lock-BCvK1ehZ.js";
2
2
  export { acquireProcessLock };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rivus/agent-kit-collab",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Collaboration primitives between coding agents: fenced leases, single-instance process locks and keyed scheduling lanes.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,8 +40,8 @@
40
40
  "devDependencies": {
41
41
  "@arethetypeswrong/cli": "0.18.5",
42
42
  "@effect/vitest": "4.0.1",
43
- "@perfectpan/lint-config": "github:PerfectPan/lint-config#v0.3.2",
44
- "@rivus/agent-kit": "0.3.0",
43
+ "@perfectpan/lint-config": "github:PerfectPan/lint-config#v0.4.0",
44
+ "@rivus/agent-kit": "0.4.0",
45
45
  "@size-limit/preset-small-lib": "14.1.0",
46
46
  "@types/node": "24.19.1",
47
47
  "effect": "4.0.1",
@@ -56,7 +56,7 @@
56
56
  "vitest": "5.0.3"
57
57
  },
58
58
  "peerDependencies": {
59
- "@rivus/agent-kit": "^0.3.0",
59
+ "@rivus/agent-kit": "^0.4.0",
60
60
  "effect": "4.0.1"
61
61
  },
62
62
  "peerDependenciesMeta": {