bareloop 0.42.0 → 0.44.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 (79) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/bareloop.context.md +103 -41
  3. package/package.json +2 -2
  4. package/src/authorflow.js +26 -11
  5. package/src/authoring.js +1 -1
  6. package/src/authorjob.js +33 -15
  7. package/src/authorreadout.js +7 -6
  8. package/src/declaredclose.js +6 -3
  9. package/src/draftspend.js +20 -2
  10. package/src/facts.js +54 -9
  11. package/src/factwords.js +58 -6
  12. package/src/inputlook.js +38 -3
  13. package/src/interviewrun.js +0 -1
  14. package/src/job.js +39 -12
  15. package/src/kinds.js +5 -0
  16. package/src/pagedropwords.js +64 -0
  17. package/src/pagemap.js +264 -0
  18. package/src/pagerules.js +319 -0
  19. package/src/panel/authorroutes.js +138 -60
  20. package/src/panel/authorsession.js +88 -32
  21. package/src/panel/index.html +1251 -522
  22. package/src/panel/jobplan.js +4 -2
  23. package/src/panel/runroutes.js +188 -6
  24. package/src/panel/scheduler.js +156 -0
  25. package/src/panel/schedules.js +667 -0
  26. package/src/panel/schedulestart.js +243 -0
  27. package/src/panel/server.js +1234 -68
  28. package/src/panel/status.js +33 -0
  29. package/src/panelmoney.js +29 -0
  30. package/src/planrun.js +92 -11
  31. package/src/promptregisters.js +4 -0
  32. package/src/providerrows.js +3 -3
  33. package/src/replay.js +118 -7
  34. package/src/reviewdoor.js +37 -0
  35. package/src/rubricstep.js +123 -11
  36. package/src/runlist.js +37 -1
  37. package/src/schedulewords.js +299 -0
  38. package/src/source.js +18 -0
  39. package/src/thinking.js +63 -2
  40. package/src/trigger.js +48 -0
  41. package/src/triggerclock.js +127 -0
  42. package/src/triggerfiles.js +215 -0
  43. package/src/triggerledger.js +79 -0
  44. package/src/userrun.js +34 -21
  45. package/types/authorflow.d.ts +42 -6
  46. package/types/authorjob.d.ts +10 -2
  47. package/types/declaredclose.d.ts +3 -1
  48. package/types/draftspend.d.ts +27 -1
  49. package/types/facts.d.ts +15 -0
  50. package/types/factwords.d.ts +26 -3
  51. package/types/inputlook.d.ts +15 -0
  52. package/types/job.d.ts +8 -1
  53. package/types/kinds.d.ts +5 -0
  54. package/types/pagedropwords.d.ts +26 -0
  55. package/types/pagemap.d.ts +154 -0
  56. package/types/pagerules.d.ts +86 -0
  57. package/types/panel/authorroutes.d.ts +50 -14
  58. package/types/panel/authorsession.d.ts +37 -12
  59. package/types/panel/runroutes.d.ts +32 -1
  60. package/types/panel/scheduler.d.ts +17 -0
  61. package/types/panel/schedules.d.ts +412 -0
  62. package/types/panel/schedulestart.d.ts +106 -0
  63. package/types/panel/server.d.ts +297 -15
  64. package/types/panel/status.d.ts +51 -0
  65. package/types/panelmoney.d.ts +5 -0
  66. package/types/planrun.d.ts +2 -0
  67. package/types/providerrows.d.ts +2 -2
  68. package/types/replay.d.ts +21 -1
  69. package/types/reviewdoor.d.ts +27 -0
  70. package/types/rubricstep.d.ts +57 -1
  71. package/types/runlist.d.ts +24 -1
  72. package/types/schedulewords.d.ts +137 -0
  73. package/types/source.d.ts +11 -0
  74. package/types/thinking.d.ts +39 -0
  75. package/types/trigger.d.ts +37 -0
  76. package/types/triggerclock.d.ts +52 -0
  77. package/types/triggerfiles.d.ts +93 -0
  78. package/types/triggerledger.d.ts +35 -0
  79. package/types/userrun.d.ts +4 -0
@@ -11,7 +11,7 @@
11
11
  // `cls` is 'machine check' | 'judge' | null (null = not known yet: the confirm card's checks are the model's plan, the
12
12
  // stage kinds exist only after drafting). Pure; no file IO.
13
13
  import { KIND_CATALOGUE } from '../authoring.js';
14
- import { checksOfClose, proseNotChecked, lineRules } from '../rubricstep.js';
14
+ import { checksOfClose, proseNotChecked, lineRules, confirmedGoalOf } from '../rubricstep.js';
15
15
 
