@intx/agent 0.1.2

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.
@@ -0,0 +1,93 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { resolve } from "node:path";
3
+
4
+ import { acquireContextDirLock, AgentInUseError } from "./lock";
5
+
6
+ describe("acquireContextDirLock", () => {
7
+ test("returns a lock whose path is the resolved absolute path", () => {
8
+ const lock = acquireContextDirLock("/tmp/agent-lock-1");
9
+ try {
10
+ expect(lock.path).toBe(resolve("/tmp/agent-lock-1"));
11
+ } finally {
12
+ lock.release();
13
+ }
14
+ });
15
+
16
+ test("rejects a second acquisition of the same directory", () => {
17
+ const lock = acquireContextDirLock("/tmp/agent-lock-2");
18
+ try {
19
+ expect(() => acquireContextDirLock("/tmp/agent-lock-2")).toThrow(
20
+ AgentInUseError,
21
+ );
22
+ } finally {
23
+ lock.release();
24
+ }
25
+ });
26
+
27
+ test("re-acquires the same directory after release", () => {
28
+ const lock1 = acquireContextDirLock("/tmp/agent-lock-3");
29
+ lock1.release();
30
+ const lock2 = acquireContextDirLock("/tmp/agent-lock-3");
31
+ try {
32
+ expect(lock2.path).toBe(resolve("/tmp/agent-lock-3"));
33
+ } finally {
34
+ lock2.release();
35
+ }
36
+ });
37
+
38
+ test("collides on lexically distinct but equivalent paths", () => {
39
+ const lock = acquireContextDirLock("/tmp/agent-lock-4/../agent-lock-4/foo");
40
+ try {
41
+ expect(() => acquireContextDirLock("/tmp/agent-lock-4/foo")).toThrow(
42
+ AgentInUseError,
43
+ );
44
+ } finally {
45
+ lock.release();
46
+ }
47
+ });
48
+
49
+ test("collides on a relative path that resolves to the same absolute path", () => {
50
+ const absolute = resolve("relative-lock-test-dir");
51
+ const lock = acquireContextDirLock("relative-lock-test-dir");
52
+ try {
53
+ expect(lock.path).toBe(absolute);
54
+ expect(() => acquireContextDirLock(absolute)).toThrow(AgentInUseError);
55
+ } finally {
56
+ lock.release();
57
+ }
58
+ });
59
+
60
+ test("does not collide between distinct directories", () => {
61
+ const lock1 = acquireContextDirLock("/tmp/agent-lock-5a");
62
+ const lock2 = acquireContextDirLock("/tmp/agent-lock-5b");
63
+ try {
64
+ expect(lock1.path).not.toBe(lock2.path);
65
+ } finally {
66
+ lock1.release();
67
+ lock2.release();
68
+ }
69
+ });
70
+
71
+ test("release is idempotent", () => {
72
+ const lock = acquireContextDirLock("/tmp/agent-lock-6");
73
+ lock.release();
74
+ lock.release();
75
+ const reacquired = acquireContextDirLock("/tmp/agent-lock-6");
76
+ reacquired.release();
77
+ });
78
+
79
+ test("AgentInUseError exposes contextDir as the resolved path", () => {
80
+ const lock = acquireContextDirLock("/tmp/agent-lock-7");
81
+ try {
82
+ acquireContextDirLock("/tmp/agent-lock-7");
83
+ throw new Error("should have thrown AgentInUseError");
84
+ } catch (err) {
85
+ expect(err).toBeInstanceOf(AgentInUseError);
86
+ if (err instanceof AgentInUseError) {
87
+ expect(err.contextDir).toBe(resolve("/tmp/agent-lock-7"));
88
+ }
89
+ } finally {
90
+ lock.release();
91
+ }
92
+ });
93
+ });
package/src/lock.ts ADDED
@@ -0,0 +1,57 @@
1
+ // Process-wide registry of held context-directory locks.
2
+ //
3
+ // The agent enforces a runtime singleton-per-contextDir invariant: at most one
4
+ // in-process agent may own a given context directory at a time. Holding two
5
+ // agents against the same directory simultaneously corrupts both the git
6
+ // state and the audit collector's bookkeeping.
7
+ //
8
+ // This is a best-effort in-process check. It does not coordinate across OS
9
+ // processes, and it compares lexically-resolved absolute paths — two paths
10
+ // that point to the same directory through symlinks or `..`/`/./` segments
11
+ // are normalized by `path.resolve`, but a hard link or a separately mounted
12
+ // bind to the same inode will not be detected. Callers passing their own
13
+ // `contextStore` (rather than a `contextDir` string) bypass the lock; they
14
+ // are responsible for their store's lifetime.
15
+
16
+ import { resolve } from "node:path";
17
+
18
+ const heldLocks = new Set<string>();
19
+
20
+ export class AgentInUseError extends Error {
21
+ readonly contextDir: string;
22
+
23
+ constructor(contextDir: string) {
24
+ super(`an agent is already open for context directory: ${contextDir}`);
25
+ this.name = "AgentInUseError";
26
+ this.contextDir = contextDir;
27
+ }
28
+ }
29
+
30
+ export type ContextDirLock = {
31
+ /** Absolute, resolved path of the locked directory. */
32
+ readonly path: string;
33
+ /** Release the lock. Idempotent. */
34
+ release(): void;
35
+ };
36
+
37
+ /**
38
+ * Acquire the process-wide lock for `contextDir`. Throws `AgentInUseError`
39
+ * if another agent already holds it. The returned `release` is idempotent.
40
+ */
41
+ export function acquireContextDirLock(contextDir: string): ContextDirLock {
42
+ const path = resolve(contextDir);
43
+ if (heldLocks.has(path)) {
44
+ throw new AgentInUseError(path);
45
+ }
46
+ heldLocks.add(path);
47
+
48
+ let released = false;
49
+ return {
50
+ path,
51
+ release() {
52
+ if (released) return;
53
+ released = true;
54
+ heldLocks.delete(path);
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,207 @@
1
+ import { describe, test, expect } from "bun:test";
2
+
3
+ import { createSendQueue, SendQueueFullError } from "./send-queue";
4
+
5
+ /**
6
+ * Tests use `string` as the result type so the queue's generic R parameter
7
+ * holds a real value (it cannot be `void` under
8
+ * `@typescript-eslint/no-invalid-void-type`). The actual values are
9
+ * arbitrary; tests assert ordering and lifecycle, not result content.
10
+ */
11
+
12
+ describe("createSendQueue", () => {
13
+ test("starts the first enqueued item immediately", () => {
14
+ const started: string[] = [];
15
+ const q = createSendQueue<string, string>({
16
+ maxDepth: 4,
17
+ start: (item) => {
18
+ started.push(item);
19
+ },
20
+ });
21
+
22
+ void q.enqueue("a");
23
+ expect(started).toEqual(["a"]);
24
+ });
25
+
26
+ test("queues subsequent items and starts each on resolveActive", async () => {
27
+ const started: string[] = [];
28
+ const q = createSendQueue<string, string>({
29
+ maxDepth: 4,
30
+ start: (item) => {
31
+ started.push(item);
32
+ },
33
+ });
34
+
35
+ const p1 = q.enqueue("a");
36
+ const p2 = q.enqueue("b");
37
+ expect(started).toEqual(["a"]);
38
+
39
+ q.resolveActive("r1");
40
+ expect(await p1).toBe("r1");
41
+ expect(started).toEqual(["a", "b"]);
42
+
43
+ q.resolveActive("r2");
44
+ expect(await p2).toBe("r2");
45
+ });
46
+
47
+ test("throws SendQueueFullError synchronously at capacity", () => {
48
+ const started: number[] = [];
49
+ const q = createSendQueue<number, string>({
50
+ maxDepth: 2,
51
+ start: (item) => {
52
+ started.push(item);
53
+ },
54
+ });
55
+
56
+ void q.enqueue(1);
57
+ void q.enqueue(2);
58
+ expect(() => q.enqueue(3)).toThrow(SendQueueFullError);
59
+ });
60
+
61
+ test("rejects pre-aborted signal without enqueuing", async () => {
62
+ const ctl = new AbortController();
63
+ ctl.abort();
64
+
65
+ const started: number[] = [];
66
+ const q = createSendQueue<number, string>({
67
+ maxDepth: 4,
68
+ start: (item) => {
69
+ started.push(item);
70
+ },
71
+ });
72
+
73
+ await expect(q.enqueue(1, ctl.signal)).rejects.toBeDefined();
74
+ expect(started).toEqual([]);
75
+ expect(q.depth).toBe(0);
76
+ });
77
+
78
+ test("removes a queued item whose signal fires before processing", async () => {
79
+ const started: number[] = [];
80
+ const ctl = new AbortController();
81
+ const q = createSendQueue<number, string>({
82
+ maxDepth: 4,
83
+ start: (item) => {
84
+ started.push(item);
85
+ },
86
+ });
87
+
88
+ const p1 = q.enqueue(1);
89
+ const p2 = q.enqueue(2, ctl.signal);
90
+ const p3 = q.enqueue(3);
91
+
92
+ expect(started).toEqual([1]);
93
+ ctl.abort();
94
+ await expect(p2).rejects.toBeDefined();
95
+
96
+ q.resolveActive("r1");
97
+ expect(await p1).toBe("r1");
98
+ expect(started).toEqual([1, 3]);
99
+
100
+ q.resolveActive("r3");
101
+ expect(await p3).toBe("r3");
102
+ });
103
+
104
+ test("settles caller on in-flight abort but waits for consumer to advance", async () => {
105
+ const started: number[] = [];
106
+ const ctl = new AbortController();
107
+ const q = createSendQueue<number, string>({
108
+ maxDepth: 4,
109
+ start: (item) => {
110
+ started.push(item);
111
+ },
112
+ });
113
+
114
+ const p1 = q.enqueue(1, ctl.signal);
115
+ const p2 = q.enqueue(2);
116
+ expect(started).toEqual([1]);
117
+
118
+ ctl.abort();
119
+ await expect(p1).rejects.toBeDefined();
120
+ // Active slot is still held until the consumer reports the cycle done.
121
+ expect(started).toEqual([1]);
122
+
123
+ q.resolveActive("late-r1");
124
+ expect(started).toEqual([1, 2]);
125
+
126
+ q.resolveActive("r2");
127
+ expect(await p2).toBe("r2");
128
+ });
129
+
130
+ test("late resolveActive after abort is a no-op for the caller", async () => {
131
+ const ctl = new AbortController();
132
+ const started: number[] = [];
133
+ const q = createSendQueue<number, string>({
134
+ maxDepth: 4,
135
+ start: (item) => {
136
+ started.push(item);
137
+ },
138
+ });
139
+
140
+ const p = q.enqueue(1, ctl.signal);
141
+ ctl.abort();
142
+ await expect(p).rejects.toBeDefined();
143
+
144
+ q.resolveActive("late");
145
+ expect(q.depth).toBe(0);
146
+ });
147
+
148
+ test("drain rejects active and pending jobs with the given reason", async () => {
149
+ const started: number[] = [];
150
+ const q = createSendQueue<number, string>({
151
+ maxDepth: 4,
152
+ start: (item) => {
153
+ started.push(item);
154
+ },
155
+ });
156
+
157
+ const p1 = q.enqueue(1);
158
+ const p2 = q.enqueue(2);
159
+ const reason = new Error("closed");
160
+
161
+ q.drain(reason);
162
+ await expect(p1).rejects.toBe(reason);
163
+ await expect(p2).rejects.toBe(reason);
164
+ expect(q.depth).toBe(0);
165
+ });
166
+
167
+ test("depth reflects active + pending", () => {
168
+ const started: number[] = [];
169
+ const q = createSendQueue<number, string>({
170
+ maxDepth: 4,
171
+ start: (item) => {
172
+ started.push(item);
173
+ },
174
+ });
175
+
176
+ expect(q.depth).toBe(0);
177
+ void q.enqueue(1);
178
+ expect(q.depth).toBe(1);
179
+ void q.enqueue(2);
180
+ expect(q.depth).toBe(2);
181
+
182
+ q.resolveActive("r1");
183
+ expect(q.depth).toBe(1);
184
+ q.resolveActive("r2");
185
+ expect(q.depth).toBe(0);
186
+ });
187
+
188
+ test("capacity check counts the abandoned active slot", async () => {
189
+ const ctl = new AbortController();
190
+ const started: number[] = [];
191
+ const q = createSendQueue<number, string>({
192
+ maxDepth: 2,
193
+ start: (item) => {
194
+ started.push(item);
195
+ },
196
+ });
197
+
198
+ const p1 = q.enqueue(1, ctl.signal);
199
+ void q.enqueue(2);
200
+ ctl.abort();
201
+ await expect(p1).rejects.toBeDefined();
202
+
203
+ // Active slot still occupied (consumer has not advanced). Adding a
204
+ // third would exceed maxDepth=2.
205
+ expect(() => q.enqueue(3)).toThrow(SendQueueFullError);
206
+ });
207
+ });
@@ -0,0 +1,200 @@
1
+ // FIFO queue for serializing send() calls against a single reactor.
2
+ //
3
+ // The agent processes one reactor cycle at a time, so concurrent send()
4
+ // callers are queued. Each queued item carries the caller's resolve/reject
5
+ // hooks and an optional AbortSignal:
6
+ //
7
+ // - If the signal is already aborted when enqueue() is called the queue
8
+ // rejects synchronously without enqueueing.
9
+ // - If the signal fires while the item is still queued the item is
10
+ // removed and rejected.
11
+ // - If the signal fires while the item is active the caller-facing
12
+ // promise rejects immediately, but the reactor cycle continues in the
13
+ // background. The queue does not start the next item until the consumer
14
+ // reports the cycle done via resolveActive/rejectActive. This keeps the
15
+ // queue ordered against actual reactor cycles — two send() promises
16
+ // cannot interleave at the reactor level.
17
+ //
18
+ // Queue depth (active + pending) is bounded by `maxDepth`; exceeding it
19
+ // throws `SendQueueFullError` synchronously from enqueue() so a buggy
20
+ // caller flooding sends fails loud instead of silently buffering.
21
+
22
+ export class SendQueueFullError extends Error {
23
+ readonly maxDepth: number;
24
+
25
+ constructor(maxDepth: number) {
26
+ super(`send queue is full (max depth ${String(maxDepth)})`);
27
+ this.name = "SendQueueFullError";
28
+ this.maxDepth = maxDepth;
29
+ }
30
+ }
31
+
32
+ type Job<T, R> = {
33
+ item: T;
34
+ signal?: AbortSignal;
35
+ abortHandler?: () => void;
36
+ resolve: (value: R) => void;
37
+ reject: (reason: unknown) => void;
38
+ /**
39
+ * True once the caller-facing promise has been settled (resolve or
40
+ * reject). Subsequent settles are no-ops. The active slot may remain
41
+ * occupied after a settle when the caller aborted mid-cycle — the queue
42
+ * waits for the consumer's resolveActive/rejectActive before pumping the
43
+ * next item.
44
+ */
45
+ settled: boolean;
46
+ };
47
+
48
+ export type SendQueueOptions<T> = {
49
+ maxDepth: number;
50
+ /**
51
+ * Called when a job moves from pending to active. The consumer drives
52
+ * the underlying work and must eventually call `resolveActive` or
53
+ * `rejectActive` exactly once.
54
+ */
55
+ start: (item: T) => void;
56
+ };
57
+
58
+ export type SendQueue<T, R> = {
59
+ enqueue(item: T, signal?: AbortSignal): Promise<R>;
60
+ /** Mark the active job complete with success and pump the next. */
61
+ resolveActive(value: R): void;
62
+ /** Mark the active job complete with failure and pump the next. */
63
+ rejectActive(reason: unknown): void;
64
+ /** Reject the active job (if any) and every pending job with `reason`. */
65
+ drain(reason: unknown): void;
66
+ /** Current pending count (queued + active). */
67
+ readonly depth: number;
68
+ };
69
+
70
+ function abortReason(signal: AbortSignal): unknown {
71
+ return signal.reason ?? new DOMException("aborted", "AbortError");
72
+ }
73
+
74
+ export function createSendQueue<T, R>(
75
+ opts: SendQueueOptions<T>,
76
+ ): SendQueue<T, R> {
77
+ const pending: Job<T, R>[] = [];
78
+ let active: Job<T, R> | null = null;
79
+
80
+ function settle(
81
+ job: Job<T, R>,
82
+ kind: "resolve" | "reject",
83
+ value: unknown,
84
+ ): void {
85
+ if (job.settled) return;
86
+ job.settled = true;
87
+ if (job.abortHandler !== undefined && job.signal !== undefined) {
88
+ job.signal.removeEventListener("abort", job.abortHandler);
89
+ }
90
+ if (kind === "resolve") {
91
+ // The queue's value type is checked at enqueue / resolveActive; the
92
+ // generic narrowing here is safe by construction.
93
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- generic resolve value
94
+ job.resolve(value as R);
95
+ } else {
96
+ job.reject(value);
97
+ }
98
+ }
99
+
100
+ function pump(): void {
101
+ while (active === null && pending.length > 0) {
102
+ const next = pending.shift();
103
+ if (next === undefined) return;
104
+ if (next.signal?.aborted === true) {
105
+ settle(next, "reject", abortReason(next.signal));
106
+ continue;
107
+ }
108
+ active = next;
109
+ opts.start(next.item);
110
+ return;
111
+ }
112
+ }
113
+
114
+ function enqueue(item: T, signal?: AbortSignal): Promise<R> {
115
+ if (signal?.aborted === true) {
116
+ return Promise.reject(abortReason(signal));
117
+ }
118
+
119
+ const depth = pending.length + (active !== null ? 1 : 0);
120
+ if (depth >= opts.maxDepth) {
121
+ throw new SendQueueFullError(opts.maxDepth);
122
+ }
123
+
124
+ let resolve!: (value: R) => void;
125
+ let reject!: (reason: unknown) => void;
126
+ const promise = new Promise<R>((res, rej) => {
127
+ resolve = res;
128
+ reject = rej;
129
+ });
130
+
131
+ const job: Job<T, R> = {
132
+ item,
133
+ ...(signal !== undefined ? { signal } : {}),
134
+ resolve,
135
+ reject,
136
+ settled: false,
137
+ };
138
+
139
+ if (signal !== undefined) {
140
+ const handler = (): void => {
141
+ const reason = abortReason(signal);
142
+ if (active === job) {
143
+ // In flight: settle the caller now; the consumer will eventually
144
+ // call resolveActive/rejectActive which becomes a no-op and
145
+ // advances the queue.
146
+ settle(job, "reject", reason);
147
+ } else {
148
+ const idx = pending.indexOf(job);
149
+ if (idx >= 0) pending.splice(idx, 1);
150
+ settle(job, "reject", reason);
151
+ }
152
+ };
153
+ signal.addEventListener("abort", handler, { once: true });
154
+ job.abortHandler = handler;
155
+ }
156
+
157
+ pending.push(job);
158
+ pump();
159
+ return promise;
160
+ }
161
+
162
+ function resolveActive(value: R): void {
163
+ if (active === null) return;
164
+ const job = active;
165
+ active = null;
166
+ settle(job, "resolve", value);
167
+ pump();
168
+ }
169
+
170
+ function rejectActive(reason: unknown): void {
171
+ if (active === null) return;
172
+ const job = active;
173
+ active = null;
174
+ settle(job, "reject", reason);
175
+ pump();
176
+ }
177
+
178
+ function drain(reason: unknown): void {
179
+ const drained: Job<T, R>[] = [];
180
+ if (active !== null) {
181
+ drained.push(active);
182
+ active = null;
183
+ }
184
+ drained.push(...pending);
185
+ pending.length = 0;
186
+ for (const job of drained) {
187
+ settle(job, "reject", reason);
188
+ }
189
+ }
190
+
191
+ return {
192
+ enqueue,
193
+ resolveActive,
194
+ rejectActive,
195
+ drain,
196
+ get depth() {
197
+ return pending.length + (active !== null ? 1 : 0);
198
+ },
199
+ };
200
+ }