@haus-tech/haus-workflow 5.0.0 → 5.1.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.
@@ -0,0 +1,640 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Startaren för `utvärdera`-svepet (plan utvardera-parallellt-svep, Task 2):
4
+ * kö, parallellitet, spawn av den inre headless-processen, mätning,
5
+ * proveniens, modellverifiering och status som deterministisk kod i stället
6
+ * för ett handskrivet svep.sh per experiment. SKILL.md beskriver flödet och
7
+ * anropar detta script; reglerna testas in-process i tests/request-svep.test.js
8
+ * (de rena hjälparna exporteras, huvudloopen körs bara när scriptet startas
9
+ * direkt).
10
+ *
11
+ * Två tickets åt gången, varje ticket kör alla valda modeller samtidigt, och
12
+ * nästa ticket startar först när en tickets samtliga modeller är klara
13
+ * (beslut 2026-09-02). `experiment.json` skrivs ALDRIG härifrån direkt, bara
14
+ * via `archive.mjs experiment` (låst läs-modifiera-skriv), så samtidiga varv
15
+ * aldrig tappar varandras rader (#493). Övriga arkivsteg (ny-korning,
16
+ * proveniens, sparr) anropas som kommandon av samma skäl som ADR-0094: ett
17
+ * steg som ålägger startaren en skrivning måste peka ut vem som utför den.
18
+ *
19
+ * Användning:
20
+ * node svep.mjs --repo-rot <abs> --konto <namn> --modell <m> [--modell <m>]...
21
+ * --exp <experiments/<slug>> [--exp ...]...
22
+ * [--tickets-parallellt 2] [--om | --hoppa-över]
23
+ * [--claude <kommando>]
24
+ *
25
+ * Ett relativt --exp löses mot <repo-rot>/.haus-workflow/requests/. Varje
26
+ * experimentkatalog måste bära onskemal.md, kallmanifest.md och experiment.json
27
+ * med `ticket` (huvudkonversationen förbereder dem, utvärdera steg 1 till 3);
28
+ * saknas något märks ticketen och hoppas över, svepet fortsätter.
29
+ *
30
+ * Per (ticket, modell): återupptagning ur experiment.json, ny-korning, startar
31
+ * `korning.mjs` per varv, som kör agentstegen som egna `claude -p --agent`-
32
+ * processer och skriver korning.json själv, stderr till stderr.log,
33
+ * matning.json (även vid krasch), proveniens, modellverifiering mot
34
+ * modelUsage (ADR-0099) och status via `archive.mjs experiment`.
35
+ *
36
+ * Exit 0 = ingen körning blev ofärdig, 1 = minst en ofärdig, 2 = användningsfel.
37
+ * Ctrl-C dödar barnprocesserna, skriver matning.json och status ofärdig för de
38
+ * som kördes, och avslutar med 130.
39
+ */
40
+ import { existsSync, readFileSync, writeFileSync, readdirSync, realpathSync } from 'node:fs'
41
+ import { spawn, spawnSync } from 'node:child_process'
42
+ import path from 'node:path'
43
+ import { fileURLToPath } from 'node:url'
44
+ import { canonicalModel, matcharModell, avgorDeps, schemalagg } from './lib.mjs'
45
+
46
+ // matcharModell, avgorDeps och schemalagg flyttade till lib.mjs (delade
47
+ // byggstenar för requestmotorns kontrollscript); re-exporteras här så
48
+ // befintliga importer (tests/request-svep.test.js och senare korning.mjs)
49
+ // är oförändrade.
50
+ export { matcharModell, avgorDeps, schemalagg }
51
+
52
+ const HAR = path.dirname(fileURLToPath(import.meta.url))
53
+ const ARCHIVE = path.join(HAR, 'archive.mjs')
54
+ const KORNING = path.join(HAR, 'korning.mjs')
55
+
56
+ export const UNDERLAG = ['onskemal.md', 'kallmanifest.md', 'experiment.json']
57
+ export const STATUSAR = ['klar', 'ofärdig', 'stopp', 'väntar på dom']
58
+ /** Statusar den inre processen själv får sätta och som startaren behåller. */
59
+ const INRE_BEHALLS = ['stopp', 'väntar på dom', 'ofärdig']
60
+ const STANDARD_TAK = 2
61
+ const SIGINT_EXIT = 130
62
+ const VARDEFLAGGOR = ['repo-rot', 'konto', 'modell', 'exp', 'tickets-parallellt', 'claude']
63
+ const BOOLFLAGGOR = ['om', 'hoppa-över']
64
+
65
+ export const ANVANDNING = [
66
+ 'användning: svep.mjs --repo-rot <abs> --konto <namn> --modell <m> [--modell <m>]...',
67
+ ' --exp <experiments/<slug>> [--exp ...]...',
68
+ ` [--tickets-parallellt ${STANDARD_TAK}] [--om | --hoppa-över] [--claude <kommando>]`,
69
+ ].join('\n')
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Rena hjälpare (testas in-process)
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /** Argumenttolkning. Returnerar `{ fel }` vid användningsfel (exit 2 i main),
76
+ * annars de tolkade valen. Alla saknade flaggor namnges på en gång, så
77
+ * anroparen inte behöver rätta en i taget (ADR-0039). */
78
+ export function tolkaArgv(argv) {
79
+ const listor = { modell: [], exp: [] }
80
+ const enkla = {}
81
+ const bool = { om: false, 'hoppa-över': false }
82
+ for (let i = 0; i < argv.length; i++) {
83
+ const a = argv[i]
84
+ if (!a.startsWith('--')) return { fel: `oväntat argument: ${a}` }
85
+ const namn = a.slice(2)
86
+ if (BOOLFLAGGOR.includes(namn)) {
87
+ bool[namn] = true
88
+ continue
89
+ }
90
+ if (!VARDEFLAGGOR.includes(namn)) return { fel: `okänd flagga: --${namn}` }
91
+ const v = argv[i + 1]
92
+ // Ett flaggvärde får aldrig tyst bli nästa flaggas namn (samma regel som
93
+ // archive.mjs): `--konto --modell` är ett fel, inte kontot "--modell".
94
+ if (v === undefined || v.startsWith('--')) return { fel: `--${namn} kräver ett värde` }
95
+ i++
96
+ if (namn in listor) listor[namn].push(v)
97
+ else enkla[namn] = v
98
+ }
99
+ const saknas = ['repo-rot', 'konto', 'modell', 'exp'].filter((n) =>
100
+ n in listor ? listor[n].length === 0 : enkla[n] === undefined,
101
+ )
102
+ if (saknas.length > 0) return { fel: `saknar ${saknas.map((n) => `--${n}`).join(', ')}` }
103
+ if (bool.om && bool['hoppa-över']) return { fel: '--om och --hoppa-över utesluter varandra' }
104
+ if (!path.isAbsolute(enkla['repo-rot']))
105
+ return { fel: `--repo-rot måste vara en absolut sökväg (fick "${enkla['repo-rot']}")` }
106
+ const dubbletter = listor.modell.filter((m, i) => listor.modell.indexOf(m) !== i)
107
+ if (dubbletter.length > 0)
108
+ return {
109
+ fel: `--modell ${dubbletter[0]} står två gånger: samma värde pekar på samma katalog och mäter ingen skillnad`,
110
+ }
111
+ const takText = enkla['tickets-parallellt'] ?? String(STANDARD_TAK)
112
+ const taket = Number(takText)
113
+ if (!/^\d+$/.test(takText) || taket < 1)
114
+ return { fel: `--tickets-parallellt måste vara ett heltal >= 1 (fick "${takText}")` }
115
+ return {
116
+ repoRot: enkla['repo-rot'],
117
+ konto: enkla.konto,
118
+ modeller: listor.modell,
119
+ exps: listor.exp,
120
+ taket,
121
+ om: bool.om,
122
+ hoppaOver: bool['hoppa-över'] || !bool.om,
123
+ claude: enkla.claude ?? 'claude',
124
+ }
125
+ }
126
+
127
+ /** Ett relativt --exp är arkivrelativt (`experiments/<slug>`), som SKILL.md
128
+ * skriver det; ett absolut används som det är. */
129
+ export function losExp(exp, repoRot) {
130
+ return path.isAbsolute(exp) ? exp : path.join(repoRot, '.haus-workflow', 'requests', exp)
131
+ }
132
+
133
+ /** `default` som modellvärde ger katalognamnet `baseline/` (Experimentytan). */
134
+ export const modellKatalog = (modell) => (modell === 'default' ? 'baseline' : modell)
135
+
136
+ /** Kontrollerar att en experimentkatalog bär underlaget huvudkonversationen
137
+ * ska ha förberett. Returnerar `{ ok: false, saknas: [...] }` eller
138
+ * `{ ok: true, ticket, eskalerad, eskaleringsorsak, experiment }`. Läser
139
+ * bara; skriver aldrig. */
140
+ export function kontrolleraUnderlag(expDir) {
141
+ const saknas = UNDERLAG.filter((f) => !existsSync(path.join(expDir, f)))
142
+ let experiment = null
143
+ if (!saknas.includes('experiment.json')) {
144
+ try {
145
+ experiment = JSON.parse(readFileSync(path.join(expDir, 'experiment.json'), 'utf8'))
146
+ } catch (e) {
147
+ saknas.push(`experiment.json (ogiltig JSON: ${e.message})`)
148
+ }
149
+ if (experiment !== null) {
150
+ // Rapporten läser `request_id`; äldre experiment (IPC 14001) skrev `ticket`.
151
+ const t = experiment.request_id ?? experiment.ticket
152
+ if (t === undefined || t === null || String(t).trim() === '')
153
+ saknas.push('experiment.json saknar `request_id`')
154
+ }
155
+ }
156
+ if (saknas.length > 0) return { ok: false, saknas }
157
+ return {
158
+ ok: true,
159
+ ticket: String(experiment.request_id ?? experiment.ticket),
160
+ eskalerad: experiment.eskalerad === true,
161
+ eskaleringsorsak:
162
+ typeof experiment.eskaleringsorsak === 'string' ? experiment.eskaleringsorsak : null,
163
+ experiment,
164
+ }
165
+ }
166
+
167
+ /** Modellens gällande körning i experiment.json: den högsta k<N> under
168
+ * katalognamnet. `null` när modellen saknar körningar. */
169
+ export function senasteKorning(experiment, katalognamn) {
170
+ const re = new RegExp(`^${katalognamn.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/k(\\d+)$`)
171
+ let basta = null
172
+ for (const [nyckel, rad] of Object.entries(experiment?.korningar ?? {})) {
173
+ const m = re.exec(nyckel)
174
+ if (!m) continue
175
+ const n = Number(m[1])
176
+ if (basta === null || n > basta.n)
177
+ basta = { n, nyckel, status: rad?.status ?? null, orsak: rad?.orsak ?? null }
178
+ }
179
+ return basta
180
+ }
181
+
182
+ /** Återupptagningsregeln ur SKILL.md:s Batch-avsnitt. `klar` och `stopp` är
183
+ * resultat värda att skydda: de hoppas utan --om. `ofärdig` är avbruten data
184
+ * och körs alltid om. `väntar på dom` omvärderas med `archive.mjs sparr`,
185
+ * svepet är den enda aktören för den statusen. Ingen körning alls
186
+ * (status undefined) körs. */
187
+ export function aterupptagning(status, { om = false } = {}) {
188
+ if (status === 'väntar på dom') return 'prova-sparr'
189
+ if (status === 'klar' || status === 'stopp') return om ? 'kör' : 'hoppa'
190
+ return 'kör'
191
+ }
192
+
193
+ /** Argumenten till den inre processen (plan requestmotor-korning, Task 3):
194
+ * `korning.mjs` som en egen node-process i stället för en prompt på stdin
195
+ * till en sonnet-orkestrator. Första elementet är korning.mjs:s absoluta
196
+ * sökväg, så anropet blir `spawn(process.execPath, korningArgs({...}))`. */
197
+ export function korningArgs({ katalog, expDir, repoRot, gid, modell, claude }) {
198
+ return [
199
+ KORNING,
200
+ '--katalog',
201
+ katalog,
202
+ '--exp',
203
+ expDir,
204
+ '--repo-rot',
205
+ repoRot,
206
+ '--request-id',
207
+ gid,
208
+ '--modell',
209
+ modell,
210
+ '--claude',
211
+ claude,
212
+ ]
213
+ }
214
+
215
+ /** Modellverifiering mot faktureringen (ADR-0099): den kanoniserade
216
+ * nyckelmängden ur modelUsage måste vara en delmängd av {modellen, sonnet,
217
+ * haiku}. `sonnet` är kundtextfixen (kontroll.mjs --fixa-kundtext), `haiku`
218
+ * CLI:ns egna hjälpanrop (ett dussin output-tokens). `default` kör
219
+ * agenternas egna defaults och prövas aldrig. */
220
+ export function verifieraModeller(modelUsage, modell) {
221
+ if (modell === 'default') return { ok: true }
222
+ const nycklar = [...new Set(Object.keys(modelUsage ?? {}).map(canonicalModel))]
223
+ const obegarda = nycklar.filter(
224
+ (k) => !matcharModell(k, modell) && !matcharModell(k, 'sonnet') && !matcharModell(k, 'haiku'),
225
+ )
226
+ if (obegarda.length > 0) return { ok: false, orsak: `MODELL-avvikelse: ${obegarda.join(', ')}` }
227
+ return { ok: true }
228
+ }
229
+
230
+ /** Statusregeln (plan Task 2, punkt 7): exit skilt från 0 eller modellfel ger
231
+ * `ofärdig` med orsak; en status den inre processen redan satt behålls
232
+ * (`behalls: true`, ingen skrivning); annars `klar`. */
233
+ export function avgorStatus({ exitCode, signal = null, modellfel = null, innerStatus = null }) {
234
+ if (exitCode !== 0) {
235
+ const orsak =
236
+ exitCode === null ? `processen dödades (${signal ?? 'okänd signal'})` : `exit ${exitCode}`
237
+ return { status: 'ofärdig', orsak }
238
+ }
239
+ if (modellfel) return { status: 'ofärdig', orsak: modellfel }
240
+ if (INRE_BEHALLS.includes(innerStatus)) return { status: innerStatus, behalls: true }
241
+ return { status: 'klar' }
242
+ }
243
+
244
+ /** Katalogerna `sparr` ska köras mot: löven i del-trädet (bunt eller
245
+ * delning, `del-N/del-M` vid DELA_UPP i en buntdel), eller körningsroten
246
+ * när den saknar delar. */
247
+ export function sparrKataloger(dir) {
248
+ let delar = []
249
+ try {
250
+ delar = readdirSync(dir, { withFileTypes: true })
251
+ .filter((d) => d.isDirectory() && /^del-\d+$/.test(d.name))
252
+ .map((d) => path.join(dir, d.name))
253
+ .sort()
254
+ } catch {
255
+ return [dir]
256
+ }
257
+ return delar.length === 0 ? [dir] : delar.flatMap(sparrKataloger)
258
+ }
259
+
260
+ /** Sluttabellen: en rad per (ticket, modell). */
261
+ export function renderaTabell(rader) {
262
+ const wall = (ms) => (Number.isFinite(ms) ? `${ms}ms` : '-')
263
+ const usd = (u) => (Number.isFinite(u) ? u.toFixed(4) : '-')
264
+ return [
265
+ '| ticket | modell | körning | status | wall | USD |',
266
+ '| --- | --- | --- | --- | --- | --- |',
267
+ ...rader.map(
268
+ (r) =>
269
+ `| ${r.ticket} | ${r.modell} | ${r.korning ?? '-'} | ${r.status}${r.orsak ? ` (${r.orsak})` : ''} | ${wall(r.wall_ms)} | ${usd(r.usd)} |`,
270
+ ),
271
+ ].join('\n')
272
+ }
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // Det riktiga arbetet (verifieras för hand i E2E, plan Task 4)
276
+ // ---------------------------------------------------------------------------
277
+
278
+ /** Ett archive.mjs-anrop, synkront: alla sökvägar är absoluta så cwd spelar
279
+ * ingen roll utöver tydlighet. */
280
+ const arkiv = (args, cwd) =>
281
+ spawnSync(process.execPath, [ARCHIVE, ...args], { encoding: 'utf8', cwd })
282
+
283
+ const felText = (r) => (r.stderr || r.stdout || '').trim() || `exit ${r.status}`
284
+
285
+ const skrivMatning = (katalog, matning) =>
286
+ writeFileSync(path.join(katalog, 'matning.json'), JSON.stringify(matning, null, 2) + '\n')
287
+
288
+ const lasKorningsrad = (expDir, nyckel) => {
289
+ try {
290
+ const exp = JSON.parse(readFileSync(path.join(expDir, 'experiment.json'), 'utf8'))
291
+ return exp?.korningar?.[nyckel] ?? null
292
+ } catch {
293
+ return null
294
+ }
295
+ }
296
+
297
+ /** Startar `korning.mjs` som en egen node-process, utan stdin, och tar tiden
298
+ * runt den. korning.json skrivs av korning.mjs självt (filens ägare); här
299
+ * sparas bara stderr som stderr.log. matning.json skrivs ÄVEN när processen
300
+ * faller eller inte går att starta: en kraschad körning har en klocktid.
301
+ * `pagaende` är svepets register för Ctrl-C. */
302
+ function korInre({ claude, gid, modell, katalog, expDir, repoRot, pagaende, nyckel }) {
303
+ return new Promise((resolve) => {
304
+ const startad = new Date()
305
+ let stderr = ''
306
+ let klar = false
307
+ const avsluta = ({ exitCode, signal, spawnfel = null }) => {
308
+ if (klar) return
309
+ klar = true
310
+ const avslutad = new Date()
311
+ const wall_ms = avslutad.getTime() - startad.getTime()
312
+ writeFileSync(path.join(katalog, 'stderr.log'), stderr)
313
+ const matning = {
314
+ startad: startad.toISOString(),
315
+ avslutad: avslutad.toISOString(),
316
+ wall_ms,
317
+ exit_code: exitCode,
318
+ ...(signal ? { signal } : {}),
319
+ ...(spawnfel ? { fel: spawnfel } : {}),
320
+ }
321
+ skrivMatning(katalog, matning)
322
+ resolve({ exitCode, signal, spawnfel, wall_ms })
323
+ }
324
+ let barn
325
+ try {
326
+ barn = spawn(
327
+ process.execPath,
328
+ korningArgs({ katalog, expDir, repoRot, gid, modell, claude }),
329
+ {
330
+ cwd: repoRot,
331
+ stdio: ['ignore', 'pipe', 'pipe'],
332
+ },
333
+ )
334
+ } catch (e) {
335
+ avsluta({ exitCode: null, signal: null, spawnfel: e.message })
336
+ return
337
+ }
338
+ pagaende.set(barn, { katalog, startad, expDir, nyckel })
339
+ // stdout ignoreras: korning.json skrivs av korning.mjs själv, inte via
340
+ // startarens stdout-buffert. Lyssnaren dränerar bara strömmen.
341
+ barn.stdout.on('data', () => {})
342
+ barn.stderr.on('data', (d) => {
343
+ stderr += d
344
+ })
345
+ barn.on('error', (e) => {
346
+ pagaende.delete(barn)
347
+ avsluta({ exitCode: null, signal: null, spawnfel: e.message })
348
+ })
349
+ barn.on('close', (code, signal) => {
350
+ pagaende.delete(barn)
351
+ avsluta({ exitCode: code, signal })
352
+ })
353
+ })
354
+ }
355
+
356
+ /** #493: en körning som ny-korning skapat men som dör innan slutstatusen
357
+ * skrivs fanns förr inte i experiment.json alls. Raden skrivs vid start som
358
+ * ofärdig och skrivs över av slutstatusen; ett fel här är en varning. */
359
+ export function registreraStart({ expDir, nyckel, arkiv: arkivFn }) {
360
+ const r = arkivFn([
361
+ 'experiment',
362
+ expDir,
363
+ '--korning',
364
+ nyckel,
365
+ '--status',
366
+ 'ofärdig',
367
+ '--orsak',
368
+ 'startad, inte avslutad',
369
+ ])
370
+ return { varning: r.status === 0 ? null : `experiment --status ofärdig: ${felText(r)}` }
371
+ }
372
+
373
+ /** Ett varv (ticket, modell): startarens hela ansvar enligt plan Task 2
374
+ * punkt 2 till 7. Kastar aldrig; ett fel blir en `ofärdig`-rad. `deps`,
375
+ * `eskalerad` och `eskaleringsorsak` skickas inte längre in: korning.mjs
376
+ * läser dem själv ur repo-roten och experiment.json (ADR-0113), och en
377
+ * parameter som ingen läser blir tyst fel så fort någon börjar tro på den. */
378
+ export async function korVarv({ gid, modell, expDir, repoRot, konto, claude, pagaende, ut }) {
379
+ const varningar = []
380
+ const modellKat = path.join(expDir, modellKatalog(modell))
381
+ const ny = arkiv(['ny-korning', modellKat, '--request-id', gid], repoRot)
382
+ if (ny.status !== 0)
383
+ return {
384
+ modell,
385
+ status: 'ofärdig',
386
+ orsak: `ny-korning: ${felText(ny)}`,
387
+ korning: null,
388
+ wall_ms: null,
389
+ usd: null,
390
+ varningar,
391
+ }
392
+ const katalog = ny.stdout.trim()
393
+ const nyckel = `${modellKatalog(modell)}/${path.basename(katalog)}`
394
+ const start = registreraStart({ expDir, nyckel, arkiv: (args) => arkiv(args, repoRot) })
395
+ if (start.varning) varningar.push(start.varning)
396
+ ut(`START ${gid} ${modell} -> ${katalog}`)
397
+
398
+ const { exitCode, signal, spawnfel, wall_ms } = await korInre({
399
+ claude,
400
+ gid,
401
+ modell,
402
+ katalog,
403
+ expDir,
404
+ repoRot,
405
+ pagaende,
406
+ nyckel,
407
+ })
408
+
409
+ // Proveniens EFTER korning.json, aldrig före: annars saknar stämpeln
410
+ // faktureringsdatan (SKILL.md steg 4).
411
+ const prov = arkiv(
412
+ [
413
+ 'proveniens',
414
+ katalog,
415
+ '--modell',
416
+ modell,
417
+ '--konto',
418
+ konto,
419
+ '--underlag',
420
+ path.join(expDir, 'kallmanifest.md'),
421
+ ],
422
+ repoRot,
423
+ )
424
+ if (prov.status !== 0) varningar.push(`proveniens: ${felText(prov)}`)
425
+
426
+ let usd = null
427
+ let modellfel = null
428
+ try {
429
+ const korning = JSON.parse(readFileSync(path.join(katalog, 'korning.json'), 'utf8'))
430
+ if (typeof korning.total_cost_usd === 'number') usd = korning.total_cost_usd
431
+ if (exitCode === 0) {
432
+ // korning.mjs har redan gjort verifieringen per steg (modellverifiering);
433
+ // svepets egen verifieraModeller är reserven när det fältet saknas
434
+ // (äldre korning.json eller en inre process som föll före summeringen).
435
+ modellfel =
436
+ korning.modellverifiering?.ok === false
437
+ ? `MODELL-avvikelse: ${korning.modellverifiering.avvikelser.join(', ')}`
438
+ : verifieraModeller(korning.modelUsage, modell).orsak
439
+ }
440
+ } catch (e) {
441
+ if (exitCode === 0) modellfel = `korning.json går inte att läsa: ${e.message}`
442
+ }
443
+
444
+ const inre = lasKorningsrad(expDir, nyckel)
445
+ const avg = spawnfel
446
+ ? { status: 'ofärdig', orsak: `kunde inte starta ${claude}: ${spawnfel}` }
447
+ : avgorStatus({ exitCode, signal, modellfel, innerStatus: inre?.status ?? null })
448
+ if (!avg.behalls) {
449
+ const st = arkiv(
450
+ [
451
+ 'experiment',
452
+ expDir,
453
+ '--korning',
454
+ nyckel,
455
+ '--status',
456
+ avg.status,
457
+ ...(avg.orsak ? ['--orsak', avg.orsak] : []),
458
+ ],
459
+ repoRot,
460
+ )
461
+ if (st.status !== 0) varningar.push(`experiment --status: ${felText(st)}`)
462
+ }
463
+ const orsak = avg.orsak ?? inre?.orsak ?? null
464
+ ut(
465
+ `KLAR ${gid} ${modell} ${avg.status} ${wall_ms}ms${usd !== null ? ` ${usd.toFixed(4)} USD` : ''}`,
466
+ )
467
+ return { modell, status: avg.status, orsak, korning: nyckel, wall_ms, usd, varningar }
468
+ }
469
+
470
+ /** `väntar på dom` omvärderas: sparr exit 0 på alla löv betyder att domen är
471
+ * fälld, då blir körningen `klar`. Annars står den kvar. */
472
+ function provaSparr({ gid, modell, expDir, repoRot, nyckel, ut }) {
473
+ const alla = sparrKataloger(path.join(expDir, nyckel)).every(
474
+ (k) => arkiv(['sparr', k], repoRot).status === 0,
475
+ )
476
+ if (!alla) {
477
+ ut(`VÄNTAR ${gid} ${modell} väntar på dom (${nyckel})`)
478
+ return {
479
+ modell,
480
+ status: 'väntar på dom',
481
+ korning: nyckel,
482
+ wall_ms: null,
483
+ usd: null,
484
+ varningar: [],
485
+ }
486
+ }
487
+ const varningar = []
488
+ const st = arkiv(['experiment', expDir, '--korning', nyckel, '--status', 'klar'], repoRot)
489
+ if (st.status !== 0) varningar.push(`experiment --status: ${felText(st)}`)
490
+ ut(`DOM ${gid} ${modell} klar (${nyckel}, domen är fälld)`)
491
+ return {
492
+ modell,
493
+ status: 'klar',
494
+ orsak: 'dom fälld',
495
+ korning: nyckel,
496
+ wall_ms: null,
497
+ usd: null,
498
+ varningar,
499
+ }
500
+ }
501
+
502
+ /** En ticket: underlagskontroll, varianter, sedan alla modeller samtidigt. */
503
+ async function korTicket(exp, val, pagaende, ut) {
504
+ const namn = path.basename(exp)
505
+ const u = kontrolleraUnderlag(exp)
506
+ if (!u.ok) {
507
+ ut(`SAKNAS ${namn}: ${u.saknas.join(', ')}`)
508
+ return {
509
+ ticket: namn,
510
+ rader: [
511
+ { modell: '-', status: 'underlag saknas', orsak: u.saknas.join(', '), korning: null },
512
+ ],
513
+ }
514
+ }
515
+ const varningar = []
516
+ const va = arkiv(
517
+ ['experiment', exp, ...val.modeller.flatMap((m) => ['--variant', m])],
518
+ val.repoRot,
519
+ )
520
+ if (va.status !== 0) varningar.push(`experiment --variant: ${felText(va)}`)
521
+ const rader = await Promise.all(
522
+ val.modeller.map(async (modell) => {
523
+ const senaste = senasteKorning(u.experiment, modellKatalog(modell))
524
+ const beslut = aterupptagning(senaste?.status ?? undefined, { om: val.om })
525
+ if (beslut === 'hoppa') {
526
+ ut(`HOPPAR ${u.ticket} ${modell} ${senaste.status} (${senaste.nyckel})`)
527
+ return {
528
+ modell,
529
+ status: senaste.status,
530
+ orsak: senaste.orsak,
531
+ korning: senaste.nyckel,
532
+ wall_ms: null,
533
+ usd: null,
534
+ varningar: [],
535
+ }
536
+ }
537
+ if (beslut === 'prova-sparr')
538
+ return provaSparr({
539
+ gid: u.ticket,
540
+ modell,
541
+ expDir: exp,
542
+ repoRot: val.repoRot,
543
+ nyckel: senaste.nyckel,
544
+ ut,
545
+ })
546
+ return korVarv({
547
+ gid: u.ticket,
548
+ modell,
549
+ expDir: exp,
550
+ repoRot: val.repoRot,
551
+ konto: val.konto,
552
+ claude: val.claude,
553
+ pagaende,
554
+ ut,
555
+ })
556
+ }),
557
+ )
558
+ return { ticket: u.ticket, rader, varningar }
559
+ }
560
+
561
+ async function main(argv) {
562
+ const val = tolkaArgv(argv)
563
+ if (val.fel) {
564
+ console.error(val.fel)
565
+ console.error(ANVANDNING)
566
+ return 2
567
+ }
568
+ if (!existsSync(val.repoRot)) {
569
+ console.error(`--repo-rot finns inte: ${val.repoRot}`)
570
+ return 2
571
+ }
572
+ const ut = (rad) => process.stdout.write(rad + '\n')
573
+ const pagaende = new Map()
574
+
575
+ // Ctrl-C: döda barnen, lämna matning.json och status ofärdig efter dem, så
576
+ // ett återupptaget svep kör om precis de varv som avbröts.
577
+ process.on('SIGINT', () => {
578
+ for (const [barn, info] of pagaende) {
579
+ barn.kill('SIGTERM')
580
+ const avslutad = new Date()
581
+ skrivMatning(info.katalog, {
582
+ startad: info.startad.toISOString(),
583
+ avslutad: avslutad.toISOString(),
584
+ wall_ms: avslutad.getTime() - info.startad.getTime(),
585
+ exit_code: null,
586
+ signal: 'SIGINT',
587
+ })
588
+ arkiv(
589
+ [
590
+ 'experiment',
591
+ info.expDir,
592
+ '--korning',
593
+ info.nyckel,
594
+ '--status',
595
+ 'ofärdig',
596
+ '--orsak',
597
+ 'avbruten med Ctrl-C',
598
+ ],
599
+ val.repoRot,
600
+ )
601
+ }
602
+ console.error(`avbrutet: ${pagaende.size} körning(ar) dödade, matning.json skrivna`)
603
+ process.exit(SIGINT_EXIT)
604
+ })
605
+
606
+ const exps = val.exps.map((e) => losExp(e, val.repoRot))
607
+ const resultat = await schemalagg(exps, val.taket, (exp) => korTicket(exp, val, pagaende, ut))
608
+
609
+ const rader = []
610
+ for (const [i, r] of resultat.entries()) {
611
+ if (r.fel) {
612
+ rader.push({ ticket: path.basename(exps[i]), modell: '-', status: 'fel', orsak: r.fel })
613
+ continue
614
+ }
615
+ for (const rad of r.rader) rader.push({ ticket: r.ticket, ...rad })
616
+ for (const v of [...(r.varningar ?? []), ...r.rader.flatMap((x) => x.varningar ?? [])])
617
+ console.error(`VARNING ${r.ticket}: ${v}`)
618
+ }
619
+ ut('')
620
+ ut(renderaTabell(rader))
621
+ // Saknat underlag är också exit 1: ett svep som tyst hoppar en ticket ser
622
+ // annars lyckat ut i en loggrad ingen läser.
623
+ return rader.some(
624
+ (r) => r.status === 'ofärdig' || r.status === 'fel' || r.status === 'underlag saknas',
625
+ )
626
+ ? 1
627
+ : 0
628
+ }
629
+
630
+ /** Huvudloopen körs bara när scriptet startas direkt; en import (testerna)
631
+ * exekverar ingenting. */
632
+ const arDirektKord = () => {
633
+ try {
634
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
635
+ } catch {
636
+ return false
637
+ }
638
+ }
639
+
640
+ if (arDirektKord()) main(process.argv.slice(2)).then((kod) => process.exit(kod))