16
16
  /** @typedef {{name: string, cls: 'machine check'|'judge'|null}} PlanCheck */
17
17
  /** @typedef {{n: number, text: string, rule: string, rules: string[], checks: PlanCheck[], refused: string[]}} PlanLine */
@@ -100,7 +100,9 @@ export function planFromSpec(spec) {
100
100
  if (line && typeof r.reason === 'string') line.refused.push(ruleText ? `"${ruleText}" — ${r.reason}` : r.reason);
101
101
  }
102
102
  // the model's signed goal sentence rides along: the page draws it as the "AI's summary" row (an older job shows it as line 1 instead)
103
- return { lines, loose, notChecked: notes, alwaysOn, older: false, ...(typeof spec?.goal === 'string' && spec.goal ? { goal: spec.goal } : {}) };
103
+ // (a rubric job's signed goal is the whole worker brief: only the sentence the person confirmed is the summary)
104
+ const summary = typeof spec?.goal === 'string' && spec.goal ? confirmedGoalOf(spec.goal) : null;
105
+ return { lines, loose, notChecked: notes, alwaysOn, older: false, ...(summary ? { goal: summary } : {}) };
104
106
  }
105
107
 
106
108
  /**
@@ -16,12 +16,16 @@ import {
16
16
  openSync, closeSync, writeFileSync, readFileSync, readdirSync,
17
17
  } from 'node:fs';
18
18
  import { dirname, join } from 'node:path';
19
- import { checkHumanGuard } from './authorroutes.js';
19
+ import {
20
+ checkHumanGuard, OLD_JOB_ACCEPT_WORDS, OLD_JOB_RESUME_WORDS, OLD_JOB_RERUN_WORDS, OLD_JOB_PAUSE_WORDS,
21
+ } from './authorroutes.js';
20
22
  import { checkMonthlyRoom, monthlyRefusalText } from '../monthly.js';
21
23
  import { ConfigError } from '../config.js';
22
24
  import { keysForDoor } from '../keysfile.js';
23
- import { jobSpecHash } from '../job.js';
25
+ import { jobSpecHash, validateJob } from '../job.js';
24
26
  import { stopFilePath } from '../legs.js';
27
+ import { panelMoney2 } from '../panelmoney.js';
28
+ import { appendNeedsRemoved } from '../runlist.js';
25
29
 
26
30
  /** the tail of an engine log shown to the person when the engine refuses at start (bytes) */
27
31
  const LOG_TAIL_BYTES = 4000;
@@ -29,6 +33,9 @@ const LOG_TAIL_BYTES = 4000;
29
33
  /** how long a resume waits for an early engine exit before reporting "started" (ms) */
30
34
  export const DEFAULT_SETTLE_MS = 6000;
31
35
 
