mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
## 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 at1c62cc8aeb, 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>
122 lines
8.3 KiB
TypeScript
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>");
|
|
}
|