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 paid runner E2E suite (`tests/runner-e2e`) drives the real browser, server, native runner, provider and Daytona > - A manual staging soak found about nine reliability bugs (S47–S55, H2, U9). Each fix got unit or integration tests > - The end-to-end journeys that exposed the bugs existed only as ad-hoc browser scripts, so a regression could return without a signal > - These journeys are expensive, so they must not run per PR or in `--all`. They must run on demand before a release > - This pull request adds the journeys to the existing `everyday-workflows` suite as an explicit `reliability` group > - The benefit is one command that runs the whole reliability set before a release or after a risky runner change ## Linked Issues or Issue Description **What is the problem?** The staging-soak scenarios that found S47–S55, H2 and U9 are not repeatable. They were ad-hoc browser scripts on staging. **Why does it matter?** Without a repeatable end-to-end case, a reliability regression (stalled follow-ups, duplicate execution, dead Retry, hangs after silent commands) can return without a signal. **What is the proposed solution?** Add the scenarios to the paid `everyday-workflows` suite as explicit-only cases with independent graders and a selectable `reliability` group. Refs #19370, #19355, #19354, #19407, #19419, #19394, #19421, #19431 (the related fixes). ## What Changed - `everyday-reliability-cases.ts`: seven new cases (`interrupt-redirect`, `reassign-midrun`, `three-turn-revise`, `long-silent-command`, `tree-pause-release`, `queue-steer`, `queued-across-restart`), their soak signatures, prompts, bounds and the explicit matrix. - `everyday-reliability-flow.ts`: browser and public-API drivers for each journey. A shared watcher records `executionBlocker`, `interruption` and execution projections while it waits. It uses an offered Retry at most once (never while an automatic retry is scheduled). - `everyday-reliability.ts`: pure graders. They encode the product rule: interrupted work resumes automatically or offers a Retry that works on first use. A send-a-message blocker, a dead or repeated Retry, a silent stall, or duplicate execution fails. - `types.ts` / `selectors.ts`: new optional `explicitGroup` on a task. Such a task is selected only by its group, `--case`, or an exact `--id`. `--all`, the plain suite and generic selectors skip it. `--list` still shows it. - `catalog.ts`: the suite gets the reliability tasks, the released Grok/Kimi/Pi facades (only for `long-silent-command`), a per-turn Daytona override for `three-turn-revise`, the `reliability` and `slow` groups, definition version 11, and a computed `expectedMatrixSize` (52 → 62). - `everyday-flow.ts`: a narrow hook calls the scenario driver after task creation. Riley is created for the reassign and tree cases. The reliability checks are added after settlement. - `everyday-observations.ts`: an allowed interrupted run may end `operator_interrupted` or `issue_reassigned`. Lifecycle checks accept a declared reassigned parent owner. - `live-fixtures.ts`: reliability cells get the existing 1,000-cent company and lead-agent hard stop. - Tests: new `everyday-reliability.test.ts` (selection exclusion, matrix, credentials, and calibrated pass/fail cases for every grader). Updated counts in `catalog.test.ts`, `everyday.test.ts` and `installed-cli.test.ts`. - Docs: new "Staging-soak reliability group" section in `EVERYDAY-WORKFLOWS.md` (cases, signatures, run commands, cadence, coverage boundary) and a link from `doc/evals.md`. `everyday-flow.ts` was already over 1,000 lines. This PR adds only about 20 lines of wiring there. The scenario logic is in the new modules. Matrix (10 cells, all Daytona, single attempt): one `runner-codex` (gpt-5.6-sol) cell per case, plus `runner-acpx-grok`, `runner-acpx-kimi` and `runner-acpx-pi` for `long-silent-command`. Run command: ```sh pnpm test:e2e:runner -- --suite everyday-workflows --group reliability --max-parallel 2 ``` ## Verification - `pnpm test:e2e:runner:typecheck` passes. - `node node_modules/vitest/vitest.mjs run --config tests/runner-e2e/vitest.config.ts everyday catalog installed-cli selectors support.test`: 15 files, 362 tests pass. - The full runner-e2e Vitest run has the same 14 failures with and without this change (`controller-outage`, `operating-receipts`, `pi-controls-evidence`, `pi-file-evidence`, `workflow-security`). These fail on the base commit in this local environment too. - `pnpm test:e2e:runner -- --list --suite everyday-workflows --group reliability` lists exactly the ten cells. - `--all`, `--suite everyday-workflows` (still 52 cells), profile/environment selectors and the daily/weekly scheduled selections contain no reliability cell. A test asserts this. - Not run: the paid cells. They need provider and Daytona credentials. The first paid run of the group is the acceptance step. ## Risks - The drivers have not run against a live stack. A first paid run can expose harness timing or selector issues (for example, how fast the queued Interrupt control appears). The failure then names the stalled step and keeps `reliability-observations.json`. - `long-silent-command` does not inject a runner connection loss. The named-cause contract is checked only if a loss happens naturally. - The Grok/Kimi/Pi facades run as ordinary released profiles. If one returns to pending qualification, it drops out of the group and the matrix size follows. - No product code changes. Existing everyday cells keep their selection and checks. The suite definition hash changes because the suite definition changed. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, file edit, code execution). Standard context window. ## 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 - [ ] All Paperclip CI gates are green - [ ] 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>
126 lines
8.7 KiB
TypeScript
126 lines
8.7 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 native profile used 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 deliberately small paid matrix: one native Daytona cell per case, plus facades for long-silent-command. */
|
|
export function everydayReliabilityExecutionIds(suiteId: string,
|
|
facadeProfileIds: readonly string[] = RELIABILITY_FACADE_PROFILE_IDS): string[] {
|
|
return [
|
|
...everydayReliabilityCaseIds.map(caseId => `${suiteId}.${RELIABILITY_NATIVE_PROFILE_ID}.daytona.${caseId}`),
|
|
...facadeProfileIds.map(profileId => `${suiteId}.${profileId}.daytona.long-silent-command`),
|
|
];
|
|
}
|