Files
PaperClipAI/tests/runner-e2e/everyday-reliability-cases.ts
T
DottaandClaude Opus 5.5 744056ecc8 feat(evals): run the staging-soak reliability cases from a TestOps plan (#19549)
## 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>
2026-11-06 14:19:58 -06:00

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`),
])];
}