@autor3search/javascript 0.2.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 +21 -0
- package/README.md +500 -0
- package/bin/autor3search-javascript.js +4 -0
- package/package.json +50 -0
- package/src/adapters/bench/index.js +31 -0
- package/src/adapters/bench/vitest.js +128 -0
- package/src/adapters/driver-child.js +72 -0
- package/src/adapters/driver-hooks.js +18 -0
- package/src/adapters/driver.js +192 -0
- package/src/adapters/gates/index.js +64 -0
- package/src/adapters/gates/lint.js +46 -0
- package/src/adapters/gates/test.js +27 -0
- package/src/adapters/gates/typecheck.js +98 -0
- package/src/adapters/gates/util.js +45 -0
- package/src/adapters/vitest-shim.js +51 -0
- package/src/bench/parse.js +120 -0
- package/src/bench/set.js +101 -0
- package/src/bench/stats.js +443 -0
- package/src/cli/cmd-baseline.js +136 -0
- package/src/cli/cmd-doctor.js +38 -0
- package/src/cli/cmd-eval.js +231 -0
- package/src/cli/cmd-init.js +136 -0
- package/src/cli/cmd-profile.js +38 -0
- package/src/cli/cmd-report.js +96 -0
- package/src/cli/cmd-status.js +68 -0
- package/src/cli/cmd-stop.js +98 -0
- package/src/cli/cmd-version.js +31 -0
- package/src/cli/context.js +98 -0
- package/src/cli/main.js +62 -0
- package/src/config.js +209 -0
- package/src/discover.js +239 -0
- package/src/doctor.js +282 -0
- package/src/duration.js +75 -0
- package/src/freeze.js +234 -0
- package/src/gitx.js +115 -0
- package/src/measure.js +127 -0
- package/src/pipeline.js +391 -0
- package/src/profile.js +100 -0
- package/src/results.js +124 -0
- package/src/runner.js +198 -0
- package/src/scope.js +92 -0
- package/src/state/index.js +312 -0
- package/src/state/lock.js +189 -0
- package/src/state/stop.js +56 -0
- package/src/verdict.js +214 -0
- package/templates/program.md +264 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persists a run's reference points OUT-OF-TREE from the repository being
|
|
3
|
+
* optimized.
|
|
4
|
+
*
|
|
5
|
+
* None of this may live inside the repository. The agent edits the
|
|
6
|
+
* repository, so in-tree state would be silently writable by the very agent
|
|
7
|
+
* it constrains: it could rewrite a frozen golden copy, drop a key from the
|
|
8
|
+
* manifest, or — worst — edit the pinned baseline WORKTREE to make the
|
|
9
|
+
* BASELINE slow, after which every candidate "improves" and every experiment
|
|
10
|
+
* returns KEEP without optimizing anything.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from 'node:crypto'
|
|
13
|
+
import { lstat, mkdir, readFile, realpath, symlink, writeFile } from 'node:fs/promises'
|
|
14
|
+
import { homedir, platform } from 'node:os'
|
|
15
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
16
|
+
|
|
17
|
+
/** Per-repository state directory name under the user cache. */
|
|
18
|
+
export const STATE_DIR_NAME = 'autor3search-javascript'
|
|
19
|
+
|
|
20
|
+
/** Environment variable relocating every run's out-of-tree state. */
|
|
21
|
+
export const STATE_HOME_ENV = 'AUTOR3SEARCH_JAVASCRIPT_STATE_HOME'
|
|
22
|
+
|
|
23
|
+
/** Paths within a run's state directory. */
|
|
24
|
+
export const BASELINE_FILE = 'baseline.json'
|
|
25
|
+
export const WORKTREE_NAME = 'baseline-worktree'
|
|
26
|
+
|
|
27
|
+
/** Run branches are named `<prefix><tag>`. */
|
|
28
|
+
export const BRANCH_PREFIX = 'autor3search-javascript/'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Mode for every directory the harness creates under the state home.
|
|
32
|
+
*
|
|
33
|
+
* The state home holds the frozen store and its manifest — the two things the
|
|
34
|
+
* score is defined against — so it is the one place outside the repository
|
|
35
|
+
* where write access is equivalent to control of the verdict. Under the
|
|
36
|
+
* default home (~/Library/Caches, ~/.cache) the parent already restricts
|
|
37
|
+
* access, but AUTOR3SEARCH_JAVASCRIPT_STATE_HOME may point anywhere,
|
|
38
|
+
* including a shared directory.
|
|
39
|
+
*/
|
|
40
|
+
export const DIR_MODE = 0o700
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The strict allow-list for a run tag: letters, digits, '.', '_' and '-'.
|
|
44
|
+
*
|
|
45
|
+
* Notably absent is '/' — or any other path separator — which alone blocks
|
|
46
|
+
* both directory traversal ("../../etc") and an absolute path
|
|
47
|
+
* ("/etc/passwd"), because a tag can then never be more than one path
|
|
48
|
+
* segment. stateDir joins the tag into a filesystem path and callers mkdir
|
|
49
|
+
* that path immediately, long before git's own ref-name rules would get a
|
|
50
|
+
* chance to reject a bad tag.
|
|
51
|
+
*/
|
|
52
|
+
const VALID_TAG = /^[A-Za-z0-9._-]+$/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Reports whether tag is safe to use as a filesystem path segment.
|
|
56
|
+
*
|
|
57
|
+
* "." and ".." are rejected even though both characters are individually
|
|
58
|
+
* allowed: either one alone means "this directory" or "the parent directory"
|
|
59
|
+
* rather than naming anything.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} tag
|
|
62
|
+
* @throws {Error}
|
|
63
|
+
*/
|
|
64
|
+
export function validTag(tag) {
|
|
65
|
+
if (!tag) throw new Error('tag must not be empty')
|
|
66
|
+
if (tag === '.' || tag === '..') {
|
|
67
|
+
throw new Error(`tag ${JSON.stringify(tag)} is not allowed: it is a directory reference, not a run identifier`)
|
|
68
|
+
}
|
|
69
|
+
if (!VALID_TAG.test(tag)) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`tag ${JSON.stringify(tag)} is not allowed: tags may contain only letters, digits, '.', '_' and '-'`,
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The out-of-tree directory holding every piece of state the metric depends
|
|
78
|
+
* on, for one repository and run tag.
|
|
79
|
+
*
|
|
80
|
+
* Keyed by a hash of the repository's real absolute path, so two checkouts of
|
|
81
|
+
* the same project never share state and the same checkout reached by two
|
|
82
|
+
* spellings resolves to one key.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} repoRoot
|
|
85
|
+
* @param {string} tag
|
|
86
|
+
* @returns {Promise<string>}
|
|
87
|
+
*/
|
|
88
|
+
export async function stateDir(repoRoot, tag) {
|
|
89
|
+
validTag(tag)
|
|
90
|
+
let absolute = resolve(repoRoot)
|
|
91
|
+
// Resolve symlinked ancestors (macOS's /tmp -> /private/tmp, for one) so the
|
|
92
|
+
// same repository reached by two different spellings hashes the same. A
|
|
93
|
+
// path that does not exist yet falls back to the unresolved form rather
|
|
94
|
+
// than failing.
|
|
95
|
+
absolute = await realpath(absolute).catch(() => absolute)
|
|
96
|
+
const key = createHash('sha256').update(absolute).digest('hex').slice(0, 16)
|
|
97
|
+
return join(await stateHome(), key, tag)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The directory holding every repository's run state.
|
|
102
|
+
*
|
|
103
|
+
* A relative override is REFUSED rather than resolved. Joining one would
|
|
104
|
+
* succeed, but the result would then depend on the working directory each
|
|
105
|
+
* command was invoked from — so `eval` run from a subdirectory and `stop` run
|
|
106
|
+
* from the repository root would address different state for the same run,
|
|
107
|
+
* and the brake would silently miss.
|
|
108
|
+
*/
|
|
109
|
+
async function stateHome() {
|
|
110
|
+
const override = process.env[STATE_HOME_ENV]
|
|
111
|
+
if (override) {
|
|
112
|
+
if (!isAbsolute(override)) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`${STATE_HOME_ENV} must be an absolute path, got ${JSON.stringify(override)}: a relative state ` +
|
|
115
|
+
`home would resolve differently depending on where each command is run from`,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
return override
|
|
119
|
+
}
|
|
120
|
+
return join(userCacheDir(), STATE_DIR_NAME)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The conventional per-platform user cache directory. */
|
|
124
|
+
function userCacheDir() {
|
|
125
|
+
if (process.env.XDG_CACHE_HOME && isAbsolute(process.env.XDG_CACHE_HOME)) {
|
|
126
|
+
return process.env.XDG_CACHE_HOME
|
|
127
|
+
}
|
|
128
|
+
if (platform() === 'darwin') return join(homedir(), 'Library', 'Caches')
|
|
129
|
+
if (platform() === 'win32') return process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local')
|
|
130
|
+
return join(homedir(), '.cache')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Symlinks the repository's own `node_modules` into a pinned worktree, so a
|
|
135
|
+
* bench runner spawned inside it can resolve its own binary and the
|
|
136
|
+
* project's dependencies.
|
|
137
|
+
*
|
|
138
|
+
* Lives here, next to WORKTREE_NAME and the worktree-tamper check above,
|
|
139
|
+
* because this module is already the one concerned with the pinned
|
|
140
|
+
* worktree's identity and integrity — this is a third worktree-lifecycle
|
|
141
|
+
* concern, not a new one, even though (unlike the rest of this file) it
|
|
142
|
+
* touches the filesystem rather than a JSON record.
|
|
143
|
+
*
|
|
144
|
+
* A `git worktree add` checks out only TRACKED files, and virtually every
|
|
145
|
+
* real project gitignores `node_modules` — so a freshly created worktree
|
|
146
|
+
* has none, and the baseline side of every measurement would otherwise fail
|
|
147
|
+
* outright the moment the bench runner tries to resolve itself (see
|
|
148
|
+
* src/adapters/bench/vitest.js's `vitestBin`). Symlinking the real
|
|
149
|
+
* `node_modules` in is the fix: both worktree and repository root then
|
|
150
|
+
* resolve the exact same installed packages.
|
|
151
|
+
*
|
|
152
|
+
* No-op, and NEVER throws, when: the repository has no `node_modules` (a
|
|
153
|
+
* repo with no dependencies — nothing to link); the worktree already has an
|
|
154
|
+
* entry there (a previous call already linked it, or something else put a
|
|
155
|
+
* real install there — either way, leave it alone); or the link attempt
|
|
156
|
+
* itself fails for some other filesystem reason. Called from both
|
|
157
|
+
* `cmd-baseline.js` (right after the worktree is created) and
|
|
158
|
+
* `src/pipeline.js` (before every measurement), so a worktree that loses the
|
|
159
|
+
* link — the target moved, the link was removed — self-heals on the very
|
|
160
|
+
* next eval rather than failing every run after the first.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} repoRoot
|
|
163
|
+
* @param {string} worktreeDir
|
|
164
|
+
* @returns {Promise<void>}
|
|
165
|
+
*/
|
|
166
|
+
export async function linkNodeModules(repoRoot, worktreeDir) {
|
|
167
|
+
const repoModules = join(repoRoot, 'node_modules')
|
|
168
|
+
const worktreeModules = join(worktreeDir, 'node_modules')
|
|
169
|
+
try {
|
|
170
|
+
await lstat(repoModules)
|
|
171
|
+
} catch {
|
|
172
|
+
return // the repository has no node_modules: nothing to link
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
await lstat(worktreeModules)
|
|
176
|
+
return // the worktree already has an entry there: leave it alone
|
|
177
|
+
} catch {
|
|
178
|
+
// does not exist yet — fall through and create the link
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
await symlink(repoModules, worktreeModules, 'dir')
|
|
182
|
+
} catch {
|
|
183
|
+
// Best-effort only. A transient permission or filesystem error here must
|
|
184
|
+
// not sink an experiment that may not even need node_modules (a repo
|
|
185
|
+
// that vendors its dependencies, for instance) — the bench runner will
|
|
186
|
+
// report its own, more specific failure if resolution still fails.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Creates a state directory and refuses to use one that other users can write.
|
|
192
|
+
*
|
|
193
|
+
* mkdir's `mode` applies only to directories it actually CREATES, so a
|
|
194
|
+
* directory that was already there keeps whatever owner and mode it had. That
|
|
195
|
+
* is the case worth checking: under a shared state home someone else can
|
|
196
|
+
* pre-create the run directory, and then swapping the frozen store and its
|
|
197
|
+
* manifest together — consistently, so the hash check still passes — puts
|
|
198
|
+
* their content into the test files that `restore` writes back before every
|
|
199
|
+
* evaluation.
|
|
200
|
+
*
|
|
201
|
+
* Every level from the state home down is checked, not just the leaf: owning
|
|
202
|
+
* the parent is enough to replace the child. Ancestors ABOVE the state home
|
|
203
|
+
* are deliberately not checked — a 0700 home inside a sticky /tmp is fine, and
|
|
204
|
+
* walking to / would fail on every ordinary machine.
|
|
205
|
+
*
|
|
206
|
+
* POSIX only. Windows reports synthetic mode bits and has no getuid, so the
|
|
207
|
+
* check is skipped there rather than made to look like it ran.
|
|
208
|
+
*
|
|
209
|
+
* @param {string} dir a directory at or below the state home
|
|
210
|
+
* @returns {Promise<string>} dir
|
|
211
|
+
*/
|
|
212
|
+
export async function ensureSecureDir(dir) {
|
|
213
|
+
await mkdir(dir, { recursive: true, mode: DIR_MODE })
|
|
214
|
+
if (typeof process.getuid !== 'function') return dir
|
|
215
|
+
|
|
216
|
+
// Walk up by path segment, never by string prefix: "/tmp/state" is a prefix
|
|
217
|
+
// of "/tmp/state-evil" but not its parent.
|
|
218
|
+
const home = resolve(await stateHome())
|
|
219
|
+
const levels = []
|
|
220
|
+
for (let cur = resolve(dir); ; cur = dirname(cur)) {
|
|
221
|
+
levels.push(cur)
|
|
222
|
+
if (cur === home || cur === dirname(cur)) break
|
|
223
|
+
}
|
|
224
|
+
// A caller that passed something outside the state home walked to the root
|
|
225
|
+
// instead of stopping at it; check only what it named.
|
|
226
|
+
if (levels[levels.length - 1] !== home) levels.length = 1
|
|
227
|
+
|
|
228
|
+
for (const level of levels) {
|
|
229
|
+
const st = await lstat(level)
|
|
230
|
+
if (st.uid !== process.getuid()) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`refusing to use ${level}: it is owned by uid ${st.uid}, not by you (uid ${process.getuid()}). ` +
|
|
233
|
+
`Whoever owns this directory controls the frozen benchmarks the score is measured against.`,
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
if ((st.mode & 0o022) !== 0) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`refusing to use ${level}: mode ${(st.mode & 0o777).toString(8)} lets other users write to it. ` +
|
|
239
|
+
`Whoever can write here can replace the frozen benchmarks the score is measured against. ` +
|
|
240
|
+
`Run: chmod 700 ${JSON.stringify(level)}`,
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return dir
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* @typedef {object} Baseline
|
|
249
|
+
* @property {string} tag human-chosen run identifier
|
|
250
|
+
* @property {string} branch run branch checked out when the baseline was recorded
|
|
251
|
+
* @property {string} commit the FROZEN anchor — never changes after `baseline` records it
|
|
252
|
+
* @property {string} measureCommit the ADVANCING pointer the pinned worktree tracks
|
|
253
|
+
* @property {string} createdAt ISO-8601, UTC
|
|
254
|
+
* @property {string[]} benchmarks the declared benchmark set
|
|
255
|
+
* @property {string} pattern the name filter derived from benchmarks
|
|
256
|
+
* @property {string} configSha256 hash of the in-repo config at baseline time
|
|
257
|
+
*/
|
|
258
|
+
|
|
259
|
+
/** Writes the baseline as indented JSON, creating parent directories. */
|
|
260
|
+
export async function saveBaseline(path, baseline) {
|
|
261
|
+
await mkdir(dirname(path), { recursive: true })
|
|
262
|
+
await writeFile(path, `${JSON.stringify(baseline, null, 2)}\n`)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Reads a baseline written by saveBaseline.
|
|
267
|
+
*
|
|
268
|
+
* A record written before measureCommit existed has no such field; it falls
|
|
269
|
+
* back to commit — exactly the value a fresh baseline starts it at — rather
|
|
270
|
+
* than staying undefined, which would fail the worktree integrity check on
|
|
271
|
+
* the very first eval of an older run.
|
|
272
|
+
*
|
|
273
|
+
* @returns {Promise<Baseline>}
|
|
274
|
+
*/
|
|
275
|
+
export async function loadBaseline(path) {
|
|
276
|
+
let text
|
|
277
|
+
try {
|
|
278
|
+
text = await readFile(path, 'utf8')
|
|
279
|
+
} catch (err) {
|
|
280
|
+
if (err.code === 'ENOENT') {
|
|
281
|
+
throw new Error(`no baseline at ${path}: run 'autor3search-javascript baseline' first`, { cause: err })
|
|
282
|
+
}
|
|
283
|
+
throw new Error(`read baseline ${path}: ${err.message}`, { cause: err })
|
|
284
|
+
}
|
|
285
|
+
let record
|
|
286
|
+
try {
|
|
287
|
+
record = JSON.parse(text)
|
|
288
|
+
} catch (err) {
|
|
289
|
+
throw new Error(`parse baseline ${path}: ${err.message}`, { cause: err })
|
|
290
|
+
}
|
|
291
|
+
if (!record.measureCommit) record.measureCommit = record.commit
|
|
292
|
+
return record
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Builds a name filter matching exactly these benchmarks. An empty list
|
|
297
|
+
* yields "." — every benchmark.
|
|
298
|
+
*
|
|
299
|
+
* Each name is regexp-escaped. Names found by src/discover.js need no
|
|
300
|
+
* escaping, but `benchmarks:` in config.yaml is documented as hand-editable,
|
|
301
|
+
* and a stray metacharacter in a hand-typed name would otherwise silently
|
|
302
|
+
* BROADEN the pattern to match benchmarks nobody selected.
|
|
303
|
+
*
|
|
304
|
+
* @param {string[]} names
|
|
305
|
+
* @returns {string}
|
|
306
|
+
*/
|
|
307
|
+
export function benchPattern(names) {
|
|
308
|
+
if (names.length === 0) return '.'
|
|
309
|
+
return `^(${names.map(escapeRegExp).join('|')})$`
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The eval claim: at most one `eval` may run against a given run at a time.
|
|
3
|
+
*
|
|
4
|
+
* Two concurrent evals is itself a bug — they would fight over the same
|
|
5
|
+
* pinned worktree — so a claim that cannot be taken is reported as an error
|
|
6
|
+
* naming the incumbent pid rather than silently proceeding.
|
|
7
|
+
*
|
|
8
|
+
* Node has no flock binding, so the claim is an atomic mkdir of a lock
|
|
9
|
+
* directory holding the owner's pid and a heartbeat it refreshes. A lock is
|
|
10
|
+
* stale only when BOTH the pid is not alive AND the heartbeat has gone cold:
|
|
11
|
+
* pids are recycled, so liveness alone would eventually let one run steal
|
|
12
|
+
* another's claim, and a heartbeat alone would strand a lock whose owner was
|
|
13
|
+
* SIGKILLed.
|
|
14
|
+
*/
|
|
15
|
+
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
|
16
|
+
import { ensureSecureDir } from './index.js'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
|
|
19
|
+
export const LOCK_DIR = 'eval.lock'
|
|
20
|
+
|
|
21
|
+
/** How often a live eval refreshes its heartbeat. */
|
|
22
|
+
export const HEARTBEAT_MS = 5_000
|
|
23
|
+
|
|
24
|
+
/** How cold a heartbeat must be before a dead owner's lock is reclaimed. */
|
|
25
|
+
export const STALE_AFTER_MS = 30_000
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Claims the run in stateDir for this process.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} stateDir
|
|
31
|
+
* @param {number} [pid]
|
|
32
|
+
* @returns {Promise<{release(): Promise<void>, touch(): Promise<void>}>}
|
|
33
|
+
* @throws {Error} when a live eval already holds the claim
|
|
34
|
+
*/
|
|
35
|
+
export async function claimEval(stateDir, pid = process.pid) {
|
|
36
|
+
await ensureSecureDir(stateDir)
|
|
37
|
+
const lockPath = join(stateDir, LOCK_DIR)
|
|
38
|
+
|
|
39
|
+
for (;;) {
|
|
40
|
+
try {
|
|
41
|
+
await mkdir(lockPath)
|
|
42
|
+
break
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (err.code !== 'EEXIST') throw err
|
|
45
|
+
const held = await readLock(lockPath)
|
|
46
|
+
if (!(await isStale(held, lockPath))) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`another autor3search-javascript eval (pid ${held.pid ?? 'unknown'}) is already running for this run`,
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
// Stale: the owner is gone and its heartbeat is cold. Take it over by
|
|
52
|
+
// removing the abandoned directory and retrying the atomic mkdir — this
|
|
53
|
+
// keeps two racing processes from both believing they won: only one
|
|
54
|
+
// mkdir can succeed per iteration, and a loser that lands here again
|
|
55
|
+
// rereads the (now fresh) lock and refuses like any other contender.
|
|
56
|
+
await rm(lockPath, { recursive: true, force: true }).catch(() => {})
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const touch = async () => {
|
|
61
|
+
await writeFile(join(lockPath, 'heartbeat'), String(Date.now()))
|
|
62
|
+
}
|
|
63
|
+
await writeFile(join(lockPath, 'pid'), String(pid))
|
|
64
|
+
await touch()
|
|
65
|
+
|
|
66
|
+
const timer = setInterval(() => {
|
|
67
|
+
touch().catch(() => {})
|
|
68
|
+
}, HEARTBEAT_MS)
|
|
69
|
+
// Never hold the event loop open on the heartbeat alone.
|
|
70
|
+
timer.unref?.()
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
touch,
|
|
74
|
+
async release() {
|
|
75
|
+
clearInterval(timer)
|
|
76
|
+
await rm(lockPath, { recursive: true, force: true })
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Reports the pid of the eval currently running for this run.
|
|
83
|
+
*
|
|
84
|
+
* A pid file that exists but does not hold a plausible pid is an ERROR, not a
|
|
85
|
+
* missing one: the value is about to be handed to a kill(2), where a
|
|
86
|
+
* non-positive target means something far broader than one process. Refusing
|
|
87
|
+
* to guess is the only safe reading of a corrupt file.
|
|
88
|
+
*
|
|
89
|
+
* The documented shape is `{pid, running}`; `heartbeat` rides along as a
|
|
90
|
+
* non-enumerable extra for callers (and tests) that want to observe it
|
|
91
|
+
* advancing, without changing what a deep-equality check against the
|
|
92
|
+
* documented shape sees.
|
|
93
|
+
*
|
|
94
|
+
* @returns {Promise<{pid: number|null, running: boolean, heartbeat?: number}>}
|
|
95
|
+
*/
|
|
96
|
+
export async function evalRunning(stateDir) {
|
|
97
|
+
const lockPath = join(stateDir, LOCK_DIR)
|
|
98
|
+
const held = await readLock(lockPath)
|
|
99
|
+
if (held.error) throw held.error
|
|
100
|
+
if (held.pid === null && held.heartbeat === null && !(await stat(lockPath).catch(() => null))) {
|
|
101
|
+
return withHeartbeat(null, false, null)
|
|
102
|
+
}
|
|
103
|
+
const stale = await isStale(held, lockPath)
|
|
104
|
+
return withHeartbeat(stale ? null : held.pid, !stale, held.heartbeat)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Builds an evalRunning result with heartbeat attached non-enumerably. */
|
|
108
|
+
function withHeartbeat(pid, running, heartbeat) {
|
|
109
|
+
const result = { pid, running }
|
|
110
|
+
Object.defineProperty(result, 'heartbeat', { value: heartbeat, enumerable: false })
|
|
111
|
+
return result
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Removes a lock left behind. Removing one that is not there is not an error. */
|
|
115
|
+
export async function clearEvalLock(stateDir) {
|
|
116
|
+
await rm(join(stateDir, LOCK_DIR), { recursive: true, force: true })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Reads the lock directory's contents, deferring pid validation to the caller. */
|
|
120
|
+
async function readLock(lockPath) {
|
|
121
|
+
const pidText = await readFile(join(lockPath, 'pid'), 'utf8').catch(() => null)
|
|
122
|
+
const beatText = await readFile(join(lockPath, 'heartbeat'), 'utf8').catch(() => null)
|
|
123
|
+
const heartbeat = beatText === null ? null : Number(beatText.trim())
|
|
124
|
+
|
|
125
|
+
if (pidText === null) return { pid: null, heartbeat, error: null }
|
|
126
|
+
const trimmed = pidText.trim()
|
|
127
|
+
// Require the pid file to hold nothing but an integer (with optional sign):
|
|
128
|
+
// Number() would otherwise accept '', ' ', '0x10', 'Infinity', and other
|
|
129
|
+
// strings no process id can be.
|
|
130
|
+
const isIntegerText = /^-?\d+$/.test(trimmed)
|
|
131
|
+
const pid = isIntegerText ? Number(trimmed) : NaN
|
|
132
|
+
if (!isIntegerText || !Number.isSafeInteger(pid)) {
|
|
133
|
+
return {
|
|
134
|
+
pid: null,
|
|
135
|
+
heartbeat,
|
|
136
|
+
error: new Error(`${join(lockPath, 'pid')}: ${JSON.stringify(trimmed)} is not a pid`),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// pid <= 1 is refused, not just pid <= 0: kill(1, ...) targets init/launchd
|
|
140
|
+
// (every process on the system on some platforms), and kill(-1, ...) - the
|
|
141
|
+
// exact form `stop --force` uses for its own valid pids - means "every
|
|
142
|
+
// process the caller may signal". A pid file holding either must never be
|
|
143
|
+
// read back and handed to process.kill.
|
|
144
|
+
if (pid <= 1) {
|
|
145
|
+
return {
|
|
146
|
+
pid: null,
|
|
147
|
+
heartbeat,
|
|
148
|
+
error: new Error(`${join(lockPath, 'pid')}: pid ${pid} is not a process this command will signal`),
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { pid, heartbeat, error: null }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A lock is stale only when its owner is gone AND its heartbeat is cold.
|
|
156
|
+
*
|
|
157
|
+
* When neither a pid nor a heartbeat can be read, the lock directory exists
|
|
158
|
+
* but is (so far) empty. That happens in exactly two situations, and they
|
|
159
|
+
* must not be confused: another claimant's mkdir just won and it has not yet
|
|
160
|
+
* written its pid/heartbeat files (a race lasting microseconds), or a past
|
|
161
|
+
* claimant crashed between its mkdir and those writes and the directory is
|
|
162
|
+
* permanently abandoned. Time is what tells them apart — so this falls back
|
|
163
|
+
* to the lock DIRECTORY's own age against the same staleness window, rather
|
|
164
|
+
* than ever treating "unreadable" as "absent" the way an empty pid/heartbeat
|
|
165
|
+
* pair alone would. Without this, one claimEval racing another could delete
|
|
166
|
+
* and recreate the winner's still-forming lock directory out from under it,
|
|
167
|
+
* and both callers would believe they held the claim.
|
|
168
|
+
*/
|
|
169
|
+
async function isStale(held, lockPath) {
|
|
170
|
+
if (held.error) return false
|
|
171
|
+
if (held.pid !== null && alive(held.pid)) return false
|
|
172
|
+
if (held.heartbeat !== null && Number.isFinite(held.heartbeat)) {
|
|
173
|
+
return Date.now() - held.heartbeat > STALE_AFTER_MS
|
|
174
|
+
}
|
|
175
|
+
const dirStat = await stat(lockPath).catch(() => null)
|
|
176
|
+
if (!dirStat) return true // lock directory is gone entirely: nothing to protect
|
|
177
|
+
return Date.now() - dirStat.mtimeMs > STALE_AFTER_MS
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Signal 0 tests for the existence of a process without touching it. */
|
|
181
|
+
function alive(pid) {
|
|
182
|
+
try {
|
|
183
|
+
process.kill(pid, 0)
|
|
184
|
+
return true
|
|
185
|
+
} catch (err) {
|
|
186
|
+
// EPERM means the process exists but belongs to another user.
|
|
187
|
+
return err.code === 'EPERM'
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sentinel coordinating between the human's shell and the agent's loop.
|
|
3
|
+
*
|
|
4
|
+
* It lives out-of-tree alongside the rest of the run state, for the same
|
|
5
|
+
* reason everything else there does: a sentinel inside the repository would
|
|
6
|
+
* dirty the working tree the agent commits from, and would have to be
|
|
7
|
+
* special-cased in the scope gate and in .gitignore. Out here it is invisible
|
|
8
|
+
* to every gate and to git, and both `stop` and `eval` still find it because
|
|
9
|
+
* the state directory is derived from the repository path and run tag, not
|
|
10
|
+
* from anything either process holds privately.
|
|
11
|
+
*/
|
|
12
|
+
import { rm, stat, writeFile } from 'node:fs/promises'
|
|
13
|
+
import { ensureSecureDir } from './index.js'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Marks that the human has asked the run to end. `eval` reports its presence
|
|
18
|
+
* alongside the verdict; the AGENT decides when to act on it, which is what
|
|
19
|
+
* makes a graceful stop graceful — nothing here interrupts an experiment
|
|
20
|
+
* already under way.
|
|
21
|
+
*/
|
|
22
|
+
export const STOP_REQUEST_FILE = 'stop.request'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Asks the run in stateDir to end after the current experiment.
|
|
26
|
+
*
|
|
27
|
+
* Creating stateDir when missing is deliberate: a human reaching for the
|
|
28
|
+
* brake should never be told the directory does not exist yet. A request
|
|
29
|
+
* against a tag whose baseline never finished is harmless — the sentinel
|
|
30
|
+
* simply sits there until a run reads it, or clearStop removes it.
|
|
31
|
+
*/
|
|
32
|
+
export async function requestStop(stateDir) {
|
|
33
|
+
await ensureSecureDir(stateDir)
|
|
34
|
+
await writeFile(join(stateDir, STOP_REQUEST_FILE), 'stop requested\n')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Cancels a pending request. Clearing one never made is not an error. */
|
|
38
|
+
export async function clearStop(stateDir) {
|
|
39
|
+
await rm(join(stateDir, STOP_REQUEST_FILE), { force: true })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Reports whether a stop is pending.
|
|
44
|
+
*
|
|
45
|
+
* Returns a boolean rather than throwing, because every caller wants the same
|
|
46
|
+
* answer for an unreadable sentinel as for an absent one: carry on. A stop
|
|
47
|
+
* that cannot be read must never abort a run by itself.
|
|
48
|
+
*
|
|
49
|
+
* @returns {Promise<boolean>}
|
|
50
|
+
*/
|
|
51
|
+
export async function stopRequested(stateDir) {
|
|
52
|
+
return stat(join(stateDir, STOP_REQUEST_FILE)).then(
|
|
53
|
+
() => true,
|
|
54
|
+
() => false,
|
|
55
|
+
)
|
|
56
|
+
}
|