@rivus/agent-kit-collab 0.0.0 → 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PerfectPan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,149 @@
1
+ # @rivus/agent-kit-collab
2
+
3
+ Collaboration primitives for processes that run coding agents side by side: a lease with fencing tokens, so that
4
+ one process at a time owns a task and a stale owner's writes are refused, a single-instance process lock, and lanes
5
+ that run work per key, one activation at a time, under a global concurrency cap. The package knows no particular
6
+ agent; it reaches files, processes, clocks and SQLite through the `Platform` of
7
+ [`@rivus/agent-kit`](https://www.npmjs.com/package/@rivus/agent-kit).
8
+
9
+ Status: 0.x, with one lockstep release policy for this package and `@rivus/agent-kit` (same version). A minor release may contain breaking
10
+ changes.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install @rivus/agent-kit @rivus/agent-kit-collab
16
+ # for /lease and /lanes, which are Effect entries:
17
+ npm install effect@4.0.1
18
+ ```
19
+
20
+ Select a verified non-placeholder release that exports the entries you need, with both packages at the same
21
+ version. See [Adopting agent-kit](https://github.com/PerfectPan/agent-kit/blob/main/docs/development/adoption.md) for migration checks and rollback.
22
+
23
+ `@rivus/agent-kit` is a peer dependency, so the process holds one copy of the platform types. `effect` 4.0.1 is an
24
+ optional peer that only `/lease` and `/lanes` need. ESM only, no side effects, Node.js 22.13 or later on darwin or
25
+ linux (the locks identify processes by boot id, pid and start time, which the platform does not support on win32).
26
+ Every lock supports local directories only; neither SQLite's locks nor lock files are reliable on NFS. Holders are
27
+ judged by host name, boot id, pid and start time, so all processes that share a lock must see one process table:
28
+ containers that share the host's name and kernel but not its PID namespace are not supported, and a host renamed
29
+ while it holds locks makes its holders look remote (judged by the TTL alone; a lock file of a dead holder then stays
30
+ until removed).
31
+
32
+ ## Entries
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 |
39
+
40
+ ### Process lock
41
+
42
+ ```ts
43
+ import { createNodePlatform } from "@rivus/agent-kit/node";
44
+ import { acquireProcessLock } from "@rivus/agent-kit-collab/process-lock";
45
+
46
+ const lock = await acquireProcessLock(createNodePlatform(), "/var/run/my-daemon/daemon.lock");
47
+ if (!lock.ok) {
48
+ console.error(`already running (pid ${lock.error.holder?.pid ?? "unknown"})`);
49
+ process.exit(1);
50
+ }
51
+ // ... run; the kernel releases the lock if the process dies, or call:
52
+ await lock.value.release();
53
+ ```
54
+
55
+ With `platform.sqlite` the lock is a SQLite database held with `locking_mode=EXCLUSIVE`; the kernel drops it the
56
+ moment the holder exits or crashes, and nothing is reclaimed. The holder's identity goes into `<path>.holder` for
57
+ diagnostics only. Without SQLite the lock is a file that holds the identity; a lock file whose holder died on this
58
+ host is reclaimed on the next attempt, one reclaimer at a time. Pass `{ wait: true, signal }` to wait for the holder
59
+ instead of getting `ProcessLockHeld` at once.
60
+
61
+ ### Lease
62
+
63
+ ```ts
64
+ import { NodePlatformLive } from "@rivus/agent-kit/node/effect";
65
+ import { createLeaseManager, sqliteLeaseStore } from "@rivus/agent-kit-collab/lease";
66
+ import { Effect, Layer } from "effect";
67
+
68
+ const LeaseLive = sqliteLeaseStore({ path: "/var/lib/my-app/leases.db" }).pipe(Layer.provideMerge(NodePlatformLive));
69
+
70
+ const program = Effect.scoped(
71
+ Effect.gen(function* () {
72
+ const leases = yield* createLeaseManager({ ttlMs: 60_000, heartbeatMs: 15_000 });
73
+ const lease = yield* leases.acquire("task:42"); // fails with LeaseHeld while another live holder renews it
74
+ // Writes carry the fencing token; when the lease is lost, the fenced work is interrupted.
75
+ yield* lease.runFenced((token) => saveResult(token));
76
+ // Stop other work on loss by racing it against lease.lost.
77
+ yield* longRunningWork.pipe(Effect.raceFirst(lease.lost));
78
+ })
79
+ );
80
+
81
+ await Effect.runPromiseExit(program.pipe(Effect.provide(LeaseLive)));
82
+ ```
83
+
84
+ - A lease has one holder. Its generation, the fencing token, grows on every acquisition and never goes back: a
85
+ release keeps the record as a tombstone. Losing the lease and getting it again gives a new generation, so the old
86
+ handle's token is refused.
87
+ - The holder renews every `heartbeatMs` in the Scope that acquired it; `heartbeatMs × 2` must not exceed `ttlMs`.
88
+ Closing the Scope stops the heartbeat and releases the lease.
89
+ - A holder on the same host whose process is gone (or whose pid now belongs to another process) is taken over at
90
+ once. A holder that is alive but stopped renewing is taken over after the TTL, measured by the observer's own
91
+ 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
93
+ stops when the lease is lost. The fence is released when the work's fiber ends, so a successor's fenced work starts
94
+ after that. An interrupted fiber ends at once, but a Promise it started keeps running: put a write that cannot be
95
+ cancelled in `Effect.uninterruptible` (the fence then waits for it to settle), or pass it the AbortSignal that
96
+ `Effect.tryPromise` provides and let it settle only once the write has stopped.
97
+ - 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
99
+ 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.
105
+
106
+ ### Lanes
107
+
108
+ ```ts
109
+ import { createLanes } from "@rivus/agent-kit-collab/lanes";
110
+ import { Effect } from "effect";
111
+
112
+ const program = Effect.scoped(
113
+ Effect.gen(function* () {
114
+ const lanes = yield* createLanes({
115
+ maxConcurrent: 4, // activations running at once across all keys
116
+ maxQueued: 32, // keys waiting for a free slot
117
+ turnTimeoutMs: 600_000, // an activation running longer is interrupted
118
+ activate: (key) => runTurn(key), // the work for one key, in a Scope of its own
119
+ onExit: (exit) => report(exit) // ActivationSucceeded | ActivationFailed | ActivationInterrupted
120
+ });
121
+ yield* lanes.wake("room:1"); // "started", "queued" or "coalesced"; never waits for the activation
122
+ // ... closing the Scope interrupts the running activations and waits for them.
123
+ })
124
+ );
125
+ ```
126
+
127
+ - A lane runs at most one activation at a time. Every wake that arrives before an activation starts is served by
128
+ it: waking a running lane any number of times leaves one activation to follow the running one, and waking a
129
+ queued lane changes nothing.
130
+ - An idle lane starts while a slot is free and nobody waits; otherwise it waits at the end of the queue, and when
131
+ `maxQueued` lanes already wait, `wake` fails with `LaneQueueFull`. A freed slot goes to the lane that has waited
132
+ longest, so a lane woken again while it ran waits behind the lanes queued meanwhile. Without `maxQueued`, the queue
133
+ holds at most one entry per key.
134
+ - `cancel(key)` drops the wakes the key owes and interrupts its running activation, and returns once the activation
135
+ has ended (its finalizers and `onExit` included). `close` refuses later wakes with `LanesClosed`, drops the queue,
136
+ interrupts every running activation and waits for them; closing the Scope that created the lanes runs it.
137
+ - An activation runs with the context `createLanes` ran in. Its Scope closes when it ends, and the lane moves on only
138
+ after that: an interrupted activation's finalizers have run before the next activation of any key takes its slot.
139
+ - `onExit` hears how each activation ended. One that succeeded or failed by itself is reported so even when a cancel,
140
+ close or timeout arrives while its Scope closes; `ActivationInterrupted` means the lanes cut it short, or stopped it
141
+ before it started, in which case `activate` was never called. `onExit` runs uninterruptibly, so `cancel` and `close`
142
+ wait for it; it must not wait for them in turn, and a defect it raises is logged as a warning.
143
+
144
+ Nothing is persisted: lanes live in one process. Expected failures are typed values with a `_tag`:
145
+ `LaneQueueFull`, `LanesClosed` and `LanesConfigInvalid`.
146
+
147
+ ## License
148
+
149
+ MIT
@@ -0,0 +1,311 @@
1
+ import * as z from "zod/mini";
2
+ import { err, ok } from "@rivus/agent-kit/catalog";
3
+ //#region src/process-lock/application/holder.ts
4
+ const stampSchema = z.object({
5
+ host: z.string(),
6
+ bootId: z.string(),
7
+ pid: z.number(),
8
+ startTime: z.number(),
9
+ acquiredAt: z.number(),
10
+ nonce: z.string()
11
+ });
12
+ function newStamp(platform) {
13
+ const { host, bootId, pid, startTime } = platform.process.self;
14
+ return {
15
+ host,
16
+ bootId,
17
+ pid,
18
+ startTime,
19
+ acquiredAt: platform.clock.now(),
20
+ nonce: crypto.randomUUID()
21
+ };
22
+ }
23
+ function holderOf(stamp) {
24
+ const { host, bootId, pid, startTime, acquiredAt } = stamp;
25
+ return {
26
+ host,
27
+ bootId,
28
+ pid,
29
+ startTime,
30
+ acquiredAt
31
+ };
32
+ }
33
+ async function readStamp(platform, path) {
34
+ const text = await readText(platform, path);
35
+ if (text === void 0) return;
36
+ let json;
37
+ try {
38
+ json = JSON.parse(text);
39
+ } catch {
40
+ return {
41
+ text,
42
+ stamp: void 0
43
+ };
44
+ }
45
+ const parsed = stampSchema.safeParse(json);
46
+ return {
47
+ text,
48
+ stamp: parsed.success ? parsed.data : void 0
49
+ };
50
+ }
51
+ /** The text of a small file, `undefined` when it does not exist. */
52
+ async function readText(platform, path) {
53
+ const decoder = new TextDecoder();
54
+ let text = "";
55
+ try {
56
+ for await (const chunk of platform.fs.read(path)) text += decoder.decode(chunk, { stream: true });
57
+ } catch (error) {
58
+ if (typeof error === "object" && error !== null && error.code === "ENOENT") return;
59
+ throw error;
60
+ }
61
+ return text + decoder.decode();
62
+ }
63
+ //#endregion
64
+ //#region src/lease/domain/lease/policies/holder-liveness.ts
65
+ /**
66
+ * Judges a recorded holder from the observer's machine. `current` is what the observer's platform reports for the
67
+ * holder's pid now (`identify(holder.pid)`), `undefined` when no such process exists. A process of an earlier boot is
68
+ * dead; a pid now owned by a process with another start time is a reused pid, so the holder is dead too.
69
+ *
70
+ * Holder and observer must see the same processes: same host name and boot id are taken to mean one process table.
71
+ * Containers that share the host's name and kernel but have their own PID namespaces break that, and a live holder
72
+ * in another namespace looks dead. A host whose name changed makes its earlier holders look remote (`unknown`), so
73
+ * they are judged by the TTL alone.
74
+ */
75
+ function holderLiveness(holder, observer, current) {
76
+ if (holder.host !== observer.host) return "unknown";
77
+ if (holder.bootId !== observer.bootId || current === void 0) return "dead";
78
+ return current.bootId === holder.bootId && current.startTime === holder.startTime ? "alive" : "dead";
79
+ }
80
+ //#endregion
81
+ //#region src/process-lock/application/file-lock.ts
82
+ /**
83
+ * How long a lock file may stay empty or unreadable before it counts as abandoned. A holder writes its stamp right
84
+ * after creating the file, so only a process that died in between leaves it empty. A holder stalled for longer than
85
+ * this between the two steps loses its file to a reclaimer and may then overwrite the reclaimer's stamp with its own;
86
+ * the read-back below catches the overwrite only when it lands before the reclaimer reads back.
87
+ */
88
+ const UNSTAMPED_GRACE_MS = 3e4;
89
+ /** `.stale`, `.stale.stale`, …: each level serializes the reclaim of the level below. */
90
+ const MAX_RECLAIM_DEPTH = 3;
91
+ /**
92
+ * Tries once to take the lock file at `path`; it never waits for a live holder. The file holds the holder's stamp. A
93
+ * file whose holder is dead (same host, and an earlier boot, no such pid or a reused pid) is reclaimed, as
94
+ * npm/lockfile does with its `.STALE` lock: only the reclaimer that takes `<path>.stale` may remove it, and only after
95
+ * reading the same dead stamp again, so two reclaimers cannot each remove the other's fresh lock.
96
+ *
97
+ * Weaker than the SQLite lock: a holder on another host is never judged dead, and neither is a dead holder's stamp
98
+ * written before this host's name changed, so such a file stays until it is removed by hand; a stamp is written after
99
+ * the file is created, a crash between the two leaves an empty file for `UNSTAMPED_GRACE_MS`, and a holder stalled
100
+ * longer than that between the two can end up holding the lock together with its reclaimer.
101
+ */
102
+ async function tryFileLock(platform, path, stamp, depth = 0) {
103
+ let reclaimed = false;
104
+ for (let turn = 0; turn < 8; turn += 1) {
105
+ if (await platform.fs.createExclusive(path)) {
106
+ try {
107
+ await platform.fs.writeAtomic(path, JSON.stringify(stamp));
108
+ } catch (error) {
109
+ await platform.fs.remove(path);
110
+ throw error;
111
+ }
112
+ const written = await readStamp(platform, path);
113
+ if (written?.stamp?.nonce !== stamp.nonce) return {
114
+ acquired: false,
115
+ stamp: written?.stamp
116
+ };
117
+ return {
118
+ acquired: true,
119
+ stamp,
120
+ release: () => releaseFileLock(platform, path, stamp.nonce)
121
+ };
122
+ }
123
+ const observed = await readStamp(platform, path);
124
+ if (observed === void 0) continue;
125
+ if (reclaimed || depth >= MAX_RECLAIM_DEPTH || !await abandoned(platform, path, observed)) return {
126
+ acquired: false,
127
+ stamp: observed.stamp
128
+ };
129
+ reclaimed = await reclaim(platform, path, observed, stamp, depth);
130
+ if (!reclaimed) return {
131
+ acquired: false,
132
+ stamp: observed.stamp
133
+ };
134
+ }
135
+ return {
136
+ acquired: false,
137
+ stamp: (await readStamp(platform, path))?.stamp
138
+ };
139
+ }
140
+ async function abandoned(platform, path, observed) {
141
+ const { stamp } = observed;
142
+ if (stamp === void 0) {
143
+ const stat = await platform.fs.stat(path);
144
+ return stat !== void 0 && platform.clock.now() - stat.mtimeMs > UNSTAMPED_GRACE_MS;
145
+ }
146
+ const { self } = platform.process;
147
+ return holderLiveness(stamp, self, platform.process.identify(stamp.pid)) === "dead";
148
+ }
149
+ /** Removes the abandoned lock file while holding `<path>.stale`; `false` when another reclaimer holds that. */
150
+ async function reclaim(platform, path, observed, stamp, depth) {
151
+ const guard = await tryFileLock(platform, `${path}.stale`, {
152
+ ...stamp,
153
+ nonce: crypto.randomUUID()
154
+ }, depth + 1);
155
+ if (!guard.acquired) return false;
156
+ try {
157
+ const current = await readStamp(platform, path);
158
+ if (current !== void 0 && current.text === observed.text && await abandoned(platform, path, current)) await platform.fs.remove(path);
159
+ return true;
160
+ } finally {
161
+ await guard.release();
162
+ }
163
+ }
164
+ async function releaseFileLock(platform, path, nonce) {
165
+ if ((await readStamp(platform, path))?.stamp?.nonce === nonce) await platform.fs.remove(path);
166
+ }
167
+ //#endregion
168
+ //#region src/process-lock/application/sqlite-lock.ts
169
+ /** SQLITE_BUSY and SQLITE_LOCKED, the primary codes of their extended codes: another connection holds the lock. */
170
+ const BUSY_CODES = /* @__PURE__ */ new Set([5, 6]);
171
+ /**
172
+ * Takes an exclusive lock on the SQLite database at `path`, creating the file, or returns `undefined` when another
173
+ * connection holds it. The lock is an fcntl lock on the file: it lasts until `ROLLBACK` and `close`, and the kernel
174
+ * drops it when the process exits or crashes, so no holder can be left behind and nothing is ever reclaimed.
175
+ */
176
+ function trySqliteLock(sqlite, path, busyTimeoutMs) {
177
+ const db = sqlite.open(path);
178
+ try {
179
+ db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
180
+ db.exec("PRAGMA journal_mode = MEMORY");
181
+ db.exec("PRAGMA locking_mode = EXCLUSIVE");
182
+ db.exec("BEGIN EXCLUSIVE");
183
+ return db;
184
+ } catch (error) {
185
+ db.close();
186
+ if (isSqliteBusy(error)) return;
187
+ throw error;
188
+ }
189
+ }
190
+ function unlockSqlite(db) {
191
+ try {
192
+ db.exec("ROLLBACK");
193
+ } finally {
194
+ db.close();
195
+ }
196
+ }
197
+ function isSqliteBusy(error) {
198
+ if (typeof error !== "object" || error === null) return false;
199
+ const { errcode, message } = error;
200
+ return typeof errcode === "number" && BUSY_CODES.has(errcode & 255) || message === "database is locked";
201
+ }
202
+ //#endregion
203
+ //#region src/process-lock/application/acquire-process-lock.ts
204
+ /**
205
+ * A single-instance lock on `path`, which must be in an existing local directory (not NFS). With `platform.sqlite`,
206
+ * `path` is a SQLite database held with `locking_mode=EXCLUSIVE`: the kernel releases it when the process exits or
207
+ * crashes, at once, and nothing is ever reclaimed. The holder's identity goes into `<path>.holder` for diagnostics
208
+ * only. Without SQLite, `path` is a lock file that holds the identity itself; a lock file whose holder died is
209
+ * reclaimed on the next attempt, which needs the holder on this host (see `tryFileLock` for the remaining gaps).
210
+ *
211
+ * Supports darwin and linux, where the platform can identify processes.
212
+ */
213
+ async function acquireProcessLock(platform, path, options = {}) {
214
+ const { signal, wait = false, retryMs = 50 } = options;
215
+ for (let attempt = 0;; attempt += 1) {
216
+ signal?.throwIfAborted();
217
+ const result = await tryProcessLock(platform, path, { first: attempt === 0 });
218
+ if (result.ok) {
219
+ if (signal?.aborted === true) {
220
+ await result.value.release();
221
+ signal.throwIfAborted();
222
+ }
223
+ return result;
224
+ }
225
+ if (!wait) return result;
226
+ await delay(backoffMs(retryMs, attempt), signal);
227
+ }
228
+ }
229
+ /** Retries of a waiting caller double from `retryMs` up to 16 times it, with jitter so that waiters drift apart. */
230
+ function backoffMs(retryMs, attempt) {
231
+ return Math.min(retryMs * 2 ** attempt, retryMs * 16) * (.75 + Math.random() * .5);
232
+ }
233
+ const SETTLE_RETRIES = 4;
234
+ /**
235
+ * One attempt without waiting for a holder. The `first` attempt of an acquisition settles a race between processes
236
+ * that start together (see `SETTLE_MS`); later attempts of a waiting caller do not block the thread at all.
237
+ */
238
+ async function tryProcessLock(platform, path, options) {
239
+ const stamp = newStamp(platform);
240
+ const { sqlite } = platform;
241
+ if (sqlite === void 0) {
242
+ const attempt = await tryFileLock(platform, path, stamp);
243
+ if (!attempt.acquired) return err({
244
+ _tag: "ProcessLockHeld",
245
+ path,
246
+ holder: attempt.stamp && holderOf(attempt.stamp)
247
+ });
248
+ return ok({
249
+ path,
250
+ mechanism: "file",
251
+ holder: holderOf(attempt.stamp),
252
+ release: once(attempt.release)
253
+ });
254
+ }
255
+ const holderFile = `${path}.holder`;
256
+ const busyTimeoutMs = options.first ? 10 : 0;
257
+ let db = trySqliteLock(sqlite, path, busyTimeoutMs);
258
+ for (let retry = 0; db === void 0 && options.first && retry < SETTLE_RETRIES; retry += 1) {
259
+ await delay(5 + Math.random() * 20, void 0);
260
+ db = trySqliteLock(sqlite, path, busyTimeoutMs);
261
+ }
262
+ if (db === void 0) {
263
+ const recorded = await readStamp(platform, holderFile);
264
+ return err({
265
+ _tag: "ProcessLockHeld",
266
+ path,
267
+ holder: recorded?.stamp && holderOf(recorded.stamp)
268
+ });
269
+ }
270
+ try {
271
+ await platform.fs.writeAtomic(holderFile, JSON.stringify(stamp));
272
+ } catch (error) {
273
+ unlockSqlite(db);
274
+ throw error;
275
+ }
276
+ const release = async () => {
277
+ try {
278
+ await platform.fs.remove(holderFile);
279
+ } finally {
280
+ unlockSqlite(db);
281
+ }
282
+ };
283
+ return ok({
284
+ path,
285
+ mechanism: "sqlite",
286
+ holder: holderOf(stamp),
287
+ release: once(release)
288
+ });
289
+ }
290
+ function once(release) {
291
+ let released;
292
+ return () => {
293
+ released ??= release();
294
+ return released;
295
+ };
296
+ }
297
+ function delay(ms, signal) {
298
+ return new Promise((resolve, reject) => {
299
+ const onAbort = () => {
300
+ clearTimeout(timer);
301
+ reject(signal?.reason);
302
+ };
303
+ const timer = setTimeout(() => {
304
+ signal?.removeEventListener("abort", onAbort);
305
+ resolve();
306
+ }, ms);
307
+ signal?.addEventListener("abort", onAbort, { once: true });
308
+ });
309
+ }
310
+ //#endregion
311
+ export { holderLiveness as a, isSqliteBusy as i, backoffMs as n, readText as o, tryProcessLock as r, acquireProcessLock as t };
@@ -0,0 +1,120 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { Result } from "@rivus/agent-kit/catalog";
3
+ import * as Cause from "effect/Cause";
4
+ import * as Scope from "effect/Scope";
5
+ //#region src/lanes/domain/lane/errors/lane-queue-full.d.ts
6
+ /** An idle lane was woken while every slot was taken and `maxQueued` lanes were already waiting. */
7
+ interface LaneQueueFull {
8
+ readonly _tag: "LaneQueueFull";
9
+ readonly key: string;
10
+ readonly maxQueued: number;
11
+ }
12
+ //#endregion
13
+ //#region src/lanes/domain/lane/errors/lanes-config-invalid.d.ts
14
+ /** `maxConcurrent`, `maxQueued` or `turnTimeoutMs` is out of range. */
15
+ interface LanesConfigInvalid {
16
+ readonly _tag: "LanesConfigInvalid";
17
+ readonly message: string;
18
+ }
19
+ //#endregion
20
+ //#region src/lanes/domain/lane/value-objects/lane-snapshot.d.ts
21
+ /**
22
+ * `idle`: nothing runs and nothing is owed. `queued`: woken while every slot was taken, waiting for one. `running`: one
23
+ * activation runs.
24
+ */
25
+ type LaneState = "idle" | "queued" | "running";
26
+ interface LaneSnapshot {
27
+ readonly key: string;
28
+ readonly state: LaneState;
29
+ /**
30
+ * A wake that no started activation has served yet: always set while `queued`, and set while `running` when a wake
31
+ * arrived after the activation started, so one more activation follows it.
32
+ */
33
+ readonly pending: boolean;
34
+ }
35
+ //#endregion
36
+ //#region src/lanes/application/create-lanes.d.ts
37
+ interface LanesConfig<E, R> {
38
+ /** Activations that may run at once across all keys; a positive integer. */
39
+ readonly maxConcurrent: number;
40
+ /**
41
+ * Lanes that may wait for a free slot; a non-negative integer. A lane waits at most once however often it is woken,
42
+ * so without a bound the queue holds at most one entry per key.
43
+ */
44
+ readonly maxQueued?: number;
45
+ /** The longest one activation may run, in milliseconds, before it is interrupted; no limit when absent. */
46
+ readonly turnTimeoutMs?: number;
47
+ /**
48
+ * The work for one key. It runs in a Scope of its own, closed when it ends, with the context `createLanes` ran in.
49
+ * `cancel`, `close` and the turn timeout interrupt it, and the lane moves on only after its finalizers have run.
50
+ */
51
+ readonly activate: (key: string) => Effect.Effect<unknown, E, R>;
52
+ /**
53
+ * Hears how each activation ended, before the lane can start its next activation. It runs uninterruptibly, and
54
+ * `cancel` and `close` wait for it, so keep it short and do not wait for either of them in it. A defect it raises is
55
+ * logged as a warning.
56
+ */
57
+ readonly onExit?: (exit: ActivationExit<E>) => Effect.Effect<void, never, R>;
58
+ }
59
+ /** Why the lanes interrupted an activation. */
60
+ type ActivationInterruptReason = "cancel" | "close" | "timeout";
61
+ /**
62
+ * How an activation ended. An activation that succeeded or failed by itself is reported so even when a cancel, close
63
+ * or timeout comes while its Scope closes; `ActivationInterrupted` means the lanes cut it short or it never started.
64
+ */
65
+ type ActivationExit<E> = {
66
+ readonly _tag: "ActivationSucceeded";
67
+ readonly key: string;
68
+ } | {
69
+ readonly _tag: "ActivationFailed";
70
+ readonly key: string;
71
+ readonly cause: Cause.Cause<E>;
72
+ } | {
73
+ readonly _tag: "ActivationInterrupted";
74
+ readonly key: string;
75
+ readonly reason: ActivationInterruptReason;
76
+ };
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
+ /** `close` ran, or the Scope that created the lanes closed. */
83
+ interface LanesClosed {
84
+ readonly _tag: "LanesClosed";
85
+ readonly key: string;
86
+ }
87
+ interface LanesStatus {
88
+ readonly running: number;
89
+ readonly queued: number;
90
+ readonly closed: boolean;
91
+ /** Every lane that is not idle: the running ones, then the queued ones in queue order. */
92
+ readonly lanes: readonly LaneSnapshot[];
93
+ }
94
+ /** Lanes created in a caller's Scope; closing that Scope runs `close`. */
95
+ interface Lanes {
96
+ /**
97
+ * Asks for an activation of `key` without waiting for it. An idle lane starts when a slot is free and nobody waits
98
+ * for one, otherwise it joins the end of the queue, or fails with `LaneQueueFull` when `maxQueued` lanes already
99
+ * wait. A queued or running lane coalesces the wake: at most one more activation follows the running one.
100
+ */
101
+ wake(key: string): Effect.Effect<WakeResult, LaneQueueFull | LanesClosed>;
102
+ /**
103
+ * Drops the wakes `key` owes and interrupts its running activation, then waits until that activation, `onExit`
104
+ * included, has ended. Wakes that arrive after the cancel are served as usual.
105
+ */
106
+ cancel(key: string): Effect.Effect<void>;
107
+ readonly status: Effect.Effect<LanesStatus>;
108
+ /**
109
+ * Refuses later wakes with `LanesClosed`, drops the queue and every pending wake, interrupts the running
110
+ * activations and waits until they have ended. Idempotent.
111
+ */
112
+ readonly close: Effect.Effect<void>;
113
+ }
114
+ /**
115
+ * Creates lanes in the caller's Scope. The lanes capture the context they are created in and give it to every
116
+ * activation; fails with `LanesConfigInvalid` for a limit out of range.
117
+ */
118
+ export declare function createLanes<E = never, R = never>(config: LanesConfig<E, R>): Effect.Effect<Lanes, LanesConfigInvalid, R | Scope.Scope>;
119
+ //#endregion
120
+ export type { ActivationExit, ActivationInterruptReason, LaneQueueFull, LaneSnapshot, LaneState, Lanes, LanesClosed, LanesConfig, LanesConfigInvalid, LanesStatus, WakeResult };