model a Quota Burn step as a scheduled-task reference plus per-invocation overrides - #6390
Merged
Conversation
…tion overrides (#6377) A burn step used to BE its work: a copied prompt in a free-form params bag keyed by a quota-only jobType, with no durable link to the scheduled task it came from. Preset identity was never persisted at all — server and client both re-derived it by comparing prompt text, so editing one word of a shipped prompt orphaned the step. A step now stores a discriminated reference — a built-in scheduled task type plus the managed app it targets when the type requires one, or a custom scheduled job id whose own app scope is authoritative — alongside an explicit per-invocation overrides bag (provider / model / effort / run params). An unset override inherits the task's saved settings, and nothing written on the step ever edits the schedule. Old jobType plans still load with every setting intact; they normalize to a retained-but-unavailable step carrying a migration reason rather than being guessed into a reference. The existing executor is untouched, so installs keep burning until #6381 converts them. Dangling, disabled, wrong-scope and ineligible references resolve to the same retained-unavailable shape against a catalog passed in by the caller, so normalization stays pure and the verdict never reaches disk. Zod now rejects a reference missing a required target app, a malformed custom job id, a step naming two kinds of work or none, and a provider pin that belongs to another quota family — each naming the offending field, so a plan can no longer save itself into spending the wrong subscription. Rolls in a supporting move: the scheduled-task target-scope vocabulary drops from services/taskScheduleRegistry.js (which re-exports it) to lib/taskTargetScope.js, because server/lib may not import upward into server/services. Part of #6372.
…e existence checks (#6377) Reviewer caught the doc overstating the empty-catalog guarantee: target scope is a property of the reference itself, so a step missing a required app is broken with or without a loaded catalog and is reported either way. Only existence, enabled state and eligibility are withheld. Behavior unchanged; the contract and its test now say what the code does.
atomantic
force-pushed
the
claim/issue-6377
branch
from
September 6, 2026 15:26
aa322eb to
d82109e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A Quota Burn step used to be its work: a copied prompt in a free-form
paramsbag keyed by a quota-onlyjobType, with no durable link to the scheduled task it came from. Preset identity was never persisted at all — the server (upgradeStoredAuditPrompt) and the client (matchedPreset) both re-derived it by comparing prompt text, so editing one word of a shipped prompt orphaned the step.A step now stores a discriminated scheduled-task reference plus an explicit per-invocation overrides bag:
taskRefis either{ kind: 'builtin', taskType, appId }— with the target app validated against the same scope contract the schedule's own request gate uses — or{ kind: 'custom', jobId }, which deliberately stores no app scope because the job record owns it.overridesholds provider / model / effort / run params. An unset override inherits the task's saved settings, and nothing written on a step ever edits the schedule.enabled,label,runOnce, family settings, and the overrides themselves round-trip byte-identical.quotaBurnStepIsDispatchable()false.Zod now rejects — each naming the offending field — a reference missing a required target app, an install-wide type given one, a malformed custom job id, a step naming two kinds of work or none, and a provider pin belonging to another quota family, so a plan can no longer save itself into spending the wrong subscription.
Data model, normalization and validation only.
data/cos/quota-burn.jsonstays machine-local and unfederated.Deliberate boundaries
jobTypepayload still loads with every setting intact and normalizes to unavailable-with-a-migration-reason, butquotaBurnRunner.js/JOB_MODULESare untouched — flipping the model and cutting execution here would stop quota burn dead on every install for the length of the epic. Add a shared quota-burn invocation path over scheduled tasks with a countPending contract #6378's reference path is gated onquotaBurnStepIsDispatchable()from the start, so it never inherits a legacy step, and Migrate legacy Quota Burn prompt plans to scheduled-task references and retire JOB_MODULES #6381 retires the old executor.providerId/model/effort/paramsremain as derived compat mirrors ofoverrides, because the shipped editor still writes them (it moves tooverridesin Replace the Quota Burn editor with a scheduled-task picker #6382). They are recomputed on every read, so they cannot drift. The tie-break is key presence, not truthiness — a payload carrying a top-level key wins with it, including a deliberatenullclear, so clearing a pinned model can't resurrect the stale one.familyForProviderId()rejects only ids that unambiguously name another family; a user's own wrapper name falls through to dispatch-time resolution, which stays authoritative.saveQuotaBurnConfig's shallow family merge and the client mirrormergeQuotaBurnPatchare unchanged — "extend both or neither", and this needs neither.Rolled in
INSTALL_WIDE_TASK_TYPES/MANAGED_APP_TARGET_TASK_TYPES/requiresManagedAppTarget/requiresInstallWideTargetmove down toserver/lib/taskTargetScope.js, re-exported unchanged fromservices/taskScheduleRegistry.js.server/libmay not import upward intoserver/services(lib/layering.test.js), and the reference schemas need the scope contract.familyForProviderId(id)added tolib/providerFamilies.js— the id-only classifier a Zod schema can use,nullwhen the id names no family.lib/importScoping.test.jsbudget raised 89,500 → 91,400. This change measures 89,889; its ~389 share is two leaf vocabulary modules with no subtree behind them, reached by the ~200 suites that crossquotaBurnConfig.js. That is the ordinary growth the budget documents as tolerable, so the raise restores the documented ~1.5k headroom rather than inching the number up.Test plan
cd server && npm test— full suite green (2011 files).cd client && npm test— full suite green (870 files); no client source changed.server/lib/quotaBurnTaskRef.test.js: no kind inference from stray fields, custom refs carry no app scope, one code per distinct unavailability cause, catalog-dependent verdicts withheld with no catalog while scope faults still report, non-mutating whole-config stamping, dispatchability gates.server/lib/quotaBurnConfig.test.js: reference round-trip (save → reload → re-normalize identical), top-level clear beating a stale override, legacy step loading with every setting plus a migration reason and never being rewritten or duplicated.server/routes/quotaBurn.test.js: each rejection asserted by itsdetails[].path, plus in-family and unclassifiable pins accepted.server/services/quotaBurnJobs/providerPick.test.js: an unpinned step resolves inside its own family and never falls back to another's subscription.Closes #6377