@haus-tech/haus-workflow 5.0.1 → 5.2.0

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