36
+ /** how long an Accept waits for the engine's answer (it re-runs the mechanical close stages) before saying it is still checking (ms) */
37
+ export const DEFAULT_ACCEPT_WAIT_MS = 5 * 60_000;
38
+
32
39
  /**
33
40
  * The next free `resolved-spec-r<k>.json` name in `dir` (k from 1) — never an existing one.
34
41
  * @param {string} dir
@@ -55,6 +62,59 @@ function readLogTail(file) {
55
62
  } catch { return ''; }
56
63
  }
57
64
 
65
+ /**
66
+ * RULED 2026-10-16: the person never sees raw engine output. Of the engine's log tail, only a line it
67
+ * owns as a plain sentence passes through (a `Nothing spent.` / `Nothing was spent.` refusal, `DOOR REFUSED (...) — nothing was
68
+ * recorded...`, `CHECKPOINT EXPIRED — ...`); anything else (validator reds, exit statuses, stack traces, argv) becomes ONE fixed
69
+ * sentence naming the log. The one owner for the Resume and Accept routes.
70
+ * @param {string} tail the log tail
71
+ * @param {'resume'|'accept'|'rerun'|'pause'} kind
72
+ * @param {string} logFile
73
+ * @param {(stage: string) => string|null} [questionOf] stage name to the panel's plain question (`null` = none)
74
+ * @returns {string}
75
+ */
76
+ export function engineRefusalWords(tail, kind, logFile, questionOf = () => null) {
77
+ const known = /(?:\bNothing (?:was )?spent\.|^DOOR REFUSED \(.*\) — nothing was recorded|^CHECKPOINT EXPIRED — )/;
78
+ const raw = /validateJob|red\(s\)|exit status|node-\d|\bat \S+:\d+|\s--[a-z]|^\s*at |Error:/;
79
+ for (const line of String(tail ?? '').split('\n').map((l) => l.trim())) {
80
+ if (line !== '' && known.test(line) && !raw.test(line)) {
81
+ // RULED 2026-10-16 (A): a refused Accept names each failed check by the panel's own plain question
82
+ const asked = kind === 'accept' ? doorRedStages(tail).map((n) => questionOf(n)).filter((q) => typeof q === 'string' && q !== '') : [];
83
+ if (asked.length > 0) {
84
+ const shown = asked.slice(0, 3).map((q) => `"${q}"`).join('; ');
85
+ const more = asked.length > 3 ? ` and ${asked.length - 3} more` : '';
86
+ return `Accept did not go through: the check ${shown}${more} said no. Nothing was recorded.`;
87
+ }
88
+ return line;
89
+ }
90
+ }
91
+ if (kind === 'resume') return `Resume did not start. Nothing was spent on it. The details are in ${logFile}.`;
92
+ if (kind === 'rerun') return `Rerun did not start. Nothing was spent on it. The details are in ${logFile}.`;
93
+ if (kind === 'pause') return `Pause did not go through. Nothing was recorded. The details are in ${logFile}.`;
94
+ return `Accept did not go through. Nothing was recorded. The details are in ${logFile}.`;
95
+ }
96
+
97
+ /**
98
+ * The stage names a door-accept-red refusal failed on, read from the engine's own refusal lines (src/userrun.js):
99
+ * the `door-accept-red:stage.<name> — ...` line and the `<name> <verdict>` lines of "the mechanical re-run, stage by stage"
100
+ * whose verdict is not `satisfied`. Names only — the caller maps them to the panel's plain questions.
101
+ * @param {string} tail
102
+ * @returns {string[]}
103
+ */
104
+ function doorRedStages(tail) {
105
+ /** @type {string[]} */
106
+ const names = [];
107
+ let inList = false;
108
+ for (const line of String(tail ?? '').split('\n')) {
109
+ const red = /^\s*door-accept-red:stage\.(.+?) — /.exec(line);
110
+ if (red) { names.push(red[1]); continue; }
111
+ if (/^\s*the mechanical re-run, stage by stage:/.test(line)) { inList = true; continue; }
112
+ const row = inList ? /^\s{4}(\S.*?)\s{2,}(\S+)\s*$/.exec(line) : null;
113
+ if (row && row[2] !== 'satisfied') names.push(row[1]);
114
+ }
115
+ return [...new Set(names)];
116
+ }
117
+
58
118
  /**
59
119
  * The run routes' handler. `getResumeContext(runid)` is the server's own reader (the same
60
120
  * `resumePlanFor` the Ended block uses, so the page never offers what this refuses):
@@ -62,7 +122,11 @@ function readLogTail(file) {
62
122
  * @param {{ port: number, token: string, env?: Record<string,string|undefined>, home?: string,
63
123
  * spawnFn?: typeof realSpawn, bareloopBin?: string, settleMs?: number,
64
124
  * getResumeContext: (runid: string) => ({row: any, plan: any}|null),
65
- * getStopContext?: (runid: string) => ({row: any, live: boolean, spineExists: boolean}|null) }} opts
125
+ * getStopContext?: (runid: string) => ({row: any, live: boolean, spineExists: boolean}|null),
126
+ * getNeedsContext?: (runid: string) => ({row: any, needs: 'Accept'|'Resume'|null}|null),
127
+ * getAcceptContext?: (runid: string) => ({row: any, live: boolean, waiting: boolean, specPath: string|null, specHash: string|null}|null),
128
+ * stageQuestionsOfSpec?: (spec: any) => Map<string, string|null>,
129
+ * acceptWaitMs?: number }} opts
66
130
  */
