@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.
- package/CHANGELOG.md +13 -0
- package/dist/cli.js +913 -751
- package/library/catalog/manifest.json +67 -61
- package/library/global/agents/request-assessor.md +10 -2
- package/library/global/agents/request-specifier-eskalerad.md +8 -0
- package/library/global/agents/request-specifier.md +8 -3
- package/library/global/skills/request/SKILL.md +590 -227
- package/library/global/skills/request/scripts/archive.mjs +736 -59
- package/library/global/skills/request/scripts/compare-scores.mjs +119 -13
- package/library/global/skills/request/scripts/contract-check.mjs +268 -40
- package/library/global/skills/request/scripts/kontroll.mjs +622 -0
- package/library/global/skills/request/scripts/korning.mjs +1502 -0
- package/library/global/skills/request/scripts/lib.mjs +477 -5
- package/library/global/skills/request/scripts/precheck.mjs +132 -5
- package/library/global/skills/request/scripts/report.mjs +85 -14
- package/library/global/skills/request/scripts/svep.mjs +640 -0
- package/library/global/skills/request-assess-impact/SKILL.md +45 -4
- package/library/global/skills/request-score/SKILL.md +51 -11
- package/library/global/skills/request-score/references/poangskala.md +3 -0
- package/library/global/skills/request-sync/SKILL.md +3 -3
- package/library/global/skills/request-write-spec/SKILL.md +9 -4
- package/library/global/skills/request-write-spec/references/specformat.md +48 -0
- package/package.json +1 -1
|
@@ -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))
|