@autor3search/javascript 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
  [![node](https://img.shields.io/node/v/@autor3search/javascript)](https://nodejs.org)
5
5
  [![license](https://img.shields.io/npm/l/@autor3search/javascript)](LICENSE)
6
6
 
7
+ [autoresearch for your codebase](https://autor3search.dev/) — the same loop in seven languages, with every agent prompt in one place.
8
+
7
9
  An autonomous performance-optimization harness for JavaScript repositories. You
8
10
  point a coding agent at your repository, run four commands, hand the agent
9
11
  `program.md`, and go to sleep. The agent proposes one performance change at a
@@ -31,7 +33,7 @@ Or run it without installing anything, which is the better option when you
31
33
  want a specific version pinned for a run:
32
34
 
33
35
  ```bash
34
- npx @autor3search/javascript@0.2.0 doctor
36
+ npx @autor3search/javascript@0.2.1 doctor
35
37
  ```
36
38
 
37
39
  Node 20 or newer. The harness measures with Vitest, so the repository you
@@ -171,17 +173,21 @@ Three ways to end a run, in increasing order of force:
171
173
  under way finishes, is measured and scored normally; the agent sees
172
174
  `"stop_requested": true` on that verdict, applies it as usual (`KEEP`
173
175
  stays, anything else resets), then exits the loop. Nothing is thrown away.
174
- 2. **`autor3search-javascript stop --force`** — writes the same request, then
175
- sends `SIGTERM` to the running `eval`, which tears down its own child
176
- process groups so no Vitest worker is left burning CPU. The agent sees
177
- `"status": "ABORTED"`, exit code 2, and no `results.tsv` row — nothing was
178
- measured, so nothing was recorded. `stop --force` reports what HEAD looks
179
- like afterward; it does not touch the repository for you.
176
+ 2. **`autor3search-javascript stop --force`** — writes a sticky forced-stop
177
+ marker. The running `eval` polls for it and cancels itself, and that
178
+ cancellation is what tears down the benchmark process tree, so no Vitest
179
+ worker is left burning CPU. The agent sees `"status": "ABORTED"`, exit
180
+ code 2, and no `results.tsv` row nothing was measured, so nothing was
181
+ recorded. The marker is sticky on purpose: if nothing was running when you
182
+ forced the stop, it stands and aborts the *next* `eval` to start instead,
183
+ until you clear it with `stop --clear`. `stop --force` reports what HEAD
184
+ looks like afterward; it does not touch the repository for you.
180
185
  3. **Ctrl+C** — the same abort path as `stop --force`, sent directly to a
181
186
  foreground `eval`.
182
187
 
183
- `autor3search-javascript stop --clear` cancels a pending stop request so the
184
- loop continues — that is your decision, never the agent's to make on its own.
188
+ `autor3search-javascript stop --clear` cancels both a pending stop request and
189
+ a pending forced stop, so the loop continues — that is your decision, never
190
+ the agent's to make on its own.
185
191
 
186
192
  ## Commands
187
193
 
@@ -380,19 +386,26 @@ Two things to know if you are wiring this up on a fork or a new package:
380
386
  |---|---|
381
387
  | **Linux** | supported — CI runs the full suite on Node 20 and 22 |
382
388
  | **macOS** | supported — CI runs the full suite on Node 20 and 22 |
383
- | **Windows** | **not supported** |
384
-
385
- On Windows 448 of 458 tests pass, so measurement itself works. What does not
386
- work is stopping: Node cannot deliver SIGINT to a child process group there
387
- the way it does on POSIX, so an interrupted `eval` never reaches the `ABORTED`
388
- path it exits with a null code instead of 2 and can leave its claim behind.
389
- An unattended harness that cannot be reliably stopped is not something to be
390
- quiet about, so `doctor` says so on Windows rather than letting you find out
391
- at 3am. Two further failures are the test suite's own POSIX assumptions
392
- (a `chmod`-unreadable directory, and path separators) rather than product bugs.
393
-
394
- WSL reports as Linux and is unaffected. Node 20 or newer is required
395
- everywhere.
389
+ | **Windows** | supported — CI runs the full suite on Node 20 and 22 |
390
+
391
+ All three behave identically, by design. `stop` and `stop --force` write marker
392
+ files into the run's state directory rather than signalling a process: the
393
+ running `eval` polls for a forced stop and cancels itself, and that cancellation
394
+ is what tears the benchmark process tree down. A signal-based forced stop could
395
+ not behave the same way everywhere Windows has no process-to-process SIGTERM,
396
+ so `eval` would be terminated outright and would never record what it abandoned.
397
+ Ctrl+C still works everywhere, because a console interrupt is real on Windows
398
+ too.
399
+
400
+ Three small things differ on Windows, none of them behavioural. `doctor` has no
401
+ load average to read there, so it makes one fewer check and says so rather than
402
+ reporting the fabricated zero the platform hands it. The state directory's
403
+ ownership and permission check is skipped, because Windows reports synthetic
404
+ mode bits and has no owner to compare against. And one test is skipped — the
405
+ one that makes a directory unlistable with `chmod`, which has no Windows
406
+ analogue.
407
+
408
+ WSL reports as Linux and is unaffected. Node 20 or newer is required everywhere.
396
409
 
397
410
  ## Limitations
398
411
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autor3search/javascript",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Autonomous AI-driven performance optimization for any JavaScript repository",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,7 +9,9 @@
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/autor3search/javascript.git"
11
11
  },
12
+ "homepage": "https://autor3search.dev",
12
13
  "keywords": [
14
+ "autoresearch",
13
15
  "benchmark",
14
16
  "performance",
15
17
  "optimization",
@@ -40,7 +42,6 @@
40
42
  "devDependencies": {
41
43
  "vitest": "^2.1.0"
42
44
  },
43
- "homepage": "https://github.com/autor3search/javascript#readme",
44
45
  "bugs": {
45
46
  "url": "https://github.com/autor3search/javascript/issues"
46
47
  },
@@ -16,10 +16,13 @@ import { RUN_LOG_NAME, evalOnce } from '../pipeline.js'
16
16
  import { RESULTS_PATH, appendRow, loadRows } from '../results.js'
17
17
  import { BASELINE_FILE, WORKTREE_NAME, loadBaseline } from '../state/index.js'
18
18
  import { claimEval } from '../state/lock.js'
19
- import { stopRequested } from '../state/stop.js'
19
+ import { forceRequested, stopRequested } from '../state/stop.js'
20
20
  import { REASON, STATUS, exitCode } from '../verdict.js'
21
21
  import { expandSingleDashFlags, loadRepoConfig, resolveRun } from './context.js'
22
22
 
23
+ /** How often a running eval looks for a forced-stop marker. */
24
+ const FORCE_POLL_MS = 500
25
+
23
26
  /**
24
27
  * @param {string[]} args
25
28
  * @param {{out: {write(s: string): void}, err: {write(s: string): void}}} io
@@ -72,9 +75,23 @@ export async function runEval(args, io) {
72
75
  const onSignal = () => controller.abort()
73
76
  process.on('SIGINT', onSignal)
74
77
  process.on('SIGTERM', onSignal)
75
- const offSignals = () => {
78
+ // The forced stop is a polled marker, not a signal — see state/stop.js for
79
+ // why. It is installed here, beside the handlers and BEFORE claimEval, for
80
+ // exactly the reason spelled out above: a marker already pending when this
81
+ // run starts must abort it at its first checkpoint, not after it has claimed
82
+ // the run and begun measuring.
83
+ const poll = setInterval(() => {
84
+ forceRequested(run.stateDir)
85
+ .then((forced) => {
86
+ if (forced) controller.abort()
87
+ })
88
+ .catch(() => {})
89
+ }, FORCE_POLL_MS)
90
+ poll.unref()
91
+ const detach = () => {
76
92
  process.off('SIGINT', onSignal)
77
93
  process.off('SIGTERM', onSignal)
94
+ clearInterval(poll)
78
95
  }
79
96
 
80
97
  let claim
@@ -83,7 +100,7 @@ export async function runEval(args, io) {
83
100
  } catch (err) {
84
101
  // dispatch() also runs in-process under test, so a leaked listener here
85
102
  // would accumulate across runs rather than dying with the process.
86
- offSignals()
103
+ detach()
87
104
  io.err.write(`${err.message}\n`)
88
105
  return 2
89
106
  }
@@ -128,11 +145,21 @@ export async function runEval(args, io) {
128
145
  if (controller.signal.aborted) {
129
146
  // ABORTED is not a verdict: nothing was measured, so NO results.tsv row
130
147
  // is written. The agent treats it as it would a FAIL.
148
+ //
149
+ // A sticky forced stop aborts every later eval on this tag too, so an
150
+ // abort that finds the marker still pending must say so and name the
151
+ // cure. It goes in `message` — which writeJson already emits and the
152
+ // human path already writes to io.err — rather than in a new line of
153
+ // output, because --json's contract is that stdout carries the verdict
154
+ // object and nothing else.
155
+ const forced = await forceRequested(run.stateDir).catch(() => false)
131
156
  const aborted = {
132
157
  status: STATUS.ABORTED,
133
158
  reason: REASON.STOP_FORCED,
134
159
  score: 0,
135
- message: 'the experiment was interrupted before it could be measured',
160
+ message: forced
161
+ ? 'the experiment was abandoned: a forced stop is pending for this tag — clear it with `stop --clear` before the next eval'
162
+ : 'the experiment was interrupted before it could be measured',
136
163
  regressions: [],
137
164
  warnings: [],
138
165
  }
@@ -149,7 +176,7 @@ export async function runEval(args, io) {
149
176
  }
150
177
  throw err
151
178
  } finally {
152
- offSignals()
179
+ detach()
153
180
  await closeLog(logStream)
154
181
  await claim.release()
155
182
  }
@@ -8,7 +8,7 @@ import * as gitx from '../gitx.js'
8
8
  import { RESULTS_PATH, loadRows } from '../results.js'
9
9
  import { BASELINE_FILE, WORKTREE_NAME, loadBaseline } from '../state/index.js'
10
10
  import { evalRunning } from '../state/lock.js'
11
- import { stopRequested } from '../state/stop.js'
11
+ import { forceRequested, stopRequested } from '../state/stop.js'
12
12
  import { expandSingleDashFlags, resolveRun } from './context.js'
13
13
 
14
14
  /**
@@ -42,6 +42,7 @@ export async function runStatus(args, io) {
42
42
  evalLine = `unknown (${err.message})`
43
43
  }
44
44
  const stopped = await stopRequested(run.stateDir)
45
+ const forced = await forceRequested(run.stateDir)
45
46
 
46
47
  const field = (name, value) => io.out.write(`${name.padEnd(14)} ${value}\n`)
47
48
  field('run tag', run.tag)
@@ -60,7 +61,14 @@ export async function runStatus(args, io) {
60
61
  `${counts.crash} crash) — next is #${rows.length + 1}`,
61
62
  )
62
63
  field('eval', evalLine)
63
- field('stop', stopped ? 'requested — the agent will end the run after the current experiment' : 'not requested')
64
+ field(
65
+ 'stop',
66
+ forced
67
+ ? 'FORCED — the next eval will abort at once; clear it with `autor3search-javascript stop --clear`'
68
+ : stopped
69
+ ? 'requested — the agent will end the run after the current experiment'
70
+ : 'not requested',
71
+ )
64
72
 
65
73
  io.out.write('\nto stop after the current experiment: autor3search-javascript stop\n')
66
74
  io.out.write('to stop now, abandoning it: autor3search-javascript stop --force\n')
@@ -6,15 +6,15 @@
6
6
  * and only then does the loop exit. Nothing is thrown away.
7
7
  *
8
8
  * `--force` is for when a long benchmark cannot be waited out. It writes the
9
- * same request, then signals the running eval to abandon the experiment. It
10
- * REPORTS what state that leaves the repository in; it does not drop anything
11
- * for you, because deciding what to do with a half-finished experiment is the
12
- * human's call.
9
+ * same request, then marks the run for the running eval to poll and abandon
10
+ * the experiment on its own. It REPORTS what state that leaves the repository
11
+ * in; it does not drop anything for you, because deciding what to do with a
12
+ * half-finished experiment is the human's call.
13
13
  */
14
14
  import { parseArgs } from 'node:util'
15
15
  import * as gitx from '../gitx.js'
16
16
  import { evalRunning } from '../state/lock.js'
17
- import { clearStop, requestStop } from '../state/stop.js'
17
+ import { clearStop, forceRequested, requestForceStop, requestStop } from '../state/stop.js'
18
18
  import { expandSingleDashFlags, resolveRun } from './context.js'
19
19
 
20
20
  /**
@@ -50,35 +50,44 @@ export async function runStop(args, io) {
50
50
  await requestStop(run.stateDir)
51
51
  io.out.write(`stop requested for ${run.tag}: the agent will end the run after the current experiment\n`)
52
52
  if (!values.force) {
53
- io.out.write('the experiment under way will still be measured, scored and applied\n')
53
+ // A plain `stop` only promises the current experiment survives when
54
+ // nothing is already asking to abandon it. A forced stop from EARLIER —
55
+ // `stop --force` issued when nothing was running, then never cleared —
56
+ // is still pending and sticky, and it will abort the next eval to start
57
+ // regardless of what this plain `stop` just requested.
58
+ if (await forceRequested(run.stateDir)) {
59
+ io.out.write('a forced stop from earlier is still pending, though, and will abort the next eval to start\n')
60
+ } else {
61
+ io.out.write('the experiment under way will still be measured, scored and applied\n')
62
+ }
54
63
  io.out.write(`to cancel: autor3search-javascript stop -tag ${run.tag} --clear\n`)
55
64
  return 0
56
65
  }
57
66
 
58
- // A corrupt pid file must not crash `stop --force` — it is refused by
59
- // evalRunning rather than guessed at, but the request above already stands
60
- // regardless, so fall back to "nothing known to signal" and keep going.
67
+ await requestForceStop(run.stateDir)
68
+
69
+ // Nothing is signalled between processes: the running eval polls this marker
70
+ // and cancels itself. That behaves identically on every platform — Windows
71
+ // has no process-to-process SIGTERM — and it cannot reach an unrelated
72
+ // process the way signalling a pid read from a file can, if the OS has
73
+ // recycled that pid since.
74
+ //
75
+ // evalRunning is therefore reporting, not mechanism. A corrupt pid file
76
+ // costs a line of the report and nothing else; the marker already stands.
61
77
  let pid = null
62
78
  let running = false
63
79
  try {
64
80
  ;({ pid, running } = await evalRunning(run.stateDir))
65
81
  } catch (err) {
66
- io.err.write(`could not read the eval lock (${err.message}); assuming there is nothing to signal\n`)
82
+ io.err.write(`could not read the eval lock (${err.message}); cannot say whether an eval is running\n`)
67
83
  }
68
84
 
69
85
  if (running) {
70
- // SIGTERM, not SIGKILL: eval installs a handler that tears down its child
71
- // process groups. Killing it outright would leave Vitest workers running,
72
- // burning CPU and corrupting every later measurement on this machine.
73
- try {
74
- process.kill(pid, 'SIGTERM')
75
- io.out.write(`signalled eval (pid ${pid}) to abandon the current experiment\n`)
76
- } catch (err) {
77
- io.err.write(`could not signal eval (pid ${pid}): ${err.message}\n`)
78
- }
86
+ io.out.write(`eval (pid ${pid}) will abandon the current experiment within a second\n`)
79
87
  } else {
80
- io.out.write('no eval is running, so there was nothing to interrupt — the request stands\n')
88
+ io.out.write('no eval is running, so there was nothing to interrupt — the forced stop stands and will abort the next eval to start\n')
81
89
  }
90
+ io.out.write(`to cancel it: autor3search-javascript stop -tag ${run.tag} --clear\n`)
82
91
 
83
92
  // Report, do not act — whether or not anything was actually interrupted,
84
93
  // this is what the repository looks like right now. The commit an
package/src/doctor.js CHANGED
@@ -37,13 +37,7 @@ export async function check(dir) {
37
37
  const absDir = resolvePath(dir)
38
38
 
39
39
  const platformCheck =
40
- platform() === 'darwin'
41
- ? ['power', checkDarwin]
42
- : platform() === 'linux'
43
- ? ['cpu governor', checkLinux]
44
- : platform() === 'win32'
45
- ? ['platform', checkWindows]
46
- : null
40
+ platform() === 'darwin' ? ['power', checkDarwin] : platform() === 'linux' ? ['cpu governor', checkLinux] : null
47
41
 
48
42
  const checks = [
49
43
  ['node', checkNode],
@@ -106,6 +100,13 @@ function checkCpu() {
106
100
  }
107
101
 
108
102
  function checkLoad() {
103
+ // os.loadavg() is not implemented on Windows: it returns a hardcoded
104
+ // [0, 0, 0]. Reporting that would tell a user under heavy load that their
105
+ // machine is perfectly idle — worse than saying nothing, in a tool that
106
+ // exists to predict whether measurement will be reliable.
107
+ if (platform() === 'win32') {
108
+ return { name: 'load', detail: 'no load average on this platform — check the machine is quiet yourself', severity: SEVERITY.NA }
109
+ }
109
110
  const [one] = loadavg()
110
111
  const perCore = one / Math.max(1, cpus().length)
111
112
  return {
@@ -144,28 +145,6 @@ async function checkVitest(dir) {
144
145
  }
145
146
  }
146
147
 
147
- /**
148
- * Windows is not a supported platform, and the reason is the stop path.
149
- *
150
- * 448 of 458 tests pass there, so most of the harness works — but Node cannot
151
- * deliver SIGINT to a child process group on Windows the way it does on POSIX,
152
- * so an interrupted `eval` does not reach the ABORTED path: it exits with a
153
- * null code instead of 2, and the run may leave a claim behind. An unattended
154
- * harness that cannot be reliably stopped is the wrong thing to be quiet about,
155
- * so this says it up front rather than at 3am.
156
- *
157
- * WSL reports linux and is unaffected.
158
- */
159
- function checkWindows() {
160
- return {
161
- name: 'platform',
162
- detail:
163
- 'Windows is not a supported platform: an interrupted eval does not reach the ABORTED path, so a run ' +
164
- 'may not stop cleanly and can leave its claim behind. Measurement itself works. Use WSL for a supported setup.',
165
- severity: SEVERITY.WARN,
166
- }
167
- }
168
-
169
148
  function checkHeapHint() {
170
149
  // Reported up front so a run does not discover mid-flight that the hint is
171
150
  // unavailable, which would otherwise look like the benchmark allocating
package/src/pipeline.js CHANGED
@@ -97,6 +97,17 @@ const GATE_VERDICT = {
97
97
  * @returns {Promise<{result: object, measurements: {time: object[], bytes: object[]|null}|null}>}
98
98
  */
99
99
  export async function evalOnce(o) {
100
+ // A sticky forced stop that is already pending when a run starts must not
101
+ // cost a full gate run before it is honoured: the scope diff, config hash,
102
+ // freeze restore and the gates themselves are all real work, and none of
103
+ // it should happen for an experiment that is about to be thrown away
104
+ // unmeasured. Checked here, before anything below touches git or the
105
+ // filesystem, rather than only after the gates (line 263's neighbour) —
106
+ // that later check exists for an abort that arrives WHILE gating is under
107
+ // way; this one is for an abort that was already true before evalOnce was
108
+ // ever called.
109
+ if (o.signal?.aborted) throw new Error('aborted before the experiment began')
110
+
100
111
  const timeoutMs = parseDuration(o.cfg.timeout)
101
112
 
102
113
  // ---- 1. Scope --------------------------------------------------------
package/src/runner.js CHANGED
@@ -183,6 +183,21 @@ class Capped {
183
183
  * Signals a child's whole process group. A negative pid addresses the group.
184
184
  * Failures are swallowed: the group is already gone in the common case, and a
185
185
  * teardown that throws would mask the real result being reported.
186
+ *
187
+ * On Windows there are no process groups, so `process.kill(-pid, …)` throws and
188
+ * the fallback terminates only the direct child — yet grandchildren still die,
189
+ * and runner.test.js proves it on every platform. The reason is libuv: it
190
+ * assigns each non-detached child to a global job object with
191
+ * JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, so when the direct child dies, its job
192
+ * handle closes and everything it spawned goes with it.
193
+ *
194
+ * That holds only because EVERY subprocess this harness starts is
195
+ * process.execPath — see adapters/bench/vitest.js and adapters/gates/*.js — so
196
+ * every intermediate is a Node process carrying such a job object. Two changes
197
+ * would silently break it and leave vitest's workers running on Windows,
198
+ * burning CPU and corrupting every later measurement: running a gate through
199
+ * `npm`/`npx` (a .cmd, so cmd.exe becomes the intermediate and there is no job
200
+ * object), or adding `detached: true` to an inner spawn.
186
201
  */
187
202
  function killGroup(pid, signal) {
188
203
  if (!pid) return
package/src/state/lock.js CHANGED
@@ -137,15 +137,21 @@ async function readLock(lockPath) {
137
137
  }
138
138
  }
139
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.
140
+ // (every process on the system on some platforms), and kill(-1, ...) means
141
+ // "every process the caller may signal". Nothing in src/cli/ hands a pid to
142
+ // process.kill any more stop --force writes a marker, not a signal (see
143
+ // state/stop.js) but alive() below still calls process.kill(pid, 0) as a
144
+ // liveness probe, and pid 1 or -1 would make that probe ALWAYS succeed. A
145
+ // lock is only reclaimed once BOTH the pid is dead and the heartbeat is
146
+ // cold (isStale, below); a pid this "alive" check can never call dead would
147
+ // leave that lock permanently unreclaimable, refusing every later eval on
148
+ // the tag forever. A pid file holding either value must never be read back
149
+ // and treated as one this process may trust.
144
150
  if (pid <= 1) {
145
151
  return {
146
152
  pid: null,
147
153
  heartbeat,
148
- error: new Error(`${join(lockPath, 'pid')}: pid ${pid} is not a process this command will signal`),
154
+ error: new Error(`${join(lockPath, 'pid')}: pid ${pid} is not a pid this run will trust`),
149
155
  }
150
156
  }
151
157
  return { pid, heartbeat, error: null }
package/src/state/stop.js CHANGED
@@ -21,6 +21,26 @@ import { join } from 'node:path'
21
21
  */
22
22
  export const STOP_REQUEST_FILE = 'stop.request'
23
23
 
24
+ /**
25
+ * Marks that the human has asked the run to abandon what it is measuring RIGHT
26
+ * NOW, rather than after the current experiment.
27
+ *
28
+ * A marker rather than a signal, because a signal cannot cross processes on
29
+ * every platform this harness supports. Windows has no process-to-process
30
+ * SIGTERM or SIGINT; Node implements `process.kill` there as TerminateProcess,
31
+ * so the target dies where it stands — its `finally` never runs, `eval` exits
32
+ * with a null code instead of 2, and its claim is left behind to go stale.
33
+ * Polling a file behaves identically everywhere. It also closes a race that
34
+ * exists on POSIX, where signalling a pid read from a file can reach an
35
+ * unrelated process the OS has since recycled that pid onto.
36
+ *
37
+ * STICKY, and deliberately so: an unattended agent loop starts a fresh `eval`
38
+ * seconds after the last one exits, so a marker consumed by the eval it aborts
39
+ * would be gone before the loop's next iteration read it — the human hits the
40
+ * brake and the run carries on regardless. Only `clearStop` removes it.
41
+ */
42
+ export const STOP_FORCE_FILE = 'stop.force'
43
+
24
44
  /**
25
45
  * Asks the run in stateDir to end after the current experiment.
26
46
  *
@@ -34,9 +54,39 @@ export async function requestStop(stateDir) {
34
54
  await writeFile(join(stateDir, STOP_REQUEST_FILE), 'stop requested\n')
35
55
  }
36
56
 
37
- /** Cancels a pending request. Clearing one never made is not an error. */
57
+ /**
58
+ * Asks the eval running in stateDir to abandon its experiment at once.
59
+ *
60
+ * Creates stateDir for the same reason requestStop does: a human reaching for
61
+ * the brake should never be told the directory does not exist yet.
62
+ */
63
+ export async function requestForceStop(stateDir) {
64
+ await ensureSecureDir(stateDir)
65
+ await writeFile(join(stateDir, STOP_FORCE_FILE), 'forced stop requested\n')
66
+ }
67
+
68
+ /**
69
+ * Reports whether a forced stop is pending. Boolean rather than throwing, for
70
+ * the same reason stopRequested is: an unreadable sentinel and an absent one
71
+ * deserve the same answer, and a marker that cannot be read must never abort a
72
+ * run by itself.
73
+ *
74
+ * @returns {Promise<boolean>}
75
+ */
76
+ export async function forceRequested(stateDir) {
77
+ return stat(join(stateDir, STOP_FORCE_FILE)).then(
78
+ () => true,
79
+ () => false,
80
+ )
81
+ }
82
+
83
+ /**
84
+ * Cancels both pending requests — one brake, one release. Clearing one never
85
+ * made is not an error.
86
+ */
38
87
  export async function clearStop(stateDir) {
39
88
  await rm(join(stateDir, STOP_REQUEST_FILE), { force: true })
89
+ await rm(join(stateDir, STOP_FORCE_FILE), { force: true })
40
90
  }
41
91
 
42
92
  /**