bareloop 0.32.0 → 0.33.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/CHANGELOG.md +79 -0
- package/README.md +4 -1
- package/bareloop.context.md +150 -77
- package/package.json +1 -1
- package/src/authorflow.js +5 -3
- package/src/authorjob.js +3 -1
- package/src/authorrun.js +36 -8
- package/src/authorscout.js +10 -6
- package/src/bundlerun.js +266 -0
- package/src/cli.js +24 -352
- package/src/interviewrun.js +3 -2
- package/src/invoke.js +22 -0
- package/src/judged.js +5 -3
- package/src/monthly.js +92 -22
- package/src/panel/authorsession.js +24 -4
- package/src/planrun.js +7 -3
- package/src/providerrows.js +85 -1
- package/src/run.js +7 -2
- package/src/runlist.js +79 -7
- package/src/source.js +8 -3
- package/src/u-watchdog.mjs +296 -0
- package/src/userrun.js +186 -64
- package/types/authorflow.d.ts +8 -2
- package/types/authorjob.d.ts +6 -1
- package/types/authorscout.d.ts +11 -2
- package/types/bundlerun.d.ts +26 -0
- package/types/invoke.d.ts +8 -0
- package/types/judged.d.ts +5 -1
- package/types/monthly.d.ts +43 -8
- package/types/planrun.d.ts +13 -1
- package/types/providerrows.d.ts +65 -0
- package/types/run.d.ts +14 -1
- package/types/runlist.d.ts +65 -3
- package/types/source.d.ts +8 -3
- package/types/userrun.d.ts +49 -0
package/src/monthly.js
CHANGED
|
@@ -8,21 +8,22 @@
|
|
|
8
8
|
// The limit REFUSES, never warns; the page and the chat can never raise it (a person
|
|
9
9
|
// edits it by hand in Settings — src/config.js).
|
|
10
10
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
11
|
+
// HOLD UNTIL DONE (hamr 2026-09-30): a run CLAIMS its whole leg cap when it starts (`claimRun`: append
|
|
12
|
+
// the run's row FIRST, carrying its `pid` and `capUsd`, then read the list — only the claims ABOVE its row
|
|
13
|
+
// count, so whoever runs first holds) and holds it until it is done. While the row's pid is a live bareloop runner the
|
|
14
|
+
// run is counted at max(spend so far, capUsd) however quiet its spine; a run with a `job-end` counts
|
|
15
|
+
// its real spend; a claim whose process is gone counts its floor and is settled — with an attributed
|
|
16
|
+
// `settled` entry — by whichever later run finds it (the first note wins). An older row with no `pid` keeps the spine
|
|
17
|
+
// mtime rule (a fresh spine, written to within DIED_MTIME_MS, is in flight). Only the refusal check
|
|
18
|
+
// reserves; the Money tab keeps showing real spend (`usd`).
|
|
18
19
|
//
|
|
19
20
|
// Honesty: a run whose spend is not fully known (a died/still-running spine, an unpriced
|
|
20
21
|
// round, a missing spine file) makes the month total an "at least" figure — never a clean
|
|
21
22
|
// number. The refusal text stays exactly `Max $X (monthly limit)`; `atLeast` travels beside it.
|
|
22
23
|
import { existsSync, statSync } from 'node:fs';
|
|
23
|
-
import { resolve } from 'node:path';
|
|
24
24
|
import { readConfig, configPath, ConfigError } from './config.js';
|
|
25
|
-
import { readRunList, DIED_MTIME_MS } from './runlist.js';
|
|
25
|
+
import { readRunList, appendRun, appendRunEvent, isLiveRunner, DIED_MTIME_MS } from './runlist.js';
|
|
26
|
+
export { isLiveRunner };
|
|
26
27
|
import { parseJsonl } from './replayio.js';
|
|
27
28
|
import { SPEND_RECORD_TYPES, spendProvenance, floorsFromRecords } from './ledger.js';
|
|
28
29
|
import { findRow } from './providerrows.js';
|
|
@@ -161,25 +162,44 @@ function inFlightCapUsd(records, spinePath, nowMs, usd) {
|
|
|
161
162
|
return Math.max(usd, Math.max(0, start.budgetUsd - fold));
|
|
162
163
|
}
|
|
163
164
|
|
|
165
|
+
/**
|
|
166
|
+
* The rows listed EARLIER in the run list than `runid`'s own row (file order is claim order). No
|
|
167
|
+
* `runid`, or a runid not in the list, = every row.
|
|
168
|
+
* @param {import('./runlist.js').RunRow[]} rows
|
|
169
|
+
* @param {string} [runid]
|
|
170
|
+
* @returns {import('./runlist.js').RunRow[]}
|
|
171
|
+
*/
|
|
172
|
+
function rowsAbove(rows, runid) {
|
|
173
|
+
if (runid === undefined) return rows;
|
|
174
|
+
const i = rows.findIndex((r) => r.runid === runid);
|
|
175
|
+
return i < 0 ? rows : rows.slice(0, i);
|
|
176
|
+
}
|
|
177
|
+
|
|
164
178
|
/**
|
|
165
179
|
* Every listed run as one {@link Leg}. The ONE reader the month total, the all-time total
|
|
166
180
|
* and the per-provider figures share.
|
|
167
|
-
* @param {{ home?: string, now?: () => number,
|
|
181
|
+
* @param {{ home?: string, now?: () => number, aboveRunid?: string }} [opts] `now` = the clock for the no-pid in-flight test; `aboveRunid` = read only the runs listed EARLIER in the file than that run (a claim yields to the claims above it, never below)
|
|
168
182
|
* @returns {Leg[]}
|
|
169
183
|
*/
|
|
170
184
|
export function readLegs(opts = {}) {
|
|
171
185
|
const nowMs = (opts.now ?? Date.now)();
|
|
172
|
-
const { rows } = readRunList({ home: opts.home });
|
|
173
|
-
const
|
|
186
|
+
const { rows: allRows, events } = readRunList({ home: opts.home });
|
|
187
|
+
const rows = rowsAbove(allRows, opts.aboveRunid);
|
|
188
|
+
const settled = new Set(events.filter((e) => e.type === 'settled').map((e) => e.runid));
|
|
174
189
|
/** @type {Leg[]} */
|
|
175
190
|
const legs = [];
|
|
176
191
|
for (const row of rows) {
|
|
177
192
|
const at = new Date(row.at);
|
|
178
193
|
const base = { at, provider: null, baseUrl: null, model: null, wallMs: null, wallComplete: false, usd: 0, complete: false, tokens: 0, vouchedRounds: 0, otherRounds: 0, reservedUsd: 0, unreadable: true };
|
|
179
|
-
|
|
194
|
+
// Rule 2 (hold-until-done): a row that carries its runner's `pid` is HELD at its cap while that
|
|
195
|
+
// pid is a live bareloop runner, however quiet its spine — killed = process gone, not silence.
|
|
196
|
+
// An older row with no `pid` keeps the spine-mtime rule (`inFlightCapUsd`), never guessed alive.
|
|
197
|
+
const hasPid = Number.isInteger(row.pid);
|
|
198
|
+
const heldCap = hasPid && !settled.has(row.runid) && typeof row.capUsd === 'number' && Number.isFinite(row.capUsd) && isLiveRunner(/** @type {number} */ (row.pid)) ? row.capUsd : null;
|
|
199
|
+
if (Number.isNaN(at.getTime()) || !existsSync(row.spine)) { legs.push({ ...base, reservedUsd: heldCap ?? 0 }); continue; }
|
|
180
200
|
/** @type {any[]} */
|
|
181
201
|
let records;
|
|
182
|
-
try { records = parseJsonl(row.spine).records; } catch { legs.push(base); continue; }
|
|
202
|
+
try { records = parseJsonl(row.spine).records; } catch { legs.push({ ...base, reservedUsd: heldCap ?? 0 }); continue; }
|
|
183
203
|
const start = records.find((r) => r && r.type === 'job-start') ?? null;
|
|
184
204
|
const leg = legSpend(records);
|
|
185
205
|
const prov = spendProvenance(records);
|
|
@@ -196,7 +216,8 @@ export function readLegs(opts = {}) {
|
|
|
196
216
|
tokens: legTokens(records),
|
|
197
217
|
vouchedRounds: prov.vouched.rounds,
|
|
198
218
|
otherRounds: prov.guessed.rounds + prov.unpriced.rounds + prov.unknown.rounds,
|
|
199
|
-
reservedUsd:
|
|
219
|
+
reservedUsd: hasPid ? (records.some((r) => r && r.type === 'job-end') || heldCap === null ? leg.usd : Math.max(leg.usd, heldCap))
|
|
220
|
+
: inFlightCapUsd(records, row.spine, nowMs, leg.usd),
|
|
200
221
|
unreadable: false,
|
|
201
222
|
});
|
|
202
223
|
}
|
|
@@ -211,7 +232,7 @@ export function readLegs(opts = {}) {
|
|
|
211
232
|
const sameLocalMonth = (at, now) => at.getFullYear() === now.getFullYear() && at.getMonth() === now.getMonth();
|
|
212
233
|
|
|
213
234
|
/**
|
|
214
|
-
* @param {{ home?: string, now?: () => number,
|
|
235
|
+
* @param {{ home?: string, now?: () => number, aboveRunid?: string }} [opts] `aboveRunid`: see {@link readLegs}
|
|
215
236
|
* @returns {{ usd: number, atLeast: boolean, runs: number, reservedUsd: number }} `usd` = real spend (the Money tab's figure); `reservedUsd` = the same with in-flight runs at their cap (the refusal check's)
|
|
216
237
|
*/
|
|
217
238
|
export function monthSpend(opts = {}) {
|
|
@@ -220,12 +241,12 @@ export function monthSpend(opts = {}) {
|
|
|
220
241
|
let reservedUsd = 0;
|
|
221
242
|
let atLeast = false;
|
|
222
243
|
let runs = 0;
|
|
223
|
-
for (const leg of readLegs({ home: opts.home, now: opts.now,
|
|
244
|
+
for (const leg of readLegs({ home: opts.home, now: opts.now, aboveRunid: opts.aboveRunid })) {
|
|
224
245
|
// an unreadable date could belong to this month — unknown, never dropped
|
|
225
246
|
if (Number.isNaN(leg.at.getTime())) { atLeast = true; continue; }
|
|
226
247
|
if (!sameLocalMonth(leg.at, nowDate)) continue;
|
|
227
248
|
runs += 1;
|
|
228
|
-
if (leg.unreadable) { atLeast = true; continue; }
|
|
249
|
+
if (leg.unreadable) { atLeast = true; reservedUsd += leg.reservedUsd; continue; }
|
|
229
250
|
usd += leg.usd;
|
|
230
251
|
reservedUsd += leg.reservedUsd;
|
|
231
252
|
if (!leg.complete) atLeast = true;
|
|
@@ -321,21 +342,21 @@ export function monthlyLimitOf(config, home) {
|
|
|
321
342
|
}
|
|
322
343
|
|
|
323
344
|
/**
|
|
324
|
-
* Does a run with this $ cap fit in what is left this month (
|
|
345
|
+
* Does a run with this $ cap fit in what is left this month (a run still held counts at its full cap)? No limit set (key absent or null) = ok, no check; a limit that is present but not a
|
|
325
346
|
* number above 0 throws `ConfigError`.
|
|
326
347
|
* Compared in whole cents. Also throws `ConfigError` when config.json is unreadable — a gate
|
|
327
348
|
* whose own instrument is broken refuses; it never silently runs with no limit.
|
|
328
|
-
* @param {{ capUsd: number, home?: string, now?: () => number,
|
|
349
|
+
* @param {{ capUsd: number, home?: string, now?: () => number, aboveRunid?: string }} args `aboveRunid`: count only the claims listed above that run (a claim in progress)
|
|
329
350
|
* @returns {MonthlyRoom}
|
|
330
351
|
*/
|
|
331
|
-
export function checkMonthlyRoom({ capUsd, home, now,
|
|
352
|
+
export function checkMonthlyRoom({ capUsd, home, now, aboveRunid }) {
|
|
332
353
|
const cfg = readConfig({ home });
|
|
333
354
|
if (cfg.problem) throw new ConfigError(cfg.problem);
|
|
334
355
|
const limit = monthlyLimitOf(cfg.config, home);
|
|
335
356
|
if (limit === null) {
|
|
336
357
|
return { ok: true, leftUsd: null, limitUsd: null, atLeast: false };
|
|
337
358
|
}
|
|
338
|
-
const spent = monthSpend({ home, now,
|
|
359
|
+
const spent = monthSpend({ home, now, aboveRunid });
|
|
339
360
|
const leftCents = Math.max(0, Math.floor((limit - spent.reservedUsd) * 100 + 1e-6));
|
|
340
361
|
const capCents = Math.ceil((Number.isFinite(capUsd) ? capUsd : 0) * 100 - 1e-6);
|
|
341
362
|
return { ok: capCents <= leftCents, leftUsd: leftCents / 100, limitUsd: limit, atLeast: spent.atLeast };
|
|
@@ -350,3 +371,52 @@ export function monthlyRefusalText(room) {
|
|
|
350
371
|
if (room.ok || room.leftUsd === null) return null;
|
|
351
372
|
return `Max $${room.leftUsd.toFixed(2)} (monthly limit)`;
|
|
352
373
|
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Close every claim ABOVE `aboveRunid` (file order) whose process is gone: a pid-bearing run row with no
|
|
377
|
+
* `settled` entry whose pid is not a live bareloop runner gets `{ type: 'settled', by: <closer>, reason:
|
|
378
|
+
* 'process gone', spentUsd: <its spine's floor>, spendComplete: false }` appended — so every claim gets a
|
|
379
|
+
* write-back, attributed. Two runs may both close the same dead claim; readers take the FIRST note.
|
|
380
|
+
* @param {{ home?: string, by: string, aboveRunid?: string }} args `by` = the run doing the closing
|
|
381
|
+
* @returns {number} how many notes were appended
|
|
382
|
+
*/
|
|
383
|
+
export function settleDeadClaims({ home, by, aboveRunid }) {
|
|
384
|
+
const { rows, events } = readRunList({ home });
|
|
385
|
+
const settled = new Set(events.filter((e) => e.type === 'settled').map((e) => e.runid));
|
|
386
|
+
let n = 0;
|
|
387
|
+
for (const row of rowsAbove(rows, aboveRunid)) {
|
|
388
|
+
if (!Number.isInteger(row.pid) || settled.has(row.runid) || isLiveRunner(/** @type {number} */ (row.pid))) continue;
|
|
389
|
+
let spentUsd = 0;
|
|
390
|
+
try { spentUsd = legSpend(parseJsonl(row.spine).records).usd; } catch { /* no spine: nothing was recorded spent */ }
|
|
391
|
+
appendRunEvent({ runid: row.runid, type: 'settled', by, reason: 'process gone', spentUsd, spendComplete: false, at: new Date().toISOString() }, { home });
|
|
392
|
+
n += 1;
|
|
393
|
+
}
|
|
394
|
+
return n;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The CLAIM (hamr 2026-09-30: whoever runs first claims the limit and holds it; no lock file, records
|
|
399
|
+
* only): the run APPENDS its row first (`row` carries its `pid` and `capUsd`), then reads the run list.
|
|
400
|
+
* Only the claims ABOVE its own row count — a live one is held at its cap (a dead one is closed with an
|
|
401
|
+
* attributed note and counts its real spend); rows below yield to it. If `capUsd` fits, it runs; if not
|
|
402
|
+
* it appends `{type:'released', reason:'refused'}` and reports the refusal, nothing spent. No limit set =
|
|
403
|
+
* no claim (`claimed: false`; the caller lists the run as it always did). An unreadable config, or a row
|
|
404
|
+
* that cannot be written, throws `ConfigError` — the gate refuses, never reads "no limit".
|
|
405
|
+
* @param {{ row: import('./runlist.js').RunRow, capUsd: number, home?: string, now?: () => number }} args
|
|
406
|
+
* @returns {{ room: MonthlyRoom, claimed: boolean }}
|
|
407
|
+
*/
|
|
408
|
+
export function claimRun({ row, capUsd, home, now }) {
|
|
409
|
+
const cfg = readConfig({ home });
|
|
410
|
+
if (cfg.problem) throw new ConfigError(cfg.problem);
|
|
411
|
+
if (monthlyLimitOf(cfg.config, home) === null) return { room: { ok: true, leftUsd: null, limitUsd: null, atLeast: false }, claimed: false };
|
|
412
|
+
try { appendRun(row, { home }); } catch (/** @type {any} */ e) {
|
|
413
|
+
throw new ConfigError(`cannot write this run's claim to the run list (${e.message})`);
|
|
414
|
+
}
|
|
415
|
+
settleDeadClaims({ home, by: row.runid, aboveRunid: row.runid });
|
|
416
|
+
const room = checkMonthlyRoom({ capUsd, home, now, aboveRunid: row.runid });
|
|
417
|
+
if (!room.ok) {
|
|
418
|
+
try { appendRunEvent({ runid: row.runid, type: 'released', by: row.runid, reason: 'refused', at: new Date().toISOString() }, { home }); } catch { /* the refusal stands; a stranded row of a dead process is closed by the next run */ }
|
|
419
|
+
return { room, claimed: false };
|
|
420
|
+
}
|
|
421
|
+
return { room, claimed: true };
|
|
422
|
+
}
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
// `src/panel/server.js`'s POST routes). Nothing here changes what a step
|
|
21
21
|
// means or what an answer does — only where the answer comes from.
|
|
22
22
|
|
|
23
|
-
import { applyConfiguredKey, keyNameFor, modelChoiceFor, rowsForHome } from '../providerrows.js';
|
|
23
|
+
import { applyConfiguredKey, judgeRatesFor, keyNameFor, modelChoiceFor, ratesFor, rowsForHome } from '../providerrows.js';
|
|
24
|
+
import { ConfigError } from '../config.js';
|
|
24
25
|
import {
|
|
25
26
|
mkdirSync, existsSync, writeFileSync, readdirSync,
|
|
26
27
|
} from 'node:fs';
|
|
@@ -310,6 +311,15 @@ export function createSession(card, deps = {}) {
|
|
|
310
311
|
const draftKeyName = keyNameFor(modelChoice.provider, modelChoice.baseUrl, keyCfg, modelChoice.name).name;
|
|
311
312
|
const apiKey = draftEnv[providerEntry.envKey];
|
|
312
313
|
if (!apiKey) { refuse(`${draftKeyName} not set. Stopped — nothing spent.`); return; }
|
|
314
|
+
// the customer's own price on this row (USD per 1M in config.json), resolved once here at $0 and
|
|
315
|
+
// handed to every model call this session makes; a bad price refuses, never falls back to the guess
|
|
316
|
+
/** @type {ReturnType<typeof ratesFor>} */
|
|
317
|
+
let draftPrice = null;
|
|
318
|
+
try { draftPrice = ratesFor(modelChoice.provider, modelChoice.baseUrl, keyCfg, modelChoice.name); } catch (e) {
|
|
319
|
+
if (!(e instanceof ConfigError)) throw e;
|
|
320
|
+
refuse(`${e.message}. Stopped — nothing spent.`);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
313
323
|
const keyProblem = apiKeyProblem(apiKey);
|
|
314
324
|
if (keyProblem) { refuse(`${draftKeyName} ${keyProblem}. Stopped — nothing spent.`); return; }
|
|
315
325
|
|
|
@@ -422,8 +432,8 @@ export function createSession(card, deps = {}) {
|
|
|
422
432
|
// `provider` for the model boundary) and/or `scout` (bypassing the real
|
|
423
433
|
// paid scout) so it can drive the REAL confirm turn/ask()/revise/hash
|
|
424
434
|
// machinery below with a deterministic fake, never a live provider call.
|
|
425
|
-
const generate = deps.generate ?? makeLoopGenerate(provider);
|
|
426
|
-
const confirmGenerate = deps.confirmGenerate ?? makeLoopGenerate(provider, { system: CONFIRM_SYSTEM });
|
|
435
|
+
const generate = deps.generate ?? makeLoopGenerate(provider, { rates: draftPrice?.rates ?? null });
|
|
436
|
+
const confirmGenerate = deps.confirmGenerate ?? makeLoopGenerate(provider, { system: CONFIRM_SYSTEM, rates: draftPrice?.rates ?? null });
|
|
427
437
|
|
|
428
438
|
state.phase = 'drafting';
|
|
429
439
|
state.progressLabel = 'drafting';
|
|
@@ -437,6 +447,7 @@ export function createSession(card, deps = {}) {
|
|
|
437
447
|
questions: questionsFor(verdictType),
|
|
438
448
|
writeScope, provider, generate,
|
|
439
449
|
ceilingUsd: card.capUsd,
|
|
450
|
+
rates: draftPrice?.rates ?? null,
|
|
440
451
|
onPhase, onCall,
|
|
441
452
|
ask, confirmGenerate, isRepo: true, langResult,
|
|
442
453
|
...(deps.scout ? { scout: deps.scout } : {}),
|
|
@@ -470,7 +481,16 @@ export function createSession(card, deps = {}) {
|
|
|
470
481
|
const judges = closeJudges(spec.closeDecl);
|
|
471
482
|
const judge = judges ? resolveJobJudge(spec, modelChoice.provider, resolveWorkerModel) : null;
|
|
472
483
|
let judgeProvider = null;
|
|
484
|
+
/** @type {ReturnType<typeof ratesFor>} */
|
|
485
|
+
let judgePrice = null;
|
|
473
486
|
if (judge) {
|
|
487
|
+
try {
|
|
488
|
+
judgePrice = judgeRatesFor(judge, modelChoice.provider, modelChoice.baseUrl, draftPrice, keyCfg);
|
|
489
|
+
} catch (e) {
|
|
490
|
+
if (!(e instanceof ConfigError)) throw e;
|
|
491
|
+
refuse(`${e.message} — refusing before gate 4 spends anything`);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
474
494
|
const judgeKeyValue = env.JUDGE_API_KEY ?? (judge.provider === modelChoice.provider ? draftEnv : applyConfiguredKey(env, judge.provider, undefined, keyCfg, judge.model))[resolveProvider(judge.provider).envKey];
|
|
475
495
|
const judgeKeyProblem = judgeKeyValue ? apiKeyProblem(judgeKeyValue) : null;
|
|
476
496
|
if (!judgeKeyValue || judgeKeyProblem) {
|
|
@@ -486,7 +506,7 @@ export function createSession(card, deps = {}) {
|
|
|
486
506
|
const signing = await prepareSigningFn({
|
|
487
507
|
spec, workdir: prep.tree, seedRef: authored.seedRef, timeoutMs,
|
|
488
508
|
shellCapUsd: spec.budgetUsd, ceilingUsd: card.capUsd, priorCalls: [...metered],
|
|
489
|
-
judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system }) : null,
|
|
509
|
+
judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgePrice?.rates ?? null }) : null,
|
|
490
510
|
judgeModel: judge?.model ?? null,
|
|
491
511
|
onJudgeCost: (c) => onCall({ label: `${c.label}:${c.id}`, costUsd: c.costUsd, unpricedRounds: c.unpricedRounds }),
|
|
492
512
|
});
|
package/src/planrun.js
CHANGED
|
@@ -762,6 +762,10 @@ ${scoutBlob || '(no scout notes)'}`;
|
|
|
762
762
|
* `false` → the drafter has no tools, so a native session would report NO cost — return a
|
|
763
763
|
* metered claude-json TEXT provider (`--output-format json`, `parse:'claude-json'`) instead,
|
|
764
764
|
* so its spend is never invisible. The Loop path (`anthropic-api`) never touches this.
|
|
765
|
+
* @param {{in: number, out: number}|null} [opts.rates] the customer's own price for the worker's key row (USD per
|
|
766
|
+
* 1K tokens, `ratesFor`) — handed to the one Loop that drives worker, scout and planner rounds, so they read
|
|
767
|
+
* `rateSource:'caller'`; null = bare-agent's built-in guess. Not applied to a native (CLI) session, which reports its own cost.
|
|
768
|
+
* @param {{in: number, out: number}|null} [opts.judgeRates] the same, for the judge's Loop
|
|
765
769
|
* @param {string|null} [opts.judgeModel] softgreen — WHICH model that judge provider drives
|
|
766
770
|
* (PRD item 32.1). Resolved by the CALLER (`resolveJudge`, src/judged.js: the spec's signed
|
|
767
771
|
* `judge` override, else the job's own worker model), never pinned in this library. It
|
|
@@ -983,7 +987,7 @@ ${scoutBlob || '(no scout notes)'}`;
|
|
|
983
987
|
* 'branch-red' | 'cap-halt' | 'wall-halt' | 'provider-red' | 'interpreter-red' |
|
|
984
988
|
* 'step-stalled' | 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
|
|
985
989
|
*/
|
|
986
|
-
export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, closeDir = null, maxStepRounds = 40, layerRoot = false, readShim = false, scout = true, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null, resumable = true }) {
|
|
990
|
+
export async function runPlan(job, { workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, rates = null, judgeRates = null, emit, remainingUsd, isUnpriced = () => false, spendComplete = () => true, capRuns = 3, strikeLimit = STRIKE_LIMIT, closeTimeoutMs, closeDir = null, maxStepRounds = 40, layerRoot = false, readShim = false, scout = true, scoutRounds = SCOUT_ROUNDS, bridge = null, now, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, priorSpentUsd = 0, reviewDoor = null, doorRerun = null, resumable = true }) {
|
|
987
991
|
// MEMORY-CACHE: what the read shim (src/readshim.js) saved THIS run, summed across
|
|
988
992
|
// every mkWorker's own shim instance (scout, drafter, each step's worker, the fix
|
|
989
993
|
// worker) — one accumulator closed over by all of them, because the shim's ledger
|
|
@@ -1687,7 +1691,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
1687
1691
|
// ride, so `runJob`'s ONE ledger accounts a close's spend exactly as it accounts
|
|
1688
1692
|
// an attempt's (a budget funds the attempt PLUS its close), and a null cost trips
|
|
1689
1693
|
// the same F6 pricing halt rather than being laundered into $0.
|
|
1690
|
-
judgeLoop: judgeProvider ? (/** @type {{system: string}} */ o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system }) : null,
|
|
1694
|
+
judgeLoop: judgeProvider ? (/** @type {{system: string}} */ o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgeRates }) : null,
|
|
1691
1695
|
// the seam's other half (PRD item 32.1) — WHICH model that loop drives. Passed
|
|
1692
1696
|
// through untouched: this module resolves no judge, exactly as it constructs no
|
|
1693
1697
|
// provider. `runJudgedFloor` stops as a wiring gap when it is absent rather than
|
|
@@ -2750,7 +2754,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2750
2754
|
}
|
|
2751
2755
|
return onLlmResult(arg);
|
|
2752
2756
|
};
|
|
2753
|
-
self = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered, onToolResult: onToolOutcome });
|
|
2757
|
+
self = new Loop({ provider: loopProvider, system, policy, onLlmResult: metered, onToolResult: onToolOutcome, ...(rates ? { rates } : {}) });
|
|
2754
2758
|
return self;
|
|
2755
2759
|
};
|
|
2756
2760
|
/** @param {string} prompt @param {typeof toolDefs} [defs] */
|
package/src/providerrows.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// `applyConfiguredKey` / `modelChoiceFor` below — no second lookup exists. Key VALUES
|
|
11
11
|
// never pass through here — only names.
|
|
12
12
|
import { resolveProvider } from './providers.js';
|
|
13
|
-
import { readConfig } from './config.js';
|
|
13
|
+
import { readConfig, ConfigError } from './config.js';
|
|
14
14
|
import { filledKeyNames } from './keysfile.js';
|
|
15
15
|
|
|
16
16
|
/** DeepSeek's OpenAI-shaped endpoint (the same string the panel's Model menu used). */
|
|
@@ -73,6 +73,9 @@ export function defaultsFor(envName) {
|
|
|
73
73
|
* @property {string} name the model id Chat uses ('' = not offered in Chat)
|
|
74
74
|
* @property {string} provider the `src/providers.js` table entry (the API shape's id)
|
|
75
75
|
* @property {string} baseUrl the saved Base URL ('' = the shape's default host)
|
|
76
|
+
* @property {unknown} [priceInPerM] the customer's own price, USD per 1M input tokens — carried RAW
|
|
77
|
+
* (present only when the config row names it); {@link ratesFor} is the one place that validates it
|
|
78
|
+
* @property {unknown} [priceOutPerM] the same, for output tokens
|
|
76
79
|
*/
|
|
77
80
|
|
|
78
81
|
/**
|
|
@@ -90,6 +93,8 @@ export function keyRows({ filled, config }) {
|
|
|
90
93
|
name: typeof s.name === 'string' ? s.name : d.name,
|
|
91
94
|
provider: SHAPES.some((x) => x.id === s.shape) ? s.shape : d.shape,
|
|
92
95
|
baseUrl: typeof s.baseUrl === 'string' ? s.baseUrl : d.baseUrl,
|
|
96
|
+
...(Object.hasOwn(s, 'priceInPerM') ? { priceInPerM: s.priceInPerM } : {}),
|
|
97
|
+
...(Object.hasOwn(s, 'priceOutPerM') ? { priceOutPerM: s.priceOutPerM } : {}),
|
|
93
98
|
};
|
|
94
99
|
});
|
|
95
100
|
}
|
|
@@ -215,3 +220,82 @@ export function applyConfiguredKey(env, provider, baseUrl, rows, model) {
|
|
|
215
220
|
if (!k.chosen && value !== 'null') return env;
|
|
216
221
|
return { ...env, [k.builtIn]: usableKey(value) };
|
|
217
222
|
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A per-1M price for a readout: two decimals at least ($1.20), more when the number needs them so a
|
|
226
|
+
* tiny price never rounds to $0.00 or $0.01 ($0.006, $0.0004).
|
|
227
|
+
* @param {number} n USD per 1M tokens
|
|
228
|
+
* @returns {string}
|
|
229
|
+
*/
|
|
230
|
+
function perMoney(n) {
|
|
231
|
+
const shown = String(n);
|
|
232
|
+
if (!shown.includes('e') && (shown.split('.')[1] ?? '').length >= 2) return `$${shown}`;
|
|
233
|
+
if (shown.includes('e')) return `$${n.toFixed(12).replace(/0+$/, '').replace(/\.$/, '')}`;
|
|
234
|
+
return `$${n.toFixed(2)}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The one readout line for a resolved customer price, preview and run tail alike:
|
|
239
|
+
* `yours: in $0.006 / out $1.20 per 1M tokens (DEEPSEEK_API_KEY row)`.
|
|
240
|
+
* @param {{ inPerM: number, outPerM: number, envName: string }} price a {@link ratesFor} result
|
|
241
|
+
* @returns {string}
|
|
242
|
+
*/
|
|
243
|
+
export function priceReadout(price) {
|
|
244
|
+
return `yours: in ${perMoney(price.inPerM)} / out ${perMoney(price.outPerM)} per 1M tokens (${price.envName} row)`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The customer's own price for the row a `(provider, baseUrl[, model])` identity belongs to —
|
|
249
|
+
* the SAME row match {@link keyNameFor} uses (`findRow`), so the price and the key always come
|
|
250
|
+
* from one row. `null` = no price set (the run keeps bare-agent's built-in guess, byte-identical
|
|
251
|
+
* to before). Both fields or neither, each a finite number >= 0, USD per 1M tokens; anything
|
|
252
|
+
* else throws `ConfigError` naming the row and field — a bad price never silently falls back to
|
|
253
|
+
* the guess. `rates` is bare-agent's `Loop({ rates })` shape (USD per 1K); `inPerM`/`outPerM`
|
|
254
|
+
* are what a readout prints.
|
|
255
|
+
* @param {string|null|undefined} provider
|
|
256
|
+
* @param {string|null|undefined} baseUrl
|
|
257
|
+
* @param {readonly KeyRow[]} rows
|
|
258
|
+
* @param {string|null|undefined} [model]
|
|
259
|
+
* @returns {{ rates: { in: number, out: number }, inPerM: number, outPerM: number, envName: string }|null}
|
|
260
|
+
*/
|
|
261
|
+
export function ratesFor(provider, baseUrl, rows, model) {
|
|
262
|
+
const row = findRow(rows, { provider, baseUrl, model });
|
|
263
|
+
if (!row) return null;
|
|
264
|
+
const hasIn = row.priceInPerM !== undefined;
|
|
265
|
+
const hasOut = row.priceOutPerM !== undefined;
|
|
266
|
+
if (!hasIn && !hasOut) return null;
|
|
267
|
+
if (hasIn !== hasOut) {
|
|
268
|
+
throw new ConfigError(`keys.${row.envName} sets ${hasIn ? 'priceInPerM' : 'priceOutPerM'} but not ${hasIn ? 'priceOutPerM' : 'priceInPerM'} — set both prices (USD per 1M tokens) or neither`);
|
|
269
|
+
}
|
|
270
|
+
for (const field of /** @type {const} */ (['priceInPerM', 'priceOutPerM'])) {
|
|
271
|
+
const v = row[field];
|
|
272
|
+
if (!(typeof v === 'number' && Number.isFinite(v) && v >= 0)) {
|
|
273
|
+
throw new ConfigError(`keys.${row.envName}.${field} must be a number, 0 or more (USD per 1M tokens) — fix it in config.json or remove both price lines`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const inPerM = /** @type {number} */ (row.priceInPerM);
|
|
277
|
+
const outPerM = /** @type {number} */ (row.priceOutPerM);
|
|
278
|
+
return { rates: { in: inPerM / 1000, out: outPerM / 1000 }, inPerM, outPerM, envName: row.envName };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The price a judge's rounds are booked at. A price belongs to the MODEL's row: when the judge is
|
|
283
|
+
* the worker's own provider it calls the worker's endpoint, so a row on that endpoint NAMED for the
|
|
284
|
+
* judge's model prices it ({@link ratesFor}); a judge model that is absent or names no row there
|
|
285
|
+
* keeps the worker's price (the worker's row, or none = the guess). A different provider resolves
|
|
286
|
+
* on its own default endpoint, as before. A bad price on the resolved row throws like `ratesFor`.
|
|
287
|
+
* The judge's KEY is chosen elsewhere (`applyConfiguredKey`, by the worker's model), so the two
|
|
288
|
+
* can come from different rows — same provider + endpoint, judge model naming another row.
|
|
289
|
+
* @param {{ provider: string, model?: string|null }} judge
|
|
290
|
+
* @param {string|null|undefined} workerProvider
|
|
291
|
+
* @param {string|null|undefined} workerBaseUrl
|
|
292
|
+
* @param {ReturnType<typeof ratesFor>} workerPrice what the worker's own rounds are booked at
|
|
293
|
+
* @param {readonly KeyRow[]} rows
|
|
294
|
+
* @returns {ReturnType<typeof ratesFor>}
|
|
295
|
+
*/
|
|
296
|
+
export function judgeRatesFor(judge, workerProvider, workerBaseUrl, workerPrice, rows) {
|
|
297
|
+
if (judge.provider !== workerProvider) return ratesFor(judge.provider, undefined, rows, judge.model);
|
|
298
|
+
const want = endpointOf(judge.provider, workerBaseUrl);
|
|
299
|
+
const named = judge.model ? rows.some((r) => r.provider === judge.provider && endpointOf(r.provider, r.baseUrl) === want && r.name === judge.model) : false;
|
|
300
|
+
return named ? ratesFor(judge.provider, workerBaseUrl, rows, judge.model) : workerPrice;
|
|
301
|
+
}
|
package/src/run.js
CHANGED
|
@@ -95,6 +95,11 @@ async function primitiveSmoke(workdir) {
|
|
|
95
95
|
* metered claude-json text provider for the toolless drafter); ignored on every Loop-driven provider
|
|
96
96
|
* @param {(type: string, data?: object) => object} opts.emit spine emitter
|
|
97
97
|
* @param {(tier: string) => any} [opts.providerFor] P: per-step model-tier provider factory (forwarded to the plan flow)
|
|
98
|
+
* @param {{in: number, out: number}|null} [opts.rates] the CUSTOMER's own price for the worker's key row
|
|
99
|
+
* (`ratesFor`, src/providerrows.js — USD per 1K tokens), resolved once by the door and handed to every
|
|
100
|
+
* worker/planner Loop as bare-agent's `rates` (rounds then read `rateSource:'caller'`). Absent = the
|
|
101
|
+
* built-in guess, exactly as before. A Loop-driven provider only: a native session reports its own cost.
|
|
102
|
+
* @param {{in: number, out: number}|null} [opts.judgeRates] the same, for the judge's own key row
|
|
98
103
|
* @param {string|null} [opts.judgeModel] SOFTGREEN — WHICH model the judge provider drives
|
|
99
104
|
* (PRD item 32.1), resolved by the CALLER (`resolveJudge`, src/judged.js) and passed
|
|
100
105
|
* through untouched. Travels with `judgeProvider`; absent, a judged stage stops as a
|
|
@@ -265,7 +270,7 @@ async function primitiveSmoke(workdir) {
|
|
|
265
270
|
* deliberately NOT in the resumable/checkpoint set (`src/reuse.js`) — an
|
|
266
271
|
* unknown in-flight state is not a known-safe resume point.
|
|
267
272
|
*/
|
|
268
|
-
export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, closeDir = null, layerRoot = false, readShim = false, scout = true, bridge = null, draftSpentUsd = 0, draftSpendComplete = true, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null, resumable = true }) {
|
|
273
|
+
export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, rates = null, judgeRates = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, closeDir = null, layerRoot = false, readShim = false, scout = true, bridge = null, draftSpentUsd = 0, draftSpendComplete = true, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null, resumable = true }) {
|
|
269
274
|
// THE READ SHIM's ARM, resolved at the door — the FIRST thing this entry does,
|
|
270
275
|
// before the ledger, before the approval gate, before a byte of the spec is read.
|
|
271
276
|
// An unrecognised spelling throws here at zero cost instead of being coerced by
|
|
@@ -532,7 +537,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
|
|
|
532
537
|
// accounts it natively (F12) and the job-end money contract is unchanged.
|
|
533
538
|
{
|
|
534
539
|
const outcome = await runPlan(job, {
|
|
535
|
-
workdir, provider, nativeProvider, providerFor, judgeProvider, judgeModel, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, closeDir, layerRoot, readShim, scout, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd, resumable,
|
|
540
|
+
workdir, provider, nativeProvider, providerFor, judgeProvider, judgeModel, rates, judgeRates, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, closeDir, layerRoot, readShim, scout, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd, resumable,
|
|
536
541
|
// hamr's ruling 2026-09-28 — ONE cap covers drafting + run: the run's
|
|
537
542
|
// enforced ceiling is the signed `budgetUsd` (unchanged, still in the
|
|
538
543
|
// spec hash) MINUS whatever drafting already spent on this job before
|
package/src/runlist.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// caller doesn't know one) — a relative path here would resolve differently
|
|
14
14
|
// depending on who later reads the file. `via` says how the row was minted:
|
|
15
15
|
// `'run-u'` (src/userrun.js, the person-path run), `'bundle'`
|
|
16
|
-
// (src/
|
|
16
|
+
// (src/userrun.js's bundle door, `bareloop run`), or `'backfill'` (`bareloop runs
|
|
17
17
|
// backfill`, reconstructed from an archived spine already on disk).
|
|
18
18
|
//
|
|
19
19
|
// SECRETS: a row carries only paths/ids/names — never a key, a prompt, or any
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
|
|
30
30
|
import {
|
|
31
31
|
existsSync, mkdirSync, appendFileSync, chmodSync, readdirSync, statSync,
|
|
32
|
+
readFileSync,
|
|
32
33
|
} from 'node:fs';
|
|
33
34
|
import { homedir } from 'node:os';
|
|
34
35
|
import {
|
|
@@ -57,7 +58,9 @@ export function runlistPath(home) {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
/**
|
|
60
|
-
* @typedef {{ at: string, runid: string, job: string, spine: string, patient: string|null, via: 'run-u'|'bundle'|'backfill' }} RunRow
|
|
61
|
+
* @typedef {{ at: string, runid: string, job: string, spine: string, patient: string|null, via: 'run-u'|'bundle'|'backfill', pid?: number, capUsd?: number }} RunRow
|
|
62
|
+
* `pid`/`capUsd` (optional; older and backfilled rows carry neither): the runner's process id and the
|
|
63
|
+
* leg's $ cap — what the monthly limit holds while that pid is a live bareloop runner (src/monthly.js).
|
|
61
64
|
*/
|
|
62
65
|
|
|
63
66
|
/**
|
|
@@ -87,7 +90,8 @@ export function appendRun(row, opts = {}) {
|
|
|
87
90
|
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
88
91
|
if (existsSync(path)) {
|
|
89
92
|
const { records } = parseJsonl(path);
|
|
90
|
-
|
|
93
|
+
// only RUN rows count: a settled/released event names the same runid for another purpose
|
|
94
|
+
if (records.some((r) => r && !r.type && r.runid === row.runid)) {
|
|
91
95
|
return { appended: false, reason: 'duplicate-runid' };
|
|
92
96
|
}
|
|
93
97
|
}
|
|
@@ -96,18 +100,86 @@ export function appendRun(row, opts = {}) {
|
|
|
96
100
|
return { appended: true };
|
|
97
101
|
}
|
|
98
102
|
|
|
103
|
+
/**
|
|
104
|
+
* @typedef {{ runid: string, type: 'settled'|'released', by: string, at: string, reason?: string, spentUsd?: number, spendComplete?: boolean }} RunEvent
|
|
105
|
+
* A claim's write-back, appended (never rewritten) beside the run rows. `settled` = the run's claim on the
|
|
106
|
+
* monthly limit is over (`spentUsd`/`spendComplete` are its final figure, `by` is WHO wrote it: the run itself
|
|
107
|
+
* at its `job-end`, or the next run that found its process gone, `reason: 'process gone'`). `released` = the
|
|
108
|
+
* claim was taken and then given back unspent, its run never started. The month's totals keep reading real
|
|
109
|
+
* spend off the spines; an event only releases the hold and records who wrote it.
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Append one {@link RunEvent}. Not locked: one short line, one append. THROWS on IO failure.
|
|
114
|
+
* @param {RunEvent} event
|
|
115
|
+
* @param {{ home?: string }} [opts]
|
|
116
|
+
* @returns {void}
|
|
117
|
+
*/
|
|
118
|
+
export function appendRunEvent(event, opts = {}) {
|
|
119
|
+
const path = runlistPath(opts.home);
|
|
120
|
+
mkdirSync(runlistHome(opts.home), { recursive: true, mode: 0o700 });
|
|
121
|
+
appendFileSync(path, `${JSON.stringify(event)}\n`);
|
|
122
|
+
try { chmodSync(path, 0o600); } catch { /* best-effort perms */ }
|
|
123
|
+
}
|
|
124
|
+
|
|
99
125
|
/**
|
|
100
126
|
* Tolerant reader — reuses {@link parseJsonl} (never a second hand-rolled
|
|
101
127
|
* parser). An absent file reads as an empty list, not an error: a fresh
|
|
102
|
-
* install has never run `appendRun` yet.
|
|
128
|
+
* install has never run `appendRun` yet. FOLDS the file: `rows` are the RUNS
|
|
129
|
+
* only (an event line is never listed as a run, and a run whose claim was
|
|
130
|
+
* `released` never started, so it is not listed either); `events` are the
|
|
131
|
+
* write-backs, in file order.
|
|
103
132
|
* @param {{ home?: string }} [opts]
|
|
104
|
-
* @returns {{ rows: RunRow[], skipped: number }}
|
|
133
|
+
* @returns {{ rows: RunRow[], skipped: number, events: RunEvent[] }}
|
|
105
134
|
*/
|
|
106
135
|
export function readRunList(opts = {}) {
|
|
107
136
|
const path = runlistPath(opts.home);
|
|
108
|
-
if (!existsSync(path)) return { rows: [], skipped: 0 };
|
|
137
|
+
if (!existsSync(path)) return { rows: [], skipped: 0, events: [] };
|
|
109
138
|
const { records, skipped } = parseJsonl(path);
|
|
110
|
-
|
|
139
|
+
// the FIRST settle for a runid is authoritative: two runs may both close the same dead claim, and the
|
|
140
|
+
// second note is a duplicate (a `released` line likewise); file order decides, later copies are ignored
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
/** @type {RunEvent[]} */
|
|
143
|
+
const events = records.filter((r) => {
|
|
144
|
+
if (!r || (r.type !== 'settled' && r.type !== 'released')) return false;
|
|
145
|
+
const key = `${r.type}:${r.runid}`;
|
|
146
|
+
if (seen.has(key)) return false;
|
|
147
|
+
seen.add(key);
|
|
148
|
+
return true;
|
|
149
|
+
});
|
|
150
|
+
const released = new Set(events.filter((e) => e.type === 'released').map((e) => e.runid));
|
|
151
|
+
const rows = records.filter((r) => r && !r.type && !released.has(r.runid));
|
|
152
|
+
return { rows, skipped, events };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Is this pid a live bareloop runner? `process.kill(pid, 0)` says whether the
|
|
157
|
+
* pid exists (EPERM = it exists, just not ours); `/proc/<pid>/cmdline` says whether it is a
|
|
158
|
+
* bareloop runner — a recycled pid that now belongs to some other program is NOT. A runner is
|
|
159
|
+
* a node executable (argv[0] is `node`, `nodejs`, `node22`, ...) with a later argv entry named
|
|
160
|
+
* `bareloop`, `bareloop.mjs`, `run-u.mjs` or `u-watchdog.mjs` (the basename, so the npm bin
|
|
161
|
+
* symlink counts), or a process whose argv[0] is itself one of those names. A non-node program
|
|
162
|
+
* that merely has such a name in its arguments (`nvim /x/bareloop`, `git -C /x/bareloop`) is not
|
|
163
|
+
* a runner. Residual, accepted: a recycled pid that is a node program with such an argument
|
|
164
|
+
* still reads as a runner. The wrong direction to err is "not a runner" on a real live run — it
|
|
165
|
+
* would release that run's cap. Where `/proc` is unreadable (not Linux, or not ours) the
|
|
166
|
+
* existence test alone decides: alive. The ONE spelling — the monthly limit's holds, and
|
|
167
|
+
* `--resume`'s old-pid check share it.
|
|
168
|
+
* @param {number} pid
|
|
169
|
+
* @returns {boolean}
|
|
170
|
+
*/
|
|
171
|
+
export function isLiveRunner(pid) {
|
|
172
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
173
|
+
try { process.kill(pid, 0); } catch (/** @type {any} */ e) { if (e?.code !== 'EPERM') return false; }
|
|
174
|
+
/** @type {string|null} */
|
|
175
|
+
let cmdline = null;
|
|
176
|
+
try { cmdline = readFileSync(`/proc/${pid}/cmdline`, 'utf8'); } catch { /* no /proc, or not ours */ }
|
|
177
|
+
if (cmdline === null) return true;
|
|
178
|
+
const names = ['bareloop', 'bareloop.mjs', 'run-u.mjs', 'u-watchdog.mjs'];
|
|
179
|
+
const [argv0 = '', ...rest] = cmdline.split('\0');
|
|
180
|
+
const first = basename(argv0);
|
|
181
|
+
if (names.includes(first)) return true;
|
|
182
|
+
return first.startsWith('node') && rest.some((a) => names.includes(basename(a)));
|
|
111
183
|
}
|
|
112
184
|
|
|
113
185
|
/**
|
package/src/source.js
CHANGED
|
@@ -441,11 +441,15 @@ async function fetchOnce(url, timeoutMs) {
|
|
|
441
441
|
* never hands a repo source's destination to `copyOut` — wiring it into
|
|
442
442
|
* `writeScope` is M3/M4's job, not this one.
|
|
443
443
|
* @param {{source: string, into: string, destination?: string,
|
|
444
|
-
* fetchTimeoutMs?: number
|
|
445
|
-
* callers omit it and get
|
|
444
|
+
* fetchTimeoutMs?: number, afterScan?: () => (void|Promise<void>)}} args
|
|
445
|
+
* `fetchTimeoutMs` is test-only — production callers omit it and get
|
|
446
|
+
* `PROVIDER_TIMEOUT_MS`. `afterScan` is a test seam too: called exactly once,
|
|
447
|
+
* after the secret scan finished and before the freeze loop re-reads any
|
|
448
|
+
* file's bytes, so a test can swap a file inside that window
|
|
449
|
+
* deterministically. Production never sets it; absent, behaviour is unchanged.
|
|
446
450
|
* @returns {Promise<{stop: null, into: string, tree: string, manifestPath: string, manifest: object}|SourceRefusal>}
|
|
447
451
|
*/
|
|
448
|
-
export async function prepareSource({ source, into, destination, fetchTimeoutMs = PROVIDER_TIMEOUT_MS }) {
|
|
452
|
+
export async function prepareSource({ source, into, destination, fetchTimeoutMs = PROVIDER_TIMEOUT_MS, afterScan }) {
|
|
449
453
|
const intoAbs = resolve(into);
|
|
450
454
|
if (existsSync(intoAbs)) {
|
|
451
455
|
return refuse('into-exists', `${intoAbs} already exists — a source door writes a FRESH tree, never reuses one (the export worktree rule)`);
|
|
@@ -674,6 +678,7 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
|
|
|
674
678
|
// (verified: 0o755 in, 0o755 out; a plain `writeFile` defaults to 0o644),
|
|
675
679
|
// so the replacement chmod's the destination from a fresh `stat` to keep
|
|
676
680
|
// that behaviour unchanged.
|
|
681
|
+
if (afterScan) await afterScan();
|
|
677
682
|
for (let i = 0; i < frozen.files.length; i += 1) {
|
|
678
683
|
const f = frozen.files[i];
|
|
679
684
|
const dest = join(treeDir, treeRel(f.rel));
|