mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +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>
258 lines
8.5 KiB
TypeScript
258 lines
8.5 KiB
TypeScript
import { runnerMatrix } from "./catalog.js";
|
|
import type { MatrixExecution, MatrixJob } from "./types.js";
|
|
|
|
export interface RunnerSelectorOptions {
|
|
all: boolean;
|
|
list: boolean;
|
|
matrixJson: boolean;
|
|
installedStartupOnly?: boolean;
|
|
ids: string[];
|
|
suites: string[];
|
|
groups: string[];
|
|
profiles: string[];
|
|
environments: string[];
|
|
cases: string[];
|
|
headed: boolean;
|
|
ui: boolean;
|
|
debug: boolean;
|
|
maxParallel: number;
|
|
maxAutomaticRetries: number;
|
|
connectionConfig?: string;
|
|
githubReviewConfig?: string;
|
|
provisionGitHubRepository?: boolean;
|
|
antigravityJourneyConfig?: string;
|
|
antigravityJourneyPreflight?: boolean;
|
|
antigravityJourneyRecover?: string;
|
|
}
|
|
|
|
export class RunnerSelectorError extends Error {}
|
|
|
|
function valueFor(args: string[], index: number, flag: string): string {
|
|
const value = args[index + 1];
|
|
if (!value || value.startsWith("--"))
|
|
throw new RunnerSelectorError(`${flag} requires a value`);
|
|
return value;
|
|
}
|
|
|
|
export function parseRunnerSelectors(
|
|
rawArgs: readonly string[],
|
|
): RunnerSelectorOptions {
|
|
const args = rawArgs.filter((value) => value !== "--");
|
|
const options: RunnerSelectorOptions = {
|
|
all: false,
|
|
list: false,
|
|
matrixJson: false,
|
|
ids: [],
|
|
suites: [],
|
|
groups: [],
|
|
profiles: [],
|
|
environments: [],
|
|
cases: [],
|
|
headed: false,
|
|
ui: false,
|
|
debug: false,
|
|
maxParallel: Number(process.env.PAPERCLIP_E2E_MAX_PARALLEL ?? "1"),
|
|
maxAutomaticRetries: 1,
|
|
};
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const flag = args[index];
|
|
if (flag === "--all") options.all = true;
|
|
else if (flag === "--list") options.list = true;
|
|
else if (flag === "--matrix-json") options.matrixJson = true;
|
|
else if (flag === "--installed-startup-only") options.installedStartupOnly = true;
|
|
else if (flag === "--headed") options.headed = true;
|
|
else if (flag === "--ui") options.ui = true;
|
|
else if (flag === "--debug") options.debug = true;
|
|
else if (flag === "--connection-config") {
|
|
options.connectionConfig = valueFor(args, index, flag);
|
|
index += 1;
|
|
}
|
|
else if (flag === "--github-review-config") {
|
|
options.githubReviewConfig = valueFor(args, index, flag);
|
|
index += 1;
|
|
}
|
|
else if (flag === "--provision-github-repository") options.provisionGitHubRepository = true;
|
|
else if (flag === "--antigravity-journey-config") {
|
|
options.antigravityJourneyConfig = valueFor(args, index, flag);
|
|
index += 1;
|
|
}
|
|
else if (flag === "--antigravity-journey-preflight") options.antigravityJourneyPreflight = true;
|
|
else if (flag === "--antigravity-journey-recover") {
|
|
options.antigravityJourneyRecover = valueFor(args, index, flag);
|
|
index += 1;
|
|
}
|
|
else if (flag === "--max-parallel") {
|
|
const value = valueFor(args, index, flag);
|
|
index += 1;
|
|
options.maxParallel = Number(value);
|
|
} else if (flag === "--max-automatic-retries") {
|
|
const value = valueFor(args, index, flag);
|
|
index += 1;
|
|
options.maxAutomaticRetries = Number(value);
|
|
} else if (
|
|
[
|
|
"--id",
|
|
"--suite",
|
|
"--group",
|
|
"--profile",
|
|
"--environment",
|
|
"--case",
|
|
].includes(flag)
|
|
) {
|
|
const value = valueFor(args, index, flag);
|
|
index += 1;
|
|
if (flag === "--id") options.ids.push(value);
|
|
else if (flag === "--suite") options.suites.push(value);
|
|
else if (flag === "--group") options.groups.push(value);
|
|
else if (flag === "--profile") options.profiles.push(value);
|
|
else if (flag === "--environment") options.environments.push(value);
|
|
else options.cases.push(value);
|
|
} else {
|
|
throw new RunnerSelectorError(`Unknown runner E2E argument: ${flag}`);
|
|
}
|
|
}
|
|
|
|
if (!Number.isInteger(options.maxParallel) || options.maxParallel < 1) {
|
|
throw new RunnerSelectorError("--max-parallel must be a positive integer");
|
|
}
|
|
if (![0, 1].includes(options.maxAutomaticRetries)) {
|
|
throw new RunnerSelectorError(
|
|
"--max-automatic-retries must be 0 or 1",
|
|
);
|
|
}
|
|
|
|
const hasDimensions =
|
|
options.suites.length +
|
|
options.groups.length +
|
|
options.profiles.length +
|
|
options.environments.length +
|
|
options.cases.length >
|
|
0;
|
|
if (options.ids.length > 0 && (hasDimensions || options.all)) {
|
|
throw new RunnerSelectorError(
|
|
"--id is exclusive with --all and dimension filters",
|
|
);
|
|
}
|
|
if (options.all && hasDimensions) {
|
|
throw new RunnerSelectorError("--all is exclusive with dimension filters");
|
|
}
|
|
if (
|
|
!options.list &&
|
|
!options.matrixJson &&
|
|
!options.all &&
|
|
options.ids.length === 0 &&
|
|
!hasDimensions
|
|
) {
|
|
throw new RunnerSelectorError(
|
|
"Billable runner E2E runs require --all or an explicit selector",
|
|
);
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function assertKnown(
|
|
label: string,
|
|
selected: readonly string[],
|
|
known: Set<string>,
|
|
) {
|
|
const unknown = selected.filter((value) => !known.has(value));
|
|
if (unknown.length > 0)
|
|
throw new RunnerSelectorError(`Unknown ${label}: ${unknown.join(", ")}`);
|
|
}
|
|
|
|
export function selectRunnerExecutions(
|
|
options: RunnerSelectorOptions,
|
|
matrix: readonly MatrixExecution[] = runnerMatrix,
|
|
): MatrixExecution[] {
|
|
const knownGroups = new Set(matrix.flatMap((execution) => execution.groups));
|
|
assertKnown(
|
|
"suite",
|
|
options.suites,
|
|
new Set(matrix.map((execution) => execution.suite.id)),
|
|
);
|
|
assertKnown("group", options.groups, knownGroups);
|
|
assertKnown(
|
|
"profile",
|
|
options.profiles,
|
|
new Set(matrix.map((execution) => execution.profile.id)),
|
|
);
|
|
assertKnown(
|
|
"environment",
|
|
options.environments,
|
|
new Set(matrix.map((execution) => execution.environment.id)),
|
|
);
|
|
assertKnown(
|
|
"case",
|
|
options.cases,
|
|
new Set(matrix.map((execution) => execution.task.id)),
|
|
);
|
|
assertKnown(
|
|
"execution id",
|
|
options.ids,
|
|
new Set(matrix.map((execution) => execution.id)),
|
|
);
|
|
|
|
const selected = matrix.filter((execution) => {
|
|
if (options.ids.length > 0) return options.ids.includes(execution.id);
|
|
if (execution.suite.manualOnly && !options.suites.includes(execution.suite.id) && !options.list) return false;
|
|
const unfilteredList = options.list && options.groups.length + options.suites.length + options.profiles.length
|
|
+ options.environments.length + options.cases.length === 0;
|
|
const explicitGroup = execution.task.explicitGroup;
|
|
// Naming any of the task's own groups (for example `slow`) also opts in.
|
|
if (explicitGroup && !unfilteredList && !options.groups.some(group => group === explicitGroup || execution.task.groups.includes(group))
|
|
&& !options.cases.includes(execution.task.id)) return false;
|
|
if (
|
|
options.all ||
|
|
(options.list &&
|
|
options.groups.length === 0 &&
|
|
options.suites.length === 0 &&
|
|
options.profiles.length === 0 &&
|
|
options.environments.length === 0 &&
|
|
options.cases.length === 0)
|
|
)
|
|
return true;
|
|
return (
|
|
(options.suites.length === 0 ||
|
|
options.suites.includes(execution.suite.id)) &&
|
|
options.groups.every((group) => execution.groups.includes(group)) &&
|
|
(options.profiles.length === 0 ||
|
|
options.profiles.includes(execution.profile.id)) &&
|
|
(options.environments.length === 0 ||
|
|
options.environments.includes(execution.environment.id)) &&
|
|
(options.cases.length === 0 || options.cases.includes(execution.task.id))
|
|
);
|
|
});
|
|
if (selected.length === 0)
|
|
throw new RunnerSelectorError(
|
|
"Runner E2E selectors matched zero executions",
|
|
);
|
|
return selected;
|
|
}
|
|
|
|
export function buildMatrixJobs(
|
|
executions: readonly MatrixExecution[],
|
|
): MatrixJob[] {
|
|
if (executions.some(e => ["provider_connection", "github_review_journey", "antigravity_agent_journey"].includes(e.task.flow))) throw new RunnerSelectorError("Attended connection journeys require explicit private setup profiles; they are not admitted to paid CI matrix jobs yet.");
|
|
return executions
|
|
.map((execution) => ({
|
|
executionId: execution.id,
|
|
suiteId: execution.suite.id,
|
|
profileId: execution.profile.id,
|
|
credentialName: execution.profile.credential,
|
|
environmentId: execution.environment.id,
|
|
caseId: execution.task.id,
|
|
timeoutMinutes: Math.max(
|
|
execution.environment.id === "daytona" ? 40 : 25,
|
|
Math.ceil(
|
|
(2 *
|
|
(execution.task.attemptTimeoutMs[execution.environment.id] +
|
|
90_000) +
|
|
5 * 60_000) /
|
|
60_000,
|
|
),
|
|
),
|
|
needsDaytona: execution.environment.id === "daytona",
|
|
}))
|
|
.sort((left, right) => left.executionId.localeCompare(right.executionId));
|
|
}
|