67
131
  export function createRunRoutes(opts) {
68
132
  const envNow = () => keysForDoor({ env: opts.env, keysHome: opts.home }).env;
@@ -98,6 +162,109 @@ export function createRunRoutes(opts) {
98
162
  return true;
99
163
  }
100
164
 
165
+ /**
166
+ * T Part 8 + PRD v1.107 Part 6 — the person's answer to a run waiting at its REVIEW DOOR: `accept`, `rerun` (with their words) or `pause`, recorded through
167
+ * the library's own door path and nothing else: `run-u --spec <signed spec> --door <runid> --decide <accept|rerun|pause> [--text …] --approve <hash>`
168
+ * (src/userrun.js, `answerReviewDoor`, src/reviewdoor.js). The panel invents no verdict path: the engine re-runs the MECHANICAL
169
+ * stages against the tree as it stands for an accept (a red there is `door-accept-red` and refuses), and only then is the disposition written
170
+ * to the run's own spine. The verdict the run minted is untouched. The engine's own words are what the page shows.
171
+ * An accept and a pause end when the engine does; a RERUN launches a fresh engagement (a paid run), so it answers like Resume: started, or the engine's refusal.
172
+ * @param {import('node:http').IncomingMessage} req
173
+ * @param {(code: number, obj: any) => void} send
174
+ * @param {string} runid
175
+ * @param {'accept'|'rerun'|'pause'} decide
176
+ * @param {any} body the request body (`text` for a rerun)
177
+ * @returns {boolean}
178
+ */
179
+ function handleDoor(req, send, runid, decide, body) {
180
+ const guard = checkHumanGuard(req, { token: opts.token, port: opts.port });
181
+ if (!guard.ok) { send(403, { ok: false, error: `refused — ${guard.reason}` }); return true; }
182
+ if (req.method !== 'POST') { send(405, { ok: false, error: 'POST only' }); return true; }
183
+ const ctx = opts.getAcceptContext ? opts.getAcceptContext(runid) : null;
184
+ if (!ctx) { send(404, { ok: false, error: 'no such run' }); return true; }
185
+ if (ctx.live) { send(409, { ok: false, error: decide === 'accept' ? 'This run is still running, so there is nothing to accept yet.' : `This run is still running, so there is nothing to ${decide} yet.` }); return true; }
186
+ if (!ctx.waiting) { send(409, { ok: false, error: decide === 'accept' ? 'This run is not waiting for your Accept (it opened no review door, the door was already answered, or it has expired).' : `This run is not waiting for your answer, so it cannot be ${decide === 'rerun' ? 'rerun' : 'paused'} (it opened no review door, the door was already answered, or it has expired).` }); return true; }
187
+ // a rerun's words ARE the gap the worker is given: empty words are refused here in plain words (the engine refuses them too, src/kinds.js normalizeHumanRuling)
188
+ const text = decide === 'rerun' && typeof body?.text === 'string' ? body.text.trim() : '';
189
+ if (decide === 'rerun' && text === '') { send(400, { ok: false, error: 'Write what you want done differently, then press Rerun. Your words are what the next attempt is given.' }); return true; }
190
+ if (!ctx.specPath || !ctx.specHash) { send(409, { ok: false, error: "The signed job this run was judged by cannot be found on disk, so its door cannot be answered from here. Nothing was recorded." }); return true; }
191
+ // RULED 2026-10-15: an old run whose saved job fails today's checks is refused at $0 in plain words, before any spawn
192
+ /** @type {any} */
193
+ let signed = null;
194
+ try { signed = JSON.parse(readFileSync(ctx.specPath, 'utf8')); } catch { signed = null; }
195
+ if (signed !== null) {
196
+ let jv;
197
+ try { jv = validateJob(signed, { shellCapUsd: signed.budgetUsd }); } catch { jv = { ok: false, reds: [] }; }
198
+ if (!jv.ok) {
199
+ const words = decide === 'accept' ? OLD_JOB_ACCEPT_WORDS : (decide === 'rerun' ? OLD_JOB_RERUN_WORDS : OLD_JOB_PAUSE_WORDS);
200
+ send(409, { ok: false, error: words, reds: jv.reds.map((r) => `${r.code} at ${r.path}`) });
201
+ return true;
202
+ }
203
+ }
204
+ const questionOf = (/** @type {string} */ n) => (signed !== null && opts.stageQuestionsOfSpec ? opts.stageQuestionsOfSpec(signed).get(n) ?? null : null);
205
+ const dir = dirname(ctx.specPath);
206
+ const logFile = join(dir, `${decide}-${runid}-${Date.now()}.log`);
207
+ const args = [
208
+ '--wait', 'systemd-inhibit', `--why=bareloop panel ${decide}`,
209
+ process.execPath, bareloopBin,
210
+ 'run-u', '--spec', ctx.specPath, '--door', runid, '--decide', decide, '--approve', ctx.specHash,
211
+ // last, so no flag lookup in the engine can land on the person's words
212
+ ...(decide === 'rerun' ? ['--text', text] : []),
213
+ ];
214
+ /** @type {number|null} */
215
+ let logFd = null;
216
+ try { logFd = openSync(logFile, 'a'); } catch { logFd = null; }
217
+ const child = spawnFn('setsid', args, { detached: true, stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'], env: envNow() });
218
+ if (logFd !== null) { try { closeSync(logFd); } catch { /* the child holds its own copy */ } }
219
+ if (typeof child?.unref === 'function') child.unref();
220
+ let done = false;
221
+ const finish = (/** @type {number} */ code, /** @type {any} */ obj) => { if (!done) { done = true; send(code, obj); } };
222
+ const answered = { accept: { accepted: true }, rerun: { rerunning: true }, pause: { paused: true } }[decide];
223
+ // an accept that outlasts the wait is still going: say so (202), never claim it passed. A rerun is a run: it is "started" once it outlives the settle window.
224
+ const timer = setTimeout(
225
+ () => (decide === 'rerun' ? finish(200, { ok: true, ...answered, runid, log: logFile }) : finish(202, { ok: true, pending: true, runid, log: logFile })),
226
+ decide === 'rerun' ? settleMs : (opts.acceptWaitMs ?? DEFAULT_ACCEPT_WAIT_MS),
227
+ );
228
+ if (typeof timer.unref === 'function') timer.unref();
229
+ if (typeof child?.once === 'function') {
230
+ child.once('exit', (/** @type {number|null} */ code) => {
231
+ clearTimeout(timer);
232
+ if (code === 0) finish(200, { ok: true, ...answered, runid, log: logFile });
233
+ else finish(409, { ok: false, error: engineRefusalWords(readLogTail(logFile), decide, logFile, questionOf), log: logFile });
234
+ });
235
+ child.once('error', (/** @type {Error} */ e) => {
236
+ clearTimeout(timer);
237
+ finish(500, { ok: false, error: `could not start the engine: ${e.message}` });
238
+ });
239
+ }
240
+ return true;
241
+ }
242
+
243
+ /**
244
+ * RULED 2026-10-15 — `POST /api/runs/:runid/needs-remove`. Takes a stopped run's card out of Needs you and does nothing else: ONE
245
+ * `needs-removed` row appended to runs.jsonl (status, the run's row, Resume and Reuse workflow are untouched). Refused (409) unless the run
246
+ * is in Needs you with the Resume tag right now; a run waiting for the person's Accept is never removable. A second click finds it already out.
247
+ * @param {import('node:http').IncomingMessage} req
248
+ * @param {(code: number, obj: any) => void} send
249
+ * @param {string} runid
250
+ * @returns {boolean}
251
+ */
252
+ function handleNeedsRemove(req, send, runid) {
253
+ const guard = checkHumanGuard(req, { token: opts.token, port: opts.port });
254
+ if (!guard.ok) { send(403, { ok: false, error: `refused — ${guard.reason}` }); return true; }
255
+ if (req.method !== 'POST') { send(405, { ok: false, error: 'POST only' }); return true; }
256
+ const ctx = opts.getNeedsContext ? opts.getNeedsContext(runid) : null;
257
+ if (!ctx) { send(404, { ok: false, error: 'no such run' }); return true; }
258
+ if (ctx.needs === 'Accept') { send(409, { ok: false, error: 'This run is waiting for your Accept, so it stays in Needs you until you answer it.' }); return true; }
259
+ if (ctx.needs !== 'Resume') { send(409, { ok: false, error: 'This run is not in Needs you, so there is nothing to remove.' }); return true; }
260
+ try { appendNeedsRemoved(runid, { home: opts.home }); } catch (e) {
261
+ send(500, { ok: false, error: `could not record it: ${/** @type {Error} */ (e).message}` });
262
+ return true;
263
+ }
264
+ send(200, { ok: true, runid, removed: true });
265
+ return true;
266
+ }
267
+
101
268
  /**
102
269
  * @param {import('node:http').IncomingMessage} req
103
270
  * @param {import('node:http').ServerResponse} res
@@ -108,13 +275,21 @@ export function createRunRoutes(opts) {
108
275
  function handle(req, res, pathname, body) {
109
276
  const stopM = /^\/api\/runs\/([A-Za-z0-9._~-]+)\/stop$/.exec(pathname);
110
277
  const m = /^\/api\/runs\/([A-Za-z0-9._~-]+)\/resume$/.exec(pathname);
111
- if (!m && !stopM) return false;
278
+ const acceptM = /^\/api\/runs\/([A-Za-z0-9._~-]+)\/accept$/.exec(pathname);
279
+ const rerunM = /^\/api\/runs\/([A-Za-z0-9._~-]+)\/rerun$/.exec(pathname);
280
+ const pauseM = /^\/api\/runs\/([A-Za-z0-9._~-]+)\/pause$/.exec(pathname);
281
+ const needsM = /^\/api\/runs\/([A-Za-z0-9._~-]+)\/needs-remove$/.exec(pathname);
282
+ if (!m && !stopM && !acceptM && !needsM && !rerunM && !pauseM) return false;
112
283
  const send = (/** @type {number} */ code, /** @type {any} */ obj) => {
113
284
  const text = JSON.stringify(obj);
114
285
  res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(text) });
115
286
  res.end(text);
116
287
  };
