@jitsusama/agentic-harness.core 0.6.1 → 0.6.3

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,30 @@
1
+ /**
2
+ * An advisory lock around one file's read-modify-write, for async
3
+ * callers.
4
+ *
5
+ * A read-modify-write that two writers run at once loses one of them:
6
+ * both read the same document and the second write lays its version
7
+ * over the first. Two layers stop that. Within a process, writers of
8
+ * one path queue behind each other, so parallel tool calls in one
9
+ * session never contend. Across processes, a lock file created with
10
+ * `wx` holds the holder's pid, and the next writer waits for it.
11
+ *
12
+ * The synchronous counterpart for quest READMEs is in
13
+ * `quest/io.ts`; its callers cannot await, so it spins instead.
14
+ *
15
+ * A lock is taken over when its holder has gone or it is older than
16
+ * any real write takes, since a crashed session must not wedge the
17
+ * file for good and a pid can be reused. Taking over renames the lock
18
+ * aside rather than deleting it, and checks the file it moved is the
19
+ * one it judged stale: between that judgement and the move, another
20
+ * writer may have taken over first and made a fresh one, and deleting
21
+ * by name would delete theirs. Release checks the same way, so a
22
+ * holder that was taken over from does not delete its successor's.
23
+ */
24
+ /**
25
+ * Run `fn` while holding the lock for `path`, queued behind any other
26
+ * writer of the same path in this process and locked against every
27
+ * other process. The lock file is `${path}.lock`, so its directory has
28
+ * to exist.
29
+ */
30
+ export declare function withFileLock<T>(path: string, fn: () => Promise<T>): Promise<T>;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * An advisory lock around one file's read-modify-write, for async
3
+ * callers.
4
+ *
5
+ * A read-modify-write that two writers run at once loses one of them:
6
+ * both read the same document and the second write lays its version
7
+ * over the first. Two layers stop that. Within a process, writers of
8
+ * one path queue behind each other, so parallel tool calls in one
9
+ * session never contend. Across processes, a lock file created with
10
+ * `wx` holds the holder's pid, and the next writer waits for it.
11
+ *
12
+ * The synchronous counterpart for quest READMEs is in
13
+ * `quest/io.ts`; its callers cannot await, so it spins instead.
14
+ *
15
+ * A lock is taken over when its holder has gone or it is older than
16
+ * any real write takes, since a crashed session must not wedge the
17
+ * file for good and a pid can be reused. Taking over renames the lock
18
+ * aside rather than deleting it, and checks the file it moved is the
19
+ * one it judged stale: between that judgement and the move, another
20
+ * writer may have taken over first and made a fresh one, and deleting
21
+ * by name would delete theirs. Release checks the same way, so a
22
+ * holder that was taken over from does not delete its successor's.
23
+ */
24
+ import { randomUUID } from "node:crypto";
25
+ import { link, open, readFile, rename, stat, unlink } from "node:fs/promises";
26
+ /** How long a writer waits for a live holder before saying so. */
27
+ const WAIT_MS = 10_000;
28
+ /** Pause between attempts at the lock. */
29
+ const RETRY_MS = 20;
30
+ /**
31
+ * A lock older than this is taken over whoever holds it. A write under
32
+ * it is a read and a rename of a small file, milliseconds, so anything
33
+ * this old is a holder that stopped rather than one still writing.
34
+ */
35
+ const STALE_MS = 30_000;
36
+ const queues = new Map();
37
+ /**
38
+ * Run `fn` while holding the lock for `path`, queued behind any other
39
+ * writer of the same path in this process and locked against every
40
+ * other process. The lock file is `${path}.lock`, so its directory has
41
+ * to exist.
42
+ */
43
+ export async function withFileLock(path, fn) {
44
+ const before = queues.get(path) ?? Promise.resolve();
45
+ const turn = before.then(() => locked(path, fn), () => locked(path, fn));
46
+ // The queue only orders writers; it never carries a failure from one
47
+ // to the next, so the entry it holds settles either way.
48
+ const settled = turn.then(() => undefined, () => undefined);
49
+ queues.set(path, settled);
50
+ try {
51
+ return await turn;
52
+ }
53
+ finally {
54
+ if (queues.get(path) === settled)
55
+ queues.delete(path);
56
+ }
57
+ }
58
+ async function locked(path, fn) {
59
+ const lockPath = `${path}.lock`;
60
+ const held = await acquire(lockPath);
61
+ try {
62
+ return await fn();
63
+ }
64
+ finally {
65
+ await release(lockPath, held);
66
+ }
67
+ }
68
+ async function acquire(lockPath) {
69
+ const deadline = Date.now() + WAIT_MS;
70
+ while (true) {
71
+ try {
72
+ const handle = await open(lockPath, "wx");
73
+ try {
74
+ await handle.writeFile(String(process.pid), "utf8");
75
+ return (await handle.stat()).ino;
76
+ }
77
+ finally {
78
+ await handle.close();
79
+ }
80
+ }
81
+ catch (error) {
82
+ if (!hasCode(error, "EEXIST"))
83
+ throw error;
84
+ }
85
+ if (await takeOverIfStale(lockPath))
86
+ continue;
87
+ if (Date.now() >= deadline) {
88
+ const holder = await holderOf(lockPath);
89
+ throw new Error(`${lockPath} has been held for over ${WAIT_MS / 1000}s by ${holder === undefined ? "a process that did not say which" : `pid ${holder}`}, which is still running. Nothing was written. If that process is not writing, delete the lock file and try again.`);
90
+ }
91
+ await new Promise((done) => setTimeout(done, RETRY_MS));
92
+ }
93
+ }
94
+ async function takeOverIfStale(lockPath) {
95
+ let judged;
96
+ try {
97
+ judged = await stat(lockPath);
98
+ }
99
+ catch (error) {
100
+ // Released between the failed create and here: try again.
101
+ if (hasCode(error, "ENOENT"))
102
+ return true;
103
+ throw error;
104
+ }
105
+ const holder = await holderOf(lockPath);
106
+ const old = Date.now() - judged.mtimeMs > STALE_MS;
107
+ // A holder that has not written its pid yet is one mid-create, not
108
+ // one that has gone, so only age can make its lock stale.
109
+ const gone = holder !== undefined && !isAlive(holder);
110
+ if (!old && !gone)
111
+ return false;
112
+ const aside = `${lockPath}.stale-${process.pid}-${randomUUID()}`;
113
+ try {
114
+ await rename(lockPath, aside);
115
+ }
116
+ catch (error) {
117
+ // Somebody else took it over first.
118
+ if (hasCode(error, "ENOENT"))
119
+ return true;
120
+ throw error;
121
+ }
122
+ const moved = await stat(aside);
123
+ if (moved.ino !== judged.ino) {
124
+ // Between the judgement and the move another writer took over and
125
+ // made a fresh lock, and this moved theirs. Put it back, unless a
126
+ // third has already made one, in which case theirs stands and the
127
+ // one moved is left for its holder's release to find missing.
128
+ try {
129
+ await link(aside, lockPath);
130
+ }
131
+ catch (error) {
132
+ if (!hasCode(error, "EEXIST"))
133
+ throw error;
134
+ }
135
+ }
136
+ await unlink(aside);
137
+ return true;
138
+ }
139
+ async function release(lockPath, ino) {
140
+ try {
141
+ if ((await stat(lockPath)).ino === ino)
142
+ await unlink(lockPath);
143
+ }
144
+ catch (error) {
145
+ // Taken over and released by somebody else already: nothing of
146
+ // this holder's is left to remove.
147
+ if (!hasCode(error, "ENOENT"))
148
+ throw error;
149
+ }
150
+ }
151
+ async function holderOf(lockPath) {
152
+ try {
153
+ const pid = Number.parseInt(await readFile(lockPath, "utf8"), 10);
154
+ return Number.isFinite(pid) && pid > 0 ? pid : undefined;
155
+ }
156
+ catch (error) {
157
+ // Gone since the create failed, which the next attempt handles.
158
+ if (hasCode(error, "ENOENT"))
159
+ return undefined;
160
+ throw error;
161
+ }
162
+ }
163
+ function isAlive(pid) {
164
+ // This process never waits on its own lock, since its writers queue
165
+ // first, so a lock naming it is one a previous process with the same
166
+ // pid left behind.
167
+ if (pid === process.pid)
168
+ return false;
169
+ try {
170
+ process.kill(pid, 0);
171
+ return true;
172
+ }
173
+ catch (error) {
174
+ // EPERM is a process that exists and belongs to somebody else.
175
+ return hasCode(error, "EPERM");
176
+ }
177
+ }
178
+ function hasCode(error, code) {
179
+ return (typeof error === "object" &&
180
+ error !== null &&
181
+ "code" in error &&
182
+ error.code === code);
183
+ }
@@ -26,7 +26,7 @@
26
26
  * that bypasses it can still write through. We rely on
