@renzrollon/interlock 1.4.1 → 1.5.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/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +47 -0
- package/README.md +28 -3
- package/bin/interlock +16 -5
- package/hooks/mod.mjs +307 -48
- package/lib/doctor.mjs +7 -6
- package/lib/graph/cli.mjs +93 -15
- package/lib/graph/extract/bicep.mjs +67 -0
- package/lib/graph/extract/create-extractor.mjs +15 -0
- package/lib/graph/extract/csharp.mjs +206 -0
- package/lib/graph/extract/csproj.mjs +42 -0
- package/lib/graph/extract/memory.mjs +9 -4
- package/lib/graph/extract/openspec.mjs +9 -5
- package/lib/graph/extract/walk.mjs +37 -12
- package/lib/graph/graph/build.mjs +77 -13
- package/lib/graph/graph/link-csharp.mjs +210 -0
- package/lib/graph/graph/query.mjs +2 -0
- package/lib/graph/graph/report.mjs +11 -1
- package/lib/graph/graph/update.mjs +56 -16
- package/lib/graph/paths.mjs +56 -4
- package/lib/graph/retrieve/context.mjs +2 -0
- package/lib/graph/retrieve/docs-index.mjs +9 -10
- package/lib/graph/retrieve/markdown.mjs +8 -8
- package/lib/limits.mjs +11 -3
- package/lib/outcomes.mjs +8 -1
- package/lib/prompts/implementer.mjs +28 -3
- package/lib/prompts/index.mjs +37 -1
- package/lib/prompts/rejected.mjs +24 -0
- package/lib/prompts/review.mjs +38 -1
- package/lib/receipt.mjs +21 -1
- package/lib/resume-card.mjs +19 -0
- package/lib/run-log.mjs +30 -2
- package/lib/run.mjs +198 -25
- package/lib/verify.mjs +32 -1
- package/lib/waves.mjs +161 -19
- package/package.json +2 -2
- package/shared/TEST-PROFILE.md +1 -1
- package/skills/bootstrap/SKILL.md +11 -4
- package/skills/fix-tests/SKILL.md +1 -0
- package/skills/graph/SKILL.md +12 -1
- package/workflows/ship.js +2 -1
package/lib/run.mjs
CHANGED
|
@@ -56,7 +56,14 @@ import {
|
|
|
56
56
|
import { detectUnarchived } from './drift.mjs'
|
|
57
57
|
import { projectSlug } from './project-slug.mjs'
|
|
58
58
|
import { composeCloseMessage, postNtfy, readNotifyConfig } from './notify.mjs'
|
|
59
|
-
import {
|
|
59
|
+
import {
|
|
60
|
+
BANNERS as VERIFY_BANNERS,
|
|
61
|
+
SKIP_REASONS,
|
|
62
|
+
foldCheckTimings,
|
|
63
|
+
interWaveVerifyBudget,
|
|
64
|
+
planVerification,
|
|
65
|
+
judgeVerification
|
|
66
|
+
} from './verify.mjs'
|
|
60
67
|
import { resolveReview, readReviewPolicy } from './review-core.mjs'
|
|
61
68
|
import { writeReviewMetrics } from './metrics.mjs'
|
|
62
69
|
import { planRemediation } from './remediate.mjs'
|
|
@@ -76,7 +83,8 @@ import {
|
|
|
76
83
|
nextStep,
|
|
77
84
|
planWaves,
|
|
78
85
|
recordBatchResult,
|
|
79
|
-
recordVerifyResult
|
|
86
|
+
recordVerifyResult,
|
|
87
|
+
runRejectedReports
|
|
80
88
|
} from './waves.mjs'
|
|
81
89
|
import {
|
|
82
90
|
assembleCommitPrompt,
|
|
@@ -1120,6 +1128,7 @@ const PASS_THROUGH = Object.freeze([
|
|
|
1120
1128
|
'batchCount',
|
|
1121
1129
|
'remainingBatches',
|
|
1122
1130
|
'previousHandoffs',
|
|
1131
|
+
'previousRejectedReports',
|
|
1123
1132
|
'changed',
|
|
1124
1133
|
'mode',
|
|
1125
1134
|
'runMode',
|
|
@@ -1191,6 +1200,9 @@ export function decorate(ctx, step, manifest, extra = {}) {
|
|
|
1191
1200
|
// add` itself. Both fold through the same `run record-batch`.
|
|
1192
1201
|
const driverWorktrees = isolate && hostCapability(manifest, 'worktree') === 'driver'
|
|
1193
1202
|
const previousHandoffs = Array.isArray(step.previousHandoffs) ? step.previousHandoffs : []
|
|
1203
|
+
const previousRejectedReports = Array.isArray(step.previousRejectedReports)
|
|
1204
|
+
? step.previousRejectedReports
|
|
1205
|
+
: []
|
|
1194
1206
|
// The stage line rides on the batch's briefings rather than on a step of its
|
|
1195
1207
|
// own: the agent doing the work is the one with a filesystem, and a marker
|
|
1196
1208
|
// written by anyone else would carry the wrong pid.
|
|
@@ -1217,6 +1229,7 @@ export function decorate(ctx, step, manifest, extra = {}) {
|
|
|
1217
1229
|
change,
|
|
1218
1230
|
lane,
|
|
1219
1231
|
previousHandoffs,
|
|
1232
|
+
previousRejectedReports,
|
|
1220
1233
|
isolateWaves: isolate,
|
|
1221
1234
|
solo: step.mode === 'solo'
|
|
1222
1235
|
})
|
|
@@ -1338,12 +1351,18 @@ function verifyStep(ctx, manifest, { context, label, changed, carried, action =
|
|
|
1338
1351
|
opts.e2e = manifest.flags.skipE2e !== true
|
|
1339
1352
|
opts.coverage = manifest.flags.skipCoverage !== true
|
|
1340
1353
|
} else {
|
|
1341
|
-
// The budget's input
|
|
1342
|
-
// invocations on the manifest,
|
|
1343
|
-
//
|
|
1344
|
-
// comparison would be `0 >= budget`
|
|
1345
|
-
// bound nothing.
|
|
1354
|
+
// The budget's input: time already spent verifying, compared against the
|
|
1355
|
+
// run's inter-wave budget. Accumulated across invocations on the manifest,
|
|
1356
|
+
// because this process runs BETWEEN agent turns and never sees the interval
|
|
1357
|
+
// a verify agent spends; without it the comparison would be `0 >= budget`
|
|
1358
|
+
// forever and the budget would bound nothing.
|
|
1346
1359
|
opts.elapsedMs = numberOrUndefined(manifest.verifyElapsedMs) || 0
|
|
1360
|
+
// The budget fixed when the run started (`settleStateHome`), never re-derived
|
|
1361
|
+
// from a profile an agent in the tree may have edited since. A manifest
|
|
1362
|
+
// written before the field existed carries none and gets the published
|
|
1363
|
+
// default, and says nothing about its absence.
|
|
1364
|
+
const fixed = manifest.verifyBudget && manifest.verifyBudget.ms
|
|
1365
|
+
if (Number.isSafeInteger(fixed) && fixed > 0) opts.budgetMs = fixed
|
|
1347
1366
|
}
|
|
1348
1367
|
const plan = planVerification(profile, opts)
|
|
1349
1368
|
writeWork(root, `vplan-${context}.json`, plan)
|
|
@@ -1351,6 +1370,32 @@ function verifyStep(ctx, manifest, { context, label, changed, carried, action =
|
|
|
1351
1370
|
// cannot tell them apart — so the slate is cleared where the checks are named.
|
|
1352
1371
|
clearVerifyTimings(root)
|
|
1353
1372
|
|
|
1373
|
+
// The one case where the run can name the cause and the remedy: a check was
|
|
1374
|
+
// dropped because the budget ran out while sitting at the published floor,
|
|
1375
|
+
// and it sat there because the profile carried no usable `unit.timeout_ms`.
|
|
1376
|
+
// A budget at the floor because the profile's own allowance is small raises
|
|
1377
|
+
// nothing — that profile had its say. Decided before the branch below, because
|
|
1378
|
+
// a checkpoint cut to typecheck alone is reduced exactly as one cut to nothing
|
|
1379
|
+
// is: a profile with a typecheck command never produces an empty plan, and
|
|
1380
|
+
// would otherwise never be told. Once per run, so the second reduced
|
|
1381
|
+
// checkpoint is not the same line again.
|
|
1382
|
+
let floor = null
|
|
1383
|
+
if (
|
|
1384
|
+
interWave &&
|
|
1385
|
+
plan.budgetExceeded &&
|
|
1386
|
+
plan.skipped.some(s => s && s.reason === SKIP_REASONS.BUDGET_EXCEEDED) &&
|
|
1387
|
+
manifest.verifyBudget &&
|
|
1388
|
+
manifest.verifyBudget.timeoutMs === null &&
|
|
1389
|
+
!manifest.verifyBudgetFloorSpoken
|
|
1390
|
+
) {
|
|
1391
|
+
floor =
|
|
1392
|
+
'VERIFY BUDGET AT THE PUBLISHED FLOOR: the test profile carries no unit.timeout_ms, so the ' +
|
|
1393
|
+
'inter-wave budget was not raised to fit this suite — run /interlock:fix-tests --reconfigure ' +
|
|
1394
|
+
'to record one'
|
|
1395
|
+
manifest.banners.push(floor)
|
|
1396
|
+
manifest.verifyBudgetFloorSpoken = true
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1354
1399
|
if (!plan.steps.length) {
|
|
1355
1400
|
// A spent budget is the reason, not whichever kind happens to sit first in
|
|
1356
1401
|
// `skipped` — typecheck is skipped for its own missing command and would
|
|
@@ -1358,13 +1403,15 @@ function verifyStep(ctx, manifest, { context, label, changed, carried, action =
|
|
|
1358
1403
|
const reason = plan.budgetExceeded
|
|
1359
1404
|
? SKIP_REASONS.BUDGET_EXCEEDED
|
|
1360
1405
|
: (plan.skipped[0] && plan.skipped[0].reason) || 'no detectable commands'
|
|
1406
|
+
const banners = [...(carried.banners || []), `VERIFICATION SKIPPED: reason=${reason}`]
|
|
1407
|
+
if (floor) banners.push(floor)
|
|
1361
1408
|
return makeStep({
|
|
1362
1409
|
action,
|
|
1363
1410
|
...carried,
|
|
1364
1411
|
context,
|
|
1365
1412
|
skipped: true,
|
|
1366
1413
|
reason,
|
|
1367
|
-
banners
|
|
1414
|
+
banners,
|
|
1368
1415
|
then: { argv: ['run', 'judge', '--context', context] },
|
|
1369
1416
|
spawns: []
|
|
1370
1417
|
})
|
|
@@ -1378,6 +1425,9 @@ function verifyStep(ctx, manifest, { context, label, changed, carried, action =
|
|
|
1378
1425
|
return makeStep({
|
|
1379
1426
|
action,
|
|
1380
1427
|
...carried,
|
|
1428
|
+
// Only when it was raised, so a checkpoint that raised nothing is the step
|
|
1429
|
+
// it always was.
|
|
1430
|
+
...(floor ? { banners: [...(carried.banners || []), floor] } : {}),
|
|
1381
1431
|
context,
|
|
1382
1432
|
skipped: false,
|
|
1383
1433
|
then: { argv: ['run', 'judge', '--context', context] },
|
|
@@ -1456,6 +1506,19 @@ function resolveDimensionRubrics(names) {
|
|
|
1456
1506
|
return (Array.isArray(names) ? names : []).map(name => ({ name, rubric: readDimensionRubric(name) }))
|
|
1457
1507
|
}
|
|
1458
1508
|
|
|
1509
|
+
/**
|
|
1510
|
+
* The run's rejected handoff reports, for the review briefing. A run with no
|
|
1511
|
+
* readable wave state has recorded none it can name, and the review is briefed
|
|
1512
|
+
* exactly as it was before the list existed.
|
|
1513
|
+
*/
|
|
1514
|
+
function rejectedReportsOfRun(root) {
|
|
1515
|
+
try {
|
|
1516
|
+
return runRejectedReports(readState(root))
|
|
1517
|
+
} catch {
|
|
1518
|
+
return []
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1459
1522
|
/**
|
|
1460
1523
|
* The review step: one worker, every selected dimension fanned out inside it,
|
|
1461
1524
|
* two skeptics per finding (design D1, D3, D4, D8).
|
|
@@ -1485,6 +1548,11 @@ function reviewStep(ctx, manifest, carried = {}) {
|
|
|
1485
1548
|
// added dimension can be told apart from one the CLI asked for (design D4).
|
|
1486
1549
|
manifest.reviewDimensions = { selected: names, reasons }
|
|
1487
1550
|
|
|
1551
|
+
// Tasks recorded as done whose handoff packet was rejected. A later wave was
|
|
1552
|
+
// told about its predecessor's; the last wave's had nobody to tell. The review
|
|
1553
|
+
// reads the whole diff, so it is told about every one the run recorded.
|
|
1554
|
+
const rejectedReports = rejectedReportsOfRun(root)
|
|
1555
|
+
|
|
1488
1556
|
const stageLine = publishStageLine('review', change, ++manifest.stageIndex)
|
|
1489
1557
|
return makeStep({
|
|
1490
1558
|
action: 'review',
|
|
@@ -1506,6 +1574,7 @@ function reviewStep(ctx, manifest, carried = {}) {
|
|
|
1506
1574
|
change,
|
|
1507
1575
|
dimensions,
|
|
1508
1576
|
policyProse: policy.prose,
|
|
1577
|
+
rejectedReports,
|
|
1509
1578
|
findingsPath: FINDINGS_PATH,
|
|
1510
1579
|
verdictsPath: VERDICTS_PATH,
|
|
1511
1580
|
stageLine
|
|
@@ -1793,6 +1862,15 @@ export function runRemediated(ctx, { round, results, hostRecords }) {
|
|
|
1793
1862
|
|
|
1794
1863
|
// --- small file helpers -----------------------------------------------------
|
|
1795
1864
|
|
|
1865
|
+
function readProfileAt(path) {
|
|
1866
|
+
if (typeof path !== 'string' || !path) return null
|
|
1867
|
+
try {
|
|
1868
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
1869
|
+
} catch {
|
|
1870
|
+
return null
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1796
1874
|
function readProfile(root, manifest) {
|
|
1797
1875
|
// The path `run start` located, root first and the state home second (design
|
|
1798
1876
|
// D16), so a worktree run reads the main checkout's profile without copying
|
|
@@ -2029,9 +2107,11 @@ function headCommit(ctx) {
|
|
|
2029
2107
|
//
|
|
2030
2108
|
// One authority for "what did this task do": the outcome `record-batch`
|
|
2031
2109
|
// recorded, not the outcome the implementing agent reported for itself. The
|
|
2032
|
-
// claim is the input the state machine adjudicated —
|
|
2033
|
-
//
|
|
2034
|
-
// is the one a tick and a tally are built from.
|
|
2110
|
+
// claim is the input the state machine adjudicated — a lane result that omits an
|
|
2111
|
+
// outcome fails every task in the lane — so where the two differ the recorded
|
|
2112
|
+
// outcome is the one a tick and a tally are built from. (A rejected handoff
|
|
2113
|
+
// packet is not such a difference: the task stays done and the rejection travels
|
|
2114
|
+
// beside the outcome, as `reportRejected`.)
|
|
2035
2115
|
//
|
|
2036
2116
|
// Both hosts read this from one place now. A second copy of "which of two
|
|
2037
2117
|
// disagreeing observations is the run's" would drift, and both hosts would
|
|
@@ -2055,10 +2135,17 @@ export function recordedOutcomes(raw) {
|
|
|
2055
2135
|
if (!entry || typeof entry !== 'object') return null
|
|
2056
2136
|
if (typeof entry.id !== 'string' || !entry.id.trim()) return null
|
|
2057
2137
|
if (!RECORDED_OUTCOMES.includes(entry.outcome)) return null
|
|
2138
|
+
const rejected =
|
|
2139
|
+
typeof entry.reportRejected === 'string' && entry.reportRejected.trim()
|
|
2140
|
+
? entry.reportRejected.trim()
|
|
2141
|
+
: null
|
|
2058
2142
|
out.push({
|
|
2059
2143
|
id: entry.id.trim(),
|
|
2060
2144
|
outcome: entry.outcome,
|
|
2061
|
-
reason: typeof entry.reason === 'string' && entry.reason.trim() ? entry.reason.trim() : ''
|
|
2145
|
+
reason: typeof entry.reason === 'string' && entry.reason.trim() ? entry.reason.trim() : '',
|
|
2146
|
+
// A whitelist rebuilds the object, so a field not named here is dropped
|
|
2147
|
+
// without any caller noticing. Only a non-empty string is a rejection.
|
|
2148
|
+
...(rejected ? { reportRejected: rejected } : {})
|
|
2062
2149
|
})
|
|
2063
2150
|
}
|
|
2064
2151
|
return out
|
|
@@ -2085,7 +2172,8 @@ export function adjudicateBatches(batches, recorded) {
|
|
|
2085
2172
|
return {
|
|
2086
2173
|
id: claim.id,
|
|
2087
2174
|
outcome: found ? found.outcome : claim.ok ? 'ok' : 'failed',
|
|
2088
|
-
reason: found ? found.reason : typeof claim.error === 'string' ? claim.error : ''
|
|
2175
|
+
reason: found ? found.reason : typeof claim.error === 'string' ? claim.error : '',
|
|
2176
|
+
...(found && found.reportRejected ? { reportRejected: found.reportRejected } : {})
|
|
2089
2177
|
}
|
|
2090
2178
|
})
|
|
2091
2179
|
|
|
@@ -2094,21 +2182,32 @@ export function adjudicateBatches(batches, recorded) {
|
|
|
2094
2182
|
return {
|
|
2095
2183
|
claimDerived: !verdicts,
|
|
2096
2184
|
tickIds: rows.filter(r => r.outcome === 'ok').map(r => r.id),
|
|
2097
|
-
waves: perBatch.map(batchRows =>
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2185
|
+
waves: perBatch.map(batchRows => {
|
|
2186
|
+
const rejectedIds = batchRows.filter(r => r.reportRejected).map(r => r.id)
|
|
2187
|
+
return {
|
|
2188
|
+
ok: batchRows.filter(r => r.outcome === 'ok').length,
|
|
2189
|
+
failed: batchRows.filter(r => r.outcome === 'failed').length,
|
|
2190
|
+
failedIds: batchRows.filter(r => r.outcome === 'failed').map(r => r.id),
|
|
2191
|
+
recordedNotAttempted: batchRows.filter(r => r.outcome === 'not-attempted').map(r => r.id),
|
|
2192
|
+
// Only a batch that held one carries the key, so a clean wave keeps the
|
|
2193
|
+
// shape it always had.
|
|
2194
|
+
...(rejectedIds.length ? { reportRejected: rejectedIds } : {})
|
|
2195
|
+
}
|
|
2196
|
+
}),
|
|
2197
|
+
// A rejected report is a task that did its work and lost its packet: it is
|
|
2198
|
+
// recorded as done, so claim and record agree and `overrides` is left alone.
|
|
2199
|
+
rejected: rows.filter(r => r.reportRejected).map(r => ({ id: r.id, reason: r.reportRejected })),
|
|
2103
2200
|
overrides: rows.filter((row, i) => verdicts && (row.outcome === 'ok') !== claims[i].ok)
|
|
2104
2201
|
}
|
|
2105
2202
|
}
|
|
2106
2203
|
|
|
2107
2204
|
/**
|
|
2108
2205
|
* An overridden claim is a finding, not a silent correction. A run that quietly
|
|
2109
|
-
*
|
|
2110
|
-
* says it overrode five claims tells its reader the implementer prompt
|
|
2111
|
-
*
|
|
2206
|
+
* re-recorded five claimed successes is indistinguishable from a clean one; a
|
|
2207
|
+
* run that says it overrode five claims tells its reader the implementer prompt
|
|
2208
|
+
* or the result contract needs attention. A rejected handoff packet is not an
|
|
2209
|
+
* override — claim and record agree there — and has its own banner
|
|
2210
|
+
* (`rejectedBanner`).
|
|
2112
2211
|
*/
|
|
2113
2212
|
export function overrideBanner(overrides) {
|
|
2114
2213
|
const detail = overrides
|
|
@@ -2132,6 +2231,59 @@ export function claimDerivedBanner() {
|
|
|
2132
2231
|
)
|
|
2133
2232
|
}
|
|
2134
2233
|
|
|
2234
|
+
/**
|
|
2235
|
+
* Who reads a rejected report next, said in the banner's last clause. A later
|
|
2236
|
+
* wave is briefed with it. The last wave has none, and a banner that claimed
|
|
2237
|
+
* one would be a degradation spoken falsely — so it names what does stand
|
|
2238
|
+
* between that work and the commit instead, or says that nothing does.
|
|
2239
|
+
*/
|
|
2240
|
+
const REJECTED_REPORT_TOLD = Object.freeze({
|
|
2241
|
+
'next-wave': 'the next wave is told these tasks filed no usable report',
|
|
2242
|
+
review:
|
|
2243
|
+
'no later wave remains to be told, so the review is told instead; final verification still ' +
|
|
2244
|
+
'gates the commit',
|
|
2245
|
+
'final-verify':
|
|
2246
|
+
'no later wave remains to be told, so final verification is the only check on this work',
|
|
2247
|
+
none:
|
|
2248
|
+
'no later wave remains to be told and --apply-only ends the run here, so nothing in this run ' +
|
|
2249
|
+
'checks this work'
|
|
2250
|
+
})
|
|
2251
|
+
|
|
2252
|
+
/**
|
|
2253
|
+
* Which of those applies to a batch recorded against `state` (the state BEFORE
|
|
2254
|
+
* the batch was recorded, whose cursor still names the batch's own wave).
|
|
2255
|
+
* Earlier batches of the same wave are not told either way: only a later wave is.
|
|
2256
|
+
*/
|
|
2257
|
+
export function rejectedReportReader(state, flags = {}) {
|
|
2258
|
+
const waveIndex = state && state.cursor ? state.cursor.waveIndex : -1
|
|
2259
|
+
if (state && Array.isArray(state.waves) && state.waves[waveIndex + 1]) return 'next-wave'
|
|
2260
|
+
if (flags.applyOnly === true) return 'none'
|
|
2261
|
+
return flags.review === true ? 'review' : 'final-verify'
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
/**
|
|
2265
|
+
* A report that could not be used, said out loud once per batch that held any.
|
|
2266
|
+
* The tasks are recorded as done, so the banner is the only place a reader learns
|
|
2267
|
+
* that no packet was kept for them, and who was told so.
|
|
2268
|
+
*
|
|
2269
|
+
* @param {string[]} ids the batch's rejected task ids
|
|
2270
|
+
* @param {Array<{id: string, reason: string}>} rejected the adjudication's list
|
|
2271
|
+
* @param {keyof typeof REJECTED_REPORT_TOLD} [reader] `rejectedReportReader`'s answer
|
|
2272
|
+
*/
|
|
2273
|
+
export function rejectedBanner(ids, rejected, reader = 'next-wave') {
|
|
2274
|
+
const detail = ids
|
|
2275
|
+
.map(id => {
|
|
2276
|
+
const found = rejected.find(r => r.id === id)
|
|
2277
|
+
return found && found.reason ? `${id} — ${found.reason}` : id
|
|
2278
|
+
})
|
|
2279
|
+
.join('; ')
|
|
2280
|
+
const told = REJECTED_REPORT_TOLD[reader] || REJECTED_REPORT_TOLD['next-wave']
|
|
2281
|
+
return (
|
|
2282
|
+
`HANDOFF REJECTED: ${detail} — recorded as done and not counted toward the task-failure cap; ` +
|
|
2283
|
+
told
|
|
2284
|
+
)
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2135
2287
|
/**
|
|
2136
2288
|
* Turn one lane's agent result into an outcome per task it was given.
|
|
2137
2289
|
*
|
|
@@ -2414,6 +2566,11 @@ function settleStateHome(ctx, manifest, given) {
|
|
|
2414
2566
|
const profile = locateInput(root, home, TEST_PROFILE_PATH)
|
|
2415
2567
|
manifest.testProfilePath = profile.path
|
|
2416
2568
|
manifest.testProfileSource = profile.source
|
|
2569
|
+
// The inter-wave verify budget, fixed here and read from the manifest from now
|
|
2570
|
+
// on (design D16): a profile an agent in the tree rewrites mid-run moves
|
|
2571
|
+
// nothing, in either direction. An unreadable profile derives the published
|
|
2572
|
+
// budget, the same as an absent one.
|
|
2573
|
+
manifest.verifyBudget = interWaveVerifyBudget(readProfileAt(profile.path))
|
|
2417
2574
|
if (profile.source === 'state-home') banners.push(`TEST PROFILE FROM MAIN CHECKOUT: ${profile.path}`)
|
|
2418
2575
|
else if (profile.source === 'none') banners.push(VERIFY_BANNERS.noProfile)
|
|
2419
2576
|
|
|
@@ -3049,6 +3206,17 @@ export function runRecordBatch(ctx, { results, hostRecords }) {
|
|
|
3049
3206
|
const verdict = adjudicateBatches([reported], recordedOutcomes(batchOutcomes(state, after)))
|
|
3050
3207
|
if (verdict.claimDerived) banners.push(claimDerivedBanner())
|
|
3051
3208
|
if (verdict.overrides.length) banners.push(overrideBanner(verdict.overrides))
|
|
3209
|
+
for (const counts of verdict.waves) {
|
|
3210
|
+
if (counts.reportRejected) {
|
|
3211
|
+
banners.push(
|
|
3212
|
+
rejectedBanner(
|
|
3213
|
+
counts.reportRejected,
|
|
3214
|
+
verdict.rejected,
|
|
3215
|
+
rejectedReportReader(state, manifest.flags)
|
|
3216
|
+
)
|
|
3217
|
+
)
|
|
3218
|
+
}
|
|
3219
|
+
}
|
|
3052
3220
|
|
|
3053
3221
|
// The lane that stopped early, plus anything the recorder found no result for
|
|
3054
3222
|
// at all. Neither is ticked and neither is a failure. One expression for the
|
|
@@ -3065,7 +3233,8 @@ export function runRecordBatch(ctx, { results, hostRecords }) {
|
|
|
3065
3233
|
ok: counts.ok,
|
|
3066
3234
|
failed: counts.failed,
|
|
3067
3235
|
failedIds: counts.failedIds,
|
|
3068
|
-
notAttempted: notAttemptedOf(counts)
|
|
3236
|
+
notAttempted: notAttemptedOf(counts),
|
|
3237
|
+
...(counts.reportRejected ? { reportRejected: counts.reportRejected } : {})
|
|
3069
3238
|
})
|
|
3070
3239
|
})
|
|
3071
3240
|
|
|
@@ -3091,10 +3260,12 @@ export function runRecordBatch(ctx, { results, hostRecords }) {
|
|
|
3091
3260
|
// What this batch recorded, for the meter's board (design D2): set here, on
|
|
3092
3261
|
// the step record-batch returns, so no other subcommand can emit it.
|
|
3093
3262
|
if (recordedStep && typeof recordedStep === 'object') {
|
|
3263
|
+
const rejectedIds = verdict.waves.flatMap(counts => counts.reportRejected || [])
|
|
3094
3264
|
recordedStep.recorded = {
|
|
3095
3265
|
ok: verdict.tickIds,
|
|
3096
3266
|
failed: verdict.waves.flatMap(counts => counts.failedIds),
|
|
3097
|
-
notAttempted: [...new Set(verdict.waves.flatMap(notAttemptedOf))]
|
|
3267
|
+
notAttempted: [...new Set(verdict.waves.flatMap(notAttemptedOf))],
|
|
3268
|
+
...(rejectedIds.length ? { reportRejected: rejectedIds } : {})
|
|
3098
3269
|
}
|
|
3099
3270
|
}
|
|
3100
3271
|
return finishStep(ctx, manifest, recordedStep)
|
|
@@ -3823,7 +3994,9 @@ export async function runClose(
|
|
|
3823
3994
|
plan: manifest.plan,
|
|
3824
3995
|
commit: manifest.commit,
|
|
3825
3996
|
commitSkipped: manifest.commitSkipped === true,
|
|
3826
|
-
paths: readPathSets(root, manifest)
|
|
3997
|
+
paths: readPathSets(root, manifest),
|
|
3998
|
+
// Absent on a run whose manifest predates the field: stays absent, never zero.
|
|
3999
|
+
...(manifest.verifyBudget ? { verifyBudget: manifest.verifyBudget } : {})
|
|
3827
4000
|
}
|
|
3828
4001
|
// The join's per-wave cache figures, where the host observed none (design D5).
|
|
3829
4002
|
if (useJoin) summary.spend = fillJoinedCache(summary.spend, joined.cacheByWave)
|
package/lib/verify.mjs
CHANGED
|
@@ -87,7 +87,7 @@ export const SKIP_REASONS = {
|
|
|
87
87
|
NO_COMMAND: 'no-detectable-command',
|
|
88
88
|
/** The failures were already red before this change touched anything. */
|
|
89
89
|
PRE_EXISTING: 'failures-are-pre-existing',
|
|
90
|
-
/**
|
|
90
|
+
/** The inter-wave verify budget the caller passed is spent; only typecheck still runs. */
|
|
91
91
|
BUDGET_EXCEEDED: 'verify-budget-exceeded',
|
|
92
92
|
/** `e2e.enabled` is false in the profile. */
|
|
93
93
|
E2E_DISABLED: 'e2e-not-enabled',
|
|
@@ -180,6 +180,37 @@ function step(kind, command, extra = {}) {
|
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The inter-wave verify budget for a run, derived from its test profile.
|
|
186
|
+
*
|
|
187
|
+
* `unit.timeout_ms` is the profile's allowance for one suite run; a run may
|
|
188
|
+
* spend at most `LIMITS.interWaveVerifications` checkpoints, so the allowance
|
|
189
|
+
* for the checkpoints the cap already permits is their product. The published
|
|
190
|
+
* `LIMITS.interWaveVerifyBudgetMs` is a floor: a profile can only raise the
|
|
191
|
+
* budget, never buy less verification with a small timeout. `source` says which
|
|
192
|
+
* one the budget came from, and `timeoutMs` is the profile's value when it was
|
|
193
|
+
* usable (null when it was not), so a budget left at the floor because the
|
|
194
|
+
* profile said nothing reads differently from one left there because the
|
|
195
|
+
* profile's own allowance is small.
|
|
196
|
+
*
|
|
197
|
+
* `planVerification` is deliberately untouched: its callers pass `budgetMs`.
|
|
198
|
+
*
|
|
199
|
+
* @param {object|null} profile parsed `.claude/testing/profile.json`, or null
|
|
200
|
+
* @returns {{ms: number, source: 'profile'|'published', timeoutMs: number|null}}
|
|
201
|
+
*/
|
|
202
|
+
export function interWaveVerifyBudget(profile) {
|
|
203
|
+
const unit = isObject(profile) && isObject(profile.unit) ? profile.unit : {}
|
|
204
|
+
const timeoutMs =
|
|
205
|
+
Number.isSafeInteger(unit.timeout_ms) && unit.timeout_ms > 0 ? unit.timeout_ms : null
|
|
206
|
+
if (timeoutMs === null) {
|
|
207
|
+
return { ms: LIMITS.interWaveVerifyBudgetMs, source: 'published', timeoutMs: null }
|
|
208
|
+
}
|
|
209
|
+
const derived = Math.min(LIMITS.interWaveVerifications * timeoutMs, Number.MAX_SAFE_INTEGER)
|
|
210
|
+
if (derived > LIMITS.interWaveVerifyBudgetMs) return { ms: derived, source: 'profile', timeoutMs }
|
|
211
|
+
return { ms: LIMITS.interWaveVerifyBudgetMs, source: 'published', timeoutMs }
|
|
212
|
+
}
|
|
213
|
+
|
|
183
214
|
/**
|
|
184
215
|
* Decide which verification commands should run for this change.
|
|
185
216
|
*
|