117
288
  if (stopM) return handleStop(req, send, stopM[1]);
289
+ if (acceptM) return handleDoor(req, send, acceptM[1], 'accept', body);
290
+ if (rerunM) return handleDoor(req, send, rerunM[1], 'rerun', body);
291
+ if (pauseM) return handleDoor(req, send, pauseM[1], 'pause', body);
292
+ if (needsM) return handleNeedsRemove(req, send, needsM[1]);
118
293
  if (!m) return false;
119
294
  const guard = checkHumanGuard(req, { token: opts.token, port: opts.port });
120
295
  if (!guard.ok) { send(403, { ok: false, error: `refused — ${guard.reason}` }); return true; }
@@ -125,6 +300,13 @@ export function createRunRoutes(opts) {
125
300
  const { plan } = ctx;
126
301
  if (!plan.ok) { send(409, { ok: false, error: `Resume is not available for this run (${plan.why}).` }); return true; }
127
302
 
303
+ // RULED 2026-10-16: like Accept, an old run whose saved job fails today's checks is refused at $0 in plain words, before any spawn
304
+ {
305
+ let jv;
306
+ try { jv = validateJob(plan.spec, { shellCapUsd: plan.spec.budgetUsd }); } catch { jv = { ok: false, reds: [] }; }
307
+ if (!jv.ok) { send(409, { ok: false, error: OLD_JOB_RESUME_WORDS, reds: jv.reds.map((r) => `${r.code} at ${r.path}`) }); return true; }
308
+ }
309
+
128
310
  // the caps: blank/absent = the signed ones. A raised (or lowered) cap is a new spec.
129
311
  /** @type {any} */
130
312
  const spec = { ...plan.spec };
@@ -142,7 +324,7 @@ export function createRunRoutes(opts) {
142
324
  // tighten-only: a cap at or below what the run has ALREADY used buys an immediate re-halt
143
325
  // (the engine only warns), so it is refused here at $0, before anything is written or spawned
144
326
  if (typeof plan.spentUsd === 'number' && spec.budgetUsd <= plan.spentUsd) {
145
- send(400, { ok: false, error: `The money cap must be above what is already spent (${plan.spendComplete ? '' : 'at least '}$${plan.spentUsd.toFixed(2)}) — raise it, then Resume.` });
327
+ send(400, { ok: false, error: `The money cap must be above what is already spent (${plan.spendComplete ? '' : 'at least '}${panelMoney2(plan.spentUsd)}) — raise it, then Resume.` });
146
328
  return true;
147
329
  }
148
330
  if (typeof plan.wallUsedMs === 'number' && typeof spec.maxWallMs === 'number' && spec.maxWallMs <= plan.wallUsedMs) {
@@ -207,7 +389,7 @@ export function createRunRoutes(opts) {
207
389
  ok: true, runid: m[1], specHash, capsChanged: changed, log: logFile,
208
390
  });
209
391
  } else {
210
- finish(409, { ok: false, error: readLogTail(logFile) || `the engine refused to resume (exit ${code}) and left no message`, log: logFile });
392
+ finish(409, { ok: false, error: engineRefusalWords(readLogTail(logFile), 'resume', logFile), log: logFile });
211
393
  }
212
394
  });
