@jitsusama/agentic-harness.core 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/command/tokenize.js +51 -5
- package/dist/internal/file-lock.d.ts +30 -0
- package/dist/internal/file-lock.js +183 -0
- package/dist/internal/quest/bash-effects.d.ts +26 -0
- package/dist/internal/quest/bash-effects.js +477 -0
- package/dist/internal/quest/bash-write.d.ts +37 -0
- package/dist/internal/quest/bash-write.js +311 -37
- package/dist/internal/quest/io.js +47 -22
- package/dist/internal/quest/record-audit.d.ts +43 -0
- package/dist/internal/quest/record-audit.js +126 -0
- package/dist/internal/quest/record-gate.d.ts +36 -0
- package/dist/internal/quest/record-gate.js +101 -0
- package/dist/internal/quest/record.d.ts +112 -0
- package/dist/internal/quest/record.js +284 -0
- package/dist/internal/quest/workspace.d.ts +32 -0
- package/dist/internal/quest/workspace.js +51 -0
- package/dist/review/ask/store.js +37 -19
- package/package.json +7 -1
package/dist/command/tokenize.js
CHANGED
|
@@ -198,11 +198,20 @@ function toHeredoc(info) {
|
|
|
198
198
|
span: { start: info.index, end: info.end },
|
|
199
199
|
};
|
|
200
200
|
}
|
|
201
|
-
|
|
201
|
+
/**
|
|
202
|
+
* A word that begins with a redirect operator: an optional file
|
|
203
|
+
* descriptor or `&`, the operator, then whatever is written against it.
|
|
204
|
+
* A `<`, `>` or `(` straight after the operator is a here-string or a
|
|
205
|
+
* process substitution, which name no file.
|
|
206
|
+
*/
|
|
207
|
+
const REDIRECT = /^(\d*|&)(>>|>|<)(?![<>(])(.*)$/s;
|
|
208
|
+
/** What follows a duplication operator: a descriptor, or `-` to close. */
|
|
209
|
+
const DUPLICATION = /^&(\d+|-)$/;
|
|
202
210
|
/**
|
|
203
211
|
* Pull redirects out of a word list. A redirect operator that names
|
|
204
|
-
* a file descriptor duplication (2>&1) stands alone
|
|
205
|
-
* operator
|
|
212
|
+
* a file descriptor duplication (2>&1) stands alone. Any other
|
|
213
|
+
* operator names its target either in the same word (`2>/dev/null`)
|
|
214
|
+
* or, when written alone, in the word that follows.
|
|
206
215
|
*/
|
|
207
216
|
function extractRedirects(words) {
|
|
208
217
|
const argv = [];
|
|
@@ -214,8 +223,26 @@ function extractRedirects(words) {
|
|
|
214
223
|
argv.push(word);
|
|
215
224
|
continue;
|
|
216
225
|
}
|
|
217
|
-
const
|
|
218
|
-
|
|
226
|
+
const attached = match[3] ?? "";
|
|
227
|
+
if (DUPLICATION.test(attached)) {
|
|
228
|
+
redirects.push({ span: word.span, operator: word.text });
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (attached) {
|
|
232
|
+
const operator = word.text.slice(0, word.text.length - attached.length);
|
|
233
|
+
const start = word.span.start + operator.length;
|
|
234
|
+
redirects.push({
|
|
235
|
+
span: word.span,
|
|
236
|
+
operator,
|
|
237
|
+
target: {
|
|
238
|
+
span: { start, end: word.span.end },
|
|
239
|
+
text: attached,
|
|
240
|
+
quoting: quotingOf(attached),
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const target = words[j + 1];
|
|
219
246
|
if (target) {
|
|
220
247
|
redirects.push({
|
|
221
248
|
span: { start: word.span.start, end: target.span.end },
|
|
@@ -303,6 +330,25 @@ function scanWords(source, start, end) {
|
|
|
303
330
|
}
|
|
304
331
|
return words;
|
|
305
332
|
}
|
|
333
|
+
/** The quote style of a word's text, read the way `scanWords` reads it. */
|
|
334
|
+
function quotingOf(text) {
|
|
335
|
+
let sawSingle = false;
|
|
336
|
+
let sawDouble = false;
|
|
337
|
+
let i = 0;
|
|
338
|
+
while (i < text.length) {
|
|
339
|
+
const ch = text[i];
|
|
340
|
+
if (ch === "'" || ch === '"') {
|
|
341
|
+
if (ch === "'")
|
|
342
|
+
sawSingle = true;
|
|
343
|
+
else
|
|
344
|
+
sawDouble = true;
|
|
345
|
+
i = skipQuoted(text, i);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
i++;
|
|
349
|
+
}
|
|
350
|
+
return classifyQuoting(sawSingle, sawDouble);
|
|
351
|
+
}
|
|
306
352
|
/** Classify a word's quote style from which quote kinds it used. */
|
|
307
353
|
function classifyQuoting(sawSingle, sawDouble) {
|
|
308
354
|
if (sawSingle && sawDouble)
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a command does to files without a redirect: the copies, moves,
|
|
3
|
+
* links, directories, clones, downloads and unpacked archives it creates,
|
|
4
|
+
* and the files it removes.
|
|
5
|
+
*
|
|
6
|
+
* Each reader takes an unquoted argv and answers with the paths as the
|
|
7
|
+
* command names them, relative or not, so the caller can place them
|
|
8
|
+
* in the directory the command runs in. A reader reads options the way
|
|
9
|
+
* its command does, since an option's value taken for a destination is a
|
|
10
|
+
* path nobody wrote. What a command writes to standard output names no
|
|
11
|
+
* file and is left to the redirect that catches it.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* The command a wrapper runs, with the wrapper and its options removed.
|
|
15
|
+
*
|
|
16
|
+
* `env` also takes the assignments before the command, and `nice -5` is
|
|
17
|
+
* the old spelling of an adjustment, so neither is read as the command.
|
|
18
|
+
*/
|
|
19
|
+
export declare function unwrap(argv: readonly string[]): string[];
|
|
20
|
+
/** The files a command creates without a redirect, as the command names them. */
|
|
21
|
+
export declare function createdBy(argv: readonly string[]): string[];
|
|
22
|
+
/**
|
|
23
|
+
* The files a command removes, as the command names them. A move removes
|
|
24
|
+
* its sources, since what was there is no longer there.
|
|
25
|
+
*/
|
|
26
|
+
export declare function removedBy(argv: readonly string[]): string[];
|