mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The runner full-stack E2E lane runs real Paperclip work against real agents, and TestOps launches its standing plans > - PR #19455 added seven staging-soak reliability cases, but no TestOps plan or workflow lane selects them, so they run only by hand with personal keys > - These cases catch restart, interrupt, queue and lost-runner regressions, so they must run from TestOps before a release with the standard reports > - This pull request adds a sibling "Reliability — staging soak" TestOps plan on the existing plan-2 lane, with native OpenCode and DeepSeek 1017 through OpenRouter as the plan profile for six of seven cases > - The benefit is a cheap, repeatable pre-release reliability launch with the same Allure/TestOps results, retained report and dashboard as plan 2 ## Linked Issues or Issue Description Refs #19455 **What existing behavior does this improve?** The staging-soak reliability cases from #19455 run only by a manual local command. **Subsystem affected** Runner full-stack E2E (`tests/runner-e2e/`) and its TestOps lane in `.github/workflows/runner-full-stack-e2e.yml`. **Current behavior** No TestOps plan includes the cases. The workflow cannot select them from TestOps. Every cell uses native Codex (`gpt-5.6-sol`) or an ACPX facade on Daytona, which is expensive. **Proposed behavior** A TestOps launch of the Reliability plan runs seven cells (six DeepSeek 1017, one native Codex for steering) through the standing plan-2 path and reports them like plan 2. **Reason and benefit** The cases become a first-class, low-cost, pre-release check with retained evidence. Plan 2 keeps its 20-case qualification semantics. **Breaking changes** None. Plan 2 selection, reporting and history identities stay the same. ## What Changed - **Plan choice: a sibling plan, not an extension of plan 2.** Plan 2 is bounded at 20 standing IDs. Its `full-run` / `test-plan-2` tag needs a complete 20-case attempt, and subset runs do not qualify it. Adding seven cases would change that denominator and the qualification history. The sibling plan shares the same TestOps GitHub job, selection, admission, projection, upload and verification code. A launch selects cases from exactly one plan (`plan_mixed` fails), and each plan has its own full-plan size (20 or 7). - `tests/runner-e2e/reliability-testops.mjs` (new): the mapping module. It has seven cases, full names `paperclip.Product E2E.everyday-workflows.<case>`, execution IDs, soak signatures and an empty `reliabilityRegistration`. A partial, duplicate, plan-2 or malformed registration is rejected. An empty registration makes selection fail closed before any spend (`product_regression_testops_reliability_unregistered` for a bootstrap, `case_mismatch` for a TestOps launch). No TestOps IDs are invented. - `product-regression-testops.mjs` / `-cli.mjs`: select a plan by its case identities or by the new bootstrap input. The projection binds each cell's own environment (`local` or `daytona`) instead of a fixed `local`. It adds a `soak-<signature>` tag and reports `plan` and a per-plan `fullPlan`. Plan-2 history IDs, parameters and results do not change. - Catalog: `runner-opencode-deepseek-1017` is now one shared fixture with `supportedEnvironments: ["local", "daytona"]`. The OpenCode server driver already runs on Daytona for `runner-opencode` (task-workspaces, core-compatibility, rolling deployment); only the model differs. The chat-feature suite stays local, and its definition hash does not change. `everyday-workflows` gains exactly six DeepSeek plan cells (definition version 12, 52 + 13 + 3 cells). - Cell matrix (plan), DeepSeek 1017 on native OpenCode: `interrupt-redirect`, `reassign-midrun` and `tree-pause-release` run on `local`. `three-turn-revise` runs on Daytona with the per-turn lease (S49). `long-silent-command` (S52/S52b) and `queued-across-restart` (S55/U9) run on the Daytona warm lease. - `queue-steer` is the one exception. It must steer an active turn, and the OpenCode server driver declares `steering: false`, so the route answers `steering_unsupported` and the case cannot pass. The plan uses the existing steering-capable `everyday-workflows.runner-codex.daytona.queue-steer` cell for it (Greptile finding). No DeepSeek `queue-steer` cell exists. The other native Codex Daytona cells and the ACPX facades stay selectable through `--group reliability`, but they are outside the plan. - `runner-full-stack-e2e.yml`: adds the `testops_plan` bootstrap input (`product-regression` by default), sets the plan-specific bootstrap launch name and tags, and renames two jobs to plan-neutral names. The OpenCode steps now match `startsWith(matrix.profileId, 'runner-opencode')`, so the DeepSeek profile gets the pinned OpenCode binary, runner TypeScript, the local Node check and the remote provider pack. Before this change, the DeepSeek profile could not run in this workflow. `OPENROUTER_API_KEY`, `OPENAI_API_KEY` (only for `queue-steer`) and `DAYTONA_API_KEY` come from the same `matrix.credentialName` and `matrix.environmentId` gates that other cells use. - `runner-e2e-testops-registration.yml` + `reliability-testops-registration.mjs` (new): a trusted, master-only, allowlisted-actor, idempotent `workflow_dispatch`. `inspect` only reads. `register` reuses cases with the same full name, creates missing cases and the plan, and assigns the plan to plan 2's GitHub job with plan 2's job parameters. It writes a receipt with a ready-to-commit `reliabilityRegistration` snippet. Only this job gets `ALLURE_TOKEN`, and it never runs a provider cell. - **No schedule and not per PR.** The plan runs only from a TestOps launch or a manual dispatch. It is not in the daily or weekly cron (`scheduledSelection` excludes it). A weekly run would add a paid 50-minute Daytona cell every week, but these signatures move with release-risky changes, not with time. - Docs: `EVERYDAY-WORKFLOWS.md` (matrix, environment reasons, local coverage limits, registration steps), `README.md`, `doc/evals.md`. ### Registration still needed (requires TestOps credentials) 1. Dispatch **Runner E2E TestOps registration** from master with `operation=inspect`. 2. Dispatch it with `operation=register`. 3. Commit the `reliabilityRegistration` snippet from the run summary into `tests/runner-e2e/reliability-testops.mjs` in one reviewed PR. 4. Launch the plan from TestOps. Tag only a complete seven-case attempt `full-run`. ### What local plan cells cannot reproduce The local cells do not reproduce the Daytona-only variants: the remote `workspace_busy` save after Stop (S47), remote stop settlement during reassignment (S48), and a lost or replaced remote runner during a tree hold (S50). S51 (lost runner) has no case yet. The explicit-only native Codex Daytona extras still cover S47, S48 and S50 on a sandbox. ## Verification - `node --test tests/runner-e2e/product-regression-testops.test.mjs tests/runner-e2e/reliability-testops.test.mjs`: 26 pass. These tests cover fail-closed selection, partial or duplicate registration, plan mixing, environment binding, per-plan denominators, preserved failure classes, the CLI select → project → verify flow for a seven-case launch, registration idempotency and job checks, and the workflow inputs and credential gates. - `vitest` for `reliability-testops-catalog`, `product-regression-testops-catalog`, `everyday-reliability` and `catalog.test`: 98 pass. These tests bind the mapping to real catalog cells (profile, model, environment, credentials, single attempt). They also check that the cells stay out of `--all`, the suite, profile and environment selectors and the daily/weekly cron. Real grader output must survive projection. - `pnpm test:e2e:runner:typecheck`: pass. - `pnpm test:e2e:runner -- --list --suite everyday-workflows --group reliability` lists 16 cells: 6 DeepSeek plan cells, 7 native Codex cells (one is the `queue-steer` plan cell) and 3 ACPX extras. `--matrix-json` for the DeepSeek plan IDs emits `needsDaytona=true` and three Daytona OpenCode cells with `credentialName=OPENROUTER_API_KEY`. - Full `pnpm test:e2e:runner:unit`: the remaining failures happen the same way on an unchanged master checkout. They are in `workflow-security` (3), `controller-outage`, `operating-receipts`, `pi-*`, `private-framework-report` and `remote-native-bootstrap`. #19529 tracks that drift. This PR adds no failures. - Not run: paid cells, a TestOps registration and a TestOps launch. They need provider and TestOps credentials, which exist only in protected CI environments. Catalog and projection tests are not live qualification. ## Risks - The registration script calls TestOps APIs (`POST /api/testcase`, `/api/testplan`, `/assign`, `/jobparameter`) through the same endpoints as the chat catalog reconcile. Tests use fakes, not the live API. `inspect` comes first, and `register` is idempotent. - DeepSeek 1017 on Daytona has not run before. The first launch is the acceptance step for those three cells. - If a later case needs another capability that OpenCode lacks, it must use a capable profile in the same way as `queue-steer`. The catalog test pins the single exception. - The OpenCode workflow conditions now match any `runner-opencode*` profile. Today this covers `runner-opencode` and `runner-opencode-deepseek-1017` only. - If someone commits a bad `reliabilityRegistration`, module load fails for both plans. A unit test rejects any committed registration that is not empty or complete. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) through Claude Code, with tool use (shell, file edits, local test runs). ## 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
166 lines
11 KiB
TypeScript
166 lines
11 KiB
TypeScript
import type { RunnerTaskFixture } from "./types.js";
|
|
|
|
/**
|
|
* Staging-soak reliability scenarios. Every case reproduces a real staging
|
|
* failure end to end. They are explicit-only: `--all`, `--suite
|
|
* everyday-workflows` and generic profile/environment selectors never pick
|
|
* them. Select them with `--group reliability`, a `--case`, or an exact `--id`.
|
|
*/
|
|
export const EVERYDAY_RELIABILITY_GROUP = "reliability";
|
|
export const EVERYDAY_RELIABILITY_SLOW_GROUP = "slow";
|
|
|
|
/** Seconds per silent iteration and iteration count for `long-silent-command`. */
|
|
export const LONG_SILENT_ITERATION_SECONDS = 60;
|
|
export const LONG_SILENT_ITERATIONS = 13;
|
|
|
|
/** Soak signature, the observed failure before its fix, and related fix PRs. */
|
|
export const everydayReliabilitySignatures = {
|
|
"interrupt-redirect": { signature: "S47", observed: "Interrupted follow-up started after 4 minutes or never; needed a second message.", fixes: ["#19370", "#19355"] },
|
|
"reassign-midrun": { signature: "S48", observed: "Reassigning an active task returned 409 or hung for 20 seconds; the old run kept executing.", fixes: ["#19354"] },
|
|
"three-turn-revise": { signature: "S49", observed: "Turn 3 on an ephemeral Daytona lease (gpt-5.6-sol) stopped behind a reconciliation / send-a-message blocker.", fixes: [] },
|
|
"long-silent-command": { signature: "S52/S52b", observed: "ACPX-facade runs (Grok, Kimi, OpenRouter/Pi) hung 3/3 after a long silent foreground command; Stop disappeared.", fixes: ["#19407", "#19419"] },
|
|
"tree-pause-release": { signature: "S50/S50b", observed: "A child started during a tree pause, or did not resume after release without wakeAgents.", fixes: ["#19394", "#19421"] },
|
|
"queue-steer": { signature: "H2", observed: "Edited, discarded and steered queue entries were lost, replayed, or delivered twice.", fixes: [] },
|
|
"queued-across-restart": { signature: "S55/U9", observed: "Follow-up queued before a controller restart sat as a bare Queued until another message.", fixes: ["#19431"] },
|
|
} as const;
|
|
|
|
export type EverydayReliabilityCaseId = keyof typeof everydayReliabilitySignatures;
|
|
export const everydayReliabilityCaseIds = Object.keys(everydayReliabilitySignatures) as EverydayReliabilityCaseId[];
|
|
|
|
export function isEverydayReliabilityCase(caseId: string): caseId is EverydayReliabilityCaseId {
|
|
return Object.hasOwn(everydayReliabilitySignatures, caseId);
|
|
}
|
|
|
|
/** Bounds encoded in the graders. Soak saw 13-90 s once fixed; 4 minutes or never before. */
|
|
export const RELIABILITY_BOUNDS = {
|
|
continuationStartMs: 3 * 60_000,
|
|
reassignRequestMs: 10_000,
|
|
restartDispatchMs: 6 * 60_000,
|
|
silentCompletionAfterCommandMs: 8 * 60_000,
|
|
steeringRetryWindowMs: 2 * 60_000,
|
|
holdObservationMs: 75_000,
|
|
} as const;
|
|
|
|
/** A resumable slow loop: an interrupted or reassigned run must continue from saved files. */
|
|
export function slowStepsPrompt(file: string, steps: number, pauseSeconds: number): string {
|
|
return `Create ${file} in the project workspace and record progress one line at a time: for each step from 1 to ${steps}, append the line "step-N" (for example "step-1"), then wait ${pauseSeconds} seconds before the next step. Do the waiting in ordinary foreground shell commands. If ${file} already has some steps, continue from the next missing step; never write a step twice. When all ${steps} steps are recorded, reply with a one-line summary.`;
|
|
}
|
|
|
|
export const interruptRedirectMessage = (nonce: string) =>
|
|
`Change of plan: stop the step loop where it is. Create redirect.txt containing exactly REDIRECT_${nonce} and reply with "Redirected ${nonce}".`;
|
|
export const stopSettleMessage = (nonce: string) =>
|
|
`After the stop: leave the step file as it is. Create settled.txt containing exactly SETTLED_${nonce} and reply with "Settled ${nonce}".`;
|
|
export const threeTurnRevisions = (nonce: string) => [
|
|
`Add two more items to items.md: "delta ${nonce}" and "epsilon ${nonce}". Keep the existing items.`,
|
|
`Convert items.md into a Markdown table with the columns Number and Item, one row per existing item in the same order. Keep no bullet list.`,
|
|
] as const;
|
|
export const longSilentCommand = (iterations = LONG_SILENT_ITERATIONS, seconds = LONG_SILENT_ITERATION_SECONDS) =>
|
|
`for i in $(seq 1 ${iterations}); do sleep ${seconds}; echo "tick-$i" >> silent.txt; done`;
|
|
export const queueSteerMessages = (nonce: string) => ({
|
|
original: `Also create edited.txt containing exactly ORIGINAL_${nonce}.`,
|
|
edited: `Also create edited.txt containing exactly EDITED_${nonce}.`,
|
|
discarded: `Also create discarded.txt containing exactly DISCARDED_${nonce}.`,
|
|
steered: `While you work, also create steered.txt containing exactly STEERED_${nonce}.`,
|
|
});
|
|
export const queuedRestartMessage = (nonce: string) =>
|
|
`Next, create after-restart.txt containing exactly RESTARTED_${nonce} and reply with "Restarted ${nonce}".`;
|
|
|
|
const definitions: ReadonlyArray<{
|
|
id: EverydayReliabilityCaseId;
|
|
label: string;
|
|
prompt: (nonce: string) => string;
|
|
expectedRunCount: number;
|
|
daytonaTimeoutMs: number;
|
|
slow?: boolean;
|
|
}> = [
|
|
{ id: "interrupt-redirect", label: "Interrupt a run with a follow-up, then send during Stop settlement",
|
|
prompt: () => slowStepsPrompt("steps.txt", 8, 30), expectedRunCount: 6, daytonaTimeoutMs: 35 * 60_000 },
|
|
{ id: "reassign-midrun", label: "Reassign an active task to another agent",
|
|
prompt: () => slowStepsPrompt("steps.txt", 8, 30), expectedRunCount: 3, daytonaTimeoutMs: 30 * 60_000 },
|
|
{ id: "three-turn-revise", label: "Create and revise one workspace file across three turns",
|
|
prompt: nonce => `Create items.md in the project workspace with a bullet list of exactly three items: "alpha ${nonce}", "beta ${nonce}" and "gamma ${nonce}". Reply when it is saved.`,
|
|
expectedRunCount: 3, daytonaTimeoutMs: 35 * 60_000 },
|
|
{ id: "long-silent-command", label: "Finish after a long silent foreground command",
|
|
prompt: nonce => `Run exactly this shell command in the project workspace as one foreground command and wait for it to finish. It takes about ${LONG_SILENT_ITERATIONS} minutes and prints nothing; that is expected. Do not background it, shorten it, split it, or interrupt it:\n\n${longSilentCommand()}\n\nAfter it finishes, count the lines in silent.txt and reply with "SILENT_DONE_${nonce} <line count>".`,
|
|
expectedRunCount: 1, daytonaTimeoutMs: 50 * 60_000, slow: true },
|
|
{ id: "tree-pause-release", label: "Pause and release a delegated task tree",
|
|
prompt: () => `Delegate two child tasks to Riley Builder, one per file. Child A: ${slowStepsPrompt("a.txt", 5, 20)} Child B: ${slowStepsPrompt("b.txt", 5, 20)} Keep the implementation with Riley. When both children are done, confirm both files are complete and finish.`,
|
|
expectedRunCount: 6, daytonaTimeoutMs: 40 * 60_000 },
|
|
{ id: "queue-steer", label: "Edit, discard and steer queued follow-ups",
|
|
prompt: () => slowStepsPrompt("steps.txt", 8, 30), expectedRunCount: 2, daytonaTimeoutMs: 30 * 60_000 },
|
|
{ id: "queued-across-restart", label: "Dispatch a follow-up queued before a controller restart",
|
|
prompt: () => slowStepsPrompt("steps.txt", 6, 20), expectedRunCount: 3, daytonaTimeoutMs: 30 * 60_000 },
|
|
];
|
|
|
|
export const everydayReliabilityTasks: readonly RunnerTaskFixture[] = definitions.map(definition => ({
|
|
id: definition.id,
|
|
label: definition.label,
|
|
groups: [EVERYDAY_RELIABILITY_GROUP, ...(definition.slow ? [EVERYDAY_RELIABILITY_SLOW_GROUP] : [])],
|
|
explicitGroup: EVERYDAY_RELIABILITY_GROUP,
|
|
workMode: "standard",
|
|
flow: "everyday_workflow",
|
|
// An upper bound for cost estimates; an offered Retry or workspace deferral may add a run.
|
|
expectedRunCount: definition.expectedRunCount,
|
|
minimumExpectedRunCount: 1,
|
|
automaticRetryPolicy: "single_attempt",
|
|
attemptTimeoutMs: { local: 20 * 60_000, daytona: definition.daytonaTimeoutMs },
|
|
expectedTerminalState: { issue: "done", run: "succeeded" },
|
|
buildTitle: nonce => `${definition.label} ${nonce}`,
|
|
buildPrompt: definition.prompt,
|
|
buildVisibleMarker: nonce => `STUDIO_${nonce}`,
|
|
buildMatchers: () => [], // The workflow records independent reliability checks.
|
|
}));
|
|
|
|
/** The explicit-only native Codex extra for every reliability case, plus ACPX facades for the silent command. */
|
|
export const RELIABILITY_NATIVE_PROFILE_ID = "runner-codex";
|
|
export const RELIABILITY_FACADE_PROFILE_IDS = ["runner-acpx-grok", "runner-acpx-kimi", "runner-acpx-pi"] as const;
|
|
|
|
/**
|
|
* The TestOps Reliability plan profile: native OpenCode with DeepSeek 1017
|
|
* through OpenRouter, the cheapest native profile with a Daytona-capable driver.
|
|
*/
|
|
export const RELIABILITY_PLAN_PROFILE_ID = "runner-opencode-deepseek-1017";
|
|
|
|
/**
|
|
* One plan cell per case. Daytona only where the soak failure was a sandbox
|
|
* lease failure: S49 (per-turn lease on turn 3), S52/S52b (a long silent command
|
|
* on a remote runner) and S55 (a restart with a remote lease). The other cases
|
|
* exercise control-plane queue, interrupt, reassignment and tree-hold paths that
|
|
* behave the same with a local runner.
|
|
*
|
|
* `queue-steer` is the exception to DeepSeek: it must steer an active turn, and
|
|
* the OpenCode server driver declares `steering: false`, so the queued-comment
|
|
* route answers `steering_unsupported`. The plan uses the existing native Codex
|
|
* Daytona cell for it, which supports steering.
|
|
*/
|
|
export const reliabilityPlanCells: Readonly<Record<EverydayReliabilityCaseId,
|
|
{ profile: typeof RELIABILITY_PLAN_PROFILE_ID | typeof RELIABILITY_NATIVE_PROFILE_ID; environment: "local" | "daytona" }>> = {
|
|
"interrupt-redirect": { profile: RELIABILITY_PLAN_PROFILE_ID, environment: "local" },
|
|
"reassign-midrun": { profile: RELIABILITY_PLAN_PROFILE_ID, environment: "local" },
|
|
"three-turn-revise": { profile: RELIABILITY_PLAN_PROFILE_ID, environment: "daytona" },
|
|
"long-silent-command": { profile: RELIABILITY_PLAN_PROFILE_ID, environment: "daytona" },
|
|
"tree-pause-release": { profile: RELIABILITY_PLAN_PROFILE_ID, environment: "local" },
|
|
"queue-steer": { profile: RELIABILITY_NATIVE_PROFILE_ID, environment: "daytona" },
|
|
"queued-across-restart": { profile: RELIABILITY_PLAN_PROFILE_ID, environment: "daytona" },
|
|
};
|
|
|
|
/** The seven TestOps Reliability plan cells, in case order. */
|
|
export function reliabilityPlanExecutionIds(suiteId: string): string[] {
|
|
return everydayReliabilityCaseIds.map(caseId =>
|
|
`${suiteId}.${reliabilityPlanCells[caseId].profile}.${reliabilityPlanCells[caseId].environment}.${caseId}`);
|
|
}
|
|
|
|
/**
|
|
* The deliberately small paid matrix: the seven plan cells, plus the
|
|
* explicit-only extras (the native Codex Daytona cell for every other case and
|
|
* the ACPX facades for long-silent-command). Only the plan cells belong to TestOps.
|
|
*/
|
|
export function everydayReliabilityExecutionIds(suiteId: string,
|
|
facadeProfileIds: readonly string[] = RELIABILITY_FACADE_PROFILE_IDS): string[] {
|
|
return [...new Set([
|
|
...reliabilityPlanExecutionIds(suiteId),
|
|
...everydayReliabilityCaseIds.map(caseId => `${suiteId}.${RELIABILITY_NATIVE_PROFILE_ID}.daytona.${caseId}`),
|
|
...facadeProfileIds.map(profileId => `${suiteId}.${profileId}.daytona.long-silent-command`),
|
|
])];
|
|
}
|