mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 00:03:15 +01:00
fix(eval-plan): only an undeclared plan is unpriced; a missing price_file is stale, a bad base policy refuses
price-gate's EVAL_GATE_UNPRICED (and --allow-unpriced) now covers only no plan declared at all or a plan with zero call lines. A declared price_file missing from the HEAD commit is EVAL_PLAN_STALE (exit 1, not overridable); an unresolvable origin/<base> is POLICY_SOURCE_UNAVAILABLE and an invalid .gstack/pregate.json is PREGATE_CONFIG_INVALID (both exit 3). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
b26b000ee9
commit
2b99763f95
6 files changed
+52
-12
No files matched your search
@@ -166,6 +166,7 @@ function doPriceGate(rest: string[]): number {
|
||||
const planFile = flag(rest, '--plan-file');
|
||||
if (planFile && !fs.existsSync(planFile)) return usage(`--plan-file ${planFile} does not exist`);
|
||||
const plan = resolvePlanSource(repoRoot, { planFile, base: resolveBaseBranch(repoRoot, flag(rest, '--base')) });
|
||||
if ('code' in plan) { process.stderr.write(resultLine('gstack-eval-plan price-gate', plan.code, plan.message) + '\n'); return plan.refused ? EXIT.refused : EXIT.fail; }
|
||||
const r = priceGate({ repoRoot, gate, plan, capUsd: num(rest, '--cap-usd'), smokeCapUsd: num(rest, '--smoke-cap-usd'), allowUnpriced: flag(rest, '--allow-unpriced') });
|
||||
if (r.receipt) {
|
||||
const out = path.resolve(flag(rest, '--out') ?? path.join(repoRoot, '.gstack', 'tmp', 'price-gate'));
|
||||
|
||||
+9
-3
@@ -79,7 +79,11 @@ approved:
|
||||
file itself changed between them (a committed plan file sits one commit on
|
||||
top of the head it was computed at, so its own commit is allowed);
|
||||
- an input's sha256 of its bytes in the HEAD commit differs from its
|
||||
`GSTACK_EVAL_INPUT` line, or the input is not in HEAD.
|
||||
`GSTACK_EVAL_INPUT` line, or the input is not in HEAD;
|
||||
- the base policy declares a `price_file` that is not in the HEAD commit.
|
||||
|
||||
`--allow-unpriced` never overrides these: only a gate with no plan declared at
|
||||
all, or a plan with zero call lines, is unpriced.
|
||||
|
||||
## Pricing outcomes
|
||||
|
||||
@@ -98,10 +102,12 @@ GATE_PRICE: total=6.7 calls=200 purposes=answer,judge receipt=.gstack/tmp/price-
|
||||
| a model with no row in `lib/pricing.ts`: that row and the total print `unknown`, never zero | 1 | `EVAL_PRICE_MISSING` |
|
||||
| total above `--cap-usd` | 1 | `EVAL_GATE_OVER_CAP` |
|
||||
| one call's reservation above `--cap-usd`, or above `--smoke-cap-usd` for a `scope=smoke` row (`CHECK: reservation-fits-cap exceeds (…)`) | 1 | `EVAL_GATE_OVER_CAP` |
|
||||
| no plan file (no `--plan-file`, no `price_file`, file not in HEAD) or a file with zero call lines | 1 | `EVAL_GATE_UNPRICED` |
|
||||
| no plan declared (no `--plan-file` and no `price_file` key) or a file with zero call lines | 1 | `EVAL_GATE_UNPRICED` |
|
||||
| the same with `--allow-unpriced "<reason>"` | 0 | prints `GATE_UNPRICED_OVERRIDE: <reason> (owner-authorized override; not authorization to spend)` and records it |
|
||||
| malformed or partial file | 1 | `EVAL_DRY_RUN_FAILED` |
|
||||
| stale binding | 1 | `EVAL_PLAN_STALE` |
|
||||
| stale binding, or a declared `price_file` missing from HEAD (not overridable) | 1 | `EVAL_PLAN_STALE` |
|
||||
| `origin/<base>` unresolvable while reading `price_file` | 3 | `POLICY_SOURCE_UNAVAILABLE` |
|
||||
| `.gstack/pregate.json` on the base is invalid | 3 | `PREGATE_CONFIG_INVALID` |
|
||||
| unknown flag or missing `--gate` | 2 | usage |
|
||||
|
||||
The price is an estimate: the table prices standard text tokens and excludes
|
||||
|
||||
@@ -2486,8 +2486,8 @@ passes and carries the `PREGATE:` line in its handoff.
|
||||
|
||||
**Meaning.** `gstack-eval-plan price-gate` found no call-plan file for the paid
|
||||
gate: no `--plan-file`, no `price_file` in the base branch's
|
||||
`.gstack/pregate.json`, the declared file is missing from the HEAD commit, or
|
||||
the file has no `GSTACK_EVAL_CALLS` lines. A gate whose cost is unknown cannot
|
||||
`.gstack/pregate.json`, or the file has no `GSTACK_EVAL_CALLS` lines. (A
|
||||
declared `price_file` missing from HEAD is `EVAL_PLAN_STALE`, not this.) A gate whose cost is unknown cannot
|
||||
be approved as priced.
|
||||
|
||||
**Fix.** The consuming repo commits a call-plan file in the format of
|
||||
@@ -2514,7 +2514,9 @@ to slip under it.
|
||||
**Meaning.** The call-plan file prices something other than what is being
|
||||
approved: its `GSTACK_EVAL_GATE` command is not the exact `--gate` string, its
|
||||
`GSTACK_EVAL_HEAD` is behind HEAD with files other than the plan file changed
|
||||
since, or an input's sha256 at HEAD differs from its `GSTACK_EVAL_INPUT` line.
|
||||
since, an input's sha256 at HEAD differs from its `GSTACK_EVAL_INPUT` line, or
|
||||
the base policy's `price_file` is not in the HEAD commit. `--allow-unpriced`
|
||||
does not override it.
|
||||
|
||||
**Fix.** Regenerate the plan file at HEAD for the exact gate command (the
|
||||
repo's own producer), commit it, and rerun `gstack-eval-plan price-gate`.
|
||||
+11
-5
@@ -413,20 +413,26 @@ function gitOut(repoRoot: string, args: string[]): Buffer | null {
|
||||
return r.status === 0 ? r.stdout : null;
|
||||
}
|
||||
|
||||
/** `--plan-file` wins; else the base policy's `price_file`, read from the HEAD commit's bytes. */
|
||||
export function resolvePlanSource(repoRoot: string, o: { planFile?: string; base: string }): PlanSource {
|
||||
export interface PlanSourceRefusal { code: ResultCodeName; message: string; refused: boolean }
|
||||
|
||||
/**
|
||||
* `--plan-file` wins; else the base policy's `price_file`, read from the HEAD
|
||||
* commit's bytes. Only "no plan declared" is unpriced (overridable); a declared
|
||||
* file missing at HEAD is stale, an unresolvable base or an invalid policy refuses.
|
||||
*/
|
||||
export function resolvePlanSource(repoRoot: string, o: { planFile?: string; base: string }): PlanSource | PlanSourceRefusal {
|
||||
if (o.planFile) {
|
||||
const file = path.resolve(o.planFile);
|
||||
const rel = path.relative(repoRoot, file);
|
||||
return { text: fs.readFileSync(file), file, source: 'flag', repoPath: rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel.split(path.sep).join('/') : null, why: null };
|
||||
}
|
||||
const load = loadConfigFromBase(repoRoot, o.base);
|
||||
if (isConfigUnavailable(load)) return { text: null, file: null, source: 'none', repoPath: null, why: `no --plan-file and the base policy is unavailable (${load.detail})` };
|
||||
if (load.errors.length) return { text: null, file: null, source: 'none', repoPath: null, why: `${load.label} is invalid: ${load.errors.map(e => `${e.path}: ${e.message}`).join('; ')}` };
|
||||
if (isConfigUnavailable(load)) return { code: 'POLICY_SOURCE_UNAVAILABLE', message: `no --plan-file and the base policy cannot be read (${load.detail})`, refused: true };
|
||||
if (load.errors.length) return { code: 'PREGATE_CONFIG_INVALID', message: `${load.label}: ${load.errors.map(e => `${e.path}: ${e.message}`).join('; ')}`, refused: true };
|
||||
const rel = load.config.price_file;
|
||||
if (!rel) return { text: null, file: null, source: 'none', repoPath: null, why: `no --plan-file and ${load.label} declares no price_file` };
|
||||
const text = gitOut(repoRoot, ['show', `HEAD:${rel}`]);
|
||||
if (!text) return { text: null, file: rel, source: 'price_file', repoPath: rel, why: `price_file ${rel} (${load.label}) is not in the HEAD commit` };
|
||||
if (!text) return { code: 'EVAL_PLAN_STALE', message: `price_file ${rel} (declared in ${load.label}) is not in the HEAD commit`, refused: false };
|
||||
return { text, file: rel, source: 'price_file', repoPath: rel, why: null };
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -483,7 +483,7 @@ export const RESULT_CODES = {
|
||||
},
|
||||
EVAL_PLAN_STALE: {
|
||||
anchor: 'eval-plan-stale',
|
||||
summary: 'the call-plan file\'s binding does not match: a different gate command, a head behind HEAD, or an input whose sha256 at HEAD differs',
|
||||
summary: 'the call-plan file\'s binding does not match (a different gate command, a head behind HEAD, an input whose sha256 at HEAD differs) or the declared price_file is not in HEAD',
|
||||
fix: 'regenerate the call-plan file at HEAD for the exact gate command you are approving, commit it, and rerun `gstack-eval-plan price-gate`',
|
||||
},
|
||||
} as const satisfies Record<string, ResultCode>;
|
||||
|
||||
@@ -280,6 +280,30 @@ describe('price-gate: binding, pricing and the receipt (bin, simulated Capy host
|
||||
expect(r.err).toMatch(/behind HEAD.*README\.md.*\(EVAL_PLAN_STALE\)/);
|
||||
});
|
||||
|
||||
test('a declared price_file missing at HEAD refuses EVAL_PLAN_STALE (not overridable); an unresolvable base or an invalid pregate.json exits 3', () => {
|
||||
const { repo } = scratchRepo(host.env);
|
||||
const git = (...args: string[]) => spawnSync('git', args, { cwd: repo, env: host.env, encoding: 'utf8' }).stdout.trim();
|
||||
const out = path.join(host.base, 'receipts');
|
||||
let r = runGate(host, repo, ['--gate', GATE, '--base', 'main', '--out', out, '--allow-unpriced', 'owner said so']);
|
||||
expect(r.code).toBe(3);
|
||||
expect(r.err).toContain('(POLICY_SOURCE_UNAVAILABLE)');
|
||||
fs.mkdirSync(path.join(repo, '.gstack'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, '.gstack', 'pregate.json'), JSON.stringify({ price_file: 'evals/missing.calls' }));
|
||||
git('add', '.gstack/pregate.json'); git('commit', '-q', '-m', 'policy');
|
||||
git('update-ref', 'refs/remotes/origin/main', git('rev-parse', 'HEAD'));
|
||||
r = runGate(host, repo, ['--gate', GATE, '--base', 'main', '--out', out, '--allow-unpriced', 'owner said so']);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.err).toMatch(/evals\/missing\.calls.*\(EVAL_PLAN_STALE\)/);
|
||||
expect(r.out).not.toContain('GATE_UNPRICED_OVERRIDE');
|
||||
fs.writeFileSync(path.join(repo, '.gstack', 'pregate.json'), JSON.stringify({ price_file: '../outside.calls' }));
|
||||
git('add', '.gstack/pregate.json'); git('commit', '-q', '-m', 'bad policy');
|
||||
git('update-ref', 'refs/remotes/origin/main', git('rev-parse', 'HEAD'));
|
||||
r = runGate(host, repo, ['--gate', GATE, '--base', 'main', '--out', out, '--allow-unpriced', 'owner said so']);
|
||||
expect(r.code).toBe(3);
|
||||
expect(r.err).toContain('(PREGATE_CONFIG_INVALID)');
|
||||
expect(fs.existsSync(out)).toBe(false);
|
||||
});
|
||||
|
||||
test('an unknown or misspelled flag exits 2 instead of pricing without it', () => {
|
||||
const { repo } = scratchRepo(host.env);
|
||||
expect(runGate(host, repo, ['--gate', GATE, '--cap', '5']).code).toBe(2);
|
||||
@@ -330,6 +354,7 @@ describe('price-gate: binding, pricing and the receipt (bin, simulated Capy host
|
||||
|
||||
test('no plan file, or a file with no call lines, is EVAL_GATE_UNPRICED exit 1; --allow-unpriced exits 0 with a recorded override', () => {
|
||||
const { repo, head, rowsSha } = scratchRepo(host.env);
|
||||
spawnSync('git', ['update-ref', 'refs/remotes/origin/main', head], { cwd: repo, env: host.env });
|
||||
const out = path.join(host.base, 'receipts');
|
||||
let r = runGate(host, repo, ['--gate', GATE, '--base', 'main', '--out', out]);
|
||||
expect(r.code).toBe(1);
|
||||
|
||||
Reference in new issue
Block a user