Files
PaperClipAI/tests/runner-e2e/scheduled-operations.ts
T
DottaandPaperclip ed3f067dbd fix(evals): select operating allowance by cadence (#17326)
## Thinking Path

> - Paperclip manages AI agents and their operating budgets.
> - Product E2E runs exercise real agents through a bounded gateway.
> - The daily and weekly selections share one repository configuration
variable.
> - Their different request limits need different operating allowances.
> - The existing parser accepts only one allowance, which cannot
represent both reviewed limits.
> - This change selects the allowance for the actual cadence and retains
all existing validation.

## Linked Issues or Issue Description

Refs #17009.

**What happened?**

The daily selection needs an $11 operating reservation; the weekly
selection needs $21. A shared $21 allowance cannot fit the $20 daily
cap. A shared $11 allowance does not cover the reviewed weekly resource
bound. A configuration containing both allowances was rejected before
paid admission.

**Expected behavior**

Each cadence uses its own reviewed allowance. Missing or malformed
selected allowances stop admission before paid work.

**Steps to reproduce**

Set the operating allowance JSON to an object with daily and weekly
allowances. Prepare either campaign through prepareScheduledCampaign.
Before this change, both calls reject the outer object.

**Paperclip version or commit**

3bd709df8b.

## What Changed

- Accept daily and weekly allowance entries in the existing
configuration variable.
- Select the actual cadence before validating its budget, runner and
expiration.
- Pass only the selected allowance through the existing workflow output.
- Preserve single-allowance compatibility and document the configuration
format.

## Verification

- Reproduced both cadence failures before the fix: 2 failed, 12 passed.
- Focused scheduled operations and operating overhead tests: 20 passed.
- Eval TypeScript check passed.
- Missing selected entries and unknown outer fields fail before creating
a manifest.
- Hosted CI passed at 1c62cc8aeb,
including full typecheck, tests, build and browser shards.
- Greptile and Paperclip reviews both rated the exact commit 5/5 with no
actionable findings.

## Risks

- A map with a missing selected cadence now fails admission. It never
substitutes the other cadence.
- Operators must still provide reviewed rates and resource bounds. This
parser does not prove actual charges.
- This PR does not change limits, credentials, workflow permissions or
enable gates.

## Model Used

- OpenAI GPT-6 in Codex, with reasoning, repository inspection, code
execution and test tools. The exact backend model identifier and
context-window size are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-23 07:52:41 -07:00

122 lines
8.3 KiB
TypeScript

import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { runnerMatrix } from "./catalog.js";
import { validateEvalOperationsManifest, type EvalOperationsManifest } from "./operations.js";
import { evalRequestReservation } from "./eval-gateway.js";
import prices from "./scheduled-prices.json" with { type: "json" };
import { scheduledOperatingOverhead, validateOperatingOverhead, type EvalOperatingOverhead } from "./operating-overhead.js";
import { usdToUnits } from "../../packages/shared/src/money.js";
import { scheduledForecast } from "./scheduled-forecast.js";
export type EvalCadence = "daily" | "weekly";
export const EVAL_SCHEDULES = { daily: "47 8 * * *", weekly: "47 9 * * 0" } as const;
const budget = (cadence: EvalCadence) => cadence === "daily" ? "20" : "100";
const marker = (profile: string) => `core-compatibility.${profile}.local.message-marker`;
const feedback = (profile: string) => `feedback-persistence.${profile}.local.complaint-and-suggestion`;
export function scheduledSelection(cadence: EvalCadence) {
if (!["daily", "weekly"].includes(cadence)) throw new Error("Unknown eval cadence");
return [marker("runner-acpx-claude"), feedback("runner-acpx-claude"),
...(cadence === "weekly" ? [marker("runner-codex"), feedback("runner-codex")] : [])];
}
export function scheduledCadence(event: string, schedule: string, requested = "manual"): EvalCadence | undefined {
if (event === "schedule") {
const found = Object.entries(EVAL_SCHEDULES).find(([, expression]) => expression === schedule);
if (!found) throw new Error("Unrecognized paid eval schedule");
return found[0] as EvalCadence;
}
if (event !== "workflow_dispatch" || !["manual", "daily", "weekly"].includes(requested)) throw new Error("Unrecognized eval trigger");
return requested === "manual" ? undefined : requested as EvalCadence;
}
export function validateScheduledBudget(input: { cadence: EvalCadence; now: number; overhead: EvalOperatingOverhead }) {
if (!Number.isFinite(input.now) || input.now < Date.parse(prices.verifiedAt) || input.now >= Date.parse(prices.expiresAt)) {
throw new Error("Scheduled eval pricing needs review before paid admission");
}
const executionIds = scheduledSelection(input.cadence);
const executions = executionIds.map(id => {
const execution = runnerMatrix.find(row => row.id === id);
if (!execution) throw new Error(`Scheduled execution is absent: ${id}`);
return execution;
});
const models = prices.models.filter(model => executions.some(execution => execution.profile.model === model.price.model)) as EvalOperationsManifest["models"];
if (executions.some(execution => !models.some(model => model.price.model === execution.profile.model
&& model.provider === (execution.profile.credential === "ANTHROPIC_API_KEY" ? "anthropic" : "openai")))) {
throw new Error("Missing exact scheduled model pricing");
}
const reserved = validateOperatingOverhead(input.overhead, budget(input.cadence));
if (input.now < Date.parse(input.overhead.verifiedAt) || input.now + 30 * 60_000 >= Date.parse(input.overhead.expiresAt)) {
throw new Error("Operating overhead allowance does not cover the campaign window");
}
if (models.some(model => reserved + usdToUnits(evalRequestReservation({ ...model, apiKey: "forecast", mode: "braintrust" })) > usdToUnits(budget(input.cadence)))) {
throw new Error("Operating overhead leaves insufficient allowance for the selected model");
}
return { executionIds, executions, models };
}
export function scheduledManifest(input: {
cadence: EvalCadence; campaignId: string; ledgerPath: string; projectId: string; now: number; runAttempt: string;
overhead: EvalOperatingOverhead;
}): EvalOperationsManifest {
if (input.runAttempt !== "1") throw new Error("Paid campaign reruns require the retained journal; a fresh allowance is refused");
const { executionIds, executions, models } = validateScheduledBudget(input);
const manifest: EvalOperationsManifest = {
version: 1, limits: { campaignId: input.campaignId, maxUsd: budget(input.cadence), overhead: input.overhead,
deadlineAt: new Date(input.now + 30 * 60_000).toISOString(), maxRequests: input.cadence === "daily" ? 60 : 120,
maxConcurrent: 1, maxAttemptsPerExecution: 1 },
ledgerPath: input.ledgerPath, executionIds, maxAttemptsPerExecution: 1, route: "braintrust", projectId: input.projectId,
models,
};
validateEvalOperationsManifest(manifest, executions, 0);
return manifest;
}
export function scheduledMatrix(cadence: EvalCadence) {
return { include: [{ executionId: "scheduled-campaign", suiteId: "core-compatibility", profileId: "runner-acpx-claude",
credentialName: "", environmentId: "local", caseId: "scheduled-campaign", timeoutMinutes: 60, needsDaytona: false }],
needsDaytona: false, executionIds: scheduledSelection(cadence) };
}
export async function prepareScheduledCampaign(env: NodeJS.ProcessEnv, now = Date.now()) {
const cadence = scheduledCadence(env.EVAL_EVENT ?? "", env.EVAL_SCHEDULE ?? "", env.EVAL_CADENCE);
if (!cadence || env.EVAL_OPERATIONS_ENABLED !== "true") throw new Error("Bounded eval operations are not enabled");
if (!/^[1-9][0-9]*$/.test(env.GITHUB_RUN_ID ?? "") || !env.GITHUB_WORKSPACE) throw new Error("Missing workflow campaign identity");
const campaignId = `gha-${env.GITHUB_RUN_ID}-1-scheduled-campaign`;
const directory = path.join(env.GITHUB_WORKSPACE, "tests/runner-e2e/results/scheduled");
const manifest = scheduledManifest({ cadence, campaignId, ledgerPath: path.join(directory, "ledger.jsonl"),
projectId: env.PAPERCLIP_E2E_BRAINTRUST_PROJECT_ID ?? "", now, runAttempt: env.GITHUB_RUN_ATTEMPT ?? "",
overhead: scheduledOperatingOverhead(env.EVAL_OPERATING_OVERHEAD_JSON, env.EVAL_TEST_RUNNER, budget(cadence), now, cadence) });
await mkdir(directory, { recursive: true, mode: 0o700 });
const manifestPath = path.join(directory, "manifest.json");
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600, flag: "wx" });
const forecast = { cadence, executionIds: manifest.executionIds, maxUsd: manifest.limits.maxUsd,
overhead: manifest.limits.overhead, budgetScope: "model requests plus declared operating overhead reservations",
measurements: scheduledForecast(runnerMatrix.filter(execution => manifest.executionIds.includes(execution.id)), manifest.models),
basis: "Retained local pilot measurements; successful samples and matching configurations are reported separately",
uncertainty: "Incomplete provider receipts and no invoice reconciliation; estimates are not a guarantee",
maximumRequestReservations: manifest.models.map(model => ({ model: model.price.model,
usd: evalRequestReservation({ ...model, apiKey: "forecast", mode: "braintrust" }) })),
maximumMonthlyTotalAllowanceUsd: "1120", pricingExpiresAt: prices.expiresAt };
await writeFile(path.join(directory, "forecast.json"), `${JSON.stringify(forecast, null, 2)}\n`, { mode: 0o600, flag: "wx" });
return { manifest, manifestPath, campaignId, cadence };
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const command = process.argv[2];
if (command === "matrix") {
const cadence = scheduledCadence(process.env.EVAL_EVENT ?? "", process.env.EVAL_SCHEDULE ?? "", process.env.EVAL_CADENCE);
if (!cadence || process.env.EVAL_OPERATIONS_ENABLED !== "true") throw new Error("Bounded eval operations are not enabled");
const now = Date.now();
const overhead = scheduledOperatingOverhead(process.env.EVAL_OPERATING_OVERHEAD_JSON, process.env.EVAL_TEST_RUNNER, budget(cadence), now, cadence);
validateScheduledBudget({ cadence, overhead, now });
console.log(JSON.stringify({ ...scheduledMatrix(cadence), overhead }));
} else if (command === "prepare") {
const prepared = await prepareScheduledCampaign(process.env);
if (!process.env.GITHUB_ENV) throw new Error("Missing workflow environment file");
const prior = await readFile(process.env.GITHUB_ENV, "utf8");
await writeFile(process.env.GITHUB_ENV, `${prior}PAPERCLIP_E2E_OPERATIONS_MANIFEST=${prepared.manifestPath}\nPAPERCLIP_E2E_CAMPAIGN_ID=${prepared.campaignId}\nPAPERCLIP_E2E_ACCOUNTING_CAMPAIGN_ID=${prepared.campaignId}\n`);
} else throw new Error("Usage: scheduled-operations.ts <matrix|prepare>");
}