27
27
  * every README write going through `withQuestLock`.
28
28
  */
29
- import { closeSync, existsSync, fsyncSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync, } from "node:fs";
29
+ import { closeSync, existsSync, fstatSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync, } from "node:fs";
30
30
  import { dirname, join } from "node:path";
31
31
  /** Lock-acquire retry budget per call. */
32
32
  const LOCK_TIMEOUT_MS = 5000;
@@ -102,30 +102,52 @@ function isProcessAlive(pid) {
102
102
  return err.code === "EPERM";
103
103
  }
104
104
  }
105
+ /**
106
+ * Take the lock over if its holder has gone or it is too old to be a
107
+ * write in progress.
108
+ *
109
+ * A lock with no readable record is judged by its file's age alone: a
110
+ * holder creates the lock and then writes its record, so for a moment
111
+ * every live lock is empty, and treating that as abandoned took locks
112
+ * from their holders. Taking over moves the lock aside and checks the
113
+ * move took the one judged stale; deleting by name could delete a lock
114
+ * another writer made after taking over first.
115
+ */
105
116
  function tryStealStaleLock(lockPath, now) {
106
- const record = readLockRecord(lockPath);
107
- if (!record) {
108
- // Malformed lock file; remove and retry.
109
- try {
110
- unlinkSync(lockPath);
111
- }
112
- catch {
113
- // Another process beat us to the cleanup; that's fine.
114
- }
117
+ let judged;
118
+ try {
119
+ judged = statSync(lockPath);
120
+ }
121
+ catch {
122
+ // Released since the failed create; the next attempt takes it.
115
123
  return true;
116
124
  }
117
- const age = now - record.startedAt;
118
- if (age < STALE_LOCK_MS && isProcessAlive(record.pid))
125
+ const record = readLockRecord(lockPath);
126
+ const stale = record
127
+ ? now - record.startedAt >= STALE_LOCK_MS || !isProcessAlive(record.pid)
128
+ : now - judged.mtimeMs >= STALE_LOCK_MS;
129
+ if (!stale)
119
130
  return false;
131
+ const aside = `${lockPath}.stale-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
120
132
  try {
121
- unlinkSync(lockPath);
122
- return true;
133
+ renameSync(lockPath, aside);
123
134
  }
124
135
  catch {
125
- // Another process beat us to the cleanup; let the
126
- // next loop iteration retry the acquire.
136
+ // Somebody else took it over first; the next attempt sees theirs.
127
137
  return false;
128
138
  }
139
+ if (statSync(aside).ino !== judged.ino) {
140
+ // Moved a fresh lock another writer made after taking over first.
141
+ // Put it back, unless a third already made one, which then stands.
142
+ try {
143
+ linkSync(aside, lockPath);
144
+ }
145
+ catch {
146
+ // EEXIST: the newer lock stands, as above.
147
+ }
148
+ }
149
+ unlinkSync(aside);
150
+ return true;
129
151
  }
130
152
  function acquireLock(lockPath) {
131
153
  const deadline = Date.now() + LOCK_TIMEOUT_MS;
@@ -139,7 +161,7 @@ function acquireLock(lockPath) {
139
161
  };
140
162
  writeSync(fd, JSON.stringify(payload));
141
163
  fsyncSync(fd);
142
- return fd;
164
+ return { fd, ino: fstatSync(fd).ino };
143
165
  }
144
166
  catch (err) {
145
167
  const code = err.code;
@@ -165,15 +187,18 @@ function acquireLock(lockPath) {
165
187
  }
166
188
  }
167
189
  }
168
- function releaseLock(lockPath, fd) {
190
+ function releaseLock(lockPath, held) {
169
191
  try {
170
- closeSync(fd);
192
+ closeSync(held.fd);
171
193
  }
172
194
  catch {
173
195
  // Already closed; cleanup of the path below still runs.
174
196
  }
175
197
  try {
176
- unlinkSync(lockPath);
198
+ // Only this holder's own lock: one taken over from it belongs to
199
+ // whoever made the new one.
200
+ if (statSync(lockPath).ino === held.ino)
201
+ unlinkSync(lockPath);
177
202
  }
178
203
  catch {
179
204
  // Lock file was stolen out from under us or never
@@ -203,12 +228,12 @@ export function withQuestLock(questDir, fn) {
203
228
  // Stat failed; the acquire loop handles it.
204
229
  }
205
230
  }
206
- const fd = acquireLock(lockPath);
231
+ const held = acquireLock(lockPath);
207
232
  try {
208
233
  return fn();
209
234
  }
210
235
  finally {
211
- releaseLock(lockPath, fd);
236
+ releaseLock(lockPath, held);
212
237
  }
213
238
  }
214
239
  /**
@@ -11,6 +11,12 @@
11
11
  * file. When nothing resolves, it fails with a clear message
12
12
  * rather than a cryptic one.
13
13
  *
14
+ * A server nobody has called for `idleMs` is stopped and dropped
15
+ * from the pool, and the next call for its root starts a fresh
16
+ * one: a TypeScript server holds hundreds of megabytes, and a long
17
+ * session touches many roots it never returns to. A call in
18
+ * flight keeps its servers alive however long it takes.
19
+ *
14
20
  * The live server pool lives in this closure's memory for the
15
21
  * life of the process that constructs it. A stateless-per-call
16
22
  * CLI adapter that wants warm servers across invocations needs
@@ -30,6 +36,8 @@ export interface StandaloneBackendOptions {
30
36
  readonly servers?: Readonly<Record<string, ServerConfig>>;
31
37
  /** Environment used for PATH resolution. Defaults to process.env. */
32
38
  readonly env?: NodeJS.ProcessEnv;
39
+ /** How long a server may sit unused before it is stopped. */
40
+ readonly idleMs?: number;
33
41
  }
34
42
  /** A standalone backend with visibility into its live pool. */
35
43
  export interface StandaloneBackend extends LspBackend {
@@ -11,6 +11,12 @@
11
11
  * file. When nothing resolves, it fails with a clear message
12
12
  * rather than a cryptic one.
13
13
  *
14
+ * A server nobody has called for `idleMs` is stopped and dropped
15
+ * from the pool, and the next call for its root starts a fresh
16
+ * one: a TypeScript server holds hundreds of megabytes, and a long
17
+ * session touches many roots it never returns to. A call in
18
+ * flight keeps its servers alive however long it takes.
19
+ *
14
20
  * The live server pool lives in this closure's memory for the
15
21
  * life of the process that constructs it. A stateless-per-call
16
22
  * CLI adapter that wants warm servers across invocations needs
@@ -28,20 +34,56 @@ export class MissingServerError extends Error {
28
34
  this.name = "MissingServerError";
29
35
  }
30
36
  }
37
+ /**
38
+ * Default idle window. Long enough that a burst of work on one
39
+ * project keeps its server warm, short enough that a project left
40
+ * behind gives its memory back within the hour.
41
+ */
42
+ const DEFAULT_IDLE_MS = 10 * 60_000;
31
43
  /** Construct a standalone backend over the given (or default) server map. */
32
44
  export function createStandaloneBackend(options = {}) {
33
45
  const servers = options.servers ?? DEFAULT_SERVERS;
34
46
  const env = options.env ?? process.env;
47
+ const idleMs = options.idleMs ?? DEFAULT_IDLE_MS;
35
48
  const pool = new Map();
49
+ // Calls in flight per pool key, and the stop timer armed when a
50
+ // key's last call finishes.
51
+ const inFlight = new Map();
52
+ const idleTimers = new Map();
36
53
  const poolKey = (name, root) => `${name}|${root}`;
54
+ const claim = (key) => {
55
+ inFlight.set(key, (inFlight.get(key) ?? 0) + 1);
56
+ clearTimeout(idleTimers.get(key));
57
+ idleTimers.delete(key);
58
+ };
59
+ const release = (key) => {
60
+ const left = (inFlight.get(key) ?? 1) - 1;
61
+ if (left > 0) {
62
+ inFlight.set(key, left);
63
+ return;
64
+ }
65
+ inFlight.delete(key);
66
+ const timer = setTimeout(() => stopIdle(key), idleMs);
67
+ // An idle server must never be what keeps the process alive.
68
+ timer.unref();
69
+ idleTimers.set(key, timer);
70
+ };
71
+ const stopIdle = (key) => {
72
+ idleTimers.delete(key);
73
+ if (inFlight.has(key))
74
+ return;
75
+ const started = pool.get(key);
76
+ pool.delete(key);
77
+ void started?.then((server) => server.dispose(), () => { });
78
+ };
37
79
  const instanceFor = (server, root, binary) => {
38
80
  const key = poolKey(server.name, root);
39
81
  const existing = pool.get(key);
40
82
  if (existing)
41
- return existing;
83
+ return { key, started: existing };
42
84
  const started = StandaloneServer.start(server, root, binary);
43
85
  pool.set(key, started);
44
- return started;
86
+ return { key, started };
45
87
  };
46
88
  // Resolve a server's effective command and args for a root. A
47
89
  // server with a `resolve` hook picks its binary per project (the
@@ -65,7 +107,22 @@ export function createStandaloneBackend(options = {}) {
65
107
  binary,
66
108
  };
67
109
  };
68
- const resolveInstances = async (filePath, typeOnly) => {
110
+ /**
111
+ * Run fn against the servers for a file, holding each one's pool
112
+ * key claimed from before it resolves until fn settles, so no
113
+ * server is stopped under a call that is using it.
114
+ */
115
+ const withInstances = async (filePath, typeOnly, fn) => {
116
+ const claimed = [];
117
+ try {
118
+ return await fn(await resolveInstances(filePath, typeOnly, claimed));
119
+ }
120
+ finally {
121
+ for (const key of claimed)
122
+ release(key);
123
+ }
124
+ };
125
+ const resolveInstances = async (filePath, typeOnly, claimed) => {
69
126
  const candidates = serversForFile(filePath, servers).filter((server) => !typeOnly || !server.isLinter);
70
127
  const instances = [];
71
128
  const reasons = [];
@@ -80,7 +137,10 @@ export function createStandaloneBackend(options = {}) {
80
137
  reasons.push(eff.reason);
81
138
  continue;
82
139
  }
83
- instances.push(await instanceFor(eff.config, root, eff.binary));
140
+ const { key, started } = instanceFor(eff.config, root, eff.binary);
141
+ claim(key);
142
+ claimed.push(key);
143
+ instances.push(await started);
84
144
  if (typeOnly)
85
145
  break;
86
146
  }
@@ -95,40 +155,46 @@ export function createStandaloneBackend(options = {}) {
95
155
  return {
96
156
  name: "standalone",
97
157
  async diagnostics(path) {
98
- const instances = await resolveInstances(path, false);
99
- const results = await Promise.all(instances.map((s) => s.diagnose(path)));
100
- return results.flat();
158
+ return withInstances(path, false, async (instances) => {
159
+ const results = await Promise.all(instances.map((s) => s.diagnose(path)));
160
+ return results.flat();
161
+ });
101
162
  },
102
163
  async definition(target) {
103
- const [server] = await resolveInstances(target.path, true);
104
- return server.definition(target);
164
+ return withInstances(target.path, true, ([server]) => server.definition(target));
105
165
  },
106
166
  async references(target) {
107
- const [server] = await resolveInstances(target.path, true);
108
- return server.references(target);
167
+ return withInstances(target.path, true, ([server]) => server.references(target));
109
168
  },
110
169
  async hover(target) {
111
- const [server] = await resolveInstances(target.path, true);
112
- return server.hover(target);
170
+ return withInstances(target.path, true, ([server]) => server.hover(target));
113
171
  },
114
172
  async documentSymbols(path) {
115
- const [server] = await resolveInstances(path, true);
116
- return server.documentSymbols(path);
173
+ return withInstances(path, true, ([server]) => server.documentSymbols(path));
117
174
  },
118
175
  async workspaceSymbols(query) {
119
176
  // Workspace symbols carry no file, so they search every
120
- // server already running; nothing is spawned on demand.
121
- const live = await Promise.all([...pool.values()]);
122
- const results = await Promise.all(live.map((server) => server.workspaceSymbols(query)));
123
- return results.flat();
177
+ // server already running; nothing is spawned on demand, and
178
+ // a server stopped for idleness is not searched until
179
+ // something file-bound starts it again.
180
+ const keys = [...pool.keys()];
181
+ for (const key of keys)
182
+ claim(key);
183
+ try {
184
+ const live = await Promise.all(keys.map((key) => pool.get(key)));
185
+ const results = await Promise.all(live.map((server) => server?.workspaceSymbols(query) ?? []));
186
+ return results.flat();
187
+ }
188
+ finally {
189
+ for (const key of keys)
190
+ release(key);
191
+ }
124
192
  },
125
193
  async rename(target, newName) {
126
- const [server] = await resolveInstances(target.path, true);
127
- return server.rename(target, newName);
194
+ return withInstances(target.path, true, ([server]) => server.rename(target, newName));
128
195
  },
129
196
  async codeActions(path, range) {
130
- const [server] = await resolveInstances(path, true);
131
- return server.codeActions(path, range);
197
+ return withInstances(path, true, ([server]) => server.codeActions(path, range));
132
198
  },
133
199
  syncDocument(path, text) {
134
200
  for (const started of pool.values()) {
@@ -142,6 +208,9 @@ export function createStandaloneBackend(options = {}) {
142
208
  return pool.size;
143
209
  },
144
210
  async dispose() {
211
+ for (const timer of idleTimers.values())
212
+ clearTimeout(timer);
213
+ idleTimers.clear();
145
214
  const started = [...pool.values()];
146
215
  pool.clear();
147
216
  await Promise.all(started.map((s) => s.then((server) => server.dispose())));
@@ -11,8 +11,10 @@
11
11
  * council needs that council still to be there, or nothing can say
12
12
  * what it consolidated.
13
13
  */
14
+ import { randomUUID } from "node:crypto";
14
15
  import { mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises";
15
16
  import { join } from "node:path";
17
+ import { withFileLock } from "../../internal/file-lock.js";
16
18
  import { changeKey } from "../keys.js";
17
19
  /** Runs on disk, one file per change. */
18
20
  export function createRunStore(root) {
@@ -71,15 +73,29 @@ export function createRunStore(root) {
71
73
  // whose whole premise is that the session may not survive it,
72
74
  // so the window stopped being theoretical. A rename within one
73
75
  // directory is atomic: a reader sees the old ledger or the new
74
- // one.
75
- const pending = `${path}.${process.pid}.tmp`;
76
+ // one. Named per write, not per process: two writes in one
77
+ // process sharing a name tore the file, the shorter write landing
78
+ // over the start of the longer.
79
+ const pending = `${path}.${process.pid}.${randomUUID()}.tmp`;
76
80
  await writeFile(pending, JSON.stringify(ledger, null, 2), "utf8");
77
81
  await rename(pending, path);
78
82
  }
83
+ /**
84
+ * Read, change and write one change's ledger as a single step. Every
85
+ * write is a whole ledger built from the one read before it, so two
86
+ * of these at once, from parallel tool calls or two sessions on one
87
+ * change, would each lay its version over the other's and drop a
88
+ * round without an error.
89
+ */
90
+ async function mutate(change, next) {
91
+ await mkdir(root, { recursive: true });
92
+ await withFileLock(join(root, fileFor(change)), async () => {
93
+ await write(change, next(await read(change)));
94
+ });
95
+ }
79
96
  return {
80
97
  async record(change, run) {
81
- const ledger = await read(change);
82
- await write(change, { runs: [...ledger.runs, run] });
98
+ await mutate(change, (ledger) => ({ runs: [...ledger.runs, run] }));
83
99
  },
84
100
  async list(change) {
85
101
  return (await read(change)).runs;
@@ -176,24 +192,26 @@ export function createRunStore(root) {
176
192
  return (await read(change)).runs.find((r) => r.id === runId);
177
193
  },
178
194
  async keep(change, run) {
179
- const ledger = await read(change);
180
- const at = ledger.runs.findIndex((held) => held.id === run.id);
181
- await write(change, {
182
- runs: at === -1
183
- ? [...ledger.runs, run]
184
- : ledger.runs.map((held, index) => (index === at ? run : held)),
195
+ await mutate(change, (ledger) => {
196
+ const at = ledger.runs.findIndex((held) => held.id === run.id);
197
+ return {
198
+ runs: at === -1
199
+ ? [...ledger.runs, run]
200
+ : ledger.runs.map((held, index) => (index === at ? run : held)),
201
+ };
185
202
  });
186
203
  },
187
204
  async replace(change, run) {
188
- const ledger = await read(change);
189
- const at = ledger.runs.findIndex((held) => held.id === run.id);
190
- if (at === -1) {
191
- // Adding it silently would make a retry look like it
192
- // patched something when it invented a round instead.
193
- throw new Error(`No run "${run.id}" is held against this change, so there is nothing to replace. Record it first, or check the id.`);
194
- }
195
- await write(change, {
196
- runs: ledger.runs.map((held, index) => (index === at ? run : held)),
205
+ await mutate(change, (ledger) => {
206
+ const at = ledger.runs.findIndex((held) => held.id === run.id);
207
+ if (at === -1) {
208
+ // Adding it silently would make a retry look like it
209
+ // patched something when it invented a round instead.
210
+ throw new Error(`No run "${run.id}" is held against this change, so there is nothing to replace. Record it first, or check the id.`);
211
+ }
212
+ return {
213
+ runs: ledger.runs.map((held, index) => (index === at ? run : held)),
214
+ };
197
215
  });
198
216
  },
199
217
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jitsusama/agentic-harness.core",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Pi-agnostic business logic for agentic-harness: state machines, guardian decisions, quest/TDD domain model.",
5
5
  "license": "MIT",
6
6
  "type": "module",