forgedock 1.3.0 → 1.4.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/bin/batch-runner.mjs +31 -2
- package/bin/engine/admission.mjs +294 -0
- package/bin/engine/admission.test.mjs +265 -0
- package/bin/engine/invariants.mjs +17 -4
- package/bin/engine/phases.mjs +201 -35
- package/bin/engine/resolve.mjs +256 -0
- package/bin/engine/resolve.test.mjs +149 -0
- package/bin/engine/state.mjs +20 -28
- package/bin/engine-cli.mjs +80 -5
- package/bin/engine.mjs +390 -18
- package/bin/forgedock.mjs +336 -59
- package/bin/hooks/interactive-engine.mjs +50 -24
- package/bin/hooks/pre-tool-use.mjs +169 -13
- package/bin/journey.mjs +8 -2
- package/bin/labels.json +5 -0
- package/bin/runner.mjs +321 -10
- package/bin/watch-utils.mjs +255 -0
- package/commands/cleanup.md +7 -3
- package/commands/orchestrate/config.md +41 -1
- package/commands/orchestrate/phase-1-resolve.md +234 -8
- package/commands/orchestrate/phase-3-dependency.md +27 -18
- package/commands/orchestrate/phase-4-execution.md +221 -22
- package/commands/quality-gate.md +6 -6
- package/commands/review-pr.md +23 -7
- package/commands/upgrade-deps.md +7 -2
- package/commands/work-on/decompose.md +2 -0
- package/commands/work-on/investigate.md +22 -4
- package/commands/work-on/remediate.md +2 -0
- package/commands/work-on/review.md +40 -1
- package/commands/work-on.md +19 -4
- package/docs/CONFIG.md +120 -0
- package/docs/spec/eval-run-result.md +8 -4
- package/docs/spec/label-state-machine.md +9 -0
- package/package.json +2 -2
- package/packages/protocol/src/emit.js +5 -22
- package/packages/protocol/src/html-comment-escape.js +68 -0
- package/packages/protocol/src/index.js +1 -0
- package/packages/protocol/src/parse.js +5 -6
- package/packages/protocol/src/phases.js +105 -0
- package/packages/protocol/src/types.js +17 -0
- package/scripts/check-admission-parity.mjs +167 -0
- package/scripts/check-command-side-effects.sh +181 -66
- package/scripts/check-phase-registry-drift.mjs +96 -0
- package/scripts/check-spec-markers.sh +1 -0
- package/scripts/forge-run.sh +35 -4
package/bin/batch-runner.mjs
CHANGED
|
@@ -112,6 +112,33 @@ export function classifyRunnerResult(result) {
|
|
|
112
112
|
return "incomplete";
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* forge#2377: derive a numeric "cost" (docs/spec/eval-run-result.md's
|
|
117
|
+
* `cost: null | number` field — a billed-token proxy, not a USD amount; see
|
|
118
|
+
* forge#2397) from a runCommand() result's `usage` object. `runCommand()`
|
|
119
|
+
* (bin/runner.mjs, since #1295/PR #1421) already accumulates
|
|
120
|
+
* `{input_tokens, output_tokens, cache_creation_input_tokens,
|
|
121
|
+
* cache_read_input_tokens}` for the API backend. The CLI backend
|
|
122
|
+
* (`runCliBackend()`) also returns a best-effort-parsed `usage` object on its
|
|
123
|
+
* success path since forge#2398/PR #2421 — `usage: null` from the CLI
|
|
124
|
+
* backend now only occurs when the CLI's `--output-format json` envelope
|
|
125
|
+
* could not be parsed (older CLI version, or a non-JSON stdout), not
|
|
126
|
+
* unconditionally. This reports total billed tokens (input + output; cache
|
|
127
|
+
* reads/writes are not separately billed at the standard rate and are
|
|
128
|
+
* omitted from this proxy) as the numeric cost signal — converting to a USD
|
|
129
|
+
* amount requires a per-model pricing table, which is out of this issue's
|
|
130
|
+
* scope (recording usage, not pricing it) and is left to a follow-up.
|
|
131
|
+
*
|
|
132
|
+
* @param {{input_tokens?: number, output_tokens?: number}|null|undefined} usage
|
|
133
|
+
* @returns {number|null}
|
|
134
|
+
*/
|
|
135
|
+
export function tokenCost(usage) {
|
|
136
|
+
if (!usage) return null;
|
|
137
|
+
const input = usage.input_tokens ?? 0;
|
|
138
|
+
const output = usage.output_tokens ?? 0;
|
|
139
|
+
return input + output;
|
|
140
|
+
}
|
|
141
|
+
|
|
115
142
|
/**
|
|
116
143
|
* Construct a per-run result object conforming to docs/spec/eval-run-result.md.
|
|
117
144
|
*
|
|
@@ -120,7 +147,9 @@ export function classifyRunnerResult(result) {
|
|
|
120
147
|
* @param {object} [opts]
|
|
121
148
|
* @param {number} [opts.wallClockMs] - Wall-clock duration (ms).
|
|
122
149
|
* @param {number} [opts.interventionCount] - Human intervention count.
|
|
123
|
-
* @param {number|null} [opts.cost] -
|
|
150
|
+
* @param {number|null} [opts.cost] - Total billed tokens (input + output), or null when
|
|
151
|
+
* the run produced no usage data (CLI backend, or an
|
|
152
|
+
* errored run). See tokenCost() (forge#2377).
|
|
124
153
|
* @param {number} [opts.iterations] - Tool-loop iteration count.
|
|
125
154
|
* @param {string} [opts.stopReason] - Anthropic stop_reason or "max_iterations".
|
|
126
155
|
* @param {string|null} [opts.error] - Error message when status === "error".
|
|
@@ -236,7 +265,7 @@ export async function runCorpus(opts = {}) {
|
|
|
236
265
|
makeRunResult(issue, status, {
|
|
237
266
|
wallClockMs,
|
|
238
267
|
interventionCount: 0,
|
|
239
|
-
cost:
|
|
268
|
+
cost: tokenCost(result.usage),
|
|
240
269
|
iterations: result.iterations ?? null,
|
|
241
270
|
stopReason: result.stopReason ?? null,
|
|
242
271
|
model: model ?? result.model ?? null,
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cascade admission policy — orchestration.cascade config surface (forge#2234).
|
|
3
|
+
*
|
|
4
|
+
* `/orchestrate`'s resource limits (`orchestration.max_concurrent`,
|
|
5
|
+
* `pipeline.token_budget_per_batch`, `pipeline.stall_timeout_minutes`, ...)
|
|
6
|
+
* were configurable, but the *admission policy* deciding whether a
|
|
7
|
+
* cascade-spawned review-finding is picked up was not — every rule
|
|
8
|
+
* (generation >= 2 cap, BATCH_FULLY_GATED idle defer, comment/typo keyword
|
|
9
|
+
* heuristic, P3 + same-file overlap) was a hardcoded constant baked into
|
|
10
|
+
* `commands/orchestrate/phase-4-execution.md` prose (see forge#1814,
|
|
11
|
+
* forge#1858, forge#2231). This module gives that policy a typed, unit-tested
|
|
12
|
+
* home: preset expansion + independently-settable granular levers, with the
|
|
13
|
+
* same validate-warn-fall-back idiom the rest of `orchestration.*`/`pipeline.*`
|
|
14
|
+
* already uses (see phase-4-execution.md L108-119 for the bash mirror of this
|
|
15
|
+
* idiom applied to `orchestration.max_concurrent`).
|
|
16
|
+
*
|
|
17
|
+
* This module is the typed reference implementation of the policy the prose
|
|
18
|
+
* specs (`commands/orchestrate/phase-4-execution.md`,
|
|
19
|
+
* `commands/orchestrate/phase-1-resolve.md`) read via `yq` at runtime — the
|
|
20
|
+
* bash blocks in those files mirror the resolution rules below by hand
|
|
21
|
+
* (the orchestrator is LLM-executed prose, not a `bin/engine/` call site),
|
|
22
|
+
* so any change to the preset table or defaults here MUST be mirrored there
|
|
23
|
+
* too. Keeping this module in `bin/engine/` gives the resolution logic a
|
|
24
|
+
* place to be unit-tested in isolation from the prose pipeline.
|
|
25
|
+
*
|
|
26
|
+
* Evidence this config surface addresses (see forge#2234 issue body): a
|
|
27
|
+
* cascade admitted via the pre-#2234 `--allow-gen2` all-or-nothing CLI flag
|
|
28
|
+
* (forge#2231) ran generation 2 -> 3 -> 4, drifting from "the engine silently
|
|
29
|
+
* kills entire batches" (gen 2, real value) to "a log sanitizer does not
|
|
30
|
+
* neutralize Unicode bidi-override characters" (gen 4, diminishing value).
|
|
31
|
+
* A binary flag cannot express "admit gen-2, stop at gen-3" or "admit
|
|
32
|
+
* cascade until N tokens spent" — `max_generation` and `token_budget` below
|
|
33
|
+
* are independent levers precisely so that shape of policy is expressible.
|
|
34
|
+
*
|
|
35
|
+
* Hard invariant (NOT configurable by design): safety exclusions — findings
|
|
36
|
+
* whose `## Problem` section indicates security/billing/anti-bot/auth
|
|
37
|
+
* concerns — are never batched and never auto-admitted by ANY policy,
|
|
38
|
+
* including `all`. That exclusion lives upstream of this module (the P3
|
|
39
|
+
* batching eligibility check in `phase-1-resolve.md` / the surface-area
|
|
40
|
+
* batching check in `phase-4-execution.md`) and is intentionally absent
|
|
41
|
+
* from the levers this module resolves.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** Sentinel string accepted anywhere an "int | unlimited" lever is read. */
|
|
45
|
+
export const UNLIMITED = "unlimited";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {Object} CascadePolicy
|
|
49
|
+
* @property {number|typeof UNLIMITED} maxGeneration - Max cascade generation depth
|
|
50
|
+
* admitted. 1 = only original (non-review-finding-spawned) issues; a
|
|
51
|
+
* review-finding whose source is itself a review-finding is generation 2,
|
|
52
|
+
* and so on up the chain. `unlimited` removes the cap entirely.
|
|
53
|
+
* @property {number|typeof UNLIMITED} tokenBudget - Per-batch token ceiling for
|
|
54
|
+
* Step 4C's review-finding cascade dispatch (mirrors, and by default reads
|
|
55
|
+
* through to, `pipeline.token_budget_per_batch`). `unlimited` removes the cap.
|
|
56
|
+
* @property {boolean} deferOnBatchGated - Whether a fully-human-gated original
|
|
57
|
+
* batch (forge#1814's `BATCH_FULLY_GATED`) suppresses further cascade dispatch.
|
|
58
|
+
* @property {boolean} keywordHeuristic - Whether the comment/typo title keyword
|
|
59
|
+
* heuristic defers P3-and-below findings.
|
|
60
|
+
* @property {boolean} p3SameFileDefer - Whether a P3 finding sharing a file with
|
|
61
|
+
* the active batch is deferred.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Named presets. Each expands to a full `CascadePolicy` — every field can
|
|
66
|
+
* still be overridden individually on top of a preset (see `resolveCascadePolicy`).
|
|
67
|
+
*
|
|
68
|
+
* - `balanced` (default): the pre-#2234 hardcoded behavior, unchanged so an
|
|
69
|
+
* absent `orchestration.cascade` section is a no-op.
|
|
70
|
+
* - `all`: "pick up everything" — a maintainer draining a backlog. Removes
|
|
71
|
+
* both caps and disables every heuristic-based defer. Safety exclusions
|
|
72
|
+
* (see module docstring) still apply — they are not part of this table.
|
|
73
|
+
* - `conservative`: same admission shape as `balanced`, but a materially
|
|
74
|
+
* lower token ceiling for cost-sensitive or noisy repos.
|
|
75
|
+
*
|
|
76
|
+
* @type {Record<string, CascadePolicy>}
|
|
77
|
+
*/
|
|
78
|
+
export const CASCADE_PRESETS = Object.freeze({
|
|
79
|
+
all: Object.freeze({
|
|
80
|
+
maxGeneration: UNLIMITED,
|
|
81
|
+
tokenBudget: UNLIMITED,
|
|
82
|
+
deferOnBatchGated: false,
|
|
83
|
+
keywordHeuristic: false,
|
|
84
|
+
p3SameFileDefer: false,
|
|
85
|
+
}),
|
|
86
|
+
balanced: Object.freeze({
|
|
87
|
+
maxGeneration: 1,
|
|
88
|
+
tokenBudget: 900000,
|
|
89
|
+
deferOnBatchGated: true,
|
|
90
|
+
keywordHeuristic: true,
|
|
91
|
+
p3SameFileDefer: true,
|
|
92
|
+
}),
|
|
93
|
+
conservative: Object.freeze({
|
|
94
|
+
maxGeneration: 1,
|
|
95
|
+
tokenBudget: 450000,
|
|
96
|
+
deferOnBatchGated: true,
|
|
97
|
+
keywordHeuristic: true,
|
|
98
|
+
p3SameFileDefer: true,
|
|
99
|
+
}),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
export const DEFAULT_CASCADE_POLICY_NAME = "balanced";
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Parse a raw config value that may be a positive integer, the literal
|
|
106
|
+
* string "unlimited" (case-insensitive), or absent/invalid. Mirrors the
|
|
107
|
+
* validate-warn-fall-back idiom used for `orchestration.max_concurrent`
|
|
108
|
+
* (phase-4-execution.md L112-119: `grep -qP '^[1-9][0-9]*$'` -> warn + default)
|
|
109
|
+
* but additionally threads the `unlimited` sentinel through, which that
|
|
110
|
+
* plain positive-int check would otherwise reject (see forge#2234 "Known
|
|
111
|
+
* Pitfalls": an `unlimited` value hitting the un-updated int-only validator
|
|
112
|
+
* silently degrades to the default and the uncap becomes a no-op).
|
|
113
|
+
*
|
|
114
|
+
* @param {unknown} raw
|
|
115
|
+
* @param {number|typeof UNLIMITED} fallback
|
|
116
|
+
* @returns {{ value: number|typeof UNLIMITED, warning: string|null }}
|
|
117
|
+
*/
|
|
118
|
+
export function parseIntOrUnlimited(raw, fallback) {
|
|
119
|
+
if (raw === undefined || raw === null || raw === "null" || raw === "") {
|
|
120
|
+
return { value: fallback, warning: null };
|
|
121
|
+
}
|
|
122
|
+
if (typeof raw === "string" && raw.trim().toLowerCase() === UNLIMITED) {
|
|
123
|
+
return { value: UNLIMITED, warning: null };
|
|
124
|
+
}
|
|
125
|
+
const n = typeof raw === "number" ? raw : Number(String(raw).trim());
|
|
126
|
+
if (Number.isInteger(n) && n > 0) {
|
|
127
|
+
return { value: n, warning: null };
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
value: fallback,
|
|
131
|
+
warning: `not a positive integer or "unlimited" ("${raw}") — falling back to default ${fallback}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Expand `orchestration.cascade` config into a fully-resolved `CascadePolicy`.
|
|
137
|
+
* Precedence: explicit granular key > preset value > `balanced` preset value.
|
|
138
|
+
* An unrecognized `policy` name falls back to `balanced` with a warning,
|
|
139
|
+
* following the same validate-warn-fall-back idiom as every other
|
|
140
|
+
* `orchestration.*`/`pipeline.*` key.
|
|
141
|
+
*
|
|
142
|
+
* @param {Object} [config] - Parsed `orchestration.cascade` object from
|
|
143
|
+
* forge.yaml (or undefined/empty when the section is absent — a no-op
|
|
144
|
+
* that resolves to `balanced` exactly like today's hardcoded behavior).
|
|
145
|
+
* @param {string} [config.policy]
|
|
146
|
+
* @param {number|string} [config.max_generation]
|
|
147
|
+
* @param {number|string} [config.token_budget]
|
|
148
|
+
* @param {boolean} [config.defer_on_batch_gated]
|
|
149
|
+
* @param {boolean} [config.keyword_heuristic]
|
|
150
|
+
* @param {boolean} [config.p3_same_file_defer]
|
|
151
|
+
* @param {number|string} [legacyTokenBudgetPerBatch] - Deprecated-alias fallback:
|
|
152
|
+
* `pipeline.token_budget_per_batch`, read when `config.token_budget` is absent
|
|
153
|
+
* so existing configs keep working unchanged (see forge#1858).
|
|
154
|
+
* @returns {{ policy: CascadePolicy, policyName: string, bothUncapped: boolean, warnings: string[] }}
|
|
155
|
+
*/
|
|
156
|
+
export function resolveCascadePolicy(config = {}, legacyTokenBudgetPerBatch) {
|
|
157
|
+
const warnings = [];
|
|
158
|
+
const requestedName =
|
|
159
|
+
typeof config.policy === "string" && config.policy.trim() !== ""
|
|
160
|
+
? config.policy.trim()
|
|
161
|
+
: DEFAULT_CASCADE_POLICY_NAME;
|
|
162
|
+
|
|
163
|
+
let policyName = requestedName;
|
|
164
|
+
let preset = CASCADE_PRESETS[requestedName];
|
|
165
|
+
if (!preset) {
|
|
166
|
+
warnings.push(
|
|
167
|
+
`orchestration.cascade.policy "${requestedName}" is not one of: ${Object.keys(CASCADE_PRESETS).join(", ")} — falling back to "${DEFAULT_CASCADE_POLICY_NAME}"`,
|
|
168
|
+
);
|
|
169
|
+
policyName = DEFAULT_CASCADE_POLICY_NAME;
|
|
170
|
+
preset = CASCADE_PRESETS[DEFAULT_CASCADE_POLICY_NAME];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const maxGen = parseIntOrUnlimited(config.max_generation, preset.maxGeneration);
|
|
174
|
+
if (maxGen.warning) warnings.push(`orchestration.cascade.max_generation ${maxGen.warning}`);
|
|
175
|
+
|
|
176
|
+
// token_budget precedence: orchestration.cascade.token_budget (new home) >
|
|
177
|
+
// pipeline.token_budget_per_batch (deprecated alias, forge#1858) > preset default.
|
|
178
|
+
// The legacy fallback is validated through parseIntOrUnlimited itself before use —
|
|
179
|
+
// NOT trusted as-is — so a malformed legacy value (0, negative, NaN, a
|
|
180
|
+
// case-mismatched sentinel like "UNLIMITED") cannot silently bypass validation
|
|
181
|
+
// the way a bare pass-through would (forge#2302).
|
|
182
|
+
const { value: validatedLegacyFallback, warning: legacyWarning } = parseIntOrUnlimited(
|
|
183
|
+
legacyTokenBudgetPerBatch,
|
|
184
|
+
preset.tokenBudget,
|
|
185
|
+
);
|
|
186
|
+
if (legacyWarning) warnings.push(`pipeline.token_budget_per_batch (legacy alias) ${legacyWarning}`);
|
|
187
|
+
const tokenBudgetFallback =
|
|
188
|
+
legacyTokenBudgetPerBatch !== undefined ? validatedLegacyFallback : preset.tokenBudget;
|
|
189
|
+
const tokenBudget = parseIntOrUnlimited(config.token_budget, tokenBudgetFallback);
|
|
190
|
+
if (tokenBudget.warning) warnings.push(`orchestration.cascade.token_budget ${tokenBudget.warning}`);
|
|
191
|
+
|
|
192
|
+
const deferOnBatchGated =
|
|
193
|
+
typeof config.defer_on_batch_gated === "boolean" ? config.defer_on_batch_gated : preset.deferOnBatchGated;
|
|
194
|
+
const keywordHeuristic =
|
|
195
|
+
typeof config.keyword_heuristic === "boolean" ? config.keyword_heuristic : preset.keywordHeuristic;
|
|
196
|
+
const p3SameFileDefer =
|
|
197
|
+
typeof config.p3_same_file_defer === "boolean" ? config.p3_same_file_defer : preset.p3SameFileDefer;
|
|
198
|
+
|
|
199
|
+
// Both-uncapped notice: neither generation depth nor token spend is bounded this
|
|
200
|
+
// run. This is never a preset default (no preset in CASCADE_PRESETS sets both to
|
|
201
|
+
// UNLIMITED... except "all", which does so deliberately) — surface it loudly so an
|
|
202
|
+
// operator running `policy: all` (or an equivalent granular-override combination)
|
|
203
|
+
// sees the tradeoff explicitly rather than discovering it from an unexpectedly long
|
|
204
|
+
// cascade tail. Distinct from the per-parse `warnings` above (which flag malformed
|
|
205
|
+
// config); this is a policy-shape notice about a valid, fully-resolved configuration.
|
|
206
|
+
const bothUncapped = maxGen.value === UNLIMITED && tokenBudget.value === UNLIMITED;
|
|
207
|
+
if (bothUncapped) {
|
|
208
|
+
warnings.push(
|
|
209
|
+
"orchestration.cascade: both max_generation and token_budget are unlimited — cascade admission has no upper bound on generation depth or token spend for this run.",
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
policy: {
|
|
215
|
+
maxGeneration: maxGen.value,
|
|
216
|
+
tokenBudget: tokenBudget.value,
|
|
217
|
+
deferOnBatchGated,
|
|
218
|
+
keywordHeuristic,
|
|
219
|
+
p3SameFileDefer,
|
|
220
|
+
},
|
|
221
|
+
policyName,
|
|
222
|
+
bothUncapped,
|
|
223
|
+
warnings,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* @param {number|typeof UNLIMITED} generation - 1-indexed cascade depth of the
|
|
229
|
+
* finding being evaluated (1 = original issue, not spawned from a
|
|
230
|
+
* review-finding; 2 = spawned from a review-finding; 3 = spawned from a
|
|
231
|
+
* finding that was itself spawned from a review-finding; ...).
|
|
232
|
+
* @param {CascadePolicy} policy
|
|
233
|
+
* @returns {boolean} true if this generation is admitted by the policy.
|
|
234
|
+
*/
|
|
235
|
+
export function admitsGeneration(generation, policy) {
|
|
236
|
+
if (policy.maxGeneration === UNLIMITED) return true;
|
|
237
|
+
return generation <= policy.maxGeneration;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* @param {number} projectedSpend - BATCH_TOKEN_SPEND if this unit were admitted.
|
|
242
|
+
* @param {CascadePolicy} policy
|
|
243
|
+
* @returns {boolean} true if there is still headroom under the token budget.
|
|
244
|
+
*/
|
|
245
|
+
export function admitsTokenSpend(projectedSpend, policy) {
|
|
246
|
+
if (policy.tokenBudget === UNLIMITED) return true;
|
|
247
|
+
return projectedSpend <= policy.tokenBudget;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Evaluate the Step 4C rule chain for a single cascade-spawned finding.
|
|
252
|
+
* Mirrors `commands/orchestrate/phase-4-execution.md` Step 4C's "Evaluation
|
|
253
|
+
* order" (rules 0-5) exactly, with rules 0/3/4 gated by the policy's
|
|
254
|
+
* corresponding toggle and rule 1 governed by `admitsGeneration`. Rule 1
|
|
255
|
+
* (generation cap) is evaluated for the finding's *computed* generation —
|
|
256
|
+
* NOT hardcoded to a single-hop "is my source a review-finding" boolean —
|
|
257
|
+
* so a `max_generation: 3` policy actually distinguishes gen 2 from gen 3,
|
|
258
|
+
* per the exact gap forge#2234 exists to close (a binary flag cannot say
|
|
259
|
+
* "admit gen-2, stop at gen-3").
|
|
260
|
+
*
|
|
261
|
+
* @param {Object} finding
|
|
262
|
+
* @param {number} finding.generation - 1-indexed, see `admitsGeneration`.
|
|
263
|
+
* @param {"P1"|"P2"|"P3"|string} finding.priority
|
|
264
|
+
* @param {string} finding.title
|
|
265
|
+
* @param {boolean} finding.sameFileAsBatch
|
|
266
|
+
* @param {boolean} finding.batchFullyGated
|
|
267
|
+
* @param {number} finding.projectedTokenSpend
|
|
268
|
+
* @param {CascadePolicy} policy
|
|
269
|
+
* @returns {{ admit: boolean, reason: string|null }}
|
|
270
|
+
*/
|
|
271
|
+
export function evaluateCascadeFinding(finding, policy) {
|
|
272
|
+
if (policy.deferOnBatchGated && finding.batchFullyGated) {
|
|
273
|
+
return { admit: false, reason: "batch fully human-gated — idle policy" };
|
|
274
|
+
}
|
|
275
|
+
if (!admitsGeneration(finding.generation, policy)) {
|
|
276
|
+
return {
|
|
277
|
+
admit: false,
|
|
278
|
+
reason: `generation ${finding.generation} exceeds orchestration.cascade.max_generation (${policy.maxGeneration})`,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
if (finding.priority === "P1" || finding.priority === "P2") {
|
|
282
|
+
return { admit: true, reason: null };
|
|
283
|
+
}
|
|
284
|
+
if (policy.keywordHeuristic && /comment|typo/i.test(finding.title || "")) {
|
|
285
|
+
return { admit: false, reason: "comment/typo heuristic" };
|
|
286
|
+
}
|
|
287
|
+
if (policy.p3SameFileDefer && finding.priority === "P3" && finding.sameFileAsBatch) {
|
|
288
|
+
return { admit: false, reason: "P3 + same file as batch" };
|
|
289
|
+
}
|
|
290
|
+
if (!admitsTokenSpend(finding.projectedTokenSpend, policy)) {
|
|
291
|
+
return { admit: false, reason: `per-batch token budget exhausted (orchestration.cascade.token_budget=${policy.tokenBudget})` };
|
|
292
|
+
}
|
|
293
|
+
return { admit: true, reason: null };
|
|
294
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
UNLIMITED,
|
|
5
|
+
CASCADE_PRESETS,
|
|
6
|
+
DEFAULT_CASCADE_POLICY_NAME,
|
|
7
|
+
parseIntOrUnlimited,
|
|
8
|
+
resolveCascadePolicy,
|
|
9
|
+
admitsGeneration,
|
|
10
|
+
admitsTokenSpend,
|
|
11
|
+
evaluateCascadeFinding,
|
|
12
|
+
} from "./admission.mjs";
|
|
13
|
+
|
|
14
|
+
describe("parseIntOrUnlimited", () => {
|
|
15
|
+
it("parses a positive integer", () => {
|
|
16
|
+
assert.deepEqual(parseIntOrUnlimited("3", 1), { value: 3, warning: null });
|
|
17
|
+
assert.deepEqual(parseIntOrUnlimited(3, 1), { value: 3, warning: null });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("parses the unlimited sentinel case-insensitively", () => {
|
|
21
|
+
assert.deepEqual(parseIntOrUnlimited("unlimited", 1), { value: UNLIMITED, warning: null });
|
|
22
|
+
assert.deepEqual(parseIntOrUnlimited("UNLIMITED", 1), { value: UNLIMITED, warning: null });
|
|
23
|
+
assert.deepEqual(parseIntOrUnlimited(" Unlimited ", 1), { value: UNLIMITED, warning: null });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("falls back to default on absent/null/empty with no warning (no-op case)", () => {
|
|
27
|
+
assert.deepEqual(parseIntOrUnlimited(undefined, 12), { value: 12, warning: null });
|
|
28
|
+
assert.deepEqual(parseIntOrUnlimited(null, 12), { value: 12, warning: null });
|
|
29
|
+
assert.deepEqual(parseIntOrUnlimited("null", 12), { value: 12, warning: null });
|
|
30
|
+
assert.deepEqual(parseIntOrUnlimited("", 12), { value: 12, warning: null });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("warns and falls back on invalid values (zero, negative, non-numeric)", () => {
|
|
34
|
+
for (const bad of ["0", "-1", "abc", "3.5"]) {
|
|
35
|
+
const r = parseIntOrUnlimited(bad, 5);
|
|
36
|
+
assert.equal(r.value, 5);
|
|
37
|
+
assert.match(r.warning, /falling back to default 5/);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("resolveCascadePolicy — presets", () => {
|
|
43
|
+
it("defaults to balanced when no config is given (no-op, matches pre-#2234 hardcoded behavior)", () => {
|
|
44
|
+
const { policy, policyName, warnings } = resolveCascadePolicy();
|
|
45
|
+
assert.equal(policyName, DEFAULT_CASCADE_POLICY_NAME);
|
|
46
|
+
assert.deepEqual(policy, CASCADE_PRESETS.balanced);
|
|
47
|
+
assert.deepEqual(warnings, []);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("policy: all removes both caps and disables every heuristic", () => {
|
|
51
|
+
const { policy, warnings } = resolveCascadePolicy({ policy: "all" });
|
|
52
|
+
assert.equal(policy.maxGeneration, UNLIMITED);
|
|
53
|
+
assert.equal(policy.tokenBudget, UNLIMITED);
|
|
54
|
+
assert.equal(policy.deferOnBatchGated, false);
|
|
55
|
+
assert.equal(policy.keywordHeuristic, false);
|
|
56
|
+
assert.equal(policy.p3SameFileDefer, false);
|
|
57
|
+
// "all" leaves both levers uncapped by design — this is the one preset expected to
|
|
58
|
+
// carry the both-uncapped notice (see "resolveCascadePolicy — both-uncapped notice"
|
|
59
|
+
// describe block below for the dedicated coverage of that behavior).
|
|
60
|
+
assert.deepEqual(warnings, [
|
|
61
|
+
"orchestration.cascade: both max_generation and token_budget are unlimited — cascade admission has no upper bound on generation depth or token spend for this run.",
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("policy: conservative keeps balanced's shape but lowers the token budget", () => {
|
|
66
|
+
const { policy } = resolveCascadePolicy({ policy: "conservative" });
|
|
67
|
+
assert.equal(policy.maxGeneration, 1);
|
|
68
|
+
assert.equal(policy.tokenBudget, 450000);
|
|
69
|
+
assert.equal(policy.deferOnBatchGated, true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("unrecognized policy name falls back to balanced with a warning", () => {
|
|
73
|
+
const { policy, policyName, warnings } = resolveCascadePolicy({ policy: "yolo" });
|
|
74
|
+
assert.equal(policyName, "balanced");
|
|
75
|
+
assert.deepEqual(policy, CASCADE_PRESETS.balanced);
|
|
76
|
+
assert.equal(warnings.length, 1);
|
|
77
|
+
assert.match(warnings[0], /not one of/);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("resolveCascadePolicy — granular overrides compose with a preset", () => {
|
|
82
|
+
it("a single granular key overrides just that field, preset supplies the rest", () => {
|
|
83
|
+
const { policy } = resolveCascadePolicy({ policy: "balanced", max_generation: "unlimited" });
|
|
84
|
+
assert.equal(policy.maxGeneration, UNLIMITED);
|
|
85
|
+
// Everything else still comes from the balanced preset.
|
|
86
|
+
assert.equal(policy.tokenBudget, 900000);
|
|
87
|
+
assert.equal(policy.deferOnBatchGated, true);
|
|
88
|
+
assert.equal(policy.keywordHeuristic, true);
|
|
89
|
+
assert.equal(policy.p3SameFileDefer, true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("granular boolean overrides on top of the all preset", () => {
|
|
93
|
+
const { policy } = resolveCascadePolicy({ policy: "all", defer_on_batch_gated: true });
|
|
94
|
+
assert.equal(policy.deferOnBatchGated, true);
|
|
95
|
+
// Unrelated fields remain the all-preset's.
|
|
96
|
+
assert.equal(policy.maxGeneration, UNLIMITED);
|
|
97
|
+
assert.equal(policy.keywordHeuristic, false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("max_generation can express admit-gen-2-stop-at-gen-3 (the case a binary flag cannot)", () => {
|
|
101
|
+
const { policy } = resolveCascadePolicy({ policy: "all", max_generation: 3 });
|
|
102
|
+
assert.equal(admitsGeneration(1, policy), true);
|
|
103
|
+
assert.equal(admitsGeneration(2, policy), true);
|
|
104
|
+
assert.equal(admitsGeneration(3, policy), true);
|
|
105
|
+
assert.equal(admitsGeneration(4, policy), false);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("resolveCascadePolicy — token_budget deprecated-alias fallback", () => {
|
|
110
|
+
it("falls back to legacy pipeline.token_budget_per_batch when orchestration.cascade.token_budget is absent", () => {
|
|
111
|
+
const { policy } = resolveCascadePolicy({ policy: "balanced" }, 300000);
|
|
112
|
+
assert.equal(policy.tokenBudget, 300000);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("orchestration.cascade.token_budget wins over the legacy alias when both are set", () => {
|
|
116
|
+
const { policy } = resolveCascadePolicy({ policy: "balanced", token_budget: 50000 }, 300000);
|
|
117
|
+
assert.equal(policy.tokenBudget, 50000);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("validates the legacy fallback itself — a malformed legacy value does not silently bypass validation (forge#2302)", () => {
|
|
121
|
+
const { policy, warnings } = resolveCascadePolicy({ policy: "balanced" }, -1);
|
|
122
|
+
// -1 is invalid — must NOT be silently adopted; falls back to the balanced preset default.
|
|
123
|
+
assert.equal(policy.tokenBudget, 900000);
|
|
124
|
+
assert.ok(warnings.some((w) => /token_budget_per_batch \(legacy alias\)/.test(w)));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("accepts a valid legacy fallback with no warning", () => {
|
|
128
|
+
const { policy, warnings } = resolveCascadePolicy({ policy: "balanced" }, 123456);
|
|
129
|
+
assert.equal(policy.tokenBudget, 123456);
|
|
130
|
+
assert.ok(!warnings.some((w) => /legacy alias/.test(w)));
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("resolveCascadePolicy — both-uncapped notice", () => {
|
|
135
|
+
it("flags bothUncapped and adds a warning when policy: all leaves both levers unlimited", () => {
|
|
136
|
+
const { policy, bothUncapped, warnings } = resolveCascadePolicy({ policy: "all" });
|
|
137
|
+
assert.equal(policy.maxGeneration, UNLIMITED);
|
|
138
|
+
assert.equal(policy.tokenBudget, UNLIMITED);
|
|
139
|
+
assert.equal(bothUncapped, true);
|
|
140
|
+
assert.ok(warnings.some((w) => /both max_generation and token_budget are unlimited/.test(w)));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("flags bothUncapped via a granular override combination, not just the all preset", () => {
|
|
144
|
+
const { bothUncapped } = resolveCascadePolicy({
|
|
145
|
+
policy: "balanced",
|
|
146
|
+
max_generation: "unlimited",
|
|
147
|
+
token_budget: "unlimited",
|
|
148
|
+
});
|
|
149
|
+
assert.equal(bothUncapped, true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("does not flag bothUncapped when only one lever is uncapped", () => {
|
|
153
|
+
const { bothUncapped: onlyGenUncapped } = resolveCascadePolicy({
|
|
154
|
+
policy: "balanced",
|
|
155
|
+
max_generation: "unlimited",
|
|
156
|
+
});
|
|
157
|
+
assert.equal(onlyGenUncapped, false);
|
|
158
|
+
|
|
159
|
+
const { bothUncapped: onlyTokenUncapped } = resolveCascadePolicy({
|
|
160
|
+
policy: "balanced",
|
|
161
|
+
token_budget: "unlimited",
|
|
162
|
+
});
|
|
163
|
+
assert.equal(onlyTokenUncapped, false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("balanced preset (default, no config) never flags bothUncapped", () => {
|
|
167
|
+
const { bothUncapped } = resolveCascadePolicy();
|
|
168
|
+
assert.equal(bothUncapped, false);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("admitsGeneration / admitsTokenSpend", () => {
|
|
173
|
+
it("unlimited admits any generation and any spend", () => {
|
|
174
|
+
const policy = CASCADE_PRESETS.all;
|
|
175
|
+
assert.equal(admitsGeneration(1, policy), true);
|
|
176
|
+
assert.equal(admitsGeneration(99, policy), true);
|
|
177
|
+
assert.equal(admitsTokenSpend(0, policy), true);
|
|
178
|
+
assert.equal(admitsTokenSpend(Number.MAX_SAFE_INTEGER, policy), true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("balanced caps generation at 1 and token spend at 900000", () => {
|
|
182
|
+
const policy = CASCADE_PRESETS.balanced;
|
|
183
|
+
assert.equal(admitsGeneration(1, policy), true);
|
|
184
|
+
assert.equal(admitsGeneration(2, policy), false);
|
|
185
|
+
assert.equal(admitsTokenSpend(900000, policy), true);
|
|
186
|
+
assert.equal(admitsTokenSpend(900001, policy), false);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("evaluateCascadeFinding — Step 4C rule-chain parity", () => {
|
|
191
|
+
const baseFinding = {
|
|
192
|
+
generation: 1,
|
|
193
|
+
priority: "P3",
|
|
194
|
+
title: "fix: something",
|
|
195
|
+
sameFileAsBatch: false,
|
|
196
|
+
batchFullyGated: false,
|
|
197
|
+
projectedTokenSpend: 0,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
it("policy: all admits a gen >= 3 finding (the regression this issue's AC requires)", () => {
|
|
201
|
+
const { policy } = resolveCascadePolicy({ policy: "all" });
|
|
202
|
+
const result = evaluateCascadeFinding({ ...baseFinding, generation: 3 }, policy);
|
|
203
|
+
assert.equal(result.admit, true);
|
|
204
|
+
assert.equal(result.reason, null);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("balanced defers a gen 2 finding even at P3", () => {
|
|
208
|
+
const { policy } = resolveCascadePolicy({ policy: "balanced" });
|
|
209
|
+
const result = evaluateCascadeFinding({ ...baseFinding, generation: 2 }, policy);
|
|
210
|
+
assert.equal(result.admit, false);
|
|
211
|
+
assert.match(result.reason, /generation 2 exceeds/);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("rule 0: batch fully gated always defers when deferOnBatchGated is true, even for P1", () => {
|
|
215
|
+
const { policy } = resolveCascadePolicy({ policy: "balanced" });
|
|
216
|
+
const result = evaluateCascadeFinding(
|
|
217
|
+
{ ...baseFinding, priority: "P1", batchFullyGated: true },
|
|
218
|
+
policy,
|
|
219
|
+
);
|
|
220
|
+
assert.equal(result.admit, false);
|
|
221
|
+
assert.match(result.reason, /batch fully human-gated/);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("rule 0 is a no-op under policy: all (deferOnBatchGated: false)", () => {
|
|
225
|
+
const { policy } = resolveCascadePolicy({ policy: "all" });
|
|
226
|
+
const result = evaluateCascadeFinding({ ...baseFinding, batchFullyGated: true }, policy);
|
|
227
|
+
assert.equal(result.admit, true);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("priority override: P1/P2 always execute, skipping keyword/same-file heuristics", () => {
|
|
231
|
+
const { policy } = resolveCascadePolicy({ policy: "balanced" });
|
|
232
|
+
const result = evaluateCascadeFinding(
|
|
233
|
+
{ ...baseFinding, priority: "P2", title: "comment: typo fix", sameFileAsBatch: true },
|
|
234
|
+
policy,
|
|
235
|
+
);
|
|
236
|
+
assert.equal(result.admit, true);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("keyword heuristic defers a P3 comment/typo finding when enabled", () => {
|
|
240
|
+
const { policy } = resolveCascadePolicy({ policy: "balanced" });
|
|
241
|
+
const result = evaluateCascadeFinding({ ...baseFinding, title: "fix: typo in README" }, policy);
|
|
242
|
+
assert.equal(result.admit, false);
|
|
243
|
+
assert.match(result.reason, /comment\/typo/);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("keyword heuristic is disabled under policy: all", () => {
|
|
247
|
+
const { policy } = resolveCascadePolicy({ policy: "all" });
|
|
248
|
+
const result = evaluateCascadeFinding({ ...baseFinding, title: "fix: typo in README" }, policy);
|
|
249
|
+
assert.equal(result.admit, true);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("P3 + same-file defers when enabled", () => {
|
|
253
|
+
const { policy } = resolveCascadePolicy({ policy: "balanced" });
|
|
254
|
+
const result = evaluateCascadeFinding({ ...baseFinding, sameFileAsBatch: true }, policy);
|
|
255
|
+
assert.equal(result.admit, false);
|
|
256
|
+
assert.match(result.reason, /same file/);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("token budget defers a P3 finding once projected spend exceeds the ceiling", () => {
|
|
260
|
+
const { policy } = resolveCascadePolicy({ policy: "balanced" });
|
|
261
|
+
const result = evaluateCascadeFinding({ ...baseFinding, projectedTokenSpend: 900001 }, policy);
|
|
262
|
+
assert.equal(result.admit, false);
|
|
263
|
+
assert.match(result.reason, /token budget exhausted/);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -319,10 +319,23 @@ function evaluateOneCloseAssertion(assertion, events) {
|
|
|
319
319
|
}
|
|
320
320
|
|
|
321
321
|
case "issue_closed_at_terminal":
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
322
|
+
// forge#2352: this assertion is enforced inside the engine itself now —
|
|
323
|
+
// not just "evaluated externally" as the previous version of this
|
|
324
|
+
// comment claimed. Two real, wired-up call sites cover it:
|
|
325
|
+
// 1. bin/engine.mjs's runIssue() phase loop: a per-iteration
|
|
326
|
+
// divergence guard (`issueSnapshot()` from bin/engine/phases.mjs)
|
|
327
|
+
// that terminates the run ("invalid" or "needs-human") the moment
|
|
328
|
+
// the issue's live GitHub state/labels diverge from the local
|
|
329
|
+
// run-log, for every phase except `close`.
|
|
330
|
+
// 2. The `close` phase's own reconcile/detectOutcome
|
|
331
|
+
// (bin/engine/phases.mjs), which is exempt from (1) because
|
|
332
|
+
// reading and acting on exactly this state IS its job.
|
|
333
|
+
// This function has no GitHub I/O access (it's a pure assertion
|
|
334
|
+
// evaluator — see the module header), so it cannot re-verify the live
|
|
335
|
+
// issue state itself; it stays a structural pass-through by design.
|
|
336
|
+
// The real enforcement lives in the two call sites above — this case
|
|
337
|
+
// exists so `assertCloseInvariants` callers get a defined, non-throwing
|
|
338
|
+
// result for this assertion id rather than an unhandled default.
|
|
326
339
|
return { ok: true, id: assertion.id };
|
|
327
340
|
|
|
328
341
|
default:
|