@bongos/core 1.19.1033 → 1.19.1035
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/.bongos-core.json +204 -99
- package/.claude/skills/builder-start/SKILL.md +1 -1
- package/.claude/skills/grade-recover/SKILL.md +2 -1
- package/.claude/skills/grader-health/SKILL.md +4 -0
- package/.claude/skills/merge-mode/SKILL.md +1 -1
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0089-modular-multi-model-adversarial-grader.md +15 -0
- package/docs/adr/README.md +1 -1
- package/docs/api/openapi.json +120 -4
- package/docs/api-reference.md +9 -2
- package/docs/architecture.md +3 -2
- package/docs/canonical-permissions.md +2 -2
- package/docs/copy-inventory.md +34 -30
- package/docs/copy-registry.json +69 -33
- package/docs/module-api-changelog.md +5 -1
- package/docs/recipes/local-dev.md +45 -3
- package/docs/recipes/packaging-the-core.md +1 -1
- package/modules/autonomy/cadence.js +52 -0
- package/modules/autonomy/db.js +19 -0
- package/modules/autonomy/routes/autonomy.js +28 -0
- package/modules/government/catalog.js +2 -2
- package/modules/government/protected-surfaces.json +1 -0
- package/modules/grading/grade-engine.js +267 -0
- package/modules/grading/grader-subagent.js +4 -3
- package/modules/grading/grader-workers/index.js +67 -11
- package/modules/grading/grader-workers/worker-verdict.js +32 -2
- package/modules/grading/grader.js +109 -88
- package/modules/hall-ui/public/deploy.js +35 -0
- package/modules/hall-ui/public/project-settings.css +7 -0
- package/modules/lifecycle/routes/tasks.js +19 -4
- package/modules/lifecycle/ship-assurance.js +17 -1
- package/modules/provisioning/control-plane-guard.js +14 -13
- package/modules/provisioning/core-upgrade.js +30 -17
- package/modules/provisioning/public-refusal.js +44 -0
- package/modules/provisioning/routes/disconnect.js +2 -1
- package/modules/provisioning/routes/env-manifest.js +5 -4
- package/modules/provisioning/routes/provisioning.js +16 -15
- package/modules/provisioning/routes/repo-private.js +2 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +80 -0
- package/scripts/gds/autobongos-run.js +10 -2
- package/scripts/gds/backfill-cost.js +20 -9
- package/scripts/gds/claim.js +2 -2
- package/scripts/gds/credential-presence.js +281 -0
- package/scripts/gds/deploy-main-audit-gate.sh +11 -4
- package/scripts/gds/dev-lib.js +212 -5
- package/scripts/gds/dev.js +102 -50
- package/scripts/gds/doctor.js +3 -2
- package/scripts/gds/grade-correlation-audit.js +280 -0
- package/scripts/gds/grade-replay.js +237 -0
- package/scripts/gds/hooks/pre-push.js +7 -3
- package/scripts/gds/main-audit.js +19 -10
- package/scripts/gds/memory-map.js +3 -1
- package/scripts/gds/onboarding-config.js +3 -2
- package/scripts/gds/plain-cards.js +4 -0
- package/scripts/gds/preflight.js +12 -4
- package/scripts/gds/provision-core-upgrade.js +155 -4
- package/scripts/gds/provision-repo-auth.js +15 -0
- package/scripts/gds/provision.js +1 -1
- package/scripts/gds/publish-credential-check.js +3 -0
- package/scripts/gds/push-main.js +14 -3
- package/scripts/gds/push-path-brief.js +3 -2
- package/scripts/gds/run-routine.js +18 -5
- package/scripts/gds/run-unit-tests.js +14 -3
- package/scripts/gds/session-cost.js +12 -3
- package/scripts/gds/ship-deploy-target.js +11 -6
- package/scripts/gds/ship-grade-steps.js +33 -0
- package/scripts/gds/ship-land.js +157 -11
- package/scripts/gds/ship-merge.js +3 -2
- package/scripts/gds/ship-preflight-steps.js +30 -8
- package/scripts/gds/ship.js +1 -1
- package/scripts/gds/upgrade.js +3 -3
- package/scripts/hall-preview/README.md +20 -3
- package/scripts/hall-preview/live-identity.js +1 -1
- package/scripts/hall-preview/refresh-fixtures.js +71 -11
- package/scripts/hall-preview/scrub-identity.js +296 -0
- package/src/bongos/core-update.js +23 -1
- package/src/bongos/routes/core-update.js +27 -2
- package/src/bongos/routes/security.js +3 -3
- package/src/build-info.js +4 -2
- package/src/module-api.js +1 -1
- package/tests/adr_0089_scope.mjs +70 -0
- package/tests/autobongos_cadence.mjs +74 -0
- package/tests/consumer_layout_boot.mjs +363 -31
- package/tests/core_upgrade_door.mjs +40 -6
- package/tests/core_upgrade_runner.mjs +240 -4
- package/tests/credential_presence.mjs +153 -0
- package/tests/deploy_audit_gate.mjs +5 -2
- package/tests/deploy_page_projects.mjs +63 -2
- package/tests/dev_lib.mjs +2 -1
- package/tests/dev_windows.mjs +324 -0
- package/tests/env_knobs_neutral_prefix.mjs +203 -0
- package/tests/fixtures/grade-replay/recorded-panel-grades.json +27 -0
- package/tests/government_protected_surfaces.mjs +10 -0
- package/tests/grade_correlation_audit.mjs +159 -0
- package/tests/grade_engine.mjs +249 -0
- package/tests/grade_engine_replay.mjs +99 -0
- package/tests/grader_prompt_window_bound.mjs +132 -0
- package/tests/grader_unbacked_score.mjs +194 -0
- package/tests/hall_preview_scrub.mjs +214 -0
- package/tests/integration_parked_ratchet.mjs +24 -1
- package/tests/provisioning_public_refusal.mjs +195 -0
- package/tests/publish_status_branch.mjs +128 -0
- package/tests/routine_observability_api.mjs +195 -0
- package/tests/ship_assurance.mjs +86 -3
- package/tests/ship_land_red_pr.mjs +236 -0
- package/tests/ship_preflight.mjs +67 -1
- package/tests/start_env_guard.mjs +16 -0
- package/tests/upgrade.mjs +20 -1
|
@@ -46,7 +46,7 @@ If the no-leak gate fails, it names the file + the residual literal. Fix the red
|
|
|
46
46
|
|
|
47
47
|
Two standing gates keep "a published core boots clean from a bare install" honest (task [#1002221](https://example.com/builders#/task/1002221), audit H1/H2):
|
|
48
48
|
|
|
49
|
-
- **`node scripts/gds/run-unit-tests.js --integration`** — the integration-smoke gate. Runs the heavier integration set (longer per-test budget), including [`tests/consumer_layout_boot.mjs`](../../tests/consumer_layout_boot.mjs), which packages the core, `npm install`s the `.tgz`, and boots `/healthz` from the real consumer layout.
|
|
49
|
+
- **`node scripts/gds/run-unit-tests.js --integration`** — the integration-smoke gate. Runs the heavier integration set (longer per-test budget), including [`tests/consumer_layout_boot.mjs`](../../tests/consumer_layout_boot.mjs), which packages the core, `npm install`s the `.tgz`, and boots `/healthz` from the real consumer layout. Since task 1002863 it also carries the database half: it installs the unpacked package from its **own shipped lockfile** with `npm ci`, runs `npm run migrate` from the consumer onto a brand-new **empty** database (and onto a second one with every optional module switched on), then boots the installed server against the first and reads a sentinel row back through `GET /api/bongos/public/versions`. It creates and drops those databases itself, so it needs a Postgres whose role may `CREATE DATABASE`; without one it prints SKIP lines, except under `CI=true`, where a missing Postgres fails the run. The `integration` job of [`.github/workflows/integration.yml`](../../.github/workflows/integration.yml) runs this set on every push to `main` (not per PR — a cost decision recorded in that file). The default `run-unit-tests.js` (no flag) stays the fast DB-free unit gate and *excludes* this set.
|
|
50
50
|
- **[`tests/pruned_lockfile_integrity.mjs`](../../tests/pruned_lockfile_integrity.mjs)** — a network-free unit guard (runs in the default gate every PR) that rebuilds the shipped pruned lockfile and asserts every registry package keeps a **well-formed, verbatim `integrity` hash**. This is the exact R88 regression (redaction once blanked those base64 hashes → `npm ci` could no longer verify) caught before a consumer's install ever fails.
|
|
51
51
|
|
|
52
52
|
## Consume + pin it (the R85 side)
|
|
@@ -50,6 +50,30 @@ const CLASSES = ['progress', 'quiet', 'stopped', 'transient', 'systemic', 'unwor
|
|
|
50
50
|
// Only these advance the failure count toward `probing`.
|
|
51
51
|
const COUNTS_AS_FAILURE = new Set(['transient', 'systemic']);
|
|
52
52
|
|
|
53
|
+
// WORKER_RAN_FLOOR_S — below this, a worker did not do any work (task 1004179).
|
|
54
|
+
//
|
|
55
|
+
// A real Claude Code session that read a brief, touched a repo and produced a
|
|
56
|
+
// verdict does not finish in seconds. The observed broken shape was 2s with no
|
|
57
|
+
// cost recorded at all. The floor is generous on purpose: it only has to
|
|
58
|
+
// separate "the process died on startup" from "a session ran", and being wrong
|
|
59
|
+
// in the permissive direction costs one extra iteration, while being wrong in
|
|
60
|
+
// the strict direction would call a fast legitimate run breakage.
|
|
61
|
+
const WORKER_RAN_FLOOR_S = 30;
|
|
62
|
+
|
|
63
|
+
// workerNeverRan — the supervisor already decided the worker FAILED; this asks
|
|
64
|
+
// whether it ever started. Both conditions are required: a worker that burned
|
|
65
|
+
// real time or real tokens and then failed DID run and met something, which is
|
|
66
|
+
// a different fact that this must not swallow.
|
|
67
|
+
function workerNeverRan(row = {}) {
|
|
68
|
+
if (row.outcome !== 'worker_failed') return false;
|
|
69
|
+
if (row.spawn_error) return true; // it could not even be launched
|
|
70
|
+
const cost = Number(row.cost_usd);
|
|
71
|
+
const spentNothing = !Number.isFinite(cost) || cost <= 0;
|
|
72
|
+
const dur = Number(row.duration_s);
|
|
73
|
+
const tooFast = Number.isFinite(dur) && dur < WORKER_RAN_FLOOR_S;
|
|
74
|
+
return spentNothing && tooFast;
|
|
75
|
+
}
|
|
76
|
+
|
|
53
77
|
// SYSTEMIC_MARKERS — substrings that mean waiting will not help.
|
|
54
78
|
//
|
|
55
79
|
// A heuristic over an error string, which is a thing to be uneasy about, so the
|
|
@@ -86,6 +110,33 @@ function classifyEvent(row = {}) {
|
|
|
86
110
|
// counting it as such would hold a broken runner at full speed indefinitely.
|
|
87
111
|
if (row.verified === true) return { class: 'progress', detail: 'a task shipped and the ledger confirms it' };
|
|
88
112
|
if (row.ledger_shape === 'undetermined') return { class: 'transient', detail: 'the ledger could not be read back' };
|
|
113
|
+
// DID THE WORKER ACTUALLY RUN? (task 1004179, found by the 1003907 proof run.)
|
|
114
|
+
//
|
|
115
|
+
// Everything below this line assumes a worker that ran and met a real
|
|
116
|
+
// obstacle — a grader refusal, a red main, a task it could not finish. That
|
|
117
|
+
// is genuinely quiet: the machine is fine, a blocker was filed, the task is
|
|
118
|
+
// out of the pool, and moving to the next one is right.
|
|
119
|
+
//
|
|
120
|
+
// A worker that never got going is the opposite, and it was landing in the
|
|
121
|
+
// same bucket. On the Windows host every worker exited in ~2s with "worker
|
|
122
|
+
// output was not JSON" because its argv was corrupted; each run filed a
|
|
123
|
+
// blocker, parked a task, and was classed `quiet` — which neither trips nor
|
|
124
|
+
// forgives the failure count. consecutive_failures stayed 0, the mode stayed
|
|
125
|
+
// `full`, and the runner would have worked through the entire allowlisted
|
|
126
|
+
// goal at one task per five minutes, parking every one of them, while the
|
|
127
|
+
// hall showed it alive and allowed to work. It got through one task before a
|
|
128
|
+
// human stopped it.
|
|
129
|
+
//
|
|
130
|
+
// The queue-vs-runner distinction this module already draws is the same one:
|
|
131
|
+
// `unworkable_queue` is a healthy machine with nothing it can do, and must
|
|
132
|
+
// never escalate. A worker that cannot start is a BROKEN machine with plenty
|
|
133
|
+
// it should be doing, and must.
|
|
134
|
+
if (workerNeverRan(row)) {
|
|
135
|
+
return {
|
|
136
|
+
class: looksSystemic(reason) ? 'systemic' : 'transient',
|
|
137
|
+
detail: `the worker never ran (${row.outcome || 'worker_failed'}${Number.isFinite(Number(row.duration_s)) ? `, ${row.duration_s}s` : ''}, no cost recorded): ${String(row.reason || '').slice(0, 200)}`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
89
140
|
// It ran, it did not ship. Not breakage — a blocker was filed and the task is
|
|
90
141
|
// out of the pool — but not progress either. Quiet keeps the runner at full
|
|
91
142
|
// speed so it moves on to the next task, which is the right response.
|
|
@@ -273,4 +324,5 @@ function shouldJumpWait(before, after) {
|
|
|
273
324
|
module.exports = {
|
|
274
325
|
CLASSES, COUNTS_AS_FAILURE, SYSTEMIC_MARKERS, DEFAULTS, START,
|
|
275
326
|
looksSystemic, classifyEvent, nextCadence, signalFingerprint, shouldJumpWait,
|
|
327
|
+
workerNeverRan, WORKER_RAN_FLOOR_S,
|
|
276
328
|
};
|
package/modules/autonomy/db.js
CHANGED
|
@@ -44,6 +44,24 @@ async function lastAutonomyHead(routine) {
|
|
|
44
44
|
return rows[0]?.head_sha || null;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// The recent autonomy-gate decisions, newest first — the off-box answer to "did the
|
|
48
|
+
// routine fire, and what did it decide" (task 1002669). Every precheck writes a row
|
|
49
|
+
// here, every SKIP included, so the table is the whole record; before this read
|
|
50
|
+
// existed, finding out needed an SSH session on the host (blocker 1000106). `routine`
|
|
51
|
+
// null means every routine. The (routine, created_at DESC) index serves both shapes.
|
|
52
|
+
async function readAutonomyRuns({ routine = null, limit = 20 } = {}) {
|
|
53
|
+
const { rows } = await pool.query(
|
|
54
|
+
`SELECT id, routine, decision, reason, head_sha, claimable_count, enabled, created_at
|
|
55
|
+
FROM autonomy_runs
|
|
56
|
+
WHERE ($1::text IS NULL OR routine = $1)
|
|
57
|
+
ORDER BY created_at DESC, id DESC
|
|
58
|
+
LIMIT $2`,
|
|
59
|
+
[routine, limit]
|
|
60
|
+
);
|
|
61
|
+
// bigserial ids arrive as strings from node-postgres; keep them strings on purpose.
|
|
62
|
+
return rows.map((r) => ({ ...r, id: String(r.id) }));
|
|
63
|
+
}
|
|
64
|
+
|
|
47
65
|
// ── the Autobongos fence (task 1003908, ADR 0307 §6) ─────────────────────────
|
|
48
66
|
//
|
|
49
67
|
// These back the one control surface the runner host cannot write to. Everything
|
|
@@ -162,6 +180,7 @@ async function readRunnerHeartbeats(builderId) {
|
|
|
162
180
|
module.exports = {
|
|
163
181
|
recordAutonomyRun,
|
|
164
182
|
lastAutonomyHead,
|
|
183
|
+
readAutonomyRuns,
|
|
165
184
|
recordRunnerHeartbeat,
|
|
166
185
|
readRunnerHeartbeats,
|
|
167
186
|
readFence,
|
|
@@ -42,6 +42,16 @@ const { validateOrRespond } = api;
|
|
|
42
42
|
//
|
|
43
43
|
// rank: archon — autonomy is an ops control surface, and a precheck records an
|
|
44
44
|
// autonomy_runs row + git-fetches. Keep it archon-scoped (ADR 0016).
|
|
45
|
+
// ?limit for GET /autonomy/runs: an integer 1..100, else the default 20. Clamped rather
|
|
46
|
+
// than refused — a read an operator types by hand should answer, not argue.
|
|
47
|
+
const RUNS_DEFAULT_LIMIT = 20;
|
|
48
|
+
const RUNS_MAX_LIMIT = 100;
|
|
49
|
+
function runsLimit(raw) {
|
|
50
|
+
const n = Number.parseInt(String(raw ?? ''), 10);
|
|
51
|
+
if (!Number.isFinite(n)) return RUNS_DEFAULT_LIMIT;
|
|
52
|
+
return Math.min(RUNS_MAX_LIMIT, Math.max(1, n));
|
|
53
|
+
}
|
|
54
|
+
|
|
45
55
|
module.exports = function buildAutonomyRouter() {
|
|
46
56
|
const router = express.Router();
|
|
47
57
|
|
|
@@ -58,6 +68,24 @@ module.exports = function buildAutonomyRouter() {
|
|
|
58
68
|
}
|
|
59
69
|
});
|
|
60
70
|
|
|
71
|
+
// The gate's past decisions — did a routine fire, and what did it decide, and why
|
|
72
|
+
// (task 1002669). READ-ONLY and behind the SAME atom as the precheck above
|
|
73
|
+
// (`autonomy.precheck`, Metic+): whoever may ask the gate the question may read its
|
|
74
|
+
// past answers, and a new atom would need a migration for no change in audience.
|
|
75
|
+
// Adds no gate anywhere — pure observability over rows the precheck already writes.
|
|
76
|
+
// ?routine=<name> narrows to one routine; ?limit=1..100 (default 20), newest first.
|
|
77
|
+
router.get('/autonomy/runs', auth.requireBuilder, auth.requirePermission('autonomy.precheck'), async (req, res) => {
|
|
78
|
+
const routine = typeof req.query.routine === 'string' && req.query.routine ? req.query.routine.slice(0, 64) : null;
|
|
79
|
+
const limit = runsLimit(req.query.limit);
|
|
80
|
+
try {
|
|
81
|
+
const runs = await db.readAutonomyRuns({ routine, limit });
|
|
82
|
+
res.json({ routine, limit, runs });
|
|
83
|
+
} catch (err) {
|
|
84
|
+
log.error('[gds] GET /autonomy/runs', err);
|
|
85
|
+
res.fail('autonomy_runs_unreadable', { status: 503, message: 'could not read the autonomy decisions' });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
61
89
|
// ── the Autobongos fence (task 1003908, ADR 0307 §6) ───────────────────────
|
|
62
90
|
//
|
|
63
91
|
// These four routes ARE the fence. Everything the unattended runner is allowed
|
|
@@ -108,7 +108,7 @@ const PERMISSIONS = [
|
|
|
108
108
|
// owner holds: the catalog's own invariant forbids granting a system key to metic,
|
|
109
109
|
// so this cannot be delegated downward by a custom rank. The web tier still gains
|
|
110
110
|
// no shell - the route only QUEUES an intent the control-plane runner drains.
|
|
111
|
-
{ key: 'core.pin.move', system: true, floor: 'archon', guards: 'POST /provisioning/instances/:id/core-upgrade — gate 3 of the release pipeline (ADR 0293)' },
|
|
111
|
+
{ key: 'core.pin.move', system: true, floor: 'archon', guards: 'POST /provisioning/instances/:id/core-upgrade — gate 3 of the release pipeline (ADR 0293); also the reads GET /core-update (the upgrade banner) and GET /core-upgrades (the upgrade ledger, task 1002669)' },
|
|
112
112
|
// autonomy.fence.manage (task 1003908, ADR 0307 §6) — THE AUTOBONGOS KILL
|
|
113
113
|
// SWITCH and the allowlist of goals an unattended runner may work. system:true
|
|
114
114
|
// + archon for core.pin.move's reason, in a slightly sharper form: this decides
|
|
@@ -253,7 +253,7 @@ const PERMISSIONS = [
|
|
|
253
253
|
{ key: 'security.report.adjudicate', system: false, floor: 'metic', guards: 'GET /security/reports, POST /security/reports/:id/{confirm,dispute,fix,link-fix}, /security/docs CRUD (ADR 0157, was archon)' },
|
|
254
254
|
{ key: 'provisioning.fleet.manage', system: false, floor: 'metic', guards: 'GET /provisioning/fleet[/cost-ledger], POST .../force-teardown (ADR 0157, was archon)' },
|
|
255
255
|
{ key: 'box.fleet.manage', system: false, floor: 'metic', scope: 'any', resource: 'box', verb: 'manage', guards: 'GET /boxes[/cost-ledger], POST /boxes/:b/close, PATCH .../block (ADR 0157, was archon)' },
|
|
256
|
-
{ key: 'autonomy.precheck', system: false, floor: 'metic', guards: 'GET /autonomy/precheck — unsupervised-runner gate (ADR 0157, was archon)' },
|
|
256
|
+
{ key: 'autonomy.precheck', system: false, floor: 'metic', guards: 'GET /autonomy/precheck — unsupervised-runner gate (ADR 0157, was archon); also GET /autonomy/runs, the gate\'s past decisions (task 1002669)' },
|
|
257
257
|
{ key: 'discord.channels.manage', system: false, floor: 'metic', guards: 'GET /discord/channels/plan, POST .../reconcile (ADR 0157, was archon)' },
|
|
258
258
|
// Metic by the discord.channels.manage analogue — configuring a project-wide
|
|
259
259
|
// social surface — rather than archon. The blast radius is bounded by the OTHER
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
{ "glob": "modules/grading/grader-prompt.js", "group": "pipeline", "why": "task 1004024. Carved out of grader.js: the deterministic permission-path pre-pass, the server-side score guards a model cannot talk past, the subagent spawn and the prompt. grader.js alone protected the facade and none of the gate." },
|
|
60
60
|
{ "glob": "modules/grading/grader-score.js", "group": "pipeline", "why": "task 1004024. Carved out of grader.js: the deterministic permission-path pre-pass, the server-side score guards a model cannot talk past, the subagent spawn and the prompt. grader.js alone protected the facade and none of the gate." },
|
|
61
61
|
{ "glob": "modules/grading/grader-subagent.js", "group": "pipeline", "why": "task 1004024. Carved out of grader.js: the deterministic permission-path pre-pass, the server-side score guards a model cannot talk past, the subagent spawn and the prompt. grader.js alone protected the facade and none of the gate." },
|
|
62
|
+
{ "glob": "modules/grading/grade-engine.js", "group": "pipeline", "why": "task 1002839. The central grading engine every work-type is graded through: it runs the deterministic route-rank and permission-path pre-passes and applies the deterministic veto, so editing it can switch off the protected-path gate for every grade at once." },
|
|
62
63
|
{ "glob": "modules/grading/grader-workers/", "group": "pipeline", "why": "the adversarial panel (Narc/Hacker/Quality/Efficiency) — blunting these blunts the gate" },
|
|
63
64
|
{ "glob": "modules/grading/grader-rubric.json", "group": "pipeline", "why": "the grader's scoring rubric (ADR 0058)" },
|
|
64
65
|
{ "glob": "scripts/gds/ship.js", "group": "pipeline", "why": "the ship lifecycle driver" },
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// modules/grading/grade-engine.js — the central grading engine: ONE named
|
|
2
|
+
// pipeline every work-type is graded through, and the registry a work-type's
|
|
3
|
+
// grader registers into (ADR 0089 §1, task 1002839).
|
|
4
|
+
//
|
|
5
|
+
// WHY. ADR 0089 decided the grader is a central, work-type-parameterized engine:
|
|
6
|
+
// the orchestration, the deterministic security pre-passes, the gate and the
|
|
7
|
+
// persisted record are written once, and a work-type contributes only what is
|
|
8
|
+
// genuinely its own — its judges, the evidence they read, and how their verdicts
|
|
9
|
+
// combine. Until this file that pipeline existed only as the body of
|
|
10
|
+
// gradeSubagent, hard-wired to the code panel, so a second work-type had no way
|
|
11
|
+
// in. The stages below are the same ones, in the same order, now named.
|
|
12
|
+
//
|
|
13
|
+
// THE PIPELINE (PIPELINE_STAGES, in order):
|
|
14
|
+
// select resolve the work-type: an explicit key, else task.discipline, else
|
|
15
|
+
// task.kind, else the default (the code panel).
|
|
16
|
+
// pre_pass the deterministic route-rank + permission-path checks. ENGINE-OWNED
|
|
17
|
+
// and zero-LLM: a work-type cannot skip, reorder or soften them.
|
|
18
|
+
// evidence the work-type gathers what its judges read (code: the post-change
|
|
19
|
+
// file bundle + generated-file provenance, read once).
|
|
20
|
+
// fan_out every judge runs independently, in parallel.
|
|
21
|
+
// aggregate the work-type combines its judges' verdicts (code: aggregatePanel).
|
|
22
|
+
// gate ENGINE-OWNED deterministic veto: a pre-pass that hard-failed fails
|
|
23
|
+
// the grade whatever the aggregate said — no verdict, from any model,
|
|
24
|
+
// upgrades a fail over a protected path (ADR 0016 / 0043 / 0080). For
|
|
25
|
+
// the code panel it never fires, because aggregatePanel already fails
|
|
26
|
+
// on a deterministic fail; it is what makes the guarantee hold for a
|
|
27
|
+
// work-type whose aggregate does not.
|
|
28
|
+
// persist shape the record the lifecycle stores: per-judge signals, cost, and
|
|
29
|
+
// the run meta. Grading owns no table (ADR 0091 §4) — the write itself
|
|
30
|
+
// stays db.applyGrade behind POST /tasks/:id/grade, where
|
|
31
|
+
// serverSideGuards re-runs the same pre-passes on authoritative data.
|
|
32
|
+
//
|
|
33
|
+
// THE REGISTRY HAS TWO DOORS, because ADR 0083 forbids one module requiring
|
|
34
|
+
// another. The grading module's own work-types register directly
|
|
35
|
+
// (registerWorkType — the code panel does, from grader.js). Any other module
|
|
36
|
+
// contributes one as a provider on the kernel seam port `grader.workType.<key>`,
|
|
37
|
+
// through the doorway — the only door it has. resolveWorkType reads both, local
|
|
38
|
+
// first, so a seam reset (tests do one) can never lose the default.
|
|
39
|
+
//
|
|
40
|
+
// REPLAY. replayRecorded re-runs aggregate + gate over a grade's PERSISTED
|
|
41
|
+
// per-judge signals — zero model calls. It is how a change to either stage is
|
|
42
|
+
// proven against real recorded grades before it touches the live gate
|
|
43
|
+
// (scripts/gds/grade-replay.js, tests/grade_engine_replay.mjs).
|
|
44
|
+
|
|
45
|
+
'use strict';
|
|
46
|
+
|
|
47
|
+
const api = require('../../src/module-api');
|
|
48
|
+
const { ASSUMED_BUILDER_MODEL, permissionPathPrePass, routeRankPrePass } = require('./grader-config.js');
|
|
49
|
+
const { selectGraderModel } = require('./grader-model.js');
|
|
50
|
+
const { extractAssistantResult, runSubagentCached } = require('./grader-subagent.js');
|
|
51
|
+
|
|
52
|
+
const PIPELINE_STAGES = Object.freeze(['select', 'pre_pass', 'evidence', 'fan_out', 'aggregate', 'gate', 'persist']);
|
|
53
|
+
|
|
54
|
+
const WORK_TYPE_PORT_PREFIX = 'grader.workType.';
|
|
55
|
+
const WORK_TYPE_KEY_RE = /^[a-z][a-z0-9_-]{0,39}$/;
|
|
56
|
+
// What a work-type must supply. gatherEvidence and recordEvidence are optional
|
|
57
|
+
// (a work-type whose judges read only the task + diff needs neither).
|
|
58
|
+
const REQUIRED_HOOKS = Object.freeze(['resolveJudges', 'modelFor', 'runJudge', 'aggregate', 'unavailable']);
|
|
59
|
+
// The pseudo-judge kinds the deterministic pre-passes produce. Replay rebuilds
|
|
60
|
+
// the pre-pass results from them so its gate judges what the live gate judged.
|
|
61
|
+
const DETERMINISTIC_KINDS = Object.freeze(['route-rank', 'permission-path']);
|
|
62
|
+
|
|
63
|
+
const local = new Map(); // key -> work-type registered directly by this module
|
|
64
|
+
let defaultKey = null;
|
|
65
|
+
|
|
66
|
+
function workTypePort(key) {
|
|
67
|
+
return `${WORK_TYPE_PORT_PREFIX}${key}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Refuse a malformed work-type at REGISTRATION, not on the first ship that
|
|
71
|
+
// resolves it — a missing hook would otherwise surface as a TypeError mid-grade.
|
|
72
|
+
function validateWorkType(def) {
|
|
73
|
+
if (!def || typeof def !== 'object') throw new TypeError('grade engine: a work-type must be an object');
|
|
74
|
+
if (typeof def.key !== 'string' || !WORK_TYPE_KEY_RE.test(def.key)) {
|
|
75
|
+
throw new TypeError(`grade engine: work-type key ${JSON.stringify(def.key)} must match ${WORK_TYPE_KEY_RE}`);
|
|
76
|
+
}
|
|
77
|
+
const missing = REQUIRED_HOOKS.filter((h) => typeof def[h] !== 'function');
|
|
78
|
+
for (const h of ['gatherEvidence', 'recordEvidence']) {
|
|
79
|
+
if (def[h] != null && typeof def[h] !== 'function') missing.push(h);
|
|
80
|
+
}
|
|
81
|
+
if (missing.length) throw new TypeError(`grade engine: work-type '${def.key}' is missing hook(s): ${missing.join(', ')}`);
|
|
82
|
+
return def;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Register one of THIS module's work-types. Idempotent for the same definition
|
|
86
|
+
// (grader.js is required from several places); a second, different definition
|
|
87
|
+
// for a key is a wiring bug and throws, like a duplicate seam provider.
|
|
88
|
+
function registerWorkType(def, { isDefault = false } = {}) {
|
|
89
|
+
validateWorkType(def);
|
|
90
|
+
const held = local.get(def.key);
|
|
91
|
+
if (held && held !== def) throw new Error(`grade engine: work-type '${def.key}' is already registered`);
|
|
92
|
+
local.set(def.key, def);
|
|
93
|
+
if (isDefault) defaultKey = def.key;
|
|
94
|
+
return def;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A work-type contributed by another module through the seam, or null. An
|
|
98
|
+
// invalid contribution is refused loudly and treated as absent, so a broken
|
|
99
|
+
// module degrades to the default grader instead of crashing every grade.
|
|
100
|
+
function seamWorkType(key) {
|
|
101
|
+
const port = workTypePort(key);
|
|
102
|
+
if (!api.hasProvider(port)) return null;
|
|
103
|
+
const def = api.resolve(port);
|
|
104
|
+
try {
|
|
105
|
+
validateWorkType(def);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.error(`grade engine: ignoring invalid work-type on port ${port}: ${err.message}`);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
return def.key === key ? def : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function lookup(key) {
|
|
114
|
+
if (typeof key !== 'string' || !key) return null;
|
|
115
|
+
return local.get(key) || seamWorkType(key);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// select — explicit key, then the task's discipline, then its kind, then the
|
|
119
|
+
// default. Never throws once a default is registered: an unknown or mistyped
|
|
120
|
+
// key grades on the default rather than stranding the ship (the task 1002659
|
|
121
|
+
// posture — a grade step that throws strands the ship).
|
|
122
|
+
function resolveWorkType({ workType, task } = {}) {
|
|
123
|
+
if (workType && typeof workType === 'object') return validateWorkType(workType);
|
|
124
|
+
const t = task || {};
|
|
125
|
+
for (const key of [workType, t.discipline, t.kind]) {
|
|
126
|
+
const def = lookup(key);
|
|
127
|
+
if (def) return def;
|
|
128
|
+
}
|
|
129
|
+
const def = defaultKey ? local.get(defaultKey) : null;
|
|
130
|
+
if (!def) throw new Error('grade engine: no default work-type registered (modules/grading/grader.js registers the code panel)');
|
|
131
|
+
return def;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Every work-type the engine can currently resolve, sorted — local and seam.
|
|
135
|
+
function listWorkTypes() {
|
|
136
|
+
const keys = new Set(local.keys());
|
|
137
|
+
for (const port of api.listPorts()) {
|
|
138
|
+
if (!port.startsWith(WORK_TYPE_PORT_PREFIX)) continue;
|
|
139
|
+
const key = port.slice(WORK_TYPE_PORT_PREFIX.length);
|
|
140
|
+
if (seamWorkType(key)) keys.add(key);
|
|
141
|
+
}
|
|
142
|
+
return [...keys].sort();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// pre_pass — both deterministic checks, in this order, for every work-type. Each
|
|
146
|
+
// is null (zero cost) when its trigger is absent; the rest of the pipeline sees
|
|
147
|
+
// only the ones that ran.
|
|
148
|
+
function runPrePasses({ changedFiles, builderRank }) {
|
|
149
|
+
return [routeRankPrePass(changedFiles), permissionPathPrePass(changedFiles, builderRank)].filter(Boolean);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// gate — the deterministic veto. Downgrade-only: it can turn a non-fail into a
|
|
153
|
+
// fail, never the reverse, and it returns the aggregate UNTOUCHED when there is
|
|
154
|
+
// nothing to enforce (always, for the code panel).
|
|
155
|
+
function applyDeterministicGate(result, prePasses) {
|
|
156
|
+
const failed = prePasses.filter((p) => p && p.workerResult && p.workerResult.verdict === 'fail');
|
|
157
|
+
if (!failed.length) return result;
|
|
158
|
+
const signals = (result && result.signals) || {};
|
|
159
|
+
if (result && result.passed !== true && signals.panel_outcome === 'fail') return result;
|
|
160
|
+
const kinds = failed.map((p) => p.workerResult.kind);
|
|
161
|
+
return {
|
|
162
|
+
...result,
|
|
163
|
+
passed: false,
|
|
164
|
+
signals: { ...signals, panel_outcome: 'fail', confidence: 0, deterministic_veto: kinds },
|
|
165
|
+
notes_md: `${(result && result.notes_md) || ''}\n\nGrade engine — DETERMINISTIC VETO: ${kinds.join(', ')} hard-failed, so this grade FAILS whatever the judges said (ADR 0016 / 0043 / 0080: no verdict may upgrade a fail over a protected path).`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// persist — append the run meta to the record's signals. Appended AFTER
|
|
170
|
+
// aggregate and gate, so the key order is exactly what aggregatePanel produced
|
|
171
|
+
// when it was handed the meta directly.
|
|
172
|
+
function persist(result, meta) {
|
|
173
|
+
return { ...result, signals: { ...(result.signals || {}), ...meta } };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The model call every judge makes: runSubagent wrapped in the content-addressed
|
|
177
|
+
// LLM cache (System 5) — the same prompt + model replays at $0. Absent llm-cache,
|
|
178
|
+
// a plain run. `taskType` is part of the cache key, so a work-type keeps its own.
|
|
179
|
+
function cachedModelCall(taskType) {
|
|
180
|
+
return ({ prompt, opts }) => runSubagentCached({
|
|
181
|
+
prompt,
|
|
182
|
+
opts,
|
|
183
|
+
cache: api.llmCache
|
|
184
|
+
? api.llmCache.makeCacheSpec({ prompt, model: opts?.model, task_type: taskType })
|
|
185
|
+
: null,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The whole pipeline for one grade. `deps.runSubagent` replaces the model call
|
|
190
|
+
// (tests; production never passes it). Returns the work-type's grade-result.
|
|
191
|
+
async function runGrade(input = {}, deps = {}) {
|
|
192
|
+
const { task, valueSummary, diff, changedFiles, diffStats, judges, builderModel, builderRank, priorRound, runOpts = {} } = input;
|
|
193
|
+
|
|
194
|
+
// select
|
|
195
|
+
const wt = resolveWorkType({ workType: input.workType, task });
|
|
196
|
+
|
|
197
|
+
// #943: bypass / disabled short-circuit — BEFORE resolving judges or spawning
|
|
198
|
+
// anything. Spawning into an outage/bypass window is what manufactured the
|
|
199
|
+
// "FAIL · 0/10" false verdict in session 1659.
|
|
200
|
+
if (runOpts.bypass === true || process.env.GRADER_BYPASS_ENABLED === '1') {
|
|
201
|
+
const reason = runOpts.bypass === true ? 'bypass_requested' : 'grader_bypass_enabled';
|
|
202
|
+
return wt.unavailable({
|
|
203
|
+
reason,
|
|
204
|
+
note: 'Grader bypassed before worker spawn (GRADER_BYPASS_ENABLED / runOpts.bypass) — no panel ran. Land via manual-confirm (/confirm).',
|
|
205
|
+
meta: { builder_model_assumed: builderModel || ASSUMED_BUILDER_MODEL, bypassed: true },
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
const panel = wt.resolveJudges(judges);
|
|
209
|
+
const graderModel = selectGraderModel({ builderModel });
|
|
210
|
+
|
|
211
|
+
// pre_pass
|
|
212
|
+
const prePasses = runPrePasses({ changedFiles, builderRank });
|
|
213
|
+
const deterministicFindings = prePasses.flatMap((p) => p.findings);
|
|
214
|
+
|
|
215
|
+
// evidence
|
|
216
|
+
const ctx = { task, valueSummary, diff, changedFiles, diffStats, builderModel, builderRank, priorRound, runOpts, graderModel, deterministicFindings };
|
|
217
|
+
const evidence = wt.gatherEvidence ? await wt.gatherEvidence(ctx) : {};
|
|
218
|
+
|
|
219
|
+
// fan_out
|
|
220
|
+
const runSubagentFn = deps.runSubagent || cachedModelCall(wt.cacheTaskType || `grade_${wt.key}`);
|
|
221
|
+
const startedAt = Date.now();
|
|
222
|
+
const results = await Promise.all(panel.map((judge) => wt.runJudge(judge, {
|
|
223
|
+
...ctx,
|
|
224
|
+
evidence,
|
|
225
|
+
model: wt.modelFor(judge, graderModel, builderModel),
|
|
226
|
+
runSubagentFn,
|
|
227
|
+
extractAssistantResultFn: extractAssistantResult,
|
|
228
|
+
})));
|
|
229
|
+
// The deterministic checks join as pseudo-judges, after the model judges, so
|
|
230
|
+
// an aggregate's any-fail rule sees them like any other verdict.
|
|
231
|
+
for (const p of prePasses) results.push(p.workerResult);
|
|
232
|
+
const totalMs = Date.now() - startedAt;
|
|
233
|
+
|
|
234
|
+
// aggregate → gate → persist
|
|
235
|
+
const gated = applyDeterministicGate(wt.aggregate(results, {}), prePasses);
|
|
236
|
+
return persist(gated, {
|
|
237
|
+
builder_model_assumed: builderModel || ASSUMED_BUILDER_MODEL,
|
|
238
|
+
grader_model: graderModel,
|
|
239
|
+
total_ms: totalMs,
|
|
240
|
+
...(wt.recordEvidence ? wt.recordEvidence(evidence, ctx) : {}),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Re-run aggregate + gate over a grade's persisted per-judge signals
|
|
245
|
+
// (signals.workers — the deterministic pseudo-judges included). Pure: no model
|
|
246
|
+
// call, no I/O. The pre-pass results are rebuilt from the recorded pseudo-judges,
|
|
247
|
+
// so the gate judges exactly what the live run's gate judged.
|
|
248
|
+
function replayRecorded({ signals, workType } = {}) {
|
|
249
|
+
const wt = resolveWorkType({ workType });
|
|
250
|
+
const judges = Array.isArray(signals && signals.workers) ? signals.workers : [];
|
|
251
|
+
const prePasses = judges
|
|
252
|
+
.filter((w) => w && w.model === 'deterministic' && DETERMINISTIC_KINDS.includes(w.kind))
|
|
253
|
+
.map((w) => ({ findings: w.issues || [], workerResult: w }));
|
|
254
|
+
return applyDeterministicGate(wt.aggregate(judges, {}), prePasses);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = {
|
|
258
|
+
PIPELINE_STAGES,
|
|
259
|
+
WORK_TYPE_PORT_PREFIX,
|
|
260
|
+
applyDeterministicGate,
|
|
261
|
+
listWorkTypes,
|
|
262
|
+
registerWorkType,
|
|
263
|
+
replayRecorded,
|
|
264
|
+
resolveWorkType,
|
|
265
|
+
runGrade,
|
|
266
|
+
workTypePort,
|
|
267
|
+
};
|
|
@@ -19,9 +19,10 @@ function runSubagent({ prompt, opts = {} }) {
|
|
|
19
19
|
const timeoutMs = opts.timeoutMs ?? 240_000;
|
|
20
20
|
const cwd = opts.cwd ?? process.cwd();
|
|
21
21
|
const model = opts.model ?? DEFAULT_GRADER_MODEL;
|
|
22
|
-
// Env escape hatch (
|
|
23
|
-
//
|
|
24
|
-
|
|
22
|
+
// Env escape hatch (<PREFIX>_CLAUDE_BIN — BONGOS_ on a vanilla instance, the
|
|
23
|
+
// legacy OTB_ still read — then the unprefixed CLAUDE_BIN) for installs where the
|
|
24
|
+
// CLI isn't named `claude` on PATH — covers any OS, not just Windows.
|
|
25
|
+
const bin = opts.claudeBin ?? api.resolveEnv('CLAUDE_BIN') ?? process.env.CLAUDE_BIN ?? 'claude';
|
|
25
26
|
|
|
26
27
|
// THE PERMISSION MODE IS ROOT-DEPENDENT (task 1003574). `bypassPermissions` maps
|
|
27
28
|
// to --dangerously-skip-permissions, and the CLI REFUSES that when euid is 0:
|
|
@@ -87,8 +87,22 @@ const TRANSIENT_WORKER_ERRORS = new Set([
|
|
|
87
87
|
// errored no-finding Quality — the tag is the diagnosis, not the trigger).
|
|
88
88
|
// The pattern is the API's exact sentence, so a diff that merely mentions the
|
|
89
89
|
// phrase cannot trip it.
|
|
90
|
-
|
|
90
|
+
//
|
|
91
|
+
// task 1003648: the separator is NOT a colon. The line the CLI really prints —
|
|
92
|
+
// stored verbatim on task 1003298's grade row — is
|
|
93
|
+
// "Prompt is too long · the request is ~251248 tokens (limit 200000) ..."
|
|
94
|
+
// and the colon form above was a paraphrase, so the tag never fired on a real
|
|
95
|
+
// overflow: it was filed as SUBAGENT_NONZERO_EXIT and retried as if transient.
|
|
96
|
+
// Any short run of non-word characters between the halves matches both.
|
|
97
|
+
const PROMPT_TOO_LONG_RE = /prompt is too long\W{1,5}the request is ~?\d+ tokens/i;
|
|
91
98
|
const PROMPT_TOO_LONG = 'PROMPT_TOO_LONG';
|
|
99
|
+
// The numbers in that same sentence, recorded so a bounded row says how far
|
|
100
|
+
// over the window it was. Absent from a line that does not carry them.
|
|
101
|
+
const OVERFLOW_NUMBERS_RE = /the request is ~?(\d+) tokens \(limit (\d+)\)/i;
|
|
102
|
+
function overflowNumbers(said) {
|
|
103
|
+
const m = OVERFLOW_NUMBERS_RE.exec(String(said || ''));
|
|
104
|
+
return m ? { request_tokens: Number(m[1]), window_tokens: Number(m[2]) } : {};
|
|
105
|
+
}
|
|
92
106
|
|
|
93
107
|
// #467: per-worker subagent timeout. The global 240s applied to EVERY worker,
|
|
94
108
|
// which timed out the Read-heavy ones (Narc walks ACs + reads files — hence
|
|
@@ -195,13 +209,14 @@ async function runWorker(worker, { task, valueSummary, diff, changedFiles, diffS
|
|
|
195
209
|
// builder response for a delta-aware re-grade; every worker renders it via
|
|
196
210
|
// the shared scaffold. contextBundle (task 1002660) likewise: the post-change
|
|
197
211
|
// file contents read once by the caller, rendered by the shared scaffold.
|
|
198
|
-
const
|
|
212
|
+
const promptInputs = { task, valueSummary, diff, changedFiles, diffStats, deterministicFindings, priorRound, contextBundle };
|
|
213
|
+
const prompt = worker.buildPrompt(promptInputs);
|
|
199
214
|
|
|
200
215
|
// One subagent attempt → verdict. Pulled out so #465 can re-run it once on a
|
|
201
216
|
// transient error without duplicating the spawn/parse/verdict plumbing.
|
|
202
217
|
// #467: per-worker timeout (Read-heavy workers get longer; see the map above).
|
|
203
218
|
const timeoutMs = timeoutForWorker(worker, runOpts);
|
|
204
|
-
async function attempt() {
|
|
219
|
+
async function attempt(promptText) {
|
|
205
220
|
let raw = null;
|
|
206
221
|
let runMs = null;
|
|
207
222
|
let costUsd = null;
|
|
@@ -209,9 +224,10 @@ async function runWorker(worker, { task, valueSummary, diff, changedFiles, diffS
|
|
|
209
224
|
// task 1003313: the WHY behind subprocessError. Null on a clean run.
|
|
210
225
|
let exitCode = null;
|
|
211
226
|
let stderrTail = null;
|
|
227
|
+
let overflow = null; // task 1003648: the refusal's own numbers, on a PROMPT_TOO_LONG only
|
|
212
228
|
const t0 = Date.now();
|
|
213
229
|
try {
|
|
214
|
-
const { stdout, run_ms, cost_usd } = await runSubagentFn({ prompt, opts: { ...runOpts, model, timeoutMs } });
|
|
230
|
+
const { stdout, run_ms, cost_usd } = await runSubagentFn({ prompt: promptText, opts: { ...runOpts, model, timeoutMs } });
|
|
215
231
|
runMs = run_ms;
|
|
216
232
|
// #486 (ADR 0028 path 1): runSubagent attaches the `claude -p` envelope's
|
|
217
233
|
// total_cost_usd. Carry it out so aggregatePanel can sum the panel's own
|
|
@@ -232,7 +248,10 @@ async function runWorker(worker, { task, valueSummary, diff, changedFiles, diffS
|
|
|
232
248
|
// the #467 budget signal, never a refusal.
|
|
233
249
|
if (err.code !== 'SUBAGENT_TIMEOUT') {
|
|
234
250
|
const said = String(err.stderr || '').trim() || String(err.stdout || '');
|
|
235
|
-
if (PROMPT_TOO_LONG_RE.test(said))
|
|
251
|
+
if (PROMPT_TOO_LONG_RE.test(said)) {
|
|
252
|
+
subprocessError = PROMPT_TOO_LONG;
|
|
253
|
+
overflow = overflowNumbers(said);
|
|
254
|
+
}
|
|
236
255
|
}
|
|
237
256
|
}
|
|
238
257
|
const parsed = subprocessError
|
|
@@ -243,18 +262,47 @@ async function runWorker(worker, { task, valueSummary, diff, changedFiles, diffS
|
|
|
243
262
|
run_ms: runMs,
|
|
244
263
|
subprocess_error: subprocessError,
|
|
245
264
|
});
|
|
246
|
-
return { verdict, runMs, costUsd, exitCode, stderrTail };
|
|
265
|
+
return { verdict, runMs, costUsd, exitCode, stderrTail, overflow };
|
|
247
266
|
}
|
|
248
267
|
|
|
249
|
-
let { verdict, runMs, costUsd, exitCode, stderrTail } = await attempt();
|
|
250
268
|
// #465: retry ONCE on a transient error (the freshest attempt wins, even if it
|
|
251
269
|
// also errored — one retry only). A real verdict or a SUBAGENT_TIMEOUT is
|
|
252
270
|
// never retried. NB (#486): the freshest attempt's cost wins; a transient
|
|
253
271
|
// first attempt's spend is not added (slight, rare undercount).
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
retried
|
|
257
|
-
|
|
272
|
+
async function attemptWithRetry(promptText) {
|
|
273
|
+
const first = await attempt(promptText);
|
|
274
|
+
if (!TRANSIENT_WORKER_ERRORS.has(first.verdict.error)) return { ...first, retried: false };
|
|
275
|
+
return { ...(await attempt(promptText)), retried: true };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let { verdict, runMs, costUsd, exitCode, stderrTail, overflow, retried } = await attemptWithRetry(prompt);
|
|
279
|
+
|
|
280
|
+
// task 1003648 — a prompt too big for this worker's model window degrades
|
|
281
|
+
// instead of failing outright. The CLI measures the real window and the real
|
|
282
|
+
// overhead (its own system prompt, tool definitions and auto-loaded context —
|
|
283
|
+
// ~150k tokens of task 1003298's 251k-token refusal), so the refusal is the
|
|
284
|
+
// authoritative "does not fit"; the one part of the prompt that can go without
|
|
285
|
+
// losing any evidence is the post-change file bundle, because the worker can
|
|
286
|
+
// Read those files itself. So: retry ONCE without it. The diff, the task and the
|
|
287
|
+
// prior round all stay — the retried worker reviews the same change, it just
|
|
288
|
+
// fetches the files instead of being handed them. If even that does not fit,
|
|
289
|
+
// the worker stays PROMPT_TOO_LONG: an explicit lens-unavailable, never a score.
|
|
290
|
+
// No bundle to drop → nothing smaller to try, so no second refusal is spent.
|
|
291
|
+
let promptBounded = null;
|
|
292
|
+
const bundledFiles = contextBundle && Array.isArray(contextBundle.files) ? contextBundle.files : [];
|
|
293
|
+
if (verdict.error === PROMPT_TOO_LONG && bundledFiles.length > 0) {
|
|
294
|
+
// Generated-file provenance is verified, trusted text — it stays.
|
|
295
|
+
const smaller = worker.buildPrompt({ ...promptInputs, contextBundle: { ...contextBundle, files: [] } });
|
|
296
|
+
promptBounded = {
|
|
297
|
+
reason: PROMPT_TOO_LONG,
|
|
298
|
+
bundle_dropped: true,
|
|
299
|
+
bundle_files: bundledFiles.length,
|
|
300
|
+
prompt_chars: prompt.length,
|
|
301
|
+
retry_prompt_chars: smaller.length,
|
|
302
|
+
...(overflow || {}),
|
|
303
|
+
};
|
|
304
|
+
({ verdict, runMs, costUsd, exitCode, stderrTail, overflow, retried } = await attemptWithRetry(smaller));
|
|
305
|
+
promptBounded.retry_error = verdict.error ?? null;
|
|
258
306
|
}
|
|
259
307
|
|
|
260
308
|
return {
|
|
@@ -277,6 +325,14 @@ async function runWorker(worker, { task, valueSummary, diff, changedFiles, diffS
|
|
|
277
325
|
...(verdict.error && stderrTail ? { stderr_tail: stderrTail } : {}),
|
|
278
326
|
// Narc-specific (carried opaquely; aggregatePanel doesn't need to know):
|
|
279
327
|
...(verdict.ac_checklist ? { ac_checklist: verdict.ac_checklist } : {}),
|
|
328
|
+
// task 1002903: the severity gate's marker. Rebuilding the row field by field
|
|
329
|
+
// used to drop it, so a stored grade could not say which low score was the
|
|
330
|
+
// one no finding backed — only the worker's prose did. Omitted when absent,
|
|
331
|
+
// so an ordinary worker row keeps its exact shape.
|
|
332
|
+
...(verdict.policy_downgrade ? { policy_downgrade: verdict.policy_downgrade } : {}),
|
|
333
|
+
// task 1003648: present only when the first prompt overflowed the window and
|
|
334
|
+
// a smaller one was tried, so an ordinary row keeps its exact shape.
|
|
335
|
+
...(promptBounded ? { prompt_bounded: promptBounded } : {}),
|
|
280
336
|
};
|
|
281
337
|
}
|
|
282
338
|
|