213
395
  child.once('error', (/** @type {Error} */ e) => {
@@ -0,0 +1,156 @@
1
+ // T Part 5 (PANEL-BUILD.md T addendum, C.5): the panel's scheduler. The panel server runs the clock and the folder watch
2
+ // while it is up; with the panel off nothing fires (T6), and slots missed collapse to ONE catch-up when it comes back.
3
+ //
4
+ // ONE `tick()` does the sync work; the tests call it by hand with an injected clock and await what it returns (the fires
5
+ // it started). The real timer only calls it.
6
+ // - 500 ms: the folder watch needs a file's size and mtime to hold for STABLE_POLLS (2) polls, so a drop is seen about
7
+ // 1.5 s after it stops changing. Poll only, never fs.watch (EMFILE on hamr's machine, and it misses atomic renames).
8
+ // - the timer is unref'd, `start`/`stop` are idempotent, the server's close calls `stop`.
9
+ // - with no schedule on disk a tick is one readdir and nothing else (no runs.jsonl read).
10
+ // What a tick does for one job with a signed schedule:
11
+ // 1. arm it if it is not armed under this signing (a crash between the sign and the arm);
12
+ // 2. clock kinds: `due`; a due slot goes to `door.fire` and is NOT consumed up front: the starter's `beforeSpawn` writes
13
+ // the `fire` row only after busy, monthly, wouldAccept and the session all said yes, so a refused slot retries;
14
+ // 3. file kinds: `poll`; each file taken is recorded `arrived` (THIS is the only writer of `arrived` rows), then the
15
+ // queue runs: busy tells each queued file once and waits; free, the head is looked at again (deleted or changed is
16
+ // dropped with a reason, the next head is tried) and goes through `door.fire`; `ran` is written by `beforeSpawn` only.
17
+ // One job's exception is written once as a `refused` row and never stops the others (fwd scheduler.js:94-110).
18
+ // BUSY IS CHEAP (T19): the run list is read at most ONCE per tick, and only when a fire or a queued head needs it.
19
+ // Rewritten from fwdloop src/panel/scheduler.js.
20
+ import { due } from '../triggerclock.js';
21
+ import { isClockTrigger, isFileTrigger } from '../trigger.js';
22
+ import { appendRow, readLedger } from '../triggerledger.js';
23
+ import {
24
+ createFileTrigger, queueOf, readSeen, recheck, recordArrived, recordDropped, recordRan,
25
+ } from '../triggerfiles.js';
26
+ import { recordFire } from '../triggerclock.js';
27
+
28
+ /**
29
+ * @param {{ door: ReturnType<typeof import('./schedules.js').createScheduleDoor>, now?: () => number, intervalMs?: number }} opts
30
+ * `intervalMs` 0 = no timer (a test drives `tick`).
31
+ */
32
+ export function createScheduler(opts) {
33
+ const { door, now = Date.now, intervalMs = 500 } = opts;
34
+ /** @type {Map<string, ReturnType<typeof createFileTrigger>>} */
35
+ const watchers = new Map();
36
+ /** `job|sig` keys already armed this process @type {Set<string>} */
37
+ const armedKeys = new Set();
38
+ /** jobs with a fire still awaiting its start (a headless prepare can take minutes): never fired a second time meanwhile @type {Set<string>} */
39
+ const inflight = new Set();
40
+ /** @type {ReturnType<typeof setInterval>|null} */
41
+ let timer = null;
42
+
43
+ /**
44
+ * The queue of a file trigger (arrived minus ran minus dropped, oldest first).
45
+ * @param {string} job @param {string} ledger @param {string} sig @param {number} nowMs @param {() => boolean} isBusy @param {Promise<unknown>[]} pending
46
+ */
47
+ function runQueue(job, ledger, sig, nowMs, isBusy, pending) {
48
+ let queue = queueOf(readSeen(ledger));
49
+ if (queue.length === 0 || inflight.has(job)) return;
50
+ const busy = isBusy();
51
+ if (busy) {
52
+ // every queued file is told ONCE (`busy`, once per sha) and waits
53
+ for (const q of queue) pending.push(door.fire(job, { kind: 'file', path: q.path, sha: q.sha, size: q.size }, { busy: true }));
54
+ return;
55
+ }
56
+ while (queue.length > 0) {
57
+ const q = queue[0];
58
+ const seen = recheck(q);
59
+ if (seen.state !== 'ok') {
60
+ const why = seen.state === 'deleted' ? 'deleted' : 'changed';
61
+ recordDropped(ledger, q, why, nowMs, sig);
62
+ appendRow(ledger, {
63
+ kind: why, sig, at: nowMs, sha: q.sha, file: q.path,
64
+ say: seen.state === 'deleted' ? `${q.name} did not run: ${seen.say}.` : `${q.name} did not run: it changed before its turn; the new version arrives again.`,
65
+ });
66
+ queue = queue.slice(1);
67
+ continue;
68
+ }
69
+ const item = { path: seen.path, sha: seen.sha, size: seen.size };
70
+ pending.push(startFire(job, { kind: 'file', ...item }, { busy: false, beforeSpawn: () => { recordRan(ledger, item, nowMs, sig); } }, sig));
71
+ return;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Fire and remember it is in flight until it settles. A throw is written once and never escapes.
77
+ * @param {string} job @param {Parameters<ReturnType<typeof import('./schedules.js').createScheduleDoor>['fire']>[1]} cause
78
+ * @param {{busy?: boolean, beforeSpawn?: () => void}} hooks @param {string} sig
79
+ */
80
+ function startFire(job, cause, hooks, sig) {
81
+ inflight.add(job);
82
+ return door.fire(job, cause, hooks)
83
+ .catch((e) => { door.tellOnce(job, sig, `fire|${/** @type {Error} */ (e).message}`, `The run could not start: ${/** @type {Error} */ (e).message}`); })
84
+ .finally(() => { inflight.delete(job); });
85
+ }
86
+
87
+ /** @returns {Promise<void>} resolves when every fire this tick started has settled */
88
+ function tick() {
89
+ const jobs = door.scan();
90
+ if (jobs.length === 0) { watchers.clear(); armedKeys.clear(); return Promise.resolve(); }
91
+ const nowMs = now();
92
+ /** @type {Promise<unknown>[]} */
93
+ const pending = [];
94
+ /** the ONE read of the run list this tick, taken the first time a job needs it @type {Map<string, boolean>} */
95
+ const busyMemo = new Map();
96
+ const live = new Set();
97
+ for (const { job, schedule } of jobs) {
98
+ const sig = schedule.specHash;
99
+ const key = `${job}|${sig}`;
100
+ live.add(key);
101
+ const isBusy = () => {
102
+ if (!busyMemo.has(job)) busyMemo.set(job, door.busy(job));
103
+ return /** @type {boolean} */ (busyMemo.get(job));
104
+ };
105
+ try {
106
+ if (!armedKeys.has(key)) { door.armFor(job, schedule); armedKeys.add(key); }
107
+ const ledger = door.ledgerOf(job);
108
+ const { trigger } = schedule;
109
+ if (isFileTrigger(trigger)) {
110
+ let w = watchers.get(key);
111
+ if (!w) {
112
+ w = createFileTrigger({
113
+ mode: trigger.kind === 'new-file' ? 'new' : 'updated', target: trigger.kind === 'new-file' ? trigger.folder : trigger.path, ledger, sig, now,
114
+ });
115
+ watchers.set(key, w);
116
+ }
117
+ const { arrived, refused } = w.poll();
118
+ for (const r of refused) door.tellOnce(job, sig, `refused|${r.path}|${r.why}`, `${r.path}: ${r.why}`);
119
+ // THE writer of `arrived`: the arrival time is this clock, never the file's mtime
120
+ for (const f of arrived) recordArrived(ledger, f, nowMs, sig);
121
+ // RULED 2026-10-15: a schedule that gave up is paused (read from the ledger): files still queue as `arrived`, none runs
122
+ if (!door.paused(job)) runQueue(job, ledger, sig, nowMs, isBusy, pending);
123
+ } else if (isClockTrigger(trigger)) {
124
+ if (inflight.has(job) || door.paused(job)) continue;
125
+ const d = due(trigger, readLedger(ledger).rows, nowMs);
126
+ if (d.fire) {
127
+ pending.push(startFire(job, { kind: 'clock', slot: /** @type {number} */ (d.slot), catchUp: d.catchUp, missed: d.missed },
128
+ { busy: isBusy(), beforeSpawn: () => { recordFire(ledger, d, nowMs, sig); } }, sig));
129
+ }
130
+ }
131
+ } catch (e) {
132
+ const why = /** @type {Error} */ (e).message;
133
+ try { door.tellOnce(job, sig, `tick|${why}`, why); } catch { /* the ledger itself is unwritable: nothing more to do for this job */ }
134
+ }
135
+ }
136
+ for (const k of [...watchers.keys()]) if (!live.has(k)) watchers.delete(k);
137
+ for (const k of [...armedKeys]) if (!live.has(k)) armedKeys.delete(k);
138
+ return Promise.all(pending).then(() => undefined);
139
+ }
140
+
141
+ return {
142
+ tick,
143
+ /** Start the real timer (idempotent). */
144
+ start() {
145
+ if (intervalMs <= 0 || timer !== null) return;
146
+ timer = setInterval(() => { tick().catch(() => { /* one bad tick never stops the next */ }); }, intervalMs);
147
+ timer.unref();
148
+ },
149
+ /** Stop it (idempotent). The server's close calls this. */
150
+ stop() {
151
+ if (timer !== null) { clearInterval(timer); timer = null; }
152
+ },
153
+ /** whether the real timer is running (a test reads this) */
154
+ get running() { return timer !== null; },
155
+ };
156
+ }