@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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +500 -0
  3. package/bin/autor3search-javascript.js +4 -0
  4. package/package.json +50 -0
  5. package/src/adapters/bench/index.js +31 -0
  6. package/src/adapters/bench/vitest.js +128 -0
  7. package/src/adapters/driver-child.js +72 -0
  8. package/src/adapters/driver-hooks.js +18 -0
  9. package/src/adapters/driver.js +192 -0
  10. package/src/adapters/gates/index.js +64 -0
  11. package/src/adapters/gates/lint.js +46 -0
  12. package/src/adapters/gates/test.js +27 -0
  13. package/src/adapters/gates/typecheck.js +98 -0
  14. package/src/adapters/gates/util.js +45 -0
  15. package/src/adapters/vitest-shim.js +51 -0
  16. package/src/bench/parse.js +120 -0
  17. package/src/bench/set.js +101 -0
  18. package/src/bench/stats.js +443 -0
  19. package/src/cli/cmd-baseline.js +136 -0
  20. package/src/cli/cmd-doctor.js +38 -0
  21. package/src/cli/cmd-eval.js +231 -0
  22. package/src/cli/cmd-init.js +136 -0
  23. package/src/cli/cmd-profile.js +38 -0
  24. package/src/cli/cmd-report.js +96 -0
  25. package/src/cli/cmd-status.js +68 -0
  26. package/src/cli/cmd-stop.js +98 -0
  27. package/src/cli/cmd-version.js +31 -0
  28. package/src/cli/context.js +98 -0
  29. package/src/cli/main.js +62 -0
  30. package/src/config.js +209 -0
  31. package/src/discover.js +239 -0
  32. package/src/doctor.js +282 -0
  33. package/src/duration.js +75 -0
  34. package/src/freeze.js +234 -0
  35. package/src/gitx.js +115 -0
  36. package/src/measure.js +127 -0
  37. package/src/pipeline.js +391 -0
  38. package/src/profile.js +100 -0
  39. package/src/results.js +124 -0
  40. package/src/runner.js +198 -0
  41. package/src/scope.js +92 -0
  42. package/src/state/index.js +312 -0
  43. package/src/state/lock.js +189 -0
  44. package/src/state/stop.js +56 -0
  45. package/src/verdict.js +214 -0
  46. 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
+ }